PreToolUse updatedInput is displayed but not executed under named permission profiles (app-server/Desktop): sandbox spawns the original command
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-serverdirectly) - macOS 26.5 (arm64)
- Hook: global
~/.codex/hooks.jsonPreToolUse (matcherBash) 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:
thread/startwith{"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).
- Same, but
thread/startwith{"cwd": ..., "permissions": ":workspace"}(nosandbox)
→ 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.