Code Mode `exec` doesn't fire `PreToolUse` hooks (fix patch attached)

Open 💬 10 comments Opened May 19, 2026 by Kaspre

Why

Code Mode's freeform exec tool — the JavaScript-execution surface in codex-rs/core/src/tools/code_mode/ — is not emitting PreToolUse hook events. This is the same bug class that #18391 fixed for apply_patch: a freeform tool whose handler doesn't opt into pre_tool_use_payload is silently invisible to the hook runtime.

Concretely, CodeModeExecuteHandler::pre_tool_use_payload is unimplemented and inherits the default None from ToolHandler, so tools::registry::run_pre_tool_use_hooks is never invoked for Code Mode dispatches. Hook-based policy, auditing, and write coordination observe shell commands (Bash) and apply_patch edits but miss every Code Mode execution — including the nested tool calls those executions issue via tools.exec_command, tools.apply_patch, and friends.

For users running Code Mode behind hook-based sandboxing or access control, this means policy enforcement that appears to be in effect for shell tools is silently bypassed whenever the model routes the same intent through a Code Mode dispatch.

Reproduction

Verified on rust-v0.130.0. Probe a model in Codex via any client with hooks configured (e.g. via Openclaw's @openclaw/codex plugin); request a tool call. The model often routes through Code Mode exec (a custom_tool_call with name: "exec", input = raw JavaScript). The configured PreToolUse hook command is never invoked. Direct function_call: exec_command dispatches DO fire the hook correctly, confirming the bug is specifically that Code Mode's handler doesn't opt in.

Proposed Fix

Branch ready in fork: https://github.com/Kaspre/codex/tree/fix/code-mode-pretooluse-hook
Compare URL (for direct review): https://github.com/openai/codex/compare/main...Kaspre:codex:fix/code-mode-pretooluse-hook

What Changed

  • Added PreToolUse payload support to CodeModeExecuteHandler via pre_tool_use_payload, extracting the raw JavaScript source from ToolPayload::Custom { input } and exposing it as tool_input.command.
  • Added HookToolName::code_mode_exec() modeled on HookToolName::apply_patch() — canonical hook name code_mode_exec (distinct in logs and policies from Bash shell tools), with exec accepted as a matcher alias for hook configurations written against the model-facing tool name.

The tool_input.command field name matches the existing convention shared by apply_patch, shell_command, and unified_exec — all carry "the thing to execute" under command regardless of whether the underlying input is shell text, patch text, or (with this change) JavaScript source. This lets a single hook policy expression apply uniformly across execution surfaces.

PostToolUse payload support is intentionally out of scope for this change — happy to add it as a follow-up if maintainers want symmetric coverage with apply_patch.

Verification

Added focused handler coverage in code_mode/execute_handler_tests.rs:

  • Freeform Code Mode (ToolPayload::Custom) dispatches produce a PreToolUsePayload whose tool_name is code_mode_exec and whose tool_input.command matches the raw JavaScript source.
  • Non-Custom payloads return None (defensive — the registry's matches_kind already gates this, but the handler shouldn't assume).

The pre-existing execute_handler_tests.rs in this directory was orphaned (#[path] declaration removed; references a parse_freeform_args function that no longer exists) and has been replaced. Wired in via the standard #[cfg(test)] #[path = "execute_handler_tests.rs"] mod tests; declaration at the bottom of execute_handler.rs, matching the convention used by apply_patch.rs and shell.rs.

Filing notes

External PR creation appears to be staff-restricted on this repo (gh pr create returns does not have the correct permissions to execute CreatePullRequest). Filing as an issue with the patch attached as a fork-branch ref for cherry-pick. Happy to convert this to a PR if a maintainer can open it on my behalf, or to follow whatever the preferred contribution flow is.

View original on GitHub ↗

10 Comments

Kaspre · 3 months ago

Reopening — on reflection, while #20204 documents the same root cause (handlers not opting into pre_tool_use_payload), Code Mode exec is NOT in the 8-handler list that #20204 covers (list_dir, view_image, mcp_resource, plan, goal, agent_jobs, tool_search, tool_suggest). The fix in this issue is additive to #20204, not a duplicate.

If maintainers prefer to consolidate, this issue's 3-file diff at https://github.com/Kaspre/codex/tree/fix/code-mode-pretooluse-hook can be cherry-picked alongside #20204's broader 8-handler patch, or folded into it as an extra commit. Code Mode exec is a particularly impactful handler to cover since it's the freeform JavaScript execution surface — a model dispatching nested tools through Code Mode silently bypasses any hook-based policy/audit/sandboxing that's in place for the direct shell/edit paths.

(Cross-comment also posted on #20204.)

Kaspre · 2 months ago

Confirmed still present as of 0.135 — CodeModeExecuteHandler still doesn't implement pre_tool_use_payload, so Code Mode exec dispatches stay invisible to PreToolUse hooks while shell / apply_patch are covered. (Re-verified end-to-end: a hook-denied read routed through Code Mode exec still succeeds, while the same read via shell is correctly blocked.)

I've rebased the fix onto 0.135 and expanded it:

  • pre_tool_use_payload on CodeModeExecuteHandler (canonical hook name code_mode_exec, with exec as a matcher alias), modeled on the merged apply_patch precedent (#18391).
  • with_updated_hook_input so a hook's updatedInput.command rewrites the freeform JS before execution (non-string command rejected with a model-visible error) — the Code Mode side of #18491.
  • Handler unit tests + suite-level registry-path tests (successful rewrite; invalid updatedInput failing before execution). cargo test -p codex-core green; cargo fmt --check clean.

Updated branch: https://github.com/Kaspre/codex/tree/rebase/code-mode-pretooluse-0.135

(Rebased onto 0.135 for local deployment; the apply_patch infrastructure it mirrors is already on main, so it ports directly — happy to provide a main-based branch if that's easier to merge.)

I still can't open a PR (CreatePullRequest is staff-restricted for me on this repo) — could a maintainer pick this up, or open one on my behalf?

juupero · 17 days ago

Confirmed this is still reproducible on Windows 11 with Codex Desktop / codex-cli 0.147.0.

Setup:

  • trusted project-local PreToolUse command hook
  • matcher covers ^(Bash|shell_command)$
  • the hook's command-level regression suite passes and the known-invalid payload exits 2 when sent directly to the configured hook launcher

Code-mode reproduction:

rg -n -e 'validate-shell-command' .codex/hooks/*.ps1

When dispatched as a nested shell tool from Code Mode, the command reached rg and failed with Windows OS error 123. The expected PreToolUse rejection did not occur. The same command payload is rejected by the hook itself because positional wildcard paths must use -g/--glob.

This confirms the gap affects nested Code Mode tool dispatch, not matcher syntax or the policy script. It also appears inconsistent with the current Hooks documentation section "Tool calls from code mode," which says PreToolUse can stop the nested tool before it runs: https://learn.chatgpt.com/docs/hooks#tool-calls-from-code-mode

baitangxiao · 17 days ago

Additional evidence from the current macOS Codex Desktop build:

  • Codex Desktop 26.803.61601 (build 6396)
  • Bundled codex-cli 0.147.0-alpha.6.5
  • This build still exposes functions.exec / Code Mode exec without equivalent, verified deny-capable PreToolUse coverage for the outer exec path or every nested tool dispatch.
  • The reachable nested inventory in this run contained 190 tools, including file writes, shell execution, Goal/Automation control-plane mutations, connector writes, network/handoff actions, and plugin/settings mutations.
  • code_mode=false while code_mode_host=true and functions.exec remains reachable. Therefore, code_mode=false is not evidence that this side-effect surface is physically disabled.
  • The host supports real decision:block / deny semantics on covered hook paths, but equivalent coverage could not be verified for functions.exec.

Impact: while this path remains reachable, a hook-based global fail-closed Implementation Gate cannot establish the invariant “no valid implementation authorization means no real side effect.”

Related in-product feedback reference:
019ff063-2411-7313-a5a8-1075c60e04bd

No session transcript, private files, local paths, credentials, browsing history, or unrelated diagnostics are attached.

deksden · 13 days ago

codex app 26.810 - bug still present!

openai, please fix!

Pappmann · 11 days ago

Additional reproduction of the same hook-coverage problem on Linux with codex-cli 0.147.0:

A user-level ~/.codex/hooks.json contains UserPromptSubmit, Stop, and PostToolUse command hooks. UserPromptSubmit and Stop are delivered, but PostToolUse is not delivered when a successful git push is routed through the current Code Mode / nested exec path. The push completes and reaches the remote, but the downstream hook integration receives no PostToolUse payload and therefore cannot detect the push.

The outer tool call is exposed as functions.exec, while the nested shell operation is exec_command. The configured matcher covers exec_command|functions.exec_command, but no PostToolUse receipt is produced for this execution path. The same integration does receive push events from another client/runtime path, so this does not look like a hook-script or remote-service failure.

This seems closely related to the Code Mode coverage gap described here. In particular, PostToolUse coverage for the outer Code Mode exec and its nested tool calls appears to be missing. A minimal sanitized reproducer can be provided if useful.

gabrafur · 3 days ago
I can reproduce a related problem with `PostToolUse` when Codex runs through Code Mode.

### Environment

- VS Code extension: `openai.chatgpt-26.818.61809-linux-arm64`
- Bundled Codex: `0.149.0-alpha.4.3`
- CLI inside Docker: `codex 0.149.1`
- Platform: Linux ARM64
- App server starts with `-c features.code_mode_host=true`

### Hook configuration

I have a project-level `.codex/hooks.json` containing a synchronous `PostToolUse` hook with matcher `Bash`.

The `/hooks` screen reports:

```text
PostToolUse    Installed 1    Active 1

The project is trusted, and the hook definition itself is trusted. The hook script and its dependencies are accessible from the corresponding client environment.

VS Code / Code Mode behavior

The model calls functions.exec, which invokes tools.exec_command(...) from JavaScript.

A command producing approximately 16,300 characters completes successfully with exit code 0. However, the nested exec_command result reaches the JavaScript caller unchanged. The configured PostToolUse hook does not replace the raw result, and none of the hook-provided metadata is present.

In my application, HOOK_NOT_ACTIVE is only a diagnostic marker meaning that the expected canonical hook output was absent; it is not a Codex error message.

Control test

Calling the hook script directly with an equivalent synthetic PostToolUse payload succeeds. It returns:

  • continue: false
  • hookSpecificOutput.hookEventName = "PostToolUse"
  • non-empty replacement context

This indicates that the hook script and its downstream runtime are working. The failure appears to be in lifecycle dispatch or delivery for the Code Mode tool path.

Attempted direct-shell workaround

I also tried starting the interactive CLI with:

codex \
  --disable code_mode \
  --disable code_mode_host \
  --disable code_mode_only \
  --enable shell_tool \
  --enable unified_exec

The effective feature list reports:

code_mode         false
code_mode_host    false
code_mode_only    false
shell_tool        true
unified_exec      true
hooks             true

Nevertheless, the CLI reports:

Code Mode is unavailable because code-mode host is disabled.
Code mode will fail closed.

The model then does not invoke a Bash or unified-exec tool at all. It responds without making a tool call, so there is no PostToolUse event.

Expected behavior

At least one of these paths should work:

  1. Nested tools.exec_command(...) calls from Code Mode should emit a usable PostToolUse event whose feedback can replace the nested result; or
  2. When Code Mode is disabled while shell_tool and unified_exec remain enabled, the model should be able to use the direct shell tool instead of failing closed.

Questions

  • Is PostToolUse currently expected to fire for tools.exec_command(...) invoked inside functions.exec?
  • Is direct shell fallback supported when code_mode_host is disabled?
  • If neither path is currently supported, could the documentation explicitly identify this limitation?

I can provide a minimal synthetic reproduction if useful.

Frankla20 · 3 days ago

Managed PreToolUse deny is ignored by code-mode nested exec on Windows

Environment

  • Windows 11, build 10.0.26200.8246
  • Codex App package 26.818.8289.0
  • Codex CLI 0.149.0-alpha.4.3
  • Local code-mode execution
  • Managed configuration loaded from %ProgramData%\OpenAI\Codex\requirements.toml

Summary

A managed PreToolUse command hook receives a shell event and returns the documented JSON deny response, but a command issued through functions.exec and its nested exec_command still executes.

The desktop log records the sequence hook/started → hook/completed → commandExecution. A controlled negative test then produces its filesystem marker, proving that the command ran after the deny response.

The hook itself is not failing to classify the command: the same implementation passes direct protocol tests for the exact wrapper command, malformed-input tests, nearest-neighbor allow cases, and four concurrent invocations.

Minimal managed configuration

allow_managed_hooks_only = true

[features]
hooks = true

[hooks]
windows_managed_dir = 'C:\managed-hooks'

[[hooks.PreToolUse]]
matcher = "^Bash$"

[[hooks.PreToolUse.hooks]]
type = "command"
command = '"C:\Program Files\nodejs\node.exe" "C:\managed-hooks\managed_powershell_hook.mjs"'
command_windows = '"C:\Program Files\nodejs\node.exe" "C:\managed-hooks\managed_powershell_hook.mjs"'
timeout = 10

Minimal deny response

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked unmanaged PowerShell invocation"
  }
}

Steps to reproduce

  1. Enable the managed hook above.
  2. Configure the hook to return the deny response for the command below.
  3. Create C:\repro\write-marker.ps1 so that it writes a harmless marker file.
  4. Issue this command through a code-mode functions.exec cell:

``text
cmd.exe /d /c powershell.exe -NoProfile -File C:\repro\write-marker.ps1
``

  1. Observe that the hook starts and completes, but the marker file is still created.

Expected behavior

The nested command must not be started after a managed PreToolUse hook returns permissionDecision: "deny".

Actual behavior

The deny decision is not propagated through the code-mode nested exec adapter, and the command executes.

Impact

Administrators cannot enforce a managed PowerShell entry point for code-mode commands. Instructions and post-execution auditing are currently the only controls that remain usable without breaking the host.

Related policy limitation

Restrictive requirements.toml prefix rules cannot safely replace the hook:

  • forbidding pwsh or pwsh.exe blocks the bundled shell used by Codex code mode;
  • forbidding powershell or powershell.exe also blocks an approved wrapper when that wrapper legitimately launches Windows PowerShell;
  • restrictive rules have no parent-command or managed-wrapper exception;
  • disabling the rules requires omitting the [rules] table; an empty prefix_rules array is rejected by the configuration validator.

Requested fix

  1. Propagate managed PreToolUse deny decisions through functions.exec → nested exec_command.
  2. Add a regression test covering this code-mode path on Windows.
  3. Expose the effective deny decision in command-execution diagnostics.
  4. Optionally provide an administrator-supported setting to disable built-in code-mode exec or route it exclusively through a managed execution broker.

Evidence available through /feedback

The corresponding /feedback submission includes the Codex diagnostic logs. Public attachments intentionally omit full local paths, task content, credentials, and unrelated log records.

JulienJBO · 2 days ago

Implementation update for the remaining outer Code Mode gap:

I audited current upstream main and confirmed that the outer freeform Code Mode exec call still uses ToolPayload::Custom, while the generic PreToolUse path only handles function payloads. Nested Code Mode tool calls are a separate path and already have coverage from #28365.

A current reference implementation is now available here:

The patch:

  • emits PreToolUse for the outer Code Mode exec call with canonical hook name code_mode_exec and matcher alias exec;
  • maps the freeform source to tool_input.command;
  • applies validated updatedInput.command rewrites before execution;
  • adds deny, rewrite, invalid-rewrite, alias, and deny-before-execution marker coverage.

Fresh local verification:

  • cargo fmt --all -- --check: pass
  • git diff --check: pass
  • focused unit tests: 5 passed, 0 failed

The new integration tests are included, but I cannot honestly claim a local green integration run on this macOS ARM environment. The checked-out upstream base currently has two independent stale field accesses in core/src/skills.rs that prevent compilation; I temporarily corrected only those two lines to isolate the focused unit tests and then reverted them. Building codex-code-mode-host is additionally blocked because the rusty_v8 v150.4.0 macOS ARM prebuilt archive URL returns HTTP 404. An existing Code Mode integration test fails under the same missing-host condition.

Credit to the earlier Kaspre reference branches linked in this issue; this version adapts that direction to the current runtime API and adds current regression coverage.

I did not open an upstream PR because the repository contribution policy explicitly says external code contributions and pull requests are not accepted. The fork branch is provided as reviewable/cherry-pickable evidence instead.

rafatairnz · 1 day ago

We reproduced the downstream SDLC impact in Codex Desktop.

Env: macOS 26.2 (build 25C56), Apple Silicon (arm64).

Setup:

  • User-level PreToolUse and PostToolUse hooks were enabled.
  • Code Mode ran nested edit and command calls.
  • The file change and normal Git commit completed.
  • The hook handler passed independent self-checks.

Result:

  • The expected audit and provenance record was absent.
  • Codex did not report that the lifecycle evidence was incomplete.

This matches the impact described in this issue. We did not capture Codex hook-dispatch logs, so this confirms the observed impact, not the source-level cause.

@JulienJBO proposed patch covers PreToolUse only. Our workflow requires matched PreToolUse and PostToolUse events. Without PostToolUse, there is no reliable completion or status boundary.

Could the production fix include:

  • Matched Pre/Post events for Code Mode and nested calls.
  • Stable correlation between the outer call and nested calls.
  • The effective cwd, worktree root, and terminal status.
  • An end-to-end linked-worktree edit test that verifies the complete event sequence.

If Codex cannot emit a complete event pair, it should provide a visible diagnostic instead of silently continuing.