PreToolUse deny (exit 2 + permissionDecision JSON) is not enforced for apply_patch - hook fires, write proceeds
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
- Project
.codex/hooks.json(trusted;[features].hooks = trueinconfig.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
}
]
}
]
}
}
- 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.
- 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).
- 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.
3 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
## Additional reproduction: same non-enforcement, but for
shell_command(Codex Desktop), and confirmed with an unconditional-deny hookI hit the same underlying bug independently while auditing whether my
~/.codex/hooks.jsonPreToolUse guard is actually enforced, and can add a data point that rules out a couple of alternate explanations (payload shape, matcher mismatch).Environment
26.623.101652(Windows 11), bundledcodex.exeversion26.623.13972.0~/.codex/hooks.json(not project-scoped), hook trusted via Settings → Hooks (confirmedtrusted_hashrecorded inconfig.tomlunder[hooks.state])PreToolUsematcher:"Bash|apply_patch"shell_command(notBash)What I confirmed
app-server event: hook/started/hook/completedshow up in~/.codex/logs_2.sqlitefor everyshell_commandcall, so PreToolUse dispatch itself is working.denydecision is never enforced, regardless of cause. To isolate this from payload-shape/matcher issues, I replaced my hook script with one that unconditionally returnsdenyfor every invocation, independent oftool_nameor command content:``
python
`import json
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "UNCONDITIONAL TEST BLOCK"}}))
shell_commandI recomputed the SHA256 for this script version (so it would be treated as a distinct, already-trusted revision) and then ran a plain
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.matcher: "Bash|apply_patch"never matches the tool name Codex actually passes for shell calls (shell_command, notBash) — 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 ignorestool_name/tool_inputentirely and always saysdenydoes 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,
Failedstatus shown, but tool call still succeeds" pattern described in this issue, just viashell_commandinstead ofapply_patch, and via Desktop rather than CLI. Givenapply_patchandshell_commandare 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/completedrows) if useful for repro/triage.Not reproducing on 0.147.0 (macOS, bash): a
denyforapply_patchis 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, bashCODEX_HOME, freshgit init, everything committedcodex exec --approve-for-me --skip-git-repo-check --dangerously-bypass-hook-trust[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:
Registered on
PreToolUsewith matcherapply_patch|Write|Edit.Result
The patch was blocked, twice:
and the model was told:
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_patchand ran this instead: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: twoBlockedhook 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 versionFailedandBlockedare 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
askrow separately with the same harness. The binary carries matching strings, includingPreToolUse hook returned unsupported permissionDecision:askandPreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason. Both hook output schemas areadditionalProperties: false, so one unexpected key discards the whole reply.So a
Failedstatus is worth reading as "the reply was rejected" rather than "the deny was ignored". Given the report's hook is apwsh -NoProfile -Commandwrapper, 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.