{"id":430,"date":"2026-07-07T13:03:10","date_gmt":"2026-07-07T13:03:10","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/claude-code-hooks-the-guardrails-i-actually-ship\/"},"modified":"2026-07-07T13:03:10","modified_gmt":"2026-07-07T13:03:10","slug":"claude-code-hooks-the-guardrails-i-actually-ship","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/claude-code-hooks-the-guardrails-i-actually-ship\/","title":{"rendered":"Claude Code Hooks: The Guardrails I Actually Ship"},"content":{"rendered":"<p>Confession. I spent about three weeks pretending Claude Code hooks were a nice-to-have. I read the docs, nodded, and went back to babysitting every agent run like a manager who won&rsquo;t stop hovering.<\/p>\n<p>Then I let an agent commit a <code>.env<\/code> file to a public repo.<\/p>\n<p>That was the day I stopped pretending. I opened <code>~\/.claude\/settings.json<\/code>, wrote a five-line PreToolUse hook, and haven&rsquo;t lost sleep about accidental commits since. Hooks aren&rsquo;t a nice-to-have. They&rsquo;re the difference between &ldquo;I let the agent work&rdquo; and &ldquo;I sit here watching every diff scroll by, defeating the whole point of hiring a helper in the first place.&rdquo;<\/p>\n<p>This post walks through the exact hook setup I ship in every project. The shape of the JSON that comes in on stdin. The exit codes that actually block a tool call. A couple of gotchas that cost me an evening each. If you&rsquo;re new to Claude Code hooks, this is the mental model I wish someone had handed me first.<\/p>\n<h2 id=\"what-claude-code-hooks-actually-are\">What Claude Code hooks actually are<\/h2>\n<p>Strip the marketing. A Claude Code hook is a shell command that runs when the agent hits a specific lifecycle event. You wire them up in <code>.claude\/settings.json<\/code> (per project) or <code>~\/.claude\/settings.json<\/code> (global). The events I use most are <code>PreToolUse<\/code> and <code>PostToolUse<\/code>, but there are others: <code>UserPromptSubmit<\/code>, <code>SessionStart<\/code>, <code>Stop<\/code>, <code>SubagentStop<\/code>, <code>Notification<\/code>.<\/p>\n<p>Every hook gets a JSON payload on stdin. For a tool event you get the tool name, the tool input, the session id, the cwd, and a transcript path. Your script reads it, decides what to do, and communicates back through exit codes or structured JSON on stdout.<\/p>\n<p>Two things you have to know before you write your first one:<\/p>\n<ul>\n<li>Exit code <code>2<\/code> blocks the tool call and sends your stderr back to the agent as a message. Exit code <code>0<\/code> is a pass. Anything else is a non-blocking error the user sees but the agent ignores.<\/li>\n<li>You can also print JSON like <code>{\"decision\": \"block\", \"reason\": \"...\"}<\/code> on stdout for the same effect, plus richer control on <code>PreToolUse<\/code> (allow, ask, block).<\/li>\n<\/ul>\n<p>That&rsquo;s basically the whole primitive. Everything else is convention. The full event list and payload schema live in the <a href=\"https:\/\/docs.claude.com\/en\/docs\/claude-code\/hooks\" rel=\"nofollow noopener\" target=\"_blank\">Claude Code hooks reference<\/a>, and the file locations are covered in the <a href=\"https:\/\/docs.claude.com\/en\/docs\/claude-code\/settings\" rel=\"nofollow noopener\" target=\"_blank\">settings docs<\/a>.<\/p>\n<h2 id=\"the-two-hooks-i-run-in-every-repo\">The two hooks I run in every repo<\/h2>\n<p>Here&rsquo;s my baseline. Nothing fancy. It has saved me from the two failure modes I actually hit: writing secrets to disk, and shipping unformatted code.<\/p>\n<h3 id=\"pretooluse-block-writes-to-sensitive-files\">PreToolUse: block writes to sensitive files<\/h3>\n<p>Before I let Claude Code touch a file, I check the path against a small deny list. If it matches, block.<\/p>\n<p>Old way, back when I was doing this by hand:<\/p>\n<pre><code class=\"language-bash\"># Me, in a real Slack message to a coworker, three months ago:\n# &quot;hey can you also check that Claude didn't touch .env or ~\/.ssh or anything\n# in secrets\/ before you merge my PR&quot;\n<\/code><\/pre>\n<p>That was the &ldquo;system.&rdquo; Predictably, I forgot to send the message once.<\/p>\n<p>New way, in <code>.claude\/settings.json<\/code>:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;hooks&quot;: {\n    &quot;PreToolUse&quot;: [\n      {\n        &quot;matcher&quot;: &quot;Write|Edit|MultiEdit&quot;,\n        &quot;hooks&quot;: [\n          {\n            &quot;type&quot;: &quot;command&quot;,\n            &quot;command&quot;: &quot;python3 .claude\/guardrails\/block_secrets.py&quot;\n          }\n        ]\n      }\n    ]\n  }\n}\n<\/code><\/pre>\n<p>And the script it points at:<\/p>\n<pre><code class=\"language-python\">#!\/usr\/bin\/env python3\nimport json, sys, re, os\n\nDENY = [\n    r&quot;\\.env(\\..+)?$&quot;,\n    r&quot;\\.pem$&quot;,\n    r&quot;id_rsa(\\.pub)?$&quot;,\n    r&quot;secrets\/.*&quot;,\n    r&quot;credentials\\.json$&quot;,\n]\n\npayload = json.load(sys.stdin)\ntool_input = payload.get(&quot;tool_input&quot;, {})\npath = tool_input.get(&quot;file_path&quot;) or tool_input.get(&quot;path&quot;) or &quot;&quot;\nrel = os.path.relpath(path, payload.get(&quot;cwd&quot;, &quot;.&quot;))\n\nfor pat in DENY:\n    if re.search(pat, rel):\n        print(\n            f&quot;Refused to touch {rel}. Update block_secrets.py to allow.&quot;,\n            file=sys.stderr,\n        )\n        sys.exit(2)\n\nsys.exit(0)\n<\/code><\/pre>\n<p>Exit code <code>2<\/code> blocks the write and hands the message to the agent, which then explains it to me instead of surprising me on <code>git diff<\/code>. Ten minutes of setup, no more panicked &ldquo;did I check <code>.env<\/code> this time&rdquo; thoughts.<\/p>\n<h3 id=\"posttooluse-run-the-formatter-and-lint\">PostToolUse: run the formatter and lint<\/h3>\n<p>This one nobody talks about, but I use it every day. When Claude Code writes or edits a JS\/TS file, I want Prettier and ESLint to run on it immediately, and I want lint failures to feed back into the agent so it fixes its own mess.<\/p>\n<pre><code class=\"language-json\">{\n  &quot;hooks&quot;: {\n    &quot;PostToolUse&quot;: [\n      {\n        &quot;matcher&quot;: &quot;Write|Edit|MultiEdit&quot;,\n        &quot;hooks&quot;: [\n          {\n            &quot;type&quot;: &quot;command&quot;,\n            &quot;command&quot;: &quot;bash .claude\/guardrails\/format_and_lint.sh&quot;\n          }\n        ]\n      }\n    ]\n  }\n}\n<\/code><\/pre>\n<p>And the script:<\/p>\n<pre><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -e\npayload=&quot;$(cat)&quot;\nfile=&quot;$(echo &quot;$payload&quot; | jq -r '.tool_input.file_path \/\/ .tool_input.path \/\/ empty')&quot;\n\ncase &quot;$file&quot; in\n  *.ts|*.tsx|*.js|*.jsx)\n    npx prettier --write &quot;$file&quot; &gt;\/dev\/null\n    if ! npx eslint --max-warnings=0 &quot;$file&quot; 1&gt;&amp;2; then\n      echo &quot;Lint failed on $file. Fix warnings before continuing.&quot; 1&gt;&amp;2\n      exit 2\n    fi\n    ;;\nesac\n<\/code><\/pre>\n<p>The trick is <code>exit 2<\/code> on lint failure. Claude Code sees the message, treats it as a tool-use error, and iterates. I&rsquo;ve watched it clean up its own unused imports about ten times this week without me typing a word.<\/p>\n<p>The real payoff isn&rsquo;t the automation itself, though that&rsquo;s nice. What I actually notice is that the agent stops asking me every three minutes whether to run the formatter. It just does, because the hook already did.<\/p>\n<h2 id=\"the-gotchas-nobody-documents\">The gotchas nobody documents<\/h2>\n<p>I lost about an evening each to these. Save yourself.<\/p>\n<p>First, hooks run in your shell, not Claude Code&rsquo;s process. That means <code>$PATH<\/code> behaves like a login shell in most cases, but not always. If your hook depends on <code>nvm<\/code>, <code>pyenv<\/code>, or a specific venv, source the activation script inside the hook or use absolute paths. Debugging a hook that &ldquo;works in my terminal&rdquo; but fails silently in the agent is not how I want to spend a Saturday.<\/p>\n<p>Second, stderr is the message and stdout is protocol. If you want to talk to the agent, write to stderr and exit <code>2<\/code>, or print structured JSON on stdout. Mixing them, or printing plain text on stdout, gets you either silence or a confused agent. I misread this on my first attempt and thought hooks didn&rsquo;t work at all.<\/p>\n<p>Third, matchers are regex over tool names, not glob patterns over file paths. <code>\"matcher\": \"Write|Edit\"<\/code> matches the tool names <code>Write<\/code> and <code>Edit<\/code>. If you want to filter by file, do that inside your script (like the <code>case \"$file\" in<\/code> block above). The docs are clear enough about this in hindsight, but I definitely wasted twenty minutes staring at <code>\"matcher\": \"*.env\"<\/code> wondering why it wasn&rsquo;t firing.<\/p>\n<p>Fourth, global settings are less sandboxed than you think. If you put a hook in <code>~\/.claude\/settings.json<\/code>, it runs for every project. Convenient for the secrets blocker. Terrible for a repo-specific lint config that assumes a Node project when you&rsquo;re currently in a Rust one. Keep per-project scripts under <code>.claude\/<\/code> in the repo, and only put truly cross-cutting rules in the global config.<\/p>\n<h2 id=\"when-i-dont-use-hooks\">When I don&rsquo;t use hooks<\/h2>\n<p>Rigorous mentor mode: this isn&rsquo;t a silver bullet. There are two situations where I turn hooks off.<\/p>\n<p>Prototyping in a throwaway branch where I want to see what the agent produces raw. If the lint hook blocks every third tool call while I&rsquo;m trying to explore an API, I lose the rhythm. In that case I switch the branch to a &ldquo;no-hooks&rdquo; mode with a local override file and just eyeball things until the shape is clearer.<\/p>\n<p>Slash commands and MCP-based tools that don&rsquo;t map cleanly to the matcher regex. Some MCP servers surface names your regex won&rsquo;t anticipate. The workaround is to broaden the matcher and filter inside the script by inspecting the payload. It works, but it&rsquo;s less clean, and if I only have one flaky MCP I&rsquo;ll usually just not hook it at all.<\/p>\n<p>If you&rsquo;re deciding whether hooks or a different tool fits your workflow, you might want to skim my longer take in <a href=\"https:\/\/abrarqasim.com\/blog\/codex-vs-claude-code-2026\/\" rel=\"noopener\">Codex vs Claude Code<\/a>. Different tools, different guardrail stories. I also keep a running set of hook scripts and prompt patterns I actually ship on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>; grab whatever bits are useful.<\/p>\n<h2 id=\"the-setup-id-copy-if-i-were-you-today\">The setup I&rsquo;d copy if I were you today<\/h2>\n<p>If you have twenty minutes this week, here is the order I would do it in.<\/p>\n<p>Create <code>.claude\/settings.json<\/code> in your project with both a <code>PreToolUse<\/code> block for a secrets deny list and a <code>PostToolUse<\/code> block for your language&rsquo;s formatter. Point them at scripts under <code>.claude\/guardrails\/<\/code> so they live in git and travel with the repo.<\/p>\n<p>Start the deny list minimal. <code>.env<\/code>, <code>id_rsa<\/code>, <code>secrets\/<\/code>. You can grow it later. A short list you actually maintain beats a long one you copy-pasted from a gist and never audited.<\/p>\n<p>Wire the formatter to <code>exit 2<\/code> on real failures, not on style warnings. If you make it too aggressive it will block the agent constantly and you will turn it off inside a day. Aim for block on lint errors and warn on style, not the reverse.<\/p>\n<p>Log every hook invocation to a file for the first week. Something like <code>&gt;&gt; .claude\/guardrails\/hook.log 2&gt;&amp;1<\/code> at the end of each command. Read the log on Friday. You will see which hooks fire, which never do, and which are catching real mistakes versus creating friction.<\/p>\n<p>Then delete the ones that never fired.<\/p>\n<p>That is it. No magic. Just a small pile of shell scripts that make the agent behave the way I would want a careful new hire to behave: don&rsquo;t touch the secrets, format the code, tell me when something&rsquo;s wrong. If your agent runs still feel like babysitting, this is where I would start.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Claude Code hooks changed how I run agents. Here is the exact PreToolUse and PostToolUse setup I ship in every repo, with the JSON, exit codes, and traps.<\/p>\n","protected":false},"author":2,"featured_media":429,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Claude Code hooks changed how I run agents. Here is the exact PreToolUse and PostToolUse setup I ship in every repo, with the JSON, exit codes, and traps.","rank_math_focus_keyword":"claude code hooks","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[4,184],"tags":[363,424,27,467,108,468,425,69],"class_list":["post-430","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","category-developer-tools","tag-ai-agents","tag-ai-coding-2","tag-automation","tag-claude","tag-claude-code","tag-cli","tag-developer-tools-2","tag-hooks"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/430","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=430"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/430\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/429"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=430"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=430"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=430"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}