# Agent hooks: preprocess prompts, allow and deny, audit

> Source: https://codelearningdojo.com/harness-hooks/
> Part of Code Learning Dojo, free to read.

There is a category difference between two ways of controlling a coding agent, and most people only use the weaker one.

**Instructions** — `AGENTS.md`, system prompts, "please always run the tests" — are advice. They compete for attention with everything else in the context window, they degrade as the session gets longer, and a model under pressure to finish will quietly deprioritise them. They are not enforcement, and treating them as enforcement is how people end up surprised.

**Hooks** are code that runs at fixed points in the agent's loop, outside the model's control. A hook does not ask the model to do something; it happens. It can refuse a command, inject facts into the prompt before the model reads it, run your linter after every edit, and write an audit line for every action taken.

If you take one idea from this page: **anything you have told an agent twice is a candidate for a hook.**

:::note Part of a set
<span class="guardrails-family"></span>Hooks are the *enforcement* layer of agent guardrails. Its companions: [customizing your agent](/customizing-harnesses/) for the full menu of ways to shape a harness, [sandboxing](/sandboxing/) for containment when policy is not enough, and [prompt injection](/prompt-injection/) for the threat all three exist to bound.
:::

:::note What this page covers, and how current it is
The lifecycle model here is stable and transfers between harnesses. The specific config keys move — this was written against Claude Code 2.1.x and the Codex CLI as of September 2026, and both were verified on a real installation while writing. Check your harness's own docs before copying a key verbatim, and treat any exact field name here as "roughly this, confirm the spelling."
:::

## The shape of the loop

Every harness runs some version of the same cycle. The hook points are the seams between the stages.

```text
session starts                    <- inject standing context
    |
user submits a prompt             <- inspect, enrich, or refuse the prompt
    |
model thinks, proposes a tool call
    |
BEFORE the tool runs              <- allow / deny / ask. the security boundary.
    |
tool runs
    |
AFTER the tool returns            <- lint, format, test, log
    |
model responds
    |
turn ends                         <- verify work is finished; force continuation
    |
session ends                      <- flush logs, summarise, clean up
```

The two seams that matter most are the ones in capitals. **Before the tool runs** is where safety lives, because it is the last point at which nothing has happened yet. **After the tool returns** is where quality lives, because it is the only place you can guarantee the model sees real feedback rather than feedback it chose to request.

## Claude Code

The richest hook system of the mainstream harnesses. Configure in `.claude/settings.json` (project, committed), `.claude/settings.local.json` (project, personal, gitignored) or `~/.claude/settings.json` (all your projects).

```json .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh", "timeout": 10 }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/lint.sh" }]
      }
    ],
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/context.sh" }] }
    ]
  }
}
```

`matcher` is a regex against the tool name, so `Edit|Write` catches both and `mcp__.*` catches every MCP tool. Omit it (as in `UserPromptSubmit`) for events that have no tool.

### The events

| Event | Fires | What it is for |
|---|---|---|
| `SessionStart` | session opens or resumes | inject standing context — branch, open tickets, deploy state |
| `UserPromptSubmit` | you press enter, before the model sees it | enrich or refuse the prompt |
| `PreToolUse` | after the model proposes a tool call, before it runs | **allow / deny / ask** |
| `PostToolUse` | after a tool succeeds | lint, format, test, log |
| `Stop` | the main agent finishes its turn | block to force it to keep going |
| `SubagentStop` | a subagent finishes | same, for delegated work |
| `Notification` | the harness notifies you | route to Slack, desktop, phone |
| `PreCompact` | before the context is compacted | save what you do not want summarised away |
| `SessionEnd` | session closes | flush logs, write a summary |

### The contract

A hook is a program. It receives a JSON object on **stdin** and communicates back through its **exit code** and **stdout**.

