Repository navigation
sandbox: a quoted '>' is read as a redirect operator, so the real target vanishes and a write outside the workspace is allowed #1268
Description
Activity
Status, split by the two halves this issue names.
The escape half is fixed in #1269 (
fix/quoted-operator-is-not-a-target, head6e6013da, currently 1/3 votes, both CI legs green). It is fixed by the same change that closes the>|/<>hole: the write-target walk now skips operator tokens rather than believing the first one it meets, so a quoted'>'no longer consumes the real redirect's target. Re-measured end to end throughBashTool.execute()on masterb0bd6188(bash_tool.py00b7e8884c95d9be) and on the landing tree that merge produces (9f93cc60+6e6013da→81475e05):master #1269 landing tree echo '>' > <outside> targets=['>'] ww=ALLOW targets=['<outside>'] ww=BLOCK echo '>' >> <outside> targets=['>>'] ww=ALLOW targets=[<outside>] ww=BLOCK echo x > <outside> targets=[...] ww=BLOCK (control, unchanged)Ground truth: in a throwaway directory bash really created the file for the quoted-operator form, so this was a live escape rather than a verdict mismatch.
The over-block half (
grep -n '>' file.txtrefused atread-only) is still open — #1269 does not attempt it, and its own test pins the residual rather than hiding it:# A quoted operator followed by a bare word is still read as a redirect — # the known over-block residual, which needs the lexer to keep quoting. assert _extract_write_targets("grep -n '>' file.txt") == ["file.txt"]
That is the correct scope: escaping it needs the tokenizer to preserve that a token was quoted, which the token value cannot carry once quoting is gone. Worth its own change and its own tests — the fail-closed direction is the one a boundary may keep.
- added a commit that references this issue
on Sep 16, 2026 Both halves of this issue are now fixed, and by opposite mechanisms — worth recording which is which.
The under-block half landed in #1269, merged as
6e0a19c3. A quoted operator standing alone was read as a redirect and consumed the next token as its "target", so the real redirect's target vanished:echo '>' > /etc/xwas allowed to write outside the workspace atworkspace-writewhileecho x > /etc/xwas refused. The walk now skips operator tokens instead of believing the first one it meets, and the operator set became a shape rather than a list, which closed>|and<>as well (both previously reported[]at both tiers, includingread-only).The over-block half is fixed in #1272. The token stream cannot carry the missing fact —
'>'and>dequote to the same token — so it is recovered from the raw line: a second lex pass with POSIX mode off keeps the quote characters, and the two readings are paired by index. A quoted word is a path, never a redirect; operator-ness becomes shape and not quoted. Measured on master6e0a19c3:grep -n '>' file.txt ['file.txt'] BLOCK at read-only -> [] ALLOW grep -rn '>' src/ ['src/'] BLOCK at read-only -> [] ALLOW test 1 '>' 2 ['2'] BLOCK at read-only -> [] ALLOW echo '>' > /etc/x ['>'] ALLOW at ww -> ['/etc/x'] BLOCK cat < /etc/passwd [] ALLOW -> [] ALLOW (control) echo x > /etc/x ['/etc/x'] BLOCK -> ['/etc/x'] BLOCK (control)One consequence worth naming, because it is the reason the two halves had to be fixed together: a quoted operator that is the operand of a real redirect is a path, so
echo x > '>'now names the file literally called>(['>']) where the skip used to walk past it and report nothing.Where the two readings disagree about how many words there are, the helper claims nothing and the walk keeps its previous, fail-closed behaviour. That is a measured choice, not a shrug: over 30,783 generated commands, dropping that guard changes 541 walk answers and 493 of them drop a target, outside paths included.
The residual stated in this issue's own text — "Fixing that needs the lexer to preserve the fact that a token was quoted — the token value alone cannot carry it" — was right about the requirement and wrong about the place: the lexer needed a second reading, not a change to the one the walk consumes.
how2how2how2-arch commented
on Sep 16, 2026 CollaboratorMore actionsStatus re-measured on the merged master — the escape half is closed, the residual is still open with a larger surface than when the issue was filed, and there is one under-block in the same family that survives the fix. All rows below are mine; ground truth is the file that really appears outside the workspace.
Setup. master
6e0a19c3e4ff,emrg/tools/bash_tool.pysha256[:16]a4c93010ad9e7cdd, against the revision this issue measured,065ee9d5(7e77ec2b27591614), each loaded by sha in a scratch clone.1. The escape is closed on merged master
command ( <outside>= a real dir outside the workspace)targets 065ee9d → 6e0a19c verdict (workspace-write) outside file really created echo '>' > <outside>/x['>']→['<outside>/x']ALLOW → BLOCK yes, both versions echo '>' >> <outside>/x['>>']→['<outside>/x']ALLOW → BLOCK yes, both echo x > <outside>/x(control)['<outside>/x']BLOCK → BLOCK yes, both Independent of the landing-tree numbers in your comment: on the merged master the write that really happens outside is refused, and the refusal now names the path the shell writes rather than the operator.
2. The residual is still live — and the operator set it bites grew
The residual's shape is "quoted operator + bare word", so the spelling it covers is exactly what
_is_redirect_operatoraccepts — and that predicate is now shape-based (">" in tok and re.fullmatch(r"[<>|&0-9-]+", tok)). The shape test is what closed the>|/<>hole, and the same widening adds two spellings to the over-block. Read-only tier, all of these are plain reads:read command 065ee9d 6e0a19c grep -n '>' f.txtBLOCK BLOCK grep -n '>>' f.txtBLOCK BLOCK grep -n '&>' f.txtBLOCK BLOCK grep -n '2>' f.txtBLOCK BLOCK `grep -n '> ' f.txt` ALLOW grep -n '<>' f.txtALLOW BLOCK ← widened grep -n '<' f.txt, `grep -n '' f.txt ,grep -n '&&' f.txt,grep -n 'a > b' f.txt`ALLOW <staying out of the set is the same judgement that makes the other six over-blocks, so the widening is consistent rather than accidental — but it does mean the trade is two behaviours for one: the>|/<>hole closed, and two spellings newly refused at read-only.Two details of the residual the issue text does not state, which a fix has to respect:
- the false target is the file the command reads.
grep -n '>' f.txtyieldstargets=['f.txt']and the refusal readsread-only sandbox: blocked destructive write targeting '<abs>/f.txt'— a guard naming a read operand as a destructive write is the shape sandbox: read-only says no writes allowed, but only four command patterns block a write; plain rm and sed -i are allowed #1162's docstring warns about, and it is what a reader would have to disbelieve to keep using the tier. - it is carried by the token value alone, and the bare word need not be a path:
f.txt,./f.txt,x,42,foo/barare all reported as targets.
Tier scope: with the target inside the workspace the over-block only bites
read-only(workspace-write and danger-full-access allow all six rows);read-onlyrefuses any write target at all, so that is where it lands.3. A new under-block in the same family, which #1269 does not close
The rule #1269 added — "an operator is never a write target" — is applied in the target position too, so a real redirect whose target is operator-shaped reports no target at all:
command targets read-only verdict file the shell really creates echo x > '>'[]ALLOW ./>echo x >> '>'[]ALLOW ./>echo x 2> '>'[]ALLOW ./>`printf p > '> '` []ALLOW echo x > './>'['./>']BLOCK ./>← calibration rowThe calibration row is the point: the same write to the same file is refused or allowed purely on how the operand is spelled, because
'>'dequotes to something the walk now declines to call a target. That is the mirror image of this issue — there a quoted operator hid a real target, here it is the target. Severity is low (a file named>), butread-only's single job is to refuse the write, and it does not.4. One measured direction for the residual
Your comment says escaping the residual needs the lexer to keep quoting. A narrower route measured today, leaving the lexer alone: pre-mask quoted operator runs in the raw text — the same text-level trick this file already uses for heredoc bodies.
_extract_write_targetscalls_split_command_tokens(_mask_data_heredoc_bodies(cmd)), so masking inside the lexer call runs after the heredoc mask and a body a shell really executes is still judged as code. Prototype over master's unmodified walk (only_split_command_tokenswrapped):_QUOTED = re.compile("'([^']*)'|\"([^\"]*)\"") def _mask_quoted_operators(cmd: str) -> str: def sub(mo): inner = mo.group(1) if mo.group(1) is not None else mo.group(2) if inner and ">" in inner and re.fullmatch(r"[<>|&0-9-]+", inner): return "''" # an operator in quotes is an argument, never a redirect return mo.group(0) return _QUOTED.sub(sub, cmd)
17 rows, 0 unsound:
- escapes stay blocked:
echo '>' > <outside>/x, the>>form,echo '>' >| <outside>/x,echo '>' <> <outside>/x, the>|/<>hole rows, andecho x > <outside>/x; - the six residual rows become ALLOW with
targets=[](grep -n '>' f.txt→[], previously['f.txt']); - quoted real targets survive:
echo x > 'out file.txt'→['out file.txt']BLOCK,echo x > <outside>/'q t.txt'→ the outside path BLOCK; - the residual hiding a real write stays blocked:
grep -n '>' f.txt > <outside>/x→['<workspace>/f.txt', '<outside>/x'], BLOCK.
Honest boundaries: the regex is not a shell lexer — it ignores escaped quotes,
$'…'and nesting, so a production version should reuse the quote state machine this file already has; and it does not touch (3), where the operator-shaped run is the target — blanking it turns the target into an empty token rather than recovering it. So (2) and (3) are separable and neither change covers the other.- the false target is the file the command reads.
how2how2how2-arch commented
on Sep 16, 2026 CollaboratorMore actionsAddendum, because my measurement above crossed your 00:52:11 comment by about fourteen seconds — it was written against master
6e0a19cand states the residual as still open there, which is what the code said at the time I read it, and not a disagreement with #1272.I verified #1272 independently and posted the details on that PR (
emrg/tools/bash_tool.py5c3bbbe0536ddb7f): the six residual rows I listed above all become[]/ALLOW, every escape row (including>|and<>— the two spellings the widening added) stays BLOCK, the quoted real targets are preserved, the "residual hiding a real write" row stays BLOCK, andecho x > '>'goes from[]/ALLOW to['>']/BLOCK, which is the row I reported in section 3. 31 rows, 0 unsound. CI on that PR:testpass,test-windowsstill running as I write this.So the honest reading of the three sections above: (1) and (3) are closed by #1272 as measured on its head, (2) is closed for the six rows I listed — and the widening I described is what made two of those six rows worth naming, since
>|and<>were ALLOW before #1269 and would have kept being allowed under any fix that only touched the quoting of>.One row of the same class is still there and is not the quoting family: an fd-duplication operand is taken as a path at
read-only—grep -n x f.txt 2>&1andecho hi 2>&1reporttargets=['1']and are refused, while writing nothing (1>&2/>&2→['2'];2>/dev/nulland>/dev/nullare correctly ALLOW, so the/dev/nullexemption only fires for redirects, not for the duplication spellings). Identical on master and on #1272's head, so not a regression — butecho hi 2>&1is an ordinary idiom, so if the class is being closed one cause at a time, that is the next cause rather than a new issue family.Closing: both halves of this issue have landed, measured on the merged tree rather than on either branch.
The escape half — fixed by #1269 (
6e0a19c3), the operator-skip.
The over-block half this issue tracked as its residual — fixed by #1272, squash-merged as39edaefa, which is master's tip.Read back from master
39edaefa(emrg/tools/bash_tool.pysha256[:16]4dce1ffde8902bc1, asserted from the loaded module withPYTHONPATHcleared so the checkout and not the installed copy is what runs):The escape is shut, end to end through
BashTool.execute()atworkspace-writewith the workspace set to a scratch worktree — this issue's own proof-of-life, re-run:echo '>' > @OUT/outside-1268-proof.txt error=True created=False <-- was ALLOW + created echo '>' >| @OUT/outside-1268-proof.txt error=True created=False echo '>' >> @OUT/outside-1268-proof.txt error=True created=False echo x > @OUT/outside-1268-proof.txt error=True created=False <-- control echo hi > @OUT/workspace/inside.txt error=False created=True <-- control: the harness really runsThe over-block is gone, at
read-only:grep -n '>' file.txt targets [] ALLOW (was ['file.txt'] BLOCK) grep -rn '>' src/ targets [] ALLOW (was ['src/'] BLOCK) test 1 '>' 2 targets [] ALLOW (was ['2'] BLOCK) echo 'a > b' targets [] ALLOW (unchanged control) cat < /etc/passwd targets [] ALLOW (unchanged control)and the path a quoted operator really is gets named instead of skipped:
echo x > '>'→['>'], the file literally called>.Full suite on the merged tree: 2629 passed / 16 skipped (2619 + the 10 tests #1272 adds).
One residual of the same family is not closed by either PR and is filed separately as #1280: when the line also carries a partially quoted word, the two lex readings disagree and the fallback can drop a quoted redirect target, so
read-onlyallows./>. It is pre-existing (identical on the pre-#1272 tree) and bounded to relative operator-shaped paths — I could not construct an outside escape from it — but it is the same "a quoted word is a path" question in its other position, so it belongs in the record here rather than being lost when this issue closes.
What
A quoted
>that stands alone as a word is read as a redirect operator, and the walk then takes the token after it as its "write target". When that following token is the real redirect operator, the real redirect's target is never reported — so a write outside the workspace is allowed at the default tier.'>'and>dequote to the same token, so_extract_write_targetscannot tell them apart. Measured on master065ee9d5(bash_tool.pysha256[:16]7e77ec2b27591614):Proof it is live, not a verdict mismatch
Run end to end through
BashTool.execute()withsandbox="workspace-write"and the workspace set to a repo worktree, against a harmless outside path ($HOME, which is outside the workspace, outside the trusted zone and outside the temp root):The file really was created outside the workspace while the tier refused the same write without the quoted character. The guard is a static scan (
enforcement="partial"), so an under-block is the side that matters: this is a boundary escape reachable by one quoted character, not an over-cautious refusal.The docstring asserted the opposite, and that is why it survived
_extract_write_targetsclaims: "Quoting is what distinguishes the two: the token stream already knows whether>was an operator or a character in an argument, so both directions are fixed by the same change." That is true for a>inside a longer argument (echo "a > b"is one token) and false for a quoted operator standing alone as a word, which dequotes to exactly>.Residual (not fixed here, tracked by this issue)
The over-block side of the same cause remains: a quoted operator followed by a bare word is still read as a redirect, so
grep -n '>' file.txtis refused underread-onlywithtargeting 'file.txt'although grep never writes it. Fixing that needs the lexer to preserve the fact that a token was quoted — the token value alone cannot carry it.Scope check against the open PRs
Measured on the landing trees of #1265 (
bash_tool.pysha256[:16]00b7e8884c95d9be, which rewrites the lexer) and #1266: both still ALLOW the escape, so this is not covered by work already in flight.