diff --git a/aegis.py b/aegis.py index 4a2d512..e8ed807 100755 --- a/aegis.py +++ b/aegis.py @@ -204,7 +204,7 @@ ALLOWLIST = os.path.join(STATE_DIR, "allowlist.json") RUN_LOG = os.path.join(STATE_DIR, "run.log") EVENT_DB = os.path.join(STATE_DIR, "aegis.db") -BASELINE_SCHEMA_VERSION = 3 +BASELINE_SCHEMA_VERSION = 4 HOSTS_FILE = (os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "drivers", "etc", "hosts") if IS_WIN else "/etc/hosts") @@ -14662,8 +14662,11 @@ def diff_agent_skills(prior, cur): # _accumulate_risk, and 'agent-skill' is in no correlate() rule — the # durable record is real, but the "auto-correlates with a later osascript # phish" chain is not yet wired. The phish itself still fires CRITICAL alone. - def _graded(key, base_sev, base_conf): - """(severity, confidence, path, markers, why) for one skill. + deadline = time.monotonic() + _SKILL_CUSTODY_BUDGET + + def _graded(key, base_sev, base_conf, sig, old_sig=None): + """(severity, confidence, path, markers, why, provenance) for one + skill. Until 2026-09-03 both tiers were hardcoded MEDIUM, which is BELOW NOTIFY_MIN_SEV — so a skill the operator did not author could appear in @@ -14672,10 +14675,20 @@ def _graded(key, base_sev, base_conf): risk accumulation, which the old comment here admitted. Reading the skill's own instructions fixes both: concealment is attack-defined (nothing legitimate tells an agent to hide what it did), and - credential+egress together is the stealer shape.""" + credential+egress together is the stealer shape. + + Custody grades the CHANGE and never the content, the same split + diff_agent_surface makes: it is asked only when the instructions carry + no directive at all. A skill whose text names a secret, a channel or + concealment is judged on that text whoever committed it.""" d = _AGENT_SKILL_DIRS.get(key) marks = _skill_instruction_markers(d) - sev, conf, why = base_sev, base_conf, "" + sev, conf, why, prov = base_sev, base_conf, "", None + if not marks: + prov, note = _skill_custody(d, sig, old_sig, deadline) + sev = _demote(sev, prov) + if note: + why = " " + note if "conceal" in marks: sev, conf = "HIGH", "high" why = (" Its instructions tell the agent to CONCEAL its actions — " @@ -14697,20 +14710,24 @@ def _graded(key, base_sev, base_conf): "legitimate skills mention both.") elif marks: why = (" Its instructions mention: %s." % ", ".join(marks)) - return sev, conf, d, ["agent-skill"] + ["imperative:" + m for m in marks], why + markers = ["agent-skill"] + ["imperative:" + m for m in marks] + if prov in _SELF_CUSTODY: + markers.append("self-custody") + return sev, conf, d, markers, why, prov def new_fn(key, sig): - sev, conf, path, marks, why = _graded(key, "MEDIUM", "medium") + sev, conf, path, marks, why, prov = _graded(key, "MEDIUM", "medium", sig) return finding( sev, "agent-skill", "New AI-agent skill installed", "%s appeared — AI-agent skills run with your full privileges and are " "a live 2026 stealer channel (a malicious SKILL.md can drive a fake " "password dialog). Verify you installed it.%s" % (key, why), "agent-skill:new:%s" % key, skill=key, confidence=conf, - path=path, markers=marks) + path=path, provenance=prov, markers=marks) def changed_fn(key, sig, old): - sev, conf, path, marks, why = _graded(key, "MEDIUM", "low") + sev, conf, path, marks, why, prov = _graded(key, "MEDIUM", "low", + sig, old) return finding( sev, "agent-skill", "AI-agent skill changed", "%s was modified — its SKILL.md or a shipped script changed. Routine " @@ -14718,11 +14735,117 @@ def changed_fn(key, sig, old): "hijack.%s" % (key, why), "agent-skill:changed:%s:%s" % (key, hashlib.sha256(sig.encode()).hexdigest()[:12]), - skill=key, confidence=conf, path=path, markers=marks) + skill=key, confidence=conf, path=path, provenance=prov, + markers=marks) return _diff_map(prior, cur, new_fn, changed_fn) +# A skill whose change touches more files than this is not graded at all: a +# bulk rewrite is not the shape custody was built to explain, and asking git +# about each file every scan would cost more than the answer is worth. +_SKILL_CUSTODY_FILES = 8 + +# Custody is asked on EVERY scan for every skill that differs from the +# baseline, and a changed skill is never re-baselined until the operator +# accepts it (the anti-laundering rule). Measured on the reference machine the +# day this was wired: 113 changed and 7 new skills out of 168, 236 files, 37 s +# of git per scan -- for answers that change only when the bytes do. +# +# So the answer is kept, keyed on the bytes' real path and content hash, and +# bounded three ways. Only an ANSWER is kept: _git_provenance returns None +# both for "in no repository" and for a git that did not answer in time, and +# a timeout remembered as a verdict is the defect the 2026-09-22 batch +# removed, so a None is asked again next scan. An entry expires after +# _SKILL_CUSTODY_TTL, so an aged-out intent record or an expired reflog is +# seen within hours rather than for the life of the watch daemon. And new +# bytes are a new key. What goes stale in between is an untracked file later +# committed, which reads at its OLD, weaker rung -- toward suspicion. +_SKILL_CUSTODY_MEMO = {} +_SKILL_CUSTODY_TTL = 6 * 3600 +_SKILL_CUSTODY_MEMO_CAP = 4096 +# Seconds one diff may spend asking git. A skill it does not reach stays +# ungraded at full severity (the fail-toward-suspicion outcome), and the memo +# lets the next scan continue from there. +_SKILL_CUSTODY_BUDGET = 10.0 + + +def _skill_file_custody(path, deadline=None): + """(rung, note) for one skill file: from the memo while fresh, else asked + at its real path -- or (None, "") once `deadline` has passed.""" + sha = sha256(path) + key = (os.path.realpath(path), sha) + now = time.time() + hit = _SKILL_CUSTODY_MEMO.get(key) + if hit and now - hit[0] < _SKILL_CUSTODY_TTL: + return hit[1], hit[2] + if deadline is not None and time.monotonic() > deadline: + return None, "" + rung, note = _bytes_custody(path, sha) + if rung is not None: + if len(_SKILL_CUSTODY_MEMO) >= _SKILL_CUSTODY_MEMO_CAP: + _SKILL_CUSTODY_MEMO.clear() + _SKILL_CUSTODY_MEMO[key] = (now, rung, note) + return rung, note + + +def _skill_sig_parts(sig): + """{file name: content hash} from a _skill_signature string, or {} when it + is not one (a test placeholder, or a format this reader predates).""" + out = {} + for part in str(sig or "").split("|"): + name, sep, h = part.partition("=") + if not sep: + continue + if name == "exec": + for item in h.split(","): + n, _at, hh = item.partition("@") + if n: + out[n] = hh + else: + out[name] = h + return out + + +def _skill_custody(skill_dir, sig, old_sig=None, deadline=None): + """(rung, note) for the files that CHANGED in one agent skill, asked where + they live, and graded by the WEAKEST of them. + + Where they live: ~/.codex/skills/ are symlinks into the operator's + skills repositories, and git answers nothing about a path under a symlink, + so a committed skill change graded as authorless (#329-#332). + + The weakest, and only the files that changed: a committed SKILL.md says + nothing about a shipped script swapped underneath it, so a skill is only + as vouched-for as its least vouched-for changed file, and a file the + signature names but the disk no longer holds is no answer at all.""" + if not skill_dir: + return None, "" + new, old = _skill_sig_parts(sig), _skill_sig_parts(old_sig) + changed = sorted(n for n, h in new.items() if old.get(n) != h) + if not changed or len(changed) > _SKILL_CUSTODY_FILES: + return None, "" + worst, seen = None, set() + for name in changed: + p = os.path.join(skill_dir, name) + try: + st = os.stat(p) + except OSError: + return None, "" + # One file, asked once: on a case-insensitive volume the signature + # lists SKILL.md and skill.md for the same inode, and git, asked about + # the spelling it does not track, answers nothing. + if (st.st_dev, st.st_ino) in seen: + continue + seen.add((st.st_dev, st.st_ino)) + rung, note = _skill_file_custody(p, deadline) + if worst is None or _custody_strength(rung) < _custody_strength(worst[0]): + worst = (rung, note) + if not _custody_strength(rung): + break + return worst + + # --- Timestomp detection (T1070.006) ----------------------------------------- def timestomp_signal(path, st=None): """A reason string if a file's timestamps look tampered, else None. `touch`/ @@ -15463,6 +15586,19 @@ def _credential_surface_present(): return found +# Path components in CREDENTIAL_SURFACE that name a CONTAINER every tool uses, +# not a secret: `.kube/config` contributed "config", `.docker/config.json` +# "config.json", every XDG entry ".config", and `.mozilla/firefox` "firefox". +# As substrings of instruction text those matched "configure", +# "configuration", any ~/.config path and the browser's name, so on the +# reference machine the credential marker fired on nearly every instruction +# file, the operator's own included. Each entry stays reachable through its +# distinctive component (.kube, .docker, bitwarden, hosts.yml, .mozilla, +# cookies.sqlite), so this table loses no secret it could name. +_CREDENTIAL_CONTAINER_WORDS = frozenset(("config", "config.json", ".config", + "firefox")) + + def _credential_path_tokens(): """Distinctive path fragments used by the instruction-file imperative detector to decide whether a line NAMES a secret. Derived from the one @@ -15470,10 +15606,11 @@ def _credential_path_tokens(): toks = set() for rel, _, _, _ in CREDENTIAL_SURFACE: base = rel.split("/")[-1] - if len(base) > 3: + if len(base) > 3 and base.lower() not in _CREDENTIAL_CONTAINER_WORDS: toks.add(base.lower()) first = rel.split("/")[0] - if first.startswith(".") and len(first) > 3: + if first.startswith(".") and len(first) > 3 and \ + first.lower() not in _CREDENTIAL_CONTAINER_WORDS: toks.add(first.lower()) toks.update({"id_rsa", "id_ed25519", "credentials", "secret", "api_key", "api-key", "access_token", "private key", ".env", "keychain"}) @@ -15615,10 +15752,22 @@ def _credential_path_tokens(): # "Do not tell the user TO RUN `codex plugin marketplace add`" is guidance # about what to recommend, not an instruction to deceive. Concealment # continues with "about/that/of"; advice continues with "to ". + # + # The lookahead used to require a WORD character after "to", and was found + # wanting on a second real file (canvas-lms, 2026-09-04, still firing HIGH + # on every scan three weeks later): 'Never tell the user to "generate a new + # token"' quotes the verb, and a quote is not \w. "Tell someone to" is an + # instruction to direct them, whatever punctuation follows; nothing that + # conceals is phrased that way. r"\b(?:do\s*not|don'?t|never)\s+(?:tell|inform|notify|alert|warn|mention\s+" r"(?:this|it)\s+to)\s+(?:the\s+)?(?:user|operator|owner|human|dev(?:eloper)?)" - r"\b(?!\s+to\s+\w)", - r"\bwithout\s+(?:telling|informing|notifying|alerting|asking)\s+" + r"\b(?!\s+to\b)", + # Not "asking": acting without ASKING is autonomy, not concealment -- the + # person is not kept in the dark, only not consulted. It fired on the + # operator's own gsd-discuss-phase skill ("downstream agents can act + # without asking the user again"), and the pattern this table exists for, + # doing something the operator never learns of, is "without telling". + r"\bwithout\s+(?:telling|informing|notifying|alerting)\s+" r"(?:the\s+)?(?:user|operator|owner|human|dev(?:eloper)?)\b", r"\b(?:hide|conceal|suppress)\s+(?:\w+\s+){0,3}from\s+(?:the\s+)?" r"(?:user|operator|owner|human|dev(?:eloper)?)\b", @@ -15630,32 +15779,88 @@ def _credential_path_tokens(): )) _IMPERATIVE_EGRESS = tuple(re.compile(p, re.I) for p in ( + # An egress DIRECTIVE names what leaves or where it goes. The object used + # to be optional, so "send to" matched as a NOUN -- "drive the gated real + # send to the original sender", a row of the operator's own mail tool's + # AGENTS.md (#335). Without an object, only an address (a URL or a mail + # address) makes it a directive. r"\b(?:send|post|upload|transmit|exfiltrate|forward|email)\s+" - r"(?:it|them|this|the\s+\w+|contents?)?\s*to\b", + r"(?:(?:it|them|this|the\s+\w+|contents?)\s+to\b|" + r"to\s+(?:https?://|[\w.+-]+@[\w-]+\.\w))", r"\binclude\s+(?:it|them|the\s+\w+|the\s+contents?)?\s*in\s+" r"(?:your|the)\s+(?:next\s+)?(?:commit|message|response|reply|answer|PR|" r"pull\s*request)\b", r"\bcommit\s+(?:it|them|the\s+\w+)\s+to\b", r"\bcurl\s+-[A-Za-z]*[dF]\b", - r"https?://(?!(?:localhost|127\.0\.0\.1|github\.com|gitlab\.com|" - r"docs\.\w+|developer\.\w+))[\w.-]+\.[a-z]{2,}/\S*", + # A URL is a DESTINATION only when something is sent to it. Bare, it is a + # citation: the rule matched every documentation link in every instruction + # file (#336 was a markdown link to a vendor's structured-outputs docs), + # and a config line's API base. A transmit verb earlier on the same line + # is what turns a reference into a channel. + r"\b(?:send|post|upload|transmit|exfiltrate|forward|email|submit|beacon)" + r"\b[^\n]{0,120}?https?://(?!(?:localhost|127\.0\.0\.1|github\.com|" + r"gitlab\.com|docs\.\w+|developer\.\w+))[\w.-]+\.[a-z]{2,}/\S*", )) +# One unit of instruction text: a paragraph, a list item, a table row or a +# heading. Wrapped prose lines join the unit they continue. +_DIRECTIVE_BREAK_RE = re.compile( + r"\n[ \t]*\n|\n(?=[ \t]*(?:[-*+][ \t]|\d+[.)][ \t]|[|#>]))") + +# The verbs that ACCESS a file. A secret after one of these, in the same unit, +# is the verb's object: something the agent is told to read, load or copy. +# Base forms only, so the third person of documentation ("the tool reads its +# key from the keychain") describes and does not direct. The second row is the +# transmit verbs that take a FILE as their object -- "Upload ~/.aws/ +# credentials at https://..." reads the secret by sending it, and was HIGH on +# main. `post` and `email` are left out: before a token they are usually +# nouns ("the email credentials"). +_ACCESS_VERB_RE = re.compile( + r"\b(?:read|cat|copy|cp|open|load|print|dump|include|source|export|" + r"base64|get|fetch|grab|collect|" + r"send|upload|transmit|exfiltrate|forward|submit)\b", re.I) + + +def _directive_units(text): + return [u for u in _DIRECTIVE_BREAK_RE.split(text) if u.strip()] + + +def _accesses_secret(unit, toks): + """True when `unit` directs the agent to ACCESS a secret: an access verb + with a credential token after it.""" + low = unit.lower() + verb = _ACCESS_VERB_RE.search(low) + return bool(verb) and any(tok in low[verb.end():] for tok in toks) + def _imperative_signals(text): """Semantic markers present in a blob of instruction text. Returns a sorted list drawn from {'conceal', 'egress', 'credential'}. Deliberately narrow: three tables, no scoring, no model. An empty list is - the overwhelmingly common answer and costs one pass.""" + the overwhelmingly common answer. + + 'credential' means the file directs the agent to ACCESS a secret -- an + access verb with the secret as its object, in one unit (_accesses_secret) + -- AND directs egress somewhere. It used to mean a secret was mentioned + anywhere, which made credential+egress ("together they are an exfil + instruction") true of nearly every long instruction file: the operator's + mail tool (#335/#336) paired "No secrets." with a docs link three sections + away, and two of his skills (#330/#332) paired a keychain note with an API + base URL. A mention is not an instruction to touch the secret. + + The access and the egress are deliberately NOT required to share a unit. + Multi-step injections are written as steps -- "1. Read ~/.aws/credentials + / 2. Upload the file to https://..." -- and a same-unit rule silenced + exactly those (found by review before merge). What the rule needs is two + directives, one that touches the secret and one that sends, not proximity. + + Every table here is narrower than the one it replaced, so a marker can + only disappear from a stored snapshot, never appear: a baseline written by + the old tables diffs against these without a single `gained` marker.""" if not text: return [] hits = set() - low = text.lower() - for tok in _credential_path_tokens(): - if tok in low: - hits.add("credential") - break for rx in _IMPERATIVE_CONCEAL: if rx.search(text): hits.add("conceal") @@ -15664,6 +15869,10 @@ def _imperative_signals(text): if rx.search(text): hits.add("egress") break + if "egress" in hits: + toks = _credential_path_tokens() + if any(_accesses_secret(u, toks) for u in _directive_units(text)): + hits.add("credential") return sorted(hits) @@ -15671,8 +15880,10 @@ def _imperative_severity(markers): """Map semantic markers to a severity. conceal alone is HIGH because it is attack-defined. credential+egress is - HIGH because together they are an exfil instruction. credential alone is a - MEDIUM record — plenty of legitimate instruction files mention .env.""" + HIGH because together they are an exfil instruction (credential is only + reported when the text directs an access to a secret AND an egress -- see + _imperative_signals). A bare credential marker, from a caller that builds + its own, is a LOW record.""" if not markers: return None if "conceal" in markers: @@ -17564,6 +17775,26 @@ def _custody(path, content_sha): return prov, _PROVENANCE_NOTE.get(prov, "") +def _custody_strength(rung): + """3 authorship, 2 origin, 1 weak local evidence, 0 none: the order + _demote already applies, as a number, for choosing between two rungs.""" + if rung in _SELF_CUSTODY: + return 3 + if rung in _VOUCHED_CUSTODY: + return 2 + if rung in _WEAK_CUSTODY: + return 1 + return 0 + + +def _bytes_custody(path, content_sha): + """_custody asked where the bytes LIVE. A symlink is answered for by + nobody: git refuses a pathspec under one ("outside repository"), so + ~/.codex/skills/ -- a link into the operator's skills repo -- graded + None for content that repo had committed (#329-#332).""" + return _custody(os.path.realpath(path), content_sha) + + # --- the intent ledger: supervised writes attest themselves ------------------- # # Git answers "how did this arrive" only for tracked files. The intent ledger @@ -17785,6 +18016,44 @@ def cmd_intent(argv): # --- discovery + snapshot ---------------------------------------------------- +# Fields only a RUNNING process has. A registration names what WILL run when +# the agent starts; it cannot carry the pid and start time of a process that +# already ran. +_PROCESS_PID_KEYS = ("osPid", "pid") +_PROCESS_START_KEYS = ("startedAtMs", "startedAt", "started_at", "startTime") + + +def _is_process_table(obj): + """True when a parsed agent file is a host's RUNTIME PROCESS TABLE: a + top-level list in which EVERY record carries a command, an integer OS pid + and a start time. + + Codex keeps one at ~/.codex/process_manager/chat_processes.json -- one + record per command a chat turn started (osPid, startedAtMs, turnId, cwd), + last rewritten by Codex itself. It is a log of what already ran, not + configuration: nothing reads it back to start anything. Walked by shape it + has a `command` in every record, so it was diffed as a delegate config, + and #521 was `npm run build` "changing underneath a static config" because + a node upgrade moved the npm that `which` found. + + The test is on the WHOLE document, never one entry. An attacker who adds + `osPid` to a server block in a real config still registers an exec, + because a real config is a mapping of servers or hooks, not a list of + process records -- and a host that parsed one would be broken by the + rewrite, not armed by it.""" + if not isinstance(obj, list) or not obj: + return False + for rec in obj: + if not isinstance(rec, dict) or not isinstance(rec.get("command"), str): + return False + pid = next((rec[k] for k in _PROCESS_PID_KEYS if k in rec), None) + if isinstance(pid, bool) or not isinstance(pid, int): + return False + if not any(k in rec for k in _PROCESS_START_KEYS): + return False + return True + + def _agent_exec_entries(obj, where=""): """Every exec-capable entry in a parsed agent config, found by SHAPE. @@ -17792,7 +18061,10 @@ def _agent_exec_entries(obj, where=""): holding a string `command`) and the hook shape (a dict holding a string `command` under a hooks/tools key) wherever they appear in the tree, at any nesting depth, under any key name — because the key names change per host - and per release and the shape does not.""" + and per release and the shape does not. A runtime process table has the + same shape and registers nothing; see _is_process_table.""" + if _is_process_table(obj): + return [] out = [] def walk(node, path, depth): @@ -17830,6 +18102,28 @@ def _toml_exec_entries(text): return out +_EXEC_SCRIPT_RE = re.compile(r"\.(?:js|mjs|cjs|py|sh|ts|rb|php|jar|ps1)$") + + +def _expand_exec_word(word): + """One word of a hook's command line, as the shell that runs it names the + path. + + Separator punctuation glued to a word (`"...hook.py";`) is shell syntax, + not part of the path. `$HOME` and `${HOME}` are expanded because the hook + runs under a shell that expands them and shlex does not: the operator's + Codex hooks quote `"$HOME/Ai/Universe/smash/smash.py"`, which came back + as that literal string and resolved to nothing, so #298/#299 were recorded + with no target -- no hash to watch and no file to ask custody of. HOME + only: every other variable is the host's environment, which this process + cannot see.""" + word = word.rstrip(";&|").strip('"').strip("'") + for var in ("${HOME}", "$HOME"): + if word == var or word.startswith((var + "/", var + "\\")): + return os.path.normpath(HOME + word[len(var):]) + return os.path.expanduser(word) + + def _resolve_exec_target(command, args): """(resolved_abs_path_or_None, sha256_or_None). @@ -17865,15 +18159,30 @@ def _looks_like_path(a): if a.startswith(("/", "./", "../", "~")) or (os.sep != "/" and re.match(r"^[A-Za-z]:[\\/]", a)): return True - return bool(re.search(r"\.(?:js|mjs|cjs|py|sh|ts|rb|php|jar|ps1)$", a)) + return bool(_EXEC_SCRIPT_RE.search(a)) - cand = None + scripts, paths = [], [] for a in candidates: if a.startswith("-"): continue + word = _expand_exec_word(a) + if os.path.isabs(word) and _EXEC_SCRIPT_RE.search(word): + scripts.append(word) if _looks_like_path(a): - cand = os.path.expanduser(a) - break + paths.append(os.path.expanduser(a)) + # An absolute script beats the first path-shaped word. In a guarded hook + # line -- `mkdir -p "$HOME/.ai"; if command -v uv ...; then uv run + # "$HOME/.../hook_postwrite.py"; ...` -- no word was path-shaped as + # written, so the resolver fell back to the PROGRAM and hashed /bin/mkdir: + # a macOS update read as the hook's payload being swapped (#453), while + # the script that carries the behaviour was never watched at all. And + # `node ~/.claude/hooks/x.js; fi` resolved to 'x.js;', a file that never + # exists, so every guarded node hook went unhashed. The fallback is the + # old rule on the word AS WRITTEN, so a line with no absolute script + # (`cd && npx tool`, `npx --dir "$HOME/p"`) resolves exactly as it + # did before -- the baseline migration (v4) re-resolves only entries this + # function now answers differently. + cand = (scripts[0] if scripts else None) or (paths[0] if paths else None) target = cand or prog if not os.path.isabs(target): try: @@ -17891,6 +18200,175 @@ def _looks_like_path(a): return target, sha256(target) +# What a hook line may wrap around its target without running anything of its +# own: shell control keywords, no-op and test builtins, and `command -v ` +# (a PATH lookup that executes nothing). +_EXEC_CONTROL_WORDS = frozenset(("if", "then", "else", "elif", "fi", "!")) +_EXEC_NOOP_PROGRAMS = frozenset(("true", "false", ":", "[", "test")) +# Launchers that run the script named after them, and the only words allowed +# between a launcher and that script. Anything else in that slot can load code +# of its own -- `python3 -c`, `node --require x.js`, `uv run --with ` -- +# and is refused rather than enumerated. +_EXEC_LAUNCHERS = frozenset(("bash", "sh", "zsh", "python", "python3", "node", + "uv", "ruby", "perl")) +_EXEC_LAUNCHER_WORDS = frozenset(("run", "--quiet", "-q")) +_EXEC_SEPARATORS = frozenset((";", "&&", "||")) +_EXEC_REDIRECTS = frozenset((">", ">>", "<", ">&", "&>", ">|", "<&")) + + +def _exec_simple_commands(line): + """The simple commands of a hook's shell line, with control words and + /dev/null redirections removed -- or None when the line does anything + this reader will not vouch for: a pipe, a background job, a subshell, + command or process substitution, a redirect into a file, a second line, + or text shlex cannot split.""" + if any(s in line for s in ("`", "$(", "<(", ">(", "\n", "\r")): + return None + try: + lex = shlex.shlex(line, posix=not IS_WIN, punctuation_chars=True) + lex.whitespace_split = True + toks = [t.strip('"').strip("'") if IS_WIN else t for t in lex] + except ValueError: + return None + cmds, cur, i = [], [], 0 + while i < len(toks): + t = toks[i] + if t in _EXEC_SEPARATORS: + cmds.append(cur) + cur = [] + elif t in _EXEC_REDIRECTS: + nxt = toks[i + 1] if i + 1 < len(toks) else "" + if nxt != "/dev/null" and not nxt.isdigit(): + return None + if cur and cur[-1].isdigit(): # the fd in `2>&1` + cur.pop() + i += 1 + elif t and set(t) <= set("|&;<>()"): # |, &, (, ) and the rest + return None + else: + cur.append(t) + i += 1 + cmds.append(cur) + out = [] + for words in cmds: + while words and words[0] in _EXEC_CONTROL_WORDS: + words = words[1:] + if words: + out.append(words) + return out + + +def _exec_runs_only(command, args, target): + """True when an exec entry runs nothing except `target`: every simple + command in it is inert glue or an invocation of the target, by a known + launcher with nothing between launcher and script that could load code. + + This is what lets the TARGET's custody speak for a new entry. It says who + wrote the script; it says nothing about anything else the line runs, so + `bash ; curl ... | sh` must not borrow it. The + operator's own guarded-invocation idiom does qualify, because every word + around the script is inert: `if command -v uv >/dev/null 2>&1; then uv + run --quiet