Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ heredocs, and reordered flags are not bypassable by syntactic tricks.
concrete reason — the simplicity is a feature.
- Tests are bash scripts that pipe JSON to the engine and assert decisions.
Keep them readable and self-contained — `tests/test-watch-<name>.sh` mirrors
`watches/watch-<name>.yml`.
`watches/watch-<name>.yml`. Those files each load one rule set, which is how
the engine is *not* invoked: the hook loads the whole directory, so a rule
reading a token out of another tool's command costs a prompt no per-set file
can see. `tests/test-all-watches.sh` is the tier that evaluates against
`watches/` entire — put a cross-set expectation there.
- Docs are generated from rules YAML by `build/gen-rules-doc.py`. Don't
hand-edit `docs/_site` content for rule references; edit the YAML and run
`just docs`.
Expand Down Expand Up @@ -196,6 +200,20 @@ consumers see the update; the tag and the marketplace notify are not what
nothing more. Multi-line strings, anchors, and `!!tag` constructs are not
supported. If you need them, that's a spec discussion, not a copy-paste of
PyYAML.
- **Word normalization ([EN-15]) reaches the spellings that survive as a
literal word**, not obfuscation in general. `"git" commit`, `g\it commit`,
`git "commit"` and `rm "-r" /etc` all resolve before matching, and the walk
steps over an option to reach the subcommand behind it (`git -C /repo
"push"`). What stays out of reach is a word assembled at runtime, because
nothing in the command text says what it will be — `C=git; $C commit` is the
shape to expect. A shell has unbounded ways to spell a word, so treat the
rules as a guard against the destructive command an agent writes plainly, not
as a sandbox against one trying to get past it.
- **Normalization changes how a word is spelled, never where a pattern looks.**
Patterns search anywhere in the command, so `echo rm -rf /` decides the same
as `rm -rf /` — and after normalization so does `echo "rm" -rf /`. That
breadth is the deliberate trade in [RL-03]; resolving a spelling neither
widens nor narrows it.

## Reading order for new contributors

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Broadening the allowlist is safe because a hook decision outranks an `allow` rul

The `Monitor` tool runs its command in the same shell as `Bash`, so ClaudeWatch screens it on the same terms and its matcher is `Bash|Monitor`. Claude Code keeps the two permission families apart, though: a command you want frictionless in both places needs a `Monitor(…)` rule alongside the `Bash(…)` one. `/ClaudeWatch:learn` proposes each candidate for the tool its records came from, so the suggestion already names the right one.

One nuance for compound commands. Claude Code does not honor a hook `ask` for a piped or chained command (e.g. `git push --force-with-lease 2>&1 | tail`) whose segments each match an allow rule — it auto-approves the pipeline before the prompt surfaces, so the confirm is skipped. A `deny`, by contrast, is honored through a pipe. So that an `ask`-tier command isn't silently bypassed when piped, ClaudeWatch escalates an `ask` to a `deny` whenever the command is compound, with a message to re-run the guarded command on its own to get the prompt. Bare commands prompt normally; the escalation only changes the piped/chained form. A command left malformed by a dangling `&&` or `||` is the one chained form the host never auto-approves: it requires approval whatever the allow rules say, and ClaudeWatch's escalation applies to it on the same terms as any other compound. A `Monitor` command escalates on the same terms, since it runs unattended and repeats on a single approval: run the guarded step as its own `Bash` call rather than folding it into a watch loop.
One nuance for compound commands. Claude Code does not honor a hook `ask` for a piped or chained command (e.g. `git push --force-with-lease 2>&1 | tail`) whose segments each match an allow rule — it auto-approves the pipeline before the prompt surfaces, so the confirm is skipped. A `deny`, by contrast, is honored through a pipe. So that an `ask`-tier command isn't silently bypassed when piped, ClaudeWatch escalates an `ask` to a `deny` whenever the command is compound, with a message to re-run the guarded command on its own to get the prompt. Bare commands prompt normally; the escalation only changes the piped/chained form. A bare subshell — `(git push)` — counts as compound on the same grounds: Claude Code treats a command inside one the way it treats one inside a pipeline. A command left malformed by a dangling `&&` or `||` is the one chained form the host never auto-approves: it requires approval whatever the allow rules say, and ClaudeWatch's escalation applies to it on the same terms as any other compound. A `Monitor` command escalates on the same terms, since it runs unattended and repeats on a single approval: run the guarded step as its own `Bash` call rather than folding it into a watch loop.

To keep agents out of that escalation in the first place, a `SessionStart` hook (`hooks/emit-rules.sh`) injects a short ambient note advising that consequential steps be run as their own Bash call rather than chained. The content lives in `rules/*.md`; the escalation is the backstop, the note is the nudge that fires before it.

Expand Down
8 changes: 5 additions & 3 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ This is the core safety advantage over Claude Code's built-in deny rules, which
**Pattern tips:**

- Use `\s+` instead of literal spaces to handle multiple spaces
- Use `\b` for word boundaries to avoid false positives
- Use `(\s|$)` to match "command with args or command alone"
- Use `\b` for word boundaries inside a token; for the token's own edges see the two bullets below, which the shell's own boundaries govern
- Write the program and subcommand as bare words — a `bash` input arrives with the leading words of each command already unquoted ([EN-15]), so `git commit` matches `"git" commit` and `git "commit"` without the pattern saying so, while operands keep their quoting
- End a bare subcommand with `(?=$|[\s;&|)`<>])`, which is where the shell ends it. `(\s|$)` looks equivalent and isn't: a command alone is followed by whatever comes next, so `(git push)`, `git push;echo done` and `` `git push` `` slip past it. Match the terminators rather than excluding the continuations — `(?![\w-])` also accepts a closing quote, so it fires on `grep -rn 'git push' .`
- Bound a bare program name with `(?:^|[\s;&|`(])` rather than `\b`, so a hyphenated name (`my-rm`) is not read as the program it ends with
- Use negative lookahead `(?!...)` to exclude variants (e.g. `git\s+rm\b(?!.*--cached)`)
- Remember `re.search()` matches anywhere — `git\s+push` will match both `git push` and `git add . && git push`

Expand Down Expand Up @@ -304,7 +306,7 @@ rules:

ask:
- name: docker run
pattern: 'docker\s+run(\s|$)'
pattern: 'docker\s+run(?![\w-])'
except: 'docker\s+run\s+--rm\b'
reason: starts a new container
ref: https://docs.docker.com/reference/cli/docker/container/run/
Expand Down
Loading