unified-exec exec_command: Bash PreToolUse tool_input drops the honored per-call workdir, so hooks cannot attribute the execution root
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?
- Configure a project
PreToolUsehook forbashthat logs the rawtool_inputit receives. - In a session, have the model run
exec_commandwithcmd: "pwd"and noworkdir, then again with an explicitworkdirpointing at a subdirectory or another worktree. - Observe that both hook deliveries carry identical
tool_input({"command":"pwd"}) and identical top-levelcwd, 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:
- Explicit vs absent stays distinguishable. Include
workdironly 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". - Raw vs resolved semantics are defined. The raw requested
workdirmay be relative and is resolved against the selected environment's cwd — which is not necessarily the sessioncwdthe hook envelope carries. Exposing the effective resolved cwd (the valuehandle_callcomputes) is the stronger contract; if only the raw field is exposed, please document which base it resolves against. - 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.