{"slug": "a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed", "title": "A Claude Code Hook That Times Out Lets the Command Through. Make It Fail Closed", "summary": "A developer demonstrated that Claude Code's PreToolUse command hooks fail open by default: a hook that times out or exits with an unexpected code does not block the tool call, so a guard meant to stop pushes to main can silently let them through. The walkthrough shows how to build a small Python guard, break it deliberately to observe the bypass, then set the new onFailure: \"block\" option added in Claude Code 2.1.295 (October 8, 2026) so that a hook which can't start, times out, or exits unexpectedly blocks the action instead.", "body_md": "The [Claude Code hooks reference](https://code.claude.com/docs/en/hooks) has a sentence worth reading twice: \"A timed-out `command`, `http`, or `mcp_tool` hook doesn't block the tool call.\" A command hook that exits with any code other than 0 or 2 gets the same treatment. The docs call that a \"non-blocking error\", and the action goes ahead.\n\nPicture the setup the [permissions docs](https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks) suggest for running Bash without prompts: add `\"Bash\"` to your allow list and register a PreToolUse hook that rejects the few commands you never want. Then the hook gets a bug. It throws on an input it didn't expect, or it hangs on a slow call. The command it was there to stop runs anyway, and the only trace is a line in the transcript.\n\n[Claude Code 2.1.295](https://code.claude.com/docs/en/changelog), released October 8, 2026, added the switch for this. In the changelog's words, `onFailure: \"block\"` means \"a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through.\"\n\nYou'll build a small guard, break it twice on purpose, watch it fail open, then flip one field and break it again. It all happens in a throwaway repo whose remote sits on your own disk, so a push that gets through costs nothing.\n\n`onFailure` needs Claude Code 2.1.295 or later, so check first:\n\n```\nclaude --version\n```\n\nThen make a repo whose `origin` is a bare [git](https://git-scm.com) repository next to it:\n\n```\nmkdir -p ~/code/hook-lab && cd ~/code/hook-lab\ngit init -q -b main\ngit init -q --bare ../hook-lab-remote.git\ngit remote add origin ../hook-lab-remote.git\ngit commit -q --allow-empty -m \"init\"\nmkdir -p .claude/hooks\n```\n\nBefore each attempt below, make a fresh commit. After it, ask the remote what it has:\n\n```\ngit commit -q --allow-empty -m \"try\"   # before\ngit ls-remote origin main              # after\n```\n\nIf `ls-remote` prints the same hash as `git rev-parse HEAD`, the push landed. An older hash, or no output at all, means it didn't.\n\n*A guard that times out leaves the gate open.*\n\nThe hook gets the tool call as JSON on stdin, and for Bash the command sits in `tool_input.command`. Save this [Python](https://www.python.org) script as `.claude/hooks/guard.py`:\n\n``` python\n#!/usr/bin/env python3\n\"\"\"PreToolUse guard for Bash calls.\"\"\"\nimport json\nimport re\nimport sys\n\nPUSH_MAIN = re.compile(r\"\\bgit\\s+push\\b.*\\b(main|master)\\b\")\nPUBLIC_REPO = re.compile(r\"\\bgh\\s+repo\\s+create\\b.*--public\\b\")\n\ndef decide(decision, reason):\n    out = {\"hookSpecificOutput\": {\n        \"hookEventName\": \"PreToolUse\",\n        \"permissionDecision\": decision,\n        \"permissionDecisionReason\": reason,\n    }}\n    print(json.dumps(out))\n    sys.exit(0)\n\ndata = json.load(sys.stdin)\ncommand = data[\"tool_input\"][\"command\"]\n\nif PUSH_MAIN.search(command):\n    decide(\"deny\", \"Pushing to main is blocked. Push a branch.\")\nif PUBLIC_REPO.search(command):\n    decide(\"ask\", \"This creates a PUBLIC GitHub repository.\")\nsys.exit(0)\n```\n\nA push to `main` or `master` gets `deny`, and Claude sees the reason. A [` gh repo create`](https://cli.github.com/manual/gh_repo_create) with `--public` gets `ask`, which \"prompts the user to confirm\", because sometimes a public repo is exactly what you meant. Anything else exits 0 with no output, so per the docs \"the tool call continues through the normal permission flow.\" The decisions go out as JSON with exit 0, the exit code the docs intend for JSON output, and the only one that still counts as a success once you turn on `onFailure` below.\n\nWire it up in `.claude/settings.json`:\n\n```\n{\n  \"permissions\": {\n    \"allow\": [\"Bash(git push *)\"]\n  },\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"python3\",\n            \"args\": [\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py\"],\n            \"timeout\": 5\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThree choices in there are on purpose. The allow rule is the lab's stand-in for \"allow Bash, let the hook catch the bad ones.\" Without it, a guard that fails open would still drop you into Claude Code's normal permission prompt, and you'd catch the push there. The guard still wins over that rule when it works: per the permissions docs, \"A blocking hook also takes precedence over allow rules.\"\n\n`args` runs `python3` directly with no shell, which the docs recommend \"whenever the hook references a path placeholder\". And `timeout` is in seconds. The default for a command hook is 600, so a hung guard would hold the call for ten minutes before letting it through. Five seconds is plenty for two regexes.\n\nTest the script before Claude ever calls it:\n\n```\necho '{\"tool_input\":{\"command\":\"git push origin main\"}}' \\\n  | python3 .claude/hooks/guard.py; echo \"exit=$?\"\n```\n\nIt should print the deny decision as one line of JSON, then `exit=0`. Swap in `git status` and it prints only `exit=0`.\n\nNow start `claude` in `hook-lab`, type `/hooks` to see the guard listed with where it came from, make a commit, and ask Claude to run `git push origin main`. The guard denies it, Claude gets \"Pushing to main is blocked. Push a branch.\", and `git ls-remote origin main` prints nothing. The remote is still empty.\n\nFirst break: a hang. Add this line right after `import sys`:\n\n``` python\nimport time; time.sleep(15)  # BREAK 1: past the timeout\n```\n\nMake a commit and ask Claude to push again. The guard runs into its 5-second timeout, Claude Code cancels it, and per the docs \"The call continues through the normal permission flow.\" Your allow rule matches, the push runs, and `ls-remote` now prints the hash of your latest commit.\n\nSecond break: a bad exit code. Delete the sleep and put these two lines after `import sys` instead:\n\n```\nprint(\"guard broke\", file=sys.stderr)  # BREAK 2\nsys.exit(3)\n```\n\nCommit and ask again. Exit 3 is neither 0 nor 2, so it's a non-blocking error: the transcript shows `Failed with non-blocking status code:` with the first line of stderr, and the push lands again. Exit 3 is standing in for real crashes here. A Python traceback from an input shape you didn't plan for exits 1, and the docs warn that without valid JSON on stdout, \"Claude Code treats exit code 1 as a non-blocking error.\"\n\n*Timeout or crash, the default is the same: the call goes through.*\n\nLeave BREAK 2 in the script and add one field to the handler:\n\n```\n\"timeout\": 5,\n\"onFailure\": \"block\"\n```\n\nCommit and ask Claude to push. This time the call is blocked, and the error includes `failed; blocking because onFailure is \"block\"`, the wording the [docs](https://code.claude.com/docs/en/hooks) show for a hook that fails. Swap BREAK 2 for BREAK 1 and try once more: still blocked, and the message says `timed out` instead of `failed`. Both times, `ls-remote` keeps printing the hash from the last push that got through.\n\nThe docs count five failures: the hook can't start, exits with a code other than 0 or 2, gets an HTTP error, times out, or prints JSON that can't be parsed or fails validation. Plain-text stdout isn't one. With `\"block\"` set, a failure \"does what exit code 2 does on that event\", which on PreToolUse blocks the tool call.\n\nOne of those bites guards that return JSON. A non-0, non-2 exit counts as a failure \"even if it printed JSON that allows the action, such as `permissionDecision: \"allow\"`.\" So once you set `\"block\"`, a guard that prints an allow and then exits 1 blocks everything it was meant to allow. Exit 0 after the JSON, like `decide()` does.\n\nDelete both breaks, run the pipe test again, and keep `onFailure` in.\n\nI build [Hermes IDE](https://hermes-ide.com), so weigh this section with that in mind. Hermes runs Claude Code in a real terminal, with its own branch and worktree per task, so nothing about the hook changes. What changes is where you find out the guard wants you.\n\nCommit `.claude/settings.json` and `.claude/hooks/guard.py`. A new git worktree holds tracked files only, so a guard kept in `.claude/settings.local.json`, the project file meant to stay out of git, won't be in the worktree a new task starts in. Committed, it rides along with every task you start with ⌘N (Ctrl+Shift+N on Windows and Linux).\n\n*The ⌘N New task sheet in Hermes 2.3.4: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the preview of the claude command Hermes will run.*\n\nWhen the guard answers `ask` on `gh repo create --public`, Claude Code stops at a permission prompt. A session waiting at a permission prompt is what Hermes's \"needs approval\" status is for. The attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I) jumps to the one that has waited longest. You answer in Claude Code's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own, so you answer the guard's question yourself. In the lab, answer no: if `gh` is signed in, yes creates a real public repo on your account.\n\nA deny or a fail-closed block needs nothing from you. Claude gets the reason and carries on, and there's no permission prompt for you to answer.\n\nHermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux; [2.3.4](https://github.com/hermes-hq/hermes-ide/releases) shipped on October 15, 2026. It isn't affiliated with Anthropic or any other agent vendor.\n\nA broken guard now blocks every call its matcher catches. With `\"matcher\": \"Bash\"`, a typo in `guard.py` stops `ls` too. To shrink that, give each rule its own handler with an `if`, which takes [permission rule syntax](https://code.claude.com/docs/en/hooks) and \"holds exactly one permission rule\":\n\n```\n{\n  \"type\": \"command\",\n  \"if\": \"Bash(git push *)\",\n  \"command\": \"python3\",\n  \"args\": [\"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py\"],\n  \"timeout\": 5,\n  \"onFailure\": \"block\"\n}\n```\n\nAdd a second handler with `\"if\": \"Bash(gh repo create *)\"`. When Claude Code can't tell which commands a Bash input runs, the docs say it runs your hook anyway.\n\nThe regexes only catch the obvious spellings. A bare `git push` while you're on `main` slips past, and so do `git -C` and shell aliases. The pattern also denies a branch called `fix-main-nav`, a false positive in the safe direction. The docs call the `if` filter best-effort and add: \"use the permission system rather than a hook to enforce a hard allow or deny.\" For `main`, the lock that doesn't depend on anyone's laptop is a [branch protection rule](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) with branch restrictions turned on, where \"only users, teams, or apps that have been given permission can push to the protected branch.\"\n\n`onFailure` does nothing on `Stop`, `SubagentStop`, `TaskCompleted` and `TeammateIdle` hooks, or on hooks that set `async` or `asyncRewake`. Don't make a guard async. If you install mods, the permissions docs note that a mod handling `tool.check` can approve a call a PreToolUse hook blocked, unless that hook lives in managed settings. On Windows, [2.1.299](https://code.claude.com/docs/en/changelog) (October 14) fixed PreToolUse hooks with no shell set failing open when Git for Windows isn't installed, so update.\n\nFail open or fail closed is the question you end up asking about every dependency that can break: an auth check that times out, a rate limiter that's down, a guard hook with a bug. The right answer depends on what a wrong yes costs you, and that trade-off runs through the core building blocks in the [System Design Pocket Guide: Fundamentals](https://www.amazon.com/dp/B0GYMFPTWV). It's one of 8 short books in the Pocket Guides series, on agents, RAG, prompts, observability, events, databases and system design: [the whole series is here](https://www.amazon.com/dp/B0GYLH38X4). If you'd like to run the lab with Claude Code inside Hermes, [download it](https://hermes-ide.com/download) or [star it on GitHub](https://github.com/hermes-hq/hermes-ide).", "url": "https://wpnews.pro/news/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed", "canonical_source": "https://dev.to/gabrielanhaia/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed-1g5k", "published_at": "2026-10-25 01:03:09+00:00", "updated_at": "2026-10-25 01:12:30.310594+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic", "GitHub", "Python", "git"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed", "markdown": "https://wpnews.pro/news/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed.md", "text": "https://wpnews.pro/news/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed.txt", "jsonld": "https://wpnews.pro/news/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed.jsonld"}}