Common input fields: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, plus `tool_name` and `tool_input` on tool events and `tool_response` on `PostToolUse`.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)                                        # the whole JSON object
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
```

:::warn Read the file path from stdin, not from an environment variable
Older examples (including an earlier version of these pages) used `$CLAUDE_FILE_PATHS`. Parsing `tool_input` from stdin is the version that is stable across releases and works identically for every event. If you have hooks using the env var, they are worth converting.
:::

Exit codes carry the decision:

| Exit | Meaning |
|---|---|
| `0` | proceed. stdout is shown in the transcript — **except** on `UserPromptSubmit` and `SessionStart`, where stdout is **added to the model's context** |
| `2` | **block.** stderr is fed back to the model as the reason |
| other | non-blocking error. stderr goes to you, the run continues |

That exit-2 behaviour is the important one: it does not just stop the action, it *tells the model why*, so the next attempt is corrected rather than repeated.

For finer control, print a JSON object on stdout instead:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Migrations are run by a human. Write it, don't apply it."
  }
}
```

`permissionDecision` takes `allow` (skip the permission prompt entirely), `deny`, or `ask` (force a prompt even if the allowlist would have permitted it). That third value is the useful one people miss — it lets you keep a broad allowlist for speed while still stopping on the specific shapes you care about.

## Codex

Codex's extension surface is narrower and differently shaped, and it is worth being straight about that rather than pretending the two are equivalent. Configuration lives in `~/.codex/config.toml`.

```toml ~/.codex/config.toml
model = "gpt-5-codex"
approval_policy = "on-request"      # untrusted | on-failure | on-request | never
sandbox_mode = "workspace-write"    # read-only | workspace-write | danger-full-access
notify = ["/Users/you/.codex/notify.sh"]

[sandbox_workspace_write]
network_access = false

[mcp_servers.docs]
command = "uvx"
args = ["some-docs-mcp-server@latest"]
```

The controls you actually get:

- **`sandbox_mode`** — the real security boundary. `read-only` cannot write anything; `workspace-write` confines writes to the working directory and, by default, denies network access from inside the sandbox. This is enforced by the OS sandbox, not by the model's cooperation, which makes it stronger than any instruction.
- **`approval_policy`** — when you are asked before something runs.
- **`notify`** — a program Codex invokes with a JSON argument on notable events. This is the closest thing to an event hook, and it is observation rather than control: you can log and alert, you cannot refuse.
- **`AGENTS.md`** — Codex reads it, same convention as everything else.
- **MCP servers** — same mechanism as elsewhere.

```bash ~/.codex/notify.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Codex passes a JSON payload as $1
printf '%s\n' "$1" >> "$HOME/.codex/notify.log"
type=$(jq -r '.type // "unknown"' <<<"$1")
[[ "$type" == "agent-turn-complete" ]] && osascript -e 'display notification "Codex finished" with title "codex"'
```

:::verdict The honest comparison
Claude Code gives you a programmable **control plane**: you can intercept, rewrite and refuse. Codex gives you a stronger default **containment model** — an OS-level sandbox with network off by default — and much less interception.

Neither is strictly better. If you want policy that reacts to *what is being done*, you want hooks. If you want a hard boundary that does not depend on you having anticipated the case, you want a sandbox. The best setup uses both ideas: run inside a sandbox, and hook the seams.
:::

## Pattern 1 — preprocess the prompt

`UserPromptSubmit` runs between you pressing enter and the model reading anything. Its stdout is prepended to your prompt as context. This is the highest-leverage and least-used hook.

The insight: you type the same twenty words of context over and over — which branch, which ticket, what is currently broken. Stop typing them.

```bash .claude/hooks/context.sh
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)
prompt=$(jq -r '.prompt // empty' <<<"$input")

# --- 1. refuse prompts that should never be sent ---------------------
if grep -qiE '(sk-[a-zA-Z0-9]{20,}|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16})' <<<"$prompt"; then
  echo "That prompt looks like it contains a credential. Not sending it." >&2
  exit 2
fi

# --- 2. always-on facts the model would otherwise have to ask for ----
cat <<CTX
<workspace-state>
branch:     $(git branch --show-current 2>/dev/null || echo n/a)
uncommitted:$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') files
last commit:$(git log -1 --format='%h %s' 2>/dev/null || echo n/a)
tests:      $(test -f .test-status && cat .test-status || echo unknown)
</workspace-state>
CTX

# --- 3. expand project shorthand -------------------------------------
if grep -qiE '\bthe failing test\b' <<<"$prompt"; then
  echo "<failing-tests>"
  uv run pytest -q --tb=no 2>&1 | tail -20 || true
  echo "</failing-tests>"
fi
exit 0
```

Three distinct things are happening, and they are worth separating in your head:

1. **A refusal.** Exit 2 stops the prompt reaching the model at all. Credential scanning is the obvious use; so is a policy that certain files are never discussed.
2. **Standing context.** Branch, dirty state, last commit, test status — injected every turn, costing you nothing to type. The model stops guessing and stops asking.
3. **Shorthand expansion.** You write "fix the failing test"; the hook runs the suite and pastes the actual output. You have turned three turns into one.

That third pattern is where the time goes. Every round trip you remove is real.

:::warn Keep it small and keep it fast
This runs on every prompt. Output that never gets used is context you are paying for on every turn, and a slow hook is latency you feel constantly. Keep it under a second, keep it under twenty lines of output, and make the expensive parts conditional on the prompt actually mentioning them — as in the example above.
:::

## Pattern 2 — allow and deny

`PreToolUse` is the last moment before something happens. Everything security-relevant belongs here.

The design principle: **deny by shape, not by string.** Blocking the literal text `rm -rf /` is theatre — the dangerous command is `rm -rf $BUILD` where `BUILD` is empty. Reason about the class of action.

```bash .claude/hooks/guard.sh
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)
tool=$(jq -r '.tool_name // empty' <<<"$input")

deny() {
  jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",
    permissionDecision:"deny", permissionDecisionReason:$r}}'
  exit 0
}
ask() {
  jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",
    permissionDecision:"ask", permissionDecisionReason:$r}}'
  exit 0
}

# ---- file reads: protect secrets regardless of which tool asked -------
if [[ "$tool" == "Read" || "$tool" == "Edit" || "$tool" == "Write" ]]; then
  f=$(jq -r '.tool_input.file_path // empty' <<<"$input")
  case "$f" in
    *.env|*.env.*|*.pem|*.key|*id_rsa*|*/secrets/*|*/.aws/*|*/.ssh/*)
      deny "Secret material. Ask the human for the value you need." ;;
  esac
fi

[[ "$tool" == "Bash" ]] || exit 0
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")

# ---- unrecoverable ---------------------------------------------------
case "$cmd" in
  *"git reset --hard"*|*"git clean -"*[fd]*)
      deny "Destroys uncommitted work. Use 'git stash' instead." ;;
  *"git push --force "*|*"git push -f "*)
      deny "Use --force-with-lease, and ask me first." ;;
  *"| sh"*|*"| bash"*|*"curl "*"|"*)
      deny "Piping a download into a shell. Fetch it, let me read it, then run it." ;;
esac

# ---- rm: reason about the argument, not the string -------------------
if grep -qE '(^|[;&|[:space:]])rm[[:space:]]' <<<"$cmd"; then
  # an unquoted or possibly-empty variable in an rm path is the real danger
  if grep -qE 'rm[^;&|]*\$[A-Za-z_{]' <<<"$cmd" && ! grep -qE 'rm[^;&|]*"\$\{[A-Za-z_]+:\?' <<<"$cmd"; then
    deny "rm with an unguarded variable path. Expand it, or use \${VAR:?} so an empty value fails."
  fi
  grep -qE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*(/|~|\\\$HOME)[[:space:]]*$' <<<"$cmd" \
    && deny "Recursive delete of a root path."
fi

# ---- outside the working tree ----------------------------------------
grep -qE '>[[:space:]]*/(etc|usr|bin|var|System)/' <<<"$cmd" \
  && deny "Writing outside the working directory."

# ---- things that should happen, but with a human present -------------
case "$cmd" in
  *"uv add"*|*"pip install"*|*"npm install"*|*"pnpm add"*|*"go get"*)
      ask "New dependency — check the package name before this runs." ;;
  *"alembic upgrade"*|*"prisma migrate deploy"*|*"migrate up"*)
      ask "Migration against a real database." ;;
  *terraform*apply*|*kubectl*delete*|*"aws "*)
      ask "Touches infrastructure." ;;
esac
exit 0
```

