Match a guarded command however the shell spells it - #31
Draft
chris-peterson wants to merge 2 commits into
Draft
Conversation
chris-peterson
force-pushed
the
escalate-ask-inside-bare-subshell
branch
from
September 2, 2026 22:09
91c6b78 to
3dfb340
Compare
A rule recognizes a guarded command by the literal text of its program, its subcommand, and its flags. Three layers each read that text more narrowly than the shell does, so a command that runs was reaching the rules as something no pattern matched. `_SHELL_COMPOUND` enumerated the operators that separate commands (`|`, `;`, newline, `&&`, `$(`, backtick) but none that group them, so [OUT-08] never escalated `(git push)`. Claude Code 2.1.257 fixed a `permissions.ask` rule being skipped in auto mode for a command running inside "a compound command or subshell", so the host treats the two shapes as one class and the engine now does too. A `(` counts only at command position — the start of the command, or after `;`, `&`, or `|` — which leaves a parenthesis inside an argument alone. That left `(git push)` deciding allow rather than ask. Thirteen rules anchored a bare subcommand with `(\s|$)`, which a closing paren satisfies neither way, so no rule matched and there was no ask to escalate. The same anchor missed every other separator — `git commit;echo done`, `git push|tee log`, `` `git stash` `` — and the block rules for `git checkout .` and `git restore .` had the hole through `\.\s*$`. Both now end where the shell ends the word: `(?![\w-])`, whose hyphen keeps `git commit-tree` out. It costs `npm install-test`, which the anchor it replaces missed too. SCHEMA.md recommended `(\s|$)` and demonstrated it in the `docker run` example, which is where the thirteen came from. `rm` read recursive and force as one flag cluster, so `rm --recursive --force /` decided allow and `rm -r -f /` only asked. Two lookaheads over the flag run take either spelling in any order, and anchoring `rm` on `\b` stops `confirm -rf /` matching. [EN-15] normalizes the leading words of each command before matching, so `"git" commit`, `g\it commit` and `git "commit"` reach the rules as `git commit`. It stops at the first flag and at an unbalanced quote, which keeps an operand quoted: unquoting `-m "wip; done"` would turn a commit message into what reads as a command boundary. A word assembled at runtime stays out of reach, since nothing in the command text says what it will be — AGENTS.md records that limit rather than implying the rules are a sandbox. Refs: #30
chris-peterson
force-pushed
the
escalate-ask-inside-bare-subshell
branch
from
September 3, 2026 00:24
3dfb340 to
2a62bfa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
ClaudeWatch screens every shell command Claude Code is about to run and answers allow, ask, or deny. A rule recognizes a guarded command by the literal text of its program, its subcommand, and its flags — so a command that runs fine but reaches the rules spelled differently gets no decision at all.
Three layers each read that text more narrowly than the shell does.
[OUT-08]'s compound check enumerated the operators that separate commands and none that group them, so(git push)never escalated. Underneath it, thirteen rules anchored a bare subcommand with(\s|$), which a closing paren satisfies neither way —(git push)was deciding allow, so there was no ask for the escalation to raise, andgit checkout ./git restore .had the same hole through\.\s*$. Separately,rmread recursive and force as one flag cluster, sorm --recursive --force /decided allow andrm -r -f /only asked; and quoting the program ("git" commit,g\it commit,git "commit") got past every pattern in the tree.Closes #30
Review guide
scripts/watchdog.py— the compound check. A(joins the operator set, but only at command position (string start, or after;,&,|), so a parenthesis inside an argument doesn't escalate.&is a boundary for this shape though not on its own: a redirection like2>&1is never followed by(.watches/watch-git.ymlandwatches/watch-installs.yml— the verb boundary, 13 patterns.(?![\w-])ends where the shell ends the word; the hyphen is what keepsgit commit-treeout. It costsnpm install-test, which the anchor it replaces missed too.watches/watch-git.yml— the two block rules, whose$meant "end of string" where it wanted "end of the command".watches/watch-files.ymlandwatches/watch-bash.yml—rm's flags, as two lookaheads over the flag run rather than one cluster, so short, long, split and reordered all match.\bonrmalso stopsconfirm -rf /.scripts/watchdog.py— the new[EN-15]step, the one engine change here beyond the regex. Worth the closest read: it rewrites the command text every rule then matches against.SPEC.md— what[EN-15]promises and, in its last sentence, what it doesn't.SCHEMA.md— where the thirteen came from. The tip recommended(\s|$)and thedocker runexample demonstrated it, so this is the fix that stops the next rule inheriting it.tests/test-engine.sh— the six spellings that now converge, plus the operands that must stay quoted.AGENTS.md— the limit, recorded as a known constraint. Nothing in the repo previously admitted one.Approach & trade-offs
(?![\w-])rather than widening the anchor to(\s|$|\)). The paren is one separator of several —git commit;echo doneand`git stash`were allow on the same anchor — so enumerating them invites the next gap. The cost is the hyphen:npm install-testandnpm install-ci-testreally do install, and both stay allow. They were allow before this too, so it's coverage this doesn't win rather than coverage it gives up, andtests/test-watch-installs.shpins it.Normalizing in the engine rather than per pattern. Quoting the program is one of at least six spellings (
"git",'git',g""it,gi''t,g\it,git "commit"). Adding["']?to the program token would have touched ~146 patterns and closed two of them, which buys confidence the coverage doesn't support.[EN-15]resolves the leading words once, before any rule sees them.The risk it carries is that it rewrites the text every rule matches against, so it stops at the first flag and at an unbalanced quote — unquoting
-m "wip; done"would turn a commit message into what reads as a command boundary, andecho "hello theremust not have a command invented after it. Checked against the 399 distinct commands in the test suite: normalization is idempotent, never lengthens the string, and changes exactly the four obfuscated cases.What it doesn't close. A word assembled at runtime —
C=git; $C commit— stays allow, because nothing in the command text says what it will be.[EN-15]'s rationale and the new AGENTS.md constraint both say so, so the next reader doesn't mistake the rules for a sandbox.