Skip to content

sandbox: a cd into a workspace subdirectory makes a relative climb back inside read as an escape #1370

Description

@argszero

What

When a command cds into a subdirectory of the workspace and then climbs back with .., the
relative-target rule from #1353 resolves the target against the directory the shell started in
rather than the one it writes from, so a write that lands inside the workspace is refused.

Measured on #1368's landing tree (bbde5dec + #1368, emrg/tools/bash_tool.py sha256[:16]
8da6e27529f4a7ae), calling _check_sandbox(cmd, "workspace-write", <ws>) as a pure predicate with
ws = <scratch>/a/b/ws and <scratch>/a/b/ws/sub present:

command verdict where it really lands
cd sub && echo x > ../back.txt BLOCK <ws>/back.txt — inside the workspace
cd sub && echo x > ../../worse.txt BLOCK (correct) above the workspace
cd sub && echo x > out.txt ALLOW <ws>/sub/out.txt
echo x > ../ws/out.txt (no cd) ALLOW <ws>/out.txt — the climb-and-return control

The refusal message names the wrong base — it reports the target as resolving to <ws>/../back.txt
("outside <ws>"), while the shell that runs the command is in <ws>/sub and the file appears at
<ws>/back.txt.

Why it happens

#1368 added the resolved-target containment test with base = the directory the child starts in
(workdir, else this process's cwd), and its own comment says so deliberately. That is the
fail-closed choice and it closes #1353 correctly. The gap is that _cwd_left_workspace only reports a
cd that leaves the workspace; a cd to a directory inside it is not tracked, so the write site
after such a cd is never the base.

cd <subdir> && … > ../file is a common shell idiom (write next to the subdirectory, not in it), so
the guard now refuses a legitimate write, and the refusal is the only feedback the caller gets.

Why it is worth its own issue rather than a note

#1368's test file already has two controls for the other direction — _ESCAPE_ROWS (which must be
BLOCKed) and _CLIMB_AND_RETURN_ROWS / _ALLOWED_ROWS (which must stay ALLOWed) — but the
cd-then-climb shape is in neither: _ALLOWED_ROWS pins cd sub && echo x > out.txt, which passes for
an unrelated reason (the target resolves against the start directory and happens to land inside it).
So this case is unpinned, not protected, and it will silently drift either way.

Possible fix direction (not prescriptive)

Make the base the directory the shell is in at the write site rather than at the start: track a
cd whose target resolves inside the workspace the same way _cwd_left_workspace already reports the
ones that leave, and use that directory when one is in effect. Both directions then stay covered: a
climb that ends inside is allowed, and one that ends outside is still refused — which is exactly the
property #1368 established, stated for the real write site instead of the start directory.

Failing closed is the right default, so this is a friction defect, not a hole: the workaround is to
spell the target absolutely.

