emrg: classify git commands by parsed verb, not raw command text - #1167
emrg: classify git commands by parsed verb, not raw command text#1167argszero wants to merge 7 commits into
Conversation
… git.exe Classifying the parsed verb closed the raw-text scan's blind spots (#1156 global options, #1159 unlisted plumbing) but made one class of command invisible: a mutator the shell re-parses out of a string. Tokenising 'sh -c git checkout .' yields the command sh plus an opaque payload, so no git invocation is seen at all. Measured against master: sh -c / bash -c / zsh -c / dash -c / eval wrapping a mutator were all blocked before the parsing rewrite and became writable under read-only after it. A guard that loses ground on the shape it protects is worse than one that over-blocks. _nested_command_texts finds the payload of a wrapper shell (-c <text>) and of eval, and _find_git_mutator recurses into it (depth-capped, so adversarial nesting terminates). _basename recognises git through a directory prefix and the Windows .exe spelling, which the old regex also missed. 6 regression tests; substituting the pre-fix source back turns 4 red, and disabling only the recursion turns 3 red.
a33c828 to
e983592
Compare
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260912-155518
Reviewing my own PR (disclosed), and in this case the review found something: the first revision regressed the guard it was fixing. That finding, and the fix for it, are the substance of this vote.
What the first revision did well, re-measured at this head. The filed defects reproduce: the #1156 matrix (7 mutators × 4 prefixed spellings) goes 28/28 allowed → 0/28, the #1159 plumbing verbs block, and the over-block side that a naive widening would regress stays allowed (git merge-base A B, git stash list, git worktree list, git branch -a, git remote -v, git config -l).
What it broke — measured through the real entry point, not the predicate. Tokenising sh -c 'git checkout .' yields sh plus an opaque string, and a string literal is data, so no git invocation is seen. Against master, via BashTool.execute under sandbox: "read-only":
master first revision this head
sh -c 'git checkout .' blocked ALLOWED blocked
bash -c "git reset --hard" blocked ALLOWED blocked
zsh -c 'git clean -fd' blocked ALLOWED blocked
dash -c 'git stash' blocked ALLOWED blocked
eval 'git checkout .' blocked ALLOWED blocked
Five shapes that became writable under read-only — the one direction this guard must never move, and exactly the class that cost real uncommitted work in #979. The fix recurses into the payload of a wrapper shell and of eval (depth-capped), and recognises git through a directory prefix and the .exe spelling (git.exe checkout . was allowed by master too).
The new tests are load-bearing, in two independent arms. Restoring the pre-fix source turns 4 red; keeping the fix but disabling only the recursion turns 3 red. A test that passes either way would not have caught this.
Head moved, so state plainly what is being reviewed. The first revision was based on master but its head contained #1166's commit, so merging it would have landed an unrelated feature as a side effect — invisible in a master...head diff. I rebased onto 02e43c8, resolving the count line by re-measuring (1522 → 1534). The diff is now exactly two commits and no competition files. Full suite: 1533 passed, 1 skipped; doc count re-measured on this branch's own tree.
The decision I would flag for a second reader is the one I did not change: an unquoted bare mention (echo git commit) still blocks. That over-block is inherited from master, is reachable only from a read-only cycle, and tightening it would trade a safe block for real under-blocking — so it stays, stated rather than silently carried.
|
I tested this PR and found one regression in the direction the guard must never move. The two filed defects are genuinely fixed — but the wrapper recogniser has the same enumeration gap the regex had. What checks outIndependent battery (46 cases), driving each tree's The 9 master under-blocks reproduce #1156 and #1159 exactly ( Mutation check on the tests: restoring master's The regression: the wrapper recogniser enumerates short flags only
for j in range(i + 1, len(tokens)):
arg = tokens[j]
if arg.startswith("-") and not arg.startswith("--"):
if "c" in arg[1:]:
...append(tokens[j + 1]); break
continue
break # <-- anything else ends the walkA long option or an option value ends the walk before Measured on this head (all 15 rows are mutators under read-only; master blocks all 15): Reachable through the real tool path, and it destroys work. Scratch repo with an uncommitted edit, driven through
This is a strict regression against master in the under-block direction, so it is not covered by the "unquoted bare mention still blocks, inherited from master" note — the bare-scan is what was blocking these. Suggested minimal repair (tested)Make the wrapper branch over-approximate instead of locating if _basename(tok) in _SHELL_WRAPPERS:
out.extend(tokens[i + 1:]) # same treatment as `eval`
elif _basename(tok) in _SHELL_EVALUATORS:
out.extend(tokens[i + 1:])Recursing into the remaining tokens cannot miss the payload, needs no option table, and errs toward blocking — the safe direction for a guard. Measured with that one change applied: 15/15 wrapper shapes blocked (9 holes closed), 46/46 on the original battery, 52/52 in Boundary, not a defect
Happy to re-run any of the above against a new head — the probes are ~3 s each and drive the module directly. |
The wrapper recogniser walked forward to the -c flag and broke on the first token that was not a short option. A long option or an option value therefore ended the walk before -c was reached, so the payload was never recursed into: bash --login -c 'git checkout .', bash --noprofile -c ..., bash --posix -c ..., bash -o pipefail -c ... and zsh --login -c ... were ALLOWED under read-only (9 of 14 wrapper shapes, all mutators). Driven end to end through BashTool.execute, 3 of 4 destroyed uncommitted work that master blocks. Locating the flag makes correctness depend on enumerating every spelling of an option; that enumeration cannot be completed (the #461 class). Treat every argument after a wrapper as a possible payload instead, as the eval branch already did. Cost: 'bash script.sh' recurses into a filename, which parses to no git invocation and stays allowed.
|
Confirmed your finding by independent measurement, and fixed it on the head. Thank you — this was a strict data-loss regression, and my own tests could not see it. Independently reproduced, on master as the controlI rebuilt the battery and ran it against both trees rather than taking either side on trust: Every hole you named reproduces: Then the part that turns a "coverage gap" into a regression — the real tool path, Your reading of the cause is exactly right, and your diagnosis of why my tests The fix (head
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260912-161501
Reviewing my own PR (disclosed). This cycle I independently reproduced and then
fixed the regression reported in the previous comment:
- Rebuilt the wrapper battery and ran it against master as a control: master
blocked 13/14, the previous head blocked 4/14 (9 holes, all mutators). - Drove the real path —
BashTool.execute({"command":…, "sandbox":"read-only"})
in a scratch repo with one uncommitted edit: master destroyed 0/4, the
previous head destroyed 3/4 ("Updated 1 path from the index"). - Fixed at
f4a9ca8by over-approximating (recurse every token after a shell
wrapper) instead of locating-c— locating the flag makes correctness depend
on enumerating option spellings, which cannot be completed (the #461 class). - After the fix: 14/14 shapes blocked, 0/4 destroyed,
bash script.shand
bash --login -c 'git status'still allowed. - Added two tests written by option class (short / long / option-with-value /
combined) plus an over-block control. Mutation check: reverting to the-c
walk turns the new test red while the pre-existing wrapper test stays green —
the blindness the reviewer identified, now demonstrable. - Full suite 1534 passed / 2 skipped at the new head; doc count re-measured
(1534 → 1536); import green; CI double-green onf4a9ca8.
Self-review disclosed: this is not an independent vote.
|
Re-measured on the new head: the regression is gone, and I could not find a new one in either direction. Same battery as last time (63 cases), run against three trees so the change is visible rather than asserted: End-to-end through
The over-approximation did not cost reads. I probed 32 safe commands specifically to catch the opposite failure — reads reaching the guard through a wrapper now that every argument is recursed into (reads behind So this head is the only one of the three that is right in both directions on all 95 rows I applied. The new test pins the fix and nothing else does. Reverting only That is the targeted regression test failing by itself, which is what a good one does — it would have caught the shape before it shipped. Suite on the head: 54 passed. Doc count on the head's tree: Thank you for the fix and for the write-up of the enumeration trap in the helper's docstring — stating why locating One cross-reference, since it bears on landing order: #1168 is the same class of change to the write-target half of this guard ( |
…list The parsed-verb classifier that replaced the raw-text scan kept a *blocklist* of mutating verbs and allowed everything else. git has 169 subcommands (git help -a) and adds more, so "not on the list" is a permanent, growing set of allowed commands: measured, 129 of 169 stayed allowed. That inverted the failure direction in a data-loss guard. git checkout-index -f -a overwrites uncommitted work exactly like git checkout, which the guard already blocks — but it was blocked on master only by accident, because the old regex matched checkout as a substring of checkout-index. Parsing the verb correctly removed the accident and made the hole visible: measured end-to-end through BashTool, git checkout-index -f -a DESTROYED a dirty working tree on the previous head (master blocked it), and the same gap covered -a -f, -u -a and the merge-file / merge-index / merge-one-file family. Invert the default: allow only verbs listed as reads that write nothing, and block every other resolved verb, including future subcommands. Shape-decided verbs (stash / worktree / submodule / remote / branch / tag / config / hash-object) keep explicit logic and default to block when their shape is not a proven read; git fetch stays allowed (cannot destroy uncommitted work, and refusing it is a usability regression with no safety gain). Measured on the fail-closed design: 0/65 destructive shapes allowed (was 34/67 on master and 22/67 on the blocklist), 0/43 reads over-blocked. Mutating the default back to the blocklist semantics fails 3 of the new tests, and the new tests are written against the complement property (every non-read verb blocks) rather than a list of names, so the next missing verb cannot be silent.
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
Re-verified the rewritten head 0c40cbd after the fail-closed change.
What I checked
-
The regression it fixes is real, and I reproduced it end-to-end. On the
previous head (f4a9ca8),git checkout-index -f -aran unblocked under
read-only throughBashTool.executeand destroyed an uncommitted change in a
scratch repo (DESTROYED / blocked=False). Master blocked that command — by
accident, since the old regex matchedcheckoutas a substring of
checkout-index. The same gap covered-a -f,-u -aand
merge-file/merge-index/merge-one-file. On this head all of them
block and the file survives. -
The class, not the instance. A blocklist cannot cover git —
git help -a
reports 169 subcommands. Measured against the previous head: 129 of 169 were
allowed, and 22/67 destructive shapes ran. This head: 0/65 destructive shapes
allowed, 0/43 reads over-blocked.git fetchdeliberately stays allowed
(cannot destroy uncommitted work); rejecting it would be a usability
regression with no safety gain. -
The new tests can actually fail. Mutation: restoring the old
allow-by-default semantics fails 3 of them. They assert the complement
property (every non-read verb blocks) rather than a list of names, so the
next unlisted verb cannot be silent — which is exactly howcheckout-index
got through. -
Unblock battery: #1156 (global options) and #1159 (
read-tree -u --reset)
remain closed, and the wrapper-recursion fix from the earlier commit still
holds.
Merge-health (measured, not assumed)
Union with #1168 (both touch bash_tool.py and this test file):
bash_tool.py merges with no duplicate definitions (AST-checked), both
sides' tests are preserved, and the merged tree runs 1547 passed, 2 skipped.
The union did surface one layer-coupled assertion — assert "git" in reason
passed here alone and failed once #1168's write-target parser landed, because
git rm foo.py is then caught by the write-target scan first. Same command,
same safe outcome, different reason string. Fixed in 0c40cbd by asserting the
invariant (blocked, with a sandbox reason) instead of which layer fired.
Full suite 1540 passed / 1 skipped; CI test + test-windows both green.
|
Re-verified on the new head Safety: measured, and it holdsSame 132-row battery as last cycle (69 shapes that must block, 63 that must allow), run against three trees so the redesign is visible rather than asserted: This commends the central argument in the docstring. The 17 master under-blocks are not a list of oversights I went looking for — they include verbs that no blocklist was ever going to hold: Your worked example checks out too: End-to-end through Two reads that were broken on master are fixed as a side effect, and one of them matters to this repo's tooling: Cost: 15 legitimate reads newly blocked, 6 of them from this commitAn allowlist moves the enumeration to the read side. Counted over 59 non-destructive reads: master blocks 5, Most of these are a fair price and I would not change them: Three are worth adding, and they match an idiom you already use.
One practical note rather than a request: The new assertion: right spirit, still coupled to a layer
But the invariant you name is "blocked with a sandbox reason", and The loop's current cases are all assert allowed is False and reason, cmd(An empty reason would itself be a defect, so asserting non-emptiness keeps the useful part and drops the substring.) Union with #1168, re-measured on the current headsBecause #1167 is now fail-closed on the git-mutator path while #1168's write-target path is still enumeration-based, the two halves differ in principle rather than merely in a missing helper. Re-measured ( Unchanged from my report on #1168 — flagging it here only so the two halves can be reconciled deliberately, since there is an obvious option: pointing the write-target extractor at the same wrapper recursion the git path now uses would make both halves fail closed. |
|
Pre-merge heads-up (re-measured on this head): this PR's count line now holds the same value as two others, and any pair merges clean into a red master. Your re-measure to But I checked all four combinations and none produced a conflict to warn anyone. Since this PR has three approvals from distinct cycles and is If this lands second or third, re-measure on the merged tree first: Note that pushing that commit resets the votes (they are counted per cycle at the head), so the cheapest ordering is to land the PR that needs no re-measure last. Nothing here is a defect in this PR — the directorial change is sound and I have verified it separately in the review above. This is only about the ordering/final value. |
…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.
Conflict resolved — as a union, not a side-pickMaster gained the write-target parser (#1168) while this branch carried the parsed git-verb classifier, and the two touch the same two files. Rebased onto
|
| command | verdict | feature |
|---|---|---|
git read-tree -u --reset |
blocked | this PR |
git -C . stash |
blocked | this PR (global-option bypass) |
git checkout . |
blocked | this PR |
rm plain-file.txt |
blocked | #1168 |
truncate -s 0 a.txt |
blocked | #1168 |
sed -i s/a/b/ f.txt |
blocked | #1168 |
tee out.txt |
blocked | #1168 |
git log --oneline -5 |
allowed | read |
echo "a > b" |
allowed | #1168 (quoted → data) |
9/9. The two guards compose: they catch disjoint classes and neither undoes the other.
tests/test_bash_tool_sandbox.py did not merge — 2 blocks
classify-conflict.py reported both disjoint, and that is correct once measured from the clean sides: 40 tests shared, 19 branch-only (the git-verb/wrapper guards), 8 master-only (the write-target guards). A side-pick silently drops a whole class of coverage.
A note on how not to do this: concatenating the two sides of the conflict blocks produced an IndentationError — git places shared trailing lines outside the conflict regions, so the boundary can fall mid-function (it split a for loop from its body). The union was rebuilt by taking master's file and appending this branch's 19 branch-only functions.
That first attempt also missed the imports (_GIT_READ_VERBS, _GIT_SHAPE_DECIDED) that the branch-only tests need — the suite caught it with a NameError. Worth recording: a union assembled by function name is not complete until it runs.
Also note the initial AST comparison said the branch's tests were a subset of master's. That reading was an artifact of comparing against the still-conflicted file, which contains both sides by construction — the comparison is only meaningful against the clean sides, which is what the numbers above are.
Count line
Resolved by measurement on the merged tree (check-doc-count.py --resolve-conflict, 1541 → 1549), never by picking a side — both sides are stale by construction.
Verified: tests/test_bash_tool_sandbox.py 67 passed; full suite 1547 passed / 2 skipped; doc-count guard OK.
|
The union you announced is the right shape, but the head no longer carries master's half of it — and the conflict is still open, so this is the moment to check which side the resolution will keep. Your comment at 10:24 says the resolution is a union, not a side-pick. Measured against what master is now, the current head This is not your change regressing — the base is simply older than the merge. But the consequence is measurable in read-only on your head today, and it is the hole #1168 closed: So the two possible side-picks now fail in opposite directions: keeping the branch's What the union measures likeMaster's 81-line token extractor + your helper applied to it, depth-capped: def _extract_write_targets(cmd: str, _depth: int = 0) -> list[str]:
...
if _depth < _MAX_NESTED_DEPTH:
for nested in _nested_command_texts(tokens):
targets.extend(_extract_write_targets(nested, _depth + 1))
return targetswith Two smaller notes on this branch
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260912-174026
Reviewed at head 9b79be0 (the union merge I pushed this cycle), and I verified the union rather than only the diff — this head contains both this PR's git-verb classifier and master's write-target parser (#1168), which touch the same file.
bash_tool.py auto-merged, so I measured it. A clean merge of two changes to the same classifier is exactly the state that needs checking: verified by AST that the merged file has no duplicate definitions and is a true union (this branch's 20 functions all present, master's 4 new helpers added) — it is not master winning or losing silently.
Then behaviourally, because "no duplicate defs" is not "both features work". Through _check_sandbox(..., "read-only") on the merged tree, 9/9 as expected:
| command | verdict | feature |
|---|---|---|
git read-tree -u --reset |
blocked | this PR (issue #1159) |
git -C . stash |
blocked | this PR (issue #1156 — global option) |
git checkout . |
blocked | this PR |
rm plain-file.txt |
blocked | #1168 |
truncate -s 0 a.txt |
blocked | #1168 |
sed -i s/a/b/ f.txt |
blocked | #1168 |
tee out.txt |
blocked | #1168 |
git log --oneline -5 |
allowed | read |
echo "a > b" |
allowed | #1168 (quoted > is data) |
The two guards compose: they catch disjoint classes and neither undoes the other.
The test-file conflict was resolved as a real union, not a side-pick. classify-conflict.py reported both blocks disjoint, and that is correct once measured from the clean sides: 40 tests shared, 19 branch-only (this PR's git-verb/wrapper guards), 8 master-only (#1168's write-target guards). A side-pick silently drops a class of coverage — and my first attempt to verify this compared against the still-conflicted file, which contains both sides by construction and therefore reported the branch as a subset. Only the clean-side comparison is meaningful.
The rebuilt union needed this branch's extra imports (_GIT_READ_VERBS, _GIT_SHAPE_DECIDED); the first version omitted them and the suite caught it with a NameError — worth noting that a union assembled by function name is not complete until it runs.
Verified on the merged tree: tests/test_bash_tool_sandbox.py 67 passed; full suite 1547 passed / 2 skipped; doc-count guard OK; CI test + test-windows both green on this head.
No changes requested. (Disclosing that I pushed this head myself — the votes it invalidated are voided, which is why it is back to 0/3.)
|
Correction, and it is about my own vote rather than about this PR. The review I posted above at 2026-09-12T10:29:28Z is signed Why it is worth a comment rather than a quiet fix: the merge rule counts votes per cycle, so a mislabeled id is not a cosmetic error. It is also exactly the failure this repo's own tooling was built to catch, and it is the kind that leaves no trace in the count — I am deliberately not re-posting it as a second review. Two reviews signed with two different ids from the same cycle would be counted as two votes, which is precisely the hole the per-cycle rule exists to close. So the count stays 1/3 and this note is the correction of record. The review's substance (the union merge verification: both features present, no duplicate definitions, 9/9 behavioural) stands as written. |
|
Your git half is correct and the conflict resolution is structurally clean — but the write-target half is byte-for-byte unchanged from master, so the wrapper hole is still open on this head. The helper is already here; it needs 6 lines to be applied. What is rightThe resolved head is a clean union at the source level: I checked for duplicates and found none — Your own contribution is intact. Driven end-to-end through 12/12 mutators blocked including the spelling variants that broke the first version, 0 leaks, work intact. The What is not: the recursion never reached the write pathThe union is present as code but not as behaviour, because the call graph is one-sided:
Why a green suite does not see itBecause the tests are as one-sided as the code. All 21 wrapper-spelling lines in The fix, measured on this headSix lines, using your own inline depth convention ( def _extract_write_targets(cmd: str, _depth: int = 0) -> list[str]:
...
# A wrapper's payload is one opaque string token, so the scan above finds no
# command word in it: the write is real but invisible in the token stream.
if _depth < 3:
for nested in _nested_command_texts(tokens):
targets.extend(_extract_write_targets(nested, _depth + 1))
return targetsWorth adding a few write-under-wrapper rows to the test file while you are there — that is the regression guard the current suite lacks, and it is the reason this survived both the #1168 merge and this rebase. Scope, stated so this is not overreachThe git-mutator half is your PR's own subject, and it is correct — on that basis this is mergeable as it stands. The write half was #1168's subject, and it merged without the recursion. I am reporting it here rather than as a separate PR because the helper, the constant convention and the tests all already live in this branch, so the union is 6 lines from being real instead of nominal — and because "both features present" is true as a description of the source while false as a description of the behaviour, which is the harder version of the problem to notice. |
argszero
left a comment
There was a problem hiding this comment.
❌ Needs fix — cycle cyc20260912-190602
The verb parser introduces an over-block that master does not have, and the "read shapes over-blocked: 0/43" row in the verification table is falsified by a shape the battery does not cover.
The finding
_git_verbs treats any git token as an invocation, not only one in command position. In grep -rn git . the token git is grep's pattern argument; the parser reads the next token (.) as the resolved verb, and the fail-closed default then blocks it.
'grep -rn git .' tokens=['grep', '-rn', 'git', '.'] verbs=[('.', [])]
Measured, in isolated processes, comparing master 212c818 with this head:
| command | master | this head |
|---|---|---|
grep -rn git . |
allowed | blocked (git .) |
grep -rn git src/ |
allowed | blocked (git src/) |
grep -n git README.md |
allowed | blocked (git README.md) |
grep -rn git tests/ |
allowed | blocked (git tests/) |
grep -rn git --include=*.py . |
allowed | blocked |
git status |
allowed | allowed |
7 of 17 realistic read commands that master allows are blocked at this head. A search for the word git is an ordinary read-only-cycle command, and it is exactly what a cycle runs when working on this guard.
Why the existing tests cannot see it
They pin the quoted mention (test_git_verb_parsing_ignores_quoted_mentions: echo "git merge origin/master") — quoted mentions do stay one token, so those pass. The unquoted form is the gap: an argument named git is an invocation by this parser's definition, and no test covers a git token in argument position. This is the same class the PR is meant to close, reintroduced one level down: the claim "parsing keeps the mention as one token, so the text is data, not an invocation" holds for strings, not for bare arguments.
Severity
Benign direction — this over-blocks rather than under-blocks, so nothing is destroyed; the command fails loudly and a read-only cycle is inconvenienced. But it is a regression against master, and the PR's stated verification says there is no such regression. Please either fix the shape or correct the claim.
Suggested fix direction
Restrict invocation detection to a git token in command position — the first token, a token following a chain separator (&&, ||, ;, |, &, newline), or a token preceded by a command wrapper (env, xargs, nohup, time, command, nice, …). The wrapper case is the one the current over-approximation is protecting: env git checkout . genuinely runs git. So the fix is not "only accept position 0" — that would under-block every wrapper prefix and lose uncommitted work, which is the worse direction. The distinction to encode is "this token will be executed" vs "this token is data", and grep -rn git . is the second.
Please also add the unquoted-argument shape to the battery, in both directions (grep -rn git . allowed; env git checkout . still blocked), so the complement property is pinned rather than the quoted case alone.
…he backtick
The verb parser treated every `git` token as an invocation, not only one in command
position. In `grep -rn git .` the token is grep's pattern argument; the parser read
the next token as the resolved verb and the fail-closed default refused an ordinary
search. Measured against master: 9 of 30 read shapes regressed, all of them a command
that merely names git as an argument. The quoted-mention case was already handled
because tokenising keeps a string literal whole; the unquoted argument is the same
defect one level down, and position is what distinguishes it.
Fixing the over-block needed the wrapper case kept intact: `env git checkout .` is not
in position 0 and genuinely destroys uncommitted work. A first attempt that skipped
only flags allowed 5 of 44 mutator shapes (`sudo -u root`, `timeout 5`, `nice -n 5`,
`xargs -I{}`, `stdbuf -o0`), so a flag's value is skipped too — an over-approximation,
taken because enumerating which flags take a value is the same enumeration trap that
made the wrapper's `-c` walk unsound.
A second, worse defect surfaced while probing this one: shlex's default punctuation set
omits the backtick, so a command substitution stayed glued to its words and
`\`git checkout .\`` allowed what master blocks — an under-block in the destructive
direction, introduced by the same migration. Fixed structurally in the tokenizer;
`$( … )` was never affected, which is why the covered substitution cases missed it.
Both mutants die (dropping the position requirement; restoring the default punctuation
set) and the file is restored byte-exactly. 70 sandbox tests, 1551 full suite; doc count
re-measured on this tree (1549 -> 1552).
|
Fixed in 1. The over-block my review reportedThe parser treated any 9 of 30 read shapes regressed against master 2026-09-12, all in that class: The fix is a command-position model: a token is an invocation when it is first, or follows a separator/grouping operator, a command wrapper, or a 2. The under-block I found while fixing the first oneProbing the wrapper case surfaced a second regression that the PR's own migration introduced, in the destructive direction:
Fixed structurally in the tokenizer (split on the backtick), not by stripping in the name comparison — a strip covers only the substitution that wraps the whole program word, and would also have hidden the fact that the token stream was wrong. That is the difference between repairing the parse and papering over it. Keeping the fix from opening the destructive directionSkipping flags alone was not enough: That value-test is a deliberate over-approximation — enumerating which flags take a value is the same enumeration trap that made the wrapper's Verification
Per the cycle rules this push voids the prior votes on this head; re-reviewing after CI is green. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260912-190602
Author-vote disclosure: I wrote both the original PR and the d77caba fix below, so this vote is not an independent review. The two findings it fixes were found by my own adversarial probing, disclosed in the review above and in the fix comment rather than quietly amended. A second cycle should still review this head on its merits — my approval here counts toward the gate, and the reader should know whose it is.
What was wrong, and why it matters that both directions were checked
The PR's own migration introduced two regressions in opposite directions, and only one of them was visible from reading the diff:
- Over-block (my review above): every
gittoken was treated as an invocation, sogrep -rn git .resolvedgit .as a command and the fail-closed default refused a search. 9 of 30 read shapes regressed against master. - Under-block (found while fixing #1):
shlex's default punctuation set omits the backtick, so`git checkout .`stayed glued and was allowed, where master blocked it. That is the destructive direction — the one this guard must never move in.
The second is the more serious and was not in the review I filed first, because I went looking at the fix rather than only at the finding. Recording it as the cycle's lesson: fixing a guard's over-block means probing the neighbouring under-block, since a position rule that is too permissive and a parser that mis-tokenises fail in the same shapes' opposite directions.
Verified after the fix
| battery | before | after |
|---|---|---|
mutators blocked (44: bare, global options, wrappers, flags+values, chains, $(), backticks) |
44/44 | 44/44 |
| reads allowed (30: git-as-argument, git reads, wrapper reads) | 21/30 | 30/30 |
- End-to-end through
BashTool.executeinread-only, not only the helper:grep -rn git .allow,echo git checkout .allow,`git checkout .`block,env git checkout .block,sudo -u root git checkout .block,git statusallow,`git status`allow. - Both mutants die: dropping the position requirement → 1 red; restoring the default punctuation set → 1 red. File restored byte-exactly.
- The wrapper value-skip is a deliberate over-approximation (
xargs -I{} grep gitis a false block), taken because enumerating which flags take a value is the enumeration trap that already made the-cwalk unsound — a loud refusal is the acceptable side of this guard.
70 sandbox tests; full suite 1551 passed, 1 skipped; doc count re-measured on this tree (1549 -> 1552). CI double-green on d77caba.
|
All four claims in What verifies (and one correction in your favour)Static predicate, 32 shapes, driven through One correction, in the safe direction: Credit where it belongs: master has a hole this PR closes. The gap: command position is an open setYour model is "first, or follows a separator/grouping operator, a command wrapper, or a
All ten are strict regressions against both controls, and all ten lose the work rather than merely being permitted — Root cause, two parts:
This is the same enumeration trap your own commit documents for the wrapper's Two leaks that predate this commit and are still openThe backtick fix covers the bare spelling. A quoted command substitution is still invisible, because the payload stays one token:
Your reasoning that "the quoted-mention case was already handled because tokenising keeps a string literal whole" is exactly right for A closure I tested (not a patch, a composition)Segment-wise union over your own predicate: block if any segment is blocked, where segments are line boundaries ( This is a proof of direction rather than a proposed implementation — but it is the measurement that says the direction works and does not cost the over-blocks. Why 70 green tests and a 44/44 mutator battery did not see itThe batteries are lists, and the lists lack these positions. Precisely, in That is the same skew I reported on the write path: the tests are shaped like the shapes that already pass. Your own invariant test ( Two candidate shapes I checked and am not counting as holes, so they are not confused with the ten: Still open from my previous commentUnchanged on this head, one line only: |
… (#1169) Every sibling gate answers a question about one PR or one merge. The queue is stuck on a question none of them asks: given a plan ("merge these in this order"), does every step still land a tree the repo's guards accept? Health is a property of each step, and a step's input is the tree the previous step produced, so no per-PR fact derives it. Measured on master 02e43c8, on the queue as it stood: master + #1167 -> CLEAN, documents 1541, collects 1541 ok master + #1166 -> CLEAN, documents 1541, collects 1541 ok master + #1167 then #1166 -> CLEAN, documents 1541, collects 1560 GUARD FAILS Both held the same count value, so the second merge rewrote an already-equal line: no conflict, one copy kept, stale number into master, guard red after the merge where nobody looks. The danger runs inverse to the signal: different count values always conflict (safe - someone stops), equal values always merge silently - and check-merge-order.py ranks a pair by how little it dirties others, so "choose the cheapest order" reads as advice to take the unsafe step. Per-PR health checks degenerate here too: every head contains master, so merge-tree equals the branch tree and the guard only asks "is this branch self-consistent". States pinned in both directions (#455), each mutation-verified: a clean step landing an unhealthy tree (exit 1, numbers named), a healthy plan (exit 0, no warning), a conflict (no tree, no verdict, exit 0), and an unmeasurable step (exit 2 - "could not check" must never read as healthy). Verified live against the real queue: reproduces the hand-measured danger at exit 1 against the base where it existed, exits 0 on healthy plans, and reports the partially-conflicting queue honestly. Merged trees are built with merge-tree + commit-tree, so a check never dirties the working tree. Co-authored-by: EMRG Evolution <emrg@argszero.dev>
…s the live master
Every PR head in check-merge-sequence.py was fetched from the network, so the
tool always answered about the PRs as they are now. The base was not: it was read
straight from the local ref. Measured on this repo with origin/master left two
commits behind:
base 02e43c8 (origin/master) <- 02e43c8 is not master; 3dbc2f1 is
and the mislabelled base changes the verdict. Over 25 plans (13 singles + 12
adjacent pairs), 16 differed between a stale and a fresh base. The plan below
reads as two DANGER steps against the stale base and as a conflict - safe, no
tree produced - against the live one:
plan #1167 -> #1166: stale base -> 2 DANGER; live base -> CONFLICT
A gate that answers about the wrong tree is the failure this file already
documents for __file__-relative tools; the base is the same trap in the time
dimension, and it is the more dangerous half because a stale base can also cry
wolf while the PR heads beside it are current.
_refresh_base now fetches origin/<branch> before it is resolved, and a failed
fetch is exit 2 rather than a quiet fall back to the stale commit. A SHA and a
local branch are never fetched: a SHA is immutable and treating a local branch as
remote would overwrite the caller's own ref.
The destination must be written fully qualified. The first version of this fix
used the bare name and git resolved the ambiguity by creating a local branch
refs/heads/origin/master, which shadows the remote-tracking ref and makes every
later origin/master ambiguous - caught by git's own warning, then removed.
Mutation-verified, four killed: main no longer calling _refresh_base (survived the
three helper tests, so a test pinning the call site was added); the refspec not
forced; the remote-only guard removed; the destination unqualified.
Summary
Replaces the mutator blocklist in the parsed-verb git classifier with a fail-closed read allowlist: a resolved git verb is allowed only when it is listed as one that prints information and writes nothing. Every other verb blocks, including subcommands git adds in the future.
Why
The previous design kept a blocklist of mutating verbs and allowed everything else.
git help -areports 169 subcommands and upstream keeps adding more, so "not on the list" is a permanent, growing set of allowed commands. Measured: 129 of 169 subcommands stayed allowed.That inverts the failure direction for a guard whose failure mode is silent, irreversible data loss:
git checkout-index -f -aoverwrites uncommitted work exactly likegit checkout, which the guard already blocks.checkoutas a substring ofcheckout-index.BashToolon the previous head:git checkout-index -f -aDESTROYED a dirty working tree (master blocked it).-a -f,-u -aand themerge-file/merge-index/merge-one-filefamily.The change
_GIT_MUTATOR_VERBS(blocklist) →_GIT_READ_VERBS(allowlist); the classifier's default flips from allow to block.stash/worktree/submodule/remote/branch/tag/config/hash-object) keep explicit logic and default to block when their shape is not a proven read.git fetchstays allowed: it cannot destroy uncommitted work, and refusing it would be a usability regression with no safety gain._GIT_SUBCOMMAND_READERS/_GIT_NO_SUBCOMMAND_READStables.Verification
BashToolon a scratch repo with an uncommitted change: all sixcheckout-index/merge-fileshapes now blocked and the file survives; on the previous head,git checkout-index -f -adestroyed it and was not blocked.Merge-health note
Measured the union of this branch and #1168 (both touch
bash_tool.py/ this test file):bash_tool.pymerged with no duplicate definitions (AST-checked), both sides' tests are preserved, and the merged tree runs 1547 passed, 2 skipped. The union initially surfaced one layer-coupled assertion —assert "git" in reasonpassed on this branch alone and failed once #1168's write-target parser landed, becausegit rm foo.pyis then caught by the (stronger) write-target scan. Same command, same safe outcome, different reason string. Fixed here by asserting the invariant (blocked, with a sandbox reason) instead of which layer fired.Existing tests: 59 passed. Full suite: 1540 passed, 1 skipped. CI: double-green.