# Hooks in Claude Code

Format edited files, block recursive rm and .env writes, and log every tool call in ticket-service with three hook scripts registered in .claude/settings.json.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/hooks/claude-code

## At a glance

| | Claude Code |
|---|---|
| Term | Hooks |
| Configured in | `hooks` key in `.claude/settings.json` (shared), `.claude/settings.local.json`, `~/.claude/settings.json`; also managed settings, plugins, skill and subagent frontmatter |
| Loads / runs | At a lifecycle event such as `PreToolUse`; a matcher narrows which tool calls trigger it |
| Scope & precedence | Hooks from all levels merge and run; matching hooks run in parallel, and for `PreToolUse` the strictest decision wins |

## Build the scenario

| Step | What you write or run | Where |
|---|---|---|
| 1 | Write `format.sh`, `guard.sh`, `log.sh` | `.claude/hooks/` |
| 2 | `chmod +x .claude/hooks/*.sh` | Terminal |
| 3 | Register the three scripts | `.claude/settings.json` |
| 4 | Ask Claude to edit `.env`, then check `/hooks` and the log | Claude Code session |

```json
{ "hooks": {
  "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [
    { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh", "args": [] }] }],
  "PreToolUse": [
    { "matcher": "Bash|Edit|Write", "hooks": [
      { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh", "args": [] }] },
    { "hooks": [
      { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log.sh", "args": [] }] }]
} }
```

Setting `args` (even empty) runs the script directly with no shell, so the path needs no quoting. Both scripts use `jq`; install it first.

**`format.sh`** runs after an edit. The edited path arrives in `tool_input.file_path`:

```bash
#!/bin/bash
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in *.ts|*.tsx|*.js) ;; *) exit 0 ;; esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
pnpm prettier --write "$f" >&2 || exit 2
pnpm eslint --fix "$f" >&2 || exit 2
```

The edit has already happened, so exit 2 cannot undo it. Claude sees the stderr, so ESLint errors that `--fix` can't repair reach the model.

**`guard.sh`** runs before the call. Exit 2 blocks it and stderr becomes the reason Claude sees:

```bash
#!/bin/bash
in=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$in")
path=$(jq -r '.tool_input.file_path // empty' <<<"$in")
rx='(^|[;&|[:space:]])rm[[:space:]]+(-[a-zA-Z]*[rR]|--recursive)'
if [[ "$cmd" =~ $rx ]]; then
  echo "Blocked: recursive rm is not allowed. Remove named files." >&2; exit 2
fi
if [[ "${path##*/}" == .env* || "$cmd" =~ \.env ]]; then
  echo "Blocked: .env* files are protected. Ask the user." >&2; exit 2
fi
```

**`log.sh`** has no `matcher`, so it runs before every tool. It prints nothing and exits 0, which means "no decision":

```bash
#!/bin/bash
d="$CLAUDE_PROJECT_DIR/.agent/logs"; mkdir -p "$d"
jq -c '{ts: (now|todate), session_id, tool_name, tool_input}' >> "$d/tool-calls.jsonl"
```

## Specifics

### Events

| Event | Fires | Can block? |
|---|---|---|
| `SessionStart` | A session begins or resumes | No (adds context) |
| `UserPromptSubmit` | You submit a prompt, before Claude processes it | Yes |
| `PreToolUse` | Before a tool call runs | Yes |
| `PostToolUse` | After a tool call succeeds | No (stderr reaches Claude) |
| `Stop` | Claude finishes responding | Yes (forces it to continue) |
| `SubagentStop` | A subagent finishes | Yes |
| `PreCompact` | Before context compaction | Yes |
| `Notification` | Claude Code sends a notification | No |

The reference lists more than 30 events, among them `PostToolUseFailure`, `PermissionRequest`, `FileChanged`, `SessionEnd` and `PostCompact`.

### Configuration and matchers

Three nesting levels: event, then matcher group, then one or more handlers. Handler types:

