{"slug": "claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard", "title": "Claude Code hooks explained: config structure, matchers, and a copy-paste PreToolUse guard", "summary": "Anthropic's Claude Code supports hooks—deterministic shell commands or HTTP endpoints that fire at fixed points in a session to block, allow, or reshape actions. A developer explains the configuration structure, the two modes for reporting decisions, and provides a copy-paste PreToolUse guard that blocks recursive rm commands. The hook JSON format for PreToolUse now requires a hookSpecificOutput object with a permissionDecision field, replacing the deprecated top-level decision field.", "body_md": "If you have only ever used Claude Code's permission prompts, hooks are the next layer down: deterministic shell commands (or HTTP endpoints, MCP tools, prompts, or subagents) that fire at fixed points in a session and can **block, allow, or reshape** what happens next. The feature is powerful, but the configuration has a few sharp edges that trip people up — especially the JSON your hook prints back, which changed and is easy to get wrong from memory.\n\nThis post walks through the current config structure, the two ways a hook reports a decision, and a copy-paste `PreToolUse`\n\nguard that blocks recursive `rm`\n\n.\n\nHooks live in a settings file (`.claude/settings.json`\n\nfor a project, `~/.claude/settings.json`\n\nfor your user). The shape is three levels of nesting, and naming them makes the rest of the docs click:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh\",\n            \"if\": \"Bash(rm *)\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n`PreToolUse`\n\n) — the lifecycle point. Events fall into three cadences: once per session (`SessionStart`\n\n, `SessionEnd`\n\n), once per turn (`UserPromptSubmit`\n\n, `Stop`\n\n), and on every tool call (`PreToolUse`\n\n, `PostToolUse`\n\n).`\"matcher\": \"Bash\"`\n\n) — a filter for `tool_name`\n\n.`hooks`\n\narray) — the thing that actually runs. There are five types: `command`\n\n, `http`\n\n, `mcp_tool`\n\n, `prompt`\n\n, and `agent`\n\n.The `if`\n\nfield narrows further using permission-rule syntax — `Bash(rm *)`\n\nhere means \"only spawn this handler when the Bash subcommand matches `rm *`\n\n,\" so the script never even launches for `npm test`\n\n. It holds exactly one rule; there is no `&&`\n\nor `||`\n\n, so multiple conditions mean multiple handlers.\n\nA matcher that contains regex characters is tested with JavaScript's `RegExp.prototype.test`\n\n, which succeeds on a match **anywhere** in the value. So:\n\n`Edit`\n\nalso matches `NotebookEdit`\n\n. If you mean only the `Edit`\n\ntool, write `^Edit$`\n\n.`mcp__<server>__<tool>`\n\n. A bare `mcp__memory`\n\nis treated as an exact string and matches `.*`\n\nsuffix: `mcp__memory__.*`\n\nfor every tool from the `memory`\n\nserver.If you want a handler to run on every occurrence of an event, omit the matcher or use `\"*\"`\n\n.\n\nThis is where most confusion lives. A command hook communicates results through **exit codes and stdout**, and you choose *one* of two modes:\n\n**Exit codes alone.** Exit `0`\n\nmeans success and no decision (staying silent does **not** approve a call — it just lets the normal permission flow continue). Exit `2`\n\nis a blocking error: Claude Code ignores stdout and feeds your **stderr** back to Claude as the reason. For `PreToolUse`\n\n, exit 2 blocks the tool call.\n\n**Exit 0 plus JSON on stdout.** For finer control, exit `0`\n\nand print a JSON object. Claude Code only parses JSON on exit 0 — if you exit 2, any JSON is ignored. You must choose one approach per hook, not both.\n\n`decision: \"block\"`\n\nIf you learned hooks a while ago, you probably remember a top-level `{\"decision\": \"block\"}`\n\n. **That is deprecated for PreToolUse.** This event now returns its decision inside a\n\n`hookSpecificOutput`\n\nobject with a required `hookEventName`\n\n, and the field is `permissionDecision`\n\nwith four possible values: `allow`\n\n, `deny`\n\n, `ask`\n\n, or `defer`\n\n. (The old `\"approve\"`\n\n/`\"block\"`\n\nmap to `\"allow\"`\n\n/`\"deny\"`\n\n.) Other events like `PostToolUse`\n\nand `Stop`\n\nstill use the top-level `decision`\n\n/`reason`\n\nfields — so the format genuinely differs per event, and copying a `PostToolUse`\n\nsnippet into `PreToolUse`\n\nwon't work.Here is the correct current shape for a `PreToolUse`\n\ndeny:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\",\n    \"permissionDecisionReason\": \"Recursive rm is blocked. Delete specific files instead.\"\n  }\n}\n```\n\n`rm`\n\nSave this as `.claude/hooks/block-rm.sh`\n\nand `chmod +x`\n\nit. It reads the event JSON on stdin, pulls the Bash command with `jq`\n\n, and denies anything with a recursive `rm`\n\n:\n\n``` bash\n#!/usr/bin/env bash\n# PreToolUse hook: deny recursive rm, stay silent otherwise.\n# Requires jq on PATH.\ninput=$(cat)\ncommand=$(printf '%s' \"$input\" | jq -r '.tool_input.command // \"\"')\n\nif printf '%s' \"$command\" | grep -qiE '(^|[[:space:]])rm[[:space:]]+(-[a-z]*r|--recursive)'; then\n  cat <<'JSON'\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\",\n    \"permissionDecisionReason\": \"Recursive rm is blocked by a project hook. Delete specific files instead.\"\n  }\n}\nJSON\n  exit 0\nfi\n\n# No decision: exit 0 with no output lets the normal permission flow decide.\nexit 0\n```\n\nWith the settings above, the `if: \"Bash(rm *)\"`\n\nfilter means the script only spawns for `rm`\n\ncommands (saving the process launch on unrelated calls), and the matcher keeps it scoped to Bash. When Claude tries `rm -rf /tmp/build`\n\n, the hook prints the deny JSON, Claude Code blocks the call, and Claude sees the reason.\n\nA note on paths: `${CLAUDE_PROJECT_DIR}`\n\nis substituted by Claude Code to your project root, so the hook resolves no matter what the working directory is at call time. If your project path contains spaces, switch the handler to exec form (set `args`\n\n) so each element is passed as one argument with no shell quoting.\n\nHooks are the deterministic guardrail layer. Two neighbors worth knowing:\n\n`if`\n\nfilter is best-effort.`if`\n\nfilter fail open and run your hook anyway.Type `/hooks`\n\nin Claude Code to open a read-only browser of everything you have configured, including which settings file each hook came from — a fast way to confirm your matcher is actually matching.\n\n*I'm Rulestack — I build and maintain drop-in rule and hook packs for Claude Code, Cursor, and Codex. There's a Claude Code Hooks Pack of production-ready guardrails if you'd rather not hand-roll each one. I post working AI-coding tips on Bluesky at @ai-shop.bsky.social — follow along if this was useful.*", "url": "https://wpnews.pro/news/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard", "canonical_source": "https://dev.to/rulestack/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-pretooluse-guard-58jj", "published_at": "2026-07-24 16:53:57+00:00", "updated_at": "2026-07-24 17:34:00.930367+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-tools", "ai-agents"], "entities": ["Anthropic", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard", "markdown": "https://wpnews.pro/news/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard.md", "text": "https://wpnews.pro/news/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard.txt", "jsonld": "https://wpnews.pro/news/claude-code-hooks-explained-config-structure-matchers-and-a-copy-paste-guard.jsonld"}}