Skip to content

sandbox: an assignment earlier in the command turns a name=value word inside a later quoted string into a write target #1467

Description

@argszero

What happens

A command that assigns a variable and then uses it is refused on a token that is prose inside a quoted string:

$ cd /Users/argszero/.emrg/evolution/emrg && F=<a path> && wc -c "$F" && shasum -a 256 "$F" \
    && echo "expected: <a hash>" && echo "=== patch --dry-run against the tree ===" \
    && patch -p1 --dry-run --forward -i "$F" && echo "patch rc=$?"
⛔ [sandbox:read-only enforcement=partial] read-only sandbox: blocked destructive write targeting 'rc=$?' — command not executed

The whole chain is refused — wc, shasum, echo and patch --dry-run are 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 (master 347f023e), no file written:

command (abridged) verdict
F=<path> && wc -c "$F" && echo "patch rc=$?" ⛔ blocked, targeting 'rc=$?'
F=<path> && shasum -a 256 "$F" && echo "patch rc=$?" ⛔ blocked, targeting 'rc=$?'
F=<path> && patch -p1 --dry-run --forward -i "$F" && echo "patch rc=$?" ⛔ blocked, targeting 'rc=$?'
echo "=== patch --dry-run against the tree ===" && echo "patch rc=$?" (no assignment) ✅ allowed
echo "patch rc=$?" · echo rc=$? · X=$? · echo "a x=$?" · printf %s $? ✅ all allowed
patch -p1 --dry-run --forward -i f.diff && echo rc=$? (literal path, no assignment) ✅ allowed

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_RE comment 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 protects python3 -c "print(1 > 0)".

Cost

Common shell idiom (V=<path> && cmd "$V" && echo "done rc=$?") → the command is refused; measured this cycle while verifying a patch --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.

Activity

  1. how2how2how2-arch commented on Sep 20, 2026

    @how2how2how2-arch
    Collaborator

    I 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 command
    echo ${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) answers

    if (_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_targets then 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 echo
    

    rm -r /private/tmp/x is 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 with blocked 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/x
    echo "$F" && echo "cp a b" b
    wc -l "$F"; echo "rm -r /private/tmp/x" /private/tmp/x
    test "$F" = "$G" && echo "touch /private/tmp/x" /private/tmp/x
    grep -c . "$F" && echo "mkdir /private/tmp/x" /private/tmp/x
    F=<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, and find's -exec / -execdir / -ok / -okdir operand. 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/null exemption, 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, targeting rc=$?, 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 existing tests/test_unresolved_wrapper_guard.py corpus that must stay blocked; and a mutation arm handing _unresolved_wrapper_payloads every 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_payloads iterate tokens but continue unless i in candidates, and keep the fullmatch test 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).

  2. argszero commented on Sep 20, 2026

    @argszero
    OwnerAuthor

    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") on master 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 command
    D 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 word

    The 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
    form NAME=$?. A quoted NAME=1 beside 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 later NAME=$? word as the
    write target.

    Also filed while measuring this: #1468 — an fd-prefixed redirect (2>/dev/null) is read as an
    operand, so cp/mv lose their destination and the write is allowed in workspace-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.

  3. how2how2how2-arch commented on Sep 20, 2026

    @how2how2how2-arch
    Collaborator

    The narrowing is right on the assignment (B settles it) but the second half is broader than NAME=$? — measured on master 347f023 with 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/x
    wc -c "$F" && echo "cp a b" ⛔ blocked b
    wc -c "$F" && echo "touch /private/tmp/x" ⛔ blocked /private/tmp/x
    wc -c "$F" && echo "mkdir /private/tmp/x" ⛔ blocked /private/tmp/x
    wc -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_payloads being 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 which NAME=$? and rm -rf X prose 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) and echo "rm -rf /tmp/x" (must block) beside the NAME=$? row — one without a $? anywhere, which is what pins the broader half.

  4. argszero commented on Sep 20, 2026

    @argszero
    OwnerAuthor

    Patch opened as #1491 (fix/operand-position-is-not-a-wrapper, head e4e2be8f).

    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 master c1a70c94, 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_payloads reads 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 of wc —
    opened that path, and the tokens behind it were re-tokenized: the argument token
    patch rc=$? became the words patch and rc=$?, and patch is 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 — including env/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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions