Start Debugging

Fail-Closed vs Fail-Open Hooks for Coding Agent Guardrails: What Claude Code, Cursor, Copilot, Codex and Gemini CLI Actually Do

A guard hook that crashes or times out lets the dangerous command through in most coding agents. Fail closed for pre-action guards, fail open for everything else, and make the script fail closed by itself because Copilot, Codex and Gemini CLI treat a timeout as allow. Compared on Claude Code 2.1.295+, Cursor hooks, Copilot CLI 1.0.105, Codex and Gemini CLI, with a portable guard script.

Short answer: fail closed for any hook that runs before an irreversible action and exists to refuse things (a PreToolUse guard on shell, deploy or write tools). Fail open for everything that formats, logs, notifies or runs after the fact. The catch is that each agent applies a different default and a different switch. Claude Code fails open unless the handler sets "onFailure": "block" (v2.1.295+). Cursor fails open unless the hook sets "failClosed": true. Copilot CLI (1.0.105) already denies when a preToolUse command hook crashes, but lets the call through when it times out, and has no setting to change that. Codex and Gemini CLI have no fail-closed switch at all. So the only portable answer is a guard script that fails closed by itself: it turns its own errors into exit 2 and answers before the host’s timeout fires.

Why every agent defaults to fail-open

A hook is a user-supplied process sitting in the agent’s hot path. If a broken Prettier hook or a dead Slack webhook could stop the agent, every misconfigured laptop would have a stuck agent, and the vendor would get the bug report. So every tool started with the same rule: exit 2 means “block”, exit 0 means “fine”, and anything else is a hook error that gets logged and skipped.

That is the right default for 90% of hooks and the wrong one for the 10% that matter. A PreToolUse guard that refuses git push --force fails open in all of these ordinary situations:

In each case the agent runs the forced push, and the only trace is a warning line nobody reads in a headless run. A guardrail that quietly turns off when it breaks is not a guardrail.

The feature matrix

Behaviour on a PreToolUse-style guardClaude Code 2.1.295+Cursor (hooks docs, Oct 2026)Copilot CLI 1.0.105CodexGemini CLI
Exit 2BlockBlockDenyBlockBlock (turn continues)
Crash / other non-zero exitProceedsProceedsDeny (“hook errored”)Not documentedProceeds with warning
TimeoutProceedsProceedsProceeds, alwaysNot documented (managed MCP hooks proceed)Not documented
Invalid JSON on exit 0Non-blocking errorBlocks for permission hooksTreated as no output, default flowHook marked failed, call continuesDefaults to allow
Per-hook fail-closed switch"onFailure": "block""failClosed": trueNoneNoneNone
Switch also covers timeoutsYesYesn/an/an/a
Default timeout600 s (command)Platform default30 s (timeoutSec)600 s60 s (60000 ms)
HTTP hook on network errorProceeds (blocks with onFailure)n/aProceedsn/an/a

Three columns in that table surprise people:

When a hook should fail closed

The rule I use: fail closed when the hook runs before an action you cannot undo and its job is to say no. Everything else fails open.

HookFail modeReason
Shell guard (PreToolUse on Bash, beforeShellExecution, preToolUse on bash)ClosedIts only job is to stop a call. A silent pass is the worst outcome.
MCP tool guard (beforeMCPExecution, PreToolUse on mcp__*)ClosedMCP tools reach production systems the shell guard never sees.
Secret or path read guard (beforeReadFile)ClosedA read of .env cannot be taken back once it is in the context.
Permission auto-approver (PermissionRequest)ClosedFailing closed denies, which is the safe direction.
Formatter, linter (PostToolUse, afterFileEdit)OpenThe edit already happened. Blocking only confuses the agent.
Audit log, telemetry, notificationsOpenObservability must not take down the session.
Prompt gate (UserPromptSubmit)Usually openFail-closed here locks you out of asking the agent to fix the hook.
Stop / completion gateOpen, move the check to CIA broken gate loops the agent against a check it cannot repair.

The last two rows are where fail-closed hurts most. A fail-closed prompt hook with a missing dependency makes the session unusable. A completion gate that blocks on failure produces exactly the loop described in fixing a coding agent that loops re-running a check it cannot satisfy. Claude Code knows this and ignores onFailure on Stop, SubagentStop, TaskCompleted and TeammateIdle.

A guard that fails closed by itself

Because Copilot, Codex and Gemini CLI let a timed-out hook through, and two of them have no switch at all, the host setting cannot be your only line of defence. Make the script fail closed on its own. The script below is the pattern I now use for every repo that runs more than one agent:

#!/usr/bin/env bash
# scripts/agent-guard.sh, tested with bash 3.2.57 (macOS) and jq 1.7.1
# Exit 0 = no objection. Exit 2 = deny. Every other outcome is turned into exit 2.
# Usage: agent-guard.sh [cursor]   (pass "cursor" when Cursor runs it natively)
set -uo pipefail

