Skip to content

Match a guarded command however the shell spells it - #31

Draft
chris-peterson wants to merge 2 commits into
mainfrom
escalate-ask-inside-bare-subshell
Draft

Match a guarded command however the shell spells it#31
chris-peterson wants to merge 2 commits into
mainfrom
escalate-ask-inside-bare-subshell

Conversation

@chris-peterson

@chris-peterson chris-peterson commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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, and git checkout . / git restore . had the same hole through \.\s*$. Separately, rm read recursive and force as one flag cluster, so rm --recursive --force / decided allow and rm -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 like 2>&1 is never followed by (.
  • watches/watch-git.yml and watches/watch-installs.yml — the verb boundary, 13 patterns. (?![\w-]) ends where the shell ends the word; the hyphen is what keeps git commit-tree out. It costs npm 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.yml and watches/watch-bash.ymlrm's flags, as two lookaheads over the flag run rather than one cluster, so short, long, split and reordered all match. \b on rm also stops confirm -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 the docker run example 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 done and `git stash` were allow on the same anchor — so enumerating them invites the next gap. The cost is the hyphen: npm install-test and npm install-ci-test really 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, and tests/test-watch-installs.sh pins 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, and echo "hello there must 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.

@chris-peterson chris-peterson self-assigned this Sep 2, 2026
@chris-peterson chris-peterson added the bug Something isn't working label Sep 2, 2026
@chris-peterson chris-peterson added this to the 1.0 milestone Sep 2, 2026
@chris-peterson
chris-peterson force-pushed the escalate-ask-inside-bare-subshell branch from 91c6b78 to 3dfb340 Compare September 2, 2026 22:09
@chris-peterson chris-peterson changed the title Escalate an ask inside a bare subshell Match a guarded command at every shell boundary Sep 2, 2026
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
chris-peterson force-pushed the escalate-ask-inside-bare-subshell branch from 3dfb340 to 2a62bfa Compare September 3, 2026 00:24
@chris-peterson chris-peterson changed the title Match a guarded command at every shell boundary Match a guarded command however the shell spells it Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_SHELL_COMPOUND does not match a bare subshell, so (git push) keeps ask

1 participant