From fe15bd38def0fddabf9b54ab0ccc86e47af4b77c Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Fri, 23 Oct 2026 23:59:12 +0000 Subject: [PATCH] Report the local command working directory in PreToolUse hooks (#57198) ## Why `PreToolUse` hooks receive the host working directory even when a local `exec_command` call specifies a different `workdir`. This prevents hooks from seeing the directory in which the command will execute. ## What changed - Share working-directory resolution between command execution and hook input, using the selected environment and the command's `workdir`. - Report the resolved local directory in the hook input's `cwd`. Keep hook processes in the host directory and retain the host `cwd` for remote commands. - Reject invalid environment selections or paths before running hooks and record failures in tool-result telemetry. ## Testing Add unit and integration coverage for default, empty, relative, and absolute working directories; Windows path conventions; remote commands; hook denial and input rewriting; and invalid arguments that fail without running hooks. GitOrigin-RevId: 97f71f6d3e21a5d95a7abf5384f06f4993241797 --- codex-rs/core/src/hook_runtime.rs | 2 + .../core/src/tools/handlers/unified_exec.rs | 16 ++ .../handlers/unified_exec/exec_command.rs | 32 ++- .../src/tools/handlers/unified_exec_tests.rs | 73 ++++++ codex-rs/core/src/tools/registry.rs | 27 +++ codex-rs/core/tests/suite/hooks.rs | 4 + codex-rs/core/tests/suite/hooks_cwd_tests.rs | 215 ++++++++++++++++++ codex-rs/hooks/src/engine/mod_tests.rs | 9 + codex-rs/hooks/src/events/pre_tool_use.rs | 30 ++- 9 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/tests/suite/hooks_cwd_tests.rs diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index e03e9861ea..bea5f8cae9 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -196,6 +196,7 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_use_id: String, tool_name: &HookToolName, tool_input: &Value, + tool_cwd: Option, target_environments: &[&str], ) -> PreToolUseHookResult { let turn_context = &step_context.turn; @@ -204,6 +205,7 @@ pub(crate) async fn run_pre_tool_use_hooks( turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), cwd: tool_hook_cwd(&step_context.environments, turn_context), + tool_cwd, transcript_path: sess.hook_transcript_path().await, model: step_context.settings.model_info.slug.clone(), permission_mode: hook_permission_mode(step_context.settings.approval_policy()), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 50faf5259a..3448b427f0 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,6 @@ use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; +use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; use crate::shell::ShellInvocation; use crate::shell::get_shell_by_model_provided_path; @@ -12,6 +13,7 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use codex_protocol::models::AdditionalPermissionProfile; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use serde::Deserialize; use std::path::PathBuf; @@ -93,6 +95,20 @@ struct ExecCommandWorkdirArgs { workdir: Option, } +impl ExecCommandWorkdirArgs { + /// Share cwd resolution between hooks and execution after environment selection. + fn resolve_cwd(&self, environment: &TurnEnvironment) -> Result { + self.workdir + .as_deref() + .filter(|workdir| !workdir.is_empty()) + .map_or_else( + || Ok(environment.cwd().clone()), + |workdir| environment.cwd().join(workdir), + ) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) + } +} + fn default_exec_yield_time_ms() -> u64 { 10_000 } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 1a188c90a1..e4dbf146e9 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -47,6 +47,7 @@ use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use codex_utils_string::truncate_middle_chars; use super::super::shell_spec::CommandToolOptions; @@ -183,16 +184,7 @@ impl ExecCommandHandler { ); let workdir_args: ExecCommandWorkdirArgs = parse_arguments(&arguments)?; let turn_environment = selected_executor(&environments, &step_context.environments)?; - let native_environment_cwd = turn_environment.cwd().clone(); - let cwd = workdir_args - .workdir - .as_deref() - .filter(|workdir| !workdir.is_empty()) - .map_or_else( - || Ok(native_environment_cwd.clone()), - |workdir| native_environment_cwd.join(workdir), - ) - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let cwd = workdir_args.resolve_cwd(turn_environment)?; let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); @@ -408,7 +400,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: native_environment_cwd, + sandbox_cwd: turn_environment.cwd().clone(), turn_environment: turn_environment.clone(), network: context.step_context.turn.network.clone(), tty, @@ -522,6 +514,24 @@ impl CoreToolRuntime for ExecCommandHandler { }) } + fn pre_tool_use_cwd( + &self, + invocation: &ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolPayload::Function { arguments } = &invocation.payload else { + return Ok(None); + }; + let args: ExecCommandWorkdirArgs = parse_arguments(arguments)?; + let environment = selected_executor( + &invocation.environments, + &invocation.step_context.environments, + )?; + let cwd = args.resolve_cwd(environment)?; + // Hook scripts execute on the host. Remote command paths cannot replace + // the host cwd in hook input, even when both machines use the same OS. + Ok((!environment.environment.is_remote()).then_some(cwd)) + } + fn with_updated_hook_input( &self, mut invocation: ToolInvocation, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index ee8b481bba..d1a76fad95 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -489,6 +489,79 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { ); } +#[tokio::test] +async fn exec_command_pre_tool_use_cwd_resolves_selected_environment() { + let (session, mut turn) = make_session_and_context().await; + let mut selected = turn.initial_environments.environments[0].clone(); + let TurnEnvironmentState::Ready(environment) = &mut selected else { + panic!("ready environment"); + }; + environment.selection.environment_id = "other".to_string(); + environment.selection.cwd = PathUri::parse("file:///C:/repo-B").unwrap(); + turn.initial_environments.environments.push(selected); + let turn = Arc::new(turn); + let mut invocation = ToolInvocation { + session: session.into(), + step_context: StepContext::for_test(Arc::clone(&turn)), + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "hook-cwd".to_string(), + tool_name: codex_tools::ToolName::plain("exec_command"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: String::new(), + }, + environments: Default::default(), + }; + for (workdir, expected) in [ + (None, "file:///C:/repo-B"), + (Some(""), "file:///C:/repo-B"), + (Some(r"nested\..\target"), "file:///C:/repo-B/target"), + (Some(r"D:\repo-C"), "file:///D:/repo-C"), + ] { + invocation.payload = ToolPayload::Function { + arguments: serde_json::json!({ + "cmd": "echo hello", + "environment_id": "other", + "workdir": workdir, + }) + .to_string(), + }; + let handler = ExecCommandHandler::default(); + invocation.environments = handler.environments_for_input(&invocation).unwrap(); + assert_eq!( + handler.pre_tool_use_cwd(&invocation).unwrap(), + Some(PathUri::parse(expected).unwrap()), + ); + } +} + +#[tokio::test] +async fn exec_command_pre_tool_use_cwd_rejects_invalid_environment_and_path() { + for args in [ + serde_json::json!({ "cmd": "echo hello", "environment_id": "missing" }), + serde_json::json!({ "cmd": "echo hello", "workdir": "\u{0}" }), + ] { + let mut invocation = invocation_for_payload( + "exec_command", + "invalid-cwd", + ToolPayload::Function { + arguments: args.to_string(), + }, + ) + .await; + let handler = ExecCommandHandler::default(); + let cwd = handler + .environments_for_input(&invocation) + .and_then(|environments| { + invocation.environments = environments; + handler.pre_tool_use_cwd(&invocation) + }); + assert!(matches!(cwd, Err(FunctionCallError::RespondToModel(_)))); + } +} + #[tokio::test] async fn exec_command_pre_tool_use_payload_skips_write_stdin() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index c60feef0be..b42bce6f23 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -43,6 +43,7 @@ use codex_tools::FunctionsNamespaceFunctionPrefixes; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use indexmap::IndexMap; use indexmap::map::Entry; @@ -175,6 +176,16 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { }) } + /// Resolves a local execution cwd exposed in `PreToolUse` stdin, when the tool + /// supports a per-call working directory. Remote commands return `None` to + /// retain the host cwd. This does not move the hook process. + fn pre_tool_use_cwd( + &self, + _invocation: &ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(None) + } + /// Rebuilds a tool invocation from hook-facing `tool_input`. /// /// Tools that opt into input-rewriting hooks should invert the same stable @@ -775,6 +786,17 @@ impl ToolRegistry { } let fail_before_execution = async |invocation: &ToolInvocation, err: FunctionCallError| { + let log_payload = tool_log_payload(&invocation.payload, &invocation.source); + otel.tool_result_with_tags( + &tool_name, + &call_id_owned, + log_payload.as_ref(), + Duration::ZERO, + /*success*/ false, + &err.to_string(), + &tool_result_tags, + &extra_trace_fields, + ); if tool.is_builtin_control_tool() { let mut analytics = ControlToolCallGuard::new(invocation); analytics.finish(ControlToolCallStatus::Failed); @@ -799,12 +821,17 @@ impl ToolRegistry { Err(err) => return Err(fail_before_execution(&invocation, err).await), }; if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { + let tool_cwd = match tool.pre_tool_use_cwd(&invocation) { + Ok(cwd) => cwd, + Err(err) => return Err(fail_before_execution(&invocation, err).await), + }; match run_pre_tool_use_hooks( &invocation.session, invocation.step_context.as_ref(), invocation.call_id.clone(), &pre_tool_use_payload.tool_name, &pre_tool_use_payload.tool_input, + tool_cwd, &invocation.environments.hook_ids(), ) .or_cancel_tool(&cancellation_token) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 1f76244051..a31b55a5d4 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -91,6 +91,10 @@ const BLOCKED_PROMPT_CONTEXT: &str = "Remember the blocked lighthouse note."; const PERMISSION_REQUEST_HOOK_MATCHER: &str = "^Bash$"; const PERMISSION_REQUEST_ALLOW_REASON: &str = "should not be used for allow"; +#[cfg(test)] +#[path = "hooks_cwd_tests.rs"] +mod cwd_tests; + #[tokio::test] async fn managed_hook_discovery_failure_rejects_session_startup_when_hooks_enabled() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/hooks_cwd_tests.rs b/codex-rs/core/tests/suite/hooks_cwd_tests.rs new file mode 100644 index 0000000000..de371808ba --- /dev/null +++ b/codex-rs/core/tests/suite/hooks_cwd_tests.rs @@ -0,0 +1,215 @@ +#![cfg(test)] + +//! PreToolUse reports local command cwd and retains host cwd for remote commands. + +use super::*; +use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::test_codex::local; +use pretty_assertions::assert_eq; +use test_case::test_case; + +#[test_case(None, "allow"; "default")] +#[test_case(Some(""), "allow"; "empty")] +#[test_case(Some("nested/../nested"), "allow"; "relative")] +#[test_case(Some("absolute"), "allow"; "absolute")] +#[test_case(Some("nested"), "json_deny"; "denied")] +#[test_case(Some("nested"), "rewrite"; "rewritten")] +#[tokio::test] +async fn pre_tool_use_cwd_matches_execution( + workdir: Option<&str>, + mode: &'static str, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let test = test_codex() + .with_pre_build_hook(move |home| { + if mode == "rewrite" { + write_updating_pre_tool_use_hook( + home, + "^Bash$", + &json!({ "command": "cat rewritten-marker.txt" }), + ) + .expect("write rewriting hook"); + } else { + write_pre_tool_use_hook(home, Some("^Bash$"), mode, "blocked by cwd hook") + .expect("write recording hook"); + } + let script_path = home.join("pre_tool_use_hook.py"); + let script = fs::read_to_string(&script_path) + .expect("read hook script") + .replace( + "payload = json.load(sys.stdin)", + "payload = json.load(sys.stdin)\npayload['process_cwd'] = str(Path.cwd())", + ); + fs::write(script_path, script).expect("record hook process cwd"); + }) + .with_config(trust_discovered_hooks) + .build_with_remote_and_local_env(&server) + .await?; + + // Hooks run on the host even when commands use the remote test executor. + let local_hook_directory = TempDir::new()?; + let mut environments = vec![test.executor_environment().selection().clone()]; + let hook_cwd = if test.executor_environment().environment().is_remote() { + let cwd = AbsolutePathBuf::try_from(local_hook_directory.path())?; + environments.push(local(cwd.clone())); + cwd + } else { + test.config.cwd.clone() + }; + let root = test.executor_environment().selection().cwd.clone(); + let nested = root.join("nested")?; + test.fs() + .create_directory( + &nested, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + for (directory, marker) in [(&root, "root-marker"), (&nested, "nested-marker")] { + for filename in ["cwd-marker.txt", "rewritten-marker.txt"] { + test.fs() + .write_file( + &directory.join(filename)?, + format!("{marker}:{filename}").into_bytes(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + } + let mut args = json!({ "cmd": "cat cwd-marker.txt", "login": false }); + if let Some(workdir) = workdir { + args["workdir"] = if workdir == "absolute" { + json!(nested.inferred_native_path_string()) + } else { + json!(workdir) + }; + } + let call_id = "hook-cwd"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &args.to_string()), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + test.submit_turn_with_environments( + "read the marker in the requested directory", + Some(environments), + ) + .await?; + + let inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + let expected_cwd = if workdir.is_none_or(str::is_empty) { + &root + } else { + &nested + }; + assert_eq!( + inputs + .iter() + .map(|input| json!({ + "cwd": input["cwd"], + "process_cwd": input["process_cwd"], + "tool_input": input["tool_input"], + })) + .collect::>(), + vec![json!({ + "cwd": if test.executor_environment().environment().is_remote() { + dunce::canonicalize(&hook_cwd)?.to_string_lossy().into_owned() + } else { + expected_cwd.inferred_native_path_string() + }, + "process_cwd": dunce::canonicalize(&hook_cwd)?.to_string_lossy(), + "tool_input": { "command": "cat cwd-marker.txt" }, + })], + ); + let requests = responses.requests(); + let output = requests[1].function_call_output(call_id); + let output = output["output"].as_str().context("command output")?; + if mode == "json_deny" { + assert!(output.contains("Command blocked by PreToolUse hook: blocked by cwd hook")); + } else { + let marker = if expected_cwd == &root { + "root-marker" + } else { + "nested-marker" + }; + let filename = if mode == "rewrite" { + "rewritten-marker.txt" + } else { + "cwd-marker.txt" + }; + assert!(output.contains(&format!("{marker}:{filename}")), "{output}"); + } + Ok(()) +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn invalid_execution_cwd_records_failure_without_running_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + for (field, value) in [("environment_id", "missing-environment"), ("workdir", "\0")] { + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "") + .expect("write recording hook"); + }) + .with_config(trust_discovered_hooks); + let test = builder.build_with_auto_env(&server).await?; + let args = json!({ "cmd": "git version", field: value }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(field, "exec_command", &args.to_string()), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + test.submit_turn("try the invalid shell arguments").await?; + assert!( + !test + .codex_home_path() + .join("pre_tool_use_hook_log.jsonl") + .exists() + ); + let output = responses.requests()[1].function_call_output(field); + let output = output["output"].as_str().context("tool failure")?; + assert!(!output.is_empty()); + logs_assert(|lines: &[&str]| { + lines + .iter() + .any(|line| { + line.contains("codex.tool_result") + && line.contains(&format!("call_id={field}")) + && line.contains("success=false") + }) + .then_some(()) + .ok_or_else(|| format!("missing failure telemetry for {field}")) + }); + } + Ok(()) +} diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a70e2ad91d..4e93a0075d 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -646,6 +646,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: turn_id: "turn-1".to_string(), subagent: None, cwd: cwd.clone(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -666,6 +667,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -740,6 +742,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1142,6 +1145,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1575,6 +1579,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1672,6 +1677,7 @@ fn profile_user_layers_load_shared_hooks_json_once() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2112,6 +2118,7 @@ print(json.dumps({ turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2147,6 +2154,7 @@ print(json.dumps({ turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2853,6 +2861,7 @@ async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 505eeae1da..30d90cfa3f 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use serde_json::Value; use super::common; @@ -26,6 +27,9 @@ pub struct PreToolUseRequest { pub turn_id: String, pub subagent: Option, pub cwd: AbsolutePathBuf, + /// Local tool cwd for hook stdin. Remote commands leave this unset to retain + /// the host cwd. The hook process still runs in `cwd`. + pub tool_cwd: Option, pub transcript_path: Option, pub model: String, pub permission_mode: String, @@ -171,7 +175,9 @@ fn latest_updated_input( /// Handler selection may include internal matcher aliases, but hook stdin keeps /// the canonical `tool_name` so audit logs and downstream policy decisions stay /// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP -/// tools pass their resolved JSON arguments. +/// tools pass their resolved JSON arguments. Local commands can expose their +/// execution cwd here, independently of the directory used to launch hooks. +/// Remote commands retain the host cwd; remote cwd reporting is deferred. fn command_input(request: &PreToolUseRequest) -> Result { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_value(PreToolUseCommandInput { @@ -180,7 +186,10 @@ fn command_input(request: &PreToolUseRequest) -> Result