7 min read
Test Claude Code hooks as behavior contracts
A sentence is not an enforcement boundary
A hook that says “block commands that delete production data” sounds like a policy. It is not proof that the action will be blocked. I would treat that sentence as a behavior contract: a claim about what the system should do that needs tests against what it actually does.
That distinction became concrete in Claude Code’s v2.1.294 release notes, published October 8, 2026. The notes report a fix for prompt and agent hooks written as instructions, such as “Block commands that…”, which had allowed operations they were meant to block. The important lesson is not that a particular version fixed a particular bug. It is that wording a rule in natural language does not, by itself, make that rule reliably enforced.
I would not remove natural-language hooks. They can express intent, add useful review, and guide an agent toward safer choices. I would stop treating them as the only barrier around an operation whose execution must be prevented. If the consequence of a miss matters, enforcement belongs at a boundary that can refuse the operation without asking the model to interpret the rule correctly.
Turn the instruction into a testable contract
A useful contract names the action, the conditions under which it must be blocked, the cases that should remain allowed, and what observable result counts as a block. “Be careful with destructive commands” is not testable. “A tool request that would delete the protected directory is denied before execution” is.
For a hook intended to block shell commands, I would write down a small test plan before relying on it:
Contract: requests that can delete protected data must not execute.
Block:
- A direct deletion command targeting the protected directory.
- The same action with flags or whitespace rearranged.
- A shell command that invokes the deletion through a wrapper.
- A command whose arguments combine a harmless operation with the protected action.
Allow:
- A read-only listing of the protected directory.
- A deletion targeting an explicitly permitted temporary directory.
Observe:
- Whether the tool action was denied before execution.
- Whether an independent marker confirms that the simulated action did not run.
This is a test fixture, not hook syntax. Its purpose is to make the intended behavior reviewable by someone who did not write the instruction. It also avoids a common testing mistake: trying one obvious prohibited phrasing and concluding that the rule is complete. A natural-language hook may respond differently when the same intent appears inside a longer command, is described indirectly, or is paired with a plausible reason to proceed.
I would run these cases in an isolated test setup with inert targets, never against real data. For each hook change, I would keep both prohibited and permitted cases. A policy that blocks everything may pass a one-sided test while making the workflow unusable; a policy that allows the expected safe case but misses a transformed unsafe one is not a dependable guard. The contract should test the boundary, not just whether the hook emits a convincing explanation.
I would also include an explicit adversarial case: the agent receives untrusted text that asks it to ignore the restriction or claims the operation is approved. The test is not about guessing every possible prompt injection. It checks whether the policy still holds when the instruction is challenged by competing text. OWASP’s LLM Prompt Injection Prevention guidance recommends enforcing permissions in execution code rather than relying on prompt wording; that is the same boundary I want the test to expose.
Put deterministic enforcement where the action happens
I separate two questions that are easy to conflate. Can the agent be instructed not to propose an action? And can the system prevent the action from executing if the agent proposes it anyway? The first is useful guidance. The second is a security property.
Claude Code documents blocking behavior for hooks such as PreToolUse, including denial through a hook decision; its hooks reference describes the supported blocking behavior and exit-code handling. That gives a project a mechanism to build a deterministic check around a tool call. It still needs careful implementation and tests: the right hook must run for the relevant action, its result must be interpreted as intended, and failure behavior must not silently turn a deny into an allow.
For an important restriction, I would layer controls. The natural-language hook can explain the rule and steer the agent away from a prohibited request. A command hook or other executable policy can inspect the actual tool input and reject known forbidden actions. The operating system, database, or service account should have only the permissions needed for the task, so a mistake in the agent layer does not grant powers that the task never required. OWASP’s guidance also recommends least privilege and validating tool calls against permissions outside the model.
These layers have different costs. A prompt-based rule is easy to change and can express context that is awkward to encode. A deterministic check takes more care to implement and may be narrower than the policy people actually mean. Permission scoping can constrain legitimate work if it is too tight. I would accept those costs for actions with meaningful consequences, and keep the model’s judgment for decisions where a mistaken interpretation is recoverable.
The key is not to call every hook deterministic simply because it runs at a named point in a workflow. I would ask what component makes the allow-or-deny decision, what input it evaluates, and what happens when that component errors or cannot run. If the answer is “the agent reads the instruction and should comply,” then I have a behavioral expectation, not an independent security boundary.
Make regressions visible
A test is useful only if it runs again after the behavior changes. I would keep hook contract cases next to the hook configuration or its policy code, run them when either changes, and include them in the upgrade check for Claude Code. The release note is a reminder that behavior can change across versions; a passing test on one version is not a permanent guarantee about all later versions.
For each case, I would record the input, the expected decision, and whether the simulated action actually ran. I would test the hook directly where practical, then verify the complete path through the tool integration. A unit test can show that a matcher rejects a string; it cannot alone show that the correct hook is registered or that the tool runner honors the rejection. Conversely, an end-to-end test with only one command may miss the input variations that matter. Both views are valuable because they catch different failures.
I would keep a deliberate safe case alongside every block case and make test failures easy to diagnose. If a previously blocked case starts passing after a configuration edit or version update, that is a regression to investigate before relying on the hook. If a safe case starts blocking, the policy may have become broader than intended. The goal is not to prove that every imaginable adversarial input has been covered; it is to make known policy expectations executable and to expose uncertainty before people mistake a prompt for a guardrail.
Natural-language hooks are worth using as instructions. They are not a substitute for enforcement. I would trust them to communicate intent, test them as behavior contracts, and put the final deny decision outside the agent whenever a missed block would matter.
Drafted with AI assistance, reviewed and edited by Ali.