Repository navigation
sandbox: a brace-expanded move destination is read as a literal word, so the move reads as inside the workspace #1396
Description
The hole
A brace-expanded move destination is read as a literal word, so the walk joins it onto the
cwd and the move reads as inside the workspace — the one direction this guard must never drift
in. {../outside,sub} is ordinary text to the tokenizer, an expansion list to the shell.
Measured predicate-only (nothing executed, synthetic paths, workspace-write), identical on
master 67ba7f52 and on PR #1392's landing tree 45f9bc85:
cmd: cd {../emrg-1357-outside,sub} && cat > f
master ALLOW _cwd_left_workspace=None write-site=<ws>/{../emrg-1357-outside,sub}
#1392 landing ALLOW _cwd_left_workspace=None write-site=<ws>/{../emrg-1357-outside,sub}
The write-site walk's answer is the tell: it hands back <ws>/{../outside,sub}, a path inside the
workspace that exists nowhere on disk. A nested list behaves the same way
(cd {../outside,{a,b}} && cat > f → ALLOW).
The shell does expand it. This host's /bin/sh is bash in POSIX mode, which brace-expands:
sh -c 'echo {a,b}' → a b. Driven end to end in a tree the probe built
(subprocess.run(["sh","-c",cmd], cwd=<ws>)), the file really lands in the sibling directory and
not in the workspace, so the join above is a lie about where the shell is.
Cause
emrg/tools/bash_tool.py:_PUNCTUATION_CHARS = "();<>|&\\n"—{, }, ,are not punctuation, so{../outside,sub}` arrives as one word, and every walk treats it as a literal relative path.
Why the fix in PR #1392 does not cover it
#1392 (_move_destination_is_unresolved) closes the class for expansion text: any surviving
$ or backtick. A brace list is neither, so it stays ALLOW there — the same verdict as on master,
measured above rather than inferred. That PR strictly narrows the hole; this is the residual.
Platform note
dash (the usual Linux /bin/sh) does not brace-expand, so on those hosts the reading is
over-permissive but the shell agrees with it. The escape is real wherever /bin/sh is bash
(macOS), which is where the daemon runs commands on this host.
Suggested fix
Extend the unresolved class rather than adding a second rule: a lexeme carrying {, } and a
, is expansion text the walk cannot resolve, and the tier's rule for an unresolvable move is
already to fail closed (cd -, and #1392's $(mktemp -d)). The nested spelling has to be caught
too, so the predicate is "a {…} pair with a comma anywhere between" rather than a non-nested
regex.
Acceptance:
- BLOCK for
cd {../outside,sub} && cat > f, its nested form, and both spellings inside an
assignment (D={../outside,sub} && cd "$D" && cat > f— which emrg: a move no scope can place is refused, not read as inside #1392's class already catches); _cwd_left_workspacereports the token it could not place, the waycd -and emrg: a move no scope can place is refused, not read as inside #1392's
destination do, so the caller can see what to respell;- a mutation arm: narrowing the predicate back to non-nested/disjoint braces must turn the new rows
red; - pinned both ways — a literal path with a brace but no comma (a legitimate file name) keeps its
verdict, and the neighbouring suites (test_bash_tool_sandbox.py,
test_unresolved_wrapper_guard.py) stay green.
Test hygiene, per the 2026-09-17 ruling: the verdict rows use synthetic paths (the predicate is
textual and opens nothing); the one arm that executes the command builds its own tree under
tmp_path and is skipif on win32 (no POSIX shell to witness with).
Activity
argszero commentedon Sep 18, 2026
Fixed by #1397, with three deliberate widenings over what this issue asked for. Each is here
because it was measured, and one of them corrects an inference I made while writing the body.
1. The class is
,or..inside a brace pair, not,only.{1..3}is a sequence withno comma at all and expands the same way; a predicate written on the comma leaves it open. A word
carrying braces with neither (
a{b}c) is one literal word to the shell as well, so it keeps theverdict the join gives it — that is this issue's own acceptance item, pinned in the new file.
2. The target side is the same defect, so it is fixed with the same predicate. The write-target
walk reads the same lexeme as a literal relative name, i.e. inside. Measured on master
67ba7f52:rm -rf {../emrg-1396-outside/doomed,inside}tee {../emrg-1396-outside/written,inside}cat > {../emrg-1396-outside/t,inside}3. The correction. While writing #1397 I claimed in a comment (and in a first draft of its
test) that a redirect takes the first of the expanded words, i.e. that
cat > {…}/fwas a liveescape. That is wrong, and the measurement above is what says so: bash refuses a redirect whose
word expands to two words. The escape is real through the verbs that take several operands (
rm,tee, and their family), and the redirect spelling is refused anyway — for the walk the twospellings are one text, and a guard should not have to know which verb it was handed to. Both
facts are pinned in
tests/test_brace_expansion_is_not_a_path.py, the second as the shape thatdoes not escape.
The price this issue did not discuss is now stated in the docstring and pinned as a row: a list
whose every spelling stays inside is refused too (
rm {dist,build},cp a {b,c}), because whichspelling the command takes is exactly what the text does not say. Spelling them out keeps working.