PreToolUse updatedInput is displayed but not executed under named permission profiles (app-server/Desktop): sandbox spawns the original command

Resolved 💬 0 comments Opened Jul 12, 2026 by BenNageris Closed Jul 12, 2026

Summary

When a thread uses a named permissions profile (permissions: ":workspace" or ":read-only" via app-server — the path the ChatGPT Desktop app uses for every non-full-access thread), a PreToolUse hook's updatedInput rewrite is applied to the tool-call record (the item event and UI show the rewritten command), but the sandbox spawns the original command. The rewrite is silently not executed.

Without the permissions parameter (plain sandbox: "workspace-write"), the identical run executes the rewritten command correctly — same binary, same hooks, same model.

Environment

  • codex-cli 0.144.0-alpha.4 (binary bundled with ChatGPT Desktop for macOS, also reproduced driving codex app-server directly)
  • macOS 26.5 (arm64)
  • Hook: global ~/.codex/hooks.json PreToolUse (matcher Bash) returning {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": {"command": "<wrapper cmd>"}}}

Reproduction

Any trusted PreToolUse hook that rewrites the command reproduces it. Ours rewrites ls -la /tmp into a wrapper (... poco run-plan) that takes ≥0.38 s to boot, which makes the bypass measurable via wall time.

Drive codex app-server over stdio (JSONL), with capabilities: {"experimentalApi": true} in initialize:

  1. thread/start with {"cwd": ..., "approvalPolicy": "on-request", "sandbox": "workspace-write"}

turn/start with input Run exactly: ls -la /tmp
rewritten command executes (output is the wrapper's; Wall time: 0.45–0.72 s).

  1. Same, but thread/start with {"cwd": ..., "permissions": ":workspace"} (no sandbox)

item/started shows the rewritten command, but the output is the raw ls output and Wall time: 0.026–0.056 s — physically impossible for the wrapper (its interpreter alone needs ≥0.38 s to start). The wrapper leaves no trace (no log line, no side effects). The original command ran.

":read-only" behaves like ":workspace". ":danger-full-access" executes the rewrite. The escalated path also executes the rewrite: when the model retries with sandbox_permissions: "require_escalated" and the command is approved, the rewritten command runs (this is how we first noticed the asymmetry — in one Desktop session, sandboxed commands ran un-rewritten while escalated ones ran rewritten).

Also reproduced end-to-end in the ChatGPT Desktop app (originator Codex Desktop, default "Agent" mode): rollout transcript shows exec_command ls -la /tmp completing in 0.026 s with raw output while the PreToolUse hook logged that it emitted the rewrite.

For comparison, all of these DO execute the rewrite on the same binary: codex exec --sandbox workspace-write, same + features.unified_exec=true, app-server without permissions (incl. approvalsReviewer: "auto_review", features.code_mode_host=true, workdir set).

Expected

Either the rewritten command is executed inside the sandbox (as on the non-profile path), or — if rewrites are intentionally unsupported under named permission profiles — the hook outcome should be reported as failed/ignored and the item event should show the command that actually ran. Displaying the rewritten command while running the original is the worst combination.

Why this matters

  • It is fully silent: the UI and the item record claim the rewritten command ran.
  • It affects every ChatGPT Desktop thread except full-access mode (Desktop always sets a managed permissions profile).
  • Security angle: PreToolUse rewrites used to sanitize/neuter commands are silently bypassed on exactly the path (sandboxed managed profile) where users most expect enforcement. This looks like a sibling of #27833 (deny not enforced for apply_patch) and #20204 (inconsistent hook coverage) — hook outputs enforced on some execution paths but not others.

Notes from source reading (0.144.0-alpha.4 tag)

tools/registry.rs applies PreToolUseHookResult::Continue { updated_input } via tool.with_updated_hook_input(...) unconditionally, and unified_exec/exec_command.rs implements with_updated_hook_input (rewrites cmd). So the mutation lands on the invocation — the divergence appears to be downstream, where the permissions-profile spawn path derives the command to execute (the original cmd survives somewhere along ExecCommandRequest/exec-policy/spawn under a managed profile). We did not pinpoint the exact line.

A secondary observation while debugging: with a named permissions profile active, [sandbox_workspace_write].writable_roots from config.toml is not merged into the effective sandbox (it is merged on the plain sandbox: "workspace-write" path). If that is by design, fine — mentioning it since it compounds the above for hooks whose wrappers need those roots.

View original on GitHub ↗