cc-hooks
Configure default Claude Code enforcement hooks and opt-in guard recipes. Triggers: "cc-hooks", "configure Claude Code hooks", "install hooks".
Install
npx skills add https://github.com/boshu2/agentops/tree/main/skills-codex/cc-hooks
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install boshu2-agentops@llmmart
git clone https://github.com/boshu2/agentops.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole boshu2/agentops collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Claude Code Hooks
Shell commands that fire at specific points in Claude Code's lifecycle.
Hooks enforce mechanically what prose cannot: a model can reason its way past an instruction, but it cannot reason its way past an exit 2 — which is exactly why every hook must be narrow, silent, and reversible.
Named failure mode — chatty happy path: a hook that emits stdout on exit 0 corrupts the tool call it was guarding; silence on success is part of the contract, not a style preference.
Prompt
Add a PreToolUse hook to fleet-router/.claude/settings.json that blocks `git push --force` on the main branch. Keep it silent on exit 0, exit 2 with a message on block, and confirm it fires with a manual test invocation before committing the change.
It's working if
- The hook script exits
2with a stderr message when it blocksgit push --force, and exit0with no stdout on the allowed path. .claude/settings.jsongains one matcher entry for the new hook, alongside the existing hooks list rather than replacing it.- A manual test invocation against the new matcher shows the block firing in the transcript, with exit
2visible, before the change gets committed. - The hook inspects only the
PreToolUsecall it guards, keeping every other file untouched.
Constraints
- Enforcement hooks (the PreToolUse policy dispatcher) ship by DEFAULT: plugin installs auto-wire
hooks/hooks.json; skill copies and checkouts wire with one command (scripts/install-hooks.sh). Operators can disable per host (/plugin disable, or remove the settings matchers). - Injection hooks (SessionStart/UserPromptSubmit context stuffing) stay dead — the #511 teardown proved delta=0 at 10.35M resident tokens. Never ship one; the hookless-cold-start gate still enforces this.
- Keep the happy path silent and block only with the event's documented exit/JSON contract because stray stdout can corrupt a tool call.
- Bound Stop hooks with
stop_hook_activeand scope matchers narrowly to prevent recursion and unrelated-command interception.
Quick Start
Add to ~/.claude/settings.json (user) or .claude/settings.json (project):
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-validator.sh"}]}]}}
Hook Events
| Event | When | Blocks? | Common Use |
|---|---|---|---|
PreToolUse |
Before tool runs | Yes | Block/modify commands |
PostToolUse |
After tool succeeds | Feedback | Auto-format, lint |
PermissionRequest |
Permission dialog | Yes | Auto-approve/deny |
UserPromptSubmit |
Prompt submitted | Yes | Add context, validate |
Stop |
Claude finishes | Yes | Force continue |
SessionStart |
Session begins | No | Load context, set env |
Notification |
Notifications | No | Desktop alerts |
Full schemas: HOOK-EVENTS.md
Matchers
"Bash" → exact match
"Edit|Write" → regex OR
"mcp__.*__write" → MCP tools
"*" or "" → all tools
Tools: Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch
Exit Codes
| Code | Effect |
|---|---|
| 0 | Success - JSON parsed from stdout |
| 2 | Block - stderr fed to Claude |
| Other | Non-blocking error |
Blocking a Tool
Simple (exit 2):
echo "Blocked: reason" >&2 && exit 2
JSON (exit 0):
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}}
Decisions: "allow" (auto-approve), "deny" (block), "ask" (show dialog)
Modifying Input
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
"updatedInput":{"command":"modified-command"}}}
Real-World: DCG + RCH
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
{"type":"command","command":"dcg"},
{"type":"command","command":"rch"}
]}]}}
- DCG: Blocks
git reset --hard,rm -rf,git push --force - RCH: Routes builds to remote workers
Details: DCG-RCH.md
Skill-First Coordination Guard (opt-in)
A copy-paste PreToolUse recipe that nudges agents to load the coordination
skill before hand-rolling the am/atm/ntm/tmux send-keys CLI. This
recipe auto-installs nothing; you opt in per host (unlike the policy
dispatcher, which ships by default).
Context-budget doctrine for hooks: hooks are the most powerful enforcement
(mechanical, can't be reasoned past) but they pollute context — use sparingly. A
hook must be SILENT on the happy path (exit 0, no stdout/stderr), fire ONLY on a
real violation (ideally once per session, sentinel-gated), prefer PreToolUse
violation-guards over UserPromptSubmit/SessionStart per-turn injectors, and
NEVER emit stray stdout on an exit-0 PreToolUse path (it is parsed as JSON and
breaks the tool call). Block via exit 2 + stderr.
The recipe ships both scripts verbatim, a precise head-only matcher (so a
br create --body "...am/atm/ntm..." never false-fires), the two-matcher
opt-in settings.json snippet, and a bats test proving every fire/silent case.
Recipe: SKILL-FIRST-COORDINATION-GUARD.md
Installed-Skill-Edit Guard (opt-in)
A PreToolUse Edit|Write guard that routes an edit of an installed skill copy
(*/.claude/skills/**, .codex, .gemini) back to the repo source of truth
skills/<name>/. This is a TRUE mistake-token — editing an installed/symlinked
copy has no legitimate form (overwritten on install, or symlinks through to the
factory checkout). Zero false-positive surface: it matches tool_input.file_path
only, so a doc that merely mentions claude/skills in its body never fires.
Reversible → it ROUTES (exit 2 + one-line redirect), not hard-blocks. Silent on
every other path; fires once per session. Ships INERT — opt-in installer:
scripts/install-installed-skill-edit-guard.sh # user scope; --project for project
Recipe: INSTALLED-SKILL-EDIT-GUARD.md
Value-proof (why this guard survives the hookless teardown)
The keystone guard ships gate-blind per-fire telemetry: on each fire it
appends exactly one JSONL line — {ts, session, token_class, path_sha256} — to
${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl (override with
AGENTOPS_GUARDRAIL_TELEMETRY). The path is SHA-256 hashed, never raw
(privacy); nothing is written on the happy path; the sensor is inert until the
guard is installed and fires. The pre-registered methodology — metric =
declining fire-ATTEMPT rate over time (a signal the redirect cannot fake, NOT the
circular hand-roll rate), minimum N, noise floor, and null-at-small-N is an
acceptable outcome — satisfies ADR-0002 l.58 ("test or eval evidence showing
positive value"), the criterion whose absence killed 2.x hooks (#511).
Methodology: GUARDRAIL-VALUE-PROOF.md
Read-Budget Guard (opt-in)
A PreToolUse Read|Bash guard that DENIES an unbounded read over the line
budget (AOP_READ_BUDGET_LINES, default 350): a Read with no limit, or a
cat/head/tail whose effective line count exceeds it. The Spotify finding:
the same rule in CLAUDE.md was advisory and ignored, and an over-budget read
re-sends its lines on every later turn. The predicate is a LOOKUP (wc -l on
the exact argument), so it is a standalone guard, never a registry policy. A
limit-bounded slice, a file at/below budget, a pipe, a redirect or quoted text
that merely mentions cat passes. Literal quoted/escaped paths are preserved;
unsupported shell syntax and directory-changing chains fail open. Negative
head counts use the actual number of retained lines for GNU head; rejected
Darwin system utility flags pass after checking executable identity. Positive
signed head counts are bounded by their numeric value. Nothing un-reads bytes
once in context → every attempt blocks (exit 2 + stderr): full message once per session naming the two
correct moves (slice it, or delegate to the plugin's agentops:bulk-reader
subagent / agentops:bulk-read workflow), one short line after. Use bare names
only for standalone definitions or links when the runtime lists them. Waive once with
AOP_WAIVE=core.context:unbounded-read; hashed telemetry adds tool, lines,
budget plus the dispatcher's mode/decision pair. Ships INERT — opt-in installer:
scripts/install-read-budget-guard.sh # user scope; --project for project
Recipe: READ-BUDGET-GUARD.md
Policy Dispatch Engine (ships by default)
The admission-control layer (epic age-4qw1): one PreToolUse dispatcher —
hooks/policy-dispatch.sh — evaluating a
policies-as-data registry
(policies/policies.json, contract
schemas/hooks-manifest.v2.schema.json) instead of N hand-wired settings
entries. This is the membrane at tool-call altitude: same vocabulary, lower
altitude than the pawl/gate at push time.
Per policy: dcg-style id (domain.object:token), mode: deny | route | audit,
matchers (tool + command/file_path regex), a route_message that names THE
correct tool, a rationale, and a pre-registered value_proof (the ADR-0002
lease-on-life: no proof accruing → retire the policy).
Predicate discipline, schema-enforced (the #511 anti-lesson): only
predicate_class: pure — syntactic mistake-tokens over the command or file
path — may deny/route. Lookup/stateful predicates ship audit-only until
promoted with reviewed fires.
scripts/lint-policies.sh enforces this mechanically
(jq-only; runs in bats and CI).
Accepted false-positive surface: because a pure predicate matches its token
anywhere in the raw command string, a protected token quoted as data (a commit
message body, a dcg test "..." probe, a here-doc payload) can still fire even
though nothing harmful would run. This is the deliberate cost of the
pure-only-may-deny rule — the alternative (repo/context lookups) is exactly the
stateful predicate the discipline bars from deny. Every fire is reversible: a
one-shot AOP_WAIVE=<policy-id> or a policy-waivers line clears it.
Semantics: happy path = exit 0, zero output. deny = exit 2 + one stderr
route line (full message once per session, short line after — every attempt
still blocks). route = exit 0 + permissionDecision:"ask" JSON. audit =
allow + record. Every fire appends one hashed guardrail-telemetry line
(token_class = policy id, plus mode/decision). Waive once with
AOP_WAIVE=<policy-id>, or a policy-waivers file line
<policy-id> <expiry-epoch>. Missing registry or jq fails OPEN.
Enforce cohort (all pure-regex, high-pain). The first four are the day-1 maintainer cohort (age-wnyt) — they guard this repository's artifacts. The fifth guards the product's own invariant and therefore fires on every consumer repo, not just this one:
| Policy | Blocks | Routes to |
|---|---|---|
core.git:add-beads-ledger |
git add naming _beads/ (private ledger leak is one-way) |
push the ledger repo itself — never git add _beads in the public tree |
core.provenance:ledger-hand-append |
redirect/tee/Edit/Write onto docs/provenance/ledger.jsonl (hash-chained, sealed) |
ao provenance add |
core.skills:copy-into-installed |
cp/rsync/mv INTO ~/.claude|.codex|.gemini/skills (dest-position enforced) |
ao skills link |
core.skills:edit-installed-copy |
Edit/Write of an installed skill copy (file_path only — prose can never fire it) |
edit repo skills/<name>/ |
core.verdicts:hand-edit |
Edit/Write, or Bash >/>>/tee/cp/rsync/mv INTO .agents/ao/verdicts/ (dest-position enforced) — the filename IS the SHA-256 of the content, so a hand edit breaks digest identity |
re-run validation and let it persist a fresh artifact (validate.py store-verdict) |
core.verdicts:hand-edit is the one policy whose subject is the promise
rather than the repo: a verdict that no longer hashes to its own filename is
forged evidence, and nothing above the tool-call altitude catches it. Reading
the store is untouched — cat/ls/jq/rg/diff over a verdict, and
copying one OUT for inspection, never fire; only writes landing IN the store
do — including in-place editors (sed -i, perl -pi/-ni) and deleters
(rm, unlink, shred), matched as flag-tokens so a read whose script text
merely contains -i stays silent (bats-proven both directions). Remaining
disclosed gap: the noclobber override redirect (>|).
How it reaches users — every install path delivers hooks:
| Install path | Delivery |
|---|---|
Claude Code plugin (claude plugin install agentops@agentops-marketplace) |
Automatic — the plugin bundles hooks/hooks.json (${CLAUDE_PLUGIN_ROOT} paths); hooks are active on install, no wiring step |
npx skills@latest add boshu2/agentops / skills.sh copy |
The skill package carries its own installer: ~/.claude/skills/cc-hooks/scripts/install-hooks.sh (one command; file copies cannot self-wire) |
| git clone / brew checkout | scripts/install-policy-dispatch.sh (delegates to the same skill-embedded installer) |
The installer lints the registry before wiring, backs up settings, and is
idempotent. Disable per host with /plugin disable agentops or by removing the
two PreToolUse matchers from settings.
Contract tests: tests/scripts/policy-dispatch.bats (block+message+telemetry
per policy, stray-stdout hazard, waivers, audit/route modes, fail-open).
Writing Your Own Hook
Minimal Python:
#!/usr/bin/env python3
import json, sys
data = json.load(sys.stdin)
cmd = data.get('tool_input', {}).get('command', '')
if 'dangerous' in cmd:
print("Blocked: dangerous", file=sys.stderr)
sys.exit(2)
sys.exit(0) # Allow
Hook input (stdin):
{"tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"...","cwd":"..."}
Environment Variables
| Variable | Scope | Purpose |
|---|---|---|
CLAUDE_PROJECT_DIR |
All | Project root |
CLAUDE_ENV_FILE |
SessionStart/Setup | Persist env vars |
Stop Hook (Force Continue)
{"decision":"block","reason":"Tests failing. Fix before stopping."}
Critical: Check stop_hook_active to prevent infinite loops.
Anti-Patterns
| Don't | Do |
|---|---|
| Old object format | Array format with matcher |
Unquoted $VAR |
"$VAR" |
| Exit 2 with JSON | Exit 2 uses stderr only |
Skip stop_hook_active check |
Always check in Stop hooks |
Debugging
claude --debug # Hook execution details
/hooks # View/edit in REPL
Output Specification
- Path: user
~/.claude/settings.jsonor project.claude/settings.json, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in. - Filename: preserve
settings.json; give scripts descriptive executable filenames rather than embedding large shell programs in JSON. - Format: valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema.
- Exit code: validate with
jq -e '.hooks | type=="object"' <settings.json>and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation. - Downstream handoff: consumed by the operator only after the exact scope, reversal command, test evidence, and opt-in location are reported.
Quality Checklist
- The matcher fires on the intended event/input and stays silent on representative near misses.
- Blocking and allow paths use the documented exit code and output channel without leaking context.
- The hook is reversible, narrowly scoped, recursion-safe, and clearly labeled as opt-in host policy.
References
- HOOK-EVENTS.md - All events with full schemas
- DCG-RCH.md - Production examples (dcg, rch)
- INSTALLED-SKILL-EDIT-GUARD.md - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
- READ-BUDGET-GUARD.md - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation
- GUARDRAIL-VALUE-PROOF.md - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
- PATTERNS.md - Auto-format, logging, notifications
- JSON-OUTPUT.md - Response schemas
Files (agentops)
-
hooks
-
codex-read-budget-guard.sh 1.9 KB
#!/usr/bin/env bash # Opt-in Codex PreToolUse adapter for the documented canonical Bash event. # Codex shell/exec_command calls arrive as tool_name=Bash, tool_input.command. # Read/read_file and MCP tools are not mapped here. The sibling guard owns the # policy, waivers, line counting and hashed telemetry; only denial advice differs. # Source: https://learn.chatgpt.com/docs/hooks (Codex CLI 0.154.0 contract). # No preamble: this installed hook must fail open, independent of the checkout. set -uo pipefail [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 command -v jq >/dev/null 2>&1 || exit 0 # CDPATH= clears a caller's directory-search setting for this one cd. # shellcheck disable=SC1007 hook_dir="$( (CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 2>/dev/null)" || exit 0 core="${hook_dir}/read-budget-guard.sh" [ -r "$core" ] || exit 0 input="$(cat 2>/dev/null)" || exit 0 printf '%s' "$input" | jq -e ' type == "object" and .hook_event_name == "PreToolUse" and .tool_name == "Bash" and (.tool_input.command | type == "string") ' >/dev/null 2>&1 || exit 0 # Capture only diagnostics, never relay stdout. Unexpected guard errors remain # fail-open; only the shared guard's explicit denial preserves exit 2. diagnostic="$(printf '%s' "$input" | bash "$core" 2>&1 >/dev/null)" decision=$? [ "$decision" -eq 2 ] || exit 0 while IFS= read -r line; do line="${line//agentops:bulk-reader/bulk-reader}" case "$line" in '→ Read a slice:'*) printf '%s\n' "→ Read a bounded shell slice: sed -n 'START,ENDp' <file>, within AOP_READ_BUDGET_LINES (default 350)." >&2 ;; ' Agent tool:'*) printf '%s\n' ' Codex: delegate the question and file path to the installed bulk-reader role; request path:line bullets only.' >&2 ;; ' Workflow:'*|' These names require the AgentOps plugin.'*) ;; *) printf '%s\n' "${line//offset+limit \/ sed -n/sed -n}" >&2 ;; esac done <<< "$diagnostic" exit 2 -
installed-skill-edit-guard.sh 4.6 KB
#!/usr/bin/env bash # installed-skill-edit-guard (PreToolUse / Edit|Write) # age-workflow-guardrail-hooks-j39.1 — route Edit/Write of an INSTALLED skill copy # back to the repo source of truth. # # The mistake-token: an Edit/Write whose target path is under */.claude/skills/** # (or .codex/skills, .gemini/skills) has NO legitimate form — those are the # installed / symlinked copies (overwritten on install; symlinks through to the # factory checkout). The source of truth is skills/<name>/ in the agentops repo. # # Reversible footgun -> ROUTE, not hard-block: exit 2 + a one-line stderr redirect. # # Context-budget discipline (hooks are powerful but pollute context — use sparingly): # - SILENT on the happy path: any other file_path -> exit 0, zero stdout/stderr. # - Fires its one redirect ONLY on an installed-skill-copy edit, at most ONCE # per session (sentinel-gated) so it never repeats. # - NEVER emits stray stdout on an exit-0 PreToolUse path (stdout there is # parsed as JSON). Block via exit 2 + stderr only. set -uo pipefail # Fail OPEN if jq is unavailable (the dispatcher precedent): a guard that can't # parse its input must never brick a tool call. Explicit preflight so the # fail-open is intentional, not an accident of an empty path falling through. command -v jq >/dev/null 2>&1 || exit 0 input="$(cat)" path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')" sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')" # Match ONLY the file_path: an Edit/Write target under an installed skills dir. # We match the path segment `.claude/skills/` (or .codex/.gemini) anywhere in the # path so ~, $HOME, and absolute /Users/*/.claude/skills/** all hit. We match the # file_path field only — a repo doc whose BODY mentions "claude/skills" lands in # tool_input.content, never file_path, so prose can never fire this guard. case "$path" in */.claude/skills/*|*/.codex/skills/*|*/.gemini/skills/*) : # installed skill copy -> fire ;; *) exit 0 # repo skills/**, any other path -> SILENT happy path ;; esac dir="${TMPDIR:-/tmp}/claude-installed-skill-edit-guard" sentinel="$dir/${sid//\//_}" [ -f "$sentinel" ] && exit 0 # already redirected this session mkdir -p "$dir" 2>/dev/null || true : > "$sentinel" 2>/dev/null || true # Derive the repo-relative target so the redirect is actionable. name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)/.*#\2#p')" [ -n "$name" ] || name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)$#\2#p')" hint="skills/<name>/" [ -n "$name" ] && hint="skills/${name}/" # --- value-proof telemetry (age-workflow-guardrail-hooks-j39.2) ------------- # Emit EXACTLY one gate-BLIND JSONL line per FIRE. The metric is the # fire-ATTEMPT rate over time (a learning signal the redirect itself cannot # fake) — see references/GUARDRAIL-VALUE-PROOF.md. PRIVACY: never the raw # command/path — only a SHA-256 hash of the path. Inert until the guard is # installed (this code only runs when the guard fires). Best-effort: telemetry # failure must NEVER change the guard's exit behavior. emit_telemetry() { command -v jq >/dev/null 2>&1 || return 0 # Hash the path (privacy): sha256sum / shasum -a 256 / openssl, first available. local h="" if command -v sha256sum >/dev/null 2>&1; then h="$(printf '%s' "$path" | sha256sum | cut -d' ' -f1)" elif command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$path" | shasum -a 256 | cut -d' ' -f1)" elif command -v openssl >/dev/null 2>&1; then h="$(printf '%s' "$path" | openssl dgst -sha256 | sed 's/^.*= *//')" else return 0 # no hasher -> emit nothing rather than risk leaking the raw path fi [ -n "$h" ] || return 0 local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}" local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}" mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0 local line line="$(jq -nc \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg session "$sid" \ --arg token_class "installed-skill-edit" \ --arg path_sha256 "$h" \ '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256}' \ )" || return 0 printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0 } emit_telemetry cat >&2 <<MSG ⛔ INSTALLED-SKILL EDIT: do not edit installed skill copies. ${path} is an INSTALLED / symlinked copy — overwritten on install, or symlinked through to the factory checkout. Editing it is lost work. → Edit ${hint} in the agentops repo (the source of truth) instead. Fires once per session. Re-run your edit against the repo skills/ path. MSG exit 2 -
policy-dispatch.sh 6.4 KB
#!/usr/bin/env bash # policy-dispatch.sh — ONE PreToolUse dispatcher over a policies-as-data registry # (age-bhsz / epic age-4qw1: admission control — the membrane at tool-call altitude). # # Reads the PreToolUse JSON once from stdin, evaluates every applicable policy # from the registry, and emits one decision: # deny -> exit 2 + route message on stderr (blocks the tool call) # route -> exit 0 + permissionDecision:"ask" JSON on stdout (surfaces a dialog) # audit -> exit 0, silent; the fire is only recorded in telemetry # Happy path: exit 0, ZERO output (stray stdout on exit-0 is parsed as JSON by # the harness and breaks the tool call — see skills/cc-hooks/SKILL.md). # # Predicate discipline (the #511 anti-lesson, schema-enforced by # scripts/lint-policies.sh + schemas/hooks-manifest.v2.schema.json): predicates # are SYNTACTIC mistake-tokens only — pure regex over tool_input.command or # tool_input.file_path. Policies with predicate_class other than "pure" are # structurally barred from deny/route until promoted from audit. # # Registry resolution order: $AOP_POLICIES, then policies.json beside this # script (installed layout), then ../policies/policies.json (repo layout). # # Waivers: AOP_WAIVE="id1,id2" env (one-shot), or a waiver file # ($AGENTOPS_HOME/policy-waivers, default ~/.agents/ao/policy-waivers) with # lines "<policy-id> <expiry-unix-epoch>". # # Telemetry: one JSONL line per fire (deny, route, audit, waived) appended to # $AGENTOPS_GUARDRAIL_TELEMETRY (default $AGENTOPS_HOME/guardrail-telemetry.jsonl, # AGENTOPS_HOME defaulting to ~/.agents/ao). Schema is a superset of the # installed-skill-edit-guard line: {ts, session, token_class, path_sha256} plus # {mode, decision}. The matched value is hashed, never stored raw. Telemetry # failure never changes the exit decision. set -uo pipefail # shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" registry="${AOP_POLICIES:-}" if [ -z "$registry" ]; then if [ -f "${script_dir}/policies.json" ]; then registry="${script_dir}/policies.json" else registry="${script_dir}/../policies/policies.json" fi fi # Fail OPEN if the registry or jq is unavailable: an admission layer that # bricks every tool call on a missing file is worse than no layer (dcg # precedent: fail-open on timeout). command -v jq >/dev/null 2>&1 || exit 0 [ -f "$registry" ] || exit 0 input="$(cat)" # 2>/dev/null: malformed stdin must be FULLY silent (fail open), not leak jq # parse errors to stderr (validator finding F3, 2026-07-20). tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)" cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)" fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)" sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)" [ -n "$tool" ] || exit 0 hash_value() { # SHA-256 of $1 for telemetry privacy; empty string when no hasher exists. if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1 elif command -v openssl >/dev/null 2>&1; then printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//' fi } emit_telemetry() { # $1 policy id, $2 mode, $3 decision, $4 matched value local h h="$(hash_value "$4")" [ -n "$h" ] || return 0 local tdir="${AGENTOPS_HOME:-${HOME}/.agents/ao}" local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}" mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0 local line line="$(jq -nc \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg session "$sid" \ --arg token_class "$1" \ --arg path_sha256 "$h" \ --arg mode "$2" \ --arg decision "$3" \ '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision}' \ )" || return 0 printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0 } waived() { # $1 policy id -> 0 when a waiver applies. case ",${AOP_WAIVE:-}," in *",$1,"*) return 0 ;; esac local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME}/.agents/ao}/policy-waivers}" [ -f "$wfile" ] || return 1 local now id expiry now="$(date +%s)" while read -r id expiry _; do [ "$id" = "$1" ] || continue case "$expiry" in (*[!0-9]*|'') continue ;; esac [ "$expiry" -gt "$now" ] && return 0 done < "$wfile" return 1 } deny_id=""; deny_msg=""; deny_val="" route_id=""; route_msg=""; route_val="" # Iterate matchers flattened as unit-separator-joined fields: # id / mode / field / pattern / route_message. NOT @tsv — TSV escaping mangles # backslashes inside regex patterns (\. arrives as \\.), silently breaking # every pattern that escapes a metacharacter. while IFS=$'\x1f' read -r pid pmode pfield ppattern pmsg; do [ -n "$pid" ] || continue case "$pfield" in command) val="$cmd" ;; file_path) val="$fpath" ;; *) continue ;; esac [ -n "$val" ] || continue printf '%s' "$val" | grep -qE "$ppattern" || continue if waived "$pid"; then emit_telemetry "$pid" "$pmode" "waived" "$val" continue fi case "$pmode" in deny) if [ -z "$deny_id" ]; then deny_id="$pid"; deny_msg="$pmsg"; deny_val="$val"; fi ;; route) if [ -z "$route_id" ]; then route_id="$pid"; route_msg="$pmsg"; route_val="$val"; fi ;; audit) emit_telemetry "$pid" "audit" "audit" "$val" ;; esac done < <(jq -r --arg tool "$tool" ' .policies[] | . as $p | .matchers[] | select(.tools | index($tool)) | [$p.id, $p.mode, .field, .pattern, ($p.route_message // "")] | join("") ' "$registry" 2>/dev/null) if [ -n "$deny_id" ]; then emit_telemetry "$deny_id" "deny" "deny" "$deny_val" sdir="${TMPDIR:-/tmp}/aop-policy-dispatch" sentinel="${sdir}/${sid//\//_}-${deny_id//[^a-zA-Z0-9]/_}" if [ -f "$sentinel" ]; then printf '⛔ policy %s: blocked (reason shown earlier this session).\n' "$deny_id" >&2 else mkdir -p "$sdir" 2>/dev/null || true : > "$sentinel" 2>/dev/null || true printf '⛔ policy %s\n%s\n' "$deny_id" "$deny_msg" >&2 fi exit 2 fi if [ -n "$route_id" ]; then emit_telemetry "$route_id" "route" "ask" "$route_val" jq -nc --arg reason "policy ${route_id}: ${route_msg}" \ '{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"ask", permissionDecisionReason:$reason}}' exit 0 fi exit 0 -
read-budget-guard.sh 19.4 KB
#!/usr/bin/env bash # read-budget-guard (PreToolUse / Read|Bash) — policy core.context:unbounded-read # # Blocks an UNBOUNDED read of a text file over the line budget (default 350). # The mistake-token: a Read without a numeric limit, or a Bash cat/head/tail # whose EFFECTIVE line count exceeds the budget. Every such line lands in this # context and is re-sent on every later turn; the same rule written into # CLAUDE.md was advisory and ignored (the Spotify finding), so it lives here as # a hook that can refuse. A LOOKUP predicate (wc -l on the exact argument), so # this is a STANDALONE opt-in guard — never a policies.json registry entry # (the dispatcher accepts pure regex predicates only). Ships INERT: nothing # wires it until scripts/install-read-budget-guard.sh is run explicitly. # # Decision (deny-not-route: EVERY attempt blocks, the guard never self-relaxes): # FIRE -> exit 2 + stderr. First fire in a session: the FULL message (both # correct moves — slice it, or delegate to a bulk-reader); later # fires in the same session: ONE short line (sentinel-gated). # PASS / WAIVED / DISABLED -> exit 0, ZERO stdout, ZERO stderr (stray stdout # on an exit-0 PreToolUse path is parsed as JSON by the harness). # # PASS cases: a Read with a numeric # limit; a file at/below budget; a missing / non-regular / binary path; a Bash # command containing | < > (a bounded consumer or a file sink); any command # word other than cat/head/tail; unresolvable tokens ($VAR, globs, backticks); # quoted text that merely mentions cat (the split is quote-aware). # # Env: # AOP_READ_BUDGET_LINES line budget (positive integer; malformed -> 350) # AOP_WAIVE comma list of waived policy ids (env, or an inline # AOP_WAIVE=<ids> prefix on the Bash command) # AOP_WAIVER_FILE "<id> <expiry-unix-epoch>" lines, same semantics # as policy-dispatch.sh # AGENTOPS_HOOKS_DISABLED=1 kill switch: exit 0, silent, no telemetry # AGENTOPS_GUARDRAIL_TELEMETRY / AGENTOPS_HOME telemetry ledger location # # Telemetry: exactly one JSONL line per FIRE and per WAIVED call — never on # pass / disabled / fail-open. The RESOLVED offending path is hashed (SHA-256); # the raw path and the raw command are never stored. Telemetry failure never # changes the exit decision. # # Fail OPEN: no jq -> exit 0; malformed JSON -> exit 0 silent; empty or unknown # tool -> exit 0. Portable bash 3.2 + BSD tools: no GNU-only flags, no sed -i, # no mapfile, no associative arrays; head/tail flags parsed with case. Bash # judging also fails open without awk or uname. set -uo pipefail # Tokens are matched literally: a `*` / `?` / `[` in a command must never be # expanded against the hook's own cwd. set -f # Kill switch: silent, no telemetry, before anything else is touched. [ "${AGENTOPS_HOOKS_DISABLED:-}" = "1" ] && exit 0 # Fail OPEN if jq is unavailable: a guard that cannot parse its input must # never brick a tool call. command -v jq >/dev/null 2>&1 || exit 0 policy_id="core.context:unbounded-read" input="$(cat)" # 2>/dev/null: malformed stdin must be FULLY silent (fail open), never leak jq # parse errors to stderr. tool="$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)" [ -n "$tool" ] || exit 0 case "$tool" in Read|Bash) ;; *) exit 0 ;; esac sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)" [ -n "$sid" ] || sid="nosession" cwd="$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null)" [ -n "$cwd" ] || cwd="$PWD" # Normalize decimal strings before arithmetic. Bash wraps overflowing integers; # saturate budgets at its signed 64-bit maximum and reject out-of-range # command counts. Leading zeroes do not invoke octal arithmetic. max_integer=9223372036854775807 normalize_uint() { local value="$1" case "$value" in ''|*[!0-9]*) return 1 ;; esac value="${value#"${value%%[!0]*}"}" [ -n "$value" ] || value=0 # Equal-width decimals compare lexically before entering machine arithmetic. # shellcheck disable=SC2071 if [ "${#value}" -gt 19 ] || { [ "${#value}" -eq 19 ] && [[ "$value" > "$max_integer" ]]; }; then [ "${2:-}" = exact ] && return 1 value="$max_integer" fi printf '%s' "$value" } budget="$(normalize_uint "${AOP_READ_BUDGET_LINES:-350}")" || budget=350 [ "$budget" -gt 0 ] || budget=350 # Set when a leading AOP_WAIVE=<ids> assignment on the Bash command names this # policy: the WHOLE call is waived. inline_waived=0 # resolve_path P → absolute path: relative paths resolve against the JSON cwd. resolve_path() { case "$1" in /*) printf '%s' "$1" ;; \~|\~/*) # Bash tokens already have their unquoted tilde expanded by the lexer. # Read paths keep the existing HOME shorthand; otherwise retain it literally. if [ "${2:-}" != literal ] && [ -n "${HOME:-}" ]; then printf '%s%s' "$HOME" "${1#\~}"; else printf '%s/%s' "$cwd" "$1"; fi ;; *) printf '%s/%s' "$cwd" "$1" ;; esac } # is_text_file P → 0 when P is an existing, readable, regular file with no NUL # byte in its first 8 KiB (the portable binary test: compare the byte count # with and without NULs stripped). is_text_file() { [ -f "$1" ] && [ -r "$1" ] || return 1 local all stripped all="$(head -c 8192 "$1" 2>/dev/null | wc -c | tr -d ' ')" stripped="$(head -c 8192 "$1" 2>/dev/null | tr -d '\000' | wc -c | tr -d ' ')" [ "$all" = "$stripped" ] } # line_count P → number of newline characters in P (trimmed). line_count() { wc -l < "$1" 2>/dev/null | tr -d ' ' } hash_value() { # SHA-256 of $1 for telemetry privacy; empty string when no hasher exists. if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1 elif command -v openssl >/dev/null 2>&1; then printf '%s' "$1" | openssl dgst -sha256 | sed 's/^.*= *//' fi } emit_telemetry() { # $1 decision (deny|waived), $2 resolved path, $3 effective lines, $4 tool. # Best-effort: no hasher -> no line (never leak the raw path); any failure # returns 0 so the exit decision is unchanged. # No ledger location at all (no HOME, no AGENTOPS_* override) -> no line; # never anchor the default at the filesystem root. [ -n "${AGENTOPS_GUARDRAIL_TELEMETRY:-}${AGENTOPS_HOME:-}${HOME:-}" ] || return 0 local h h="$(hash_value "$2")" [ -n "$h" ] || return 0 local tdir="${AGENTOPS_HOME:-${HOME:-}/.agents/ao}" local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}" mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0 local line line="$(jq -nc \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg session "$sid" \ --arg token_class "$policy_id" \ --arg path_sha256 "$h" \ --arg mode "deny" \ --arg decision "$1" \ --arg tool "$4" \ --argjson lines "$3" \ --argjson budget "$budget" \ '{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256, mode:$mode, decision:$decision, tool:$tool, lines:$lines, budget:$budget}' \ 2>/dev/null)" || return 0 # Braces: a failed redirect is reported by bash BEFORE a trailing 2>/dev/null # applies, and an exit-0 path must stay silent. { printf '%s\n' "$line" >> "$tfile"; } 2>/dev/null || return 0 } waived() { # 0 when a waiver applies: inline prefix, AOP_WAIVE env, or an unexpired # waiver-file entry (same semantics as policy-dispatch.sh). [ "$inline_waived" -eq 1 ] && return 0 case ",${AOP_WAIVE:-}," in *",${policy_id},"*) return 0 ;; esac local wfile="${AOP_WAIVER_FILE:-${AGENTOPS_HOME:-${HOME:-}/.agents/ao}/policy-waivers}" [ -f "$wfile" ] || return 1 local now id expiry now="$(date +%s)" while read -r id expiry _; do [ "$id" = "$policy_id" ] || continue case "$expiry" in (*[!0-9]*|'') continue ;; esac [ "$expiry" -gt "$now" ] && return 0 done < "$wfile" return 1 } fire() { # $1 resolved path, $2 effective lines, $3 tool. Never returns. if waived; then emit_telemetry "waived" "$1" "$2" "$3" exit 0 fi emit_telemetry "deny" "$1" "$2" "$3" local sdir="${TMPDIR:-/tmp}/aop-read-budget-guard" local sentinel="${sdir}/${sid//\//_}" if [ -f "$sentinel" ]; then printf '⛔ policy %s: %s is %s lines (budget %s) — slice it (offset+limit / sed -n) or delegate to agentops:bulk-reader (full reason shown earlier this session).\n' \ "$policy_id" "$1" "$2" "$budget" >&2 exit 2 fi mkdir -p "$sdir" 2>/dev/null || true { : > "$sentinel"; } 2>/dev/null || true cat >&2 <<MSG ⛔ policy ${policy_id} $1 is $2 lines (budget ${budget}). An unbounded read puts every line into this context and re-sends it on every later turn. → Read a slice: Read(file_path, offset, limit) with limit ≤ ${budget}, or Bash: sed -n '1,${budget}p' $1 / grep -n <pattern> $1. → Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context: Agent tool: subagent_type "agentops:bulk-reader", prompt "<question>\nfiles: $1" Workflow: agentops:bulk-read { question: "<question>", files: ["$1"] } These names require the AgentOps plugin. Use bare names only when the runtime lists standalone definitions or links under those names. Waive once: AOP_WAIVE=${policy_id} (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=$2 in the hook env (an operator setting, not a command prefix). MSG exit 2 } # ---------------------------------------------------------------- Read ------ check_read() { local fpath ltype abs lines fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path | select(type == "string")' 2>/dev/null)" [ -n "$fpath" ] || return 0 # A bounded slice always passes; offset alone does NOT bound. ltype="$(printf '%s' "$input" | jq -r '.tool_input.limit | type' 2>/dev/null)" [ "$ltype" = "number" ] && return 0 abs="$(resolve_path "$fpath")" is_text_file "$abs" || return 0 lines="$(line_count "$abs")" case "$lines" in ''|*[!0-9]*) return 0 ;; esac [ "$lines" -gt "$budget" ] || return 0 fire "$abs" "$lines" "Read" } # ---------------------------------------------------------------- Bash ------ # Resolve an executable without invoking it. Bare names use the command's # literal PATH assignments and cwd; paths containing / resolve against cwd. resolve_command() { case "$1" in */*) resolve_path "$1" literal ;; *) (cd "$cwd" 2>/dev/null && PATH="$2" type -P -- "$1" 2>/dev/null) ;; esac } # check_segment receives literal words from the lexer, prefixed with "a" for # syntactic assignments or "w" for ordinary words. Never eval command input. check_segment() { local -a toks files toks=("$@"); files=() local n i t cmdw command_literal command_path utility_family platform v n_raw want_next sign num options local lookup_path="${PATH:-}" n=${#toks[@]} [ "$n" -gt 0 ] || return 0 i=0 while [ "$i" -lt "$n" ]; do t="${toks[$i]}" case "$t" in aPATH=*) lookup_path="${t#aPATH=}" ;; aAOP_WAIVE=*) v="${t#aAOP_WAIVE=}" case ",${v}," in *",${policy_id},"*) inline_waived=1 ;; esac ;; a*) ;; *) break ;; esac i=$((i + 1)) done [ "$i" -lt "$n" ] || return 0 command_literal="${toks[$i]#w}" cmdw="${command_literal##*/}" case "$cmdw" in cat|head|tail) ;; *) return 0 ;; esac command_path="$(resolve_command "$command_literal" "$lookup_path")" || return 0 [ -n "$command_path" ] || return 0 command_path="$(resolve_path "$command_path" literal)" [ -f "$command_path" ] && [ -x "$command_path" ] || return 0 # Darwin system utilities reject some GNU forms without reading. Follow # executable identity, including symlinks: basename alone is insufficient. # uname is a guard dependency; never probe a caller-selected executable. utility_family=generic platform="$(uname -s 2>/dev/null)" || return 0 if [ "$platform" = Darwin ]; then case "$cmdw" in cat) [ "$command_path" -ef /bin/cat ] && utility_family=bsd-cat ;; head) [ "$command_path" -ef /usr/bin/head ] && utility_family=bsd-head ;; tail) [ "$command_path" -ef /usr/bin/tail ] && utility_family=bsd-tail ;; esac fi i=$((i + 1)) n_raw=10; want_next=""; options=1 while [ "$i" -lt "$n" ]; do t="${toks[$i]:1}" i=$((i + 1)) if [ -n "$want_next" ]; then n_raw="$t"; want_next=""; continue fi if [ "$options" -eq 1 ]; then case "$t" in --) options=0; continue ;; --help|--version) return 0 ;; -) continue ;; # stdin, including after -- (handled below too) esac if [ "$cmdw" = cat ]; then # Only known output-format flags are non-bounding. Unknown options # may terminate without reading any file, so fail open. if [ "$utility_family" = bsd-cat ]; then case "$t" in --*|-*[AET]*) return 0 ;; esac fi case "$t" in --number|--number-nonblank|--squeeze-blank|--show-all|--show-ends|--show-nonprinting|--show-tabs) continue ;; -*) v="${t#-}"; case "$v" in *[!AbensTtuvE]*) return 0 ;; *) continue ;; esac ;; esac else case "$t" in -c*|--bytes|--bytes=*|-f|-F|--follow|--follow=*) return 0 ;; -n|--lines) want_next=1; continue ;; -n*) n_raw="${t#-n}"; continue ;; --lines=*) n_raw="${t#--lines=}"; continue ;; -[0-9]*) n_raw="${t#-}"; continue ;; --quiet|--silent|--verbose) [ "$utility_family" = bsd-head ] && return 0; continue ;; -*) v="${t#-}" case "$v" in *[!qv]*) return 0 ;; esac [ "$utility_family" = bsd-head ] && return 0 continue ;; esac fi fi [ "$t" = - ] && continue files[${#files[@]}]="$t" done [ -z "$want_next" ] || return 0 [ "${#files[@]}" -gt 0 ] || return 0 local abs lines eff total maxlines maxpath if [ "$cmdw" = cat ]; then total=0; maxlines=0; maxpath="" for t in "${files[@]}"; do abs="$(resolve_path "$t" literal)" is_text_file "$abs" || continue lines="$(line_count "$abs")" case "$lines" in ''|*[!0-9]*) continue ;; esac if [ "$lines" -gt "$((max_integer - total))" ]; then total="$max_integer" else total=$((total + lines)) fi if [ "$lines" -gt "$maxlines" ] || [ -z "$maxpath" ]; then maxlines="$lines"; maxpath="$abs" fi done [ -n "$maxpath" ] || return 0 [ "$total" -gt "$budget" ] || return 0 fire "$maxpath" "$total" Bash fi # head -K means all but the last K; tail +K starts at line K (0 and 1 # both start at the first line). Out-of-range counts fail open: the utility # may reject them instead of reading. Never let them wrap in arithmetic. sign=""; num="$n_raw" case "$n_raw" in +*) sign="+"; num="${n_raw#+}" ;; -*) sign="-"; num="${n_raw#-}" ;; esac [ "$utility_family" = bsd-head ] && [ "$sign" = - ] && return 0 num="$(normalize_uint "$num" exact)" || return 0 for t in "${files[@]}"; do abs="$(resolve_path "$t" literal)" is_text_file "$abs" || continue lines="$(line_count "$abs")" case "$lines" in ''|*[!0-9]*) continue ;; esac if [ "$cmdw" = head ] && [ "$sign" = - ]; then eff=$((lines - num)) [ "$eff" -lt 0 ] && eff=0 elif [ "$cmdw" = tail ] && [ "$sign" = + ]; then if [ "$num" -le 1 ]; then eff="$lines"; else eff=$((lines - num + 1)); fi [ "$eff" -lt 0 ] && eff=0 else eff="$num" [ "$eff" -gt "$lines" ] && eff="$lines" fi [ "$eff" -gt "$budget" ] || continue fire "$abs" "$eff" Bash done return 0 } check_bash() { local cmd token local -a words words=() cmd="$(printf '%s' "$input" | jq -r '.tool_input.command | select(type == "string")' 2>/dev/null)" [ -n "$cmd" ] || return 0 # Pipes and redirects are explicitly outside this guard, even in quotes. case "$cmd" in *'|'*|*'<'*|*'>'*) return 0 ;; esac command -v awk >/dev/null 2>&1 || return 0 # The lexer keeps literal word boundaries (including spaces/newlines), # strips shell quotes, and removes escaped newlines outside single quotes. # It emits NOTHING until the whole command is known to use this subset. # Expansions, ANSI-C quotes, control syntax, directory changes and persistent # assignments before later segments fail open for the whole call. This avoids both stale-cwd attribution and # prematurely blocking text before an unmatched/unsupported later quote. while IFS= read -r -d '' token; do if [ "$token" = s ]; then if [ "${#words[@]}" -gt 0 ]; then check_segment "${words[@]}"; fi words=() else words[${#words[@]}]="$token" fi done < <(printf '%s\n' "$cmd" | awk ' function word_done( value, kind) { if (!active) return if (persistent_assignment) bad = 1 value = word if (tilde && ENVIRON["HOME"] != "") value = ENVIRON["HOME"] substr(value, 2) kind = assignment ? "a" : "w" records[++count] = kind value segment_words++; pending_and = 0 if (!command_seen && !assignment) { command_seen = 1 # Builtins/wrappers may change cwd or shell evaluation for later # segments; reserved words require a real shell grammar. if (value ~ /^(cd|pushd|popd|builtin|command|eval|source|\.|if|then|else|elif|fi|while|until|do|done|for|case|esac|select|function|!|time|coproc|exec)$/) bad = 1 } word = ""; active = 0; assignment = 0; quoted = 0; tilde = 0 } function segment_done() { word_done() # Assignment-only commands persist shell state for later segments. # Do not judge those later words using the original hook environment. if (segment_words && !command_seen) persistent_assignment = 1 records[++count] = "s" command_seen = 0; segment_words = 0 } BEGIN { q = ""; word = ""; count = 0 } { line = $0; n = length(line); continuation = 0 for (i = 1; i <= n; i++) { c = substr(line, i, 1); nextc = substr(line, i + 1, 1) if (q == "\047") { if (c == "\047") q = ""; else word = word c continue } if (c == "\\") { if (i == n) { continuation = 1; break } active = 1; quoted = 1 if (q == "\"" && nextc !~ /[\\"$`]/) word = word "\\" word = word nextc; i++; continue } if (q == "\"") { if (c == "\"") q = "" else if (c == "$" || c == "`") bad = 1 else word = word c continue } if (c == "#" && !active) break if (c == "\"" || c == "\047") { q = c; active = 1; quoted = 1; continue } if (c == " " || c == "\t") { word_done(); continue } if (c == ";") { word_done(); if (!segment_words || pending_and) bad = 1 segment_done(); continue } if (c == "&" && nextc == "&") { word_done(); if (!segment_words || pending_and) bad = 1 segment_done(); pending_and = 1; i++; continue } if (c ~ /[$`*?\[(){}&]/) { bad = 1; continue } if (c == "~") { if (!active && (nextc == "/" || nextc == "" || nextc ~ /[ \t;]/)) tilde = 1 else { bad = 1; continue } } if (c == "=" && !quoted && word ~ /^[A-Za-z_][A-Za-z0-9_]*$/) assignment = 1 active = 1; word = word c } if (!continuation) { if (q != "") word = word "\n"; else segment_done() } } END { if (q != "" || continuation || pending_and || bad) exit for (j = 1; j <= count; j++) printf "%s%c", records[j], 0 } ') return 0 } case "$tool" in Read) check_read ;; Bash) check_bash ;; esac exit 0 -
skill-first-coord-guard.sh 2.3 KB
#!/usr/bin/env bash # Portable PreToolUse command guard for Claude Code, Codex, and AGY. # # The hook is intentionally silent and fail-open unless it sees an actual # Agent Mail / NTM command head or `tmux send-keys`. On the first match for a # runtime session, it asks the agent to load the owning skill contract and # exits 2 so the command can be reconsidered. The next attempt is allowed. set -uo pipefail [[ "${AGENTOPS_HOOKS_DISABLED:-0}" == "1" ]] && exit 0 command -v jq >/dev/null 2>&1 || exit 0 input="$(cat)" || exit 0 cmd="$( printf '%s' "$input" | jq -er ' ( .tool_input.command // .tool_input.cmd // .tool_input.command_line // .command // "" ) | strings ' 2>/dev/null )" || exit 0 [[ -n "$cmd" ]] || exit 0 sid="$( printf '%s' "$input" | jq -r ' ( .session_id // .thread_id // .conversation_id // .project_id // "" ) | tostring ' 2>/dev/null )" || sid="" # Remove quoted spans and heredoc bodies before examining shell command heads. # This avoids firing on issue bodies, commit messages, or prose that merely # mentions a coordination command. stripped="$( printf '%s' "$cmd" | perl -0777 -pe " s/'[^']*'//g; s/\"[^\"]*\"//g; s/<<-?\s*([A-Za-z_][A-Za-z0-9_]*).*?^\s*\1\b//gms; " 2>/dev/null )" || exit 0 is_coord=0 printf '%s' "$stripped" | awk ' BEGIN { RS="[;&\n]|\\|\\|?"; FS="[ \t]+" } { i=1 while (i<=NF && ($i=="" || $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++ head=$i sub(/^.*\//, "", head) if (head=="am" || head=="ntm") found=1 if (head=="tmux" && $(i+1)=="send-keys") found=1 } END { exit (found ? 0 : 1) } ' && is_coord=1 [[ "$is_coord" -eq 1 ]] || exit 0 if [[ -n "$sid" ]]; then sentinel_dir="${TMPDIR:-/tmp}/agentops-coordguard" safe_sid="$(printf '%s' "$sid" | tr -c 'A-Za-z0-9_.-' '_')" sentinel="$sentinel_dir/$safe_sid" [[ -f "$sentinel" ]] && exit 0 mkdir -p "$sentinel_dir" 2>/dev/null || true : >"$sentinel" 2>/dev/null || true fi cat >&2 <<'MSG' AgentOps coordination guard: load the `agent-mail` or `ntm` skill contract before hand-writing coordination commands. Re-run the command after loading the skill; this guard fires at most once for a runtime session. MSG exit 2
-
-
policies
-
policies.json 7.9 KB
{ "schema": "hooks-manifest.v2", "comment": "Policies-as-data registry for policy-dispatch.sh (age-bhsz, epic age-4qw1). Predicate discipline: only predicate_class 'pure' (syntactic regex over tool_input.command / tool_input.file_path) may carry mode deny|route; lookup/stateful predicates ship audit-only until promoted with reviewed fires. Enforced by skills/cc-hooks/scripts/lint-policies.sh against schemas/hooks-manifest.v2.schema.json. Patterns are POSIX ERE (BSD grep -E compatible: no \\b, no lookaround).", "policies": [ { "id": "core.git:add-beads-ledger", "predicate_class": "pure", "mode": "deny", "matchers": [ { "tools": [ "Bash" ], "field": "command", "pattern": "(^|[;&|][[:space:]]*)git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+add[[:space:]]([^;&|]*[[:space:]/=])?_beads" } ], "route_message": "_beads/ is the PRIVATE bead ledger (its own git repo) — never stage it into the public tree; the leak is one-way. Sync it by pushing the ledger repo itself: (cd _beads && git push)", "rationale": "CLAUDE.md footgun row + memory agentops-br-private-ledger. Only the explicit '_beads' path form is matched; 'git add -A' silently sweeping _beads/ is the stateful variant and stays out of deny per predicate discipline.", "value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.git:add-beads-ledger); retire on false-positive evidence" }, { "id": "core.provenance:ledger-hand-append", "predicate_class": "pure", "mode": "deny", "matchers": [ { "tools": [ "Bash" ], "field": "command", "pattern": "(>>?[[:space:]]*[^[:space:];&|]*docs/provenance/ledger\\.jsonl)|(tee[[:space:]]+(-a[[:space:]]+)?[^[:space:];&|]*docs/provenance/ledger\\.jsonl)" }, { "tools": [ "Edit", "Write" ], "field": "file_path", "pattern": "(^|/)docs/provenance/ledger\\.jsonl$" } ], "route_message": "docs/provenance/ledger.jsonl is HASH-CHAINED (prev_hash/payload_hash/hash on every record) and SEALED — a hand-written row breaks VerifyChain for every record after it. Append through the owning command instead: ao provenance add (schema-validated, sealed onto the chain tip)", "rationale": "Memory pawl-gated-land-flow ('SEALED, never hand-append'). Reads (grep/jq/cat with no redirect onto the file) never match.", "value_proof": "declining fire-attempt rate (token_class core.provenance:ledger-hand-append); retire on false-positive evidence" }, { "id": "core.skills:copy-into-installed", "predicate_class": "pure", "mode": "deny", "matchers": [ { "tools": [ "Bash" ], "field": "command", "pattern": "(^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.(claude|codex|gemini)/skills(/[^[:space:];&|]*)?[[:space:]]*([;&|]|$)" } ], "route_message": "~/.claude/skills (and .codex/.gemini) are INSTALLED/symlinked copies — a cp/rsync/mv into them writes through the symlink into whatever branch the source checkout is on, or is overwritten on install. Link instead: ao skills link. Closes the Bash gap of the Edit|Write-only installed-skill-edit-guard.", "rationale": "Global CLAUDE.md 'Never cp into ~/.claude/skills'. Destination position is enforced: the installed-skills path must be the LAST token of the command segment, so copying FROM an installed dir out to the repo never fires.", "value_proof": "declining fire-attempt rate (token_class core.skills:copy-into-installed); retire on false-positive evidence" }, { "id": "core.skills:edit-installed-copy", "predicate_class": "pure", "mode": "deny", "matchers": [ { "tools": [ "Edit", "Write" ], "field": "file_path", "pattern": "/\\.(claude|codex|gemini)/skills/" } ], "route_message": "This is an INSTALLED skill copy (overwritten on install, or symlinked through to the factory checkout) — editing it is lost work. Edit skills/<name>/ in the source repo instead.", "rationale": "Registry twin of the standalone installed-skill-edit-guard: matches tool_input.file_path ONLY (prose that merely mentions claude/skills lands in tool_input.content and can never fire). Subsumes the standalone guard for dispatcher users; the standalone script remains for hosts wanting only that one guard.", "value_proof": "declining fire-attempt rate (token_class core.skills:edit-installed-copy); 2 real fires already recorded by the standalone guard's telemetry; retire on false-positive evidence" }, { "id": "core.verdicts:hand-edit", "predicate_class": "pure", "mode": "deny", "matchers": [ { "tools": [ "Bash" ], "field": "command", "pattern": "(>>?[[:space:]]*[^[:space:];&|]*\\.agents/ao/verdicts/)|(tee[[:space:]]+(-[^[:space:];&|]*[[:space:]]+)*[^[:space:];&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)(cp|rsync|mv)[[:space:]][^;&|]*[[:space:]][^[:space:];&|]*\\.agents/ao/verdicts/[^[:space:];&|]*[[:space:]]*([;&|]|$))" }, { "tools": [ "Bash" ], "field": "command", "pattern": "((^|[;&|][[:space:]]*)sed[[:space:]]+([^;&|]*[[:space:]])?-i[^[:space:]]*[[:space:]][^;&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)perl[[:space:]]+([^;&|]*[[:space:]])?-(p|n)?i[^[:space:]]*[[:space:]][^;&|]*\\.agents/ao/verdicts/)|((^|[;&|][[:space:]]*)(rm|unlink|shred)[[:space:]][^;&|]*\\.agents/ao/verdicts/)" }, { "tools": [ "Edit", "Write" ], "field": "file_path", "pattern": "(^|/)\\.agents/ao/verdicts/" } ], "route_message": ".agents/ao/verdicts/ holds content-addressed evidence — the filename IS the SHA-256 of the file's own canonical content, and only the validate flow writes it. A hand edit breaks digest identity: the name keeps pointing at bytes that no longer hash to it, so every consumer reads a verdict that cannot verify. Don't patch the artifact — re-run validation and let it persist a fresh one: ao provenance store-verdict --root <subject-root> --evidence-root <explicit-non-Git-root> --draft <draft.json> --intent-source <intent> --subject-manifest <manifest.json> --author-context-id <author> --validator-context-id <validator> --freshness-source <runtime|caller> --freshness-attester-id <attester> --scope-result <PASS|FAIL|NOT_PROVEN>", "rationale": "The first policy guarding the PRODUCT's invariant rather than this repo's own artifacts. CLAUDE.md 'Validate once, fresh' and docs/architecture/rpi-traversal.md make a verdict.v2 artifact digest-named (sha256/<digest>.json), so hand-editing one is silent evidence forgery, not a typo fix. Sibling craft of core.provenance:ledger-hand-append. Destination position is enforced on cp/rsync/mv (the verdicts path must be the LAST token of the segment), so copying a verdict OUT for inspection never fires, and reads (cat/ls/jq with no redirect ONTO the store) never match. .agents/ao/intents/ stays deliberately out of scope: this policy guards one invariant and ships the evidence for that one; widening it needs its own fire evidence. In-place editors (sed -i, perl -pi/-ni, with or without a backup suffix) and deleters (rm, unlink, shred) are matched as flag-tokens so reads never fire (sed 's/-input//' <verdict> stays silent — bats-proven both directions). Remaining disclosed gap: the noclobber override redirect (>|), which needs its own shaping.", "value_proof": "declining fire-attempt rate in guardrail telemetry (token_class core.verdicts:hand-edit); retire on false-positive evidence" } ] }
-
-
references
-
DCG-RCH.md 7.7 KB
# DCG and RCH: Production Hook Examples Real-world PreToolUse hooks from production systems. ## Combined Configuration ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "dcg" }, { "type": "command", "command": "rch" } ] } ] } } ``` Both hooks run in parallel on every Bash command. --- ## DCG (Destructive Command Guard) **Purpose:** Safety hook that blocks dangerous commands before execution. ### What DCG Blocks **Git Commands:** - `git reset --hard` - Destroys uncommitted work - `git checkout -- <path>` - Discards local changes - `git restore` (without --staged) - Discards changes - `git clean -f` - Deletes untracked files - `git push --force` - Rewrites remote history - `git branch -D` - Force-deletes branch - `git stash drop/clear` - Destroys stashes **Filesystem:** - `rm -rf` outside of /tmp, /var/tmp, $TMPDIR **Additional Packs:** - `containers.docker` - Container destruction - `kubernetes.kubectl` - Cluster operations - `databases.sql` - DROP, TRUNCATE, DELETE without WHERE - `cloud.terraform` - Infrastructure destruction ### Installation ```bash # Install via Homebrew brew install dcg # Or from source cargo install destructive_command_guard ``` ### Configuration ```bash # Environment variables DCG_VERBOSE=0-3 # Verbosity (0=quiet, 3=trace) DCG_QUIET=1 # Suppress non-error output DCG_NO_COLOR=1 # Disable colors DCG_FORMAT=text|json|sarif DCG_CONFIG=/path # Explicit config file DCG_HOOK_TIMEOUT_MS # Evaluation timeout ``` ### How It Works 1. Receives JSON hook input via stdin 2. Parses the `tool_input.command` field 3. Evaluates against pattern packs 4. Returns exit 2 with explanation if blocked 5. Returns exit 0 if allowed ### Example Output (Blocked) ``` 🛡️ DCG blocked: git reset --hard This command destroys uncommitted work. Alternatives: • git stash - Save changes temporarily • git diff > backup - Export changes first • git reset --soft - Keep changes staged ``` --- ## RCH (Remote Compilation Helper) **Purpose:** Intercepts build commands and offloads to faster remote workers. ### What RCH Intercepts - `cargo build`, `cargo test`, `cargo check` - `make`, `cmake --build` - `go build`, `go test` - `npm run build`, `yarn build` - Other configurable patterns ### How It Works ``` ┌─────────────────────────────────────────────────────────┐ │ Claude Code │ │ ───────────── │ │ 1. Claude wants: cargo build --release │ │ │ │ │ ▼ │ │ 2. PreToolUse hook fires → RCH receives JSON │ │ │ │ │ ▼ │ │ 3. RCH detects: "This is a cargo command" │ │ │ │ │ ▼ │ │ 4. RCH routes to remote worker via SSH │ │ - Syncs project files │ │ - Executes on fast machine │ │ - Streams output back │ │ │ │ │ ▼ │ │ 5. Returns JSON: permissionDecision: "allow" │ │ with updatedInput containing modified command │ └─────────────────────────────────────────────────────────┘ ``` ### Installation ```bash # Quick start rch hook install && rch daemon start # Verify rch status --workers --jobs ``` ### Commands ```bash rch hook install # Install PreToolUse hook rch hook test # Test with sample cargo build rch daemon start # Start local daemon rch daemon stop # Stop daemon rch workers probe # Test worker connectivity rch workers add # Add new worker rch config show # Show configuration rch doctor # Run diagnostics ``` ### Configuration ```bash # Environment variables RCH_PROFILE=dev|prod|test RCH_LOG_LEVEL=trace|debug|info|warn|error RCH_DAEMON_SOCKET=/path/to/socket RCH_SSH_KEY=/path/to/key RCH_TRANSFER_ZSTD_LEVEL=1-22 ``` ### Config Precedence 1. Command-line arguments 2. Environment variables 3. Profile defaults (RCH_PROFILE) 4. .env / .rch.env files 5. Project config (.rch/config.toml) 6. User config (~/.config/rch/config.toml) 7. Built-in defaults --- ## Hook Interaction DCG and RCH work together: 1. **DCG runs first** (parallel, but faster) - If DCG blocks → command never reaches RCH - If DCG allows → continues to RCH 2. **RCH evaluates** - If build command → intercept and route - If not build → pass through unchanged 3. **Results merged** - Both can modify the command - Both can add context - Any block is final --- ## Writing Your Own Hook Like DCG/RCH ### Minimal Rust Structure ```rust use serde::{Deserialize, Serialize}; use std::io::{self, Read}; #[derive(Deserialize)] struct HookInput { tool_name: String, tool_input: ToolInput, } #[derive(Deserialize)] struct ToolInput { command: String, } #[derive(Serialize)] struct HookOutput { #[serde(rename = "hookSpecificOutput")] hook_specific_output: HookSpecificOutput, } #[derive(Serialize)] struct HookSpecificOutput { #[serde(rename = "hookEventName")] hook_event_name: String, #[serde(rename = "permissionDecision")] permission_decision: String, #[serde(rename = "permissionDecisionReason")] permission_decision_reason: String, } fn main() { let mut input = String::new(); io::stdin().read_to_string(&mut input).unwrap(); let hook_input: HookInput = serde_json::from_str(&input).unwrap(); if should_block(&hook_input.tool_input.command) { eprintln!("Blocked: dangerous command"); std::process::exit(2); } // Allow std::process::exit(0); } ``` ### Minimal Python Structure ```python #!/usr/bin/env python3 import json import sys def main(): input_data = json.load(sys.stdin) command = input_data.get('tool_input', {}).get('command', '') if is_dangerous(command): print("Blocked: dangerous command", file=sys.stderr) sys.exit(2) # Allow with modification output = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Safe command", "updatedInput": { "command": modify_command(command) } } } print(json.dumps(output)) sys.exit(0) if __name__ == '__main__': main() ``` --- ## Troubleshooting ### DCG Not Blocking ```bash # Check DCG is in path which dcg # Test manually echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | dcg echo $? # Should be 2 ``` ### RCH Not Intercepting ```bash # Check daemon running rch daemon status # Check worker connectivity rch workers probe --all # Test hook manually rch hook test ``` ### Hook Format Error ``` hooks.PreToolUse: Expected array, but received object ``` **Fix:** Use new array format: ```json // Wrong {"PreToolUse": {"tools": ["Bash"], "hooks": [...]}} // Correct {"PreToolUse": [{"matcher": "Bash", "hooks": [...]}]} ``` -
GUARDRAIL-VALUE-PROOF.md 9.7 KB
# Guardrail Value-Proof Methodology (pre-registered) `age-workflow-guardrail-hooks-j39.2` · BC6-Orchestration · cc-hooks family This document is the **pre-registered methodology** that lets a workflow-guardrail hook earn the "lease on life" ADR-0002 demands. It is written and committed *before* the measurement is run, so the success criterion and the null-tolerance cannot be retrofitted to whatever the data happens to say. > **Status at landing (no overclaim):** this ENABLES the ADR-0002 proof — it does > not yet PROVIDE it. The guard ships INERT (opt-in installer); the telemetry > channel collects **zero** data until it is installed AND N≥30 real fires > accrue. So ADR-0002 l.58 is *not cleared at landing* — it becomes clearable once > the data exists. (Recorded by the 2026-06-17 recent-commits review.) ## Why this exists (the whole point) AgentOps went hookless (#511) on the finding that hooks "couldn't be proven to have value" — the 2.x A/B eval showed injected context made no difference (`aggregate_delta = 0`). ADR-0002 (`docs/adr/ADR-0002-agentops-3-hookless-cdlc-rearchitecture.md`, l.58) therefore requires, for any hook to survive: **"test or eval evidence showing positive value."** Without that evidence, the installed-skill-edit keystone guard is just another unproven hook awaiting the next teardown. This methodology + the per-fire telemetry it consumes *is* that evidence pipeline. ## The sensor: gate-blind per-fire telemetry The keystone guard (`skills/cc-hooks/hooks/installed-skill-edit-guard.sh`) emits **exactly one JSONL line per FIRE** to `${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl` (override with `AGENTOPS_GUARDRAIL_TELEMETRY`): ```json {"ts":"2026-06-16T18:30:00Z","session":"<session_id>","token_class":"installed-skill-edit","path_sha256":"<64-hex>"} ``` - `ts` — UTC ISO-8601, second resolution. - `session` — the Claude `session_id` (the unit the attempt-rate is computed per). - `token_class` — which mistake-token / guard fired (`installed-skill-edit`). - `path_sha256` — **a SHA-256 hash of the edited path, never the raw path.** **Privacy invariant:** the raw command/path is NEVER persisted — only the hash. The hash is one-way; it lets us count *distinct* edited targets and detect repeats without ever logging what the agent was editing. Asserted in `tests/scripts/installed-skill-edit-telemetry.bats`. **Inert by default:** the emission code only runs when the guard fires, and this standalone guard ships INERT (opt-in installer only) even though the PreToolUse policy dispatcher ships by default. On a machine where the guard is not installed, zero lines are ever written. On a machine where it IS installed, the happy path (any non-installed-skill edit) writes nothing. **Gate-blind:** the sensor records the *attempt*, not the outcome of the redirect. It cannot see whether the agent subsequently "did the right thing" — by design (see the Goodhart note below). ## The metric: fire-ATTEMPT rate over time Define, per session `s`: - `fires(s)` = count of telemetry lines with `token_class = installed-skill-edit` emitted during session `s`. The success signal is a **declining fire-attempt rate across sessions** — i.e. a downward trend in `fires(s)` (or `fires(s)` normalized by session length / edit volume) as `s` advances in time. The interpretation: once a guard reliably interrupts a mistake-token, the agent (and the operator tuning prompts/ skills around it) stops *attempting* the mistake. That is a learning signal that the gate's own redirect **cannot fabricate** — the redirect fires *after* the attempt is already counted; lowering the count requires the attempt itself to stop happening, which the hook cannot do by counting. ### Why NOT the hand-roll / "did they comply" rate (the Goodhart trap) The original design measured the hand-roll rate with the guard on vs off. That was **rejected** (premortem finding #3) as circular / Goodhart: - The gate's redirect lowers the post-redirect hand-roll rate *by construction* — the guard exists to do exactly that, so "the rate went down" proves nothing. - The counterfactual ("would the agent have complied without the guard?") is unobservable in a single timeline. - `N=1` with the guard always-on is the same regime that produced the repo's `delta=0` / `-0.37` corpus-A/B nulls. The attempt rate over time sidesteps all three: it is measured on the *input* side of the redirect, so the redirect cannot move it; the trend is across the agent's *own* history, needing no off-arm counterfactual. ## Pre-registered decision rule Fixed **before** any data is collected: - **Minimum N:** at least **30 sessions** with the guard installed before any trend claim is made. Below N, report raw counts only — no verdict. - **Noise floor:** fire counts are low-rate and bursty (one footgun cluster can spike a single session). A declining trend counts only if it survives a per-session-median (or 5-session moving-average) smoothing — a single quiet session is not a trend. - **Earns its keep (KEEP):** at N ≥ 30, the smoothed fire-attempt rate shows a **monotone-ish downward trend** (later windows strictly below earlier windows) AND the guard demonstrably caused at least one redirect (≥1 fire) without ever firing on the happy path (zero false-positive telemetry lines). This is positive behavior-change evidence per ADR-0002 l.58. - **NULL is ACCEPTABLE (KEEP-on-no-harm):** if at N ≥ 30 the rate is flat or the trend is inconclusive, that is an **expected, acceptable outcome — not a project failure.** The repo's measured A/B base rate for context interventions is null/negative; a flat attempt-rate paired with **zero context tax** (silent on every happy path, asserted by the keystone bats) and **zero false positives** satisfies the ADR-0002 l.58 "lease on life" as *no harm + a measurable signal channel that exists and runs*. A guard that is provably silent and provably fires only on the real mistake-token has earned its keep even with a flat trend, because the failure mode it replaces (unproven, noisy, always-injecting hooks) is strictly worse. - **CUT:** the guard is cut if, at N ≥ 30, telemetry shows it fired on the **happy path** (any false-positive line — a path that was not an installed-skill edit), OR the emission imposed a measurable context/latency tax, OR the fire-attempt rate **rises** with no operator explanation. Any of these means it costs more than it proves. ## Falsifiability summary | Outcome at N ≥ 30 | Verdict | Rationale | |---|---|---| | Smoothed attempt-rate declines, ≥1 true fire, 0 false fires | KEEP | positive behavior-change evidence (ADR-0002 l.58) | | Attempt-rate flat/inconclusive, 0 false fires, 0 tax | KEEP (null = acceptable) | no harm + live signal channel; beats unproven always-on hooks | | Any false-positive fire, OR measurable tax, OR rising rate | CUT | costs more than it proves | ## Reproducing the read (when N is reached) ```bash # Fires per session, oldest→newest: jq -r 'select(.token_class=="installed-skill-edit") | .session' \ "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}" \ | sort | uniq -c # Distinct targets touched (hashes), to spot repeated footguns: jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \ "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}" \ | sort | uniq -c | sort -rn ``` No raw path is ever available in the ledger — only hashes — so the read is privacy-preserving by construction. ## Read-budget guard (core.context:unbounded-read) The opt-in read-budget guard (`skills/cc-hooks/hooks/read-budget-guard.sh`, recipe [READ-BUDGET-GUARD.md](READ-BUDGET-GUARD.md)) reuses this sensor and this decision rule. Its `token_class` is the policy id `core.context:unbounded-read`; each line carries five extra fields: ```json {"ts":"…","session":"…","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350} ``` - `mode` / `decision` — the dispatcher's pair: `mode` is always `deny` (this guard never routes); `decision` is `deny` (a fire) or `waived` (an `AOP_WAIVE` waiver let the call through: one line, no fire). - `tool` — `Read` or `Bash`. - `lines` / `budget` — JSON numbers: the effective line count of the offending read and the budget it exceeded. `path_sha256` hashes the RESOLVED path; the raw path and the raw command are never written. **Metric:** the same declining fire-attempt rate per session. Secondary, stated-denominator estimate: `sum(lines)` over `decision == "deny"` lines is an upper bound on lines kept out of context (denominator = fires the guard saw; it says nothing about pipes, redirects, globs, `sed`, `awk`, `less` — silent by design). **Countermetric:** waiver rate = `waived / (deny + waived)` per session. **CUT signals (any one):** a fire on a `limit`-bounded Read, a bounded effective read, a command that does not read the attributed file, or a file at or below budget (except a `cat` sum over budget). Each false positive is a defect, not noise; regression coverage is evidence for the tested shapes, not proof that a shell parser makes false positives impossible; or a waiver rate above 50% at N ≥ 30 — the budget is wrong for this repository, not the agent (retune `AOP_READ_BUDGET_LINES`; do not keep a guard everyone waives). Same **N ≥ 30** minimum and **null-is-acceptable** rule as above: a flat attempt rate with zero false fires and zero happy-path output is KEEP. Ships INERT — zero lines until installed; ADR-0002 l.58 is not cleared at landing here either. ```bash jq -r 'select(.token_class=="core.context:unbounded-read") | [.session,.decision,.tool,.lines] | @tsv' \ "${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agents/ao/guardrail-telemetry.jsonl}" ``` -
HOOK-EVENTS.md 7.3 KB
# Hook Events Reference Complete documentation for all Claude Code hook events. ## PreToolUse **When:** After Claude creates tool parameters, before tool execution **Can Block:** Yes ### Input Schema ```json { "session_id": "string", "transcript_path": "/path/to/session.jsonl", "cwd": "/current/directory", "permission_mode": "default|plan|acceptEdits|dontAsk|bypassPermissions", "hook_event_name": "PreToolUse", "tool_name": "Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|mcp__*", "tool_input": { /* tool-specific */ }, "tool_use_id": "toolu_01ABC..." } ``` ### Tool-Specific Inputs **Bash:** ```json { "command": "npm test", "description": "Run test suite", "timeout": 120000, "run_in_background": false } ``` **Write:** ```json { "file_path": "/absolute/path/to/file.txt", "content": "file content" } ``` **Edit:** ```json { "file_path": "/absolute/path/to/file.txt", "old_string": "original text", "new_string": "replacement", "replace_all": false } ``` **Read:** ```json { "file_path": "/absolute/path/to/file.txt", "offset": 0, "limit": 100 } ``` ### Output: Decision Control ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask", "permissionDecisionReason": "Explanation", "updatedInput": { "field": "modified value" }, "additionalContext": "Context added for Claude" } } ``` | Decision | Effect | |----------|--------| | `allow` | Bypass permission system, execute immediately | | `deny` | Block execution, reason shown to Claude | | `ask` | Show permission dialog to user | --- ## PostToolUse **When:** Immediately after tool completes successfully **Can Block:** No (tool already ran) ### Input Schema ```json { "session_id": "string", "transcript_path": "/path/to/session.jsonl", "cwd": "/current/directory", "permission_mode": "default", "hook_event_name": "PostToolUse", "tool_name": "Write", "tool_input": { /* original input */ }, "tool_response": { /* tool result */ }, "tool_use_id": "toolu_01ABC..." } ``` ### Output: Feedback to Claude ```json { "decision": "block", "reason": "Linting errors found. Fix before continuing.", "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "Error on line 42: missing semicolon" } } ``` --- ## PermissionRequest **When:** Permission dialog is about to be shown **Can Block:** Yes (auto-allow or auto-deny) ### Output: Auto-Resolve Permission ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow|deny", "updatedInput": { "command": "safe-command" }, "message": "Reason for denial", "interrupt": false } } } ``` --- ## UserPromptSubmit **When:** User submits prompt, before Claude processes **Can Block:** Yes ### Input Schema ```json { "session_id": "string", "hook_event_name": "UserPromptSubmit", "prompt": "User's input text" } ``` ### Output: Add Context or Block **Add context (simple):** Print to stdout with exit 0 ```bash echo "Current time: $(date)" exit 0 ``` **Add context (JSON):** ```json { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "Project is in maintenance mode until 5pm" } } ``` **Block prompt:** ```json { "decision": "block", "reason": "Cannot process: contains sensitive data" } ``` --- ## Stop **When:** Claude finishes responding (not on user interrupt) **Can Block:** Yes (force continue) ### Input Schema ```json { "session_id": "string", "hook_event_name": "Stop", "stop_hook_active": true, "transcript_path": "/path/to/session.jsonl" } ``` **Important:** Check `stop_hook_active` to prevent infinite loops. ### Output: Force Continue ```json { "decision": "block", "reason": "Tests are failing. Fix the errors in src/auth.ts" } ``` ### Prompt-Based Stop Hook ```json { "hooks": { "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks complete.", "timeout": 30 } ] } ] } } ``` LLM responds: `{"ok": true}` or `{"ok": false, "reason": "Tasks incomplete"}` --- ## SubagentStop **When:** Subagent (Task tool) finishes **Can Block:** Yes ### Input Schema ```json { "session_id": "string", "hook_event_name": "SubagentStop", "stop_hook_active": false, "agent_id": "def456", "agent_transcript_path": "/path/to/subagents/agent-def456.jsonl" } ``` --- ## SubagentStart **When:** Subagent is spawned **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "SubagentStart", "agent_id": "agent-abc123", "agent_type": "Explore|Plan|Bash|custom-name" } ``` --- ## SessionStart **When:** Session begins or resumes **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "SessionStart", "source": "startup|resume|clear|compact", "model": "claude-sonnet-4-6", "agent_type": "agent-name" } ``` ### Matchers - `startup` - New session - `resume` - From --resume, --continue, /resume - `clear` - After /clear - `compact` - After compaction ### Persisting Environment Variables ```bash #!/bin/bash if [ -n "$CLAUDE_ENV_FILE" ]; then echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE" echo 'export API_KEY=xxx' >> "$CLAUDE_ENV_FILE" fi exit 0 ``` --- ## SessionEnd **When:** Session terminates **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "SessionEnd", "reason": "clear|logout|prompt_input_exit|other" } ``` --- ## Notification **When:** Claude Code sends notifications **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "Notification", "message": "Claude needs your permission to use Bash", "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog" } ``` ### Example: Custom Desktop Notifications ```json { "hooks": { "Notification": [ { "matcher": "permission_prompt", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Permission needed'" } ] }, { "matcher": "idle_prompt", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Waiting for input'" } ] } ] } } ``` --- ## PreCompact **When:** Before context compaction **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "PreCompact", "trigger": "manual|auto", "custom_instructions": "" } ``` ### Matchers - `manual` - From /compact command - `auto` - Automatic due to full context --- ## Setup **When:** Invoked with --init, --init-only, or --maintenance **Can Block:** No ### Input Schema ```json { "session_id": "string", "hook_event_name": "Setup", "trigger": "init|maintenance" } ``` ### Matchers - `init` - From --init or --init-only - `maintenance` - From --maintenance Has access to `CLAUDE_ENV_FILE` for persisting environment. --- ## MCP Tool Naming MCP tools follow pattern: `mcp__<server>__<tool>` ```json { "matcher": "mcp__memory__.*", "hooks": [{ "type": "command", "command": "log-memory-ops.sh" }] } ``` Examples: - `mcp__memory__create_entities` - `mcp__filesystem__read_file` - `mcp__github__search_repositories` -
INSTALLED-SKILL-EDIT-GUARD.md 4.3 KB
# Installed-Skill-Edit Guard (opt-in) A PreToolUse `Edit|Write` guard that routes an edit of an **installed skill copy** (`*/.claude/skills/**`, `*/.codex/skills/**`, `*/.gemini/skills/**`) back to the repo source of truth (`skills/<name>/`). AgentOps is hookless by default — this guard ships **inert**; you activate it with the opt-in installer. ## Why it exists — a TRUE mistake-token An `Edit`/`Write` whose target path is under `*/.claude/skills/**` has **no legitimate form**. Those files are installed / symlinked copies: - they are **overwritten** on `scripts/install.sh`, so an edit there is silently lost work, or - they **symlink through** to the factory checkout, so an edit there writes into whatever branch that checkout happens to be on — never the intended source. CLAUDE.md's standing rule is "NEVER edit `~/.claude/skills/` — edit `skills/` in this repo." That rule is advisory context, which is delta≈0. This guard makes it **mechanical**: it keys on the action signature (the `file_path`), not the agent's self-narrative, so it fires even when the agent believes it is doing the right thing. Unlike an activity-keyed guard (which false-fires on legitimate identical forms and gets disabled — the #511 fate), this token is syntactically detectable with **zero false-positive surface**: only an installed-skills `file_path` matches, and a repo doc that merely *mentions* `claude/skills` in its body lands in `tool_input.content`, never `file_path`. ## Reversible → ROUTE, not hard-block Editing the wrong copy is recoverable (re-do the edit against `skills/`), so the guard **routes** rather than hard-blocks: exit 2 + a one-line stderr redirect naming the correct `skills/<name>/` target. It does not silently swallow the edit or deny irreversibly. ## Context-budget doctrine Hooks are the most powerful enforcement (mechanical, can't be reasoned past) but they pollute context — use sparingly: - **SILENT on the happy path**: any non-installed-skills `file_path` → exit 0, zero stdout, zero stderr. - Fire the one redirect **only on a real violation**, at most **once per session** (sentinel-gated in `$TMPDIR`), so it never repeats. - **NEVER emit stray stdout on an exit-0 PreToolUse path** — stdout there is parsed as JSON and a stray line breaks the tool call. Block via exit 2 + stderr only. ## The guard Ships as `skills/cc-hooks/hooks/installed-skill-edit-guard.sh`. It reads the PreToolUse JSON on stdin, matches `tool_input.file_path` only, and derives the repo-relative `skills/<name>/` target for the redirect message. ## Opt-in install ```bash # user scope (~/.claude/settings.json) — the default scripts/install-installed-skill-edit-guard.sh # project scope (.claude/settings.json) scripts/install-installed-skill-edit-guard.sh --project # explicit target SETTINGS=/path/to/settings.json scripts/install-installed-skill-edit-guard.sh ``` The installer copies the guard to `~/.claude/hooks/installed-skill-edit-guard.sh` and adds (idempotently) a PreToolUse `Edit|Write` matcher: ```json { "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "~/.claude/hooks/installed-skill-edit-guard.sh" } ] } ] } } ``` Requires `jq` on `PATH`. ## Test it `tests/scripts/installed-skill-edit-guard.bats` round-trips the real PreToolUse JSON shape on stdin and proves the contract: - **FIRE (exit 2)**: `~/.claude/skills/<x>/SKILL.md`, an absolute `/Users/*/.claude/skills/**`, `.codex/skills/**`, `.gemini/skills/**`. - **SILENT (exit 0, zero output)**: repo `skills/**` (absolute or relative), an unrelated source file, a doc whose path mentions `claude` but not the installed-skills segment, and a missing `file_path`. - **once-per-session**: first violation fires, the second self-relaxes. ```bash bats tests/scripts/installed-skill-edit-guard.bats ``` ## Known limitations It matches the `file_path` only, so it cannot guard an edit reached through a tool that does not populate `file_path` (e.g. a `Bash` `sed -i` into the installed copy) — that is a `Bash` path, not an `Edit`/`Write`, and out of scope here. The cost of a missed case is one un-routed edit; there is no false fire and no broken tool call. Erring toward silence keeps it cheap on context and safe to run. -
JSON-OUTPUT.md 5.7 KB
# JSON Output Reference Complete schemas for hook JSON responses. ## Common Fields (All Hooks) ```json { "continue": true, "stopReason": "Why Claude should stop", "suppressOutput": false, "systemMessage": "Warning shown to user" } ``` | Field | Type | Description | |-------|------|-------------| | `continue` | boolean | If false, Claude stops after hooks run | | `stopReason` | string | Message shown when continue=false | | `suppressOutput` | boolean | Hide from verbose mode (ctrl+o) | | `systemMessage` | string | Warning displayed to user | --- ## PreToolUse ### Allow (Auto-Approve) ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Safe operation auto-approved" } } ``` ### Deny (Block) ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Blocked: dangerous command" } } ``` ### Ask (Show Dialog) ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "Requires explicit approval" } } ``` ### Modify Input ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Modified for safety", "updatedInput": { "command": "npm run lint -- --fix", "timeout": 60000 } } } ``` ### Add Context ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "additionalContext": "Environment: production. Proceed with caution." } } ``` ### Full Example ```json { "continue": true, "suppressOutput": true, "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Build command routed to remote worker", "updatedInput": { "command": "rch-exec cargo build --release" }, "additionalContext": "Build will execute on worker-1 (32 cores)" } } ``` --- ## PermissionRequest ### Allow Permission ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow" } } } ``` ### Allow with Modified Input ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow", "updatedInput": { "command": "npm run build:safe" } } } } ``` ### Deny Permission ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": "Denied: production deployment requires approval", "interrupt": false } } } ``` ### Deny and Stop Claude ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": "Critical: manual intervention required", "interrupt": true } } } ``` --- ## PostToolUse ### Provide Feedback (Block) ```json { "decision": "block", "reason": "Linting errors found. Fix before continuing.", "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "Errors:\n- line 42: missing semicolon\n- line 55: unused variable" } } ``` ### Add Context Only ```json { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "File formatted successfully with prettier" } } ``` --- ## UserPromptSubmit ### Add Context (Simpler) Just print to stdout: ```bash echo "Current time: $(date)" echo "Project: myapp v1.2.3" ``` ### Add Context (JSON) ```json { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "User is working on feature-auth branch. 3 open PRs pending review." } } ``` ### Block Prompt ```json { "decision": "block", "reason": "Prompt contains potentially sensitive data. Please rephrase." } ``` --- ## Stop / SubagentStop ### Allow Stop (Default) No output needed, or: ```json {} ``` ### Force Continue ```json { "decision": "block", "reason": "Tests are failing. Run `npm test` and fix errors in src/auth.ts before stopping." } ``` --- ## SessionStart ### Add Context ```json { "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "Project: myapp\nBranch: feature-auth\nOpen issues: 5" } } ``` --- ## Setup ### Add Context ```json { "hookSpecificOutput": { "hookEventName": "Setup", "additionalContext": "Dependencies installed. Database migrations applied." } } ``` --- ## Prompt-Based Hook Response For `type: "prompt"` hooks: ### Allow ```json { "ok": true } ``` ### Block/Deny ```json { "ok": false, "reason": "Tasks incomplete. The test suite is still failing." } ``` --- ## Exit Code Behavior Summary | Exit Code | JSON Parsed? | Effect | |-----------|--------------|--------| | 0 | Yes | Success, JSON controls behavior | | 2 | No | Block, stderr fed to Claude | | Other | No | Non-blocking error, stderr to verbose | **Important:** Exit code 2 ignores any JSON output. Use stderr for the message. --- ## Deprecated Fields These still work but use new format: | Old | New | |-----|-----| | `decision: "approve"` | `permissionDecision: "allow"` | | `decision: "block"` | `permissionDecision: "deny"` | | `reason` | `permissionDecisionReason` | --- ## Field Availability by Event | Field | PreToolUse | PostToolUse | UserPromptSubmit | Stop | |-------|------------|-------------|------------------|------| | `continue` | ✓ | ✓ | ✓ | ✓ | | `decision` | ✓ | ✓ | ✓ | ✓ | | `permissionDecision` | ✓ | - | - | - | | `updatedInput` | ✓ | - | - | - | | `additionalContext` | ✓ | ✓ | ✓ | - | | `reason` | ✓ | ✓ | ✓ | ✓ | -
PATTERNS.md 9.4 KB
# Hook Patterns and Recipes Common patterns for Claude Code hooks. ## Auto-Format on File Write ### TypeScript/JavaScript with Prettier ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.ts || \"$f\" == *.tsx || \"$f\" == *.js ]] && npx prettier --write \"$f\"; } || true" } ] } ] } } ``` ### Go with gofmt ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.go ]] && gofmt -w \"$f\"; } || true" } ] } ] } } ``` ### Rust with rustfmt ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.rs ]] && rustfmt \"$f\"; } || true" } ] } ] } } ``` ### Multi-Language Formatter ```bash #!/bin/bash # ~/.claude/hooks/auto-format.sh INPUT=$(cat) FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path') case "$FILE" in *.ts|*.tsx|*.js|*.jsx) npx prettier --write "$FILE" 2>/dev/null ;; *.go) gofmt -w "$FILE" 2>/dev/null ;; *.rs) rustfmt "$FILE" 2>/dev/null ;; *.py) black "$FILE" 2>/dev/null || ruff format "$FILE" 2>/dev/null ;; esac exit 0 ``` --- ## File Protection ### Block Sensitive Files ```json { "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|\\.git/|credentials|secrets|password)' && { echo 'Blocked: sensitive file' >&2; exit 2; } || exit 0" } ] } ] } } ``` ### Block Production Paths ```bash #!/bin/bash INPUT=$(cat) # Never assign to $PATH — it clobbers the shell's executable search path and # breaks every command after it. Use a distinct variable name. FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path') BLOCKED_PATTERNS=( "/prod/" "/production/" "deploy/" ".env.production" ) for pattern in "${BLOCKED_PATTERNS[@]}"; do if [[ "$FILE_PATH" == *"$pattern"* ]]; then echo "Blocked: production file $FILE_PATH" >&2 exit 2 fi done exit 0 ``` --- ## Command Validation ### Block Dangerous Git Commands ```python #!/usr/bin/env python3 import json import sys import re DANGEROUS_PATTERNS = [ r'git\s+reset\s+--hard', r'git\s+clean\s+-[fd]', r'git\s+push\s+.*--force', r'git\s+checkout\s+--\s+\.', r'git\s+branch\s+-D', ] input_data = json.load(sys.stdin) command = input_data.get('tool_input', {}).get('command', '') for pattern in DANGEROUS_PATTERNS: if re.search(pattern, command): print(f"Blocked: dangerous git command", file=sys.stderr) sys.exit(2) sys.exit(0) ``` ### Suggest Better Commands ```python #!/usr/bin/env python3 import json import sys import re SUGGESTIONS = [ (r'\bgrep\b(?!.*\|)', "Use 'rg' (ripgrep) instead of grep"), (r'\bfind\s+\S+\s+-name\b', "Use 'fd' or 'rg --files' instead of find"), (r'\bcat\s+\S+\s*\|\s*grep', "Use 'rg pattern file' directly"), ] input_data = json.load(sys.stdin) command = input_data.get('tool_input', {}).get('command', '') for pattern, suggestion in SUGGESTIONS: if re.search(pattern, command): print(f"Suggestion: {suggestion}", file=sys.stderr) # Non-blocking - just advice break sys.exit(0) ``` --- ## Logging and Auditing ### Log All Commands ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-command-log.txt" } ] } ] } } ``` ### Structured JSON Logging ```bash #!/bin/bash INPUT=$(cat) TIMESTAMP=$(date -Iseconds) LOG_ENTRY=$(echo "$INPUT" | jq -c --arg ts "$TIMESTAMP" '{timestamp: $ts, tool: .tool_name, input: .tool_input}') echo "$LOG_ENTRY" >> ~/.claude/hooks.jsonl exit 0 ``` ### Log to Syslog ```bash #!/bin/bash INPUT=$(cat) TOOL=$(echo "$INPUT" | jq -r '.tool_name') CMD=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // "unknown"') logger -t claude-code "Tool: $TOOL, Target: $CMD" exit 0 ``` --- ## Custom Notifications ### Desktop Notification (Linux) ```json { "hooks": { "Notification": [ { "matcher": "permission_prompt", "hooks": [ { "type": "command", "command": "notify-send -u critical 'Claude Code' 'Permission required'" } ] }, { "matcher": "idle_prompt", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Waiting for your input'" } ] } ] } } ``` ### macOS Notification ```json { "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"Awaiting input\" with title \"Claude Code\"'" } ] } ] } } ``` ### Slack/Discord Webhook ```bash #!/bin/bash INPUT=$(cat) MSG=$(echo "$INPUT" | jq -r '.message') curl -X POST "$SLACK_WEBHOOK_URL" \ -H 'Content-Type: application/json' \ -d "{\"text\": \"Claude Code: $MSG\"}" \ 2>/dev/null exit 0 ``` --- ## Context Injection ### Add Project Context at Session Start ```bash #!/bin/bash # SessionStart hook if [ -f "$CLAUDE_PROJECT_DIR/.claude/context.md" ]; then cat "$CLAUDE_PROJECT_DIR/.claude/context.md" fi # Add git status echo "Current branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')" echo "Modified files: $(git status --porcelain 2>/dev/null | wc -l || echo 0)" exit 0 ``` ### Add Context from External Tool ```python #!/usr/bin/env python3 import json import subprocess import sys # Get current issues result = subprocess.run(['gh', 'issue', 'list', '--limit', '5', '--json', 'title,number'], capture_output=True, text=True) if result.returncode == 0: issues = json.loads(result.stdout) if issues: output = { "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": f"Open issues: {json.dumps(issues)}" } } print(json.dumps(output)) sys.exit(0) ``` --- ## Stop Hook: Ensure Quality ### Run Tests Before Stopping ```python #!/usr/bin/env python3 import json import sys import subprocess input_data = json.load(sys.stdin) # Prevent infinite loops if input_data.get('stop_hook_active'): sys.exit(0) # Check if tests pass result = subprocess.run(['npm', 'test'], capture_output=True, timeout=60) if result.returncode != 0: output = { "decision": "block", "reason": f"Tests failing. Fix before stopping. Error: {result.stderr.decode()[:500]}" } print(json.dumps(output)) sys.exit(0) ``` ### Check for Uncommitted Changes ```bash #!/bin/bash INPUT=$(cat) # Skip if already in stop loop if echo "$INPUT" | jq -e '.stop_hook_active' > /dev/null 2>&1; then exit 0 fi # Check for uncommitted changes if [ -n "$(git status --porcelain 2>/dev/null)" ]; then echo '{"decision":"block","reason":"Uncommitted changes detected. Commit or stash before finishing."}' fi exit 0 ``` --- ## Prompt-Based Hook (LLM Evaluation) ### Intelligent Stop Decision ```json { "hooks": { "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS\n\nCheck:\n1. Are all requested tasks complete?\n2. Are there any errors that need fixing?\n3. Is follow-up work needed?\n\nRespond: {\"ok\": true} to stop, or {\"ok\": false, \"reason\": \"explanation\"} to continue.", "timeout": 30 } ] } ] } } ``` --- ## Environment Setup ### Load nvm/Node Version ```bash #!/bin/bash # SessionStart hook with CLAUDE_ENV_FILE ENV_BEFORE=$(export -p | sort) # Load nvm export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" # Use project's node version if [ -f ".nvmrc" ]; then nvm use 2>/dev/null fi # Persist environment changes if [ -n "$CLAUDE_ENV_FILE" ]; then ENV_AFTER=$(export -p | sort) comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE" fi exit 0 ``` ### Activate Python Virtualenv ```bash #!/bin/bash if [ -n "$CLAUDE_ENV_FILE" ]; then if [ -d ".venv" ]; then echo 'export VIRTUAL_ENV=".venv"' >> "$CLAUDE_ENV_FILE" echo 'export PATH=".venv/bin:$PATH"' >> "$CLAUDE_ENV_FILE" fi fi exit 0 ``` --- ## Skill/Agent Scoped Hooks ### In SKILL.md Frontmatter ```yaml --- name: secure-deployment description: Deploy with security checks hooks: PreToolUse: - matcher: "Bash" hooks: - type: command command: "$CLAUDE_PROJECT_DIR/.claude/hooks/deploy-check.sh" once: true # Only runs once per session --- ``` ### In Subagent Definition ```yaml --- name: code-reviewer hooks: PostToolUse: - matcher: "Edit|Write" hooks: - type: command command: "./scripts/lint-check.sh" --- ``` -
READ-BUDGET-GUARD.md 20.4 KB
# Read-Budget Guard (opt-in) A PreToolUse `Read|Bash` guard that blocks an **unbounded read of a file over the line budget** — a `Read` with no `limit`, or a `cat` / `head` / `tail` whose effective line count exceeds `AOP_READ_BUDGET_LINES` (default 350) — and names the two correct moves: read a slice, or delegate the file to a cheap reader that returns line-referenced bullets. AgentOps is hookless by default — this guard ships **inert**; you activate it with the opt-in installer. ## Why it exists — the rule CLAUDE.md could not enforce Spotify open-sourced its internal Claude Code setup and reports (its claim, not re-measured here) a ~90% token cut. The part that transfers is not the number but the finding behind it: v1 put "never read a large file whole" in CLAUDE.md and the rule was ignored — advisory context, delta≈0, the same result AgentOps measured in #511. The rule only held once it moved into a PreToolUse hook that refuses the tool call and points at the bounded alternatives. The cost it guards is compounding, not one-shot. An unbounded read of an N-line file puts N lines into this context **and re-sends them on every later turn** of the session. A 2,000-line read on turn 3 is paid again on turns 4 through 40. A bounded slice costs its slice once; a delegated read costs a few bullets, because the file bytes never enter the caller's context at all. ## The predicate — a LOOKUP, so a standalone guard The policy dispatcher registry (`policies/policies.json`) only lets a `predicate_class: pure` regex over the raw command or `file_path` `deny` (the #511 anti-lesson). "Is this file over 350 lines?" is not a regex: it is a **lookup** — one deterministic local check, `wc -l` on the exact argument, no repo state, no history, no model. So this guard ships as a standalone opt-in recipe next to [INSTALLED-SKILL-EDIT-GUARD.md](INSTALLED-SKILL-EDIT-GUARD.md) and never as a registry policy, even though it borrows the registry's id form (`core.context:unbounded-read`), its waiver mechanics and its telemetry line. The guard passes numeric-limit `Read` calls, missing/non-regular/binary files, and commands containing pipes or redirects. Its Bash lexer recognizes a conservative literal-command subset described below; unsupported syntax fails open. Regression tests check both missed reads and false attribution. The parser is not a full shell interpreter, and a passing test suite does not establish that every possible shell command is classified correctly. ## Deny, not route The installed-skill-edit guard routes because a wrong edit is recoverable. An over-budget read is not: once the bytes land in context, nothing un-reads them. So this guard **denies** (exit 2 + stderr) and **every attempt blocks** — it never self-relaxes, because the second unbounded read costs exactly what the first would have. What is once-per-session is the *explanation*: the first fire in a session prints the full message; later fires print one short line (still exit 2). The message names the two correct moves and nothing else. Context-budget doctrine still applies: silent on every happy path (exit 0, zero stdout, zero stderr — a stray stdout line on an exit-0 PreToolUse path is parsed as JSON and breaks the tool call), block via exit 2 + stderr only, fail OPEN. ## The contract Ships as `skills/cc-hooks/hooks/read-budget-guard.sh` (inert until the opt-in installer wires it; `set -uo pipefail`, no `-e`). It reads the real PreToolUse JSON on stdin (`{tool_name, tool_input, session_id, cwd}`) with `jq`; a missing `session_id` is `nosession`. Policy id and `token_class`: `core.context:unbounded-read`. ### `Read` - `tool_input.limit` is a number → **PASS**. `offset` alone does not bound a read and does not pass. - Otherwise resolve `tool_input.file_path` (relative → against the JSON `cwd`, else `$PWD`). Not an existing regular readable file, or binary (a NUL byte in the first 8192 bytes) → **PASS**. - `lines = wc -l < file`; `lines > budget` → **FIRE**. ### `Bash` - The command contains any of `|`, `<`, `>` → **PASS**. A pipe feeds a bounded consumer, a redirect feeds a file sink; neither lands whole in context. Out of scope by design, not by accident. - Otherwise tokenize literal words and split on `;`, `&&` and newlines outside single/double quotes. Quoted and escaped spaces remain part of the same filename; concatenated literal fragments (`my" notes".md`) work too. Backslash-newline is deleted outside single quotes, including inside double quotes. Other quoted newlines remain literal filename bytes. A `#` at a word start begins a comment through the newline. - Parsing completes before any segment is judged. Unmatched quotes, malformed separators, expansion syntax (`$VAR`, substitution, ANSI-C `$'...'`, unquoted globs), shell control syntax and directory-changing commands (`cd`, `pushd`, `popd`, including `builtin`/`command` wrappers) skip the whole call. Later segments are never attributed to the original `cwd` after a recognized directory change. - Leading syntactic `VAR=value` assignments are removed; a quoted assignment word such as `"NAME=value"` is still a command word. An `AOP_WAIVE=...` prefix containing the policy id waives the whole call. The basename of the first remaining word must be `cat`, `head` or `tail`. Resolve that literal executable against the command cwd and PATH (including literal leading PATH assignments); missing or non-executable paths pass. Resolution never invokes the selected executable. An assignment-only segment followed by another nonempty segment (`PATH=/nonexistent; cat file`) skips the whole call because the assignment persists shell state; later segments must not reuse the hook environment. A trailing assignment alone does not hide an earlier read. - On Darwin, compare executable identity (`-ef`, following symlinks) with `/bin/cat`, `/usr/bin/head` and `/usr/bin/tail`. The system `cat` rejects GNU-only `-A`, `-E`, `-T` (including combinations) and long flags; the system `head` rejects negative counts and quiet/verbose flags. Those forms pass because the native utility does not read the file. GNU executables named `cat`/`head`/`tail` retain the generic GNU forms below, even on Darwin. - Unquoted leading `~/` expands against `HOME`; quoted/escaped tildes remain literal. Other files resolve against the input `cwd`; missing, non-regular and binary files are skipped. `--` ends flag parsing, including before an option-looking filename. `-` denotes stdin and is skipped. - `cat`: effective = **sum** of resolved files' line counts; FIRE when over budget (the message names the largest file; `N` is the total). Known output formatting flags (`-n`, `-b`, `-s`, `-A`, `-e`, `-E`, `-t`, `-T`, `-u`, `-v`, their combinations and GNU long equivalents) do not bound the read. - `head`: `-n N`, `-nN`, `-N`, `--lines=N`, `--lines N` (default 10). Positive counts, including `-n +N`, use `min(N, lines)` per file; GNU negative `-n -K` (all but the last K) uses `max(lines - K, 0)`. FIRE if any is over budget. - `tail`: the same flag forms; negative counts use `min(K, lines)`; `-n +K` = `max(lines - K + 1, 0)`, with `+0` and `+1` both meaning the whole file. FIRE if any effective count is over budget. - `--help`, `--version`, unknown flags, byte counts and follow modes skip the segment. Supported `head`/`tail` quiet/verbose formatting flags are accepted, except for the Darwin system `head` as described above. Invalid or missing numeric option values skip the segment. - Decimal normalization removes leading zeroes before arithmetic. Budgets above `9223372036854775807` saturate at that value; command counts outside that range skip the segment because the utility may reject them. Huge positive values cannot wrap into tiny budgets or negative read indices. ### Always PASS (exit 0, zero output) Any other `tool_name` (an `Edit` of a huge file is a write, not a read); an empty or unparseable command; `cat` with no file; `git status`; `grep -n`, `sed -n '1,400p'`, `awk`, `less`, `more` — bounded or paged consumers, silent by design because they *are* the correct moves. ### Waiver, kill switch, budget | Control | Effect | |---|---| | `AOP_READ_BUDGET_LINES=<n>` | the budget; default 350, and anything that is not a positive integer falls back to 350; larger than signed 64-bit values saturate as described above. Hook env only — an operator setting, never honored as a command prefix (that would be an uncounted self-relax) | | `AOP_WAIVE=core.context:unbounded-read` | waive once — as hook env, or as a prefix on the Bash command itself (comma list; the id must be in it) | | `AOP_WAIVER_FILE` line `core.context:unbounded-read <expiry-epoch>` | timed waiver; default file `${AGENTOPS_HOME:-$HOME/.agents/ao}/policy-waivers`, same semantics as the dispatcher; an expired line still fires | | `AGENTOPS_HOOKS_DISABLED=1` | kill switch: exit 0, silent, no telemetry | A waived call exits 0 with zero output and writes one telemetry line with `decision: "waived"`, so waivers are counted — they are the countermetric. ### Fail OPEN No `jq` on `PATH` → exit 0. Malformed JSON → exit 0, silent. Empty or unknown tool → exit 0. Bash judging also passes without `awk` or `uname`. A guard that cannot decide must never brick the tool call. Telemetry failure never changes the exit decision. ### The message First fire in a session (full): ```text ⛔ policy core.context:unbounded-read <path> is <N> lines (budget <B>). An unbounded read puts every line into this context and re-sends it on every later turn. → Read a slice: Read(file_path, offset, limit) with limit ≤ <B>, or Bash: sed -n '1,<B>p' <path> / grep -n <pattern> <path>. → Or delegate the whole file to a cheap reader that returns line-referenced bullets and keeps the bytes out of this context: Agent tool: subagent_type "agentops:bulk-reader", prompt "<question>\nfiles: <path>" Workflow: agentops:bulk-read { question: "<question>", files: ["<path>"] } These names require the AgentOps plugin. Use bare names only when the runtime lists standalone definitions or links under those names. Waive once: AOP_WAIVE=core.context:unbounded-read (hook env, or a prefix on the Bash command). Raise the budget: AOP_READ_BUDGET_LINES=<N> in the hook env (an operator setting, not a command prefix). ``` Later fires in the same session (short, still exit 2): ```text ⛔ policy core.context:unbounded-read: <path> is <N> lines (budget <B>) — slice it (offset+limit / sed -n) or delegate to agentops:bulk-reader (full reason shown earlier this session). ``` The per-session sentinel lives under `${TMPDIR:-/tmp}/aop-read-budget-guard/` (one file per `session_id`, `/` replaced by `_`). ### Telemetry Exactly one JSONL line per FIRE and per WAIVED call — none on pass, disabled or fail-open — appended to `${AGENTOPS_GUARDRAIL_TELEMETRY:-${AGENTOPS_HOME:-$HOME/.agents/ao}/guardrail-telemetry.jsonl}`: ```json {"ts":"2026-09-12T10:00:00Z","session":"<session_id>","token_class":"core.context:unbounded-read","path_sha256":"<64-hex>","mode":"deny","decision":"deny","tool":"Read","lines":412,"budget":350} ``` `path_sha256` is the SHA-256 of the **resolved** offending path — never the raw path, never the command. `lines` and `budget` are JSON numbers. No hasher (`sha256sum` / `shasum -a 256` / `openssl dgst -sha256`) → no line rather than a raw path. Methodology and the pre-registered KEEP/CUT rule: [GUARDRAIL-VALUE-PROOF.md](GUARDRAIL-VALUE-PROOF.md). ## The delegation pairing The guard's second arrow points at the delegation layer; without it the guard only says "no". Three bounded, one-shot, cheap-model delegations ship next to it — Claude Code plugin agents and Workflow-tool conveyors; the caller sees bullets or a receipt, never bytes, and nothing is kept between calls: | Piece | What the caller gets | |---|---| | `agents/bulk-reader.md` — subagent `agentops:bulk-reader` (`Read`/`Grep`/`Glob`/`Bash`, no `Write`/`Edit`, haiku) | line-referenced bullets (`path:line`, at most 40 unless the caller sets another cap), no prose | | `workflows/bulk-read.js` — `agentops:bulk-read { question, files, root?, model?, maxBullets?, budgetLines? }` | one reader per file in parallel; `{question, files:[{file, bullets, lines_covered, complete, note?, error?}], bullets_total}` | | `workflows/code-write.js` with subagent `agentops:code-writer` (`agents/code-writer.md`) — `agentops:code-write { items:[{key, spec, reference, target, check?}] }` | metadata-only realpath/stat preflight for batches, then sequential writers; bounded receipts (`written`, `lines`, `check_ok`, `summary`), no check output; a reference file is REQUIRED | These invocation names require the AgentOps plugin. Bare names apply only to standalone definitions or links when the runtime actually lists those names. The plugin adds the prefix; source agent names and workflow `meta.name` stay bare. Guard compatibility: the reader and writer prompts read in **slices** (`Read` with `offset` + `limit ≤ budgetLines`), never an unbounded `Read`/`cat`/`head`/`tail`. Readers start at offset 1 and continue through EOF; the limit is per call, and the bullet cap does not limit coverage. Truncated responses require smaller slices from the first unread line, not an EOF claim. An early answer does not establish the final decision while lines remain unread. So a delegate's own reads pass this guard on a host where it is installed — the delegation is not an exemption, it is a reader that obeys the same rule. A follow-up question about the same file costs another delegation, not another copy of the file in this context. Malformed worker replies produce explicit errors. Missing reader receipts leave coverage unknown (`lines_covered: null`); missing writer receipts leave write and check state unknown, never proving that no file changed. Metadata preflight and target-only edits still require worker compliance; the Workflow surface is not a filesystem sandbox. A receipt or a bullet list is a runtime fact, not validation. Whatever a writer lands still gets fresh, author-distinct judgment like any other change. Pattern and doctrine in AgentOps terms: [context-budget delegation](../../agent-native/references/context-budget-delegation.md); workflow install and args: `workflows/README.md` in the repository checkout. ## Opt-in install ```bash # user scope (~/.claude/settings.json) — the default scripts/install-read-budget-guard.sh # project scope (.claude/settings.json) scripts/install-read-budget-guard.sh --project # explicit target SETTINGS=/path/to/settings.json scripts/install-read-budget-guard.sh ``` The installer copies the guard to `~/.claude/hooks/read-budget-guard.sh`, takes a uniquely named timestamped `.bak` before changing existing settings, and adds (idempotently, matching command type and matcher) one PreToolUse `Read|Bash` matcher: ```json { "hooks": { "PreToolUse": [ { "matcher": "Read|Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/read-budget-guard.sh" } ] } ] } } ``` Requires `jq` on `PATH`. The plugin manifest `hooks/hooks.json` is not touched: nothing wires this guard automatically, on any install path. Uninstall is the line the installer prints: remove the matcher, then `rm` the copied script. ## Test it Five bats files round-trip the real PreToolUse JSON (built with `jq -nc`, never hand-written strings) under an isolated `TMPDIR` and `HOME`, with `AGENTOPS_GUARDRAIL_TELEMETRY` pointed into `TMPDIR`: - `tests/scripts/read-budget-guard.bats` — **FIRE** (exit 2, stderr names the policy id): an unbounded `Read` of a 400-line file, `Read` with `offset` only, `cat big.txt`, `cat -n big.txt`, `head -n 500` / `-500` / `--lines=500`, `tail -n 400`, `tail -n +5`, `cat a.txt b.txt` (200 + 200), a relative path resolved through the JSON `cwd`, a second fire in the same session (short line, still exit 2), the first fire's output contains `bulk-reader`. **SILENT** (exit 0, zero output): `Read` with `limit 100`, a 100-line file, a NUL-bearing binary with 400 newlines, a missing path, a directory, `cat big.txt | head -20`, `cat big.txt > out.txt`, `head big.txt`, `head -n 50`, `tail -n 20`, `grep -n`, `sed -n '1,400p'`, `cat small.txt`, `git status`, bare `cat`, `cd sub && cat big.txt`, an `Edit` of a big file. **WAIVERS**: env, command prefix, waiver file (future expiry passes, expired still fires), `AGENTOPS_HOOKS_DISABLED=1`, `AOP_READ_BUDGET_LINES=1000`. **FAIL-OPEN**: malformed JSON `{`, no `jq` on `PATH`. - `tests/scripts/read-budget-guard-regression.bats` — independent-review reproductions for ANSI-C quoted prose, cwd collisions, negative-head counts, help/unknown flags, literal spaced paths, quoted continuations, `--`, integer overflow, quoted tildes, malformed syntax and shell control flow. Every case captures stdout and stderr separately. The legacy negative-head expectation was corrected from 400 to 395 for GNU `head -n -5` on a 400-line file; the legacy silent expectation for a 400-line spaced filename was corrected to denial. Both changes restore the effective-read contract; a separate small spaced file with a large sibling checks that paths are not misattributed. - `tests/scripts/read-budget-guard-utility.bats` — compare actual utility exit status and stdout line counts with guard decisions. Darwin system rejects remain silent; positive signed head reads block; GNU formatting and negative counts remain guarded. GNU-specific tests use the installed GNU executable through a command named `cat`/`head`, and explicitly skip when GNU is absent. Darwin-only tests explicitly skip on other hosts. The earlier negative-head tests use this same distinction instead of claiming BSD rejected input reads. - `tests/scripts/read-budget-guard-telemetry.bats` — one line per fire; valid JSON with every field; `lines` and `budget` are numbers; `path_sha256` is 64 hex and equals the hash of the resolved path; the raw path and the raw command never appear; nothing on the happy path; `waived` on a waiver; two lines for two fires in one session; nothing when disabled. - `tests/scripts/install-read-budget-guard.bats` — mode 755; exactly one `Read|Bash` matcher whose command is the installed path; idempotent re-run; `--project` writes `.claude/settings.json` in the cwd; a `.bak` when settings pre-existed; the installed file byte-equals the repo source. ```bash bats tests/scripts/read-budget-guard.bats \ tests/scripts/read-budget-guard-regression.bats \ tests/scripts/read-budget-guard-telemetry.bats \ tests/scripts/read-budget-guard-utility.bats \ tests/scripts/install-read-budget-guard.bats ``` ## Known limitations The parser deliberately skips unsupported shapes instead of guessing. Known false-negative shapes: - **Pipes and redirects** pass wholesale (`cat big.txt | cat` included) — the `|` / `<` / `>` check does not inspect the consumer. - **Expansions and shell grammar** (`cat *.log`, `cat "$f"`, backticks, ANSI-C quotes, conditionals, subshells) skip the whole call. The hook never evaluates shell input. Literal quoted/escaped special characters are preserved, and unquoted leading `~/` is the one expansion mirrored. - **Command prefixes** (`sudo cat`, `time cat`, `env X=1 cat`) are silent: only a segment whose command word is `cat`, `head` or `tail` is judged. - **Persistent assignments**: an assignment-only segment before a later nonempty segment skips the whole call; persistent shell state is not tracked. An assignment prefix attached to a command remains supported. - **Directory changes and evaluation builtins** (`cd`, `pushd`, `popd`, `builtin`, `command`, `source`, `eval`, `exec`) skip the whole call. Shell functions, aliases and the exit status of earlier commands are not resolved; this guard cannot establish runtime reachability or arbitrary shell state. - **`sed`, `awk`, `less`, `more`, `grep`, `xargs`, `sh -c`** are silent by design; only `cat`, `head` and `tail` are inspected. `head -c` and `tail -f` skip their segment. - **Other utility implementations**: executable names use the generic flag set unless they match the Darwin system identities above. Arbitrary custom replacements and their option contracts are not inspected or executed. - **Lines, not bytes**: `wc -l` is the predicate, so a one-line multi-megabyte file passes. - **A subagent's own reads run under the same hook.** A `bulk-reader` that issues an unbounded `Read` is blocked like anyone else — which is why the shipped reader prompt slices. A hand-written reader that does not slice is denied, not exempted. -
SKILL-FIRST-COORDINATION-GUARD.md 10.7 KB
# Skill-First Coordination Guard (opt-in recipe) A copy-paste PreToolUse hook pair that nudges an agent to **load the coordination skill before hand-rolling the `am` / `atm` / `ntm` / `tmux send-keys` CLI surfaces**. AgentOps is hookless by design — nothing here auto-installs. This is documentation plus a recipe you opt into per host. ## Why it exists The dominant multi-agent failure mode is an agent reverse-engineering the coordination CLI (Agent Mail, ATM/NTM, raw `tmux send-keys`) from first principles instead of loading the skill that already carries the command surface *and* the doctrine. The skill knows the reservation protocol, the inbox model, the liveness truth stack; the hand-rolled invocation does not. This guard fires one loud nudge the first time it sees a bare coordination command in a session, then self-relaxes the moment the relevant skill loads. ## Context-budget doctrine for hooks Hooks are the most powerful enforcement available — mechanical, can't be reasoned past — but they **pollute context**, so use them sparingly: - A hook must be **SILENT on the happy path**: exit 0, no stdout, no stderr. - Fire **only on a real violation**, ideally **once per session**, sentinel-gated so it never repeats. - Prefer **PreToolUse violation-guards** over `UserPromptSubmit` / `SessionStart` per-turn injectors — the latter pay context on every turn whether or not anything is wrong. - **NEVER emit stray stdout on an exit-0 PreToolUse path** — stdout there is parsed as JSON and a stray line breaks the tool call. Block via **exit 2 + stderr** instead. This recipe is built to that doctrine: silent on every non-coordination command, one stderr message gated by a per-session sentinel file, self-relaxing after the skill loads. ## The matching defect this recipe fixes A naive line-based match — `grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*(am|atm|ntm)([[:space:]]|$)'` — **over-matches**: `grep` is line-oriented, so `^` matches *every* heredoc-body line, and a quoted `|ntm` inside an argument reads as a top-level `|` delimiter. A `br create "t" --body "...mentions am/atm/ntm and agent-mail|ntm|agent-native..."` would falsely fire even though no coordination command is being *run*. The fix below matches `am`/`atm`/`ntm`/`tmux send-keys` **only as an actual command head** — never inside quoted strings, heredoc bodies, or prose. It strips quoted spans (multiline-aware) and heredoc bodies, splits the remainder on top-level separators (`;` `&` `|` newline), and tests only the head token of each segment (skipping leading `VAR=val` assignments). ## Script 1 — the guard (`skill-first-coord-guard.sh`) PreToolUse / Bash. Fires once per session on a real hand-roll; silent otherwise. ```bash #!/usr/bin/env bash # skill-first-coord-guard (PreToolUse / Bash) # Nudge to load the agent-mail / ntm (ATM) skill BEFORE hand-rolling the # am / atm / ntm / tmux-send-keys CLI surfaces. # # Context-budget discipline (hooks are powerful but pollute context — use sparingly): # - SILENT on the happy path (non-coordination commands → exit 0, no output). # - Fires its one loud message ONLY on an actual hand-roll, and at most ONCE # per session. Self-relaxes after the coordination skill loads # (skill-first-coord-mark.sh) or after the single nag. set -uo pipefail input="$(cat)" cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')" sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')" # Match the coordination CLI (am/atm/ntm) or `tmux send-keys` ONLY as an actual # command HEAD — never inside quoted strings, heredoc bodies, or prose. A naive # line-based grep over-matches: `^` matches every heredoc-body line, and a # quoted `|ntm` reads as a top-level delimiter, so a `br create` whose BODY # merely mentions am/atm/ntm would falsely fire. So we: # 1. strip single/double-quoted spans (multiline-aware) and heredoc bodies, # 2. split what remains on top-level separators ( ; & | newline ), # 3. test only the HEAD token of each segment (skipping VAR=val assignments). is_coord=0 stripped="$(printf '%s' "$cmd" | perl -0777 -pe " s/'[^']*'//g; # single-quoted spans s/\"[^\"]*\"//g; # double-quoted spans (multiline) s/<<-?\s*([A-Za-z_][A-Za-z0-9_]*).*?^\s*\1\b//gms; # heredoc bodies ")" printf '%s' "$stripped" | awk ' BEGIN { RS="[;&\n]|\\|\\|?"; FS="[ \t]+" } { i=1 while (i<=NF && ($i=="" || $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++ # skip VAR=val head=$i if (head=="am" || head=="atm" || head=="ntm") { found=1 } if (head=="tmux") { nxt=$(i+1); if (nxt=="send-keys") found=1 } # tmux send-keys } END { exit (found?0:1) } ' && is_coord=1 [ "$is_coord" -eq 1 ] || exit 0 dir="${TMPDIR:-/tmp}/claude-coordguard" sentinel="$dir/${sid//\//_}" [ -f "$sentinel" ] && exit 0 # skill already loaded, or already nagged this session mkdir -p "$dir" 2>/dev/null || true : > "$sentinel" 2>/dev/null || true cat >&2 <<'MSG' ⛔ SKILL-FIRST (coordination): load the skill before hand-rolling the AM/ATM CLI. • am (Agent Mail) → Skill tool: agent-mail • atm / ntm pane command → Skill tool: ntm; agent-native for role lifecycle • tmux send-keys to a pane → Skill tool: ntm The skill carries the command surface + doctrine — don't reverse-engineer the CLI. Fires once per session and self-relaxes after the skill loads. Re-run your command. MSG exit 2 ``` ## Script 2 — the mark (`skill-first-coord-mark.sh`) PreToolUse / Skill. Silently records that a coordination skill loaded so the guard self-relaxes. Zero context output — pure side effect. ```bash #!/usr/bin/env bash # skill-first-coord-mark (PreToolUse / Skill) # Silently record that a coordination skill loaded this session so the # skill-first-coord-guard self-relaxes. ZERO context output — pure side effect. set -uo pipefail input="$(cat)" skill="$(printf '%s' "$input" | jq -r '.tool_input.skill // ""')" case "$skill" in agent-mail|ntm|agent-native) sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')" dir="${TMPDIR:-/tmp}/claude-coordguard" mkdir -p "$dir" 2>/dev/null || true : > "$dir/${sid//\//_}" 2>/dev/null || true ;; esac exit 0 ``` ## Opt-in install 1. Save both scripts (e.g. to `~/.claude/hooks/`) and `chmod +x` them. 2. Add the hook pair to `~/.claude/settings.json` (user) or `.claude/settings.json` (project). Note the **two separate matchers** — `Bash` runs the guard, `Skill` runs the mark: ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/skill-first-coord-guard.sh" } ] }, { "matcher": "Skill", "hooks": [ { "type": "command", "command": "~/.claude/hooks/skill-first-coord-mark.sh" } ] } ] } } ``` Requires `jq`, `perl`, and `awk` on `PATH` (all standard on macOS and Linux). ## Test it (and prove it) A small bats test exercising the fire / silent / once-per-session contract. Save as `tests/skill-first-coord-guard.bats` and run with `bats <file>`: ```bash #!/usr/bin/env bats # Contract for skill-first-coord-guard.sh: # FIRE (exit 2): am robot status · atm up · ntm --robot-attention # git commit -m x && am mail send · tmux send-keys -t x hi # SILENT (exit 0): ls -la · npm test · team build · echo "I am here" # br create "t" --body "...am/atm/ntm... agent-mail|ntm|agent-native..." GUARD="${GUARD:-$HOME/.claude/hooks/skill-first-coord-guard.sh}" setup() { export TMPDIR="$(mktemp -d)"; } run_guard() { # $1=command $2=session_id jq -nc --arg c "$1" --arg s "$2" '{tool_input:{command:$c},session_id:$s}' | bash "$GUARD" } @test "FIRE: am robot status" { run run_guard 'am robot status' "s1"; [ "$status" -eq 2 ]; } @test "FIRE: atm up" { run run_guard 'atm up' "s2"; [ "$status" -eq 2 ]; } @test "FIRE: ntm --robot-attention" { run run_guard 'ntm --robot-attention' "s3"; [ "$status" -eq 2 ]; } @test "FIRE: chained && am mail send" { run run_guard 'git commit -m x && am mail send' "s4"; [ "$status" -eq 2 ]; } @test "FIRE: tmux send-keys" { run run_guard 'tmux send-keys -t x hi' "s5"; [ "$status" -eq 2 ]; } @test "SILENT: ls -la" { run run_guard 'ls -la' "s6"; [ "$status" -eq 0 ]; [ -z "$output" ]; } @test "SILENT: npm test" { run run_guard 'npm test' "s7"; [ "$status" -eq 0 ]; [ -z "$output" ]; } @test "SILENT: team build" { run run_guard 'team build' "s8"; [ "$status" -eq 0 ]; [ -z "$output" ]; } @test "SILENT: echo quoted am" { run run_guard 'echo "I am here"' "s9"; [ "$status" -eq 0 ]; [ -z "$output" ]; } @test "SILENT: br create body mentions am/atm/ntm (false-positive guard)" { run run_guard 'br create "t" --body "...am/atm/ntm... agent-mail|ntm|agent-native..."' "s10" [ "$status" -eq 0 ]; [ -z "$output" ] } @test "once-per-session: first fires, second self-relaxes" { run run_guard 'am robot status' "same"; [ "$status" -eq 2 ] run run_guard 'atm up' "same"; [ "$status" -eq 0 ] } ``` Proven output of an equivalent pure-shell harness against all contract cases (every FIRE → exit 2, every SILENT → exit 0 with zero stderr bytes, including the `br create` false-positive case and a multiline heredoc body): ``` === FIRE (expect exit 2) === exit=2 am robot status exit=2 atm up exit=2 ntm --robot-attention exit=2 git commit -m x && am mail send exit=2 tmux send-keys -t x hi === SILENT (expect exit 0, no stderr) === exit=0 ls -la [stderr-bytes=0] exit=0 npm test [stderr-bytes=0] exit=0 team build [stderr-bytes=0] exit=0 echo "I am here" [stderr-bytes=0] exit=0 br create "t" --body "...am/atm/ntm... agent-mail|ntm|..." [stderr-bytes=0] === once-per-session sentinel === 1st am : exit=2 2nd atm: exit=0 ``` ## Known limitations The guard tests only the actual command **head**, so it intentionally does NOT fire when a coordination CLI is reached indirectly — via command-substitution (`$(am …)`), backticks, or a command-prefix wrapper (`time am …`, `env X=1 am …`). This is by design: the guard is an opt-in *nudge*, not a security boundary. The cost of a missed case is exactly one un-nudged hand-roll — no false fire, no broken command. Erring toward silence keeps it cheap on context and safe to run. The recipe also requires `jq`, `awk`, and `perl` on `PATH`.
-
-
scripts
-
install-hooks.sh 3.5 KB
#!/usr/bin/env bash # install-hooks.sh — wire the AgentOps policy dispatcher into Claude settings, # from ANY install shape (epic age-4qw1: hooks ship by default). # # This copy lives INSIDE the cc-hooks skill package so every distribution path # carries its own wiring: # - npx skills / skills.sh copy -> ~/.claude/skills/cc-hooks/scripts/install-hooks.sh # - git clone / brew checkout -> skills/cc-hooks/scripts/install-hooks.sh # (scripts/install-policy-dispatch.sh at the repo root delegates here) # - Claude Code PLUGIN installs need NO installer at all: the plugin bundles # hooks/hooks.json and Claude wires it automatically. # # Everything resolves relative to THIS script's skill dir, so it works from a # copied skill directory with no repo present. Idempotent; backs up settings. # # Usage: # install-hooks.sh # user settings (~/.claude/settings.json) # install-hooks.sh --project # project settings (.claude/settings.json) # SETTINGS=/path/settings.json install-hooks.sh set -euo pipefail umask 022 # shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" skill_dir="$(dirname "$script_dir")" src_dispatch="${skill_dir}/hooks/policy-dispatch.sh" src_policies="${skill_dir}/policies/policies.json" lint="${script_dir}/lint-policies.sh" [[ -f "$src_dispatch" ]] || { echo "ERROR: dispatcher missing: ${src_dispatch}" >&2; exit 1; } [[ -f "$src_policies" ]] || { echo "ERROR: registry missing: ${src_policies}" >&2; exit 1; } command -v jq >/dev/null || { echo "ERROR: jq required" >&2; exit 1; } # Never install a registry that fails its own contract. bash "$lint" "$src_policies" settings="${SETTINGS:-}" if [[ -z "$settings" ]]; then case "${1:-}" in --project) settings=".claude/settings.json" ;; *) settings="${HOME}/.claude/settings.json" ;; esac fi hooks_dir="${HOME}/.claude/hooks/aop" mkdir -p "$hooks_dir" install -m 0755 "$src_dispatch" "${hooks_dir}/policy-dispatch.sh" install -m 0644 "$src_policies" "${hooks_dir}/policies.json" dst="${hooks_dir}/policy-dispatch.sh" echo "✓ installed ${dst} (+ policies.json beside it)" mkdir -p "$(dirname "$settings")" [[ -f "$settings" ]] || echo '{}' > "$settings" if [[ -s "$settings" ]]; then backup="$(mktemp "${settings}.bak.$(date +%Y%m%d%H%M%S).XXXXXX")" cp -p "$settings" "$backup" echo "✓ backed up settings → ${backup}" fi tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT jq --arg cmd "$dst" ' .hooks //= {} | .hooks.PreToolUse //= [] | reduce ("Bash", "Edit|Write") as $m (.; if any(.hooks.PreToolUse[]?; .matcher == $m and any((.hooks // [])[]?; .command == $cmd)) then . else .hooks.PreToolUse += [{ "matcher": $m, "hooks": [ { "type": "command", "command": $cmd } ] }] end ) ' "$settings" > "$tmp" && mv "$tmp" "$settings" trap - EXIT if grep -qF "$dst" "$settings"; then echo "✓ wired PreToolUse (Bash, Edit|Write) policy dispatcher into ${settings}" else echo "ERROR: failed to wire dispatcher into ${settings}" >&2 exit 1 fi echo "" echo "Policy dispatcher active for this Claude scope. SILENT on every clean call;" echo "deny policies block with a one-line route to the correct tool; fires land one" echo "hashed telemetry line in \${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl." echo "Waive once: AOP_WAIVE=<policy-id> <your command>" echo "Uninstall: remove the two PreToolUse matchers for ${dst} from ${settings}, then rm -rf ${hooks_dir}" -
lint-policies.sh 4 KB
#!/usr/bin/env bash # lint-policies.sh — mechanical enforcement of the hooks-manifest.v2 contract # (age-bhsz). jq + grep only (no jsonschema dependency), so the discipline is # checkable in bats, pre-commit, and CI alike. # # Checks, in order: # 1. registry parses as JSON and declares schema hooks-manifest.v2 # 2. every policy has id / predicate_class / mode / matchers / route_message / # rationale / value_proof # 3. id matches domain.object:token and is unique # 4. mode is deny|route|audit; predicate_class is pure|lookup|stateful # 5. PREDICATE DISCIPLINE: predicate_class != pure => mode == audit # (the #511 anti-lesson — stateful guards are barred from blocking # until promoted from audit with reviewed fires) # 6. matcher tools are Bash|Edit|Write; field is command|file_path # 7. every pattern compiles under grep -E on this host # # Usage: lint-policies.sh [registry.json] (default: ../policies/policies.json) set -uo pipefail # shellcheck disable=SC1007 # CDPATH= scopes an empty CDPATH to the cd, intentionally script_dir="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" registry="${1:-${script_dir}/../policies/policies.json}" fail() { printf 'lint-policies: FAIL: %s\n' "$1" >&2; exit 1; } command -v jq >/dev/null 2>&1 || fail "jq is required" [ -f "$registry" ] || fail "registry not found: ${registry}" jq empty "$registry" 2>/dev/null || fail "not valid JSON: ${registry}" schema="$(jq -r '.schema // ""' "$registry")" [ "$schema" = "hooks-manifest.v2" ] || fail "schema must be hooks-manifest.v2, got: '${schema}'" count="$(jq '.policies | length' "$registry")" [ "$count" -ge 1 ] || fail "policies array is empty" # Required fields present and non-empty on every policy. missing="$(jq -r ' .policies[] | . as $p | ["id","predicate_class","mode","matchers","route_message","rationale","value_proof"][] | select(($p[.] // "") == "" or ($p[.] == null)) | ($p.id // "<no-id>") + " missing " + . ' "$registry")" [ -z "$missing" ] || fail "$missing" # id format + uniqueness. bad_id="$(jq -r '.policies[].id | select(test("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$") | not)' "$registry")" [ -z "$bad_id" ] || fail "id not domain.object:token: ${bad_id}" dup_id="$(jq -r '[.policies[].id] | group_by(.) | map(select(length > 1) | .[0]) | .[]' "$registry")" [ -z "$dup_id" ] || fail "duplicate policy id: ${dup_id}" # Enums. bad_mode="$(jq -r '.policies[] | select(.mode | IN("deny","route","audit") | not) | .id' "$registry")" [ -z "$bad_mode" ] || fail "invalid mode on: ${bad_mode}" bad_class="$(jq -r '.policies[] | select(.predicate_class | IN("pure","lookup","stateful") | not) | .id' "$registry")" [ -z "$bad_class" ] || fail "invalid predicate_class on: ${bad_class}" # THE DISCIPLINE RULE: non-pure predicates may only audit. undisciplined="$(jq -r '.policies[] | select(.predicate_class != "pure" and .mode != "audit") | .id' "$registry")" [ -z "$undisciplined" ] || fail "predicate discipline violation (non-pure predicate in blocking mode): ${undisciplined}" # Matcher shape. bad_tool="$(jq -r '.policies[] | .id as $id | .matchers[].tools[] | select(IN("Bash","Edit","Write") | not) | $id + " tool " + .' "$registry")" [ -z "$bad_tool" ] || fail "invalid matcher tool: ${bad_tool}" bad_field="$(jq -r '.policies[] | .id as $id | .matchers[] | select(.field | IN("command","file_path") | not) | $id' "$registry")" [ -z "$bad_field" ] || fail "invalid matcher field on: ${bad_field}" # Every pattern must compile under grep -E on this host. # join(), not @tsv: TSV escaping mangles backslashes inside patterns. while IFS=$'\x1f' read -r pid pattern; do [ -n "$pid" ] || continue if ! printf '' | grep -qE "$pattern" 2>/dev/null; then # grep exits 1 on no-match with a VALID pattern; only exit >1 is a compile error. rc=$? [ "$rc" -le 1 ] || fail "pattern does not compile (grep -E rc=${rc}) on ${pid}: ${pattern}" fi done < <(jq -r '.policies[] | .id as $id | .matchers[] | [$id, .pattern] | join("")' "$registry") printf 'lint-policies: OK (%s policies)\n' "$count"
-
-
.agentops-generated.json 265 B
{ "generator": "codex-sync", "source_skill": "skills/cc-hooks", "layout": "modular", "source_hash": "7f88206dba2dd7297b9c867b11a951993425fde34de48dbce38e058b5b94e79c", "generated_hash": "4e07db6637d618887e47f0e22026e79be85022e29e1766e3699b167b34cecd3f" } -
prompt.md 363 B
# cc-hooks Configure Claude Code hooks and narrow enforcement guards. Use when: the caller requests hook installation, repair or policy changes; a hook is not required to use other skills. ## Instructions Load and follow the skill instructions from the sibling `SKILL.md` file for this skill. Then read local files in `references/` and `scripts/` when needed. -
SKILL.md 16.9 KB
--- name: cc-hooks description: 'Configure Claude Code hooks and narrow enforcement guards. Use when: the caller requests hook installation, repair or policy changes; a hook is not required to use other skills.' --- # Claude Code Hooks Shell commands that fire at specific points in Claude Code's lifecycle. Hooks enforce mechanically what prose cannot: a model can reason its way past an instruction, but it cannot reason its way past an exit 2 — which is exactly why every hook must be narrow, silent, and reversible. Named failure mode — **chatty happy path**: a hook that emits stdout on exit 0 corrupts the tool call it was guarding; silence on success is part of the contract, not a style preference. ## Prompt ```text Add a PreToolUse hook to fleet-router/.claude/settings.json that blocks `git push --force` on the main branch. Keep it silent on exit 0, exit 2 with a message on block, and confirm it fires with a manual test invocation before committing the change. ``` ## It's working if - The hook script exits `2` with a stderr message when it blocks `git push --force`, and exit `0` with no stdout on the allowed path. - `.claude/settings.json` gains one matcher entry for the new hook, alongside the existing hooks list rather than replacing it. - A manual test invocation against the new matcher shows the block firing in the transcript, with exit `2` visible, before the change gets committed. - The hook inspects only the `PreToolUse` call it guards, keeping every other file untouched. ## Constraints - Enforcement hooks (the PreToolUse policy dispatcher) ship by DEFAULT: plugin installs auto-wire `hooks/hooks.json`; skill copies and checkouts wire with one command (`scripts/install-hooks.sh`). Operators can disable per host (`/plugin disable`, or remove the settings matchers). - Injection hooks (SessionStart/UserPromptSubmit context stuffing) stay dead — the #511 teardown proved delta=0 at 10.35M resident tokens. Never ship one; the hookless-cold-start gate still enforces this. - Keep the happy path silent and block only with the event's documented exit/JSON contract because stray stdout can corrupt a tool call. - Bound Stop hooks with `stop_hook_active` and scope matchers narrowly to prevent recursion and unrelated-command interception. <!-- TOC: Quick Start | Events | Blocking | Writing Hooks | Anti-Patterns | References --> ## Quick Start Add to `~/.claude/settings.json` (user) or `.claude/settings.json` (project): ```json {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-validator.sh"}]}]}} ``` ## Hook Events | Event | When | Blocks? | Common Use | |-------|------|---------|------------| | `PreToolUse` | Before tool runs | Yes | Block/modify commands | | `PostToolUse` | After tool succeeds | Feedback | Auto-format, lint | | `PermissionRequest` | Permission dialog | Yes | Auto-approve/deny | | `UserPromptSubmit` | Prompt submitted | Yes | Add context, validate | | `Stop` | Claude finishes | Yes | Force continue | | `SessionStart` | Session begins | No | Load context, set env | | `Notification` | Notifications | No | Desktop alerts | Full schemas: [HOOK-EVENTS.md](references/HOOK-EVENTS.md) ## Matchers ``` "Bash" → exact match "Edit|Write" → regex OR "mcp__.*__write" → MCP tools "*" or "" → all tools ``` Tools: `Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Task`, `WebFetch`, `WebSearch` ## Exit Codes | Code | Effect | |------|--------| | 0 | Success - JSON parsed from stdout | | 2 | **Block** - stderr fed to Claude | | Other | Non-blocking error | ## Blocking a Tool **Simple (exit 2):** ```bash echo "Blocked: reason" >&2 && exit 2 ``` **JSON (exit 0):** ```json {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}} ``` Decisions: `"allow"` (auto-approve), `"deny"` (block), `"ask"` (show dialog) ## Modifying Input ```json {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow", "updatedInput":{"command":"modified-command"}}} ``` ## Real-World: DCG + RCH ```json {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[ {"type":"command","command":"dcg"}, {"type":"command","command":"rch"} ]}]}} ``` - **DCG**: Blocks `git reset --hard`, `rm -rf`, `git push --force` - **RCH**: Routes builds to remote workers Details: [DCG-RCH.md](references/DCG-RCH.md) ## Skill-First Coordination Guard (opt-in) A copy-paste PreToolUse recipe that nudges agents to **load the coordination skill before hand-rolling the `am`/`atm`/`ntm`/`tmux send-keys` CLI**. This recipe auto-installs nothing; you opt in per host (unlike the policy dispatcher, which ships by default). **Context-budget doctrine for hooks:** hooks are the most powerful enforcement (mechanical, can't be reasoned past) but they pollute context — use sparingly. A hook must be SILENT on the happy path (exit 0, no stdout/stderr), fire ONLY on a real violation (ideally once per session, sentinel-gated), prefer PreToolUse violation-guards over `UserPromptSubmit`/`SessionStart` per-turn injectors, and NEVER emit stray stdout on an exit-0 PreToolUse path (it is parsed as JSON and breaks the tool call). Block via exit 2 + stderr. The recipe ships both scripts verbatim, a precise head-only matcher (so a `br create --body "...am/atm/ntm..."` never false-fires), the two-matcher opt-in `settings.json` snippet, and a bats test proving every fire/silent case. Recipe: [SKILL-FIRST-COORDINATION-GUARD.md](references/SKILL-FIRST-COORDINATION-GUARD.md) ## Installed-Skill-Edit Guard (opt-in) A PreToolUse `Edit|Write` guard that routes an edit of an **installed skill copy** (`*/.claude/skills/**`, `.codex`, `.gemini`) back to the repo source of truth `skills/<name>/`. This is a TRUE mistake-token — editing an installed/symlinked copy has no legitimate form (overwritten on install, or symlinks through to the factory checkout). Zero false-positive surface: it matches `tool_input.file_path` only, so a doc that merely mentions `claude/skills` in its body never fires. Reversible → it ROUTES (exit 2 + one-line redirect), not hard-blocks. Silent on every other path; fires once per session. Ships INERT — opt-in installer: ```bash scripts/install-installed-skill-edit-guard.sh # user scope; --project for project ``` Recipe: [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) ### Value-proof (why this guard survives the hookless teardown) The keystone guard ships **gate-blind per-fire telemetry**: on each fire it appends exactly one JSONL line — `{ts, session, token_class, path_sha256}` — to `${AGENTOPS_HOME:-~/.agents/ao}/guardrail-telemetry.jsonl` (override with `AGENTOPS_GUARDRAIL_TELEMETRY`). The path is **SHA-256 hashed, never raw** (privacy); nothing is written on the happy path; the sensor is inert until the guard is installed and fires. The pre-registered methodology — metric = declining fire-ATTEMPT rate over time (a signal the redirect cannot fake, NOT the circular hand-roll rate), minimum N, noise floor, and **null-at-small-N is an acceptable outcome** — satisfies ADR-0002 l.58 ("test or eval evidence showing positive value"), the criterion whose absence killed 2.x hooks (#511). Methodology: [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) ## Read-Budget Guard (opt-in) A PreToolUse `Read|Bash` guard that DENIES an **unbounded read over the line budget** (`AOP_READ_BUDGET_LINES`, default 350): a `Read` with no `limit`, or a `cat`/`head`/`tail` whose effective line count exceeds it. The Spotify finding: the same rule in CLAUDE.md was advisory and ignored, and an over-budget read re-sends its lines on every later turn. The predicate is a LOOKUP (`wc -l` on the exact argument), so it is a standalone guard, never a registry policy. A `limit`-bounded slice, a file at/below budget, a pipe, a redirect or quoted text that merely mentions `cat` passes. Literal quoted/escaped paths are preserved; unsupported shell syntax and directory-changing chains fail open. Negative `head` counts use the actual number of retained lines for GNU head; rejected Darwin system utility flags pass after checking executable identity. Positive signed head counts are bounded by their numeric value. Nothing un-reads bytes once in context → every attempt blocks (exit 2 + stderr): full message once per session naming the two correct moves (slice it, or delegate to the plugin's `agentops:bulk-reader` subagent / `agentops:bulk-read` workflow), one short line after. Use bare names only for standalone definitions or links when the runtime lists them. Waive once with `AOP_WAIVE=core.context:unbounded-read`; hashed telemetry adds `tool`, `lines`, `budget` plus the dispatcher's `mode`/`decision` pair. Ships INERT — opt-in installer: ```bash scripts/install-read-budget-guard.sh # user scope; --project for project ``` Recipe: [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) ## Policy Dispatch Engine (ships by default) The admission-control layer (epic age-4qw1): **one** PreToolUse dispatcher — [hooks/policy-dispatch.sh](hooks/policy-dispatch.sh) — evaluating a **policies-as-data** registry ([policies/policies.json](policies/policies.json), contract `schemas/hooks-manifest.v2.schema.json`) instead of N hand-wired settings entries. This is the membrane at tool-call altitude: same vocabulary, lower altitude than the pawl/gate at push time. Per policy: dcg-style id (`domain.object:token`), `mode: deny | route | audit`, matchers (tool + `command`/`file_path` regex), a `route_message` that names THE correct tool, a rationale, and a pre-registered `value_proof` (the ADR-0002 lease-on-life: no proof accruing → retire the policy). **Predicate discipline, schema-enforced** (the #511 anti-lesson): only `predicate_class: pure` — syntactic mistake-tokens over the command or file path — may `deny`/`route`. Lookup/stateful predicates ship `audit`-only until promoted with reviewed fires. [scripts/lint-policies.sh](scripts/lint-policies.sh) enforces this mechanically (jq-only; runs in bats and CI). **Accepted false-positive surface:** because a pure predicate matches its token anywhere in the raw command string, a protected token quoted as *data* (a commit message body, a `dcg test "..."` probe, a here-doc payload) can still fire even though nothing harmful would run. This is the deliberate cost of the pure-only-may-deny rule — the alternative (repo/context lookups) is exactly the stateful predicate the discipline bars from `deny`. Every fire is reversible: a one-shot `AOP_WAIVE=<policy-id>` or a `policy-waivers` line clears it. Semantics: happy path = exit 0, zero output. `deny` = exit 2 + one stderr route line (full message once per session, short line after — every attempt still blocks). `route` = exit 0 + `permissionDecision:"ask"` JSON. `audit` = allow + record. Every fire appends one hashed guardrail-telemetry line (`token_class` = policy id, plus `mode`/`decision`). Waive once with `AOP_WAIVE=<policy-id>`, or a `policy-waivers` file line `<policy-id> <expiry-epoch>`. Missing registry or jq fails OPEN. Enforce cohort (all pure-regex, high-pain). The first four are the day-1 maintainer cohort (age-wnyt) — they guard *this repository's* artifacts. The fifth guards the **product's own invariant** and therefore fires on every consumer repo, not just this one: | Policy | Blocks | Routes to | |---|---|---| | `core.git:add-beads-ledger` | `git add` naming `_beads/` (private ledger leak is one-way) | push the ledger repo itself — never `git add _beads` in the public tree | | `core.provenance:ledger-hand-append` | redirect/`tee`/Edit/Write onto `docs/provenance/ledger.jsonl` (hash-chained, sealed) | `ao provenance add` | | `core.skills:copy-into-installed` | `cp`/`rsync`/`mv` INTO `~/.claude|.codex|.gemini/skills` (dest-position enforced) | `ao skills link` | | `core.skills:edit-installed-copy` | Edit/Write of an installed skill copy (`file_path` only — prose can never fire it) | edit repo `skills/<name>/` | | `core.verdicts:hand-edit` | Edit/Write, or Bash `>`/`>>`/`tee`/`cp`/`rsync`/`mv` INTO `.agents/ao/verdicts/` (dest-position enforced) — the filename IS the SHA-256 of the content, so a hand edit breaks digest identity | re-run validation and let it persist a fresh artifact (`validate.py store-verdict`) | `core.verdicts:hand-edit` is the one policy whose subject is the *promise* rather than the repo: a verdict that no longer hashes to its own filename is forged evidence, and nothing above the tool-call altitude catches it. Reading the store is untouched — `cat`/`ls`/`jq`/`rg`/`diff` over a verdict, and copying one OUT for inspection, never fire; only writes landing IN the store do — including in-place editors (`sed -i`, `perl -pi`/`-ni`) and deleters (`rm`, `unlink`, `shred`), matched as flag-tokens so a read whose script text merely contains `-i` stays silent (bats-proven both directions). Remaining disclosed gap: the noclobber override redirect (`>|`). **How it reaches users — every install path delivers hooks:** | Install path | Delivery | |---|---| | Claude Code plugin (`claude plugin install agentops@agentops-marketplace`) | **Automatic** — the plugin bundles `hooks/hooks.json` (`${CLAUDE_PLUGIN_ROOT}` paths); hooks are active on install, no wiring step | | `npx skills@latest add boshu2/agentops` / skills.sh copy | The skill package carries its own installer: `~/.claude/skills/cc-hooks/scripts/install-hooks.sh` (one command; file copies cannot self-wire) | | git clone / brew checkout | `scripts/install-policy-dispatch.sh` (delegates to the same skill-embedded installer) | The installer lints the registry before wiring, backs up settings, and is idempotent. Disable per host with `/plugin disable agentops` or by removing the two PreToolUse matchers from settings. Contract tests: `tests/scripts/policy-dispatch.bats` (block+message+telemetry per policy, stray-stdout hazard, waivers, audit/route modes, fail-open). ## Writing Your Own Hook **Minimal Python:** ```python #!/usr/bin/env python3 import json, sys data = json.load(sys.stdin) cmd = data.get('tool_input', {}).get('command', '') if 'dangerous' in cmd: print("Blocked: dangerous", file=sys.stderr) sys.exit(2) sys.exit(0) # Allow ``` **Hook input (stdin):** ```json {"tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"...","cwd":"..."} ``` ## Environment Variables | Variable | Scope | Purpose | |----------|-------|---------| | `CLAUDE_PROJECT_DIR` | All | Project root | | `CLAUDE_ENV_FILE` | SessionStart/Setup | Persist env vars | ## Stop Hook (Force Continue) ```json {"decision":"block","reason":"Tests failing. Fix before stopping."} ``` **Critical:** Check `stop_hook_active` to prevent infinite loops. ## Anti-Patterns | Don't | Do | |-------|-----| | Old object format | Array format with `matcher` | | Unquoted `$VAR` | `"$VAR"` | | Exit 2 with JSON | Exit 2 uses stderr only | | Skip `stop_hook_active` check | Always check in Stop hooks | ## Debugging ```bash claude --debug # Hook execution details /hooks # View/edit in REPL ``` ## Output Specification - **Path:** user `~/.claude/settings.json` or project `.claude/settings.json`, plus explicitly named hook scripts. The PreToolUse policy dispatcher ships by default (every install path wires it — see "Policy Dispatch Engine"); the additional guard recipes (skill-first coordination, standalone installed-skill-edit, read-budget) stay inert until opted in. - **Filename:** preserve `settings.json`; give scripts descriptive executable filenames rather than embedding large shell programs in JSON. - **Format:** valid Claude hook JSON using event arrays, matchers, and command objects; hook stdout/stderr and exit codes follow the selected event schema. - **Exit code:** validate with `jq -e '.hooks | type=="object"' <settings.json>` and a representative silent/fire test for each matcher; any parse error, noisy happy path, or recursion risk blocks activation. - **Downstream handoff:** consumed by the operator only after the exact scope, reversal command, test evidence, and opt-in location are reported. ## Quality Checklist - The matcher fires on the intended event/input and stays silent on representative near misses. - Blocking and allow paths use the documented exit code and output channel without leaking context. - The hook is reversible, narrowly scoped, recursion-safe, and clearly labeled as opt-in host policy. ## References - [HOOK-EVENTS.md](references/HOOK-EVENTS.md) - All events with full schemas - [DCG-RCH.md](references/DCG-RCH.md) - Production examples (dcg, rch) - [INSTALLED-SKILL-EDIT-GUARD.md](references/INSTALLED-SKILL-EDIT-GUARD.md) - Opt-in guard routing installed-skill edits to repo skills/ (keystone) - [READ-BUDGET-GUARD.md](references/READ-BUDGET-GUARD.md) - Opt-in guard denying unbounded reads over the line budget; pairs with bulk-read / code-write delegation - [GUARDRAIL-VALUE-PROOF.md](references/GUARDRAIL-VALUE-PROOF.md) - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58) - [PATTERNS.md](references/PATTERNS.md) - Auto-format, logging, notifications - [JSON-OUTPUT.md](references/JSON-OUTPUT.md) - Response schemas -
skill.spec.json 3.5 KB
{ "name": "cc-hooks", "skill_api_version": 1, "form": "A", "quality_score": 0.92, "sections": [ { "id": "title", "title": "Claude Code Hooks", "type": "intro", "priority": "required" }, { "id": "quickstart", "title": "Quick Start", "type": "procedure", "priority": "required" }, { "id": "events", "title": "Hook Events", "type": "table", "priority": "required" }, { "id": "matchers", "title": "Matchers", "type": "overview", "priority": "required" }, { "id": "exitcodes", "title": "Exit Codes", "type": "table", "priority": "required" }, { "id": "blocking", "title": "Blocking a Tool", "type": "procedure", "priority": "required" }, { "id": "modifying", "title": "Modifying Input", "type": "procedure", "priority": "standard" }, { "id": "dcgrch", "title": "Real-World: DCG + RCH", "type": "examples", "priority": "standard" }, { "id": "writing", "title": "Writing Your Own Hook", "type": "procedure", "priority": "required" }, { "id": "envvars", "title": "Environment Variables", "type": "table", "priority": "standard" }, { "id": "stophook", "title": "Stop Hook (Force Continue)", "type": "procedure", "priority": "standard" }, { "id": "antipattern", "title": "Anti-Patterns", "type": "constraints", "priority": "required" }, { "id": "debugging", "title": "Debugging", "type": "procedure", "priority": "standard" }, { "id": "references", "title": "References", "type": "routing", "priority": "required" } ], "references": [ { "file": "references/HOOK-EVENTS.md", "topic": "all hook events with full input/output schemas" }, { "file": "references/DCG-RCH.md", "topic": "production examples (dcg, rch) wired as PreToolUse hooks" }, { "file": "references/SKILL-FIRST-COORDINATION-GUARD.md", "topic": "opt-in skill-first coordination guard recipe + hook context-budget doctrine" }, { "file": "references/READ-BUDGET-GUARD.md", "topic": "opt-in read-budget guard: blocks unbounded reads over the line budget and routes to slices or cheap-model bulk-read delegation" }, { "file": "references/PATTERNS.md", "topic": "auto-format, logging, notification hook patterns" }, { "file": "references/JSON-OUTPUT.md", "topic": "hook response JSON schemas" } ], "metadata": { "tier": "execution", "stability": "stable", "dependencies": ["dcg", "rch"], "hexagonal_role": "supporting", "context_window": "inherit", "practices": ["pragmatic-programmer"], "triggers": [ "cc-hooks", "Claude Code hooks", "PreToolUse", "PostToolUse", "Stop hook", "Notification hook", "block a command", "auto-format on edit", "custom permissions", "write a hook" ], "token_estimate": { "minimal": 60, "overview": 220, "standard": 650, "full": 1200 } }, "output_contract": "A hooks block in ~/.claude/settings.json or .claude/settings.json (matcher + command entries), or a hook script that reads tool JSON on stdin and signals allow/deny/ask via exit codes (0/2) or hookSpecificOutput JSON. Stop hooks must guard stop_hook_active against infinite loops.", "evidence": { "sources": [ "skills/cc-hooks/SKILL.md", "skills/cc-hooks/references/HOOK-EVENTS.md", "skills/cc-hooks/references/DCG-RCH.md", "skills/cc-hooks/references/JSON-OUTPUT.md" ] } }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.