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

Open
#33,986 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
72/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
rust
Domain
security, tooling

Research direction

Start in codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs, comparing pre_tool_use_payload with handle_call's workdir handling. Read the regression test exec_command_pre_tool_use_payload_uses_raw_command in codex-rs/core/src/tools/handlers/unified_exec_tests.rs, then update coverage so explicit non-empty workdir information is represented with defined semantics and absent workdir remains omitted.

Written by the indexing model from the issue text.

Description

bug CLI exec hooks
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.

Dominant language
Rust
Stars
125k
Forks
19.5k
Avg merge
1m
Merged PRs (30d)
1k

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from openai/codex

All issues in openai/codex

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.