check() {
  set -e
  command -v jq >/dev/null || { echo "guard: jq not installed" >&2; return 3; }
  local payload cmd
  payload=$(cat)
  # Claude Code, Codex, Gemini CLI, Cursor (Claude format): .tool_input.command
  # Copilot CLI camelCase preToolUse: .toolArgs (a JSON string or an object)
  # Cursor beforeShellExecution: .command
  cmd=$(jq -r '.tool_input.command
               // (.toolArgs | if type == "string" then (fromjson? // {}) else . end | .command?)
               // .command
               // empty' <<<"$payload")
  [[ -n "$cmd" ]] || return 0          # not a shell call: no opinion
  if grep -Eq '(^|[;&| ])(rm -rf /|git push (-f|--force)|terraform apply)' <<<"$cmd"; then
    echo "guard: blocked: $cmd" >&2
    return 2
  fi
  return 0
}

# Run the check in the background so a signal can interrupt the wait.
check <&0 &
pid=$!
# Answer before the host's own timeout: several hosts treat a timeout as allow.
( sleep "${GUARD_DEADLINE:-4}"; kill -TERM "$pid" 2>/dev/null ) &
watchdog=$!

wait "$pid"; rc=$?
kill "$watchdog" 2>/dev/null

case $rc in
  0) # Cursor's failClosed counts "no output" as a failure, so say allow out loud.
     [[ "${1:-}" == "cursor" ]] && echo '{"permission": "allow"}'
     exit 0 ;;
  2) exit 2 ;;
  *) echo "guard: check failed or timed out (rc=$rc), denying" >&2; exit 2 ;;
esac

The design choices, each one tied to a row of the matrix:

I ran the failure paths on macOS against payloads in each host’s shape:

InputResult
{"tool_input":{"command":"ls -la"}}exit 0
Claude-shape git push --force origin mainexit 2, guard: blocked: ...
Copilot toolArgs as a JSON string with rm -rf /exit 2
Copilot toolArgs as an object with git push -fexit 2
Cursor {"command":"terraform apply"}exit 2
Edit payload with no commandexit 0
nope (not JSON)exit 2, rc=5 from jq
PATH=/bin (no jq)exit 2, jq not installed
stdin stalls 6 s, GUARD_DEADLINE=1exit 2 after 1 s

One trap in testing: macOS 15 and later ship /usr/bin/jq, so PATH=/bin:/usr/bin does not simulate a missing dependency. Use PATH=/bin.

Wiring the same script into each agent

Point every agent at the one script and add the host switch where one exists. The host switch is the second layer. If the script itself dies before it can run (wrong path, no bash), only the host setting catches it.

// .claude/settings.json, Claude Code v2.1.295+
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/scripts/agent-guard.sh"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

The full Claude Code side, including what counts as a failure and where onFailure is ignored, is in how to make a Claude Code hook fail closed with onFailure: “block”. Below 2.1.295 the field is silently ignored.

// .cursor/hooks.json, Cursor hooks (docs as of October 2026)
{
  "version": 1,
  "hooks": {
    "beforeShellExecution": [
      {
        "command": "bash scripts/agent-guard.sh cursor",
        "timeout": 10,
        "failClosed": true
      }
    ]
  }
}

failClosed defaults to false. The Cursor docs describe it as making “hook failures (crash, timeout, non-zero exit code, no output) block the action instead of allowing it through.” Enterprise, team, project and user hook files all merge, and a deny from any of them wins.

// .github/hooks/agent-guard.json, Copilot CLI 1.0.105
{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "type": "command",
        "bash": "bash scripts/agent-guard.sh",
        "timeoutSec": 10
      }
    ]
  }
}

There is no fail-closed field to add here. Crashes already deny. Timeouts always pass, which is why the script’s 4-second watchdog has to sit under timeoutSec. Use a command hook, not an HTTP hook, for a guard: HTTP preToolUse hooks fall through to the normal permission flow on any network error, timeout or non-2xx response.

For Codex and Gemini CLI, register the same script on PreToolUse and BeforeTool respectively. With no host switch, the script’s own fail-closed logic is all the protection you get, so keep its dependencies minimal.

The gotcha that picks for you: one .claude/settings.json, three behaviours

Here is the case that pushed me to write the script-side pattern. Cursor and Copilot both read hooks from a repository’s .claude/settings.json:

So the same committed guard is fail-closed in one agent, fail-open (or not even triggered) in the second, and half fail-closed in the third. If your team runs more than one agent on a repo, do not trust a hook file shared across tools. Register the guard natively in each tool’s own hook file, and let the script carry the fail-closed logic.

Gotchas that bite after you switch to fail-closed

The recommendation, restated

Make guards fail closed and everything else fail open. Do not rely on the host for it. Write the guard so every unexpected outcome becomes exit 2 and it answers before the shortest host timeout. Then add "onFailure": "block" in Claude Code and "failClosed": true in Cursor as the second layer that catches a script that cannot even start. On Copilot, Codex and Gemini CLI the script is the only layer you control, so test its failure paths on purpose: rename jq out of the path, feed it garbage, stall its stdin, and confirm every case ends in a denial.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back