From e39cbddfc7a1dfb4a6bf7862a0bfcad2ada16147 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:32:53 -0700 Subject: [PATCH 1/2] The agent surface asked custody of the wrong file, read a process log as config, and read prose as directives Precision plan 2026-09-23, step P2d. Every judged-noise incident on the agent-surface and agent-skill sensors traced to a parser or reach defect, not a missing allowlist: - #521: ~/.codex/process_manager/chat_processes.json is Codex's runtime process table (osPid, startedAtMs, turnId per record), parsed as a delegate config; a node upgrade under `npm run build` became "exec target changed". A document that is a list of process records now registers nothing. The test is on the whole document, so an `osPid` key cannot hide a server block in a real config. - #453, #298, #299: the resolver hashed /bin/mkdir for a guarded hook line, never expanded a quoted "$HOME/...", and kept a glued ';' in the path (every `if command -v node ...; then node x.js; fi` hook was unhashed). An absolute script now wins; otherwise the old rule stands on the word as written. Baseline schema v4 re-points exactly the entries whose answer moved (13 distinct commands, 35 entries on this machine) and adopts their current content, so the upgrade scan is silent; entries that did not move keep a pending hash. - #135, #303: a NEW exec entry asked custody of the config file only. It now also asks the target's custody (at its realpath) when the target's rung is stronger and the line runs nothing else (_exec_runs_only: pipes, $(), redirects into files, extra commands, -c/--with/--require all refuse), and never when the config line arrived from a remote. - #329-#332: skill custody was never asked, and ~/.codex/skills/ are symlinks git cannot answer for. Now asked at the realpath, over the files that changed, graded by the weakest, never when the text carries a directive. Memoized on (realpath, sha) for answers only, TTL 6h, 10s budget per diff: the live set cost 37s of git per scan before this bound; 10.2s cold, 1.3s steady-state in watch mode after it. - #335, #336, #329, #331: the directive tables fired on the operator's own prose. Conceal: 'tell the user to "x"' is advice (the lookahead required \w after "to"), and "without asking" is autonomy, not concealment. Credential: "config"/".config"/"config.json"/"firefox" were tokens, and a secret mentioned anywhere paired with a channel anywhere; the pair now has to sit in one directive unit. Egress: a bare URL is a citation, and "send to" with no object is a noun. Every table is narrower, so a stored marker can only disappear: 134 live instruction files, 79 lose a marker, 0 gain one. Still alerting by design: #313 (a Developer ID binary with no git or intent provenance -- the publisher rung is S5's), and any exec whose target has no provenance. Co-Authored-By: Claude Opus 5.5 (1M context) --- aegis.py | 562 ++++++++++++++++++-- tests/test_agent_surface_provenance.py | 683 +++++++++++++++++++++++++ tests/test_custody_roster.py | 9 +- 3 files changed, 1201 insertions(+), 53 deletions(-) create mode 100644 tests/test_agent_surface_provenance.py diff --git a/aegis.py b/aegis.py index 4a2d512..6ace332 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,39 +15779,80 @@ 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 DIRECTIVE, for the purpose of pairing a secret with a channel: 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]|[|#>]))") + + +def _directive_units(text): + return [u for u in _DIRECTIVE_BREAK_RE.split(text) if u.strip()] + 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 a DIRECTIVE names a secret: the secret and an egress or + concealment directive in the same unit (_directive_units). It used to mean + a secret was mentioned anywhere in the file, 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." in one table row with a docs link three sections away, and two + of his skills (#330/#332) paired a keychain note with an API base URL. An + exfil instruction names the secret and the channel together, because that + is the instruction. A file that only mentions a secret carries no directive + about it, and no longer reports one. + + 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") break - for rx in _IMPERATIVE_EGRESS: - if rx.search(text): + toks = _credential_path_tokens() + for unit in _directive_units(text): + directive = any(rx.search(unit) for rx in _IMPERATIVE_EGRESS) + if directive: hits.add("egress") + elif "conceal" in hits: + directive = any(rx.search(unit) for rx in _IMPERATIVE_CONCEAL) + if directive and "credential" not in hits: + low = unit.lower() + if any(tok in low for tok in toks): + hits.add("credential") + if "egress" in hits and "credential" in hits: break return sorted(hits) @@ -15671,8 +15861,9 @@ 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 a directive names the secret -- see _imperative_signals). + credential without egress is a LOW record.""" if not markers: return None if "conceal" in markers: @@ -17564,6 +17755,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 +17996,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 +18041,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 +18082,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 +18139,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 +18180,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