emrg: parse write targets instead of scanning raw text (#1162) - #1168
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ 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.txtnamed the size0
as the file (an option's value is not an operand), andsed -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 andfind -deletefixes 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 makesbash_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.
|
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 First, what the PR gets right. I rebuilt the battery independently and measured both trees: Both claimed directions hold: the quoted- The regression
Five are strict regressions: master blocks them, this head allows them. Driven end-to-end through
A read-only session exists to protect uncommitted work; Root cause, and why it is not incidental. The head contains zero occurrences of It survives landing both PRsThis matters for ordering, so I measured the union rather than the branch. The two PRs touch the same three files; git conflicts on 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 targetswith Measured with that change applied to this head: Suggest also adding the nested cases to the test file — the head currently has zero rows mentioning Boundary, stated so it is not mistaken for closedThe interpreter vector ( |
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
left a comment
There was a problem hiding this comment.
✅ 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)", agh issue create --titlecontaining>,
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 chainedecho x > f.txt && rm a.txt— all block.
0/27 mismatches. Its own suite: 48 passed.
Design points I checked rather than assumed
- The
sedscript is correctly not treated as a path:_positional_args(...)[1:]
skips the script operand, sosed -i s/a/b/ f.txtnamesf.txt, nots/a/b/. _OPTIONS_WITH_VALUEexists for exactly the right reason —truncate -s 0 a.txt
would otherwise report the size0as the target and name nothing real._COMMAND_SEPARATORSis 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 keepsenforcement="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.
|
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: So the two halves now differ in principle, not just in a missing helper:
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 ( The suggested change in my earlier comment is unchanged and still applies verbatim to this head: union the nested command texts into 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
left a comment
There was a problem hiding this comment.
✅ 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.
…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.
|
This regression is now live on master, and the guard is active on every read-only session. #1168 merged at 17:50:32 ( Re-measured on master
|
| 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.
|
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: So the two trees fail in opposite directions:
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 The suggested union (master's token extractor + |
Fixes #1162 (and the class/guard split tracked in #1161).
The bug: one root cause, two opposite failures
_extract_write_targetsdecided what a command writes by regex-scanning rawtext. 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 quotedargument was read as a redirect:
Measured on master: 7 of 7 such read commands were blocked under
read-only. Thegh issue createcase is not hypothetical — it cost the hosta 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:
Measured on master: 7 of 7 allowed under
read-only. A read-only sessionexists to protect uncommitted work, and
rm a.txtdestroys it exactly ascompletely as
rm -rf dir.The change
Tokenize with
shlex(POSIX mode,punctuation_chars) and decide on the tokenstream, which already knows whether
>was an operator or a character inside anargument. Both directions are fixed by the same change.
Verb coverage is extended to the writers that were invisible:
rm/rmdironany operand (not only recursive),
cp/mvdestinations,sed -i,truncate,tee,shred, andfind ... -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.txtreported the size0as the file to be written.Dropping tokens that start with
-is not enough — an option's value is notan operand. Only that still blocked, but it named a token that is not a path.
sed -i s/a/b/ f.txtreported the scripts/a/b/.sed's first operandis the script; only the operands after it are rewritten.
Verification
re-measured on this tree (1528 → 1530).
extractor; the 2 tests added for the option-value and
find -deletefixeseach fail against a reverted implementation. All 8 are load-bearing.
test_check_read_only_blocks_git_mutatorsrequired the word
gitin the reason, butgit rm foo.pyis now blocked bythe write-target scan (which names
foo.py), so the assertion now acceptseither 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.pyauto-merging cleanly to a filewith two definitions of each, the second silently shadowing the first in the
gitguard's direction — git reports no conflict, so it is the #1138 class ofsilent 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 areindependent 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 mainblocked,rm a.txtblocked,git.exe commitblocked).