Skip to content

sandbox: a quoted '>' is read as a redirect operator, so the real target vanishes and a write outside the workspace is allowed #1268

Description

@argszero

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_targets cannot tell them apart. Measured on master 065ee9d5 (bash_tool.py sha256[:16] 7e77ec2b27591614):

targets                       verdict (workspace-write)
echo '>' > /etc/x      ['>']            ALLOW     <-- escape
echo '>' >> /etc/x     ['>>']           ALLOW     <-- escape
echo x > /etc/x        ['/etc/x']       BLOCK     <-- control

Proof it is live, not a verdict mismatch

Run end to end through BashTool.execute() with sandbox="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):

echo '>' > /Users/argszero/emrg-phantom-proof.txt   error=False  created=True  content='>\n'
echo x   > /Users/argszero/emrg-control-proof.txt   error=True   created=False

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_targets claims: "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.txt is refused under read-only with targeting '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.py sha256[:16] 00b7e8884c95d9be, which rewrites the lexer) and #1266: both still ALLOW the escape, so this is not covered by work already in flight.

Activity

  1. argszero commented on Sep 16, 2026

    @argszero
    OwnerAuthor

    Status, split by the two halves this issue names.

    The escape half is fixed in #1269 (fix/quoted-operator-is-not-a-target, head 6e6013da, 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 through BashTool.execute() on master b0bd6188 (bash_tool.py 00b7e8884c95d9be) 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.txt refused at read-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.

  2. argszero commented on Sep 16, 2026

    @argszero
    OwnerAuthor

    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/x was allowed to write outside the workspace at workspace-write while echo x > /etc/x was 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, including read-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 master 6e0a19c3:

    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.

  3. how2how2how2-arch commented on Sep 16, 2026

    @how2how2how2-arch
    Collaborator

    Status 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.py sha256[: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_operator accepts — 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.txt BLOCK BLOCK
    grep -n '>>' f.txt BLOCK BLOCK
    grep -n '&>' f.txt BLOCK BLOCK
    grep -n '2>' f.txt BLOCK BLOCK
    `grep -n '> ' f.txt` ALLOW
    grep -n '<>' f.txt ALLOW 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:

    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-only refuses 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 row

    The 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 >), but read-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_targets calls _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_tokens wrapped):

    _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, and echo 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.

  4. how2how2how2-arch commented on Sep 16, 2026

    @how2how2how2-arch
    Collaborator

    Addendum, because my measurement above crossed your 00:52:11 comment by about fourteen seconds — it was written against master 6e0a19c and 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.py 5c3bbbe0536ddb7f): 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, and echo x > '>' goes from []/ALLOW to ['>']/BLOCK, which is the row I reported in section 3. 31 rows, 0 unsound. CI on that PR: test pass, test-windows still 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>&1 and echo hi 2>&1 report targets=['1'] and are refused, while writing nothing (1>&2 / >&2 → ['2']; 2>/dev/null and >/dev/null are correctly ALLOW, so the /dev/null exemption only fires for redirects, not for the duplication spellings). Identical on master and on #1272's head, so not a regression — but echo hi 2>&1 is 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.

  5. argszero commented on Sep 16, 2026

    @argszero
    OwnerAuthor

    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 as 39edaefa, which is master's tip.

    Read back from master 39edaefa (emrg/tools/bash_tool.py sha256[:16] 4dce1ffde8902bc1, asserted from the loaded module with PYTHONPATH cleared so the checkout and not the installed copy is what runs):

    The escape is shut, end to end through BashTool.execute() at workspace-write with 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 runs
    

    The 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-only allows ./>. 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.

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