unified-exec exec_command: Bash PreToolUse tool_input drops the honored per-call workdir, so hooks cannot attribute the execution root
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 72/100
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
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.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.5k
- Avg merge
- 1m
- Merged PRs (30d)
- 1k
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from openai/codex
-
enhancement remote
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
bug CLI windows-os
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
macOS sandbox blocks hw.optional.arm64 sysctl, causing Flutter to misdetect Apple Silicon as x64 Openbug CLI sandbox
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
bug CLI TUI
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
-
CLI config enhancement skills
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
kwakseongjae/auto-hwp#319 ·
-
area:cli bug filter-quality good first issue priority:medium
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
bevyengine/bevy#25861 ·
-
comp-datalake
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ClickHouse/ClickHouse#121222 ·
-
A-linter
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
oxc-project/oxc#26863 ·