diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f9cbe76e2a76..add80f66f2b0 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1155,6 +1155,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 68bf7477e578..98f56c11e391 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7949,6 +7949,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 772eb6f47aef..ad3b9d6cb9d8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4673,6 +4673,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 84fea949c88f..881c34360119 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 7b55420da24e..18fdb5008d62 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index a531b78dcff8..b75ee3930a5b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c8903a14494..9ed858e1a8bc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -347,7 +347,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - SessionStart, UserPromptSubmit, Stop + PreToolUse, SessionStart, UserPromptSubmit, Stop } ); diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 26b49facc335..7e6ecaca1045 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PreToolUseOutcome; +use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; @@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks( .await } +pub(crate) async fn run_pre_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, +) -> Option { + let request = PreToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: "Bash".to_string(), + tool_use_id, + command, + }; + let preview_runs = sess.hooks().preview_pre_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let PreToolUseOutcome { + hook_events, + should_block, + block_reason, + } = sess.hooks().run_pre_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, hook_events).await; + + if should_block { block_reason } else { None } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index bcee62a044a3..37b7d015b38a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,6 +5,7 @@ use std::time::Instant; use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; +use crate::hook_runtime::run_pre_tool_use_hooks; use crate::memories::usage::emit_metric_for_tool_read; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -20,7 +21,10 @@ use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use codex_utils_readiness::Readiness; +use serde::Deserialize; use tracing::warn; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -243,6 +247,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } + if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload) + && let Some(reason) = run_pre_tool_use_hooks( + &invocation.session, + &invocation.turn, + invocation.call_id.clone(), + command.clone(), + ) + .await + { + return Err(FunctionCallError::RespondToModel(format!( + "Bash command blocked by hook: {reason}. Command: {command}" + ))); + } + let is_mutating = handler.is_mutating(&invocation).await; let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); @@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { } } +#[derive(Deserialize)] +struct PreToolUseExecCommandArgs { + cmd: String, +} + +fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option { + match (tool_name, payload) { + ("shell" | "container.exec", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) + } + ("local_shell", ToolPayload::LocalShell { params }) => Some( + codex_shell_command::parse_command::shlex_join(¶ms.command), + ), + ("shell_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.command) + } + ("exec_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.cmd) + } + _ => None, + } +} + // Hooks use a separate wire-facing input type so hook payload JSON stays stable // and decoupled from core's internal tool runtime representation. impl From<&ToolPayload> for HookToolInput { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 5d9e98df350c..46e7d0c3f684 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; use async_trait::async_trait; +use codex_protocol::models::ShellToolCallParams; use pretty_assertions::assert_eq; struct TestHandler; @@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[test] +fn pre_tool_use_command_uses_raw_shell_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "command": "printf shell command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("shell_command", &payload), + Some("printf shell command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_shell_joins_vector_input() { + let payload = ToolPayload::LocalShell { + params: ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf hi".to_string(), + ], + workdir: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + additional_permissions: None, + justification: None, + }, + }; + + assert_eq!( + pre_tool_use_command("local_shell", &payload), + Some("bash -lc 'printf hi'".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_uses_raw_exec_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("exec_command", &payload), + Some("printf exec command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_skips_non_shell_tools() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ + "plan": [{ "step": "watch the tide", "status": "pending" }] + }) + .to_string(), + }; + + assert_eq!(pre_tool_use_command("update_plan", &payload), None); +} diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 0334db8b41c8..3eb1a35a8220 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_pre_tool_use_hook( + home: &Path, + matcher: Option<&str>, + mode: &str, + reason: &str, +) -> Result<()> { + let script_path = home.join("pre_tool_use_hook.py"); + let log_path = home.join("pre_tool_use_hook_log.jsonl"); + let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?; + let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +mode = {mode_json} +reason = {reason_json} + +payload = json.load(sys.stdin) + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if mode == "json_deny": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + }} + }})) +elif mode == "exit_2": + sys.stderr.write(reason + "\n") + raise SystemExit(2) +"#, + log_path = log_path.display(), + mode_json = mode_json, + reason_json = reason_json, + ); + + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running pre tool use hook", + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = Value::String(matcher.to_string()); + } + + let hooks = serde_json::json!({ + "hooks": { + "PreToolUse": [group] + } + }); + + fs::write(&script_path, script).context("write pre tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> { let script_path = home.join("session_start_hook.py"); let log_path = home.join("session_start_hook_log.jsonl"); @@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result> { .collect() } +fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl")) + .context("read pre tool use hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse pre tool use hook log line")) + .collect() +} + fn read_session_start_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("session_start_hook_log.jsonl")) .context("read session start hook log")? @@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command"; + let marker = std::env::temp_dir().join("pretooluse-shell-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover pre tool use marker")?; + } + + test.submit_turn_with_policy( + "run the blocked shell command", + codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked by pre hook"), + "blocked tool output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked tool output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked command should not create marker file" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + let transcript_path = hook_inputs[0]["transcript_path"] + .as_str() + .expect("pre tool use hook transcript_path"); + assert!( + !transcript_path.is_empty(), + "pre tool use hook should receive a non-empty transcript_path", + ); + assert!( + Path::new(transcript_path).exists(), + "pre tool use hook transcript_path should be materialized on disk", + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-local-shell"; + let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("printf blocked > {}", marker.display()), + ]; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_local_shell_call( + call_id, + "completed", + command.iter().map(String::as_str).collect(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local shell blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover local shell marker")?; + } + + test.submit_turn("run the blocked local shell command") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("local shell output string"); + assert!( + output.contains("Bash command blocked by hook: blocked local shell"), + "blocked local shell output should surface the hook reason", + ); + assert!( + output.contains(&format!( + "Command: {}", + codex_shell_command::parse_command::shlex_join(&command) + )), + "blocked local shell output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked local shell command should not execute" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + codex_shell_command::parse_command::shlex_join(&command), + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-exec-command"; + let marker = std::env::temp_dir().join("pretooluse-exec-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "cmd": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "exec command blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover exec marker")?; + } + + test.submit_turn("run the blocked exec command").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked exec command"), + "blocked exec command output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked exec command output should surface the blocked command", + ); + assert!(!marker.exists(), "blocked exec command should not execute"); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-update-plan"; + let args = serde_json::json!({ + "plan": [{ + "step": "watch the tide", + "status": "pending", + }] + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "update_plan", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "plan updated"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("update the plan").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("update plan output string"); + assert!( + !output.contains("should not fire"), + "non-shell tool output should not be blocked by PreToolUse", + ); + + let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl"); + assert!( + !hook_log_path.exists(), + "non-shell tools should not trigger pre tool use hooks", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index c96988d18b4e..a0d42e229ad4 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .features .disable(Feature::GhostCommit) .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); }) .build(&server) .await @@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; logs_assert(|lines: &[&str]| { let line = lines diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 0e49166b816b..2b935fce95fa 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput { fn hook_event_name(event_name: HookEventName) -> &'static str { match event_name { + HookEventName::PreToolUse => "PreToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json new file mode 100644 index 000000000000..86dfac165c8d --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PreToolUseToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": { + "$ref": "#/definitions/PreToolUseToolInput" + }, + "tool_name": { + "const": "Bash", + "type": "string" + }, + "tool_use_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "pre-tool-use.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json new file mode 100644 index 000000000000..0992983fe4ec --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop" + ], + "type": "string" + }, + "PreToolUseDecisionWire": { + "enum": [ + "approve", + "block" + ], + "type": "string" + }, + "PreToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + }, + "permissionDecision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + } + ], + "default": null + }, + "permissionDecisionReason": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + }, + "PreToolUsePermissionDecisionWire": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-tool-use.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 292777ff67f0..f44928983d57 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -4,6 +4,7 @@ "definitions": { "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index c6935aa6dadd..27878752c1a9 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -10,6 +10,7 @@ }, "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 0d9357e392b8..1a2d962bc8a5 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,6 +8,8 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index db0f38c64557..55dfca63cd33 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -3,11 +3,12 @@ use std::path::Path; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use regex::Regex; use super::ConfiguredHandler; use super::config::HookHandlerConfig; use super::config::HooksFile; +use crate::events::common::matcher_pattern_for_event; +use crate::events::common::validate_matcher_pattern; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - } }; + for group in parsed.hooks.pre_tool_use { + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + source_path.as_path(), + codex_protocol::protocol::HookEventName::PreToolUse, + matcher_pattern_for_event( + codex_protocol::protocol::HookEventName::PreToolUse, + group.matcher.as_deref(), + ), + group.hooks, + ); + } + for group in parsed.hooks.session_start { append_group_handlers( &mut handlers, @@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::SessionStart, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::SessionStart, group.matcher.as_deref(), ), @@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::UserPromptSubmit, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::UserPromptSubmit, group.matcher.as_deref(), ), @@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::Stop, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::Stop, group.matcher.as_deref(), ), @@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - DiscoveryResult { handlers, warnings } } -fn effective_matcher( - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, -) -> Option<&str> { - match event_name { - codex_protocol::protocol::HookEventName::SessionStart => matcher, - codex_protocol::protocol::HookEventName::UserPromptSubmit - | codex_protocol::protocol::HookEventName::Stop => None, - } -} - fn append_group_handlers( handlers: &mut Vec, warnings: &mut Vec, @@ -139,7 +144,7 @@ fn append_group_handlers( group_handlers: Vec, ) { if let Some(matcher) = matcher - && let Err(err) = Regex::new(matcher) + && let Err(err) = validate_matcher_pattern(matcher) { warnings.push(format!( "invalid matcher {matcher:?} in {}: {err}", @@ -205,7 +210,7 @@ mod tests { use super::ConfiguredHandler; use super::HookHandlerConfig; use super::append_group_handlers; - use super::effective_matcher; + use crate::events::common::matcher_pattern_for_event; #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { @@ -219,7 +224,7 @@ mod tests { &mut display_order, Path::new("/tmp/hooks.json"), HookEventName::UserPromptSubmit, - effective_matcher(HookEventName::UserPromptSubmit, Some("[")), + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")), vec![HookHandlerConfig::Command { command: "echo hello".to_string(), timeout_sec: None, @@ -242,4 +247,66 @@ mod tests { }] ); } + + #[test] + fn pre_tool_use_keeps_valid_matcher_during_discovery() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + handlers, + vec![ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hello".to_string(), + timeout_sec: 600, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + }] + ); + } + + #[test] + fn pre_tool_use_treats_star_matcher_as_match_all() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(handlers[0].matcher.as_deref(), Some("*")); + } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index e316d9af98ca..0b29e12fa043 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -14,6 +14,7 @@ use super::CommandShell; use super::ConfiguredHandler; use super::command_runner::CommandRunResult; use super::command_runner::run_command; +use crate::events::common::matches_matcher; #[derive(Debug)] pub(crate) struct ParsedHandler { @@ -30,13 +31,9 @@ pub(crate) fn select_handlers( .iter() .filter(|handler| handler.event_name == event_name) .filter(|handler| match event_name { - HookEventName::SessionStart => match (&handler.matcher, matcher_input) { - (Some(matcher), Some(input)) => regex::Regex::new(matcher) - .map(|regex| regex.is_match(input)) - .unwrap_or(false), - (None, _) => true, - _ => false, - }, + HookEventName::PreToolUse | HookEventName::SessionStart => { + matches_matcher(handler.matcher.as_deref(), matcher_input) + } HookEventName::UserPromptSubmit | HookEventName::Stop => true, }) .cloned() @@ -109,7 +106,9 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart => HookScope::Thread, - HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, + HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => { + HookScope::Turn + } } } @@ -172,6 +171,50 @@ mod tests { assert_eq!(selected[1].display_order, 1); } + #[test] + fn pre_tool_use_matches_tool_name() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_star_matcher_matches_all_tools() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_regex_alternation_matches_each_tool_name() { + let handlers = vec![make_handler( + HookEventName::PreToolUse, + Some("Edit|Write"), + "echo same", + 0, + )]; + + let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit")); + let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write")); + let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected_edit.len(), 1); + assert_eq!(selected_write.len(), 1); + assert_eq!(selected_bash.len(), 0); + } + #[test] fn user_prompt_submit_ignores_matcher() { let handlers = vec![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index e6297d71d54e..74dcae8e1ac0 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -46,6 +48,7 @@ impl ConfiguredHandler { fn event_name_label(&self) -> &'static str { match self.event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", @@ -94,6 +97,10 @@ impl ClaudeHooksEngine { crate::events::session_start::preview(&self.handlers, request) } + pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec { + crate::events::pre_tool_use::preview(&self.handlers, request) + } + pub(crate) async fn run_session_start( &self, request: SessionStartRequest, @@ -102,6 +109,10 @@ impl ClaudeHooksEngine { crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await } + pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await + } + pub(crate) fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index d72ae071551e..3fc0e7a0bab8 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput { pub additional_context: Option, } +#[derive(Debug, Clone)] +pub(crate) struct PreToolUseOutput { + pub universal: UniversalOutput, + pub block_reason: Option, + pub invalid_reason: Option, +} + #[derive(Debug, Clone)] pub(crate) struct UserPromptSubmitOutput { pub universal: UniversalOutput, @@ -31,6 +38,9 @@ pub(crate) struct StopOutput { use crate::schema::BlockDecisionWire; use crate::schema::HookUniversalOutputWire; +use crate::schema::PreToolUseCommandOutputWire; +use crate::schema::PreToolUseDecisionWire; +use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; @@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option { }) } +pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { + let PreToolUseCommandOutputWire { + universal: universal_wire, + decision, + reason, + hook_specific_output, + } = parse_json(stdout)?; + let universal = UniversalOutput::from(universal_wire); + let hook_specific_output = hook_specific_output.as_ref(); + let use_hook_specific_decision = hook_specific_output.is_some_and(|output| { + output.permission_decision.is_some() + || output.permission_decision_reason.is_some() + || output.updated_input.is_some() + || output.additional_context.is_some() + }); + let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| { + if use_hook_specific_decision { + hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output) + } else { + unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref()) + } + }); + let block_reason = if invalid_reason.is_none() { + if use_hook_specific_decision { + hook_specific_output.and_then(|output| match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Deny) => output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason), + _ => None, + }) + } else { + match decision.as_ref() { + Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason), + Some(PreToolUseDecisionWire::Approve) | None => None, + } + } + } else { + None + }; + + Some(PreToolUseOutput { + universal, + block_reason, + invalid_reason, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); @@ -119,3 +177,97 @@ where fn invalid_block_message(event_name: &str) -> String { format!("{event_name} hook returned decision:block without a non-empty reason") } + +fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option { + if !universal.continue_processing { + Some("PreToolUse hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("PreToolUse hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("PreToolUse hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + +fn unsupported_pre_tool_use_hook_specific_output( + output: &crate::schema::PreToolUseHookSpecificOutputWire, +) -> Option { + if output.updated_input.is_some() { + Some("PreToolUse hook returned unsupported updatedInput".to_string()) + } else if output + .additional_context + .as_deref() + .and_then(trimmed_reason) + .is_some() + { + Some("PreToolUse hook returned unsupported additionalContext".to_string()) + } else { + match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Allow) => { + Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Ask) => { + Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Deny) => { + if output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason) + .is_none() + { + Some(invalid_pre_tool_use_reason_message()) + } else { + None + } + } + None => { + if output.permission_decision_reason.is_some() { + Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string()) + } else { + None + } + } + } + } +} + +fn unsupported_pre_tool_use_legacy_decision( + decision: Option<&PreToolUseDecisionWire>, + reason: Option<&str>, +) -> Option { + match decision { + Some(PreToolUseDecisionWire::Approve) => { + Some("PreToolUse hook returned unsupported decision:approve".to_string()) + } + Some(PreToolUseDecisionWire::Block) => { + if reason.and_then(trimmed_reason).is_none() { + Some(invalid_block_message("PreToolUse")) + } else { + None + } + } + None => { + if reason.is_some() { + Some("PreToolUse hook returned reason without decision".to_string()) + } else { + None + } + } + } +} + +fn invalid_pre_tool_use_reason_message() -> String { + "PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason" + .to_string() +} + +fn trimmed_reason(reason: &str) -> Option { + let trimmed = reason.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 2ad54e506222..1a51e5952735 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -4,6 +4,8 @@ use serde_json::Value; #[allow(dead_code)] pub(crate) struct GeneratedHookSchemas { + pub pre_tool_use_command_input: Value, + pub pre_tool_use_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, pub user_prompt_submit_command_input: Value, @@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas { pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { static SCHEMAS: OnceLock = OnceLock::new(); SCHEMAS.get_or_init(|| GeneratedHookSchemas { + pre_tool_use_command_input: parse_json_schema( + "pre-tool-use.command.input", + include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"), + ), + pre_tool_use_command_output: parse_json_schema( + "pre-tool-use.command.output", + include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"), + ), session_start_command_input: parse_json_schema( "session-start.command.input", include_str!("../../schema/generated/session-start.command.input.schema.json"), @@ -56,6 +66,8 @@ mod tests { fn loads_generated_hook_schemas() { let schemas = generated_hook_schemas(); + assert_eq!(schemas.pre_tool_use_command_input["type"], "object"); + assert_eq!(schemas.pre_tool_use_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index b6358e068a2d..b4c274bbd300 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -1,4 +1,5 @@ use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; @@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events( }) .collect() } + +pub(crate) fn matcher_pattern_for_event( + event_name: HookEventName, + matcher: Option<&str>, +) -> Option<&str> { + match event_name { + HookEventName::PreToolUse | HookEventName::SessionStart => matcher, + HookEventName::UserPromptSubmit | HookEventName::Stop => None, + } +} + +pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> { + if is_match_all_matcher(matcher) { + return Ok(()); + } + regex::Regex::new(matcher).map(|_| ()) +} + +pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool { + match matcher { + None => true, + Some(matcher) if is_match_all_matcher(matcher) => true, + Some(matcher) => input + .and_then(|input| { + regex::Regex::new(matcher) + .ok() + .map(|regex| regex.is_match(input)) + }) + .unwrap_or(false), + } +} + +fn is_match_all_matcher(matcher: &str) -> bool { + matcher.is_empty() || matcher == "*" +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::HookEventName; + use pretty_assertions::assert_eq; + + use super::matcher_pattern_for_event; + use super::matches_matcher; + use super::validate_matcher_pattern; + + #[test] + fn matcher_omitted_matches_all_occurrences() { + assert!(matches_matcher(None, Some("Bash"))); + assert!(matches_matcher(None, Some("Write"))); + } + + #[test] + fn matcher_star_matches_all_occurrences() { + assert!(matches_matcher(Some("*"), Some("Bash"))); + assert!(matches_matcher(Some("*"), Some("Edit"))); + assert_eq!(validate_matcher_pattern("*"), Ok(())); + } + + #[test] + fn matcher_empty_string_matches_all_occurrences() { + assert!(matches_matcher(Some(""), Some("Bash"))); + assert!(matches_matcher(Some(""), Some("SessionStart"))); + assert_eq!(validate_matcher_pattern(""), Ok(())); + } + + #[test] + fn matcher_uses_regex_matching() { + assert!(matches_matcher(Some("Edit|Write"), Some("Edit"))); + assert!(matches_matcher(Some("Edit|Write"), Some("Write"))); + assert!(!matches_matcher(Some("Edit|Write"), Some("Bash"))); + assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(())); + } + + #[test] + fn matcher_supports_anchored_regexes() { + assert!(matches_matcher(Some("^Bash$"), Some("Bash"))); + assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput"))); + assert_eq!(validate_matcher_pattern("^Bash$"), Ok(())); + } + + #[test] + fn invalid_regex_is_rejected() { + assert!(validate_matcher_pattern("[").is_err()); + assert!(!matches_matcher(Some("["), Some("Bash"))); + } + + #[test] + fn unsupported_events_ignore_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")), + None + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::Stop, Some("^done$")), + None + ); + } + + #[test] + fn supported_events_keep_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")), + Some("Bash") + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")), + Some("startup|resume") + ); + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 3bb54699af3c..603395eb5a7c 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -1,4 +1,5 @@ -mod common; +pub(crate) mod common; +pub mod pre_tool_use; pub mod session_start; pub mod stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs new file mode 100644 index 000000000000..8366bb632cb0 --- /dev/null +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -0,0 +1,479 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::engine::output_parser; +use crate::schema::PreToolUseCommandInput; + +#[derive(Debug, Clone)] +pub struct PreToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, +} + +#[derive(Debug)] +pub struct PreToolUseOutcome { + pub hook_events: Vec, + pub should_block: bool, + pub block_reason: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PreToolUseHandlerData { + should_block: bool, + block_reason: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &PreToolUseRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PreToolUseRequest, +) -> PreToolUseOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ); + if matched.is_empty() { + return PreToolUseOutcome { + hook_events: Vec::new(), + should_block: false, + block_reason: None, + }; + } + + let input_json = match serde_json::to_string(&PreToolUseCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "PreToolUse".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + tool_name: "Bash".to_string(), + tool_input: crate::schema::PreToolUseToolInput { + command: request.command.clone(), + }, + tool_use_id: request.tool_use_id.clone(), + }) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize pre tool use hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + + let should_block = results.iter().any(|result| result.data.should_block); + let block_reason = results + .iter() + .find_map(|result| result.data.block_reason.clone()); + + PreToolUseOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + should_block, + block_reason, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut should_block = false; + let mut block_reason = None; + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(reason) = parsed.block_reason { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } + } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }); + } + } + Some(2) => { + if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: PreToolUseHandlerData { + should_block, + block_reason, + }, + } +} + +fn serialization_failure_outcome(hook_events: Vec) -> PreToolUseOutcome { + PreToolUseOutcome { + hook_events, + should_block: false, + block_reason: None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookOutputEntry; + use codex_protocol::protocol::HookOutputEntryKind; + use codex_protocol::protocol::HookRunStatus; + use pretty_assertions::assert_eq; + + use super::PreToolUseHandlerData; + use super::parse_completed; + use crate::engine::ConfiguredHandler; + use crate::engine::command_runner::CommandRunResult; + + #[test] + fn permission_decision_deny_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn deprecated_block_decision_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"decision":"block","reason":"do not run that"}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn unsupported_permission_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(), + }] + ); + } + + #[test] + fn deprecated_approve_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), r#"{"decision":"approve"}"#, ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported decision:approve".to_string(), + }] + ); + } + + #[test] + fn unsupported_additional_context_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported additionalContext".to_string(), + }] + ); + } + + #[test] + fn plain_stdout_is_ignored() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "hook ran successfully\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!(parsed.completed.run.entries, vec![]); + } + + #[test] + fn invalid_json_like_stdout_fails_instead_of_becoming_noop() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "{\"decision\":\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }] + ); + } + + #[test] + fn exit_code_two_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result(Some(2), "", "blocked by policy\n"), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("blocked by policy".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "blocked by policy".to_string(), + }] + ); + } + + fn handler() -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hook".to_string(), + timeout_sec: 5, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + } + } + + fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> CommandRunResult { + CommandRunResult { + started_at: 1, + completed_at: 2, + duration_ms: 1, + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + error: None, + } + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 768a24c5e313..86b39b0b7dd4 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,8 @@ mod registry; mod schema; mod types; +pub use events::pre_tool_use::PreToolUseOutcome; +pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 3b63bda8c391..440af648dd02 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,8 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -92,6 +94,13 @@ impl Hooks { self.engine.preview_session_start(request) } + pub fn preview_pre_tool_use( + &self, + request: &PreToolUseRequest, + ) -> Vec { + self.engine.preview_pre_tool_use(request) + } + pub async fn run_session_start( &self, request: SessionStartRequest, @@ -100,6 +109,10 @@ impl Hooks { self.engine.run_session_start(request, turn_id).await } + pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + self.engine.run_pre_tool_use(request).await + } + pub fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 067658541a38..277500984c06 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -13,6 +13,8 @@ use std::path::Path; use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; +const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json"; +const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; @@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum HookEventNameWire { + #[serde(rename = "PreToolUse")] + PreToolUse, #[serde(rename = "SessionStart")] SessionStart, #[serde(rename = "UserPromptSubmit")] @@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire { Stop, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.output")] +pub(crate) struct PreToolUseCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub permission_decision: Option, + #[serde(default)] + pub permission_decision_reason: Option, + #[serde(default)] + pub updated_input: Option, + #[serde(default)] + pub additional_context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUsePermissionDecisionWire { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUseDecisionWire { + #[serde(rename = "approve")] + Approve, + #[serde(rename = "block")] + Block, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseToolInput { + pub command: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.input")] +pub(crate) struct PreToolUseCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "pre_tool_use_tool_name_schema")] + pub tool_name: String, + pub tool_input: PreToolUseToolInput, + pub tool_use_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(SESSION_START_INPUT_FIXTURE), schema_json::()?, @@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SessionStart") } +fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("PreToolUse") +} + +fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("Bash") +} + fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("UserPromptSubmit") } @@ -346,6 +441,9 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::PRE_TOOL_USE_INPUT_FIXTURE; + use super::PRE_TOOL_USE_OUTPUT_FIXTURE; + use super::PreToolUseCommandInput; use super::SESSION_START_INPUT_FIXTURE; use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; @@ -362,6 +460,12 @@ mod tests { fn expected_fixture(name: &str) -> &'static str { match name { + PRE_TOOL_USE_INPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.input.schema.json") + } + PRE_TOOL_USE_OUTPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.output.schema.json") + } SESSION_START_INPUT_FIXTURE => { include_str!("../schema/generated/session-start.command.input.schema.json") } @@ -395,6 +499,8 @@ mod tests { write_schema_fixtures(&schema_root).expect("write generated hook schemas"); for fixture in [ + PRE_TOOL_USE_INPUT_FIXTURE, + PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, @@ -414,6 +520,10 @@ mod tests { fn turn_scoped_hook_inputs_include_codex_turn_id_extension() { // Codex intentionally diverges from Claude's public hook docs here so // internal hook consumers can key off the active turn. + let pre_tool_use: Value = serde_json::from_slice( + &schema_json::().expect("serialize pre tool use input schema"), + ) + .expect("parse pre tool use input schema"); let user_prompt_submit: Value = serde_json::from_slice( &schema_json::() .expect("serialize user prompt submit input schema"), @@ -424,7 +534,7 @@ mod tests { ) .expect("parse stop input schema"); - for schema in [&user_prompt_submit, &stop] { + for schema in [&pre_tool_use, &user_prompt_submit, &stop] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); assert!( schema["required"] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index beccedb781b1..4281a75a3e66 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1341,6 +1341,7 @@ pub enum EventMsg { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { + PreToolUse, SessionStart, UserPromptSubmit, Stop, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6183aacd870a..1104bedcba42 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9382,6 +9382,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap similarity index 59% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap index 27474ef6d77d..11fa8ab8e349 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -1,10 +1,9 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- -• Running SessionStart hook: warming the shell +• Running PreToolUse hook: warming the shell -SessionStart hook (completed) +PreToolUse hook (completed) warning: Heads up from the hook hook context: Remember the startup checklist. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 91% rename from codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap index 27474ef6d77d..30dfa78e5b03 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- • Running SessionStart hook: warming the shell diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cc91dce6f2fe..4a04724607b0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -11206,7 +11206,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -11214,15 +11240,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -11236,15 +11262,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -11267,7 +11293,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index b233527faf52..586ff6af7f3a 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -10620,6 +10620,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap new file mode 100644 index 000000000000..f9a87c568288 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: combined +--- +• Running PreToolUse hook: warming the shell + +PreToolUse hook (completed) + warning: Heads up from the hook + hook context: Remember the startup checklist. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 100% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 2b14dac16d9f..036a64aab84b 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -11610,7 +11610,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -11618,15 +11644,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -11640,15 +11666,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -11671,7 +11697,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI.