Claude Code Hooks

SkillAI & models

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Claude Code Hooks skill

What this skill tells your AI

The instructions your AI receives, as published by boshu2/agentops in skills/cc-hooks/SKILL.md and read by ahel’s review.

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 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.

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

EventWhenBlocks?Common Use
PreToolUseBefore tool runsYesBlock/modify commands
PostToolUseAfter tool succeedsFeedbackAuto-format, lint
PermissionRequestPermission dialogYesAuto-approve/deny
UserPromptSubmitPrompt submittedYesAdd context, validate
StopClaude finishesYesForce continue
SessionStartSession beginsNoLoad context, set env
NotificationNotificationsNoDesktop 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

CodeEffect
0Success - JSON parsed from stdout
2Block - stderr fed to Claude
OtherNon-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

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:

PolicyBlocksRoutes to
core.git:add-beads-ledgergit 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-appendredirect/tee/Edit/Write onto docs/provenance/ledger.jsonl (hash-chained, sealed)ao provenance add
core.skills:copy-into-installedcp/rsync/mv INTO `~/.claude.codex
core.skills:edit-installed-copyEdit/Write of an installed skill copy (file_path only — prose can never fire it)edit repo skills/<name>/
core.verdicts:hand-editEdit/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 identityre-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 pathDelivery
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 copyThe skill package carries its own installer: ~/.claude/skills/cc-hooks/scripts/install-hooks.sh (one command; file copies cannot self-wire)
git clone / brew checkoutscripts/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

VariableScopePurpose
CLAUDE_PROJECT_DIRAllProject root
CLAUDE_ENV_FILESessionStart/SetupPersist 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'tDo
Old object formatArray format with matcher
Unquoted $VAR"$VAR"
Exit 2 with JSONExit 2 uses stderr only
Skip stop_hook_active checkAlways check in Stop hooks

Debugging

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) 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

Signals

GitHub stars
434
Forks
40
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
cc-hooks
Source
github.com/boshu2/agentops