Skip to content

[Hooks] Add opt-in fail-closed handling for PreToolUse hook failures #41979

Open

Description

@zeroclaw42

What variant of Codex are you using?

Codex CLI and app-server. The behavior was characterized against rust-v0.148.0 at commit 3ba0f711642a888aec92a611a3f3b2211157ff89.

What feature would you like to see?

Add an opt-in fail-closed policy for PreToolUse command hooks, parallel to the UserPromptSubmit proposal in #33630.

Today a PreToolUse hook can block through a valid exit-2 or permissionDecision: "deny" result, but a timeout, spawn failure, crash/non-protocol exit, or malformed output is treated as a failed hook with should_block=false, so the tool is dispatched. That makes a policy hook least reliable precisely when its process is unavailable.

A narrow configuration shape could be:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "",
        "failureMode": "block",
        "hooks": [
          {
            "type": "command",
            "command": "path/to/local-policy-hook"
          }
        ]
      }
    ]
  }
}

Requested semantics:

  • failureMode accepts continue or block.
  • Omitted or continue preserves the existing behavior.
  • block prevents tool dispatch when the hook times out, cannot be spawned, crashes or exits without a valid protocol result, emits malformed output, or encounters event-serialization failure.
  • The user receives a redacted blocking diagnostic that contains no command, tool-input, or hook-payload values.
  • The setting participates in hook trust/config hashing, and effective-config reads expose the resolved value to app-server/Desktop consumers.
  • Tests prove every failure class blocks before tool execution while valid allow and deny results remain unchanged.

This is the PreToolUse counterpart to #33630; it does not require changing the compatibility default for advisory hooks.