Note the three-way split, which is the part people get wrong by using only two:

- **deny** — for things with no legitimate use in this repo. Cheap, and the reason string teaches the model the alternative.
- **ask** — for things that are *normal* but consequential. Dependency installs are the canonical case: you want them to happen, you just want to read the package name first, because [models invent plausible package names and attackers register them](https://learn-python.com/review/dependencies/).
- **allow** (silence, exit 0) — everything else, so the session stays fast.

A guard that denies too much gets disabled. Aim for a short deny list and a slightly longer ask list.

:::danger The threat model has changed
The reason a `PreToolUse` guard matters more than it used to is not that the model is unreliable. It is that an agent's actions can be influenced by any text that reaches its context — a web page it fetched, an issue someone else wrote, an MCP tool result, a dependency's README.

That means the dangerous configuration is **untrusted input plus a powerful tool in the same session**. Neither alone is a problem. A `PreToolUse` guard is enforcement that sits outside the context window entirely, which is exactly why it holds when instructions do not.
:::

## Pattern 3 — close the quality loop

`PostToolUse` on `Edit|Write` is the highest-value quality hook in any language, because it makes feedback unconditional. The model does not get to decide whether to check its work.

```bash .claude/hooks/lint.sh
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
[[ -n "$file" && -f "$file" ]] || exit 0

out=""
case "$file" in
  *.py)   uv run ruff check --fix "$file" 2>&1 | tail -20 || true
          out=$(uv run mypy "$file" 2>&1 | tail -20) ;;
  *.go)   gofmt -w "$file"; out=$(go vet "./$(dirname "$file")" 2>&1 | tail -20) ;;
  *.ts|*.tsx) out=$(npx tsc --noEmit 2>&1 | grep -F "$file" | head -20) ;;
  *.sh)   shfmt -w -i 2 -ci "$file"; out=$(shellcheck -S warning "$file" 2>&1 | head -30) ;;
  *.c|*.h) out=$(clang-tidy "$file" -- -Wall -Wextra 2>&1 | head -20) ;;
esac

if [[ -n "$out" ]]; then
  echo "$out" >&2
  exit 2          # feed it back to the model as something to fix
fi
exit 0
```

Exit 2 here is deliberate: the errors go back to the model as a problem to solve, not to you as a notification. The model fixes its own lint failures before it ever tells you it is done.

The `Stop` hook is the complement — it fires when the model thinks it has finished, and blocking it forces another lap:

```bash .claude/hooks/verify.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Guard against an infinite loop: only intervene once per turn.
[[ "$(jq -r '.stop_hook_active // false')" == "true" ]] && exit 0

if ! make check >/tmp/check.log 2>&1; then
  { echo "make check is failing. You are not done:"; tail -30 /tmp/check.log; } >&2
  exit 2
fi
exit 0
```

That `stop_hook_active` guard matters. Without it a hook that always blocks produces an agent that can never finish.

## Pattern 4 — make the session auditable

Both harnesses already write a complete transcript to disk. Most people have never looked at theirs, and it is the best available answer to "what did it actually do?"

**Claude Code** writes one JSONL file per session, under a directory named after the working directory:

```bash
ls ~/.claude/projects/                       # one dir per project, path-slugified
ls ~/.claude/projects/-Users-you-myrepo/     # <session-uuid>.jsonl
```

Each line is an event. Records carry `type` (`user`, `assistant`, `system`, and housekeeping types), plus `sessionId`, `timestamp`, `uuid`, `parentUuid`, `cwd`, `version` and `gitBranch`. The `parentUuid` chain is what lets you reconstruct the tree, including subagent sidechains (`isSidechain`).

```bash
# every Bash command run in this project, newest last
find ~/.claude/projects/-Users-you-myrepo -name '*.jsonl' -exec cat {} + \
  | jq -r 'select(.type=="assistant")
           | .message.content[]?
           | select(.type=="tool_use" and .name=="Bash")
           | .input.command'
```

**Codex** writes rollout files, bucketed by date:

```bash
ls ~/.codex/sessions/2026/09/04/            # rollout-<timestamp>-<uuid>.jsonl
```

Its record types are `session_meta`, `turn_context`, `event_msg`, `response_item` and `world_state`. There is also a `~/.codex/session_index.jsonl` and SQLite state alongside it.

```bash
jq -r 'select(.type=="response_item") | .payload.type' \
  ~/.codex/sessions/2026/09/04/rollout-*.jsonl | sort | uniq -c
```

For a live audit log rather than forensics, the cheapest useful thing is four lines:

```bash .claude/hooks/audit.sh
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)
jq -c '{ts: now|todate, session: .session_id, tool: .tool_name,
        cmd: (.tool_input.command // .tool_input.file_path // null),
        cwd: .cwd}' <<<"$input" >> "${CLAUDE_PROJECT_DIR:-.}/.agent-audit.jsonl"
exit 0
```

Register it on `PreToolUse` with matcher `.*` and you have a per-repo, greppable record of every action any agent took, independent of the harness's own format. Gitignore it. Read it the first time something surprises you, and you will find the surprise was three turns earlier than you thought.

Claude Code also supports OpenTelemetry export (`CLAUDE_CODE_ENABLE_TELEMETRY=1` plus the standard `OTEL_*` variables) if you want this in the same place as the rest of your metrics — worth it for a team, overkill for one person.

## Where to start

If you install nothing else, install these three:

1. **`PostToolUse` on `Edit|Write`** running your formatter and linter, exiting 2 on failure. Biggest quality improvement per line of config, in any language.
2. **`PreToolUse` denying secret files**, with an `ask` on dependency installs. Two cases, most of the risk.
3. **`UserPromptSubmit` injecting branch and dirty state.** Five lines, and you stop typing the same context every turn.

Everything else on this page is refinement. Add a rule when you have corrected the same thing twice — the same discipline that keeps [an `AGENTS.md` short](https://learn-python.com/ai/agents-md/), applied to enforcement instead of advice.

## Common questions

### Do hooks work with Cursor, Cline or Aider?

The lifecycle model transfers; the configuration does not. Cursor and Cline have their own rules and permission systems, and Aider relies on git plus its own flags. Where a harness has no post-edit hook, `pre-commit` and a file watcher cover most of the same ground — less immediate, since the model does not see the output mid-turn, but far better than nothing.

### Should hooks live in the repo or in my user config?

Enforcement in the repo, preferences in your user config. Security guards and lint hooks are shared facts about the project and belong in `.claude/settings.json`, reviewed like any other change. Notification routing and personal shortcuts belong in `~/.claude/settings.json`.

### Can a hook be bypassed by the model?

Not by the model — hooks run in the harness, outside the context window, and their decision is not something the model can argue with. They can be bypassed by a *person* editing the settings file, which is the correct threat model: hooks are guardrails against mistakes and against injected instructions, not a sandbox against a determined human. For containment rather than policy, use an OS sandbox or a container.

### Is a hook that runs on every prompt going to slow me down?

Only if you let it. Budget under a second for `UserPromptSubmit` and keep expensive work conditional on the prompt mentioning it. Set an explicit `timeout` in the config so a hung hook degrades into a warning instead of a stall.

### What is the single most underused hook?

`UserPromptSubmit`. Almost everyone discovers `PreToolUse` for safety and `PostToolUse` for linting, and almost nobody uses the one that removes the context they retype twenty times a day.
