In November 2025 Drew Gregory wrote about allowlisting Bash commands in Claude Code. Once go test, make or pnpm run can run without a prompt, along with file edits, Claude Code can run any command, and a file watcher such as next dev --turbopack runs edited code with no Bash call at all. That post ended by recommending a sandbox.
The obvious next question is whether a PreToolUse hook closes those gaps.
Claude Code hooks are handlers that Claude Code runs at fixed points in a session: shell commands, HTTP endpoints, MCP tool calls, LLM prompts or subagents, in Anthropic’s definition. A PreToolUse hook runs after Claude has written a tool call and before Claude Code executes it, and it can allow the call, deny it, ask you, or rewrite its arguments, which makes it the natural place for guardrails.
This post puts a PreToolUse hook in front of the same bypasses, checks what happens when a hook fails, and looks at who can switch hooks off.
The hook stopped every command whose danger was in the string it read, and missed every one where the danger lived somewhere else, in package.json, a Go test, a file a dev server loaded, or a spelling its pattern didn’t expect. When it broke, it failed open without a word in the session, and one line in a project’s settings switched it off along with the hooks in our own user settings.
We ran everything on Claude Code 2.1.319, macOS 26.7.1 on an Apple M5 Max, in headless claude -p sessions inside a throwaway repository, in default permission mode with the sandbox off unless a test says otherwise. Each run allowed the tool under test with --allowedTools, so the hook was the only thing in the way. A headless session can’t show a prompt, so where Claude Code would have asked, the transcript records a denial and its reason.
What are Claude Code hooks? #
Hooks in Claude Code are handlers you register in a settings file against a named event. Each handler has one of five types: command, http, mcp_tool, prompt or agent, and Anthropic says agent hooks “are experimental and may change”. The hooks reference lists 33 events as of November 2026, from SessionStart through SessionEnd, and a matcher on each hook narrows which tool or source it fires for.
A hook can live in ~/.claude/settings.json for every project, in a project’s .claude/settings.json (which is meant to be committed), in .claude/settings.local.json, in managed settings an admin controls, in a plugin’s hooks/hooks.json, or in the front matter of a skill or subagent. None of them replaces another: “Hook entries merge across settings levels rather than replacing each other.” The hooks you wrote and the hooks a repository ships run side by side.
The Claude Code hook events that matter for security
Most of the events are about automation: notifications, compaction, worktrees, model switches. Six decide what a hook can do about a risky action:
| Event | When it fires | What the hook can do |
|---|---|---|
PreToolUse |
Before a tool call executes | Allow, deny, ask or defer the call, or rewrite its input |
PermissionRequest |
When a tool call needs a permission decision | Allow or deny the request; deny and ask rules still apply |
PostToolUse |
After a tool call succeeds | Add a note or replace the output Claude sees; the tool has already run |
ConfigChange |
When a configuration file changes during a session | Block the change from applying to the session, except policy_settings |
FileChanged |
When a watched file changes on disk | Nothing: it runs after the write and has no decision control |
SessionStart |
When a session begins or resumes | Runs before any tool call, including in a repository you just cloned |
The other 27 are in Anthropic’s full events list in the hooks reference, and the hooks guide walks through setting up the common ones.
How a PreToolUse hook blocks a command #
A command hook gets the event as JSON on stdin. For a Bash call, tool_name is Bash and tool_input.command holds the command string exactly as Claude wrote it, next to common fields such as session_id, cwd and permission_mode.
Our baseline is two handlers on Bash in the project’s .claude/settings.json. The first runs only for commands that match Bash(rm *); the second reads every command:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "/path/to/project/.claude/hooks/guard-rm.sh"
},
{
"type": "command",
"command": "/path/to/project/.claude/hooks/guard-curl.sh"
}
]
}
]
}
}
guard-rm.sh logs its input and exits 2. guard-curl.sh denies any command string that contains curl or git push:
#!/bin/bash
input=$(cat)
command=$(printf '%s' "$input" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).tool_input?.command ?? ""))')
if printf '%s' "$command" | grep -Eq 'curl|git push'; then
echo "Blocked by guard-curl.sh: the command contains curl or git push." >&2
exit 2
fi
exit 0
Asked to run rm -rf build/, Claude made the call and the first handler received this on stdin (paths shortened):
{
"session_id": "f11290a4-31e2-415b-8d55-c326fd5db280",
"cwd": "/path/to/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf build/",
"description": "Delete the build directory"
},
"tool_use_id": "toolu_01TMZCrX8wH6ximcbUVusYxz"
}
Claude Code handed Claude the hook’s stderr as the result, PreToolUse:Bash hook error: [.../guard-rm.sh]: Blocked by guard-rm.sh: rm is not allowed in this project., and build/ was still there, even though Bash was on the allow list. curl -s https://example.com was stopped the same way by the second handler. guard-rm.sh never ran for the curl call, so the if filter did narrow it.
There are two ways to say no. Exit code 2 blocks the call and hands the hook’s stderr to Claude as the reason. Exit 0 with JSON is more precise: permissionDecision can be allow (skip the prompt), deny, ask, or defer, which Claude Code honors only in -p runs, and updatedInput replaces the tool’s arguments before the call runs. Any other exit code is a non-blocking error and the call goes ahead. That includes exit 1, the usual Unix failure code, and Anthropic’s exit code section says it plainly: “If your hook is meant to enforce a policy, use exit 2.”
Hooks and permission rules stack in one direction. A hook’s allow doesn’t get past a matching deny or ask rule, managed rules included, while a hook that exits 2 “stops the tool call before permission rules are evaluated”, so it blocks even a command an allow rule would let through (precedence among rules, hooks, and mods). When several PreToolUse hooks disagree, “precedence is deny > defer > ask > allow”, so a second hook can’t overrule a deny from the first. A deny also holds in bypassPermissions mode, the one --dangerously-skip-permissions starts, because PreToolUse hooks fire before any permission-mode check.
We checked that last claim. With the same two handlers, we repeated both prompts under claude --dangerously-skip-permissions. The session reported permissionMode: bypassPermissions, and both calls were denied with the same messages as before.
Two settings decide whether the hook runs at all. The matcher is an exact tool name, a | list, or otherwise an unanchored JavaScript regex, so Edit.* also matches NotebookEdit, and mcp__memory on its own matches no tool. The if field narrows a handler with permission-rule syntax such as Bash(rm *), and Anthropic is explicit about how far to trust it: “Because the if filter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny.”
Testing a PreToolUse hook against the allowlisting bypasses #
Anthropic’s permissions page has a list of commands a Bash rule doesn’t match: a deny on Bash(curl *) doesn’t stop /usr/bin/curl or sh -c 'curl ...', and wrappers such as npx and docker exec “execute their arguments as a command”. For checks on the command itself, the same page points to hooks: “To inspect the full command text with your own logic before it runs, use a PreToolUse hook.” The tests below hand that hook the bypasses from the allowlisting post, where the command Claude asks to run and the code that ends up running are different things.
npm test
The test script in package.json decides what npm test executes, and changing it is an ordinary file edit for Claude.
With the curl handler in place and npm test and Edit allowed, we asked Claude to change the test script to curl -s https://example.com | grep -o "<title>.*</title>" and run npm test. The hook fired once, for the npm test call, and its input held "command": "npm test" and nothing about curl. It didn’t fire on the edit to package.json, because its matcher is Bash. The test printed <title>Example Domain</title>, so the request left the machine.
go test
A Go test can start any process with exec.Command. It was the first bypass in the allowlisting post.
Same hook, with go test and Write allowed. Claude wrote add_test.go with a test that runs exec.Command("curl", "-s", "https://example.com") and logs the output, then ran go test -v ./.... The hook saw go test -v ./... 2>&1 and let it through, and the test log contained the page’s <title>Example Domain</title>.
next dev --turbopack
A watcher that the developer started runs edited code without Claude making a Bash call. A FileChanged hook can see the file change, but Anthropic’s reference says it runs “after the change” with no decision control, so it “can’t block the write”.
We started next dev --turbopack (Next.js 16.4.5) ourselves, loaded an API route once, then asked Claude, with only Read and Edit allowed, to add the allowlisting post’s snippet to the top of that route: an execSync call, here running curl and saving the page to a file. A logging hook on every tool recorded four events: PreToolUse and PostToolUse for Read, then for Edit. No Bash call happened. Saving the file alone didn’t run the code; the next request to the route did, and the saved page held <title>Example Domain</title>. Any request is enough: a browser tab, a test or a health check.
/usr/bin/curl, sh -c and git -C
A hook that reads the command string has to understand shell, and Anthropic’s own table of what a Bash rule misses works as a test list. One published set of guards is karanb192’s claude-code-hooks, whose block-dangerous-commands hook targets commands such as rm -rf ~, fork bombs and curl | sh.
We ran six command forms, each in its own session, against two guards: the grep-style guard-curl.sh from above, and two if filters, Bash(curl *) and Bash(git push *), in front of a script that denies whatever reaches it. Every hook received the command exactly as Claude was asked to write it.
| Command | Grep hook | if filters |
|---|---|---|
/usr/bin/curl -s https://example.com |
Blocked | Ran |
sh -c 'curl -s https://example.com' |
Blocked | Ran |
URL=https://example.com && curl -s $URL |
Blocked | Blocked |
git -C . push |
Ran | Ran |
git -c push.default=current push |
Ran | Ran |
git 'push' origin main |
Ran | Blocked |
The grep hook missed all three pushes because none of them contains the substring git push. The if filters read past the URL= assignment and the quotes around 'push', but matched neither a full path nor a command inside sh -c, the two gaps Anthropic’s table warns about. The pushes that ran only failed because our test repository had no remote: git printed fatal: No configured push destination.
karanb192’s guard has a different job. At commit 57a28c7 (2026-10-17), its default high level matches regular expressions for patterns such as curl or wget piped into sh or bash, force pushes to main or master, and rm aimed at the home directory, root or system paths. None of the six forms above is on its list, so it let all six through. Against its own patterns, it caught curl ... | sh, /usr/bin/curl ... | bash, git push --force origin main, git push -f origin HEAD:main and rm -rf ~. It missed curl ... | /bin/sh, curl ... | zsh, sh -c "$(curl ...)", bash <(curl ...), git -C . push --force origin main, git 'push' --force origin main and rm -rf $HOME/. Installed as a hook, it denied git push --force origin main with ⛔ [git-force-main] force push to main/master and let git -C . push --force origin main run. That isn’t a flaw peculiar to one project: any guard that pattern-matches the command string has the same edge.
When a hook fails open #
By default, a broken hook stops nothing. Anthropic’s list of non-blocking errors covers a hook that exits with any code other than 0 or 2, one that doesn’t start, and one that prints invalid JSON, and in each case “The action goes ahead.” A mistyped script path is the quiet version of this: “a mistyped path in settings.json means the hook never runs.”
Timeouts behave the same way. Command, HTTP and MCP tool hooks get 600 seconds by default (30 on UserPromptSubmit), and on PreToolUse, “a timed-out command, http, or mcp_tool hook doesn’t block the tool call”, so Anthropic’s advice is “don’t count on a stalled hook to act as a gate”. A hook that hands its decision to a background process and exits loses it too: a deny that process prints after Claude Code has stopped reading “doesn’t block the tool call”. HTTP hooks treat non-2xx responses and connection failures as non-blocking errors.
The fix is one field. "onFailure": "block" on a command or http hook makes a failed hook block the action. It requires Claude Code v2.1.295 or later and has no effect on async hooks.
We pointed the curl handler at a misspelled script path, then at a script that sleeps 20 seconds under a 3-second timeout, each with and without onFailure, and asked Claude to run curl -s https://example.com:
| Hook | Without onFailure |
With "onFailure": "block" |
|---|---|---|
| Misspelled script path | curl ran and printed200 |
Blocked: failed; blocking because onFailure is "block" |
| Sleeps past a 3-second timeout | curl ran and printed200 |
Blocked: timed out; blocking because onFailure is "block" |
The broken hook left no trace in the session: nothing in the transcript, and Claude reported a clean run. Only the claude --debug log had it: Hook PreToolUse:Bash (PreToolUse) error: status code 127, followed by No such file or directory.
Who can turn a hook off? #
Anyone who can change the settings that load it, and in some modes that includes Claude. Command hooks “execute shell commands with your full user permissions” according to Anthropic’s security considerations, and they run outside the Claude Code sandbox, as we covered in the sandbox post. A hook protects the machine only while its configuration and its script stay as written, and there are three ways for that to change.
Claude editing its own settings
.claude/ is a protected path, along with .git, .vscode, .idea and a few others. Writes to it are prompted in default and acceptEdits mode, sent to the classifier in auto, denied in dontAsk and allowed in bypassPermissions. Allow rules don’t change that. The prompt itself has a wider option, though, “Yes, and allow Claude to edit files in this project’s .claude folder for this session”, and after one click the rest of the session’s edits to project hooks go through unasked. Sandboxed shell commands can’t write the .claude settings files, .claude/hooks or the hooks inside .git either, because “A command that could edit those files could grant itself permissions, or add a hook or MCP server that Claude Code runs outside the sandbox.”
That leaves two cases the docs don’t settle: a guard script stored outside .claude/, and a settings file written by code Claude runs rather than by Claude’s Edit tool. ConfigChange adds a third question. A "decision": "block" “prevents the configuration change from being applied” to the running session; the reference doesn’t say what happens to the file on disk.
We tested all three, plus the protected path itself, with Edit on the allow list:
- Claude removing the hook. Asked to set
"hooks"to{}in.claude/settings.json, Claude was stopped indefaultandacceptEditsmode withClaude requested permissions to write to .../.claude/settings.json, but you haven't granted it yet.The allow rule didn’t open it. Inautomode the classifier let the edit through in three runs out of three, andbypassPermissionsallowed it. We asked for the edit directly, so the classifier was judging the user’s own request. We didn’t test an instruction planted in a file. - A guard outside
.claude/. With the hook pointing atscripts/guard.sh, Claude removed itsexit 2indefaultandacceptEditsmode with no prompt. Outside.claude/, a guard script is an ordinary file, and the same allow rule that.claude/ignored covered it. - A test rewriting the settings. A
test.jsthat overwrites.claude/settings.jsonwith{"hooks":{}}did exactly that undernpm testwith the sandbox off. With the sandbox on, the write failed withEPERM: operation not permitted, open '.claude/settings.json'. When we asked Claude to write that test itself, it did in one run; in the next, Anthropic’s safeguards flagged the identical request and Claude declined, so we put the file in place ourselves and asked Claude only to runnpm test. ConfigChangewith"decision": "block". The hook fired when Claude emptied"hooks", and the running session kept the old ones: the nextrm -rf build/was still denied. The edit was on disk all the same, and the next session loaded the empty file and ranrm -rf build/. Without theConfigChangehook, the change applied to the running session at once, andrmwent through in the same session.
A repository’s disableAllHooks
disableAllHooks follows settings precedence, so “a "disableAllHooks": false in a project’s .claude/settings.json overrides a true in your user settings.” The reference shows that direction only. Whether a project’s true also silences the hooks in your user settings isn’t stated, so we checked. Managed hooks are out of reach either way: only disableAllHooks set in managed settings can disable them, and “There is no way to disable an individual hook while keeping it in the configuration.”
It does. We put an rm guard in ~/.claude/settings.json. In a project with empty settings, it denied rm -rf build/. In a project whose .claude/settings.json held only { "disableAllHooks": true }, the user hook never ran and build/ was deleted. A repository you clone can switch off the guards you set for every project. A hook you depend on belongs in managed settings, covered below.
Cloned repositories and claude -p
In an interactive session, Claude Code “holds back hooks from every settings file, including your own ~/.claude/settings.json, until you accept the workspace trust dialog”. A -p or SDK session is different: “Claude Code never shows the dialog and treats the folder as trusted, so hooks committed in a repository’s .claude/settings.json run in a folder you’ve never trusted.” Before scripting claude -p over a repository you didn’t write, Anthropic suggests reviewing its .claude/ settings, starting with --bare, or passing --settings '{"disableAllHooks": true}' (workspace trust).
We cloned a repository whose .claude/settings.json has one SessionStart hook, touch /tmp/hook-ran, into a folder Claude Code had never opened. claude -p "hello" created /tmp/hook-ran. With --settings '{"disableAllHooks": true}', Claude answered and the file didn’t appear.
Five Claude Code hooks worth running #
Hooks are still the cheapest place in Claude Code to stop an obvious mistake and tell Claude why. These examples are worth the setup, as long as you know what each one misses:
- A
PreToolUsehook onBashthat denies destructive commands, such asrm -rfoutside the project and force pushes. It reads the command string, so whatever got pastguard-curl.shin the tests above gets past this too. - A hook on
Read|Edit|WriteandBashthat denies.env,.git/and key files, in line with Anthropic’s “Skip sensitive files” advice. Files you mention with@in a prompt are added “without any tool call”, so noPreToolUsehook fires for them. - A
git pushguard for protected branches.git -C . pushandgit -c push.default=current pushare the forms to test it against: in our runs, both got past a grep forgit pushand anif: "Bash(git push *)"filter. - A
ConfigChangehook onproject_settingsandlocal_settingsthat logs every change and blocks the ones you care about. It can’t blockpolicy_settings, and it doesn’t run for server-managed settings. - An async
PostToolUsehook that appends each tool call to a log. It can’t block anything, since the tool has already run, and it records only what passes through Claude’s tools.
Every blocking hook in that list should deny with exit 2 or JSON rather than exit 1, set "onFailure": "block", and run a script by absolute path. Anthropic’s hook security best practices add “Always quote shell variables” and “Block path traversal”.
We wrote all five as small Node scripts and ran them together on Claude Code 2.1.319. The blocking ones return a JSON deny:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node /path/to/project/.claude/hooks/block-destructive.js",
"onFailure": "block"
},
{
"type": "command",
"command": "node /path/to/project/.claude/hooks/guard-push.js",
"onFailure": "block"
}
]
},
{
"matcher": "Read|Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "node /path/to/project/.claude/hooks/block-sensitive.js",
"onFailure": "block"
}
]
}
],
"ConfigChange": [
{
"matcher": "project_settings|local_settings",
"hooks": [
{
"type": "command",
"command": "node /path/to/project/.claude/hooks/config-guard.js"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node /path/to/project/.claude/hooks/audit-log.js",
"async": true
}
]
}
]
}
}
Each one did its job and had the gap the list predicts:
block-destructive.jsresolves everyrm -rorrm -ftarget against the project directory. It deniedrm -rf /tmp/...andrm -rf ~/..., and agit push --force.cd /tmp && rm -rf hooks-lab-victimdeleted the folder, because the script resolved the relative path against the project, andfind ... -deletenever mentionsrm.block-sensitive.jsdenied reading.envand.git/configand runningcat .env.cat .en*printed the key, andcat .e""nvgot past the script when we fed it that command directly. Asked about@.env, Claude answered with the key and no hook ran at all.guard-push.jsstrips quotes and also denies any push whilemainis checked out, which caughtgit -C . push,git -c push.default=current push,git 'push' origin mainandsh -c "git push origin main". A git alias,git ship origin mainwithshipset topush, went through; it failed only because our test repository had no remote.config-guard.jsreceived"source": "local_settings"when Claude created.claude/settings.local.jsonwith no hooks, and blocked it: the nextrm -rf /tmp/...in that session was still denied. The file itself was still written, as in the earlierConfigChangetest.audit-log.jslogged every call that succeeded. Calls a hook denied, and a push that failed, never reachedPostToolUse, so the log only records what ran. What Claude tried and was refused is in the session transcript.
Claude Code managed settings for hooks #
On a team, the hooks that hold are the ones developers can’t edit. Managed settings sit at the top of settings precedence, above --settings, local project, shared project and user settings. The file is /Library/Application Support/ClaudeCode/managed-settings.json on macOS, /etc/claude-code/managed-settings.json on Linux and WSL, and C:\Program Files\ClaudeCode\managed-settings.json on Windows, or it arrives through MDM or server-managed settings (managed settings).
The hook-related keys, from the settings reference:
allowManagedHooksOnly: true: “only managed hooks run, plus Agent SDK hooks and hooks from plugins your managed settings force-enable”.strictPluginOnlyCustomization, set totrueor to["hooks"], blocks hooks from user and project sources so they can come only from plugins or managed settings.disableAllHooksin managed settings is the only switch that turns managed hooks off.allowManagedPermissionRulesOnlymakes managed settings the only source of allow, ask and deny rules, which settles the other half of the precedence question.allowedHttpHookUrlslimits which URLs HTTP hooks can call, andhttpHookAllowedEnvVarslimits which environment variables they can put in request headers.
Anthropic names two limits on the same page. “A developer who is an administrator on the machine can edit the managed source itself”, and “managed settings bind Claude Code only. A developer who calls the API from another tool isn’t under them.” To confirm a machine picked the file up, run /status: its Setting sources line shows Enterprise managed settings (file), the older name many guides still use for Claude Code enterprise settings.
Checking the process instead of the command with Formal Endpoint #
The bypasses that got past the hook have one thing in common: the hook reads the command Claude wrote, and the network request comes from a process that command started. Formal Endpoint, the egress proxy we build, works from the other end. In Transparent Mode it captures TCP connections at the OS network layer, and on macOS it knows which process opened each one and that process’s ancestors.
A network rule picks the connections the Endpoint handles. Each rule is a CEL expression with a condition for pre_tcp, pre_tls and post_tls. process.has_agent_ancestor is “True when the connecting process is a known agent or has one in its ancestor list”, so a curl started by npm test, started in turn by Claude Code, matches however the command line was written. The starting rule in the docs:
{
"condition": {
"pre_tcp": process.is_agent || process.has_agent_ancestor,
"pre_tls": true,
"post_tls": technology in ["mcp", "llm"]
}
}
pre_tcp runs on macOS only, where Claude Code is identified by its code signature. Linux has no signature check: the agent flags compare process names, so there Claude Code is the process called claude.
The next dev case is where that stops. A watcher the developer started in their own terminal has no agent ancestor, so an agent rule doesn’t match the code it runs, even when Claude wrote that code. Covering it takes a rule on the destination for every process, or the sandbox or a VM around the whole session.
The closest thing Formal has to a PreToolUse check sits on the model’s response. Our Protect Git Tokens in Command Lines guide is a response policy on LLM traffic that “blocks agent tool calls whose arguments contain a token or a URL with credentials, so the command never runs”. The Endpoint evaluates it. No settings file in a repository can turn it off, because it isn’t in one. The guide is direct about its reach: “The patterns match common formats, not every secret. A token passed through a credential helper, a script file, or a variable outside a URL does not match.”
Two more documented controls cover what a hook can’t take back. Credential injection keeps the key off the laptop: a Formal Connector stores it and adds it to requests on their way upstream, and in the Stripe guide’s words, “Stripe receives the real key. The laptop never does.” A test file has nothing to read. The proxies post applies the same setup to the Anthropic API key. And the block agent git pushes guide blocks git-receive-pack from Claude Code’s process tree while a push from your own terminal goes through.
The limits are the Endpoint’s own. Process identity from code signatures is macOS only, and Linux matches process names. Transparent Mode isn’t available on Windows, where the Endpoint connects through formal connect only. Agent rules only know the agents in Formal’s identity list, so an agent missing from it passes through them. AI coding agent security covers how the same rules apply to Cursor, Codex and Devin, and our guide to egress filtering covers the rules underneath.
What we’d run #
- Hooks for the mistakes an agent makes in plain sight, and for telling Claude why it was stopped.
- Deny with exit 2 or a JSON
deny, never exit 1, and"onFailure": "block"on every blocking hook. - Scripts by absolute path, kept inside
.claude/hooks. Outside it, our guard was an ordinary file Claude edited without a prompt. - A look at a repository’s
.claude/settings.jsonbefore working in it: itsdisableAllHooksswitched off our user hooks too. - Team hooks from managed settings, with
allowManagedHooksOnly. --bareor--settings '{"disableAllHooks": true}'forclaude -pon repositories nobody reviewed.- The sandbox or a VM around the session, and egress and credentials handled outside the agent.
Asked for the safest way to run Claude Code, Anthropic’s security page recommends VMs “to run scripts and make tool calls, especially when interacting with external web services”, and reminds readers that no system is “completely immune to all attacks”. For what Formal adds around the agent on a team, see Claude Code security.
Hooks earned a place in our setup as the cheapest way to stop Claude’s obvious mistakes and tell it why. They didn’t earn trust as a boundary: everything that got past ours moved the dangerous part somewhere the hook doesn’t look, and the settings that load a hook could be changed by a repository, by Claude in auto mode, or by a test. Keep the hooks, and put the checks you can’t afford to lose outside the agent, where no settings file can turn them off.
Frequently asked questions #
What’s the difference between PreToolUse and PostToolUse hooks?
A PreToolUse hook runs before the tool call and can stop it. A PostToolUse hook runs after a call succeeds: its "decision": "block" only adds a reason next to the tool result, and updatedToolOutput replaces the output Claude sees. Failed calls go to PostToolUseFailure instead, and PostToolBatch fires once a batch of parallel calls has resolved, before the next model call.
How do I see which Claude Code hooks are active?
Type /hooks. It opens a read-only browser that labels each hook with where it comes from (user, project or local settings, a plugin, or the current session) and shows what it runs and which file defines it. To change a hook, edit that file; there’s no per-hook off switch. To watch hooks as they run, start a session with claude --debug.
Can hooks be written in something other than bash?
Yes. Besides command hooks, Claude Code supports http hooks, which POST the event JSON to a URL, mcp_tool hooks, prompt hooks that ask a model, and experimental agent hooks that spawn a subagent with tools such as Read and Grep. On Windows, a command hook can run in PowerShell with "shell": "powershell".
Can a hook block a command in dangerously-skip-permissions mode?
Yes. A PreToolUse hook that returns a deny “blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions”. What changes in that mode is the protected-path check: writes to .claude/, where project hooks live, are allowed without a prompt. The dangerously-skip-permissions post covers what else the mode turns off.
Do Claude Code hooks run inside the sandbox?
No. Anthropic’s sandboxing page says “The sandbox covers shell commands only. Claude’s file tools, MCP servers, and hooks run outside it.” The sandbox does protect hooks from sandboxed commands: they can’t write .claude/hooks or the hooks inside .git, so a sandboxed test can’t plant one there.
Can an HTTP hook send session data to an outside service?
It sends its event to whatever URL it’s configured with. Once allowedHttpHookUrls is set, Claude Code “runs an HTTP hook only if its URL matches one of the patterns and blocks the rest without running them”, and httpHookAllowedEnvVars caps which environment variables any HTTP hook can put in its headers. Both are arrays that merge across settings files, so the list in effect combines every file’s entries.