Claude Code hooks for security: PreToolUse examples, and what a hook cannot stop
Claude Code hooks are commands (or HTTP calls, MCP tools, prompts and agents) that Claude Code runs at fixed points in a session. For security, the one that matters is PreToolUse: it runs after Claude has decided to call a tool and before the tool runs, and it can block the call. Below: how hooks work as of October 2026, two Claude Code hooks examples, what the free InnerWarden hook writes, the Claude Code security risks a hook cannot stop, and what the Linux Execution Gate adds.
What are Claude Code hooks?
A hook is an entry in a Claude Code settings file that says "when this event fires, for tools matching this pattern, run this". Anthropic's hooks reference lists every event a hook can attach to. The ones that matter for security:
| Event | When it fires | Can it block? |
|---|---|---|
| PreToolUse | After Claude builds a tool call, before it runs | Yes |
| PermissionRequest | When a permission prompt would appear | Through its decision object, not exit 2 |
| PostToolUse | After the tool ran | No, the tool already ran |
| UserPromptSubmit | Before your prompt reaches the model | Yes |
| ConfigChange | When a settings file changes mid-session | Yes (except managed policy) |
| SessionStart, SessionEnd | Start and end of a session | No |
Hooks live in ~/.claude/settings.json (you), .claude/settings.json (the project, shared through git), .claude/settings.local.json (the project, just you), managed policy settings and enabled plugins, plus skill and subagent frontmatter, and every level's entries merge. An administrator can set allowManagedHooksOnly so that only managed hooks run, plus SDK hooks and plugins that managed settings force-enable.
The shape: event, then matcher groups, then handlers:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "...", "timeout": 30 }]
}
]
}
}A matcher is an exact tool name (Bash), a | list (Edit|Write), * for everything, or a JavaScript regular expression. MCP tools are named mcp__<server>__<tool>, so mcp__github__.* matches every tool from the github server. Besides command, a handler can be http, mcp_tool, prompt or (experimental) agent.
Two details carry security weight. In an interactive session, Claude Code holds back hooks until you accept the workspace trust dialog; headless -p and SDK runs show no dialog and treat the folder as trusted, so hooks committed in a repository's .claude/settings.json run in a folder you never reviewed. And edits to hooks are normally picked up automatically by a file watcher, so anything that can write those files can change what guards the session.
How a PreToolUse hook blocks a tool call
Claude Code sends the hook one JSON object on stdin: common fields such as session_id, cwd, permission_mode and hook_event_name, plus tool_name, tool_input and tool_use_id. For a Bash call, the command is in tool_input.command.
The hook answers with its exit code, or with JSON on stdout:
- Exit 0: no objection. If stdout is a JSON object, Claude Code reads it.
- Exit 2: block. Stderr becomes the reason Claude sees, and the docs are explicit that exit 2 blocks even if the hook also printed JSON saying allow.
- Any other exit code, including 1: a non-blocking error, and the tool call goes ahead (if stdout was valid JSON, that JSON decides on its own). From Claude Code v2.1.295, a command or HTTP handler can set
onFailure: "block"to fail closed instead. - JSON:
hookSpecificOutput.permissionDecisioncan beallow,deny,askordefer, with apermissionDecisionReasonand the requiredhookEventName. When several hooks answer,denybeatsdefer, which beatsask, which beatsallow. A hook'sallownever overrides your deny or ask rules.
The default timeout for a command hook is 600 seconds, and a timed-out hook does not block the call unless it is set to fail closed. In the docs' own words, "don't count on a stalled hook to act as a gate."
PreToolUse fires for Bash, Read, Edit, Write, Grep, Glob, WebFetch, WebSearch, subagent calls and MCP tools, among others.
Claude Code hooks examples: a minimal security hook
If you do not use InnerWarden, here is a hand-written PreToolUse hook for the Bash tool. It is a tripwire, not a wall: text matching is easy to get around (see how command blocklists get beaten), it will flag some harmless commands, and it needs jq.
Save as .claude/hooks/guard.sh in your project and make it executable:
#!/usr/bin/env bash
# Minimal PreToolUse guard for Claude Code's Bash tool.
# Teaching example: string matching is easy to evade. Use it as a tripwire.
set -u
block() {
echo "guard.sh blocked this command: $1" >&2
exit 2 # exit 2 = Claude Code refuses the tool call and shows this reason
}
# Fail closed: if we cannot read the call, do not let it run unscreened.
command -v jq >/dev/null 2>&1 || block "jq is not installed, so the command could not be screened"
cmd="$(jq -r '.tool_input.command // empty')" || block "could not parse the hook input"
case "$cmd" in
*"| sh"*|*"|sh"*|*"| bash"*|*"|bash"*) block "a download piped into a shell" ;;
*".ssh/"*|*".aws/credentials"*) block "a credential path" ;;
*".env"*) block "a .env file" ;;
*"rm -rf /"*|*"rm -rf ~"*) block "a recursive delete of / or your home" ;;
esac
exit 0Wire it in .claude/settings.json. The empty args list runs the script directly, without a shell, which is the form Anthropic recommends for a hook that uses a path placeholder such as ${CLAUDE_PROJECT_DIR}:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
"args": [],
"timeout": 10
}
]
}
]
}
}A second pattern hands the decision to a person: this hook asks before any git push, using JSON output instead of an exit code:
#!/usr/bin/env bash
cmd="$(jq -r '.tool_input.command // empty')"
if [[ "$cmd" == *"git push"* ]]; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "git push goes to a shared remote: confirm it"}}'
fi
exit 0Anthropic's warning applies to both: "Command hooks execute shell commands with your full user permissions." Review every hook before you add it, including the ones in a repository you just cloned.
The InnerWarden hook: what innerwarden install claude-code writes
Tier: InnerWarden Community (free, inner-warden, Apache-2.0). Linux and macOS, Windows experimental.
InnerWarden Community uses the same mechanism with a parser behind it: each command is parsed into a bash syntax tree, not matched as text, and screened by the same analyser as innerwarden check. Screening happens locally; the only network calls are the ones you opt into, such as an LLM second opinion on ambiguous commands or an alert channel.
# Community (free): install, then wire Claude Code in monitor (records, never blocks)
curl -fsSL https://www.innerwarden.com/free | sh
innerwarden install claude-code --monitorIt merges three entries into ~/.claude/settings.json and leaves every other key and hook in place (abridged; the path is wherever the binary lives):
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "\"/home/you/.local/bin/innerwarden\" hook --monitor --agent claude-code" }] },
{ "matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [{ "type": "command", "command": "\"/home/you/.local/bin/innerwarden\" hook --monitor --agent claude-code" }] }
],
"PostToolUse": [
{ "matcher": "*",
"hooks": [{ "type": "command", "command": "\"/home/you/.local/bin/innerwarden\" hook --monitor --agent claude-code" }] }
]
}
}What each entry does, read from the Community 1.5.5 source:
PreToolUseonBashscreens every shell command. In enforce it exits 2 on a deny, with the reason on stderr and a line telling the agent that only the person supervising it can authorise the command, from their own terminal.PreToolUseon the file tools judges where a write lands, never what it says, and only against a short list of privileged places: Claude Code's own settings, the guard's configuration and program, shell startup files, SSH keys, sudo rules, cron, services and stored credentials. An ordinary edit to your project passes at once and records nothing. Because hook settings reload when they change, this is what stops the agent from editing away the hook that screens it.PostToolUseon*cannot refuse anything. It records what tool results contained, so a value lifted from a poisoned file or web page and reused in a command within 30 minutes is held for review with its source named.- It fails closed. In enforce, a tool call the hook cannot parse, or an internal error while screening, exits 2 rather than letting the call through unscreened.
Restart Claude Code as the installer asks, then check what is actually judged:
# Community (free)
innerwarden statusOn a wired machine it prints: "claude-code: judged before they run: shell commands, and file changes (Write, Edit, MultiEdit, NotebookEdit) to privileged files such as its settings, the guard's configuration and your shell startup files. Its other tools are recorded after they run."
When the record looks right, run innerwarden enforce from your own terminal to switch to blocking. Since Community 1.5.4 the guard refuses an agent that tries to run innerwarden allow, mute or dry-run on itself, and 1.5.5 adds monitor. That raises the cost of an agent widening its own guard; it does not close every route a process running as your user has. Setup for other agents is in connect your agent.
Claude Code security risks a hook cannot stop
A PreToolUse hook, ours included, judges the tool call it is shown. Everything below happens somewhere else.
- What the command starts. The hook sees
npm test,makeorpython build.py. It does not see what the test script, the Makefile or the Python file then does, and a process started withnohupor&outlives the call that was judged. - Files that enter without a tool call. By default, files you
@-mention are inserted into the context directly, and noPreToolUsehook fires for them. - Reads the rules do not name. Per Anthropic's permissions page, Read and Edit deny rules cover the file tools and commands such as
cat, but notgrep -rrun from the directory that holds the file, nor "a Python or Node script that opens files itself". InnerWarden's file-tool hook judges writes to privileged files; reads throughReadare recorded, not judged. - The same program in another form. Anthropic says a Bash rule "isn't a security boundary around the program":
Bash(curl *)does not stop/usr/bin/curlorsh -c 'curl ...'. Text-matching hooks share that weakness. A parser narrows the gap and does not close it. - Processes outside the tool loop. Per the sandboxing docs, Claude Code's sandbox covers shell commands only: file tools, MCP servers and hooks run outside it, with your full access. A hook sees an MCP tool call, not what the server process does afterwards.
- A hook that is no longer in the path. Hook settings reload on change, and in
bypassPermissionsmode writes to protected paths, which include the.claudedirectory and.mcp.json, are allowed. Anything running as your user, outside Claude Code, can edit them too. - Network egress. A command the hook lets through can still connect anywhere.
None of this makes hooks pointless; it makes them one layer. Inside Claude Code, pair them with its deny rules and its sandbox (/sandbox, off by default). Read the permission modes page before choosing a mode, and our post on Claude Code in auto mode for what changes when the agent stops asking.
What the Linux Execution Gate adds
Tier: Active Defence stack (Pro & Enterprise), paid, source-available. Linux only, on kernels with BPF LSM active.
Most of the gaps above share a shape: something executed that the hook never saw. The Execution Gate works below the agent, an eBPF LSM program at bprm_check_security, the kernel's check before any program executes, scoped to the agent's cgroup. It does not care how the command was phrased, whether the hook ran, or which script three processes down started the program: in enforce, a binary not on the agent's allowlist does not run.
# Active Defence (Pro & Enterprise), Linux, as root: observe, rehearse, then enforce
sudo innerwarden-ctl exec-gate arm --pid <claude-pid> --observe
sudo innerwarden-ctl exec-gate rehearse --pid <claude-pid> --window 604800
sudo innerwarden-ctl exec-gate enforce --pid <claude-pid> --window 604800enforce refuses to flip until a rehearsal over the window is clean, so the first day of enforcement does not break the agent's own tooling. The gate decides which programs may start, not what an allowed program does once it runs: an allowed python3 can still read what your user can read, which is why Active Defence pairs it with the Secret Read Guard for declared sensitive paths.
Since v0.16.61, every final Active Defence release has been attested on real Linux hosts (cloud VMs with bpf in the active LSM stack, not CI runners) with the kernel refusing an unauthorised exec on both x86_64 and aarch64: 17 releases through v0.16.78. How the layers fit together is on runtime guardrails, per-agent setup on integrations, and what Pro includes on pricing. On macOS and Windows, InnerWarden offers the Community hook and monitoring, not kernel enforcement.
FAQ
What is a PreToolUse hook in Claude Code?
A hook that runs after Claude Code has prepared a tool call and before the tool executes. It receives the call as JSON on stdin and can allow it, ask the user, or block it with exit code 2 or permissionDecision: "deny".
Can a Claude Code hook block a command?
Yes. Exit code 2 from a PreToolUse hook blocks the call and sends stderr to Claude as the reason. Exit code 1 does not block, and neither does a hook that times out, unless the handler is configured to fail closed.
Do Claude Code hooks run for MCP tools?
Yes. MCP tools appear as mcp__<server>__<tool>, so a matcher such as mcp__github__.* covers every tool from one server. The hook sees the call, not what the MCP server process does with it.
Are Claude Code hooks a security boundary?
Not on their own. A hook judges the calls it is shown, runs as your user, and can be edited by anything that can write your settings. Treat it as one layer next to deny rules, the sandbox and, on Linux, a kernel control.