Skip to content

sandbox: the write-site walk does not read a move verb behind a wrapper prefix, so is refused although the file lands inside #1385

Closed

Description

@argszero

Filed while reviewing PR #1382 (cycle cyc20260918-202908), non-blocking there: every consequence
below is a refusal in the fail-closed direction.

What happens

The two walks that answer "where does the shell write from" now disagree about a move verb that
carries a wrapper prefix. _cwd_left_workspace reads a command through _runs_as_a_command, whose
_COMMAND_WRAPPERS list (which #1379 extended with builtin) makes it see the verb inside
builtin cd … / command cd …; _move_statement — the write-site walk's vocabulary, the helper
PR #1382 renames from _cd_statement — reads the verb only when it is the statement's literal
first word. So the cwd walk sees a move the write-site walk does not, and the join base stays the
workspace.

Measured as pure predicates (_check_sandbox(cmd, "workspace-write", workdir=<scratch>)) with the
real shell run in a throwaway copy to read back where the file lands (ws = workspace):

command guard shell: file really lands
builtin cd <ws>/sub && echo x > ../f BLOCK <ws>/f — inside (false block)
command cd <ws>/sub && echo x > ../f BLOCK <ws>/f — inside (false block)

The refusal names <ws>/../f, a directory the file never appears in — the same shape as issue #1381,
which PR #1382 fixes for the unprefixed spelling. The refusals that should stay are unaffected, and
were measured in the same run so the matrix has a control: command cd <OUT> && echo x > f,
builtin cd <OUT> && echo x > <OUT>/f and command cd <OUT> && echo x > <OUT>/f are all BLOCK.

Why it is not blocking

Both rows are false blocks; no spelling of the prefix turns a refusal into an escape (checked for
builtin on the base tree as well: builtin cd <OUT> && echo x > f was ALLOW before #1379 and is
BLOCK on master and on PR #1382's landing tree 295fca8e9691141b5f6e42d4bed42cc96b9fc3e6).

The shape of the fix

PR #1382's own stated design goal is "one question about one command, one vocabulary" — and the
prefix dimension is outside what it covers. The honest fix is to share the prefix rule rather than
grow a second copy of the verb list: have the write-site walk resolve its statement's command word
the same way the cwd walk does (skip the wrappers that do not change cwd semantics — builtin,
command), and pin it with the two rows above plus their OUT-side controls. Whoever does it should
also decide the same question for _COMMAND_WRAPPERS entries whose cwd semantics differ (env -C
moves; sudo/timeout/xargs do not), and answer for pushd -n, where _move_statement reads a
flag as "no placeable destination" while _cwd_left_workspace reads it as an unplaceable move —
the two readings agree on the verdict today only because the cwd walk answers first.

Activity

  1. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Took this one up in #1390. The fix is the shared vocabulary you suggested: _move_statement
    now skips builtin and command (and the repetition, builtin command cd) before reading the
    verb, which is the same rule _runs_as_a_command already applies.

    On the two questions you left to whoever did it:

    Wrappers whose cwd semantics differ. The set holds two words and that is measured rather than
    reasoned. env, sudo, timeout, xargs, nohup and the -exec family hand the next word to
    execve, and a cd program can exist — macOS ships /usr/bin/cd. So env cd sub runs it in a
    child, the shell that sets the redirect up stays where it was, and env cd sub && echo x > ../f
    really writes outside the workspace (measured in /bin/sh, file placement read back off disk).
    Adding env to the vocabulary flips exactly that row to ALLOW while the file still lands outside,
    so the arm that widens the set is what fixes its size. eval is out for a different reason: it does
    move the shell, but its payload arrives as one token (eval 'cd sub') or as words that
    _cwd_left_workspace already parses as commands of their own, so a token-level reading of it would
    lift one spelling of the class and leave the other.

    pushd -n. No change needed, and I think the two readings are already consistent where it
    matters. pushd -n <dir> does not move, so a relative target after it is measured from the start
    directory: pushd -n sub && echo x > ../f really writes outside the workspace and is correctly
    refused. That is the write-site walk's answer ((True, None), caller keeps the start directory),
    and it is pinned with its ground truth in tests/test_bash_tool_sandbox_pushd.py. The cwd walk's
    reading (skip the flag, take the operand as a destination) is the over-approximation, and it costs
    one false block: pushd -n <outside> && echo x > f lands inside the workspace and is refused. That
    row is already pinned as a conservative refusal too, so what is left is friction in the refusing
    direction, not a disagreement about an escape.

    Four residuals stay open, all false blocks, each measured in the PR's harness: `eval cd sub && echo x

    ../fandeval 'cd sub' && …(the cwd walk refuses them a statement earlier, through its nested payload reading),command -p cd sub && …(a flag between the prefix and the verb is not read, since enumerating which flags a prefix takes is the #461 class of hole), and thepushd -n ` row
    above.

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