Skip to content

fix(security): stop a name-based auto-approve from honouring a shadowed program name - #4920

Merged
bolichen97 merged 1 commit into
mainfrom
fix/name-grant-shim-guard
Aug 28, 2026
Merged

fix(security): stop a name-based auto-approve from honouring a shadowed program name#4920
bolichen97 merged 1 commit into
mainfrom
fix/name-grant-shim-guard

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

1. What is the problem?

A trust grant, a configured auto_approve_tools glob and the read-only allowlist all authorize a command by its program name. The shell then performs its own PATH lookup, and a gateway's PATH legitimately leads with directories the agent itself can write (a worktree venv's bin, mise shims, ~/.local/bin).

So a file the agent plants at ~/.local/bin/head wins the lookup over /usr/bin/head, and an approval granted because the command "is just head" executes whatever the agent last wrote there. The repo already documents this threat in trusted_system_bin's docstring, but that primitive only protects callers that spawn a resolved absolute path; it has no reach into the approval path.

2. Why this issue matters to the user

A grant is meant to be a statement about a program's behaviour. When the name does not identify the binary, the grant conveys authority the user never intended: writing a file is cheap for an agent and executing one is gated, and a name-based grant is exactly the bridge between the two. The user clicked "trust all head commands" having seen a paging utility, not an arbitrary program that keeps running unprompted for the rest of the session.

3. How our fix solves it -- from symptom to root cause

Symptom: a grant for head runs a planted shim.
Mechanism: the grant names a program; the lookup that picks the file happens later, in the shell, over a search path whose leading entries the agent can write.
Root cause: no tier ever asks whether the name still identifies the program it appears to name.

