PreToolUse deny (exit 2 + permissionDecision JSON) is not enforced for apply_patch - hook fires, write proceeds

Open 💬 3 comments Opened Jun 12, 2026 by Lukeini
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

With a project-scoped .codex/hooks.json registering a PreToolUse hook on matcher apply_patch|Edit|Write (trusted via the per-hook trust prompt, [features].hooks = true), the hook FIRES for apply_patch - but a deny emitted through both documented channels (exit code 2 with a stderr message, and stdout JSON hookSpecificOutput.permissionDecision: "deny") is not enforced. The transcript shows the hook ran and reports Failed, yet apply_patch reports success and the file is written with exactly the content the hook denied.

Related to but distinct from #26733: there the hook never fires because Write|Edit|Bash matchers do not cover the internal patch tool. In our setup apply_patch IS a matchable name and the hook dispatches - the gap is one level deeper, at deny enforcement.

Reproduction

  1. Project .codex/hooks.json (trusted; [features].hooks = true in config.toml):
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "apply_patch|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "pwsh -NoProfile -ExecutionPolicy Bypass -Command '$repo = if ($env:ASSISTANT_TEAM_ROOT) { $env:ASSISTANT_TEAM_ROOT } else { (& git rev-parse --show-toplevel).Trim() }; & (Join-Path $repo \"Tools/RuntimeValidation/validate-written-artifact.ps1\") -HookProvider codex -Mode PreToolUse; exit $LASTEXITCODE'",
            "timeout": 15
          }
        ]
      }
    ]
  }
}
  1. On a deny finding, the hook command emits all documented deny signals at once:
  • stdout (compact JSON): {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"<reason>"}}
  • a human-readable message on stderr
  • exit code 2

Verified standalone: running the exact hook command in a terminal against the same payload returns exit 2 and prints the JSON above.

  1. Confirm the hooks are live in the session: a PostToolUse hook registered for the SAME matcher in the same file executes and logs on every apply_patch (this is the oracle proving dispatch, payload parsing, and command execution all work).
  1. Ask Codex to create a file whose content triggers the deny, using apply_patch. In our case: a Markdown draft containing the U+2014 character written into a folder our validator classifies as an external draft location.

Expected behavior

Per the hooks documentation, a PreToolUse hook exiting 2 (or returning permissionDecision: "deny") blocks the tool call, and PreToolUse intercepts apply_patch.

Actual behavior

Transcript shows hook: PreToolUse -> Failed, then apply_patch outputs Success. Updated the following files: A <path> - the file is created with the denied content. The write is not blocked and the permissionDecisionReason is never surfaced.

Data points

  • Codex CLI 0.133.0 (Windows 11, pwsh 7 hook command): 3 runs post trust-grant - PreToolUse fired, was marked Failed, and the write proceeded each time.
  • Codex Desktop 0.138.0-alpha.7 (same machine, fresh session): probe pair - the deny-triggering probe was applied despite the dual deny channel; a control probe (non-deny content) executed and was logged by the PostToolUse hook within the same minute, confirming hooks were live in that exact session.
  • The same script's PostToolUse mode works reliably on both builds (it logs findings; we use it as a post-write revert fallback - delete plus feedback message - and it executed live during a real session).
  • Claude Code honors the same JSON deny emitted by the same validator script, so the script side is proven cross-provider.

Impact

PreToolUse cannot serve as a write guardrail for apply_patch even when it fires and matches. Projects that need enforcement must fall back to a PostToolUse revert (write-then-delete), which is what we shipped - workable, but the denied content transiently exists on disk, and pre-write blocking remains unavailable on the Codex side.

Environment

  • Codex CLI 0.133.0 and Codex Desktop 0.138.0-alpha.7 (both reproduce)
  • Windows 11; hook command runs pwsh 7 with -NoProfile
  • Project-scoped .codex/hooks.json; [features].hooks = true; hooks trusted via the per-hook trust prompt

Notes for reproducers

  • After ANY change to a hook command in hooks.json, per-hook trust must be re-granted or the changed hook is silently skipped (sessions started before the change keep the previously trusted config in memory). This initially produced a false "PreToolUse never fires" trail for us; all data points above were collected post trust-grant.
  • Happy to attach full transcripts and the validator script on request.

Related issues: #26733 (internal patch tool not matched by Write/Edit/Bash - note that apply_patch does match and fire on our builds), #20204 (PreToolUse coverage inconsistency across tool handlers), #26452 (codex exec hook dispatch flakiness - observed by us as well, excluded from this report). This report is specifically about deny enforcement when the hook DOES fire.

View original on GitHub ↗

3 Comments

github-actions[bot] contributor · 2 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #27342

Powered by Codex Action

cgb479023-design · 1 month ago

## Additional reproduction: same non-enforcement, but for shell_command (Codex Desktop), and confirmed with an unconditional-deny hook

I hit the same underlying bug independently while auditing whether my ~/.codex/hooks.json PreToolUse guard is actually enforced, and can add a data point that rules out a couple of alternate explanations (payload shape, matcher mismatch).

