ClaudeWatch is a Claude Code plugin that enforces Bash command and script
safety via a PreToolUse hook. A generic engine evaluates regex rules loaded
from self-contained YAML rule sets and emits a single coalesced decision
(block / ask / allow) for each Bash, Monitor, Write, or Edit invocation. Rules may
target the bash command string or the body of a script file being written or
edited, so destructive intent is caught both inline (pwsh -Command "...")
and at the moment a script file is authored.
This spec captures the contract — what the system must do — independent of how it's currently implemented. Mechanism notes (current file layout, parser internals) live in §11.
Requirement IDs use [XX-NN]. Categories:
- EN — Engine (input, output, lifecycle)
- RS — Rule sets (file format, discovery)
- RL — Rules (block, ask, except)
- OUT — Output decisions
- LOG — Decision logging (on by default, opt-out side channel)
- HK — Hook wiring
- EXT — Extensibility
- SK — Skills (
/ClaudeWatch:rules,/ClaudeWatch:learn) - DOC — Documentation
- DIST — Distribution / install
- SH — Shipped rule sets
- DEV — Development workflow
Requirements use EARS syntax — Ubiquitous (no
keyword), State-Driven (While), Event-Driven (When), Optional (Where),
Unwanted (If…then).
Core contract: Given tool input on stdin, the engine deterministically
emits exactly one of {deny, ask, no-output} and exits 0. The engine handles
these tool inputs: Bash and Monitor (both matched against the shell command
string they carry), Write (matched against the new file content), and Edit
(matched against the full post-edit file content).
- [EN-01] The engine shall read tool input from stdin as a single JSON object.
- [EN-02] When
tool_nameis not one of"Bash","Monitor","Write","Edit", the engine shall produce no output and exit 0. - [EN-03] When the relevant input field for the tool is empty or absent, the engine shall produce no output and exit 0. The relevant field is
tool_input.commandforBashandMonitor,tool_input.contentforWrite, andtool_input.new_string(combined withtool_input.file_path) forEdit. - [EN-04] If stdin is not valid JSON, then the engine shall produce no output and exit 0.
- [EN-04a] If stdin is not valid JSON, then the engine shall write the parse error to stderr before exiting so the failure is visible in transcripts.
- [EN-05] The engine shall accept a rules path as its first CLI argument.
- [EN-05a] Where the engine is invoked without a rules-path argument, it shall use the directory
../watchesrelative to the engine script. - [EN-06] When the rules path is a directory, the engine shall evaluate every
*.ymlfile in that directory. - [EN-07] When the rules path is a single file, the engine shall evaluate only that file.
- [EN-08] If the rules path does not exist, then the engine shall emit a
denydecision with a "rules not found" reason that names the path. - [EN-09] When the engine evaluates multiple rule files, it shall process them in a stable, sorted order.
- [EN-10] The engine's process exit code shall be
0regardless of decision or error condition. - [EN-11] The engine shall not require any third-party Python packages at runtime.
- [EN-12] When evaluating a
Writeinput, the engine shall match rules against the value oftool_input.content. - [EN-13] When evaluating an
Editinput, the engine shall read the file attool_input.file_path, apply theold_string→new_stringsubstitution (all occurrences iftool_input.replace_allis true, otherwise the first occurrence), and match rules against the resulting full content. If the file cannot be read, the engine shall match againsttool_input.new_stringalone. - [EN-14] When evaluating a
Monitorinput, the engine shall match rules against the value oftool_input.commandand treat it as abashinput throughout — same rule targets ([RL-11]), same command-shape logging ([LOG-03]), same compound-command escalation ([OUT-08]). Rationale: the Monitor tool runs itscommandin the same shell environment asBash, so a Monitor call the engine does not read is an unscreened shell. A Monitor invocation that carries awsobject instead of acommandhas no shell command to screen and falls under [EN-03]. - [EN-15] Before matching a
bashinput, the engine shall normalize the leading words of each command in it by removing the quoting and backslash escaping that shell syntax permits inside a word, so that"git" commit,g""it commit,g\it commit,git "commit"andrm "-r" /etcall reach the rules as the shell reads them. A word shall be rewritten only when it resolves to exactly one word whose quoting closes inside it, and only when that word is bare — letters, digits,_,.,/,-. A word that resolves to anything else shall be left exactly as written, and the walk shall span at most four words past any prefix it steps over. A leadingVAR=valueassignment orsudoprefixes a command without being its program, and an option names one no more than they do, so none of them shall count toward the four; stepping over options is what letsgit -C /repo "push"andaws --profile prod "s3" rmresolve, since both tools carry their global options ahead of the subcommand a rule matches ([SH-01]). Rationale: a rule recognizes a guarded command by the literal text of its program and subcommand, and the shell resolves every one of those spellings to the same word before running anything, so a rule that matches the command as typed must match it as the shell reads it. The bare-word guard is what keeps an argument out of it: a quoted operand carrying an operator is data that both the rules and [OUT-08]'s quoted-span handling read as quoted, so unquoting-m "wip; done"would turn a commit message into what looks like a command boundary. Normalization is pure string rewriting over the command text, so the decision stays a function of its inputs ([EN-01]). It does not change which text a rule matches within a command — patterns search anywhere ([RL-03]), soecho "rm" -rf /reaches the same decision asecho rm -rf /rather than a different one. This does not close deliberate obfuscation in general — a shell has unbounded ways to spell a word, and variable indirection (C=git; $C commit) resolves only at runtime — it closes the spellings that survive as a single literal word.
Logging records each decision for later review (see [SK-13]–[SK-17]) and is on
by default — the decision log is the input the /ClaudeWatch:learn workflow
reads. It is separable from the decision: the engine's output is identical
whether or not logging is enabled.
- [LOG-01] By default — when
CLAUDEWATCH_LOGis unset, or set to1/true/on/yes(case-insensitive) — the engine shall append one JSON record per evaluated input to the default path~/.claude/claudewatch/decisions.jsonl. WhereCLAUDEWATCH_LOGis set to any other non-disabling value, the engine shall treat it as the destination path and append records there. - [LOG-02] Where
CLAUDEWATCH_LOGis set tooff,0,false,none, or the empty string (case-insensitive), the engine shall write no log, and its decision and exit behavior shall be unchanged. This is the opt-out. - [LOG-03] Each log record shall include the decision (
allow/ask/deny), the matched rule reasons, a UTC timestamp, and thesession_id,cwd, and activepermission_modefrom the hook input. For aBashorMonitorinput the record shall include the command shape (fieldcommand_shape) — the program name plus its leading subcommand tokens, with any leadingVAR=valueassignments andsudodropped and the token run stopping at the first flag, path, or value (e.g.git push --force origin main→git push,SECRET=… mysql -p… db→mysql) — rather than the raw command string. The shape is what/ClaudeWatch:learngroups by ([SK-14]); recording it rather than the full command keeps inline secrets (credentials in flags, URLs, or assignments) out of the durable, plaintext log. For aWrite/Editinput the record shall include the target file path rather than the file content. - [LOG-04] Logging shall not influence the decision and shall not change the process exit code. If a log write fails, then the engine shall report the failure to stderr and otherwise continue, still emitting its decision.
- [LOG-05] The engine shall restrict the decision log to owner-only access — file mode
0600and its parent directory mode0700— applied on each write so a pre-existing wider mode is corrected, so that the plaintext log is not readable by other accounts on the host. A failure to set the mode shall be handled as a log-write failure per [LOG-04]. - [LOG-06] The log shall begin with a header line
{"schema": N}declaring the log's schema version, distinct from the per-input records of [LOG-01]. The current version is2(records store the command shape per [LOG-03]); version1is the pre-shape format that recorded the raw command string. On write, where the existing log has no header or declares a version other than the current one, the engine shall discard the existing log and start a fresh one with the current header, so that a log written under an earlier schema — which may contain raw commands with inline secrets — is not carried forward across an upgrade. Consumers of the log (the analyzer [SK-13] and the reset tool [SK-19]) shall ignore the header line. The discard applies to the active log only; archived logs ([SK-19]) are user-retained and left untouched.
A rule set is a single YAML file declaring a named bundle of rules.
- [RS-01] Each rule set shall declare a top-level
namefield. - [RS-02] Where a
filterregex is declared, when it does not match the bash command, the engine shall skip alltarget: bashrules in that set. Thefiltershall not gatetarget: file-contentrules. - [RS-03] Each rule set shall declare a
rulesmap containing optionalblockandasklists. - [RS-04] Where a rule set file is named
*.yml.disabled, the engine shall not load it. - [RS-05] If a rule set fails to load (parse error, file unreadable), then the engine shall emit a
denywith a load-error reason and continue evaluating remaining rule sets. - [RS-06] If a rule set's
filterregex is invalid, then the engine shall emit adenywith the regex error and skip the rest of that set. - [RS-07] Each rule set may declare an optional top-level
extensionsfield as an inline list (e.g.extensions: ['.ps1', '.psm1']). The engine shall evaluatetarget: file-contentrules in this set only when theWrite/Edittarget file's extension matches one of the listed values (case-insensitive). - [RS-08] If a rule set has no
extensionsfield, then the engine shall not evaluate itstarget: file-contentrules for anyWrite/Editinput.
Rules are the matchable units within a rule set.
- [RL-01] Each rule shall declare
name,pattern, andreason. - [RL-02] Each rule may declare an optional
refURL. - [RL-03] Rule patterns shall be Python regexes matched against the input string with
re.search()semantics (matches anywhere; no implicit anchoring). The input string depends on the rule'starget: the bash command fortarget: bash, the script body fortarget: file-content. - [RL-04] If a rule's
patternis empty, then the engine shall emit adenywith a configuration-error reason naming the rule. - [RL-05] If a rule's
patternis an invalid regex, then the engine shall emit adenywith the regex error and continue. - [RL-06] Within a rule set, the engine shall evaluate
blockrules beforeaskrules. - [RL-07] Where an
exceptregex is declared on anaskrule, when bothpatternandexceptmatch, the engine shall skip that rule (no ask emitted). - [RL-08] If an
exceptfield appears on ablockrule, then the engine shall log a warning to stderr and ignore the field; the block rule shall still fire onpatternmatch. - [RL-09] If a rule's
exceptregex is invalid, then the engine shall emit adenywith the regex error and continue. - [RL-10] Each rule may declare an optional
targetfield with valuebashorfile-content. If omitted, the rule defaults totarget: bash. - [RL-11] When evaluating a
BashorMonitorinput, the engine shall evaluate onlytarget: bashrules. - [RL-12] When evaluating a
WriteorEditinput, the engine shall evaluate onlytarget: file-contentrules whose containing rule set'sextensionslist matches the input's file extension. - [RL-13] If a rule's
targetvalue is neitherbashnorfile-content, then the engine shall emit adenywith a configuration-error reason naming the rule. - [RL-14] If a YAML line in a rule set does not match any recognized field at its indent level, then the engine shall log a warning to stderr naming the rule set, the rule (when applicable), and the unrecognized line; the engine shall continue processing the remaining lines. This surfaces typos like
refrence:instead ofref:that would otherwise be silently dropped. - [RL-15] An
askrule may declare the optional inline-list fieldsunless_condition(named engine predicates) andunless_regex(regexes), which exempt the rule the same wayexcept([RL-07]) does. When the rule'spatternmatches, the engine shall skip the rule (no ask emitted) if anyunless_regexentry matches the input or anyunless_conditionpredicate matches — OR'd together, and OR'd withexcept. If anunless_regexentry is an invalid regex, or anunless_conditionnames a predicate the engine does not ship, the engine shall emit adenywith a configuration-error reason naming the rule (an unknown predicate never silently fails to match). Both fields apply only toaskrules; on ablockrule the engine shall log a warning to stderr and ignore them, mirroring [RL-08]. - [RL-16] The engine shall ship the
unless_conditionpredicateis_in_project_tree: it matches (skipping the ask) when the input is abashrmcommand whose every deletion target resolves strictly under an anchor root. The anchor roots shall be the working directory (cwd) supplied in the hook input and the project root supplied in theCLAUDE_PROJECT_DIRenvironment variable Claude Code exports to hook commands;/and the user's home directory shall be rejected as roots, as shall any non-absolute value. A relative target shall be resolved againstcwdonly, since that is the directory the shell itself would resolve it against; an absolute target may sit under either root.cwdfollows the session's shell and therefore leaves the project the moment the session changes directory into a scratch area, while the project root holds still — anchoring on both keeps an in-repo delete exempt from either vantage point. The check shall be pure string resolution of the command's target paths against the roots — no filesystem access — so the decision stays deterministic; both roots are deterministic per-invocation inputs supplied by the host, so the core contract ([EN] determinism) holds. The predicate shall decline (the ask stands) whenever in-tree-ness cannot be established from the command text: no usable root; the command is compound (handled by [OUT-08]); the program is notrm; there are no targets; a target is an anchor root itself or a.gitdirectory; a target carries an unresolvable marker (~,$, backtick, a glob*/?/[, or a..segment); or a target resolves outside every root. - [RL-17] The engine shall ship the
unless_conditionpredicateis_ephemeral_scratch: it matches (skipping the ask) when the input is abashrmcommand whose every deletion target's final path segment names a directory a tool writes and can write again —.playwright-mcp,__pycache__,.pytest_cache,.mypy_cache,.ruff_cache. The premise is the directory's identity rather than its location, so unlike [RL-16] the predicate shall ignore where the target sits: deleting one of these loses nothing that re-running the tool does not reproduce, wherever on disk it is. Matching shall be on the final segment only, so a path inside such a directory is not exempt and the exemption cannot be widened by appending a subdirectory. The predicate shall decline on the same conditions [RL-16] does for compound commands, parse failures, a non-rmprogram, absent targets, and unresolvable markers, so a glob or variable can never stand in for the name. - [RL-18] The engine shall ship the
unless_conditionpredicateis_recoverable: it matches (skipping the ask) when the input is abashrmcommand whose every deletion target satisfies [RL-16]'s pure-string under-root resolution and is provably restorable from git after the delete: every target is tracked, none is a submodule, and no file under any of them is untracked, ignored, or modified without being committed. A submodule is declined rather than answered for — the superproject records only the commit it points at, so work uncommitted inside it is not visible to the checks made here. Unlike [RL-16] and [RL-17] this predicate reads the filesystem: its checks shall be read-only, shall be bounded by a timeout, and shall fail closed — the predicate declines (the ask stands) whenever a check cannot complete, whether the tool is absent, the timeout elapses, or the command reports failure. This is the sole exemption to the no-filesystem-access property [RL-16] states, and it is scoped to this predicate: the decision stays a pure function of the command, the roots, and the on-disk state those name, with no clock, randomness, or network.
The engine emits at most one decision per invocation.
- [OUT-01] When emitting a decision, the engine shall write a single JSON object to stdout matching the Claude Code
hookSpecificOutputschema withhookEventName: "PreToolUse"andpermissionDecisionset to"deny"or"ask". - [OUT-02] The engine shall format each violation canonically as
<rule-name>: <reason>(just<reason>when the rule has no name), with— <ref>appended whenrefis present. The rule-set name is omitted — thereflink supplies that context. This canonical form is what both the displayed reason and the decision log ([LOG-03]) carry. - [OUT-03] When any block rule matches in any rule set, the engine shall emit a single
denydecision aggregating all block reasons, separated by newlines. - [OUT-04] When no block rule matches and at least one ask rule matches in any rule set, the engine shall emit a single
askdecision aggregating all ask reasons, separated by newlines — subject to the compound-command escalation in [OUT-08]. The decision shall be a function of the command alone: the same input grades the same way whateverpermission_modethe host reports, and the engine shall not read that field on the decision path ([LOG-03] logs it; nothing consumes it). Whether an emittedaskreaches a person is the host's half of the contract and is not guaranteed. Two behaviors are established: an interactive session inautoclears the call before the hook'saskhas a prompt surface, so the command runs unconfirmed (claude-code#89561); a headless--printsession has nobody to answer, so the call fails closed and is denied. The engine cannot distinguish these —permission_modereadsautoin both the prompted and the prompt-suppressed case, and--permission-prompts nonesets no field of its own. Anaskis therefore a request for confirmation, never evidence one happened, and a rule guarding something unrecoverable belongs in the block tier rather than relying on the prompt. - [OUT-05] When no rule matches, the engine shall produce no stdout output (allow-by-default).
- [OUT-06] (retired in 0.18.0) —
the ask prompt rendered theClaude Code 2.1.235 began sanitizing hook-supplied reason strings, replacing every control character with U+FFFD, so the escape reached the user as visible garbage instead of a link. Both paths now use the canonical<reason>prose as an OSC 8 terminal hyperlink to theref, withCLAUDEWATCH_HYPERLINKSas the opt-out.— <ref>form of[OUT-02], andCLAUDEWATCH_HYPERLINKSis gone. - [OUT-07] A deny decision's
permissionDecisionReasonshall end with the[plugin:ClaudeWatch]source tag — but the logged reasons ([LOG-03]) shall not. Claude Code annotates ask prompts with the originating plugin but leaves deny errors unattributed, so the engine appends the tag itself on the deny path to keep the source visible. Ask decisions shall not carry the tag (the host supplies it). - [OUT-08] When the coalesced decision for a
BashorMonitorinput isaskand the command is compound — outside any single- or double-quoted span it contains a shell control operator (|,;, newline,&&,$(, or backtick), separates two commands with a lone&, or opens a subshell or process substitution — a(at command position: the start of the command, after a separator or a redirection, and behind thetimeand!keywords — the engine shall escalate the decision todenyand prepend a note stating the ask was escalated because a compound command or subshell can be auto-approved segment-by-segment by the host allow list (skipping the confirmation) and that the guarded command should be run on its own to be prompted. Rationale: adenyis honored in every permission mode and anaskis not ([OUT-04]), so escalating is what keeps a guarded compound from running unremarked. A compound command is the shape where this costs most: the host's allow list can approve a pipeline segment-by-segment and auto-run it before any prompt surfaces, so even a mode that would prompt on the bare command may not prompt on the chained one. The escalation is therefore narrower than the exposure — a bareaskis bypassed inautotoo ([OUT-04]) and is not escalated, because escalating everyaskunder a prompt-less mode would hard-block the ordinary workflow (git commitandgit pushare the two most frequent ask-tier matches) and the engine cannot tell a prompt-less session from a prompting one anyway. The escalation applies only to anaskdecision (adenyalready survives, andallow/no-match stays silent per [OUT-05]) and only to shell-command inputs —BashandMonitor([EN-14]);Write/Editare single operations, not shell pipelines. AMonitorcommand runs unattended and repeats on one approval, so an ask-tier command inside one escalates on the same terms; the way to be prompted is the same, run the guarded command as its ownBashcall. Operators inside quoted spans are string data, not command boundaries, and shall not trigger escalation. A bare subshell groups rather than separates, so it carries none of the listed operators, yet the host treats it as the same class — Claude Code 2.1.257 fixed apermissions.askrule being skipped in auto mode for a command running "inside a compound command or subshell". Restricting the(to command position leaves a parenthesis inside an argument alone, since only a leading(opens a subshell;timeand!are named because they are the two words that may precede one with no operator between them, and</>because a process substitution runs its own command the same way. A lone&both backgrounds a command and separates it from the next, so it counts only when something follows it — a trailing&is one command — and only away from the redirections it shares a character with (2>&1,>&2,&>log).
- [HK-01] The plugin shall register
PreToolUsehooks that invoke the engine against the plugin'swatches/directory for theBash,Monitor,Write, andEdittools. Matchers may be combined via regex alternation (e.g.matcher: "Write|Edit"). - [HK-02] (retired in 0.18.0) —
the plugin shall register aNever implemented; the placeholder hook was removed with it. The ambient-guidanceSessionStarthook for plugin-update self-checks.SessionStarthook of [HK-04] is unaffected. - [HK-03] The plugin shall declare its hooks in
hooks/hooks.json. - [HK-04] The plugin shall register a
SessionStarthook that emits ambient guidance into the session context advising that a consequential command be run as its own Bash call rather than piped or chained, so the compound-command escalation ([OUT-08]) is avoided before it triggers. The guidance shall be sourced fromrules/*.mdso it can be edited without changing the hook.
- [EXT-01] Where a new
*.ymlfile is added to thewatches/directory, the engine shall auto-discover it on the next invocation without any configuration change tohooks.json. - [EXT-02] Where a rule set is renamed from
*.ymlto*.yml.disabled, the engine shall stop loading it on the next invocation. - [EXT-03] New rule sets shall not require code changes to the engine.
The plugin ships interactive Claude Code skills that surface and edit rules without leaving the session.
[SK-01] (retired in 0.16.0) —
theIt was removed; that overview now lives in the README and the docs site./ClaudeWatch:helpskill provided an overview of the plugin.
- [SK-02] Where the user invokes
/ClaudeWatch:rules, the skill shall list every enabled rule set with two tables (block, ask) using stable IDs of the form<short-name>-block-NNand<short-name>-ask-NN(zero-padded). - [SK-03] When listing rule sets, the skill shall also list any disabled rule sets (
*.yml.disabled). - [SK-04] Where the user passes
--list, the skill shall list rules and exit without entering the edit loop. - [SK-05] When the user enters an edit command, the skill shall accept one operation per turn and loop until the user enters
done. The supported operations are:<id>:block(move rule to block),<id>:ask(move rule to ask),<id>:allow(remove the rule),add(add a new rule),disable <name>(disable a rule set),enable <name>(re-enable a disabled rule set), andnew(create a new rule set). - [SK-06] Before writing edits, the skill shall scan for duplicate patterns, shadowing patterns, and cross-section conflicts (same pattern in both block and ask) and shall report any findings to the user.
- [SK-07] Before writing edits, the skill shall present a preview of the updated rule tables and shall require explicit confirmation (
yes/edit/abort) from the user. - [SK-08] When applying edits, the skill shall write only modified rule set files and shall preserve the standard YAML format.
- [SK-09] When the user issues
disable <name>, the skill shall renamewatches/<name>.ymltowatches/<name>.yml.disabled. - [SK-10] When the user issues
enable <name>, the skill shall renamewatches/<name>.yml.disabledtowatches/<name>.yml. - [SK-11] When the user issues
new, the skill shall create a newwatches/watch-<name>.yml(the name shall start withwatch-) with the standard YAML format. - [SK-12] After applying changes, the skill shall run
tests/test-watchdog.shand shall report any failures.
The learn skill aggregates the decision log ([LOG-01]–[LOG-04]) into a batch of proposed permission changes, so the user vets accumulated prompts once rather than per command.
- [SK-13] Where the user invokes
/ClaudeWatch:learn, the skill shall analyze the decision log and present its proposals. If no log exists, the skill shall make no changes and shall report why: when logging is disabled (CLAUDEWATCH_LOGset to an opt-out value per [LOG-02]) it shall warn that disabling logging disables/ClaudeWatch:learnand explain how to re-enable; otherwise (logging on by default, but no records yet) it shall explain that no sessions have run with the hook active. - [SK-14] The skill shall present three groups: allow candidates (frequently-allowed commands not covered by the current allow list), ask candidates (commands ClaudeWatch repeatedly asks about, with the matched rule), and a deny summary (blocked commands grouped by reason, informational). An allow candidate shall name the tool its records came from and propose a permission rule for that tool (
Bash(…)orMonitor(…)), and shall be judged already-covered only against existing rules for the same tool: the host treats the two as separate rule families, so aBashrule does not cover the same command issued throughMonitor. The coverage judgment approximates the host's matcher and shall err toward proposing, since a redundant proposal costs the user a line to read and a wrong suppression costs them a candidate they never see: a rule's literal shall cover a command shape only where it lands on a token boundary, and a rule whose wildcard precedes further pattern text (Bash(git * main)) shall cover nothing. - [SK-15] The skill shall accept an optional time window (e.g.
--since 1d) and forward it to the analysis. - [SK-16] Before writing any change, the skill shall present the exact edits and require explicit confirmation, and shall apply only the items the user approves.
- [SK-17] The skill shall apply approved allow-list additions to a
settings.jsonwhose scope (user or project) the user selects, shall route rule changes (exceptadditions, demotions) through/ClaudeWatch:rules, and shall not modify deny-summary items automatically. - [SK-18] The analysis shall report the window its proposals are drawn from: the number of decision records considered, the count of distinct sessions among them, and the time span from the oldest to the newest record. The skill shall surface this so the user can weigh how much history backs the suggestions.
- [SK-19] The skill shall offer to reset the decision log after the user has applied their chosen changes, so that the next analysis measures from the post-change baseline rather than re-surfacing already-dispositioned commands. Reset shall archive the current log by default — moving it to a fixed user directory (
~/.claude/claudewatch/archive/, beside the durable log per [LOG-01]) so the prior history is recoverable — and shall support a--hardmode that deletes it outright. Where logging is disabled ([LOG-02]) or no log exists, reset shall make no change and shall report why. Reset shall report what it cleared (record count and span).
- [DOC-01] The plugin shall ship a Docsify documentation site under
docs/. - [DOC-02]
just docsshall regenerate the rules-reference page from the YAML rule files. - [DOC-04] The documentation shall include a YAML schema reference covering top-level fields and rule fields.
- [DOC-05]
just docsshall regenerate a prompts page, linked from the docs-site sidebar, approximating the Claude Code permission prompt each rule produces — block rules as an outright rejection, ask rules as a confirmation prompt — with the reason prose linking to the rule'sref.
- [DIST-01] The plugin shall expose the metadata required for installation as a Claude Code plugin (name, version, description, repository, license) in its manifest. The mechanism by which the plugin is hosted or distributed (marketplace registration, install command) is out of scope.
- [DIST-02] The plugin shall declare a manifest at
.claude-plugin/plugin.json. - [DIST-03] The plugin shall be runnable from a working copy via
claude --plugin-dir .(no install required for local testing).
These are the rule sets the plugin ships out of the box. Each is
self-contained and removable by renaming its file to *.yml.disabled.
-
[SH-01] The plugin shall ship the following rule sets:
-
watch-aws— AWS CLI operations classified by reversibility. Block rules shall cover irreversible operations: AWS operations whose verb begins withdelete-,remove-,deregister-,terminate-,purge-,reset-, orrevoke-; the EC2release-addressoperation; and thes3high-levelrmandrbsubcommands. A single ask rule shall match any otheraws <service> <operation>, with anexceptthat allows read-only operations (get-/list-/describe-/head-verb prefixes and thes3high-levellssubcommand). Rules shall match through interspersed global flags (--prefixed options before the service and between service and operation), including quoted values containing spaces, so thataws --profile prod ec2 terminate-instancesis not silently bypassed. -
watch-bash— destructive shell primitives in.sh/.bash/.zshfile content authored viaWrite/Edit. File-content only; bash-target coverage for the same primitives lives inwatch-files. Block rules shall cover:rm -rf /(the trailing/*included),curl|sh/wget|sh,dd of=/dev/sd*(andnvme/disk/hd/xvd),mkfs /dev/..., andshred. Ask rules shall cover: recursiverm -rf(withexceptfor~/.cache/,/tmp/,/var/tmp/,$TMPDIR),chmod 777, recursivechown -R, and shellevalof dynamic strings. -
watch-dotnet— .NET decompilation and NuGet introspection. Ask rules nudge agents toward SourceLink rather than decompilation: .NET decompiler invocations (ilspycmd,ildasm,dotpeek,dnspy/dnspyex,justdecompile),unzip/tarof a.nupkgfile,curl/wgetof a.nupkgURL, andnuget install. Decompiler-name matching shall be case-insensitive so that Windows-style executables (dnSpy.exe,ILDASM.exe) are not bypassed. Each rule'srefshall point to SourceLink documentation. -
watch-files— generic destructive filesystem operations. Block rules shall cover:rm -rf /(the trailing/*included),chmod 777,mv … /dev/null, andshred. Ask rules shall cover: recursiverm -rf, recursiverm -r,mvfrom a root-level path,chmod, andchown. The two recursive-rm ask rules shall carry anunless_regexexempting paths under~/.cache/,/tmp/, or/var/tmp/, andunless_conditionentries ofis_in_project_treeandis_ephemeral_scratch([RL-15]–[RL-17]), so a recursive delete confined to the working tree — recoverable from git history — is allowed, as is one that names a regenerable tool directory wherever it sits, while a delete reaching outside the tree still prompts. Thermrules shall read recursive and force as independent options, so that every spelling of the pair matches — clustered (-rf), split (-r -f), reordered, long (--recursive --force), and the-Rsynonym — and shall boundrmwith shell-command boundaries rather than\b(as thewatch-secretsentry below requires for the same reason), somy-rmdoes not match. They shall also exemptgit rm, whose deletions this rule set leaves towatch-git, which declines them as recoverable staging. -
watch-git— destructive git operations. Block rules shall cover:git push --force(excluding--force-with-lease),git checkout .,git restore .,git clean -f,git stash drop,git stash clear, andgit reflog expire/delete. Ask rules shall cover:git reset --hard,git checkout -- <file>,git commit,git stash,git push,git push --force-with-lease,git push --delete(remote-branch delete, via--delete/-d/the:branchcolon refspec), andgit branch -D. The split follows the no-recovery/recoverable line: deleting a remote branch leaves no remote reflog to recover from, yet you can re-push from a local copy, so it asks rather than blocks;git branch -Dand--force-with-leaseare likewise recoverable (local reflog, stale-ref protection) and so ask. Stage-only operations (git add,git rm,git rm --cached,git resetwithout--hard) are intentionally unwatched — staging is recoverable, and prompting on it is noise. Each rule shall match through git's pre-subcommand global flags (-C <path>,-c <key>=<value>,--git-dir[=]<path>,-P), including quoted values containing spaces, so that invocations likegit -C /repo push --forceare not silently bypassed. A subcommand carrying no arguments shall match on whatever follows it in the shell, not only on whitespace or end-of-string, so(git push),git commit;echo doneand`git stash`are matched — while a longer subcommand that merely starts with the same word (git commit-tree) is not, and neither is a quoted string that merely ends with one, so searching for a guarded command (grep -rn 'git push' .) is not itself guarded. -
watch-installs— package and dependency installation. Block rules shall cover:curl … | sh,wget … | sh,npm install -g/--global,sudo pip[3] install, andbrew install. Ask rules shall cover:npm install,yarn add,pnpm add,pip[3] install,cargo add,cargo install,go install,go get,gem install,composer require, andnpxremote-fetch forms (-y/--yes,-p/--package, or a versioned/scoped spec such aspkg@versionor@scope/pkg). A barenpx <tool>that runs an already-installed binary is allowed, since it executes local code no differently fromnpm run; only the forms that download and run a remote package prompt. An argument-less install shall match on whatever follows it in the shell, so(npm install)andnpm install;echo doneare matched on the same terms asnpm installalone. -
watch-node— Node/JavaScript destructive primitives both inline (innode -e/bun -e/deno/tsx/ts-nodebash invocations) and in.js/.mjs/.cjs/.ts/.mts/.ctsfile content authored viaWrite/Edit. Block rules shall cover:fs.rmSync/rmdirSync/rmof//~/$HOME,child_processexec-family calls invokingrm -rf /, andnew Function(...). Ask rules shall cover:fs.rm/rmSyncwithrecursive: true,fs.unlink/unlinkSync,child_process.exec/execSync,vm.runInThisContext/runInNewContext/runInContext, andeval(...). -
watch-pwsh— PowerShell destructive primitives both inline (inpwsh -Command "..."bash invocations) and in.ps1/.psm1/.psd1file content authored viaWrite/Edit. Block rules shall cover:Format-Volume,Clear-Disk,Restart-Computer,Stop-Computer, andInvoke-WebRequestpiped toInvoke-Expression/iex. Ask rules shall cover:Remove-Item -Recurse -Force(withexceptfor~/.cache/,/tmp/,/var/tmp/on the bash target), otherRemove-Itemforms,Stop-Process -Force, andSet-Content/Out-Fileoverwriting sensitive paths (/etc/,~/.ssh/,~/.aws/). -
watch-python— Python destructive primitives both inline (inpython3 -c "..."bash invocations) and in.pyfile content authored viaWrite/Edit. Block rules shall cover:shutil.rmtreeof//~/$HOME,pickle.loads,__import__('os').system/popen, andsubprocess.*shell=Truecontainingrm/dd of=/dev/. Ask rules shall cover: othershutil.rmtree,os.remove/os.unlink,os.system, genericsubprocess.*shell=True,eval(, andexec(. -
watch-ruby— Ruby destructive primitives both inline (inruby -ebash invocations) and in.rbfile content authored viaWrite/Edit. Block rules shall cover:FileUtils.rm_rf/rm_r/rm_fof//~/ENV[...],Marshal.load/restore, andYAML.load(...)on input. Ask rules shall cover: otherFileUtils.rm_rf,File.delete/unlink,Kernel.system/execwith a string literal, backtick exec with interpolation (#{...}),eval(...), andinstance_eval/class_eval/module_eval. -
watch-secrets— credential and secret exfiltration. Block rules shall cover: reading SSH private keys (cat … /.ssh/id_*), reading cloud credentials (.aws/credentials,.gcp/,.azure/,.config/gcloud), andecho/printf-ing environment variables whose names matchSECRET|TOKEN|PASSWORD|API.?KEY|PRIVATE.?KEY. Ask rules shall cover: reading files whose names suggest secrets, exporting secret-named env vars inline, reading.envfiles, reading*.pem/*.key/*.crt/*.certfiles, dumping the environment viaenvorprintenv, and reading dotfiles. Rules that match commands as full shell tokens (e.g.env,printenv) shall use shell-command boundaries (start/whitespace/;/&&/|/backtick/parens) rather than\bword boundaries, so that hyphenated tokens (my-env) do not defeat the match.
-
-
[SH-02] Each shipped rule set that includes
target: bashrules shall declare a top-levelfilterregex that short-circuits commands outside the rule set's domain. -
[SH-03] Each shipped rule set shall include
refURLs pointing to upstream tool documentation, vendor docs, or a CWE/OWASP entry. -
[SH-04] Each shipped rule set shall have a corresponding test file
tests/test-watch-<name>.shthat exercises representative match/no-match cases.
- [DEV-01]
just testshall run the full test suite viatests/test-watchdog.sh. - [DEV-02] The test suite shall exercise each shipped rule set independently and shall also exercise engine-level behavior (decision aggregation, error handling, file/directory rules paths).
- [DEV-03]
just rulesshall launch an interactive Claude Code session with the local plugin loaded and the rules skill open. - [DEV-04]
just preview-docsshall serve the generated docs locally for review.
These describe how the current implementation satisfies the spec. They are not requirements — they may change without bumping the spec version.
- The engine is a single Python script at
scripts/watchdog.pywith a minimal pure-Python YAML parser (no PyYAML dependency). The parser supports inline list syntax (['.ps1', '.psm1']) for the top-levelextensionsfield. - The hook command line is
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/watchdog.py" "${CLAUDE_PLUGIN_ROOT}/watches", invoked from twoPreToolUsematchers:Bash|MonitorandWrite|Edit. - The ambient-guidance emission ([HK-04]) is a second SessionStart hook,
hooks/emit-rules.sh, which prints a# Ambient rules from the ClaudeWatch pluginheader followed by eachrules/*.mdfile to stdout. Claude Code adds SessionStart stdout to context on startup, resume, and compaction, so the guidance persists across a compaction. This mirrors theemit-rules.shpattern in sibling plugins (beacon,anchor), which also keep their ambient markdown underrules/. The YAML rule sets the engine evaluates live in a separatewatches/directory ([HK-01]), so the two never collide. - The rules-skill ID convention strips the
watch-prefix from the rule set name (e.g.watch-git→git-block-01). - Decision logging is implemented in
watchdog.pyas a single_log_eventcall after the decision is computed; it is on by default andCLAUDEWATCH_LOGonly redirects the path or opts out. The UTC timestamp is the only clock in the script, and it is confined to the logging side channel — the decision path remains clock-free and deterministic. /ClaudeWatch:learnreads the log viascripts/analyze-decisions.py, a separate read-only, stdlib-only tool. The engine remains the only decision-making component; the analyzer never evaluates rules.- The compound-command detection ([OUT-08]) strips quoted spans with a simple
regex before scanning for shell operators. It does not parse escaped quotes
(
\") or$'…', so an operator hidden behind such quoting may go undetected. This is safe by construction: the escalation only ever tightens anaskinto adeny, so a missed compound form degrades to the existingask(the pre-escalation behavior), never to something weaker. Theblock/askpattern matching itself runs on the full, unstripped command, so quote stripping does not affect which rules fire — only whether a matchedaskis escalated.