unified-exec exec_command: Bash PreToolUse tool_input drops the honored per-call workdir, so hooks cannot attribute the execution root

Open 💬 0 comments Opened Jul 18, 2026 by duhlink

What version of Codex are you using?

codex-cli 0.144.5 (Homebrew, macOS arm64). The behavior is unchanged on current main as of 2026-07-18.

What platform is your computer?

macOS (Apple Silicon). The projection lives in codex-rs/core, so this is platform-independent.

What issue are you seeing?

When the model calls unified-exec exec_command with a per-call workdir, the Bash PreToolUse hook payload drops that field, even though execution honors it.

In codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs, ExecCommandHandler::pre_tool_use_payload parses ExecCommandArgs and projects only the command:

parse_arguments::<ExecCommandArgs>(arguments)
    .ok()
    .map(|args| PreToolUsePayload {
        tool_name: HookToolName::bash(),
        tool_input: serde_json::json!({ "command": args.cmd }),
    })

while handle_call in the same handler separately parses ExecCommandEnvironmentArgs, resolves workdir against the selected environment's cwd, and executes there:

let cwd = environment_args
    .workdir
    .as_deref()
    .filter(|workdir| !workdir.is_empty())
    .map_or_else(
        || Ok(native_environment_cwd.clone()),
        |workdir| native_environment_cwd.join(workdir),
    )

So two exec_command calls with identical cmd but different workdir values deliver byte-identical tool_input to the hook while running in different directories. The hook envelope's top-level cwd is the session working directory (per the Hooks docs), not the per-call execution root, so a hook cannot reconstruct where the command will actually run. The command-only projection is regression-locked by exec_command_pre_tool_use_payload_uses_raw_command in codex-rs/core/src/tools/handlers/unified_exec_tests.rs.

Authorization impact: in multi-worktree repositories where a PreToolUse hook enforces "mutations happen only in the assigned task worktree", a relative mutation (e.g. git add ., a formatter -w run) with workdir=<task worktree> is observationally identical to the same command running in the primary checkout or a foreign directory. A strict hook must fail closed and deny legitimate worktree work; a permissive hook silently misattributes the execution root and fails open.

I verified this with paired read-only probes on 0.144.5: two exec_command calls, both cmd: "pwd", one with the task-worktree workdir and one with the primary-checkout workdir. Both hook deliveries carried tool_input whose SHA-256 matches canonical {"command":"pwd"} exactly (proving no other distinguishing field was present), while the captured outputs showed the two distinct physical directories.

What steps can reproduce the bug?

  1. Configure a project PreToolUse hook for bash that logs the raw tool_input it receives.
  2. In a session, have the model run exec_command with cmd: "pwd" and no workdir, then again with an explicit workdir pointing at a subdirectory or another worktree.
  3. Observe that both hook deliveries carry identical tool_input ({"command":"pwd"}) and identical top-level cwd, while the tool outputs show different directories.

What is the expected behavior?

Bash PreToolUse tool_input should carry a trustworthy per-call workdir. Three properties matter for authorization-grade consumers:

  1. Explicit vs absent stays distinguishable. Include workdir only when the call supplied a non-empty one; omit it otherwise, so hooks can tell "no per-call workdir — environment cwd applies" apart from "explicit workdir".
  2. Raw vs resolved semantics are defined. The raw requested workdir may be relative and is resolved against the selected environment's cwd — which is not necessarily the session cwd the hook envelope carries. Exposing the effective resolved cwd (the value handle_call computes) is the stronger contract; if only the raw field is exposed, please document which base it resolves against.
  3. Read-only is sufficient. Hook input rewriting currently only rewrites command (with_updated_hook_input), so adding the field does not need to grant hooks a workdir-rewrite capability.

Minimal seam sketch for the raw-field variant (the resolved-cwd variant would instead thread the selected environment cwd into the payload — either works for consumers as long as the semantics are documented):

fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
    let ToolPayload::Function { arguments } = &invocation.payload else {
        return None;
    };
    let args = parse_arguments::<ExecCommandArgs>(arguments).ok()?;
    let workdir = parse_arguments::<ExecCommandEnvironmentArgs>(arguments)
        .ok()
        .and_then(|env| env.workdir)
        .filter(|workdir| !workdir.is_empty());
    let mut tool_input = serde_json::json!({ "command": args.cmd });
    if let Some(workdir) = workdir {
        tool_input["workdir"] = serde_json::Value::String(workdir);
    }
    Some(PreToolUsePayload {
        tool_name: HookToolName::bash(),
        tool_input,
    })
}

plus updating exec_command_pre_tool_use_payload_uses_raw_command and adding a case asserting the field is omitted when no workdir was supplied.

Related

#20879 reports the same class of gap for native apply_patch (no per-call workdir context for hooks). This issue is specifically about unified-exec exec_command, where a per-call workdir already exists and is honored by execution but is dropped from the hook projection.

View original on GitHub ↗