Environment

  • Codex Desktop 26.623.101652 (Windows 11), bundled codex.exe version 26.623.13972.0
  • Global ~/.codex/hooks.json (not project-scoped), hook trusted via Settings → Hooks (confirmed trusted_hash recorded in config.toml under [hooks.state])
  • PreToolUse matcher: "Bash|apply_patch"
  • Shell tool actually invoked by the agent in this build is shell_command (not Bash)

What I confirmed

  1. Hook does fire. app-server event: hook/started / hook/completed show up in ~/.codex/logs_2.sqlite for every shell_command call, so PreToolUse dispatch itself is working.
  2. The deny decision is never enforced, regardless of cause. To isolate this from payload-shape/matcher issues, I replaced my hook script with one that unconditionally returns deny for every invocation, independent of tool_name or command content:

``python
import json
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "UNCONDITIONAL TEST BLOCK"}}))
`
I recomputed the SHA256 for this script version (so it would be treated as a distinct, already-trusted revision) and then ran a plain
shell_command write to a path that should have been blocked by any policy. **The write succeeded anyway.** dispatch_tool_call_with_terminal_outcome: spawn_child_async` proceeded normally in the logs, with no denial surfaced to the session.

  1. Separately, I also noticed the matcher: "Bash|apply_patch" never matches the tool name Codex actually passes for shell calls (shell_command, not Bash) — confirmed by invoking the hook script directly with both payload shapes. But per point 2 above, this is not the whole story: even a hook that ignores tool_name/tool_input entirely and always says deny does not block the call. So there appear to be (at least) two independent gaps: (a) matcher naming mismatch for the built-in shell tool, and (b) permissionDecision: "deny" not being enforced at the dispatch layer even when it does reach Codex.

This matches the "hook fires, Failed status shown, but tool call still succeeds" pattern described in this issue, just via shell_command instead of apply_patch, and via Desktop rather than CLI. Given apply_patch and shell_command are the two most common built-in tool paths people would use hooks to guard, I think this issue (or a linked one) should probably track both surfaces, since the root cause in the dispatch/enforcement layer looks shared rather than tool-specific.

Happy to share log excerpts (from ~/.codex/logs_2.sqlite, app-server event: hook/started|hook/completed rows) if useful for repro/triage.

nordscope-fi · 18 days ago

Not reproducing on 0.147.0 (macOS, bash): a deny for apply_patch is enforced. Offering a hypothesis for the "hook ran, write proceeded" observation.

I hit this thread while verifying a linter's Codex adapter and ran the deny path deliberately. Reporting the negative result, since a version data point is worth having either way, and one detail in the original report may point at a different cause.

Environment

  • codex-cli 0.147.0, macOS 26.5.2, arm64, bash
  • Throwaway CODEX_HOME, fresh git init, everything committed
  • codex exec --approve-for-me --skip-git-repo-check --dangerously-bypass-hook-trust
  • Project trusted via [projects."<path>"] trust_level = "trusted"

The hook

A single-purpose script, so nothing of my own is in the way. It reads stdin, writes one object, exits 0:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"DENY-SENTINEL-9F2A refused by policy"}}

Registered on PreToolUse with matcher apply_patch|Write|Edit.

Result

The patch was blocked, twice:

hook: PreToolUse
hook: PreToolUse Blocked

and the model was told:

Script error:
Command blocked by PreToolUse hook: DENY-SENTINEL-9F2A refused by policy. Command: *** Begin Patch ...

The part that might explain the original report

The file still ended up with the content the hook denied. Not because the deny was ignored, but because the model worked around it. After the second block it stopped using apply_patch and ran this instead:

perl -0pi -e '$_ = "We leverage this approach.\n"' notes.md

My sentinel matched apply_patch|Write|Edit, not the shell, so nothing stopped that. Checking the file afterwards looks exactly like "deny was not enforced". The distinguishing evidence is the rollout: two Blocked hook runs, then a shell call.

A second possibility, from the status word

The original report says the transcript shows the hook "reports Failed". On this version Failed and Blocked are different outcomes:

| Reply | Status | Effect |
|---|---|---|
| permissionDecision: "deny" with a non-empty reason | Blocked | call refused |
| permissionDecision: "ask" | Failed | call proceeds, reason reaches nobody |
| permissionDecision: "allow" | Failed | same |
| malformed or extra keys | Failed | same |

I confirmed the ask row separately with the same harness. The binary carries matching strings, including PreToolUse hook returned unsupported permissionDecision:ask and PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason. Both hook output schemas are additionalProperties: false, so one unexpected key discards the whole reply.

So a Failed status is worth reading as "the reply was rejected" rather than "the deny was ignored". Given the report's hook is a pwsh -NoProfile -Command wrapper, anything that wrapper prints on stdout ahead of the JSON would do it.

Happy to re-run any variation on 0.147.0, including the exit-code-2 channel, which I did not test.