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 codex_utils_absolute_path::AbsolutePathBuf; 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: AbsolutePathBuf, 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| { common::hook_run_for_tool_use(dispatcher::running_summary(&handler), &request.tool_use_id) }) .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) => { let hook_events = common::serialization_failure_hook_events_for_tool_use( matched, Some(request.turn_id.clone()), format!("failed to serialize pre tool use hook input: {error}"), &request.tool_use_id, ); return serialization_failure_outcome(hook_events); } }; let results = dispatcher::execute_handlers( shell, matched, input_json, request.cwd.as_path(), Some(request.turn_id.clone()), 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| { common::hook_completed_for_tool_use(result.completed, &request.tool_use_id) }) .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 codex_protocol::ThreadId; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use super::PreToolUseHandlerData; use super::parse_completed; use super::preview; use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::events::common; #[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(), }] ); } #[test] fn preview_and_completed_run_ids_include_tool_use_id() { let request = request_for_tool_use("tool-call-123"); let runs = preview(&[handler()], &request); assert_eq!(runs.len(), 1); assert_eq!( runs[0].id, format!( "pre-tool-use:0:{}:tool-call-123", test_path_buf("/tmp/hooks.json").display() ) ); let parsed = parse_completed( &handler(), run_result(Some(0), "", ""), Some("turn-1".to_string()), ); let completed = common::hook_completed_for_tool_use(parsed.completed, &request.tool_use_id); assert_eq!(completed.run.id, runs[0].id); } #[test] fn serialization_failure_run_ids_include_tool_use_id() { let request = request_for_tool_use("tool-call-123"); let runs = preview(&[handler()], &request); let completed = common::serialization_failure_hook_events_for_tool_use( vec![handler()], Some(request.turn_id.clone()), "serialize failed".into(), &request.tool_use_id, ); assert_eq!(completed.len(), 1); assert_eq!(completed[0].run.id, runs[0].id); } 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: test_path_buf("/tmp/hooks.json").abs(), 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, } } fn request_for_tool_use(tool_use_id: &str) -> super::PreToolUseRequest { super::PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), tool_name: "Bash".to_string(), tool_use_id: tool_use_id.to_string(), command: "echo hello".to_string(), } } }