fix(security): stop a name-based auto-approve from honouring a shadowed program name - #4920
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of 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
Suggestions
[DESIGN-REVIEWED] c288828 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. The existing mechanisms the module leans on ( 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 shipsIntent: stop an approval granted for a program name from executing a file the agent planted ahead of it on
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c288828 |
4676330 to
cb91c87
Compare
Round 1 disposition (head
|
cb91c87 to
deac077
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNon-blocking: a FINDING — src/kiro_crew/dashboard/chat_runner.py:7602 — the hook tier's name-grant check reads [OPUS-REVIEWED] c288828 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Round 2 disposition (head
|
|
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: |
deac077 to
7758864
Compare
Round 3 disposition (head
|
Round 15 disposition (head
|
| 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.
6a23ac4 to
f249c19
Compare
Round 16 disposition (head
|
|
Rebased onto current main and fixed the one blocking finding. New head GPT 5.6 round-17 -- FIXED, not overridden
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 Attribution, for the record: this is not a pre-existing hole on main. The fix is one wider rule in
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 notesOnly
Verification
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. |
|
Head That head left exactly one red, and it was mine. Backend Tests (Windows) (2) -- my regression, fixed at the cause
Attribution, measured rather than assumed:
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. The verdict is byte-identical either way, so this changes no behaviour on any platform -- it removes a thread, not a check.
Verification760 passed locally: Still one commit, still the same 6 files, |
|
Head Design Review BLOCK -- accepted in part, scoped and tracked
The coverage claim is correct and I verified it rather than taking it on faith. All three honour sites exist exactly as cited -- I took the second of the two fixes it offers, for reasons that are structural rather than convenience:
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 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). StateStill one commit, still the same 6 files, |
|
Head Opus 4.8 BLOCKING --
|
|
Head Everything else on BLOCKING -- dispatcher aliases bypass validation. FIXED
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. 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 Both resolution paths are covered: found-on- BLOCKING -- name-grant refusals were not SEL-audited. FIXEDCorrect 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 -- Two judgment calls in it:
The trust-reads tier needed its condition restructured to capture the refusal rather than test it inline; the existing branch body is untouched. Verification1104 passed:
|
Stopping the automated fix loop here and handing this backState on I am not attempting a round-20 patch, and the reason is the pattern rather than the difficulty of either fix. The round table
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. But the prescribed fix does not close the class, and I can show it. A trusted binary has the identical exposure: 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 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:
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 What I recommendThe inversion section 5 already proposes -- accept only a plain Options as I see them:
Nothing has been merged and no override has been used. The coverage gap on the other three surfaces stays tracked in #6361. |
|
Head Everything else on "One-shot approval creates an undisclosed persistent identity grant" -- FIXED by withdrawing round 18This is the same line two rounds apart, in opposite directions:
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 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 #6438The mechanism is real and I verified it rather than reasoning about it: 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: 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 Standing back, because four rounds is the signalRounds 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 Verification925 passed:
|
|
Note on the new The eslint half passes ( 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. |
|
Head Why a rebase was needed rather than another re-runThe 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 What else cleared on the way
Rebase verification52 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:
No override has been used on any round of this PR. |
|
Head The rebase worked: BLOCKING -- a custom
|
…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
|
Head
BLOCKING -- a non-shell approval could mint a shell-program witness. FIXED
Real and reachable, and I checked the mechanism rather than the claim. 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 The first version of this test was wrong, and I am flagging that rather than burying itMy initial regression test asserted The version that ships drives the approval to completion (resolving the approval future through
Mutation-verified in both directions: passes with the gate, and fails with Verification786 passed across Where this leaves the review loopTwenty-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 |
Fully green 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
- Round 20's script finding was rebutted, not fixed.
egrep's body really isexec grep -E "$@", butstrings /usr/bin/gitandmanshow binaries dispatch by name too -- refusing scripts would close one shape and leave the class open. The durable fix is at the exec boundary (Name-based trust grants are undermined by agent-writable PATH entries: a planted shim wins the lookup #4438'sPATHhalf), tracked in Name grant cannot bind what a vouched-for program execs next: egrep reaches a planted grep, and binaries do the same #6438. - Rounds 18 and 20 asked for opposite things on the same line (pin more, then pin less). I took the safe side -- one extra prompt rather than an identity recorded from a surface a human may not read as durable -- and did not remove pinning outright, which would delete the mechanism the design depends on.
- Coverage is one of four surfaces.
task_executor.py:393,subagent.py:6249andmessaging/dispatch.py:295honour the same grant unchecked. Tracked in Name-grant check covers only the dashboard chat loop: subagent, task-runner and messaging honour a shadowed program name unchecked #6361;dispatch.py's gate is synchronous, so widening there is a design change rather than a port. - Section 5's inversion still stands as the real endpoint. Six rounds of new sites is the argument for it. It changes what users see on ordinary commands, so it stayed a maintainer's decision rather than something to land unattended.
Foreign reds encountered and disposed of
- Frontend Lint fails on every open PR: capture-aws-control.mjs duplicates capture-hero-art-proxy.mjs against a 0% jscpd threshold #6439 -- main's jscpd clone (
capture-aws-control.mjs) reddened Frontend Lint on every open PR. Main fixed it in6c5afab6b; two job re-runs still compiled pre-fix code, so the merge ref was pinned and a rebase onto healed main was needed to cut a fresh one. - 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_deadlinesaw7715.53in_TURN_DEADLINE, a value it cannot produce (its own budget is 120s): a ContextVar leaked from another test in the same xdist worker. Cleared on re-run. Coverage Gate was pure downstream (backend-test=failure -- failing closed).
Awaiting maintainer approval -- mergeStateStatus is BLOCKED only on REVIEW_REQUIRED. I have not merged.
1. What is the problem?
A trust grant, a configured
auto_approve_toolsglob and the read-only allowlist all authorize a command by its program name. The shell then performs its ownPATHlookup, and a gateway'sPATHlegitimately leads with directories the agent itself can write (a worktree venv'sbin, mise shims,~/.local/bin).So a file the agent plants at
~/.local/bin/headwins the lookup over/usr/bin/head, and an approval granted because the command "is justhead" executes whatever the agent last wrote there. The repo already documents this threat intrusted_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
headcommands" 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
headruns 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.pyanswers 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:auto_approve_toolsglobs, and the read-only allowlist) is re-judged and downgraded to the interactive card when the check refuses;trust_readstier consults it before honouring a read-only classification.hooks.pyis deliberately unchanged (byte-identical to main).HookManager.on_tool_callis synchronous and called on the gateway's event loop, and this check resolves names againstPATHand 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:
~/.local/bin/headover/usr/bin/headis the reported attack);.venv/bin,node_modules/.bin);gh,node,kirocrew, a version manager'spython), 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-setst_ctime_ns. Metadata alone is forgeable by the writer this pin exists to catch -- a same-size rewrite plusos.utimerestores 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;#!/usr/bin/env noderesolvesnodefromPATHat 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 (
shlexswallows"$(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=, theDYLD_*family); a dispatcher that runs a program named in its arguments (env,sudo,timeout,xargs,exec,eval,source, and their kin); anenvshebang carrying options, whose interpreter cannot be read off the line; and anyPATHcontaining 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_textreads a constant out of a module table, the same severing patternspine/keeper.pyandcli_bench.pyuse.Known costs, and one named residual.
python3behind a mise shim while/usr/bin/python3exists) costs one prompt per command instead of a silent auto-approve. Coreutils andgitread verbs -- the read-only allowlist's day-to-day traffic -- are unaffected.PATHwith.on it disables name-based auto-approve entirely until it is cleaned up.TOOL_AUTO_APPROVEis honoured with no name check attask_executor.py:393,subagent.py:6249andmessaging/dispatch.py:295, so Slack, Discord, Telegram, subagents, the taskrunner,llm_helpersand 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_gateis synchronous and loop-bound while this check must resolvePATHand digest a file off the loop -- the same constraint that keepshooks.pybyte-identical here -- and the shared entry point would have to be promoted out ofchat_runnerfirst. 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.PATHto 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.pyand is now owned byname_grantand 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 ownPATHor 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/binresolutions; a stock install resolving THROUGH anode_modulessegment still honoured; interpreter chains, including a shadowed interpreter, an agent-tree interpreter, and anenvshebang 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
KeyErrorand 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:
BASH_FUNC_head%%) shadows a name directly, with no writable file anywhere: bash re-imports it in the child, sohead fileruns the payload while the check resolves/usr/bin/headand vouches for it. Refused now on theBASH_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.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._EXEC_ENV_VARScaughtPATHand the loader family but not a tool or interpreter told to load code by its environment, soGIT_SSH_COMMAND=/writable/evil git fetch ssh://xvouched for/usr/bin/gitand ran the planted file -- andPYTHONPATH,NODE_OPTIONS,PERL5OPT,RUBYOPTandJAVA_TOOL_OPTIONSare the same shape. Refused as FAMILIES (aLD_*/DYLD_*prefix, an_OPTIONS/OPT/PATH/LIB/_PRELOADsuffix) 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 ordinaryFOO=bar head xis still skipped, which a test pins.realpath,statandopenraiseValueError, notOSError, on an embedded null, andshlexhands the token through intact -- so anOSError-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).test_dashboard_approval.py's trust-reads deny test drives the tier withls, 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 thetrust_readsdeny it asserts never happens (an xdist worker death rather than a plain failure). It now stubs the one off-loop entry point, the same seamtest_chat_runner_coverage.pyuses, so it measures redaction rather than hostPATHsemantics.event.shell_commandso 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.egrep's whole body isexec grep -E "$@", so a plantedgrepis reached one hop after this check has vouched foregrep. This is the "not binding on the exec" residual above rather than a separate defect, and it is not script-specific --strings /usr/bin/gitandmanboth surfacePATH-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'sPATH-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> --helpusage 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