| `type` | Runs | Notes |
|---|---|---|
| `command` | A shell command or, with `args`, a direct executable | Input on stdin; default timeout 600 s |
| `http` | POST of the event JSON to a URL | Needs `allowedEnvVars` for header variables |
| `mcp_tool` | A tool on a configured MCP server | Skipped for `SessionStart` at launch |
| `prompt` | Single-turn model evaluation | Returns a JSON decision; default timeout 30 s |
| `agent` | A subagent that can read files | Experimental; default timeout 60 s |

A matcher made only of letters, digits, `_`, `-`, spaces, `,` and `|` is an exact list of names (`Edit|Write`); anything else is an unanchored JavaScript regex. Omit it, or use `*`, to match every occurrence. MCP tools are named `mcp__<server>__<tool>`. On tool events, an `if` field such as `"Bash(rm *)"` filters further by arguments. Events without matcher support, such as `UserPromptSubmit` and `Stop`, ignore a matcher.

### Input and output

| Direction | Content |
|---|---|
| In (stdin) | JSON with `session_id`, `transcript_path`, `cwd`, `permission_mode`, `hook_event_name`; tool events add `tool_name`, `tool_input`, `tool_use_id`; `PostToolUse` adds `tool_response` |
| Out, plain | Exit code plus stderr |
| Out, JSON | Exit 0 and a JSON object on stdout, with universal fields such as `continue` and `systemMessage` and event fields such as `hookSpecificOutput` |

Choose one style per hook. Stdout must hold only the JSON object, and `additionalContext` and `systemMessage` are capped at 10,000 characters.

### Blocking and decisions

| Outcome | How | Effect |
|---|---|---|
| Success, no decision | Exit 0, no JSON | Normal permission flow continues; silence does not approve |
| Block | Exit 2 (stderr is the reason) | Blocks on blocking events; wins even over an allow rule |
| Non-blocking error | Any other exit code, including 1, or a missing script | Action proceeds; notice in the transcript |
| Deny with reason | `PreToolUse` JSON: `permissionDecision` `allow`, `deny`, `ask` or `defer`, plus `permissionDecisionReason` | `deny` reason goes to Claude |

Several `PreToolUse` hooks resolve as `deny` > `defer` > `ask` > `allow`. Deny and ask permission rules are still evaluated whatever the hook returns. A timed-out command hook renders no decision and does not block the call.

### Scope, trust and limits

| Topic | Behavior |
|---|---|
| Locations | User, project and local settings files, managed policy settings, plugin `hooks/hooks.json`, skill frontmatter (rest of the session once invoked), subagent frontmatter (while it runs) |
| Merging | Levels add to each other; an identical handler runs once |
| Trust | Interactive sessions hold back hooks from every settings file until you accept the workspace trust dialog |
| Switch off | `"disableAllHooks": true`; it cannot disable managed hooks from outside managed settings |
| Inspect | `/hooks` is a read-only browser showing each hook's event, matcher, type and source file |

## Gotchas

<warning>

Only exit 2 blocks. A guard that exits 1 lets the command run, and a mistyped or non-executable script path also fails open with only a non-blocking notice. Watch for it on the first run.

</warning>

- In `claude -p` and SDK sessions there is no trust dialog, so hooks committed in a repository's `.claude/settings.json` run without consent. Review them first, or start with `--bare`, or pass `--settings '{"disableAllHooks": true}'`.
- `PostToolUse` on `Edit|Write` does not fire when a `Bash` command or another process rewrites a file; `FileChanged` covers that. `PreToolUse` also does not fire for files you reference with `@` in a prompt.
- The `.env` check on `Bash` commands is a text match, so it also blocks `cat .env` and misses indirect writes. It is a guardrail; use [permission rules or a sandbox](https://ai-sw-factory.mellicci.dev/fundamentals/security-model) for a hard boundary.
- `log.sh` and `guard.sh` run in parallel, so the log also records calls the guard denies. It stores full `tool_input`, including `Write` contents: keep `.agent/logs/` out of version control.
- Hooks run with your full user permissions. Review them like any script.
