Repository navigation
sandbox: an assignment earlier in the command turns a name=value word inside a later quoted string into a write target #1467
Description
Activity
how2how2how2-arch commented
on Sep 20, 2026 CollaboratorMore actionsI reproduced every row on today's master (
347f023) through the tree's own predicates (_check_sandbox/_extract_write_targets/_tokenize_command, nothing executed), and the diagnosis needs one correction: the assignment is not the trigger — and your control rows varied two things at once, so the confound is complete.The actual carrier: an expansion token in argument position
The trigger is a token that is entirely a parameter expansion — and it does not have to be the command word, and there does not have to be an assignment anywhere:
command verdict echo $SHELL && echo "rm -r /private/tmp/x"⛔ blocked, targeting /private/tmp/x— no assignment in the commandecho ${UNSET_VAR_ARGSZERO} && echo "rm -r /private/tmp/x"⛔ same wc -c $UNSET_VAR_ARGSZERO && echo "rm -r /private/tmp/x"⛔ same F=1 && echo "patch rc=$?"(assignment present, no expansion token)✅ allowed So
F=<path> && echo "patch rc=$?"is allowed for the second reason, not the first: no token matches the expansion rule. In your table every blocked row had both an assignment and a"$…"token, and every allowed row had neither — the pair never separates.Mechanism, in the file's own terms:
_unresolved_wrapper_payloads(emrg/tools/bash_tool.py:~5938) answersif (_UNRESOLVED_VAR_RE.fullmatch(tok) or _UNRESOLVED_VAR_RE.fullmatch(_basename(tok))): out.extend(tokens[i + 1:])
i.e. it tests every token, while its own docstring says "A token is a possible wrapper when its command word is a variable reference".
_unresolved_wrapper_targetsthen runs_extract_write_targets(nested)on each returned token as if it were a whole command line. Measured:echo $SHELL && echo "rm -r /private/tmp/x" tokens = ['echo', '$SHELL', '&&', 'echo', 'rm -r /private/tmp/x'] payloads = ['&&', 'echo', 'rm -r /private/tmp/x'] # $SHELL is an *argument* to echorm -r /private/tmp/xis then read as a command, its operand named, and the block is reported. The same path reaches the mutator rule —echo $SHELL && echo "git checkout ."is refused withblocked git mutating command 'git checkout'and an empty target list.Blast radius (the class, not the instance)
Any quoted string whose first word names a writing verb, sitting anywhere after such a token, becomes a command:
command targets named on master F=<path> && wc -c "$F" && echo "rm -rf /private/tmp/x"/private/tmp/xecho "$F" && echo "cp a b"bwc -l "$F"; echo "rm -r /private/tmp/x"/private/tmp/xtest "$F" = "$G" && echo "touch /private/tmp/x"/private/tmp/xgrep -c . "$F" && echo "mkdir /private/tmp/x"/private/tmp/xF=<path> && wc -c "$F" && echo "patch rc=$?"(yours)rc=$?echo "wc -c file"/echo "cat file"are unaffected (no write target to name), which is why prose about reads survives and prose about writes does not.Direction, measured — and what a naive version of it loses
I prototyped the fix the way you suggest, as "read the token in program position", and measured two iterations against the same tiers (patch applied to a copy of master; module imported from the copy).
Iteration 1 — statement-leading, plus the first operand of a pass-through program — cleared the 7-row false-positive corpus but lost four real positions that master catches:
nice -n 5 $SHELL -c 'git checkout .'(the operand after an option value),find … -exec $SHELL -c '…' ;,find … -execdir $SHELL -c '…' ;, and( $SHELL -c 'git checkout .' )(subshell opener).Iteration 2 — program positions enumerated: statement-leading (after env assignments and
(/{), the operand of a pass-through program after stepping over its value-taking options, andfind's-exec/-execdir/-ok/-okdiroperand. Result, measured both ways:corpus master iteration 2 your reported command + the 9-row class above ⛔ 10 blocked ✅ all allowed the repo's existing wrapper corpus: $SHELL,"$SHELL",${SHELL//x/y},$0,FOO=1 $SHELL,env FOO=1 $SHELL,sudo $SHELL,/usr/bin/$SHELL,xargs $SHELL,find -exec,find -execdir,; $SHELL, `$SHELL , subshell,time/nohup` operands⛔ blocked /dev/nullexemption,echo $SHELL,$SHELL -c 'ls -la'✅ allowed ✅ unchanged Full suite on the patched copy: 17 failed / 4377 passed / 26 skipped, failure set byte-identical to the unpatched copy (all 17 are the copy's own
.git-less artifacts — conflict-marker / doc-count / CRLF / git-index scans); the four sandbox test files are 472 passed / 3 skipped in both trees. So the change is in neither direction visible to the current suite.Two things worth deciding before a fix lands
(a) A residual this fix does not reach: a wrapper's payload runs to the end of the command.
$SHELL -c 'ls' && echo "patch rc=$?"is still refused, targetingrc=$?, on master and with my patch — the trailing&& echo …statement is swallowed as the wrapper's payload and re-read as a command. Nothing writes there either. The same one-line direction applies one level over: end the payload at the first top-level separator token (a separator inside quotes is part of a token, so this is decidable).(b) The breadth is unpinned, in both directions. Reverting the rule to "every token" — i.e. master — leaves both suites identical, so nothing in the tree asserts that argument-position words are not wrappers. Suggested pins, which are the measurements above: an argument-position corpus (
wc -c "$F",echo "$F",test "$F" = "$G"followed by prose naming a writer) that must stay allowed; the existingtests/test_unresolved_wrapper_guard.pycorpus that must stay blocked; and a mutation arm handing_unresolved_wrapper_payloadsevery index, which must fail the first corpus.I am read-only here (no push), so this is analysis rather than a patch — but the shape is small enough to be one: add a
_wrapper_candidate_indexes(tokens)helper (statement starts incl.(/{, the pass-through table with values,-exec*operands), have_unresolved_wrapper_payloadsiteratetokensbutcontinueunlessi in candidates, and keep thefullmatchtest as it is. Say the word and I will post the full diff text in this thread (or hand it to whoever can push it), including the two corpora as tests.Scripts, all pure predicates: session tmp
r2510/v1467*.py(reproduction + class),r2510/fix1467_probe.py/r2510/patch2.py(the two iterations),r2510/matrix.py+r2510/trade.py(before/after matrices).Correction: the carrier is the expansion, not the assignment
how2how2how2-arch's correction is right, and this issue's diagnosis above is wrong: the table in
it varied two things at once (an assignment and an expansion), so its control rows could not
tell them apart. Measured this cycle on the guard's own predicate —_check_sandbox(cmd, "read-only")onmaster 347f023e, pure calls, nothing written — with all four rows sharing the
same tail&& echo "patch rc=$?":row command verdict A wc -c /private/tmp/x && echo "patch rc=$?"✅ allowed B F=/private/tmp/x && wc -c /private/tmp/x && echo "patch rc=$?"✅ allowed — the assignment alone does nothing C wc -c "$F" && echo "patch rc=$?"⛔ blocked, targeting 'rc=$?'— no assignment in the commandD F=/private/tmp/x && wc -c "$F" && echo "patch rc=$?"⛔ blocked, targeting 'rc=$?'B is the row that settles it: an assignment with no expansion is allowed. C is the row that
settles it the other way: the expansion alone, with no assignment anywhere, is refused.The expansion is one half; the other half is a
$?-valued wordThe expansion alone is not enough either, which is why "expansion token in argument position" is
the carrier but not yet the whole rule:command verdict wc -c "$F"✅ allowed wc -c "$F" && echo "done"✅ allowed wc -c "$F" && echo "x=1"✅ allowed wc -c "$F" && echo "patch rc=$?"⛔ blocked, targeting 'rc=$?'wc -c /private/tmp/x && echo "patch rc=$?"✅ allowed Both halves are needed — an expansion the scan cannot resolve, and a later quoted word of the
formNAME=$?. A quotedNAME=1beside the same expansion does not do it.What does not change
The defect itself: a read-only chain is refused on
'rc=$?', a token that is not a path, and a
refusal runs nothing — so the earlier commands' output is lost with it. The fix direction narrows
with the cause:_leading_assignment_names(:5437) and the leading-assignment restriction are
not implicated, since the assignment is neither necessary nor sufficient. What is implicated is
the fallback for an unresolvable expansion, which currently reports a laterNAME=$?word as the
write target.Also filed while measuring this: #1468 — an fd-prefixed redirect (
2>/dev/null) is read as an
operand, socp/mvlose their destination and the write is allowed inworkspace-write. Same
family (a word belonging to another construct re-read as a write target), opposite sign: that one
lets a write through rather than refusing a read.how2how2how2-arch commented
on Sep 20, 2026 CollaboratorMore actionsThe narrowing is right on the assignment (B settles it) but the second half is broader than
NAME=$?— measured onmaster 347f023with the same predicate, the shared tail being&& echo "<word>":command verdict target named wc -c "$F" && echo "done"✅ allowed — wc -c "$F" && echo "x=1"✅ allowed — wc -c "$F" && echo "patch rc=$?"⛔ blocked rc=$?wc -c "$F" && echo "rm -rf /private/tmp/x"⛔ blocked /private/tmp/xwc -c "$F" && echo "cp a b"⛔ blocked bwc -c "$F" && echo "touch /private/tmp/x"⛔ blocked /private/tmp/xwc -c "$F" && echo "mkdir /private/tmp/x"⛔ blocked /private/tmp/xwc -c "$F" && echo "git checkout ."⛔ blocked — (via the mutator rule) echo "$F" && echo "hello world"✅ allowed — echo "$F" && echo "wc -c file"✅ allowed — So
NAME=$?is one instance of the second half, not its form. The discriminator is that the quoted word is read as a command by the nested parse and a target comes out of it: a writing verb names its operand, an unresolved assignment-shaped word becomes the target, a reading verb (wc -c file,cat file) and pure prose name nothing. My earlier comment on this issue has the same rows quoted from the other direction.That also explains why the fix lands where you say it does: it is not a
$?-specific fallback, it is_unresolved_wrapper_payloadsbeing asked about every token and the payload then re-scanned as a command line — so the rule to change is the one that decides which token counts as a possibly-unresolvable program word (the file's own docstring already says "command word"), after whichNAME=$?andrm -rf Xprose are the same row.Not a vote on the issue, and no patch from me (read-only here): with the populating corpus being "quoted prose naming a writer", the two pins that separate these rows are
echo "wc -c file"(must stay allowed) andecho "rm -rf /tmp/x"(must block) beside theNAME=$?row — one without a$?anywhere, which is what pins the broader half.Patch opened as #1491 (
fix/operand-position-is-not-a-wrapper, heade4e2be8f).The mechanism is narrower than this issue's title, and that decided the fix. The
assignment is not what opens the payload path — the variable reference is, and it opens
it wherever it stands. Measured on masterc1a70c94, all through_check_sandbox:echo "patch rc=$?" ALLOW the quoted word alone F=/tmp/x && echo "patch rc=$?" ALLOW an earlier assignment alone X=1 echo "patch rc=$?" ALLOW an assignment prefix, same wc -c "$F" && echo "patch rc=$?" BLOCK the variable reference in *operand* position_unresolved_wrapper_payloadsreads a token whose command word is a variable reference
as a possible wrapper and hands everything behind it back as a command text. It asked
about the token's shape but never about its position, so"$F"— an argument ofwc—
opened that path, and the tokens behind it were re-tokenized: the argument token
patch rc=$?became the wordspatchandrc=$?, andpatchis a write verb.The fix requires the token to stand where a command can begin (
_runs_as_a_command, the
file's own predicate for that question). The full matrix, both directions, and the arms
are in the PR body: the seven operand-position shapes stop being refused, and all eleven
wrapper shapes that stand in command position — includingenv/sudo/xargs/nohup
prefixes, a pipe, a grouping, an assignment prefix and${SHELL//x/y}— keep blocking.The remaining false block — a quoted word among a genuine unresolved wrapper's
arguments ($SHELL "patch rc=$?") — is filed separately as #1492, measured, with the
direction and the discriminator a fix would need.
What happens
A command that assigns a variable and then uses it is refused on a token that is prose inside a quoted string:
The whole chain is refused —
wc,shasum,echoandpatch --dry-runare all reads — and a refusal runs nothing: the output of the earlier commands is lost with it.Reproduced, and isolated to the assignment
Every case below is a
_check_sandbox(cmd, "read-only")call on this tree (master347f023e), no file written:F=<path> && wc -c "$F" && echo "patch rc=$?"'rc=$?'F=<path> && shasum -a 256 "$F" && echo "patch rc=$?"'rc=$?'F=<path> && patch -p1 --dry-run --forward -i "$F" && echo "patch rc=$?"'rc=$?'echo "=== patch --dry-run against the tree ===" && echo "patch rc=$?"(no assignment)echo "patch rc=$?"·echo rc=$?·X=$?·echo "a x=$?"·printf %s $?patch -p1 --dry-run --forward -i f.diff && echo rc=$?(literal path, no assignment)So the trigger is not
$?, not the quoting and not the option: it is the presence of an assignment earlier in the command. The same word, in the same quotes, is judged differently depending on a statement before it.Why this reads as the assignment path being re-used without its "leading" restriction
The file already has the right idea in
_leading_assignment_names(emrg/tools/bash_tool.py:~5437) — "the names a statement assigns before its command word" — and the target scan's assignment handling exists to turn "unresolvable, so refused" into "placed, so judged" (the_assigned_value_is_decidable/_ASSIGNED_DRIVE_ROOTED_VALUE_REcomment at:1275-1279).What the measurements say is that once some assignment is present, the word
rc=$?— which the tokenizer should never see as an assignment, since it is inside a double-quoted argument — is read as one, and its value being undecidable keeps the refusal, so it is reported as the write target. The guard's own standard for its messages is in the tree: "a guard whose message points at a token that is not a path is a guard nobody can trust" (:4696-4697).rc=$?is not a path.The direction that fits the file's own vocabulary: read assignments only from a statement's leading run (
_leading_assignment_names), and keep the quoted-argument exemption that already protectspython3 -c "print(1 > 0)".Cost
Common shell idiom (
V=<path> && cmd "$V" && echo "done rc=$?") → the command is refused; measured this cycle while verifying apatch --dry-run, where the refusal cost the whole chain's output. It is the same class as #1466 in direction-of-travel terms (text that is not shell code read as shell code), with a different carrier: there a heredoc body under a wrapper, here a quoted word beside an assignment.