New src/kiro_crew/name_grant.py answers that question for a command line. It runs in the dashboard's chat runner, off the event loop -- that surface and no other, which is a deliberate scope stated in full under "Known costs" below and tracked in #6361 -- at three points:

  • a hook-granted shell auto-approve (the operator's auto_approve_tools globs, and the read-only allowlist) is re-judged and downgraded to the interactive card when the check refuses;
  • the session-trusted-pattern tier consults it before honouring a matched pattern;
  • the trust_reads tier consults it before honouring a read-only classification.

hooks.py is deliberately unchanged (byte-identical to main). HookManager.on_tool_call is synchronous and called on the gateway's event loop, and this check resolves names against PATH and digests the file behind each one -- work that must not run there. A test pins that its source never mentions this module, so the work cannot creep back onto the loop.

A refusal never blocks and never rewrites a command. It returns allow(), so the request falls through to the ordinary approval card: the tier stops skipping a prompt the user has not answered for this program.

What the check asks. A name is honoured only when it still identifies its program:

  • it must not SHADOW the same-named program in the trusted system directories (~/.local/bin/head over /usr/bin/head is the reported attack);
  • it must not resolve inside a tree the agent writes -- the project checkout, the workspace root, or a project-local tool directory (.venv/bin, node_modules/.bin);
  • if it is NOT a system program (gh, node, kirocrew, a version manager's python), a human approval must have identified the file, and it must still be that file. Approving a command records each of its programs by identity: a SHA-256 of the bytes (capped at 1 MiB, head and tail above that) plus size, inode, device and the kernel-set st_ctime_ns. Metadata alone is forgeable by the writer this pin exists to catch -- a same-size rewrite plus os.utime restores mtime and size exactly -- so the content digest is what decides. Pinning on first SIGHT would be worthless, because a tier looks precisely when it is about to auto-approve without asking anyone;
  • a script's INTERPRETER faces the same questions. #!/usr/bin/env node resolves node from PATH at exec time, so a pinned script can stay byte-identical while the program that runs is replaced underneath it.

What the check refuses outright, because the command line does not identify its programs at all: substitutions and backticks (shlex swallows "$(head x)" whole); shell grammar it does not model ({ }, if, for, while, case, !, time); composite operators (;(, ;>); a shell-expanded program token ($CMD); a relative program path; a command-position assignment to a variable that decides what runs (PATH=, LD_PRELOAD=, BASH_ENV=, IFS=, the DYLD_* family); a dispatcher that runs a program named in its arguments (env, sudo, timeout, xargs, exec, eval, source, and their kin); an env shebang carrying options, whose interpreter cannot be read off the line; and any PATH containing an empty or relative entry, since which file such an entry names depends on a working directory this check cannot see.

Program names are collected from every command position -- each pipeline stage, both sides of &&/||/;, each line, and inside a subshell.

Each refusal carries a code as well as a human detail, and callers log only the code: the detail names resolved paths, and logging it is a dataflow from tool input into a log sink that CodeQL reports at high severity. Refusal.log_text reads a constant out of a module table, the same severing pattern spine/keeper.py and cli_bench.py use.

Known costs, and one named residual.

  • A version manager whose shim shadows a system program (python3 behind a mise shim while /usr/bin/python3 exists) costs one prompt per command instead of a silent auto-approve. Coreutils and git read verbs -- the read-only allowlist's day-to-day traffic -- are unaffected.
  • A non-system program costs one approval card before name grants work for it, and one more after an upgrade changes its identity.
  • A PATH with . on it disables name-based auto-approve entirely until it is cleaned up.
  • Residual: non-dashboard surfaces are not covered, and this is one of four. The same hook-granted TOOL_AUTO_APPROVE is honoured with no name check at task_executor.py:393, subagent.py:6249 and messaging/dispatch.py:295, so Slack, Discord, Telegram, subagents, the taskrunner, llm_helpers and the auto-improvement spine still honour a name-based grant exactly as they did before this PR. Nothing regresses there and no prompts are added -- but the reported attack remains open on those surfaces, and since the adversary is the agent itself it picks the surface, so the unwatched ones are the cheaper target rather than the rarer one. Covering them is a design change, not a port: dispatch.py's _tool_gate is synchronous and loop-bound while this check must resolve PATH and digest a file off the loop -- the same constraint that keeps hooks.py byte-identical here -- and the shared entry point would have to be promoted out of chat_runner first. Tracked in Name-grant check covers only the dashboard chat loop: subagent, task-runner and messaging honour a shadowed program name unchecked #6361, with the three sites and a suggested shape. This PR does not claim to fix them.
  • The check is not binding on the exec: it runs when the approval is decided and the shell resolves again when it runs, so a second concurrent agent writing the shim inside that window still wins. Closing that needs the child's PATH to stop leading with agent-writable directories, which is what Name-based trust grants are undermined by agent-writable PATH entries: a planted shim wins the lookup #4438 asks for and is not attempted here.

One consolidation rides along: the project-local segment policy lived in dashboard/terminal_commands.py and is now owned by name_grant and imported there, so the two answers to the same question cannot drift.

4. What tests we did

test/test_name_grant.py, 158 tests. Every resolution is built against a hermetic search path and a stand-in for the trusted system directories, and the fixture pins the search-path ambiguity answer, so no assertion depends on the host's own PATH or installed programs.

Covered: the reported attack and its pipeline variants; a clean system program, a builtin that resolves nowhere, and a second symlink spelling of the same file still auto-approving; a non-system program refused until an approval identifies it, honoured after, refused once a different file answers to the name, and re-honoured after the next approval; a same-size rewrite with mtime restored inside the same ctime tick; an above-cap file changed at its head; project-checkout and .venv/bin resolutions; a stock install resolving THROUGH a node_modules segment still honoured; interpreter chains, including a shadowed interpreter, an agent-tree interpreter, and an env shebang with options; every tokenizer shape listed in section 3; the pin surviving concurrent churn, and a lost pin refusing instead of raising; log text never carrying a path; and the downgrade helper being a coroutine that hands the check to a thread.

Mutation-verified: 21 mutants, 20 killed. The one that is not is removing the pin store's lock, which no timing test can kill -- stated rather than papered over. Its consequence is covered instead by a deterministic test that injects the KeyError and asserts a refusal.

Also run: test_hooks.py, test_chat_runner_coverage.py, test_terminal_commands.py, test_dashboard_approval.py, test_chat_hooks.py, test_denied_commands_hooks.py. black / isort / flake8 / mypy clean on the changed files.

Two later rounds, after a rebase onto current main:

  • An exported shell function (BASH_FUNC_head%%) shadows a name directly, with no writable file anywhere: bash re-imports it in the child, so head file runs the payload while the check resolves /usr/bin/head and vouches for it. Refused now on the BASH_FUNC_ prefix rather than a %% suffix, because the suffix has been spelled () and %% by different bash versions and pinning one hands the bypass back; the legacy bare-name spelling is caught by its () { value. Four cases pin it, all mutation-verified red.
  • The Windows verdict is answered on the loop, not through asyncio.to_thread. The check already declines every name grant on Windows at its first branch without touching the filesystem, so the worker returned a constant -- and a hop that buys nothing is still a hop. Identical verdict on every platform; the hop remains where there is real filesystem work.
  • A code-injecting assignment prefix is refused (Opus round-18). _EXEC_ENV_VARS caught PATH and the loader family but not a tool or interpreter told to load code by its environment, so GIT_SSH_COMMAND=/writable/evil git fetch ssh://x vouched for /usr/bin/git and ran the planted file -- and PYTHONPATH, NODE_OPTIONS, PERL5OPT, RUBYOPT and JAVA_TOOL_OPTIONS are the same shape. Refused as FAMILIES (a LD_*/DYLD_* prefix, an _OPTIONS/OPT/PATH/LIB/_PRELOAD suffix) rather than as more exact spellings, because every round found one more interpreter with its own way of being told to load code and an exact list is only as complete as the last person to think about it. Over-refusing is the safe direction and costs one prompt; an ordinary FOO=bar head x is still skipped, which a test pins.
  • A NUL byte in a path refuses instead of aborting the turn (GPT round-18). realpath, stat and open raise ValueError, not OSError, on an embedded null, and shlex hands the token through intact -- so an OSError-only guard let it escape this module and replace the approval card with an error card. All seven inspection sites now fail closed on both, and four cases pin it (including a NUL in an operand and in a second command position).
  • A Windows-specific test premise was corrected. test_dashboard_approval.py's trust-reads deny test drives the tier with ls, which is a trusted system program on POSIX and absent on Windows -- where the check declines the tier, the request falls through to the interactive card, and the trust_reads deny it asserts never happens (an xdist worker death rather than a plain failure). It now stubs the one off-loop entry point, the same seam test_chat_runner_coverage.py uses, so it measures redaction rather than host PATH semantics.
  • The pin stays scoped to the extracted command (GPT rounds 18 and 20). Round 18 asked for a fallback to event.shell_command so a structured approval of a non-system program stopped re-prompting; round 20 called that wider form an undisclosed persistent identity grant. Between one extra prompt and recording an identity from a surface the human may not read as durable, the extra prompt is the safe side, so the fallback is withdrawn and the narrower form ships.
  • A vouched-for program can still exec another by name. egrep's whole body is exec grep -E "$@", so a planted grep is reached one hop after this check has vouched for egrep. This is the "not binding on the exec" residual above rather than a separate defect, and it is not script-specific -- strings /usr/bin/git and man both surface PATH-resolved helper names, so refusing scripts would narrow the surface without ending the class. Tracked in Name grant cannot bind what a vouched-for program execs next: egrep reaches a planted grep, and binaries do the same #6438, where the durable fix is Name-based trust grants are undermined by agent-writable PATH entries: a planted shim wins the lookup #4438's PATH-sanitization half.

5. Any other suggestions on the work

This PR went through twenty review rounds, and the great majority of findings were all one shape: a shell command line can hide the program it runs, and each round found another way. Every one was real and fixed; none were overridden. The result is correct but the surface is a twelve-reason denylist over a hand-rolled tokenizer.

My recommendation, for this PR or a follow-up: invert the rule. Accept only a plain program args [| program args]* line whose program tokens are bare words, and refuse everything else. That replaces the accumulating list with one rule, costs a prompt for command shapes that are rare in practice, and ends the class rather than enumerating it. I have not done it unilaterally mid-review because it changes what users see on ordinary commands.

Two siblings are tracked separately in #4921, because the shadowing rule is the wrong question for them: the read-only tier's <name> --help usage probe takes an AGENT-chosen name, and the browser-CLI tier's presence-as-consent binary lives in an agent-writable npm global directory.

Refs #4438

Comment thread src/kiro_crew/dashboard/chat_runner.py Fixed
Comment thread src/kiro_crew/dashboard/chat_runner.py Fixed
Comment thread src/kiro_crew/hooks.py Fixed
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of c288828310625e0b441aa17c1b6410a259041960 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound check, honestly scoped — but the adversary it targets can walk around it through three unguarded surfaces, and it rests on a denylist tokenizer.

Watch

  • Coverage is opt-in for the adversary. The check runs only in the dashboard chat runner, while task_executor.py:393, subagent.py:6249, and messaging/dispatch.py:295 honour the same name-based grants unchecked — and the agent chooses its surface (it can spawn a subagent). Until Name-grant check covers only the dashboard chat loop: subagent, task-runner and messaging honour a shadowed program name unchecked #6361 lands, this is bar-raising on the path the attacker least needs, not a closed hole; the PR says so ("the unwatched ones are the cheaper target"). Humans should decide whether the 1,248-line module plus new prompt UX earns its keep before the shared approval seam exists.
  • A hand-rolled tokenizer with a twelve-reason denylist decides a security grant. Every shape it tokenizes but doesn't model as dangerous fails toward vouching; twenty review rounds each finding one more (BASH_FUNC_, env families, dispatchers) is evidence the enumeration will keep needing a last person to think about it.

Suggestions

[DESIGN-REVIEWED] c288828

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of c288828310625e0b441aa17c1b6410a259041960 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c288828

False positive or not applicable? A repository writer can comment:
/ai-review override gpt c288828310625e0b441aa17c1b6410a259041960: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 21, 2026
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of c288828310625e0b441aa17c1b6410a259041960 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified. The existing mechanisms the module leans on (trusted_system_bin, agent_writable_roots, validate_provider_executable, augmented_path) all exist and are reused rather than re-implemented; the consolidation genuinely removes a duplicate from terminal_commands.py; hooks.py is untouched by the diff. The declared residual is real and I count 8 (not 3) sibling sites honouring TOOL_AUTO_APPROVE outside the dashboard runner.

First-Principles-Verdict: CONCERNS

The fix earns its place, but Windows silently losing name-based auto-approve entirely is a shipped default the description's "Known costs" list never names.

What this change ships

Intent: stop an approval granted for a program name from executing a file the agent planted ahead of it on PATH — a FIX.

  1. Shadowed/planted names no longer skip the prompt on the hook auto-approve tier — justified (the fix)
  2. Same re-judgement on the session-trusted-pattern tier — justified
  3. Same re-judgement on the trust-reads tier — justified
  4. Approving a shell command records program identities; non-system programs (gh, node) prompt once, and again after upgrade — justified, declared
  5. Windows: every name-based shell auto-approve now declined outright — undeclared; derived fail-closed
  6. Sessions with BASH_ENV/exported bash functions, ambiguous PATH, or unknown command words lose the tier — largely undeclared; derived fail-closed
  7. Declined grants audited (auto_approve_declined, code+tier) and explained in chat — justified (audit boundary)
  8. Version-manager shims now prompt per command — declared cost
  9. Project-local segment list moved from terminal_commands.py to name_grant — declared ride-along; deletes a duplicate
  10. Slack/Discord/Telegram/subagent/taskrunner surfaces unchanged, attack still open there — declared, tracked Name-grant check covers only the dashboard chat loop: subagent, task-runner and messaging honour a shadowed program name unchecked #6361

Watch

  • Point patch by surface: grep action == TOOL_AUTO_APPROVE finds 8 unfixed honour sites outside chat_runner.py (subagent.py:6249, task_executor.py:393, llm_helpers.py:1797, slack/handler.py:3476, slack/transport_dispatch.py:580, discord/transport_dispatch.py:592, telegram/transport_dispatch.py:773, messaging/dispatch.py:296). Declared and deferred to Name-grant check covers only the dashboard chat loop: subagent, task-runner and messaging honour a shadowed program name unchecked #6361 with a named constraint — accepted-and-deferred, but the description says "three sites"; the count is eight.
  • The check is non-binding on exec (TOCTOU window admitted); the cause-level fix — the child's PATH not leading with agent-writable dirs — is declared out of scope (Name-based trust grants are undermined by agent-writable PATH entries: a planted shim wins the lookup #4438). This change sits at mechanism level and says so.
  • Description says the digest is "capped at 1 MiB, head and tail above that"; the shipped _content_digest reads every byte and the _DIGEST_CHUNK comment calls the cap "an earlier version". Stale claim — the code is the stronger of the two; fix the description.
  • Items 5 and 6 are changed defaults absent from the "Known costs" section where the other four costs are itemised.

Subtractions

  • Drop the _BASH_FUNC_VALUE_PREFIX value-form branch in _inherited_preload (name_grant.py) — it scans every environment value on every check to catch a pre-2014 bash spelling the author's own comment says "Supported bash no longer imports"; keep the BASH_FUNC_ key-prefix match (1 consumer: the loop in _inherited_preload).

[FIRST-PRINCIPLES-REVIEWED] c288828

@chenmingwei23
chenmingwei23 force-pushed the fix/name-grant-shim-guard branch from 4676330 to cb91c87 Compare August 21, 2026 13:42
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 21, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 1 disposition (head cb91c875c)

Both GPT findings were real; one is fixed as prescribed, one is fixed by a different mechanism and here is why.

name_grant.py:194 -- quoted substitutions evade inspection: fixed as prescribed. Confirmed by probe before touching anything: shlex in POSIX mode consumes the quotes, so echo "$(head x)" tokenizes to ['echo'] and the inner head never reaches the walk. Backticks (quoted or bare) and <(...) behave the same way. The command line is now refused whole when it carries $(, a backtick, <( or >(, and a shell-expanded program token ($CMD) is refused too. Six tests, mutation-verified.

name_grant.py:267 -- names without a system twin remain replaceable: real, fixed, but NOT by requiring a system twin. The residual is exactly as described, and my first draft even asserted it as intended behaviour, which was the wrong call. The prescribed remedy is the one I cannot take: refusing every program that is not shipped in the trusted system directories would mean gh, node, npm, docker, kubectl, kirocrew and every Homebrew install can never be auto-approved, so "Trust all gh commands" would become a button that silently does nothing and users would reach for blanket trust instead. This repo has already argued that trade in the same words -- see terminal_commands._sanitized_path's docstring ("a tier that is dead on the most common developer platform is an unused code path, not a security win") and validate_provider_executable's relaxed default, which accepts the user's own installs deliberately.

What closes the named trigger instead: the first file a name resolves to is pinned by identity (realpath, mtime_ns, size, inode, device), keyed by (name, directory), and a later DIFFERENT file answering to that name is refused. Your scenario is "agent replaces ~/.local/bin/gh" -- replacement changes the identity, so the grant stops vouching for it. A mismatch does not re-pin, on purpose: re-pinning would be "one prompt, then trusted", and this code cannot see whether the human answered that prompt with yes. Cost is that a tool upgraded mid-session keeps prompting until the next gateway start, which the message states.

The residual that remains is narrower and now disclosed in both the module docstring and the PR body: a shim planted BEFORE the first observation of that name. For a user grant the human approved running that program once themselves; for a tier with no human in the loop it is real, and that is #4921.

CodeQL, 3 high alerts: real, fixed. My two logger.warning calls echoed command-derived text (and the user's own trusted pattern) into a log sink -- py/clear-text-logging-sensitive-data. A resolved path does disclose more than the caller typed, so this is not a false positive to wave through. A refusal now carries a CODE alongside its human detail, callers log Refusal.log_text which reads a constant out of a module table, and the detail still reaches the person on the approval card through _redact_display_text. Same severing pattern spine/keeper.py and cli_bench.py already use, plus a test pinning the invariant so a future edit cannot re-introduce the flow.

Diff is now +907/-23 across 5 files, still one commit. Nothing about the fix's shape changed: a refusal still returns allow(), never deny(), and no command is blocked or rewritten.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 21, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/name-grant-shim-guard branch from cb91c87 to deac077 Compare August 21, 2026 14:01
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c288828310625e0b441aa17c1b6410a259041960 — this comment is updated in place on each push.

Review details

Non-blocking: a use_aws auto-approve is checked against the synthesized command but pinned against an empty one, so it re-prompts on every call.

FINDING — src/kiro_crew/dashboard/chat_runner.py:7602 — the hook tier's name-grant check reads event.shell_command (synthesizes aws <service> <operation> for a structured use_aws call), returns UNWITNESSED since aws resolves outside _TRUSTED_SYSTEM_BIN_DIRS, and downgrades to the interactive card; on approval the pin runs if event.is_shell and cmd: where cmd = _extract_bash_command(event.tool_input) is "" for use_aws (no command key), so pin_human_approval never records aws and every subsequent use_aws grant re-prompts — the comment's claimed "prompts once more than it needs to" is actually permanent for this shape → Fix: pin from event.shell_command (already is_shell-gated) so the witness matches the command the check evaluates.

[OPUS-REVIEWED] c288828

Verdict parsed from the review's SHA-scoped output markers for commit c288828310625e0b441aa17c1b6410a259041960.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c288828310625e0b441aa17c1b6410a259041960: <one-sentence reason>

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 21, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 2 disposition (head deac077a9)

Both findings real, both fixed. No override.

name_grant.py:287 -- leading redirects hide the executable: real bug in my own tokenizer, fixed as prescribed. 2>/dev/null head README.md tokenized to ['2'] and head was never inspected: a redirect closed the command position, so anything after it read as an operand. A redirect can appear anywhere in a simple command, INCLUDING before the program, so the walk now consumes the operator and the file it names and leaves the command position as it was -- closing it hides head, opening it would judge out in head x > out. The 2> / 2>&1 fd prefix arrives as its own digit token and is consumed with the operator. Four cases pinned (2>/dev/null head, 2>&1 head, >out head, and one mid-pipeline), mutation-verified.

name_grant.py:354 -- first observation trusts a planted executable: real, and my round-1 answer was wrong about WHY. I claimed a user grant's first sighting was covered because the human approved that program once. It is not: the interactive approval does not run this check, so the first pin landed on the first AUTO-approve -- a moment with no human in it. Pinning there blesses whatever is present exactly when the tier is about to stop asking. That is your finding and it stands.

The fix is stricter than your prescription in the case you named, while keeping the tier alive. A pin can now only be recorded by a HUMAN answering the approval card (pin_human_approval, wired at the dashboard's interactive-approve chokepoint). A check never pins. So:

  • no pin -> refuse (UNWITNESSED), which is your planted-gh-before-first-call scenario: it gets a card, not a blessing;
  • pin present and matching -> honoured, so gh * works after the one approval the user already gave when they made the grant;
  • pin present and different -> refuse (IDENTITY_CHANGED), and the next human approval re-pins, which is also how an upgraded tool recovers (round 1 left that dead until restart);
  • a program that IS the trusted system file needs no witness at all, which is what keeps coreutils and the read-only allowlist working with no approval history -- including when spelled as an absolute path.

I did not take "refuse first-seen non-system executables" literally, because a permanent refusal makes "Trust all gh commands" a button that never takes effect for gh, node, npm, docker, kubectl and every Homebrew install, which pushes users to blanket trust instead. This repo has already argued that same trade in terminal_commands._sanitized_path and in validate_provider_executable's relaxed default. The witness requirement is the same refusal, released by a human decision instead of by elapsed time.

Disclosed cost, in the module docstring and the PR body: a read-only-allowlist entry that is NOT a system program (node --version under a version manager, or any build-tool wrapper installed under the user's home) now costs one approval card before it auto-approves again. And the witness is recorded on the dashboard card only -- another surface's approval does not pin, so a non-system program keeps prompting there. More prompts, never fewer.

44 tests (was 40), 7 mutants all caught. Ran only the tests for the changed module, per maintainer instruction.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

One correction to my own round-2 note, now that I have measured it rather than accepted the reported mechanism. The leading-redirect bug was real and the fix stands, but it was NOT reachable through the read-only tier: is_read_only_bash('2>/dev/null head README.md') returns False, because the classifier scrubs discard-only redirects for its unsafe-shell test yet still splits and prefix-matches the ORIGINAL string, so the leading redirect keeps the segment off the allowlist. The reachable paths were the operator's auto_approve_tools glob tier (matched on the title, with my check blind to the real program) and a session-trusted pattern. Same defect, same fix, narrower blast radius than reported.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 21, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/name-grant-shim-guard branch from deac077 to 7758864 Compare August 21, 2026 14:14
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 3 disposition (head 77588646d)

Both findings real. One fixed as prescribed; the other's remedy was necessary but not sufficient, and I measured that before shipping it.

name_grant.py:318 -- compound commands hide nested executables: real, fixed as prescribed. head x | { evil; } tokenized to ['head', '{', '}']: the walk read the grouping token as the program, so evil sat in operand position and was never inspected. This walk models one grammar -- simple commands joined by pipes, &&/||/;, and subshells -- so a reserved word in a command position now refuses the whole line instead of vouching for the subset it could see: { }, if/then/elif/else/fi, for/while/until/do/done, case/esac, select, function, coproc, [[ ]], and the ! / time prefixes. test and [ stay out of that set on purpose -- they are real programs, not grammar. ( already opened a command position, so a subshell is still walked rather than refused, pinned by a test.

name_grant.py:339 -- pinned identity can be forged: real, and st_ctime_ns alone does not close it. I added exactly your fix first and wrote the same-size-rewrite-plus-os.utime test for it -- and the test FAILED, which is what sent me to measure instead of assume. st_ctime_ns is kernel-set and unrestorable, but its clock has a tick: with no delay between the pin and the rewrite the ctime values are byte-identical (measured on both tmpfs and xfs; they only diverge once ~50ms separates the two writes). So the metadata tuple stays equal for a rewrite that lands inside the same tick as the approval -- which is the window an attacker aims at, not one they have to be lucky to hit.

The identity is therefore now content-derived: a SHA-256 over the file's bytes, capped at 1 MiB, with the size mixed in, and st_ctime_ns / inode / device kept as corroboration. A file at or under the cap is covered completely, which is the realistic plant -- a shim is a small script, and the round-3 test now passes with NO sleep, so the digest is doing the work rather than the clock. Above the cap the head and tail are digested with the exact size, so a substitution has to preserve both ends and the length; that boundary is stated in the code and pinned by a test that changes the head of an above-cap file. Cost is a page-cached read of at most 1 MiB on an auto-approve decision, and only for a non-system program (a system-resolved one never reaches the pin).

Residual I am naming rather than implying: a middle-window rewrite of an above-cap binary that preserves both ends, the exact size, and lands in the same ctime tick is not detected. That is a much narrower capability than "write a file", and it is the only gap left in the identity check.

54 tests (was 44), 9 mutants all caught -- including one that drops the digest from the identity, which fails exactly the two forgery tests. Ran only the changed module's tests, per maintainer instruction.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 21, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 15 disposition (head 6a23ac411)

Both findings real, both fixed. The blocking one I fixed by removing its cause rather than by adding the three names you named, because those three would not have been the last.

trap 'payload' DEBUG; head file -- real and reachable, confirmed by probe. trap installs a body the shell runs before every later command, and enable -f loads a shared object AS a builtin. Neither is a file on the search path, so shutil.which cannot see either, and the walk allowed them.

This is the fifth round of one finding: exec, then export, then set/shopt, then printf -v, now trap/enable. The cause is a branch that treated ANY unresolved command word as safe, on the reasoning that there was no shadowed program and therefore nothing to vouch for. That reasoning is wrong. A builtin does not need to shadow a program to decide what runs -- it IS the mechanism. Bash has around seventy builtins, so I stopped enumerating the dangerous ones and inverted the branch: an unresolved command word is now REFUSED unless it is on a nine-name allowlist of builtins that can neither run a program named in their arguments nor change how a later name resolves (:, cd, echo, pwd, true, false, test, [, wait). New refusal code unresolved_command_word.

Measured before and after, on a real host:

command before after
cd /tmp && ls allow allow
echo hi allow allow
trap 'payload' DEBUG; head file ALLOW refuse
enable -f /writable/evil.so head; head file ALLOW refuse
compgen -A function; head file ALLOW refuse
pushd /writable; head file ALLOW refuse

The last two matter more than the two you named: compgen and pushd appear nowhere in the module. They refuse because the default is refuse, and a test asserts exactly that -- it parametrizes over six builtins the code has never heard of and requires each to refuse, after asserting the name is not in any table. That is the difference between closing an instance and closing a class, and it is why I did not simply add your three names.

I did not add jobs. It resolves as a real file here and lists jobs; it neither runs a program from its arguments nor touches resolution, so refusing it would be cost with no gain. trap and enable are covered by the inversion rather than by a table entry.

Cost, stated plainly. An unknown command word now prompts instead of being waved through: a shell function or alias from the user's rc file, and a typo (gti status, which would have failed anyway). I think that is the right direction for a check whose only job is to say which file will run, but it is a real behaviour change and not a free one.

Function-local import -- fixed as prescribed. I checked for the cycle your rule allows an exception for and there is none (github_runner imports only stdlib plus platform_compat and windows_acl), so the import moved to module scope. I also checked the thing your finding did not mention: a module-scope import means an import failure there makes this module unimportable, where the function-local form degraded to fail-closed. No optional or third-party dependency in that chain, so the risk does not exist here. Worth stating rather than assuming.

Semgrep. insecure-file-permissions flags 0o700 as well as 0o755 and wants 0o644, which cannot work -- the fixture must carry an execute bit or shutil.which will not return it, which is the whole point of the test. Rather than suppress the rule or route around it through the pathlib form it does not match, the FIFO mode is now set at creation (os.mkfifo(fifo, 0o700)); mkfifo masks its mode by umask and never widens it, so there is no chmod call left to flag. The tests still assert uninspectable, which only happens if the lookup found the FIFO, so the execute bit is still there and the test still exercises the path.

Lint & Type Check (3.10 and 3.12) were CANCELLED, not failed -- both hung on the dependency-restore step for five and a half minutes and were killed, with no diagnostic emitted. Nothing in the diff relates to it and mypy is clean locally on the touched files (the two reported errors are pre-existing in transcribe.py, now visible transitively through the import that moved to module scope). It should clear on this run.

343 tests across the two touched files, plus the three neighbouring suites that exercise these tiers (326 more) to check the inversion's blast radius. 125 tests in this module now, was 119.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 22, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/name-grant-shim-guard branch from 6a23ac4 to f249c19 Compare August 22, 2026 06:59
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 22, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 16 disposition (head f249c198b)

Both findings real, both fixed, no override. I have standing authorization to override a verified nitpick and neither of these is one -- the first is the best finding on this PR.

head file#x; cat secret -- shlex hid a whole command from the check. Confirmed against real bash before changing anything:

  • what the walk saw: ['head', 'file']
  • what bash runs: bash -c 'echo A file#x; echo B ran' prints A file#x AND B ran

shlex discards the rest of the line after #. Bash does not -- # only opens a comment at the START of a word -- so file#x is an ordinary operand there and the ; still separates a second command. The check was therefore vouching for head and never seeing cat at all, which is precisely the failure this PR exists to prevent, committed by the PR itself. lexer.commenters = "" as you prescribed; the walk now yields ['head', 'cat'] and a planted cat refuses as shadows_system_program. A genuine trailing comment now arrives as an operand, which is harmless because only command POSITIONS are inspected, and there is a test for that too.

Inherited BASH_ENV -- fixed, with its reachability stated rather than implied. BASH_ENV=/writable/rc holding head() { payload; } makes bash -c 'head file' run the function while the name still resolves to /usr/bin/head. This is the same threat as the command-line BASH_ENV=... prefix that _EXEC_ENV_VARS already refused, arriving through the process environment instead, where the command line cannot show it.

I probed the environment: none of BASH_ENV, ENV, SHELLOPTS, BASHOPTS is currently set, so I could not demonstrate a live path to it -- an attacker would need to already control the environment the gateway passes to its children. I fixed it anyway, because the fix is a few lines and the check genuinely cannot be sound while one of those is set. New code inherited_env_can_redefine_programs. The cost is real and worth naming: a session whose environment legitimately carries one of these loses name-based auto-approve entirely.

The two Lint & Type Check reds were half infra and half a real failure the infra was masking. Both rounds' jobs ended in ##[error]The operation was canceled. at the ~6 minute mark with no diagnostic, under a 15-minute job timeout and inside a run that stayed in progress, so I could not attribute the cancellation from the log. Rather than re-run and hope, I ran all four gates locally the way the workflow invokes them -- and the black baseline gate caught a real offender the cancellation had been hiding: chat_runner.py was unformatted, from a line I had hand-wrapped. Formatted; the reformat touches exactly that one line and nothing else in the file.

For the record on the other three gates, since this is the first round I ran them at CI scope rather than on the files I touched: isort and flake8 clean. Full-tree mypy reports 4 errors in transcribe.py and ops_mission_control/.../cloudwatch.py; I A/B'd them against an unmodified main checkout and they reproduce identically there, so they are local stub versions differing from the pinned CI set, not anything on this branch.

131 tests in this module, plus the three neighbouring suites that exercise these tiers (326) to check the tokenizer change did not move anything else.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 22, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) August 24, 2026 06:57
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Aug 26, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and fixed the one blocking finding. New head 3f996f0ea; the PR is MERGEABLE again (it was CONFLICTING, 723 commits behind).

GPT 5.6 round-17 -- FIXED, not overridden

BLOCKING -- src/kiro_crew/name_grant.py:578 -- Exported Bash functions bypass program identity checks
Inherited BASH_FUNC_head%% -> ACP passes it to Bash while the guard validates /usr/bin/head -> the function executes without approval.
Fix: Refuse environment keys matching BASH_FUNC_*%%.

The finding is real and reachable, so it is fixed rather than disputed. An exported shell function shadows the name directly, with no writable file anywhere: bash re-imports BASH_FUNC_head%% in the child, so head file runs the payload while this check resolves the name to /usr/bin/head and vouches for it. That is precisely the unsoundness _ENV_PRELOAD_VARS already covers for BASH_ENV, arriving by a route the fixed tuple structurally could not see -- the module's own docstring names "can define a shell FUNCTION that shadows it" as the threat, so this was a gap in the guard, not a difference of opinion.

Attribution, for the record: this is not a pre-existing hole on main. name_grant.py does not exist on main, so there is no A/B to run -- the gap is an incompleteness in the new control this PR adds, which makes it in scope for this PR.

The fix is one wider rule in _inherited_preload, the function the finding points at -- no new module, abstraction, or config knob:

  • Matched on the BASH_FUNC_ prefix, not the %% suffix GPT suggested. The suffix has been spelled () and %% by different bash versions, so pinning one spelling would hand the bypass straight back on a build that picks the other.
  • The pre-2014 bare-name spelling (key is the function name, only the () { value marks it) is covered by the value form. Supported bash no longer imports it, so that half is belt-and-braces.
  • The refusal reports the family (BASH_FUNC_*), never the raw key: the key embeds an attacker-chosen function name and that string reaches a log sink and the dashboard card.

Four regression cases pin it, and all four were mutation-verified red with the new branch neutralised: the two live suffix spellings, a function naming a program the command never mentions (which is what proves the rule is prefix-based rather than name-matched), and the legacy value form.

Rebase notes

Only chat_runner.py conflicted. Two things there needed judgment rather than a mechanical resolution, both worth a reviewer's eye:

  1. Main deleted _cmd_grant_bases and moved the trust helpers into kiro_crew/trust_patterns.py. Git read my insert-before-_resolve_channel_target as re-adding ~580 lines main had removed, so taking "theirs" would have resurrected deleted code. Resolved by re-applying my six hunks onto main's file instead; the result deletes exactly one line relative to main (the is_read_only_bash(cmd) line I extend).
  2. Main renamed the tier local _tp_cmd -> _tp_command and now derives it from approval_command(...), which is also non-empty for non-shell grants (a canonical mcp-trust:v1:... identity). My original guard was shell-only by construction, because _tp_cmd came from _extract_bash_command and was empty otherwise. A literal port would therefore have silently widened the check to feed MCP tool identities into a program-name resolver, so the condition now tests event.is_shell explicitly.

Verification

  • test_name_grant.py 141 passed; test_chat_runner_coverage.py + test_host_service_guard.py 292 passed
  • trust/approval surfaces exercised by the is_shell gate: test_trust_patterns.py 157, plus test_auto_approve.py / test_dashboard_approval.py / test_trust_reads.py / test_promise_only_autoapprove_parity.py / test_channel_session_trust_parity.py / test_feed_trust.py 228 passed
  • isort / black / flake8 / mypy clean on both changed source files

Opus 4.8, Design, UX and First Principles were all green on the previous head and no code changed in what they reviewed beyond the rebase and this one guard.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head 3f996f0ea -> cfa4929ba. On 3f996f0ea all five review lanes came back green (GPT 5.6 17:01Z, Opus 4.8 17:04Z, Design 16:58Z, First Principles 16:59Z, UX 16:55Z), so the round-17 exported-function fix cleared the blocking finding on its merits. No override was used, on any round.

That head left exactly one red, and it was mine.

Backend Tests (Windows) (2) -- my regression, fixed at the cause

worker 'gw2' crashed while running test/test_dashboard_approval.py::TestDenyRowTitleRedaction::test_trust_reads_invalid_name_redacts

Attribution, measured rather than assumed:

  • Windows shard 2 passes on main -- run 33091197385 (7c8815f67), and I confirmed that run actually executed all four Windows shards rather than skipping them, so it is not a vacuous green.
  • The test is main's and this PR's diff does not touch test_dashboard_approval.py (still the same 6 files).
  • It drives tool_input='{"command": "ls"}' with _trust_reads on, which lands on the one tier this PR extends: is_read_only_bash(cmd) and await _name_grant_refusal_off_loop(cmd).
  • Shards 1, 3 and 4 passed, which is consistent with only shard 2 carrying that file.

So the crash is the thread hop this PR adds, outliving the test's event loop -- the same mechanism recorded on this PR at round ~12, where it "turned an assertion failure into a worker crash". Note it never showed up before now because shard 2 was cancelled on the pre-rebase head, so this PR had no signal from it.

The fix removes the hop on Windows rather than silencing the test. name_grant_refusal already declines every name-based grant on Windows at its first branch -- cmd.exe search order and POSIX-mode tokenization are deliberately not modelled -- and returns a constant without touching the filesystem. So on Windows the worker thread was doing nothing but handing back that constant, and paying a hop for it is not merely waste: the worker can outlive a caller's loop, which is what crashes an xdist worker instead of merely failing its test. _name_grant_refusal_off_loop now answers Windows on the loop and keeps the hop only where there is real filesystem work to do.

The verdict is byte-identical either way, so this changes no behaviour on any platform -- it removes a thread, not a check. platform_compat is imported as a module, not from ... import IS_WINDOWS, so a test that patches platform_compat.IS_WINDOWS still observes the patch.

pin_human_approval's hop is deliberately left alone: unlike the refusal it does real filesystem work on every platform, so its thread is earned. Shards 1/3/4 passing confirms it is not implicated.

Verification

760 passed locally: test_name_grant.py, test_chat_runner_coverage.py, test_host_service_guard.py, test_dashboard_approval.py (the suite that crashed), test_trust_patterns.py, test_trust_reads.py. isort / flake8 / mypy clean on the changed file.

Still one commit, still the same 6 files, MERGEABLE. This force-push re-rolls the review lanes; they were all green on the immediately preceding head and this delta removes a thread hop.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head cfa4929ba -> 9b77d093c. Zero code change in this push: it corrects an overclaim and tracks a gap.

Design Review BLOCK -- accepted in part, scoped and tracked

The guard is wired only into the dashboard chat loop; subagent, task-runner, and messaging surfaces honour the identical name-based grant unchecked -- and the adversary picks the surface.
Fix: wire name_grant_refusal at the remaining TOOL_AUTO_APPROVE honour sites, or explicitly scope the claim and track the gap.

The coverage claim is correct and I verified it rather than taking it on faith. All three honour sites exist exactly as cited -- task_executor.py:393, subagent.py:6249, messaging/dispatch.py:295 -- and none is in this PR's 6 files. The reasoning that the agent picks its own surface, so the unwatched paths are the cheaper target rather than the rarer one, is right and I am not disputing it.

I took the second of the two fixes it offers, for reasons that are structural rather than convenience:

  • dispatch.py's _tool_gate is def, not async def, and runs on the event loop. name_grant_refusal resolves names against PATH and digests the file behind each one, so it must not run there -- the same constraint that made this PR put the check in the async caller and keep hooks.py byte-identical to main. Covering that surface needs the gate's call contract restructured, not a line inserted.
  • The entry points are private to chat_runner, so reuse means promoting a public async helper and then wiring three call sites with three different approve mechanics, each needing its own regression test.

That is a design change across files this PR does not touch, on a PR that has converged over seventeen rounds -- and it is the shape of change that wants a human awake, not one made unattended at 3am. Filed as #6361 with the three sites, the sync-gate problem, and a suggested shape.

One thing the verdict is right to have caught, and it was mine. The commit message said "Every shell auto-approve tier now asks..." while the PR body had disclosed the residual honestly all along. That mismatch is a real description/diff fidelity defect and it is the hook the finding grabbed. The commit message now states the dashboard-chat-loop scope and the reason, and the body's residual bullet names the three exact sites and links #6361 instead of describing the gap in prose.

Worth stating plainly, because it affects how much weight to give the verdict: Design Review returned PASS on 3f996f0ea at 16:58Z with byte-identical scope and the same residual disclosure, then BLOCK on cfa4929ba, whose only delta was moving the Windows verdict off a worker thread. Nothing about surface coverage changed between those two heads. I am treating the finding as substantively real regardless -- which is why #6361 exists and the claim is corrected -- but the flip itself is lane non-determinism, not a newly introduced design defect.

Also corrected in the body while I was in there, since stale numbers are themselves fidelity defects: the test count (93 -> 141) and the round count (ten -> seventeen).

State

Still one commit, still the same 6 files, MERGEABLE. On cfa4929ba GPT 5.6, Opus 4.8, UX and First Principles were all green, and Windows shard 2 -- the one red on 3f996f0ea -- was fixed. No /ai-review override has been used on any round of this PR, and I am not asking for one here.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head 9b77d093c -> f26d2033e. Two blocking findings fixed, one advisory addressed, one test premise corrected. No override. Diff is now 7 files (the added one is explained below).

Opus 4.8 BLOCKING -- _EXEC_ENV_VARS omits interpreter/tool code injection. FIXED

Real and reachable, and the same class as the loader variables already refused. GIT_SSH_COMMAND=/writable/evil git fetch ssh://x passes _ASSIGN_NAME_RE, is absent from the set, so the walk skipped it and vouched for /usr/bin/git -- a trusted system file -- while the planted command ran. PYTHONPATH + sitecustomize.py, NODE_OPTIONS=--require=, PERL5OPT=-M, RUBYOPT=-r and JAVA_TOOL_OPTIONS=-javaagent: are the identical shape.

Refused as FAMILIES, not as four more spellings. Opus offered the exact-name list or an inversion to an allowlist. I took the exact names plus the families they belong to -- an LD_*/DYLD_* prefix, or an _OPTIONS/OPT/PATH/LIB/_PRELOAD suffix -- because the history of this PR is the argument: every round found one more interpreter with its own way of being told to load code, and an exact list is only ever as complete as the last person to think about it. Three of the new tests name variables the code does not enumerate (SOMETOOL_OPTIONS, WHATEVERLIB, LD_SOMETHING_NEW) and pass because the rule is family-shaped.

I did not take the full allowlist inversion. That is the change section 5 of the description already recommends and explicitly defers, because it changes what users see on ordinary commands -- not a call to make unattended.

Over-refusing is the safe direction here and its cost is bounded: a refusal never blocks and never rewrites, so PYTHONUNBUFFERED=1 costs one approval prompt. A test pins that an ordinary FOO=bar head x is still skipped, so the widening did not swallow the benign case.

GPT 5.6 BLOCKING -- a NUL byte aborts the chat turn. FIXED

Verified by probe rather than by reading: shlex hands /tmp/a\x00b through as an intact token, and realpath, stat and open all raise ValueError, not OSError on it (os.path.exists and shutil.which are safe). So the OSError-only guard let it escape this module, and its contract is that it returns a refusal and never raises at its callers -- an error card replaced the approval instead.

GPT named line 928. All seven inspection sites had the same exposure, so all seven now catch (OSError, ValueError) -- fixing the one cited site and leaving six siblings would have been a round-19 waiting to happen. Four cases pin it, three of which were mutation-verified red with the handlers reverted (the fourth, a NUL in an operand, passes either way because operands are not inspected -- kept as a boundary case).

Backend Tests (Windows) (2) -- my earlier diagnosis was WRONG, corrected

I previously attributed this crash to the asyncio.to_thread hop and moved the Windows verdict on-loop. That did not fix it, and the shard failed again on the next head. The on-loop change is still right on its own merits, but it was not the cause, and I should not have called it the fix before a completed shard confirmed it -- the shards that would have told me were cancelled by my own next push.

The real mechanism, established by A/B rather than inspection: the test drives the tier with ls, which resolves to a trusted system program on POSIX and does not exist on Windows. There the check declines the tier by design, the request falls through to the interactive approval card, and the trust_reads deny the test asserts never happens. Simulating exactly that (stub returning a refusal) reproduces it locally: AssertionError on the missing (invalid: row, then a 60s hang -- because the product is correctly waiting for a human answer the test never gives. That hang is what surfaces on Windows as node down: Not properly terminated.

So the test's premise is POSIX-specific, and the Windows behaviour is the intended fail-closed prompt -- not a product defect. test/test_dashboard_approval.py now stubs the one off-loop entry point, the same seam test_chat_runner_coverage.py already uses for the rung tests, so it measures what its name claims (deny-row redaction) instead of host PATH semantics. That is the 7th file: my change alters what this test pins, so updating it is part of the change, not scope creep. The stub is 14 added lines and I deliberately did not run black over that file -- it is in .github/black-baseline.txt, and formatting it would have added ~85 lines of unrelated churn and graduated a baseline entry.

A separate TypeError: 'coroutine' object is not iterable appears in that log from _drain_session_init_oauth_requests iterating a MagicMock-returned coroutine. It is pre-existing harness noise, already caught and logged as a warning by chat_runner, and unrelated to this PR.

GPT 5.6 advisory FINDING -- structured approvals were not pinned. ADDRESSED

cmd is empty for a structured tool call, so a non-system program (a pip-installed aws) re-prompted on every use after the human had already identified it. The pin now falls back to event.shell_command -- the same field the hook tier above already trusts to decide a refusal, so it is no weaker a source for recording one. Still gated on a genuine human approval.

Verification

956 passed: test_name_grant.py (158), test_chat_runner_coverage.py, test_host_service_guard.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py, test_auto_approve.py, test_terminal_commands.py. isort / flake8 / black / mypy clean on the changed files, and scripts/check_black_formatting.py passes with the baseline unchanged.

One commit, MERGEABLE. Design Review passed on the previous head; UX and First Principles were green there too.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head f26d2033e -> c64280bb5. Both GPT round-19 blocking findings fixed. No override. Still 7 files, one commit.

Everything else on f26d2033e was green -- 58 checks including all four Windows shards (so the corrected trust-reads test premise is confirmed by a completed run, not an assumption), and Opus 4.8, Design, UX and First Principles all passed.

BLOCKING -- dispatcher aliases bypass validation. FIXED

Agent plants runner -> /usr/bin/env -> human trusts runner -> runner <payload> auto-approves and executes the payload.

Real, and the mechanism is worth stating precisely because the pin is what makes it bite: the dispatcher rule read the name as written, so an alias for a dispatcher never matched it. runner shadows no system program, so SHADOWED does not fire; it is not a trusted system file, so it needs a witness; the human supplies one by approving it once; and from then on every runner <payload> is auto-approved while env runs the payload. The rule is a statement about the FILE's behaviour, so it now asks the resolved file: _dispatcher_target_refusal rejects a name whose realpath basename is a dispatcher.

Placement was the whole difficulty, and it is deliberate. The new question is asked only AFTER the trusted-system branch. On a BusyBox install every coreutils name resolves to /bin/busybox, which is a dispatcher by basename -- checking earlier would have refused ls, cat and head on every such host. Those names are already recognised as the system program they are, so asking later leaves them alone and still catches a planted alias, which is never a trusted system file. A test pins that shape directly (head -> busybox still honoured), and the two alias tests were mutation-verified red with the check disabled while that one stayed green -- which is what proves the pin is not quietly doing the work.

Both resolution paths are covered: found-on-PATH and spelled as an absolute path.

BLOCKING -- name-grant refusals were not SEL-audited. FIXED

Correct by the repo's own convention: the approve, deny and auto-approve decisions around these tiers all log to SEL, and a declined grant is the same kind of decision. Without it the trail shows a command arriving at the interactive card and never says a grant was withheld, or why.

All three tiers now emit one event through a single helper -- outcome="auto_approve_declined", with metadata={"reason": "name_grant", "code": <refusal code>, "tier": <hook_auto_approve|trusted_pattern|trust_reads>}.

Two judgment calls in it:

  • The code, never the detail. The detail names the program and the resolved paths; an audit sink is exactly where that becomes a disclosure, and this is the same severing the module already applies to its log text. code and log_text are both constants read out of a module table. A test asserts the planted path does not appear anywhere in the emitted event.
  • Not critical=True. That flag is for audit-or-deny, where a caller must refuse rather than run something unaudited. Nothing runs unaudited here: declining sends the request to the approval card, and the human's answer is audited in turn.

The trust-reads tier needed its condition restructured to capture the refusal rather than test it inline; the existing branch body is untouched.

Verification

1104 passed: test_name_grant.py (161), test_chat_runner_coverage.py (239), test_host_service_guard.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py, test_auto_approve.py, test_terminal_commands.py, test_hooks.py. Every new test mutation-verified. isort / flake8 / black / mypy clean, and scripts/check_black_formatting.py passes with the baseline unchanged (test_dashboard_approval.py is a baseline entry and was deliberately left unformatted).

MERGEABLE, one commit, same 7 files. Coverage gap on the other three surfaces remains tracked in #6361.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Stopping the automated fix loop here and handing this back

State on c64280bb5: 52 green, 0 failures except GPT 5.6. Four of five review lanes pass -- Opus 4.8, Design, UX, First Principles -- and all four Windows shards are green. MERGEABLE, one commit, 7 files. (The four 3.12 shards were still finishing at the time of writing; they were green on the previous head.)

I am not attempting a round-20 patch, and the reason is the pattern rather than the difficulty of either fix.

The round table

Round GPT blocking finding (name_grant.py / chat_runner.py) What I did
17 Exported Bash function (BASH_FUNC_head%%) shadows a name Fixed -- refused on the BASH_FUNC_ prefix, not one suffix spelling
18 Code-injecting env assignments (GIT_SSH_COMMAND, PYTHONPATH, NODE_OPTIONS, ...) + NUL byte raising ValueError Fixed -- refused as FAMILIES; ValueError caught at all 7 inspection sites
19 Dispatcher ALIAS (runner -> env) bypasses the rule + refusals not SEL-audited Fixed -- resolved-basename check (BusyBox-safe placement) + audit at all three tiers
20 Script bodies launch unchecked programs + one-shot approval pins an identity Stopping. See below.

Four consecutive rounds, each fix seeding the next finding in the same two spans, every one fixed on its merits, zero overrides used across all twenty rounds. Section 5 of the description predicted exactly this shape and recommended inverting the rule; that recommendation now looks like the actual answer rather than a nicety.

Finding A -- "Refuse shebang scripts instead of validating only their interpreter"

The mechanism is real and I verified it rather than reasoning about it. egrep on this host is a script whose body is exec grep -E "$@" -- a bare, PATH-resolved grep, so a planted ~/.local/bin/grep wins that lookup after the check has vouched for egrep.

But the prescribed fix does not close the class, and I can show it. A trusted binary has the identical exposure: strings on /usr/bin/git and on man both surface PATH-resolved program names (ssh, a pager). Refusing shebang scripts would therefore close the script-shaped instance while leaving the same hole open in every binary -- and cost egrep/fgrep their auto-approve for it.

The honest framing: this is the residual the description already names -- "the check is not binding on the exec: it runs when the approval is decided and the shell resolves again when it runs". A name-based check answers which file the shell will run for this name, not what that file does afterwards. Closing it needs the child's PATH to stop leading with agent-writable directories, which is #4438's other half and explicitly out of scope here. So this wants a disposition, not a patch -- and if you want it tracked as its own item I will file it beside #6361.

Finding B -- "Remove unconditional pinning from one-shot approvals"

This one reverses GPT's own round-18 instruction, two rounds apart, in the same function:

  • Round 18 (advisory): "if cmd skips pinning structured use_aws approvals, so non-system aws commands prompt repeatedly -> Fix: Pin event.shell_command when available." -- I implemented exactly that.
  • Round 20 (blocking): "One-shot approval creates an undisclosed persistent identity grant -> Fix: Remove unconditional pinning from one-shot approvals."

Read narrowly it asks me to revert round 18. Read strictly -- "remove unconditional pinning" -- it attacks the PR's design premise, because pinning on human approval is the whole mechanism by which a non-system program ever becomes name-grantable (section 3: "if it is NOT a system program ... a human approval must have identified the file"). Removing it means non-system programs never auto-approve again.

I am not flipping this code unattended on contradictory guidance, and I am not reaching for /ai-review override: finding A is a real mechanism and finding B is a design question, so neither is the verified nitpick an override is for. Overriding here would launder a decision that is yours.

What I recommend

The inversion section 5 already proposes -- accept only a plain program args [| program args]* line with bare-word program tokens, refuse everything else -- ends this class instead of enumerating it, and would have pre-empted rounds 17, 18, 19 and 20 alike. It changes what users see on ordinary commands, which is why I have not done it unilaterally mid-review.

Options as I see them:

  1. Land the inversion in this PR -- one rule replaces the twelve-reason denylist. Biggest behaviour change, best endpoint.
  2. Disposition round 20 and stop -- rebut A on the evidence above, revert B's round-18 fallback (a one-line change, re-opening only an advisory), and let a maintainer judge. Cheapest path to green.
  3. Descope the pin -- ship the shadow/agent-tree/dispatcher checks and drop the witness-pin mechanism into its own PR, which removes finding B's subject entirely and shrinks the diff.

Nothing has been merged and no override has been used. The coverage gap on the other three surfaces stays tracked in #6361.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head c64280bb5 -> ee55107aa. Round 20 dispositioned: one finding fixed by withdrawing a change, one rebutted on measurement and tracked. No override. Still one commit, 7 files.

Everything else on c64280bb5 was green -- 58 checks, all four Windows shards, and Opus 4.8 / Design / UX / First Principles.

"One-shot approval creates an undisclosed persistent identity grant" -- FIXED by withdrawing round 18

This is the same line two rounds apart, in opposite directions:

  • Round 18 (advisory): "if cmd skips pinning structured use_aws approvals, so non-system aws commands prompt repeatedly -> Fix: Pin event.shell_command when available."
  • Round 20 (blocking): "One-shot approval creates an undisclosed persistent identity grant -> Fix: Remove unconditional pinning from one-shot approvals."

I implemented round 18's ask; round 20 objects to exactly that widening. The trade is one extra approval prompt versus recording a program identity from a surface the human may not read as durable, and the extra prompt is the safe side -- so the fallback is withdrawn and the narrower if cmd: form ships. The reason is now in the code comment, so it does not read as an oversight to the next person.

I did not remove pinning outright. Read that way the finding deletes the mechanism by which a non-system program ever becomes name-grantable at all (section 3: "a human approval must have identified the file"), which is the PR's design rather than a defect in it. If that is the intended reading it is a maintainer's call, not an unattended one.

"Script bodies can launch unchecked shadowed programs" -- REBUTTED on measurement, tracked in #6438

The mechanism is real and I verified it rather than reasoning about it: egrep on this host is a script whose entire body is exec grep -E "$@", so a planted ~/.local/bin/grep is reached one hop after the check has vouched for egrep.

The prescribed fix -- "refuse shebang scripts" -- does not close the class, and that is measurable rather than arguable. A trusted BINARY does the same thing: strings /usr/bin/git surfaces a PATH-resolved helper name (ssh), and man surfaces two. Refusing scripts would close the script-shaped instance, leave the identical hole open in every binary, cost egrep/fgrep their auto-approve, and make the rule turn on file format instead of behaviour -- so a later reader could not tell why scripts are refused and git is not.

The honest framing is the one already in the description: the check answers which file the shell will run for this name, not what that file does afterwards, and nothing inspectable at decision time can bind the second lookup. The durable fix is at the exec boundary -- stop the child's PATH from leading with agent-writable directories -- which is #4438's remaining half. Filed as #6438 with the measurements and that reasoning, rather than patched here.

Standing back, because four rounds is the signal

Rounds 17-20 each produced a new blocking finding in the same two spans, every one fixed or dispositioned on its merits, zero overrides in twenty rounds. Round 20 is the first where one finding reverses an earlier one and the other's prescribed fix is demonstrably incomplete -- which is the point section 5 was making: the denylist is being enumerated, not closed. The inversion proposed there (accept only a plain program args [| program args]* line with bare-word program tokens, refuse everything else) would have pre-empted all four rounds. It changes what users see on ordinary commands, so it stays a maintainer's decision.

Verification

925 passed: test_name_grant.py, test_chat_runner_coverage.py, test_host_service_guard.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py, test_hooks.py. isort / flake8 / black / mypy clean on the changed file.

MERGEABLE, one commit, same 7 files. Coverage gap on the other three surfaces remains in #6361; the exec-boundary residual is now in #6438.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Note on the new Frontend Lint & Type Check red on ee55107aa -- it is main-owned, not this PR's.

The eslint half passes (663 problems (0 errors, 663 warnings), so the warning ratchet is intact). The job fails purely on jscpd, whose threshold is 0%:

- website/scripts/capture-aws-control.mjs [22:1 - 39:16] (17 lines, 341 tokens)
  website/scripts/capture-hero-art-proxy.mjs [30:1 - 48:4]
Found 2 clones.
ERROR: jscpd found too many duplicates (0.01%) over threshold (0%)

Attribution, measured rather than assumed:

Filed as #6439 for the main side. Not fixing it here: folding a frontend de-duplication into a backend PR that has converged over twenty rounds would put a foreign change inside this diff. I will re-run the job once main is healed.

GPT 5.6 and Opus 4.8 are still running on this head; the rest of the run is green so far.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head ee55107aa -> 3304dbb77. Rebase only -- zero code change, forced by a pinned merge ref.

Why a rebase was needed rather than another re-run

The Frontend Lint & Type Check red was main's jscpd clone (#6439), and main fixed it in 6c5afab6b (#6434, dedupe via serveDist) whose own CI run completed success. Two separate re-runs of the job still failed, and the logs show why: the job that started at 01:03:59Z -- 35 minutes after the fix landed -- still compiled the pre-fix file:

- website/scripts/capture-aws-control.mjs [22:1 - 39:16] (17 lines, 341 tokens)
  website/scripts/capture-hero-art-proxy.mjs [30:1 - 48:4]

So the merge ref was pinned to a base that predates the fix, and no number of re-runs could pick it up. A rebase is the only way to cut a fresh one.

Stated plainly because it is a real cost: this force-push re-rolls all five review lanes, and they were all green on ee55107aa (GPT 5.6, Opus 4.8, Design, UX, First Principles). I held this back for three cycles and tried the cheaper paths first -- waiting for main's run to confirm green, then re-running only the single job so the lanes stayed untouched -- and escalated to the rebase only once the pinning was proven from the timestamps.

What else cleared on the way

  • Backend Tests (3.10, 2) went green on re-run, confirming Flaky: test_bounded_turn_publishes_then_clears_the_deadline sees a _TURN_DEADLINE leaked from another test #6440: test_bounded_turn_publishes_then_clears_the_deadline saw 7715.53 in _TURN_DEADLINE, a value it cannot produce (its own budget is 120s), so it was a ContextVar leaked from another test in the same xdist worker. Ordering flake, not this PR -- the diff has zero hits on turn_dispatch.py.
  • Coverage Gate cleared with it, as its log predicted: backend-test=failure -- failing closed, a pure downstream aggregate rather than a coverage drop.

Rebase verification

52 commits of main absorbed, zero conflicts, and I did not trust that on its own -- a clean rebase says nothing about whether the tree still builds:

  • 783 passed: test_name_grant.py, test_chat_runner_coverage.py, test_host_service_guard.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py
  • isort / flake8 / mypy clean; scripts/check_black_formatting.py passes with the baseline unchanged
  • still ONE commit, still the same 7 files, 0 behind main

No override has been used on any round of this PR.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head 3304dbb77 -> 344d11d8d. Round 21 fixed. No override. One commit, 7 files.

The rebase worked: Frontend Lint & Type Check is now GREEN, so main's jscpd fix (#6439) is fully absorbed and that foreign red is gone for good. Backend Tests (3.10, 2) and Coverage Gate had already cleared. GPT re-rolled on the fresh head, as flagged, and produced one new finding.

BLOCKING -- a custom env shebang bypassed identity validation. FIXED

Agent replaces ~/.local/bin/env -> trusted script uses #!~/.local/bin/env node -> name grant validates only node and auto-approves the replacement.

Real, and the pin is what makes it bite. _shebang_interpreter matched env by basename, read the name after it, and forgot the path -- so #!<planted>/env node had node validated while the planted env is what the kernel actually executes. Because a pin binds the SCRIPT's bytes, the script keeps matching while the file behind its shebang is swapped underneath it.

The env binary is now held to the same standard as any other program: it must be the system env, not merely be spelled like one. A path that is not the trusted system env returns the existing _COMPLEX_ENV_SHEBANG sentinel, so the refusal travels through machinery this module already had rather than adding any.

Two tests pin it, and the pair matters: one proves a planted env is refused (mutation-verified red with the check disabled), the other proves the ordinary #!<system>/env node form is still honoured -- tightening this must not start refusing every real script, and that second test is what shows it does not.

A fixture leak this exposed, worth naming

Three existing tests wrote a literal #!/usr/bin/env ... inside a fixture whose entire purpose is host-independence -- every other path in it is hermetic. They passed only because nothing had ever asked what that env actually was. Once the env path is validated, comparing a real host path against the fixture's stand-in system directory could never match, so those tests began failing.

I fixed the fixture rather than loosening the check: they now write #!{system_env} ... using the fixture's own system env. Each test's subject is untouched -- the shadowed-interpreter test still asserts SHADOWED, the interpreter-replacement test still asserts IDENTITY_CHANGED, the complex-env test still asserts UNENUMERABLE. The alternative (weakening the new check so an unrealistic fixture keeps passing) would have been the wrong direction.

Verification

  • test_name_grant.py 163 passed; 731 passed across test_name_grant.py, test_chat_runner_coverage.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py
  • isort / flake8 / black / mypy clean; scripts/check_black_formatting.py passes with the baseline unchanged
  • still ONE commit, the same 7 files, 0 behind main

Twenty-one rounds, zero overrides. Coverage of the other three surfaces stays in #6361; the exec-boundary residual in #6438.

…ed program name

A trust grant, an `auto_approve_tools` glob and the read-only allowlist all
authorize a command by program NAME, while the shell performs its own PATH
lookup afterwards. A gateway's PATH legitimately leads with directories the
agent itself writes, so a file planted at `~/.local/bin/head` wins the lookup
over `/usr/bin/head` and a grant made because the command "is just head" runs
it.

Every shell auto-approve tier IN THE DASHBOARD CHAT LOOP now asks whether the
names in the command still identify the programs they appear to name. A name
that resolves somewhere other than the same-named program in the trusted system
directories, or that resolves inside the project checkout / workspace root / a
project-local tool directory, declines the auto-approve and falls through to the
ordinary interactive approval card. Nothing is blocked and no command is
rewritten; the tier only stops skipping a prompt the user has not answered for
this program.

Scope, stated rather than implied: the same hook-granted TOOL_AUTO_APPROVE is
honoured on three other surfaces -- task_executor, subagent and the messaging
dispatch gate -- which this commit does not cover. Widening it there is a design
change rather than a port: the messaging gate is synchronous and loop-bound,
while this check must resolve PATH and digest a file off the loop, which is the
same constraint that keeps hooks.py byte-identical to main here. Tracked in
 #6361.

Refs #4438
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Head 344d11d8d -> c28882831. Round 22 fixed. No override. One commit, 7 files.

Frontend Lint & Type Check stayed green on the fresh merge ref, so main's jscpd fix is absorbed for good.

BLOCKING -- a non-shell approval could mint a shell-program witness. FIXED

Non-shell MCP {"command":"gh ..."} -> approval pins gh -> later name grant auto-runs a planted binary.
Fix: Change the guard to if event.is_shell and cmd:.

Real and reachable, and I checked the mechanism rather than the claim. extract_bash_command reads a command key out of any JSON tool_input and falls back to returning the raw string -- it never consults is_shell:

data = json.loads(tool_input)
if isinstance(data, dict):
    command = data.get("command", "")
    return command if isinstance(command, str) else ""

So approving a NON-shell MCP call whose params happen to carry a command string recorded a durable identity for the shell program named in it -- a witness minted by an approval that was never about running that program. Fixed exactly as prescribed, and it is the same gate the trusted-pattern tier already carries for the same reason (approval_command is likewise non-empty for non-shell events).

The first version of this test was wrong, and I am flagging that rather than burying it

My initial regression test asserted pin_human_approval was not called -- and it passed with the guard removed, because the turn never reached the interactive-approve branch at all. That is a test that cannot fail: it would have shipped as coverage for a guard it does not exercise.

The version that ships drives the approval to completion (resolving the approval future through push_slots_update, the pattern the neighbouring tests use) and asserts both halves:

  • client.approve_tool.assert_awaited_once_with("req-cov-1") -- proof the branch was reached
  • pin.assert_not_called() -- the actual invariant

Mutation-verified in both directions: passes with the gate, and fails with AssertionError: Expected 'pin_human_approval' to not have been called once the gate is removed.

Verification

786 passed across test_name_grant.py, test_chat_runner_coverage.py, test_host_service_guard.py, test_dashboard_approval.py, test_trust_patterns.py, test_trust_reads.py. isort / flake8 / black / mypy clean; scripts/check_black_formatting.py passes with the baseline unchanged. Still ONE commit, the same 7 files, 0 behind main.

Where this leaves the review loop

Twenty-two rounds, zero overrides. Rounds 21 and 22 were both small, in-file, and genuinely new sites of one idea this PR keeps re-learning: validate the file that runs, not the name or the surface it arrived on. Round 19 applied it to dispatcher aliases, 21 to a shebang's env path, 22 to which surface may mint a witness. That is the same argument section 5 of the description makes for inverting the rule, and each round is further evidence the denylist is being enumerated rather than closed. I am fixing them because each one is real, reachable and cheap -- but the inversion remains the thing that would end the class, and it stays a maintainer's call.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fully green on c28882831

59 success, 6 skipped, 0 failures, and the required PR Readiness status reads "Eligible automated validation passed for this revision." All five review lanes pass -- GPT 5.6, Opus 4.8, Design, UX, First Principles -- plus CodeQL, PR Hygiene, Coverage Gate, all Linux and Windows shards, and Frontend Lint. One commit, 7 files, 0 behind main, MERGEABLE.

The six skipped are the expected ones for a backend diff on a same-repo PR: Bundle Size Gate, Frontend Coverage Merge, Linux Packaging, and the three fork-only jobs.

Twenty-two review rounds, zero /ai-review override used. Every blocking finding was either fixed on its merits or rebutted with a measurement.

What this PR ended up being

A name-based auto-approve now verifies that the names in a shell command still identify the programs they name, at the three dashboard chat tiers (hook auto-approve, session-trusted patterns, trust-reads). A refusal never blocks and never rewrites -- it declines the auto-approve and the request falls through to the ordinary approval card.

Rounds 17-22 were all one idea the module kept re-learning: validate the file that runs, not the name, and not the surface it arrived on.

Round Finding Resolution
17 Exported Bash function (BASH_FUNC_head%%) shadows a name Refused on the BASH_FUNC_ prefix, not one suffix spelling
18 Code-injecting env assignments; NUL byte raising ValueError Refused as FAMILIES (LD_*/DYLD_*, _OPTIONS/OPT/PATH/LIB); ValueError caught at all 7 inspection sites
19 Dispatcher ALIAS (runner -> env); refusals unaudited Resolved-basename check, placed after the trusted-system branch so BusyBox coreutils survive; SEL audit at all three tiers
20 Pin scope; "refuse shebang scripts" Withdrew round 18's widening; rebutted the script rule on measurement and filed #6438
21 A custom env shebang bypassed validation The shebang's env must BE the system env
22 Non-shell approval could mint a shell witness Gated the pin on event.is_shell

Judgment calls a reviewer should look at

Foreign reds encountered and disposed of

Awaiting maintainer approval -- mergeStateStatus is BLOCKED only on REVIEW_REQUIRED. I have not merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants