{"slug": "claude-code-hooks-safety-through-invariants", "title": "Claude Code Hooks – Safety Through Invariants", "summary": "A developer has published a guide to Claude Code Hooks, a built-in feature that lets scripts intercept tool calls in the agent's lifecycle to enforce safety invariants. The writeup explains that PreToolUse hooks, registered in global or project-level settings.json files and scoped to specific tools via matchers, are the only point where execution can still be blocked, making them suitable for preventing accidental API-key commits or unwanted AI co-author tags. The developer notes the feature remains barely used by most developers.", "body_md": "Info: This is a later upload of an introductory post I made a few months ago.\n\nMore and more developers are using Claude Code to write code – not just for prototypes or experiments, but directly in their daily work on real systems. This saves time and makes it possible to tackle complex tasks faster. At the same time, it brings with it an aspect that is easy to overlook: Claude Code works autonomously with real tools. It executes shell commands, creates files, and makes commits – often without you actively confirming each individual step.\n\nThat is usually exactly what you want – because who wants to click \"Allow\" every 10 seconds? – but it also means that mistakes with real consequences can happen. For example, a commit message that lists an AI model as a co-author, which may be undesirable in professional contexts. Or even a commit that accidentally contains an API key.\n\nClaude Code offers **Hooks** – a feature built precisely for these kinds of safeguards – and, to my knowledge, one that is still barely used by most developers.\n\nBefore we look at the configuration, let's clarify what hooks actually are and how they are anchored in Claude Code.\n\nTo do its work, Claude Code uses so-called **Tools**.\n\n**Tools** are the instruments Claude Code is allowed to use to complete tasks. These include reading (`Read`) and writing (` Write`) files, executing shell commands (` Bash`), or modifying existing code (` Edit`). Each of these actions corresponds to a tool with a defined name.\n\n**Hooks** are executable programs or scripts that Claude Code calls at specific points in the tool lifecycle – similar to Git hooks, but for Claude actions. A hook can inspect, block, or allow a tool execution.\n\n**Matchers** determine which tools a hook is active for. Instead of checking every tool call, hooks can be scoped to specific tools – for example, only to `Bash`.\n\nThere are different hook types that fire at different points in time:\n\n`PreToolUse` – called `PostToolUse` – called `Notification` – called when Claude Code sends a notification.`Stop` – called when Claude Code finishes its work.\nFor security checks, `PreToolUse` is the relevant type, as it is the only point where execution can still be prevented.\n\nHooks are defined in Claude Code's `settings.json`. This configuration file comes in several variants – we'll look at the following two:\n\n**Global configuration** at `~/.claude/settings.json` – applies to all Claude Code sessions, regardless of the project. This is the right place for hooks that should always apply: general security checks, Git safeguards, or cross-team policies.\n\n**Project-specific configuration** in `.claude/settings.json` in the root directory of a project – applies only to that project and should ideally be version-controlled. This is where hooks belong that depend on project-specific tools, such as lint checks with `pnpm`, tests with a particular Java build tool, or formatters that only exist in that repository.\n\nFor Claude Code Enterprise customers, there is also configuration at the **organization level**. I'm confident that over time we'll receive corresponding policies there that cleanly restrict tool usage. Since these concepts are still quite new, that's not something you can expect yet. And many people use Claude Code without an Enterprise license, through which such things are, to my knowledge, not available either.\n\nI keep general hooks like the ones described below in the global configuration, since they are relevant for every development session. Project-specific hooks that depend on project-specific commands like `pnpm lint`, `./gradlew check`, or similar, I define at the project level.\n\nA hook is registered in `settings.json` under its respective hook type:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/hooks/my-hook.sh\",\n            \"statusMessage\": \"Checking...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe `matcher` specifies which tool the hook applies to. In the example, it activates for every `Bash` tool execution. `\"type\": \"command\"` defines that the hook should be run as a command (here the shell script `my-hook.sh`).\n\nClaude Code communicates with hooks via standard I/O. The flow is as follows:\n\n**Input:** Claude Code writes a JSON object to the hook process's `stdin`. It contains information about the upcoming tool execution – for the `Bash` tool, for example, the shell command to be executed:\n\n```\n{\n  \"tool_input\": {\n    \"command\": \"git commit -m 'Add feature'\"\n  }\n}\n```\n\n**Output:** The hook writes a decision as JSON to `stdout`. The format depends on the hook type. For `PreToolUse`, Claude Code expects the following structure:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\", // or \"allow\"\n    \"permissionDecisionReason\": \"Reason for the denial\"\n  }\n}\n```\n\nPossible values for `permissionDecision` are `deny` (blocks the execution) and `ask` (prompts the user for confirmation), or simply `allow`. If the hook produces no JSON output and exits with exit code 0, the tool is executed normally.\n\n**Important:** The output format differs by hook type. `PostToolUse` hooks expect a different format than `PreToolUse` hooks. The complete format specifications are documented in the [official Claude Code Hooks reference](https://code.claude.com/docs/en/hooks).\n\nThe exit code of a hook plays a decisive role in how Claude Code behaves:\n\n`stdout`.\n**It is therefore advisable to always exit with code 2 on unexpected errors.** This strategy is called \"Fail Closed\": if the hook doesn't function correctly, it would rather block too much than too little. This prevents an error in the hook code (e.g., a parse error) from silently bypassing the security check.\n\n```\nExit 0  → Hook successful → evaluate JSON decision\nExit 2  → Unexpected error → always block the tool (Fail Closed)\nOther   → Undefined behavior → avoid\n```\n\nThe `check-secrets` hook fires on every `git commit` call and scans the staged changes for known secret patterns: API keys, passwords, private keys, AWS access keys, GitHub personal access tokens, or simply high-entropy strings – long character sequences that don't follow any known pattern.\n\nThe flow:\n\n`git commit`, it exits immediately with exit code 0 – no action needed.` git diff --cached` and scans the output line by line against known patterns.`deny` decision with an error message to The patterns range from generic credential names like `db_password =` or `api_key:` combined with longer character sequences, to patterns for high-entropy strings of at least 32 characters that detect unknown token formats.\n\nThe scanner runs against `git diff --cached` – exactly what will go into the next commit. Only added lines (those starting with `+`) are checked, to avoid false positives on deleted or unchanged lines.\n\nHow exactly the hook script classifies secrets is of course up to each person – the good news is: if a hook ever fires incorrectly, you can still perform the commit yourself. (That also means secrets inserted by Claude but not committed by Claude won't be caught.) It's up to you individually how you prefer your workflow. What matters is that you have a system for automatically reviewing Claude's work. And Claude hooks are particularly well-suited for this, since they only restrict Claude – not you yourself.\n\nA quick note on git hooks: Good old pre-commit hooks are theoretically also a way to enforce something like this. However, caution is warranted here too. My Claude Code has, for example, already tried to bypass git pre-commit hooks using `git commit --no-verify` after a regular commit failed. You would therefore need to restrict git commands even further to be truly certain that pre-commit hooks are actually executed.\n\nIn the image below you can see the hook in action. I slipped a fake secret into Claude Code's work without it noticing (!), and it was about to commit the changes. The hook stopped it, and Claude Code then recognized that it needed to remove the secret. This example nicely illustrates that not only can we reliably stop undesired behavior – we can also activate a self-healing mechanism through the communication between hooks and Claude Code:\n\nLet's look at another hook example. Claude Code occasionally suggests adding itself as a co-author in commit messages, following the pattern `Co-Authored-By: Claude Sonnet <noreply@anthropic.com>`. In some projects this is undesirable – for instance with clients who don't want to expose AI usage, or in certain open-source contexts.\n\nThe `check-model-author` hook prevents this automatically:\n\n`claude`, `sonnet`, `haiku`, `opus`, `gpt`, `gemini`, `llama`, `copilot`\n`noreply@anthropic.com` or `noreply@openai.com`\nThe pattern is deliberately broad so that future model names (e.g., new Claude versions) are automatically detected without needing to update the hook.\n\nBelow you can see the hook in action again. I instructed Claude Code to make a commit and mention itself as the author. The commit fails, and the hook returns a response to Claude Code. Here too, the model is able to adapt to the situation and commits with a changed message:\n\nIn my global `~/.claude/settings.json`, both hooks are registered for the `Bash` matcher under `PreToolUse`:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/hooks/check-secrets.sh\",\n            \"statusMessage\": \"Scanning staged changes for secrets...\"\n          },\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/hooks/check-model-author.sh\",\n            \"statusMessage\": \"Checking commit message for AI co-authors...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nSince both hooks only become truly active on `git commit` calls, they have no effect on other Bash commands and don't meaningfully slow down normal work.\n\nIt is also possible to narrow the matcher further, so that the hook script only runs for specific Bash commands. For example, `\"matcher\": \"Bash(git commit*)\"` would only execute hook scripts when the command starts with `git commit`. This also works, when Claude Code chains commands, e.g: `git add <files> && git commit -m \"...\")`.\n\nClaude Code Hooks are a powerful but still underutilized feature. Particularly when Claude Code is used productively and autonomously, hooks provide the ability to enforce critical invariants – regardless of what Claude proposes or executes in a session.\n\nTwo principles are worth keeping in mind:\n\n**Fail Closed, not Fail Open.** Hooks that exit with code 2 on unexpected errors would rather block too much than too little. This prevents a broken safety net from creating a false sense of security.\n\n**Global vs. project-specific.** General checks like secret scanning or commit policies belong in the global configuration. Project-specific checks that require particular runtime environments or build tools belong in the `.claude/settings.json` of the respective project.\n\nThe barrier to entry is low: a hook is essentially just an executable program that reads JSON and writes JSON. Whether it's a shell script, Go binary, or Python script – the format is what matters, not the language. Anyone already using Claude Code in real projects should integrate hooks firmly into their workflow.\n\nCommit and secret policies can of course also be documented in `CLAUDE.md`. Claude respects that quite well. But hooks offer an additional security layer that can be worthwhile – especially since LLMs don't act deterministically and can sometimes be very narrowly focused on their current task.\n\nIt should also be said that the examples and lifecycle points mentioned here are just a fraction of what's possible with hooks. We can, for example, send notifications when Claude Code needs input, automatically run formatters after edits, and much more.\n\nFor those who want to go deeper, the complete reference for all hook types, matchers, and output formats is available in the [official Claude Code documentation](https://code.claude.com/docs/en/hooks).\n\nHappy \"vibe\" coding.", "url": "https://wpnews.pro/news/claude-code-hooks-safety-through-invariants", "canonical_source": "https://dev.to/tobi-braun/claude-code-hooks-safety-through-invariants-52nl", "published_at": "2026-09-22 08:04:14+00:00", "updated_at": "2026-09-22 08:22:46.860780+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/claude-code-hooks-safety-through-invariants", "markdown": "https://wpnews.pro/news/claude-code-hooks-safety-through-invariants.md", "text": "https://wpnews.pro/news/claude-code-hooks-safety-through-invariants.txt", "jsonld": "https://wpnews.pro/news/claude-code-hooks-safety-through-invariants.jsonld"}}