How to write a Claude Code hook that cannot steer your agent
I build ccdeck, a local dashboard for Claude Code and Codex sessions, and its hook is the worked example; everything about it is quoted from the source at the v3.42.0 tag (hook/hook.js, src/server/installer.mjs and src/w
I build ccdeck, a local dashboard for Claude Code and Codex sessions, and its hook is the worked example; everything about it is quoted from the source at the v3.42.0 tag (
hook/hook.js,src/server/installer.mjsandsrc/web/__tests__/hook-read-only.test.ts, with two more tests named where they come up); at 3.48.0 the hook, its tests and the quoted installer lines are unchanged. Claude Code's behaviour is quoted from Anthropic's hooks reference as I read it on 14 October 2026.
Plenty of hooks only want to watch: a logger, a notifier, a dashboard. They still run on every event they register for, and on some events Claude Code waits for them and reads what they say. A PreToolUse hook sees rm -rf before it runs, and its answer can decide whether it does.
The read-only claim needs to hold in the code. List every channel the hook has for talking back, close each one, and test that it stays closed.
Every way a hook can talk back
A command hook gets the event as JSON on stdin. From the reference, it has these ways to affect what happens next:
- JSON on stdout. "Claude Code parses your stdout as JSON output when the whole output is one JSON object", and its fields can allow, deny, defer or rewrite the call. With a valid JSON object, an exit code other than 0 or 2 is ignored: "the fields alone decide".
- Exit code 2. "Exit with code 2 to block the action", with stderr as the reason.
-
Any other non-zero exit. Not a block, but "a
<hook name> hook errornotice in the transcript", on every call it happens on. -
Plain text on stdout, on some events. For
UserPromptSubmit,UserPromptExpansion,SessionStartandPostModelSwitch, "Claude Code adds plain-text stdout as context that Claude can see and act on". A stray log line there is a message to the model. -
The entry's own fields.
onFailure: "block"turns any failure (a crash, a missing script, a timeout) into a block.async: trueremoves the blocking, but not the voice: when an async hook exits, itsadditionalContextandsystemMessagereach Claude on the next turn. - Time. The default timeout for a command hook is 600 seconds on most events, and hooks "block Claude's execution until they complete". A timed-out hook "doesn't block the tool call", but you waited for it.
An HTTP hook (type: "http") has the same powers through its response body, so a hook that POSTs straight to a local service hands the decision to whatever answers on that port; and a failed connection is a non-blocking error, a notice on every call while the service is down.
For an observer, that list turns into rules: a command hook that never writes to stdout, exits 0 on every path, declares no onFailure or async, keeps a short timeout, and checks who it is talking to before it sends anything. The rest of this post is how ccdeck's hook does each of those, and how its tests pin them.
Rule 1: nothing on stdout, exit 0 everywhere
ccdeck's test file opens with the reason it exists:
// The README says the deck cannot steer your agent. This is the file that makes
// that a property of the code rather than an intention.
and names the failure it guards against:
One console.log left in from debugging, or one
process.exit(1)on an error path, turns a read-only observer into a participant in every tool call on the machine β and silently, because a hook that prints unparseable text is ignored until the day it prints something parseable, and a hook that exits non-zero either blocks the call it was reporting (exit 2) or shows a hook error on it (any other code).
Every path in hook.js is written to end at exit 0. A throw is the one way out that a reader of the code cannot see, so there is a last resort:
process.on("uncaughtException", () => process.exit(0));
process.on("unhandledRejection", () => process.exit(0));
That line came from a bug: a malformed record in the deck's registry crashed the hook with exit 1 and a stack trace, and, per the comment, "Claude Code surfaces a non-zero exit as <hook> hook error plus the first stderr line", on every tool call.
Rule 2: test the source for every other channel
Writing the rule down is not enough; the next debugging session undoes it. hook-read-only.test.ts asserts it twice.
Over the source, it bans every spelling of stdout and every exit code but one:
expect(SRC).not.toMatch(/\bprocess\s*\.\s*stdout\b/);
expect(SRC).not.toMatch(/\bconsole\s*\./);
expect(SRC).not.toMatch(/\bwriteSync\s*\(/);
// β¦
expect(writes).toEqual(["req.write(body);"]); // the one write: the outbound request
// β¦
expect([...new Set(codes)]).toEqual(["0"]); // every process.exit(β¦) is exit(0)
expect(SRC).not.toMatch(/process\s*\.\s*exitCode/);
console.log is stdout with another name and fs.writeSync(1, β¦) is stdout with no name, so all three spellings are banned. process.exitCode is banned outright rather than checked for a value.
By running the real script, it starts an "honest deck" (a local HTTP server that answers the handshake below), feeds the hook a PreToolUse for rm -rf / on stdin, and checks:
expect(deck.seen).toEqual(["/api/event"]); // it really delivered
expect(run.stdout).toBe(""); // and said nothing back
expect(run.code).toBe(0);
The delivery is asserted on purpose: silence from a hook that did nothing would prove nothing. The same silence is then checked on every early return (no deck, unparseable payload, no cwd), on payloads that throw (a cwd that is a number, JSON nested 20,000 levels deep), and on eight malformed registry records, and a healthy deck registered next to a malformed record must still receive its event. Every run points CLAUDE_CONFIG_DIR and HOME at a temp directory, so the test never reads your real ~/.claude.
Rule 3: a time budget counted from process start
On PreToolUse the user waits until every matching hook returns, and the installer's comment is blunt about the declared timeout: it "is in SECONDS and it is a kill". So the hook has a deadline per phase and a cap over all of them:
const CHALLENGE_TIMEOUT_MS = 400; // one retry, only on a timeout
const POST_TIMEOUT_MS = 1000;
const DISCOVERY_TIMEOUT_MS = 400; // a second window only if the first found nothing
const CAP_MS = 1900;
The cap is measured from when Node started, not from when main() ran:
setTimeout(() => process.exit(0), Math.max(200, CAP_MS - Math.round(process.uptime() * 1000)));
That detail came from a measurement. The old timer started inside main(), so the shell fork, Node's start-up and reading a stdin payload that can be megabytes were outside the budget. With a stalled deck, the hook came out at 1.84 s idle and 1.98β2.19 s on a loaded machine, past the timeout: 2 it declared at the time (issue #1018). Now the cap counts from process start, the declared timeout is 3 seconds as a backstop, and hook-budget.test.ts pins the two numbers against each other so they cannot drift apart again.
The deadlines can add up to more than the cap (2.6 s if the filesystem, a listener and a deck all stall on one run), and the source says which number to believe: the cap "is the backstop over all of them, and it is what a reader should believe rather than the addition". Discovery gets its second 400 ms window only when the first found no deck and reads were still out, because on a loaded machine those reads are usually slow rather than stalled (issue #1872). A dead port costs nothing: a refused loopback connection is instant.
Rule 4: prove the listener before you send
An observer usually forwards the event somewhere, and the event is everything: in the hook's own words, "prompt text, tool inputs, tool results, cwd". Sending it to a port because a process ID looks alive is not safe. A deck killed with SIGKILL or a power cut leaves its record behind, the OS can hand the PID to another process, and the port "may by then belong to anything at all (4317, the deck's own default, is also the standard OTLP collector port)".
So each running deck writes a fresh random token into its record at start-up, and before posting, the hook challenges the listener with a nonce it has never seen:
function challengeProof(token, nonce) {
return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
}
The token itself never crosses the wire, so a wrong listener learns nothing it could replay, and the answer is compared with crypto.timingSafeEqual, "purely so a hostile listener cannot walk the expected proof out of us one byte at a time".
Rule 5: plan for the runtime disappearing
The command written into someone's settings outlives the environment it was written in. ccdeck builds its entries for ~/.claude/settings.json like this, one per event for up to twelve events, tagged so they can be found and removed:
function buildHookEntry(command) {
return {
[MARK_KEY]: true, // "__agent-dag"
hooks: [{ type: "command", command, timeout: 3 }],
};
}
The command used to name the Node binary directly. Then nvm uninstall took that binary away, and every tool call of every session showed a hook error from then on. On macOS and Linux the arguments now go through a fixed /bin/sh script first:
const NODE_FALLBACK = 'n=$1; shift; [ -x "$n" ] || n=$(command -v node) || exit 0; exec "$n" "$@"';
It runs the recorded Node while it is still executable, the node on Claude Code's own PATH once it is not, and nothing at all when there is no Node: exit 0, no output. The Claude Code version is part of the environment too. Until 2.1.101, an event name Claude Code did not know made it ignore the whole settings file, so ccdeck adds its two newest entries only where every Claude Code it finds knows them: StopFailure from 2.1.78, and UserPromptExpansion from 2.1.116. That second one is also an event where plain text on stdout becomes context for the model, which is Rule 1 again. The paths reach it as positional arguments, quoted once, so none of them is spliced into shell code. hook-command-node-fallback.test.ts runs it through a real /bin/sh and states the trade: "a hook error on every tool call is a worse answer than a deck that hears nothing from a machine it cannot run on."
One more case of the environment surprising you: your own tool's Claude runs fire your hooks too. ccdeck reads Claude quota in one of its ways with claude --print /usage, which is itself a Claude Code run. Without a guard, every quota poll drew itself on the dashboard as an empty session. The deck marks that run with AGENTS_DECK_INTERNAL=1, and the hook exits before reading a byte.
What this does not guarantee
-
It is a guarantee about one file. Anything that can write to your
~/.claudecan changesettings.jsonor the hook itself. No hook design defends against that. (ccdeck writes two other things there, separate from the hook: its Telemetry Radar switches write flags into theenvblock when you flip one, and its Settings βΊ Claude Code page writes the one key you change; its uninstall leaves both as you set them.) -
It is a guarantee about the hook, not about every button. ccdeck's Continue, on a background session whose turn stopped on an error, runs Claude Code's own
claude stopandclaude --resume <id> --bgat your press. That is a program starting a Claude Code command beside the session, not the hook answering one. -
The receiver sees everything the hook sees. Prompts, tool inputs and results go to
127.0.0.1. That is the point of a dashboard; the handshake exists so they go only to it. - Fire-and-forget means gaps. If no deck is running, the event is not recorded.
-
Very large events arrive cut. The deck refuses a body over 5,000,000 characters, and a
PostToolUsefor an Edit carries the whole edited file. Over 4,900,000 the hook replaces the biggest fields with a marker saying how large they were, and flags the event as truncated; the event name, session, tool and call id are never what goes. -
It still costs a process per event: a Node start on every hook call, with a
/bin/shin front of it on macOS and Linux, bounded by the budget above.
A checklist for any observing hook
- Use a command hook, without
onFailureand withoutasync. Write nothing to stdout, on any event: onUserPromptSubmitandSessionStart, plain text is context for the model. - Exit 0 on every path, and catch the throws you cannot see.
- Test the source for every spelling of stdout and every exit code but 0, then run the real script and assert that it delivered and said nothing.
- Give it a time budget from process start, and keep the declared
timeoutstrictly above it. - Prove the listener before sending the event; a live PID is not proof.
- Survive a missing runtime with a silent fallback, and guard against your own tool's runs firing the hook.
AI assistance was used to prepare this article and check it against the cited source code and documentation.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.