Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .claude/hooks/remind-rules-index.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
listing those places so a rule-set change doesn't silently leave them stale.

This guards development of ClaudeWatch itself — it is NOT part of the shipped
plugin (that is `hooks/hooks.json` + `scripts/watchdog.py`). Pure stdlib, exits 0.
plugin (that is `hooks/hooks.json` + `scripts/watchdog.mjs`). Pure stdlib, exits 0.
"""
import json
import re
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
# The full bash test suite on Unix — the canonical gate. The engine and the
# analyzer/reset tools run under Node; the suite's inline assertion helpers
# use Python, so both runtimes are provisioned.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Run the watchdog test suite
run: bash tests/test-watchdog.sh

# Native-Windows evidence that the shipped hooks run on Windows. The hooks
# invoke `node` directly (hooks.json), so there is no interpreter-resolution
# launcher to exercise — Node is the interpreter. Each step is the proof for
# one acceptance criterion: a PreToolUse decision, allow-by-silence, and
# SessionStart emission.
windows:
runs-on: windows-latest
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
CLAUDEWATCH_LOG: 'off'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '20'

# The engine produces a deny under Windows Node.
- name: Engine produces a deny under Windows Node
shell: pwsh
run: |
$block = '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}'
$out = $block | node scripts/watchdog.mjs watches
if ($out -match '"permissionDecision":"deny"') { Write-Host "OK: deny" }
else { Write-Error "expected deny, got: $out"; exit 1 }

# Allow-by-default: a benign command produces no output and exit 0.
- name: Engine is silent on a benign command
shell: pwsh
run: |
$safe = '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'
$out = $safe | node scripts/watchdog.mjs watches
if ([string]::IsNullOrEmpty($out)) { Write-Host "OK: silent allow" }
else { Write-Error "expected no output, got: $out"; exit 1 }

# SessionStart emit-rules emits the ambient header under Windows Node.
- name: SessionStart emit-rules emits under Windows Node
shell: pwsh
run: |
$out = node hooks/emit-rules.mjs
if ($out -match '# Ambient rules from the ClaudeWatch plugin') { Write-Host "OK: emit-rules" }
else { Write-Error "emit-rules did not emit the header"; exit 1 }

# SessionStart cli-freshness is a no-op that exits 0.
- name: SessionStart cli-freshness runs under Windows Node
shell: pwsh
run: |
node hooks/cli-freshness.mjs
if ($LASTEXITCODE -eq 0) { Write-Host "OK: cli-freshness" }
else { Write-Error "cli-freshness exited non-zero: $LASTEXITCODE"; exit 1 }
44 changes: 25 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,29 @@ heredocs, and reordered flags are not bypassable by syntactic tricks.
no network on the decision path. Decision logging (on by default, see
[LOG-01]–[LOG-04]; `CLAUDEWATCH_LOG=off` opts out) is a side channel: it
stamps a timestamp and writes a file *after* the decision is computed, never
feeding back into it. Keep it that way — the clock stays in `_log_event`, not
in `evaluate_rules`.
feeding back into it. Keep it that way — the clock stays in `_logEvent`, not
in `evaluateRules`.
2. **Exit code is always 0.** A non-zero exit blocks the host (Claude Code)
from getting a useful decision. All errors are surfaced as `deny` decisions
with explanatory messages.
3. **Single coalesced decision per invocation.** Multiple matching rules
across multiple rule sets aggregate into one `deny` (preferred) or one
`ask`. Never emit more than one decision.
4. **The engine ships self-contained.** `scripts/watchdog.py` and everything on
the decision path use only the Python standard library (with a minimal
4. **The engine ships self-contained.** `scripts/watchdog.mjs` and everything on
the decision path use only the Node standard library (with a minimal
built-in YAML parser), so the plugin installs cleanly and evaluates rules
fast in any environment Claude Code runs in — no pip step on the hot path.
Build- and release-time tooling that never runs on the decision path (e.g.
`scripts/gen-plugin-json.py`) may use PyYAML; it runs only in dev and CI.
fast in any environment Claude Code runs in — Node ships with Claude Code, so
there is no install step and no separate interpreter to resolve on the hot
path. Build- and release-time tooling that never runs on the decision path
(e.g. `scripts/gen-plugin-json.py`, `build/gen-rules-doc.py`) is Python and
may use PyYAML; it runs only in dev and CI, never on a user's machine.
5. **Allow-by-default.** When no rule matches, the engine produces no stdout
output. Silence is allow.

## Build philosophy

- **The engine is generic; the rules are domain-specific.** Treat
`scripts/watchdog.py` as a stable library. New safety domains are new YAML
`scripts/watchdog.mjs` as a stable library. New safety domains are new YAML
files in `watches/`, not new code.
- **Make adding a rule set frictionless.** Drop a `watch-*.yml` file, add a
test file, regenerate docs. No registration, no manifest, no engine change.
Expand Down Expand Up @@ -69,7 +71,7 @@ heredocs, and reordered flags are not bypassable by syntactic tricks.

## Repo conventions

- One Python file (`scripts/watchdog.py`), one YAML format, one hook config
- One engine file (`scripts/watchdog.mjs`), one YAML format, one hook config
(`hooks/hooks.json`). Resist refactoring into modules until there's a
concrete reason — the simplicity is a feature.
- Tests are bash scripts that pipe JSON to the engine and assert decisions.
Expand All @@ -84,9 +86,10 @@ heredocs, and reordered flags are not bypassable by syntactic tricks.
- **Rule edits** — Use `/ClaudeWatch:rules` for interactive edits; it
validates and previews. Manual edits to YAML are fine for bulk changes,
but run `just test` before committing.
- **Engine changes** — Update `scripts/watchdog.py` and `tests/test-engine.sh`
in the same change. The engine has a small surface; every behavior should
be exercised by a test.
- **Engine changes** — Update `scripts/watchdog.mjs` and `tests/test-engine.sh`
(and `tests/test-node-port.sh` for Node-specific behaviors) in the same
change. The engine has a small surface; every behavior should be exercised by
a test.
- **New or changed rule set** — Add/edit `watches/watch-<name>.yml` and
`tests/test-watch-<name>.sh`. No `hooks.json` change needed (auto-discovery).
A rule set is described in three hand-maintained indexes that drift
Expand Down Expand Up @@ -152,12 +155,15 @@ what `claude plugin update` reads.

## Known constraints (do not paper over)

- **macOS-style absolute paths** appear in shipped rules (`~/.ssh/...`,
`~/.aws/credentials`). These are user-home patterns, not platform-specific
per se — but the documentation references unix conventions. If
cross-platform support becomes a goal, that's a spec change, not a quick
fix.
- **`SessionStart` hook is a no-op placeholder.** `hooks/cli-freshness.sh`
- **The runtime is cross-platform; the rule patterns are unix-convention.**
The hooks invoke `node` directly (Node ships with Claude Code), so the engine
runs natively wherever Claude Code runs — no `.sh`/`.ps1` launcher, no
interpreter resolution. The shipped *rules*, however, still use unix-home
path patterns (`~/.ssh/...`, `~/.aws/credentials`). These are user-home
patterns, not platform-specific per se — but they reference unix conventions.
Broadening the rule patterns to Windows path conventions is a spec change,
not a quick fix.
- **`SessionStart` hook is a no-op placeholder.** `hooks/cli-freshness.mjs`
exists for symmetry across the chris-peterson plugin namespace and to
reserve a spot for future plugin-update self-checks. Do not delete it
silently — see [FUT-01] in `SPEC.md`.
Expand All @@ -170,7 +176,7 @@ what `claude plugin update` reads.

1. `README.md` — the elevator pitch and install path
2. `SPEC.md` — the contract
3. `scripts/watchdog.py` — the engine (under 250 lines)
3. `scripts/watchdog.mjs` — the engine
4. `watches/watch-git.yml` — the canonical rule-set example
5. `SCHEMA.md` — the YAML rule format reference (for rule authors)
6. `tests/test-watch-git.sh` — the test pattern
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A Claude Code plugin that enforces command and script safety rules via a `PreToolUse` hook on `Bash`, `Write`, and `Edit`.

Claude Code's built-in permission system uses naive string matching that [fails for compound commands, heredocs, and flag reordering](https://github.com/anthropics/claude-code/issues/30519). `ClaudeWatch` solves this with Python regex rules matched anywhere in the command string (or in the body of a script being written/edited) via `re.search()`.
Claude Code's built-in permission system uses naive string matching that [fails for compound commands, heredocs, and flag reordering](https://github.com/anthropics/claude-code/issues/30519). `ClaudeWatch` solves this with regex rules matched anywhere in the command string (or in the body of a script being written/edited) — find-anywhere, no implicit anchoring.

## Rule Sets

Expand Down Expand Up @@ -47,7 +47,7 @@ Broadening the allowlist is safe because a hook decision outranks an `allow` rul

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.

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.
To keep agents out of that escalation in the first place, a `SessionStart` hook (`hooks/emit-rules.mjs`) 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.

`watch-aws` leans on this. Unlike the interpreter sets — which stay silent on most commands and only block destructive variants — it *asks* on most `aws` commands and stays silent only on read-only ops (`get-`/`list-`/`describe-`/`head-`, `s3 ls`). `Bash(aws *)` is what makes those reads frictionless; without it they still hit Claude Code's default prompt. Mutations still prompt and destructive ops (`delete-`, `terminate-`, `s3 rm`, …) are still blocked, because the hook's decision wins over the allow rule.

Expand All @@ -63,7 +63,7 @@ Deciding *which* patterns to add to the allowlist is itself the chore. The engin

For a `Bash` decision the log records the command *shape* — the program plus its leading subcommand tokens (`git push`, `aws s3 cp`), stopping at the first flag, path, or value — rather than the full command, which keeps inline secrets (credentials in flags, URLs, or `VAR=value` prefixes) out of the plaintext log. The shape is what `/ClaudeWatch:learn` groups by, so nothing is lost for the workflow. The log file and its directory are owner-only (`0600`/`0700`).

Then `/ClaudeWatch:learn` aggregates the log into a batch proposal: frequently-allowed commands that aren't in your allowlist yet (promote them), ask rules you keep approving (add an `except`), and blocks that may be in your way. `scripts/analyze-decisions.py` does the read-only analysis; the skill drives the per-item approval. Because it works from the hook's own decisions rather than scanning transcripts heuristically, it distinguishes allowed / asked / blocked instead of guessing what looks read-only. You vet a window's worth of prompts once instead of one at a time. The proposal leads with the window it covers (records, sessions, span) so you can weigh it, and once you've applied changes the skill offers to reset the log (`scripts/reset-decisions.py`, archives by default) so the next pass measures from the new baseline rather than re-surfacing what you just handled.
Then `/ClaudeWatch:learn` aggregates the log into a batch proposal: frequently-allowed commands that aren't in your allowlist yet (promote them), ask rules you keep approving (add an `except`), and blocks that may be in your way. `scripts/analyze.mjs` does the read-only analysis; the skill drives the per-item approval. Because it works from the hook's own decisions rather than scanning transcripts heuristically, it distinguishes allowed / asked / blocked instead of guessing what looks read-only. You vet a window's worth of prompts once instead of one at a time. The proposal leads with the window it covers (records, sessions, span) so you can weigh it, and once you've applied changes the skill offers to reset the log (`scripts/reset.mjs`, archives by default) so the next pass measures from the new baseline rather than re-surfacing what you just handled.

## The `\n#` gate (and how to work around it)

Expand All @@ -89,6 +89,13 @@ claude plugin marketplace add chris-peterson/claude-marketplace
claude plugin install ClaudeWatch@chris-peterson
```

The engine (`scripts/watchdog.mjs`) and the `/ClaudeWatch:learn` tooling
(`scripts/analyze.mjs`, `scripts/reset.mjs`) run on Node, which ships with
Claude Code — there is nothing extra to install, and the hooks run natively on
every platform Claude Code supports. Python is needed only for dev/release-time
tooling (`scripts/gen-plugin-json.py`, `build/gen-rules-doc.py`) and the test
suite's assertion helpers, never on a user's machine.

## Updating

Third-party Claude Code marketplaces have auto-update **off by default**. Either:
Expand Down
6 changes: 3 additions & 3 deletions SCHEMA.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Rules YAML Schema

This is the reference for the **claude-watchdog** rules file format. Each YAML file is a self-contained rule set that the watchdog engine (`scripts/watchdog.py`) evaluates independently.
This is the reference for the **claude-watchdog** rules file format. Each YAML file is a self-contained rule set that the watchdog engine (`scripts/watchdog.mjs`) evaluates independently.

## Overview

Expand Down Expand Up @@ -165,7 +165,7 @@ Two `PreToolUse` hooks point the engine at the `watches/` directory — one for
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/watchdog.py ${CLAUDE_PLUGIN_ROOT}/watches"
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/watchdog.mjs ${CLAUDE_PLUGIN_ROOT}/watches"
}
]
},
Expand All @@ -174,7 +174,7 @@ Two `PreToolUse` hooks point the engine at the `watches/` directory — one for
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/watchdog.py ${CLAUDE_PLUGIN_ROOT}/watches"
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/watchdog.mjs ${CLAUDE_PLUGIN_ROOT}/watches"
}
]
}
Expand Down
Loading
Loading