Agent hooks: preprocess prompts, allow and deny, audit
Instructions are advice. Hooks are enforcement. A working reference for the extension points in Claude Code and Codex, and what to build with them.
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.
The shape of the loop#
Every harness runs some version of the same cycle. The hook points are the seams between the stages.
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 upThe 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).
{
"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.
#!/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")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:
{
"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.
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-onlycannot write anything;workspace-writeconfines 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.
#!/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"'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.
#!/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 0Three distinct things are happening, and they are worth separating in your head:
- 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.
- Standing context. Branch, dirty state, last commit, test status — injected every turn, costing you nothing to type. The model stops guessing and stops asking.
- 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.
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.
#!/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 0Note 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.
- 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.
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.
#!/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 0Exit 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:
#!/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 0That 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:
ls ~/.claude/projects/ # one dir per project, path-slugified
ls ~/.claude/projects/-Users-you-myrepo/ # <session-uuid>.jsonlEach 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).
# 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:
ls ~/.codex/sessions/2026/09/04/ # rollout-<timestamp>-<uuid>.jsonlIts 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.
jq -r 'select(.type=="response_item") | .payload.type' \
~/.codex/sessions/2026/09/04/rollout-*.jsonl | sort | uniq -cFor a live audit log rather than forensics, the cheapest useful thing is four lines:
#!/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 0Register 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:
PostToolUseonEdit|Writerunning your formatter and linter, exiting 2 on failure. Biggest quality improvement per line of config, in any language.PreToolUsedenying secret files, with anaskon dependency installs. Two cases, most of the risk.UserPromptSubmitinjecting 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, 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.
Get the updates
Agent tooling moves monthly. We send a short note when something on these sites changes materially — a new harness feature, a failure mode worth knowing, a config that stopped being right. Nothing else.
Unsubscribe in one click. We never sell the list.