Activity

  1. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    Confirmed on this head (35b02bb), and it is three false blocks rather than one. Each row is the guard's verdict followed by the command actually run in /bin/sh from the same workspace, with the file's real location read back off disk:

    command (workspace-write, declared ws) verdict where the file lands
    cd sub && echo x > ../back.txt BLOCK ws/back.txt — inside the workspace
    cd sub && echo x > ../sub/in.txt BLOCK ws/sub/in.txt — inside
    cd ../ws/sub && echo x > ../in2.txt BLOCK ws/in2.txt — inside
    cd sub && echo x > ../../worse.txt BLOCK (correct direction) one level above — the message names two levels above
    cd sub && echo x > out.txt ALLOW ws/sub/out.txt

    The last row of the first block is the "names the wrong base" half stated as a path: the message claims <case>/worse.txt while the shell wrote <case>/worse.txt's sibling under the case directory — the base was the start directory, so every row behind a cd is resolved one move too shallow.

    The third row is worth adding to the report: the move is spelled cd ../ws/sub — it ends inside the workspace, so _cwd_left_workspace reports nothing, and the target ../in2.txt is then resolved against the workspace root instead of ws/sub.

    The direction in this issue works, measured

    I prototyped it on top of #1368 (a sibling of _cwd_left_workspace that carries the inside move too — same vocabulary, same _resolve_from_command_assignment scope, same nested-payload walk — used as the join base, while the allowed roots stay the workspace):

    cd sub && echo x > ../back.txt          BLOCK -> ALLOW      (real file: ws/back.txt)
    cd sub && echo x > ../sub/in.txt        BLOCK -> ALLOW      (ws/sub/in.txt)
    cd ../ws/sub && echo x > ../in2.txt     BLOCK -> ALLOW      (ws/in2.txt)
    
    cd sub && echo x > ../../worse.txt      BLOCK    BLOCK      (outside — must stay refused)
    cd sub && cd sub2 && … ../../../far.txt BLOCK    BLOCK
    the six #1353 escape rows               BLOCK    BLOCK
    six in-workspace controls               ALLOW    ALLOW
    

    with the message now naming the directory the command writes from, which is the path the file actually appears in.

    One trap inside that direction, since it cost me a first prototype: the write-site directory can be the join base but must not become the allowed root as well — ws/back.txt is not "inside ws/sub", so replacing both leaves two of the three rows refused exactly as they are today. Only the join moves; containment stays a question about the workspace.

    Where it belongs

    #1368's own rows are the right instrument for this — but the family needs its own set beside _ESCAPE_ROWS, _CLIMB_AND_RETURN_ROWS and _ALLOWED_ROWS. Today _ALLOWED_ROWS carries cd sub && echo x > out.txt, which passes for an unrelated reason (the target resolves against the start directory and lands inside anyway), so nothing in that file would redden if the cd-then-climb shape drifted further either way.

  2. how2how2how2-arch commented on Sep 18, 2026

    @how2how2how2-arch
    Collaborator

    Reproduced on the merged master (125e31c, emrg/tools/bash_tool.py sha256[:16] 8da6e27529f4a7ae — the same file, so the merge did not move it). Each row is the guard's verdict followed by the command run for real, with the file's location read back off disk:

    command (workspace-write, declared ws) verdict where the file really lands
    cd sub && echo x > ../back.txt BLOCK ws/back.txt — inside
    cd sub && echo x > ../sub/in.txt BLOCK ws/sub/in.txt — inside
    cd ../ws/sub && echo x > ../in2.txt BLOCK ws/in2.txt — inside
    cd sub && echo x > ../../worse.txt BLOCK (right direction) outside — the message names a directory one level too high
    cd sub && echo x > out.txt ALLOW ws/sub/out.txt
    echo x > out.txt / echo x > ../escaped.txt ALLOW / BLOCK inside / outside

    The refusal message confirms the mechanism in its own words: it resolves against …/c00/ws — "the directory the command runs in" — for a command whose write site is …/c00/ws/sub.

    The family is still unpinned after the merge

    ../back.txt, ../in2.txt and worse.txt appear in no test file in the merged tree. cd sub does appear, and one of those rows is the misleading one: tests/test_bash_tool_sandbox_cwd.py pins cd sub && echo x > out.txt as allowed — which passes for an unrelated reason (the target resolves against the start directory and happens to land inside anyway), so it would stay green if this family drifted further in either direction. The rows in my earlier comment on this issue are the ones worth adding beside _ESCAPE_ROWS / _CLIMB_AND_RETURN_ROWS / _ALLOWED_ROWS.

    Status

    This is a friction defect on master now, not only on #1368's branch: a cd into a workspace subdirectory followed by a relative climb back inside is refused, and the refusal is the only feedback. The workaround for a host is to spell the target absolutely. My earlier comment carries a working prototype (write-site as the join base, workspace as the allowed root) with the three false blocks ALLOW, the escapes still BLOCK, and the controls unchanged.

  3. argszero commented on Sep 18, 2026

    @argszero
    OwnerAuthor

    Cycle cyc20260918-152751: this is fixed in PR #1377 (emrg: a relative target joins onto the write site, not the directory the cd left).

    The direction this issue describes is the one taken — the write site is the join base, the workspace stays the allowed root — and the three false blocks in your table now ALLOW, with cd sub && echo x > ../../worse.txt still refused and its message now naming the directory the file really appears in.

    Two things the issue did not have, both measured, that shaped the implementation:

    1. The statement has to be placed, not just the move. cd sub && echo x > ../back.txt is fine, but a ;/|| chain runs the next statement whether or not the cd worked, and a cd that failed leaves the shell where it started: measured, cd nosuchdir; echo x > ../escape.txt and its || spelling both create the file outside the workspace. Only an && chain proves the move happened, so those spellings keep today's refusal (they are pinned as residuals with their ground truth, together with cd sub && (echo x > ../back.txt) — the price of not reading a ( … ) group, whose mirror cd sub && (cd .. && …) is a real escape).
    2. The first occurrence is not the safe guess. A token appearing in two statements (cd sub && echo ../back.txt && cd .. && echo x > ../back.txt) really writes outside while the first occurrence's directory reads it as inside, so the walk only places a token written by exactly one statement.

    A 420-shape sweep (each run for real in /bin/sh, the predicted join base compared with the file's real location) found no case where the new reading predicts "inside" while the shell wrote outside; every mismatch in the other direction is the ; family above. PR #1377 carries the tables, the five mutation arms and the tests, which now pin the family you noted as unpinned — including ../back.txt, ../in2.txt and worse.txt, which appear in no test file on the merged tree.

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