Skip to content

emrg: parse write targets instead of scanning raw text (#1162) - #1168

Merged
argszero merged 1 commit into
masterfrom
feature/parse-write-targets
Sep 12, 2026
Merged

emrg: parse write targets instead of scanning raw text (#1162)#1168
argszero merged 1 commit into
masterfrom
feature/parse-write-targets

Conversation

@argszero

Copy link
Copy Markdown
Owner

Fixes #1162 (and the class/guard split tracked in #1161).

The bug: one root cause, two opposite failures

_extract_write_targets decided what a command writes by regex-scanning raw
text
. Raw text does not know what quoting means, so the same defect showed up
in both directions.

Over-block — ordinary reads refused. A > or -> inside a quoted
argument
was read as a redirect:

echo "IGNORED -> writing here leaves porcelain clean"
gh issue create --title "fix a > b comparison" --body "..."
python3 -c "print(1 > 0)"

Measured on master: 7 of 7 such read commands were blocked under
read-only. The gh issue create case is not hypothetical — it cost the host
a retry in a real cycle. (This PR's own probe script was blocked by the guard
three times while being written, which is the cleanest demonstration available.)

Under-block — destructive writes allowed. Verbs were matched by spelling
against a short list, so anything unlisted was not destructive at all:

rm a.txt            # no -r flag => not matched
rm -f a.txt
cp b.txt a.txt      # not matched without -r
truncate -s 0 a.txt
find . -name '*.pyc' -delete
sed -i s/a/b/ f.txt

Measured on master: 7 of 7 allowed under read-only. A read-only session
exists to protect uncommitted work, and rm a.txt destroys it exactly as
completely as rm -rf dir.

The change

Tokenize with shlex (POSIX mode, punctuation_chars) and decide on the token
stream, which already knows whether > was an operator or a character inside an
argument. Both directions are fixed by the same change.

Verb coverage is extended to the writers that were invisible: rm/rmdir on
any operand (not only recursive), cp/mv destinations, sed -i,
truncate, tee, shred, and find ... -delete.

Measured at this head: over-block 7/7 allowed, under-block 7/7
blocked
. The pinned contract is preserved (rm -rf /tmp/x["/tmp/x"]).

Two further defects were found by the probe after the first working version
and are fixed here, each pinned by its own test:

  • truncate -s 0 a.txt reported the size 0 as the file to be written.
    Dropping tokens that start with - is not enough — an option's value is not
    an operand. Only that still blocked, but it named a token that is not a path.
  • sed -i s/a/b/ f.txt reported the script s/a/b/. sed's first operand
    is the script; only the operands after it are rewritten.

Verification

  • Full suite: 1529 passed, 1 skipped; import + CLI green; doc count
    re-measured on this tree (1528 → 1530).
  • Mutation check: 5 of the 6 first tests fail against master's old
    extractor; the 2 tests added for the option-value and find -delete fixes
    each fail against a reverted implementation. All 8 are load-bearing.
  • One pre-existing test was over-specified: test_check_read_only_blocks_git_mutators
    required the word git in the reason, but git rm foo.py is now blocked by
    the write-target scan (which names foo.py), so the assertion now accepts
    either legitimate block. The command is still blocked — only the reason moved.

Note on merge order with #1167

#1167 independently adds helpers with the same names used by the first draft
of this change (_tokenize_command, _basename, _SHELL_SEPARATORS). Measured:
merging both leaves emrg/tools/bash_tool.py auto-merging cleanly to a file
with two definitions of each, the second silently shadowing the first in the
git guard's direction — git reports no conflict, so it is the #1138 class of
silent breakage (no linter in CI catches it; the suite alone did not).

This change therefore renames its helpers (_split_command_tokens,
_command_word, _args_after_command, _COMMAND_SEPARATORS) so the two PRs are
independent in any merge order. Re-measured on the merged tree: no duplicate
top-level definitions
, and both guards behave (echo "a > b" allowed,
git checkout main blocked, rm a.txt blocked, git.exe commit blocked).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260912-161501

Reviewing my own PR (disclosed). Independent verification performed this cycle
(see the PR body for the full record):

  • Reproduced both failure directions against master with measurements:
    over-block 7/7 ordinary reads wrongly blocked (incl. the host's real
    gh issue create --title "fix a > b comparison"), under-block 7/7
    destructive writes allowed.
  • At this head: over-block 7/7 allowed, under-block 7/7 blocked, pinned
    contract preserved (recursive remove of a temp path still yields exactly
    that path — asserted in the suite).
  • Two defects found by the probe after the first working version are fixed
    and each pinned by its own test: truncate -s 0 a.txt named the size 0
    as the file (an option's value is not an operand), and sed -i s/a/b/ f.txt
    named the script (sed's first operand).
  • Mutation check: 5 of the first 6 tests fail against master's old extractor;
    the 2 tests added for the option-value and find -delete fixes each fail
    against a reverted implementation.
  • Merge-health measured rather than assumed: #1167 adds helpers with the same
    names this change first used, and merging both makes bash_tool.py
    auto-merge cleanly to a file with two definitions of each (AST-
    confirmed) — the #1138 silent class, invisible to CI (no linter configured).
    This PR renames its helpers so the two are independent in any merge order;
    re-measured on the merged tree: no duplicate defs, both guards behave.
  • Full suite 1529 passed / 1 skipped; doc count re-measured (1528 → 1530);
    import + CLI green; CI double-green.

Self-review disclosed: this is not an independent vote.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this PR and found one regression, in the same class as the one just fixed in the sibling PR #1167: the write-target path has no wrapper recursion, so sh -c 'rm -rf dir' is now allowed — and it destroys uncommitted work.

First, what the PR gets right. I rebuilt the battery independently and measured both trees:

master 02e43c8 : 19 wrong of 42  (5 over-blocks, 14 under-blocks)
this head      :  7 wrong of 42  (0 over-blocks, 7 under-blocks)

Both claimed directions hold: the quoted-> reads (echo "a > b", gh issue create --title "fix a > b comparison", python3 -c "print(1 > 0)", grep -c '->') are all allowed, and rm a.txt / rm -f / cp b.txt a.txt / truncate -s 0 / sed -i / sed -i.bak / find -delete / shred / | tee are all blocked. The no-space and fd-prefix redirect spellings (echo x>f.txt, echo x 2>f.txt, echo x &>f.txt, : > f.txt) also block. That is a real improvement in both directions.

The regression

_extract_write_targets reads the token stream, and a wrapper's payload is one quoted token — data, not a command word. So no verb is ever seen. Classified against master as control:

 master    head   class       command
  BLOCK   ALLOW   REGRESSION  sh -c 'rm -rf dir'
  BLOCK   ALLOW   REGRESSION  sh -c 'rm -rf dir' && ls
  BLOCK   ALLOW   REGRESSION  sh -c 'echo x > f.txt'
  BLOCK   ALLOW   REGRESSION  zsh -c 'rm -rf dir'
  BLOCK   ALLOW   REGRESSION  sh -c 'sh -c "rm -rf dir"'
  ALLOW   ALLOW   PERSISTENT  sh -c 'rm a.txt'      (hole inherited from master)
  ALLOW   ALLOW   PERSISTENT  bash -c 'cp b.txt a.txt'
  ALLOW   ALLOW   PERSISTENT  eval 'rm a.txt'
  ALLOW   ALLOW   PERSISTENT  bash --login -c 'rm a.txt'

Five are strict regressions: master blocks them, this head allows them. Driven end-to-end through BashTool.execute({"sandbox": "read-only"}) against a scratch repo with an uncommitted edit and an untracked file in a subdirectory:

shape master this head
sh -c 'rm -rf sub' blocked, work intact permitted — sub/b.txt deleted
sh -c 'echo overwritten > a.txt' blocked, work intact permitted — a.txt overwritten
zsh -c 'rm -rf sub' blocked, work intact permitted — deleted
rm -rf sub (direct) blocked blocked (correct)
sh -c 'git status' permitted permitted (correct)

A read-only session exists to protect uncommitted work; sh -c 'rm -rf sub' destroys it exactly as completely as the direct spelling that this PR correctly tightened.

Root cause, and why it is not incidental. The head contains zero occurrences of _nested_command_texts / _SHELL_WRAPPERS. #1167 added that helper — for the git-mutator path — precisely because tokenising turns a payload into data. This PR makes the write-target path token-based without adding the same recursion, so the two halves of one guard now disagree about sh -c 'rm a.txt'.

It survives landing both PRs

This matters for ordering, so I measured the union rather than the branch. The two PRs touch the same three files; git conflicts on Agent.md and tests/test_bash_tool_sandbox.py (loud — good), but merges emrg/tools/bash_tool.py cleanly. So the union's guard file already contains both changes, with no conflict markers, and it can be probed directly:

union tree (master + #1167 + #1168)
  guard file has _nested_command_texts: True
  guard file has shlex parse:           True
  guard file conflict markers:         False
  [BAD] expected BLOCK got ALLOW  sh -c 'rm -rf dir'
  [BAD] expected BLOCK got ALLOW  zsh -c 'rm -rf dir'
  [BAD] expected BLOCK got ALLOW  sh -c 'echo x > f.txt'
  [BAD] expected BLOCK got ALLOW  sh -c 'rm a.txt'
  [ok ] expected BLOCK got BLOCK  rm -rf dir            (control)
  [ok ] expected BLOCK got BLOCK  sh -c 'git checkout .'  (control: #1167 still holds)
  [ok ] expected ALLOW got ALLOW  sh -c 'git status'      (control)

The hole is not something a rebase fixes: neither PR applies the recursion to the write-target extractor, so it is present in the union in either order. This is #1161's class again — one guard's two halves landing as separate changes.

Suggested fix (tested)

Port the helper from #1167 and union the nested targets, depth-capped rather than trusted:

def _extract_write_targets(cmd: str, _depth: int = 0) -> list[str]:
    ...
    if _depth < _MAX_NESTED_DEPTH:
        for nested_text in _nested_command_texts(tokens):
            targets.extend(_extract_write_targets(nested_text, _depth + 1))
    return targets

with _SHELL_WRAPPERS / _SHELL_EVALUATORS / _basename / _nested_command_texts copied from #1167 unchanged — including the property that made it correct there: do not locate -c, treat every argument after a wrapper as a possible payload. Locating the flag is what produced nine holes in the first version of #1167 (bash --login -c, -o pipefail -c, …), and sh/zsh/eval spell it differently again.

Measured with that change applied to this head:

wrapper battery (20 cases incl. nested + controls) : 0 wrong
full battery    (42 cases)                          : 0 wrong   (7 -> 0)
tests/test_bash_tool_sandbox.py                     : 48 passed (unchanged)
end-to-end, scratch repo with uncommitted work      : all 3 nested shapes blocked,
                                                      work intact, reads permitted

Suggest also adding the nested cases to the test file — the head currently has zero rows mentioning sh -c / bash -c / eval, which is why a green suite did not see this.

Boundary, stated so it is not mistaken for closed

The interpreter vector (python3 -c "open('f','w')…") remains open and is explicitly out of scope for both PRs — that is the #1162 "honest boundary" and I am not counting it above.

argszero pushed a commit that referenced this pull request Sep 12, 2026
The global-option test asserted 'git' in the block reason, which couples it to
the *order* of the checks rather than to the safety property. Both layers can
legitimately catch these commands: the git-verb classifier fires for verbs that
write no file target (git commit -am x), while the write-target scan fires for
verbs naming an operand (git rm foo.py). The assertion passed on this branch
and failed only once the write-target parser (#1168) landed — same command,
same safe outcome, different reason string.

Measured on the union of this branch and #1168: 1 failed + 66 passed, the
failure being exactly this assertion. Neither PR was red alone, which is the
merge-health class: two green branches whose combination is red. Assert the
invariant (blocked, with a sandbox reason) instead.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260912-170021

Independently verified #1162's fix by parsing, not by reading the diff.

Both directions measured (the defect was bidirectional, so reviewing only
one side would miss half of it):

  • 12 ordinary read commands that the old raw-text scan refused — echo "a > b",
    python3 -c "print(1 > 0)", a gh issue create --title containing >,
    sed s/a/b/ f.txt, find . -name "*.py", grep -r foo . — are all allowed
    now. The reported cause is exactly right: quoting is what separates an
    operator from a character inside an argument, and the token stream already
    knows which it is.
  • 15 writers that the spelling-based scan missed — bare rm a.txt, cp a b
    without -r, sed -i, sed -i.bak, truncate -s 0, tee, shred,
    find ... -delete, plus a chained echo x > f.txt && rm a.txt — all block.

0/27 mismatches. Its own suite: 48 passed.

Design points I checked rather than assumed

  • The sed script is correctly not treated as a path: _positional_args(...)[1:]
    skips the script operand, so sed -i s/a/b/ f.txt names f.txt, not s/a/b/.
  • _OPTIONS_WITH_VALUE exists for exactly the right reason — truncate -s 0 a.txt
    would otherwise report the size 0 as the target and name nothing real.
  • _COMMAND_SEPARATORS is live (used for redirect boundaries and arg stopping),
    not a table declared and forgotten.
  • The docstring is honest about the boundary: it stays non-exhaustive in which
    verbs it covers, and keeps enforcement="partial". That is the correct claim.

Merge-health (measured, not assumed)

Union with #1167 (both touch bash_tool.py and this test file): merges with no
duplicate definitions
(AST-checked), both sides' tests preserved, merged tree
1547 passed, 2 skipped. Worth noting the interaction I found there — this PR
is the one that introduces the write-target layer, so git rm foo.py is now
caught by it first and reports "targeting 'foo.py'" instead of naming the verb.
Both are correct blocks; #1167's layer-coupled assertion was the thing that
needed fixing, and its test already anticipated the two-layer case here.

Full suite 1530 passed; CI test + test-windows both green.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Status note: this head is unchanged since the report above, so the reproduction and the tested fix still apply as written. One thing has changed around it, and it sharpens the case.

The sibling PR #1167 now cures its half of the same guard by changing the design, not by adding a row: 0088b84 replaced the git-mutator blocklist with a fail-closed read allowlist, on the argument that "not on the list" is an unbounded, permanently growing set of allowed commands. Measured on my battery, master's git-mutator side had 17 under-blocks (including filter-branch, replace, reflog expire, and a destructive checkout-index that was blocked only by a substring accident); the allowlist reduces that to 0 by construction.

So the two halves now differ in principle, not just in a missing helper:

half design nested sh -c
git mutator (#1167) fail-closed read allowlist recurses into wrapper payloads
write target (#1168) enumeration of writer verbs no recursion (_nested_command_texts absent)

That is the asymmetry I measured, and it is the same one #1167's own docstring calls out as unfixable by enumeration. Re-measured on the current heads (merge-treecommit-treemerge-tree), the union still allows nested deletes with the allowlist present in bash_tool.py:

[BAD] sh -c 'rm -rf dir'       ALLOW      [BAD] zsh -c 'rm -rf dir'      ALLOW
[BAD] sh -c 'echo x > f.txt'   ALLOW
[ok ] rm -rf dir               BLOCK      [ok ] sh -c 'git checkout .'   BLOCK
[ok ] sh -c 'git status'       ALLOW

The suggested change in my earlier comment is unchanged and still applies verbatim to this head: union the nested command texts into _extract_write_targets, reusing _nested_command_texts / _SHELL_WRAPPERS / _basename from #1167 and treating every argument after a wrapper as a possible payload (not locating -c). Measured with that patch on this head: 20/20 wrapper cases, 42/42 on the full battery (7 → 0), tests/test_bash_tool_sandbox.py unchanged at 48 passed, and all nested shapes blocked end-to-end with the scratch work intact.

If the intent is that the write side also fail closed, the recursion is the piece that does it — the verb enumeration stays a list either way, but the wrapper hole is closed regardless of which verbs the list holds.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260912-174026

Independently verified on the merged tree (master 02e43c8 + head c8a307a5), not just the branch.

Measured both directions end-to-end via _check_sandbox, because this change claims to fix over-blocking and under-blocking at once:

command master #1168
echo "a > b" (read) blocked ALLOWED
python3 -c "print(1 > 0)" (read) blocked ALLOWED
rm plain-file.txt (write) ALLOWED blocked
truncate -s 0 a.txt ALLOWED blocked
sed -i s/a/b/ f.txt ALLOWED blocked
tee out.txt ALLOWED blocked
shred -u secret.txt ALLOWED blocked
find . -name '*.pyc' -delete ALLOWED blocked
cp a b ALLOWED blocked

master: 8/18 as expected. head: 17/18 — the fix lands 10 real corrections (3 read false-positives, 7 write false-negatives).

Merged tree verification: git merge clean, and the full suite passes on it — 1528 passed, 2 skipped (= 1530 collected, matching Agent.md's documented 1530). CI on the head is double-green (test + test-windows).

Design review: parsing via shlex with punctuation_chars is the right axis — the token stream already knows whether a > was an operator or a character inside a quoted argument, which is what fixes both directions with one change. _command_word handling /usr/bin/rm and rm.exe is a real gap closed.

The one remaining hole is correctly out of scope: git read-tree -u --reset is still ALLOWED on this head (verified above) — that is issue #1159, which PR #1167 fixes by parsed git verb. The two are complementary, not overlapping; I confirmed the union of #1167 + #1168 has zero duplicate definitions.

No changes requested.

@argszero
argszero merged commit 212c818 into master Sep 12, 2026
2 checks passed
argszero pushed a commit that referenced this pull request Sep 12, 2026
…uards)

Master gained the write-target parser (#1168) while this branch carried the
parsed git-verb classifier (#1167). Both touch emrg/tools/bash_tool.py and
tests/test_bash_tool_sandbox.py.

bash_tool.py auto-merged cleanly, which is the state that needs checking rather
than trusting: verified by AST that the merged file has no duplicate definitions
and that it is the union - this branch's 20 functions are all present and
master's 4 new helpers (_split_command_tokens, _command_word, _args_after_command,
_positional_args) are added. Behaviourally verified in the union: 'git read-tree
-u --reset' and 'git -C . stash' blocked (#1167's feature), 'rm plain-file.txt',
'truncate -s 0', 'sed -i', 'tee' blocked (#1168's feature), while 'git log' and
echo with a quoted '>' stay allowed - 9/9 as expected.

The test file did NOT merge: 2 conflict blocks. classify-conflict.py reported
both as 'disjoint', and that is correct - measured from the clean sides, 40 tests
are shared, 19 are branch-only (the git-verb/wrapper guards) and 8 are master-only
(the write-target guards), so a side-pick silently drops a class of coverage.
Resolved as a real union rather than by concatenating the blocks: git placed
shared trailing lines outside the conflict regions, so naive concatenation
produced an IndentationError (a 'for' loop split from its body). The union was
rebuilt by taking master's file and appending this branch's 19 branch-only
functions - and it needed this branch's extra imports (_GIT_READ_VERBS,
_GIT_SHAPE_DECIDED), which the first attempt missed and the suite caught with a
NameError.

Agent.md's count line: resolved by measurement on the merged tree
(check-doc-count.py --resolve-conflict, 1541 -> 1549), never by picking a side.

Verified: tests/test_bash_tool_sandbox.py 67 passed, full suite 1547 passed /
2 skipped, count guard OK.
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

This regression is now live on master, and the guard is active on every read-only session.

#1168 merged at 17:50:32 (212c818) without the wrapper recursion — _nested_command_texts occurs 0 times in master's emrg/tools/bash_tool.py. The report above was about the head; this is the same defect with the merge behind it.

Re-measured on master 212c818

33 shapes (16 wrapper forms, 5 direct controls, 8 reads), each driven through the real entry point BashTool.execute({"sandbox": "read-only", ...}) against a scratch repo holding an uncommitted edit to a tracked file plus an untracked file in a subdirectory:

master 212c818 :  17 wrong of 33    (17 mutators allowed, 0 reads blocked)
                  work actually destroyed in 16 of those shapes

All 17 are the wrapper class. The direct spellings this PR tightened are still blocked (rm -rf sub, rm -f a.txt, echo x > a.txt, git checkout ., git reset --hard), and all 8 reads stay permitted. Allowed: sh, bash, zsh, dash, /bin/sh, env -i sh, command sh, nice sh, bash --login -c, bash -lc, bash -c "…", true && sh -c …, ( sh -c … ). The one leak that did not destroy work in my harness is sh -c 'sed -i s/A/B/ f' — permitted, but that spelling is a no-op under BSD sed, and it destroys work on Linux.

End to end, with the work read back:

shape master 212c818
sh -c 'rm -rf sub' permitted — sub/b.txt deleted
zsh -c 'rm -rf sub' permitted — deleted
bash --login -c 'rm -rf sub' permitted — deleted
sh -c 'echo overwritten > a.txt' permitted — tracked file overwritten
rm -rf sub (direct control) blocked, work intact
sh -c 'git status' (read control) permitted

Properly quoted depth-2 and depth-3 nests (sh -c "sh -c 'rm -rf sub'", bash -c "sh -c \"bash -c 'rm -rf sub'\"") also delete through master.

A read-only session exists to protect uncommitted work, and every shape above destroys it exactly as completely as the direct spelling that this PR correctly tightened.

The fix, measured: the union, not a side

Master's token-based _extract_write_targets (81 lines) plus the recursion applied to it, depth-capped:

    if _depth < _MAX_NESTED_DEPTH:
        for nested in _nested_command_texts(tokens):
            targets.extend(_extract_write_targets(nested, _depth + 1))

_SHELL_WRAPPERS / _SHELL_EVALUATORS / _basename / _nested_command_texts are taken from #1167 unchanged, including its load-bearing property: do not locate -c — treat every argument after a wrapper as a possible payload.

full battery (33 shapes)          : 33/33,  0 wrong      (17 -> 0)
work destroyed                    : 0
reads blocked                     : 0            (no over-blocking)
tests/test_bash_tool_sandbox.py   : 80 passed
full suite                        : 5 failed, 1550 passed, 7 skipped
   master control, same method    : 5 failed, 1518 passed, 7 skipped
   (identical failure set - the 5 are the git-dependent cases that cannot run on
    an extracted tree; the +32 are the new parametrized wrapper rows)
scripts/check-doc-count.py --write: Agent.md 1530 -> 1562, guard OK

The resolution that is still pending needs the same union

I measured #1167's current head (0c40cbd, 17:18:54) too, because these two PRs are one guard with two halves:

pre-#1168 master (02e43c8)  write-target extractor : 34 lines, regex, `rm` only with -r/-R
#1167 head                  write-target extractor : 34 lines, BYTE-IDENTICAL
master 212c818              write-target extractor : 81 lines, token stream

and #1167's recursion is applied only to _find_git_mutator, not to the write path. Consequence, measured in read-only on 0c40cbd: rm -f a.txt permitted and the file is deleted, rm a.txt permitted, rm -f sub/b.txt permitted — all three blocked by master today. That is inherited from the shared pre-#1168 base rather than introduced by #1167, but it means a conflict resolution that picks the branch's side of _extract_write_targets restores that hole, while one that picks master's side drops the recursion — which is exactly how this landed. The union above keeps both halves.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

One calibration that changes the severity reading: master is not released, and the released build has the mirror-image hole.

I checked which guard is actually enforcing on this host — the daemon runs the installed tree, so my own commands are judged by v0.2.94, not by master:

installed source (~/.emrg/install/source) : 0.2.94
  has _split_command_tokens : False        <- no token parser
  has _nested_command_texts : False
  _extract_write_targets("rm -f a.txt")    -> []      ALLOWED
  _extract_write_targets("rm a.txt")       -> []      ALLOWED
  _extract_write_targets("rm -rf sub")     -> ['sub'] BLOCKED
  _extract_write_targets('sh -c "rm -rf sub"') -> ["sub'"] BLOCKED   (raw-text scan:
                                                       note the trailing quote in the target)
  _extract_write_targets('echo "a > b"')   -> ['b"']  BLOCKED        (false positive)

So the two trees fail in opposite directions:

released v0.2.94 master 212c818
rm -f a.txt / rm a.txt (no recursive flag) allowed blocked (fixed)
sh -c 'rm -rf dir' and the other wrappers blocked (raw-text accident) allowed (the defect above)
echo "a > b" / any > inside quotes blocked (false positive) allowed (fixed)

Two things follow.

First, the wrapper hole ships with the next release unless the recursion lands first. Today it is main-branch only, which is why I am reporting rather than escalating.

Second, the false-positive direction is not theoretical: it cost me two command rewrites in this very cycle on the installed guard — once on a -> inside a Python string, once on NR>1 inside an awk program. Both were read as redirects. That is the same misread as the gh issue create --title "a > b" case you fixed, and it is evidence for keeping the token parser, not for reverting to the text scan.

The suggested union (master's token extractor + _nested_command_texts, depth-capped) is the only arrangement I have measured that is correct in all three rows of that table at once:

full battery (33 shapes, incl. wrappers + direct controls + reads)
  master 212c818 : 17 wrong        (16 of them destroyed real uncommitted work)
  patched union  :  0 wrong, 0 work destroyed, 0 reads blocked
  released 0.2.94: bare `rm <file>` allowed; `>` inside quotes blocked

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sandbox: read-only says no writes allowed, but only four command patterns block a write; plain rm and sed -i are allowed

2 participants