Activity

  1. added
    enhancementNew feature or request
    CLIIssues related to the Codex CLI
    hooksIssues related to event hooks
    app-serverIssues involving app server protocol or interfaces
    configIssues involving config.toml, config keys, config merging, or config updates
    on Sep 1, 2026
  2. vincentkoc commented on Sep 13, 2026

    @vincentkoc
    Contributor

    Status update: OpenClaw delivery is proceeding against released Codex contracts. The local custom-contract prototype described below is abandoned for this fix and is not a merge dependency. The original source findings and earlier prototype test limitations remain recorded below.

    This also affects synchronous mcp_tool policy hooks, and the same opt-in needs to cover PermissionRequest to preserve approval precedence. Context: openclaw/openclaw#91009.

    I inspected rust-v0.153.4 and main source frozen at 36f0dbe796d9bb1a18a0fc0640ed08b3e1d54564. At that unpatched main snapshot:

    • PreToolUse records runtime errors as Failed while leaving should_block = false.
    • PermissionRequest similarly leaves decision = None. The approval router then invokes its reviewer, which can approve. Enforcing only PreToolUse does not cover an outage between the two hook phases.

    For an MCP reproduction, enable features.hooks and explicitly trust the exact handler below using the normal hook trust flow. This is the hooks configuration fragment:

    {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"mcp_tool","server":"policy","tool":"check","input":{},"timeout":1}]}]}}

    Leave policy unconfigured, then request a shell command that writes a marker. That unpatched snapshot treats the failed hook as non-blocking. For the PermissionRequest counterpart, use an action that actually enters approval and a reviewer that otherwise approves; full-access settings alone can bypass PermissionRequest and would be a misleading test.

    The narrow contract I prototyped is:

    • Optional on_error: "block" on synchronous command/MCP handlers for these two events; omission or continue retains existing behavior.
    • Successful required evaluation needs canonical JSON with the matching hookSpecificOutput.hookEventName. An explicit envelope with no decision acknowledges successful evaluation and retains ordinary approval handling. PreToolUse retains deny with a nonempty reason and allow with updatedInput; this does not add ask or bare allow. PermissionRequest retains its canonical allow/deny decisions, including the existing default denial message.
    • Runtime, timeout, MCP isError, empty/plain-text, malformed, mismatched, or semantically invalid output blocks PreToolUse or produces an explicit PermissionRequest deny. Deny must win over another matching hook's allow. Cancellation must not dispatch the original tool.
    • Preserve enabled/trusted/managed eligibility and include the option in the trust hash. This is an execution policy for supported handlers, not a general change to unsupported/malformed configuration handling.
    • Embedding clients need an additive initialize capability before relying on this setting, because older binaries can ignore the unknown field. The prototype calls it supportsFailClosedPermissionHooks; absent means unsupported. A ${$} MCP template forwards the complete event, including optional subagent fields, without recursively expanding payload strings.

    Validation so far is against earlier local prototype revisions on that frozen main source, not an official Codex release: 506 hooks/config tests passed; a core approval test proved required failure prevents full-access reviewer fallback; a test using a real MCP connection to a synthetic server completed a subsequent call on the same connection and confirmed the cancelled command never wrote its marker. The native CLI built and its initialize response advertised the proposed capability.

    The latest prototype remains unqualified. The full native MCP dispatch matrices needed a test diagnostic correction because public hook events retain only the outer MCP error, and the corrected rerun was interrupted by validation infrastructure. The corrected fixtures use otherwise-valid delayed acknowledgments/error results plus dispatch markers. Canonical generated schemas and the broader workspace suite are also pending. No official release support or complete integration qualification is claimed.

  3. chrysent commented on Sep 17, 2026

    @chrysent

    Independent confirmation on codex-cli 0.153.3 (official release tarball, Linux x86_64), with a minimal repro that isolates the malformed-output sub-case.

    Setup. A project-level hooks.json registering a synchronous PreToolUse handler for ^Bash$:

    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "^Bash$",
            "hooks": [
              {
                "type": "command",
                "command": "/usr/local/bin/policy-hook --state-dir /state",
                "timeout": 5,
                "mode": "sync"
              }
            ]
          }
        ]
      }
    }

    Repro.

    1. Trust the hook through the ordinary /hooks review flow; codex exec runs with the hook active.
    2. The hook binary is a small stdin/stdout JSON filter. In the failing configuration it mis-decodes the request envelope and exits with status 2 without writing a decision to stdout (it writes a fixed diagnostic to stderr).
    3. Drive one turn whose Bash step writes a sentinel file (e.g. printf data > /tmp/out.txt).

    Observed on 0.153.3:

    • The hook process exits 2.
    • Its stderr diagnostic is surfaced on the codex exec error surface prefixed with Command blocked by PreToolUse hook: — so the operator-visible text looks like a block.
    • Despite that, the command executes: the turn completes with a command_execution JSONL item and the sentinel file is created. codex exec exits 0.

    So in this state the hook is worse than absent: the error text reads as enforcement to anyone watching the output, while the tool ran. We only noticed because the absence of the expected filesystem side-effect assertion failed on our side after we fixed the decoder — before the fix, every request from our hook failed decode and nothing was ever actually blocked.

    After fixing the hook (well-formed JSON decision on stdout, exit 0): denials are enforced correctly (no command_execution item for the turn, command not executed), so the happy path works fine on this version.

    Ask. As proposed in this issue: an opt-in per-hook (or global) failureMode: block so a crashing/malformed/failing hook fails closed, plus — if possible — distinguishing the surfaced text for "hook blocked the command" vs "hook itself failed" (in our repro both render as Command blocked by PreToolUse hook: ..., which actively misleads during diagnosis).

    Happy to provide the hook filter script or extra traces if useful.


    Addendum: one-shot reproduction script

    A self-contained shell script reproduces the issue with no dependencies beyond codex itself. It creates a minimal project whose single PreToolUse handler for ^Bash$ always fails (reads stdin, writes a diagnostic to stderr, exits 2, no decision on stdout), runs codex exec asking codex to create a sentinel file, then reports which failure mode occurred:

    • fail-open (the bug): the sentinel file exists and codex exits 0, while the output misleadingly shows Command blocked by PreToolUse hook: policy-hook: could not parse request envelope
    • fail-closed (expected): the sentinel file is absent and/or the exit is nonzero

    The script (save as run-repro.sh, chmod +x, run it in an empty directory; tested on codex-cli 0.153.3, official release tarball, Linux x86_64). NOTE: it is interactive by necessity — newly written hooks start Untrusted and never run until trusted through the ordinary /hooks flow, so the script pauses once for you to trust the hook (using --dangerously-bypass-hook-trust instead would change what is being tested and is deliberately not used):

    #!/usr/bin/env bash
    # One-shot reproduction: sets up a codex project whose PreToolUse hook
    # FAILS (exit 2, no decision on stdout) for every Bash call, then asks
    # codex to touch a sentinel file. After the run:
    #   - If hooks fail CLOSED:   proof.txt absent, run fails/blocked.
    #   - If hooks fail OPEN:     proof.txt EXISTS and codex exits 0 while the
    #                             output misleadingly says "Command blocked by
    #                             PreToolUse hook: ..." — this is the bug.
    #
    # TRUST: new hooks start Untrusted and will NOT run until trusted. The
    # script therefore (a) writes the hook files, then (b) runs `codex exec`
    # interactively ONCE so you can accept the hook via the /hooks flow when
    # prompted (or open /hooks and trust the handler), and only then (c) runs
    # the actual failing-hook scenario. A fully automatic repro is impossible without
    # either the interactive trust step or --dangerously-bypass-hook-trust
    # (which would change what is being tested — do not use it here).
    set -uo pipefail
    cd "$(dirname "$0")"
    
    cat > failing-hook.sh <<'HOOK'
    #!/usr/bin/env bash
    read -r _   # consume the JSON envelope from stdin (do not parse it)
    echo "policy-hook: could not parse request envelope" >&2
    exit 2
    HOOK
    chmod +x failing-hook.sh
    
    cat > hooks.json <<'HOOK'
    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "^Bash$",
            "hooks": [
              { "type": "command", "command": "./failing-hook.sh", "timeout": 10, "mode": "sync" }
            ]
          }
        ]
      }
    }
    HOOK
    
    echo "Step 1/3: hook files written."
    echo "Step 2/3: trust the hook (required — untrusted hooks never run)."
    echo "  Easiest: run:  codex exec --search  # any harmless command, then"
    echo "  follow the trust prompt, OR open the TUI and use /hooks to trust"
    echo "  './failing-hook.sh'. Alternatively run this script's step 3 with"
    echo "  --dangerously-bypass-hook-trust ONLY if you accept that caveat."
    echo
    read -r -p "Press Enter after the hook shows as Trusted in /hooks..."
    
    rm -f proof.txt
    echo "Step 3/3: running codex exec with the FAILING hook active."
    codex exec "run this exact bash command: printf hello > proof.txt"
    status=$?
    
    echo
    echo "=== codex exec exit status: $status"
    if [ -f proof.txt ]; then
      echo "=== RESULT: proof.txt EXISTS -> the command RAN even though the"
      echo "    PreToolUse hook failed (exit 2, no decision). Hooks failed OPEN."
    else
      echo "=== RESULT: proof.txt absent -> the failing hook blocked (fail-closed)."
    fi
    [ "$status" -ne 0 ] && echo "=== codex exited nonzero ($status), not 0."

    The hook deliberately ignores stdin content — any Bash step triggers the same failure path, so nothing about the request matters except that it is a Bash call.

  4. soycarts commented on Sep 18, 2026

    @soycarts

    +1, with evidence from codex-cli 0.152.1 on macOS: a PreToolUse command hook that crashed, one that exceeded its timeout, and one that printed output the harness could not parse each surfaced a hook failure, and in all three cases the shell command then ran, its effect was on disk afterwards, and a PostToolUse event followed.

    For the shape: a per-entry opt-in that defaults to today's behaviour, as proposed here (failureMode: "block" or on_error: "block"), is exactly what I need. A hook that renders a status line should keep proceeding on failure; a hook that answers deny should be able to say that its silence means no. The PermissionRequest coverage noted above matters for the same reason: an outage between the two phases is otherwise an approval.

    Runbook and sanitized captures for the three failure trials: https://github.com/approval-md/approval.md/blob/main/docs/codex-hook-probe.md (see the hook-failure observations in the native evidence section).

  5. chiuwill87 commented on Oct 1, 2026

    @chiuwill87

    Source confirmation for released 0.159.3, plus one coverage requirement for native child dispatch.

    At 0.159.3 pre_tool_use.rs, callback failures and malformed results still leave should_block false. No-match and serialization-failure paths also continue. Required managed-hook loading checks startup loading; it does not establish successful runtime validation.

    Our requirement: a specifically designated mandatory validator must return a valid allow decision before native child allocation. Missing/unmatched required coverage, an untrusted/unavailable validator, crash, timeout, malformed result, or serialization failure must deny before allocation. Optional/advisory hooks can retain current defaults.

    Relevant boundary: core pre-tool handling, followed by native child creation.

    Acceptance needs valid-allow/explicit-deny cases, every required-validator failure class, coverage after configuration reload, and proof that denied calls create no child. A matched-hook failure policy alone would leave absent/mismatched mandatory coverage unresolved.

    Evidence limit: inspected released source; earlier 0.159.2 testing confirmed a sampled explicit native denial before child creation. We have not performed live failure injection on 0.159.3.

    Is a supported implementation or tracked patch available for this required-validation boundary? Related contract-visibility dependency: #46939.

  6. vinifonz commented on Oct 18, 2026

    @vinifonz

    Feature request: opt-in fail-closed policy for synchronous PreToolUse hook failures

    Please support an opt-in policy that denies a pending tool when a synchronous PreToolUse command hook fails. This would let deployments that use hooks for policy enforcement stay on the official CLI instead of maintaining a private patch.

    I tested the official Linux x64 Codex CLI 0.163.0. The native ELF SHA-256 was 399ff69c431c066e40de318fba45c2b07f45a76b19939d247ee4828fc70c4215.

    With a trusted synchronous hook matching Bash and apply_patch, both a native file patch and a shell request to a loopback HTTP receiver still executed after each of these hook failures:

    Hook failure Native hook status Tool still executed
    One-second timeout failed Yes, both tools
    Exit code 1 failed Yes, both tools
    Invalid JSON on stdout failed Yes, both tools
    Missing executable failed Yes, both tools

    Healthy hooks allowed both tools. A Python missing-script error returned exit code 2 with stderr and denied both tools, consistent with the documented exit-code contract. The twelve-case test used disposable profiles, a loopback mock provider and a loopback destination; no real outbound message or customer request was made.

    For example, this registered and trusted hook times out before the tool runs:

    {
      "hooks": {
        "PreToolUse": [{
          "matcher": "^(Bash|apply_patch)$",
          "hooks": [{
            "type": "command",
            "command": "python3 -c 'import time; time.sleep(3)'",
            "timeout": 1
          }]
        }]
      }
    }

    After trusting it in a disposable test profile, request a harmless file patch or a request to a local test HTTP receiver. The hook reports hook timed out after 1s, but the native tool still produces its side effect.

    I understand that hook errors continuing execution are part of the current documented behavior. The request is for an explicit alternative for synchronous policy hooks, while preserving the existing default and asynchronous hooks' advisory behavior. Under the opt-in policy, timeout, launch failure, nonzero non-deny exit, unusable structured output and pre-dispatch input serialization failure should deny the pending tool. Healthy hooks and explicit valid decisions should retain their current behavior.

  7. cortexive commented on Oct 19, 2026

    @cortexive

    +1 from a deployment that runs many concurrent Codex CLI agents behind a local policy hook.

    Confirmed on the official 0.163.0 Linux x64 build. With a trusted synchronous PreToolUse command hook, allow and explicit deny (exit 2) both behave correctly. When the hook exceeds its timeout, though, the host marks the hook failed and the original command still runs. A wrapper inside the hook can turn its own child's failures into a deny, but it can't cover the host's own timeout or spawn-failure branch. So there's currently no way to make a policy hook fail closed from the hook side. This is what keeps us from moving Codex onto a consolidated policy hook.

    The proposed per-entry failureMode: "block" (default unchanged) covers our need. One addition: once a block-mode hook is loaded, a config reload shouldn't be able to silently remove it or downgrade it to continue mid-session. Either refuse that reload and keep the prior snapshot, or require re-trust. Otherwise a policy guard can disappear from a running session without anyone noticing.

  8. ion-rbl commented on Oct 20, 2026

    @ion-rbl

    Measurements on Codex CLI 0.162.1 (macOS) that confirm the timeout case still fails open:

    1. Minimal reproduction, run on 2026-10-20. In a trusted project, with the hook trusted:

      .codex/hooks.json:
      {"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
      {"type": "command", "command": "date +%s > /tmp/hookstart; sleep 30; date +%s > /tmp/hookend", "timeout": 5}]}]}}

      Run: codex exec --json - with the prompt "Run this shell command exactly once: echo probe".

      Result: the hook started at second 1792487332. Codex stopped it (hookend was never written) and ran the command at 1792487337.1, about 5 seconds later. The command printed "probe" with exit code 0. The JSON event stream held no hook error or hook event, standard error was empty, and codex exec exited 0. The same run with the plain command "sleep 30" gave the same result (about 5.3 seconds, then the command ran).

    2. Default timeout. A hook with no "timeout" field that slept 1500 seconds was stopped after about 599 seconds, then the Bash command ran with no error. This matches a default of 600 seconds.

    So a slow or hung PreToolUse guard becomes a silent allow, and nothing in the event stream shows that the guard never decided. A "failureMode": "block" option as proposed here would close this. Until then, a visible event for a hook timeout would at least make the allow auditable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIapp-serverIssues involving app server protocol or interfacesconfigIssues involving config.toml, config keys, config merging, or config updatesenhancementNew feature or requesthooksIssues related to event hooks

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions