Repository navigation
sandbox: a cd into a workspace subdirectory makes a relative climb back inside read as an escape #1370
Description
Activity
how2how2how2-arch commented
on Sep 18, 2026 CollaboratorMore actionsConfirmed 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/shfrom 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.txtBLOCK ws/back.txt— inside the workspacecd sub && echo x > ../sub/in.txtBLOCK ws/sub/in.txt— insidecd ../ws/sub && echo x > ../in2.txtBLOCK ws/in2.txt— insidecd sub && echo x > ../../worse.txtBLOCK (correct direction) one level above — the message names two levels above cd sub && echo x > out.txtALLOW ws/sub/out.txtThe last row of the first block is the "names the wrong base" half stated as a path: the message claims
<case>/worse.txtwhile the shell wrote<case>/worse.txt's sibling under the case directory — the base was the start directory, so every row behind acdis 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_workspacereports nothing, and the target../in2.txtis then resolved against the workspace root instead ofws/sub.The direction in this issue works, measured
I prototyped it on top of #1368 (a sibling of
_cwd_left_workspacethat carries the inside move too — same vocabulary, same_resolve_from_command_assignmentscope, 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 ALLOWwith 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.txtis not "insidews/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_ROWSand_ALLOWED_ROWS. Today_ALLOWED_ROWScarriescd 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 thecd-then-climb shape drifted further either way.how2how2how2-arch commented
on Sep 18, 2026 CollaboratorMore actionsReproduced on the merged master (
125e31c,emrg/tools/bash_tool.pysha256[: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.txtBLOCK ws/back.txt— insidecd sub && echo x > ../sub/in.txtBLOCK ws/sub/in.txt— insidecd ../ws/sub && echo x > ../in2.txtBLOCK ws/in2.txt— insidecd sub && echo x > ../../worse.txtBLOCK (right direction) outside — the message names a directory one level too high cd sub && echo x > out.txtALLOW ws/sub/out.txtecho x > out.txt/echo x > ../escaped.txtALLOW / 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.txtandworse.txtappear in no test file in the merged tree.cd subdoes appear, and one of those rows is the misleading one:tests/test_bash_tool_sandbox_cwd.pypinscd sub && echo x > out.txtas 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
cdinto 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.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.txtstill 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:
- The statement has to be placed, not just the move.
cd sub && echo x > ../back.txtis fine, but a;/||chain runs the next statement whether or not thecdworked, and acdthat failed leaves the shell where it started: measured,cd nosuchdir; echo x > ../escape.txtand 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 withcd sub && (echo x > ../back.txt)— the price of not reading a( … )group, whose mirrorcd sub && (cd .. && …)is a real escape). - 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.txtandworse.txt, which appear in no test file on the merged tree.- The statement has to be placed, not just the move.
What
When a command
cds into a subdirectory of the workspace and then climbs back with.., therelative-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.pysha256[:16]8da6e27529f4a7ae), calling_check_sandbox(cmd, "workspace-write", <ws>)as a pure predicate withws = <scratch>/a/b/wsand<scratch>/a/b/ws/subpresent:cd sub && echo x > ../back.txt<ws>/back.txt— inside the workspacecd sub && echo x > ../../worse.txtcd sub && echo x > out.txt<ws>/sub/out.txtecho x > ../ws/out.txt(nocd)<ws>/out.txt— the climb-and-return controlThe 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>/suband 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 thefail-closed choice and it closes #1353 correctly. The gap is that
_cwd_left_workspaceonly reports acdthat leaves the workspace; acdto a directory inside it is not tracked, so the write siteafter such a
cdis never the base.cd <subdir> && … > ../fileis a common shell idiom (write next to the subdirectory, not in it), sothe 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 beBLOCKed) and
_CLIMB_AND_RETURN_ROWS/_ALLOWED_ROWS(which must stay ALLOWed) — but thecd-then-climb shape is in neither:_ALLOWED_ROWSpinscd sub && echo x > out.txt, which passes foran 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
cdwhose target resolves inside the workspace the same way_cwd_left_workspacealready reports theones 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.