Codex Handbook
hooks/src/events/pre_tool_use.rs 782 lines
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 serde_json::Value;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;use crate::schema::SubagentCommandInputFields;#[derive(Debug, Clone)]pub struct PreToolUseRequest {    pub session_id: ThreadId,    pub turn_id: String,    pub subagent: Option<common::SubagentHookContext>,    pub cwd: AbsolutePathBuf,    pub transcript_path: Option<PathBuf>,    pub model: String,    pub permission_mode: String,    pub tool_name: String,    pub matcher_aliases: Vec<String>,    pub tool_use_id: String,    pub tool_input: Value,}#[derive(Debug)]pub struct PreToolUseOutcome {    pub hook_events: Vec<HookCompletedEvent>,    pub should_block: bool,    pub block_reason: Option<String>,    pub additional_contexts: Vec<String>,    pub updated_input: Option<Value>,}#[derive(Debug, Default, PartialEq, Eq)]struct PreToolUseHandlerData {    should_block: bool,    block_reason: Option<String>,    additional_contexts_for_model: Vec<String>,    updated_input: Option<Value>,}pub(crate) fn preview(    handlers: &[ConfiguredHandler],    request: &PreToolUseRequest,) -> Vec<HookRunSummary> {    let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);    dispatcher::select_handlers_for_matcher_inputs(        handlers,        HookEventName::PreToolUse,        &matcher_inputs,    )    .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 matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);    let matched = dispatcher::select_handlers_for_matcher_inputs(        handlers,        HookEventName::PreToolUse,        &matcher_inputs,    );    if matched.is_empty() {        return PreToolUseOutcome {            hook_events: Vec::new(),            should_block: false,            block_reason: None,            additional_contexts: Vec::new(),            updated_input: None,        };    }    let input_json = match command_input_json(&request) {        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());    let additional_contexts = common::flatten_additional_contexts(        results            .iter()            .map(|result| result.data.additional_contexts_for_model.as_slice()),    );    let updated_input = if should_block {        None    } else {        latest_updated_input(&results)    };    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,        additional_contexts,        updated_input,    }}/// Chooses the rewrite from the hook that actually finished last.////// Hook results stay in configured order for stable reporting, but the/// `PreToolUse` contract resolves competing rewrites by completion order.fn latest_updated_input(    results: &[dispatcher::ParsedHandler<PreToolUseHandlerData>],) -> Option<Value> {    results        .iter()        .filter_map(|result| {            result                .data                .updated_input                .clone()                .map(|updated_input| (result.completion_order, updated_input))        })        .max_by_key(|(completion_order, _)| *completion_order)        .map(|(_, updated_input)| updated_input)}/// Serializes command stdin for a selected `PreToolUse` hook.////// Handler selection may include internal matcher aliases, but hook stdin keeps/// the canonical `tool_name` so audit logs and downstream policy decisions stay/// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP/// tools pass their resolved JSON arguments.fn command_input_json(request: &PreToolUseRequest) -> Result<String, serde_json::Error> {    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());    serde_json::to_string(&PreToolUseCommandInput {        session_id: request.session_id.to_string(),        turn_id: request.turn_id.clone(),        agent_id: subagent.agent_id,        agent_type: subagent.agent_type,        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: request.tool_name.clone(),        tool_input: request.tool_input.clone(),        tool_use_id: request.tool_use_id.clone(),    })}fn parse_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,) -> dispatcher::ParsedHandler<PreToolUseHandlerData> {    let mut entries = Vec::new();    let mut status = HookRunStatus::Completed;    let mut should_block = false;    let mut block_reason = None;    let mut additional_contexts_for_model = Vec::new();    let mut updated_input = 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(additional_context) = parsed.additional_context {                            common::append_additional_context(                                &mut entries,                                &mut additional_contexts_for_model,                                additional_context,                            );                        }                        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,                            });                        }                        if !should_block {                            updated_input = parsed.updated_input;                        }                    }                } else if output_parser::looks_like_json(&run_result.stdout) {                    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,            additional_contexts_for_model,            updated_input,        },        completion_order: 0,    }}fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToolUseOutcome {    PreToolUseOutcome {        hook_events,        should_block: false,        block_reason: None,        additional_contexts: Vec::new(),        updated_input: 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::command_input_json;    use super::latest_updated_input;    use super::parse_completed;    use super::preview;    use crate::engine::ConfiguredHandler;    use crate::engine::command_runner::CommandRunResult;    use crate::events::common;    #[test]    fn command_input_uses_request_tool_name() {        let mut request = request_for_tool_use("call-apply-patch");        request.tool_name = "apply_patch".to_string();        let input_json = command_input_json(&request).expect("serialize command input");        let input: serde_json::Value =            serde_json::from_str(&input_json).expect("parse command input");        assert_eq!(input["tool_name"], "apply_patch");    }    #[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()),                additional_contexts_for_model: Vec::new(),                updated_input: None,            }        );        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 permission_decision_allow_can_update_input() {        let parsed = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo rewritten"}}}"#,                "",            ),            Some("turn-1".to_string()),        );        assert_eq!(            parsed.data,            PreToolUseHandlerData {                should_block: false,                block_reason: None,                additional_contexts_for_model: Vec::new(),                updated_input: Some(serde_json::json!({ "command": "echo rewritten" })),            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(parsed.completed.run.entries, vec![]);    }    #[test]    fn last_completed_updated_input_wins() {        let mut later_configured = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo configured later"}}}"#,                "",            ),            Some("turn-1".to_string()),        );        later_configured.completion_order = 0;        let mut earlier_configured = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo finished later"}}}"#,                "",            ),            Some("turn-1".to_string()),        );        earlier_configured.completion_order = 1;        assert_eq!(            latest_updated_input(&[later_configured, earlier_configured]),            Some(serde_json::json!({ "command": "echo finished later" }))        );    }    #[test]    fn permission_decision_allow_without_updated_input_fails_open() {        let parsed = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}"#,                "",            ),            Some("turn-1".to_string()),        );        assert_eq!(            parsed.data,            PreToolUseHandlerData {                should_block: false,                block_reason: None,                additional_contexts_for_model: Vec::new(),                updated_input: 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:allow".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()),                additional_contexts_for_model: Vec::new(),                updated_input: None,            }        );        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_with_additional_context_blocks_processing() {        let parsed = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"decision":"block","reason":"do not run that","hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"remember this"}}"#,                "",            ),            Some("turn-1".to_string()),        );        assert_eq!(            parsed.data,            PreToolUseHandlerData {                should_block: true,                block_reason: Some("do not run that".to_string()),                additional_contexts_for_model: vec!["remember this".to_string()],                updated_input: None,            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);        assert_eq!(            parsed.completed.run.entries,            vec![                HookOutputEntry {                    kind: HookOutputEntryKind::Context,                    text: "remember this".to_string(),                },                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,                additional_contexts_for_model: Vec::new(),                updated_input: 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,                additional_contexts_for_model: Vec::new(),                updated_input: 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 additional_context_is_recorded() {        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: true,                block_reason: Some("do not run that".to_string()),                additional_contexts_for_model: vec!["nope".to_string()],                updated_input: None,            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);        assert_eq!(            parsed.completed.run.entries,            vec![                HookOutputEntry {                    kind: HookOutputEntryKind::Context,                    text: "nope".to_string(),                },                HookOutputEntry {                    kind: HookOutputEntryKind::Feedback,                    text: "do not run that".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,                additional_contexts_for_model: Vec::new(),                updated_input: 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,                additional_contexts_for_model: Vec::new(),                updated_input: 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()),                additional_contexts_for_model: Vec::new(),                updated_input: None,            }        );        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(),            source: codex_protocol::protocol::HookSource::User,            display_order: 0,            env: std::collections::HashMap::new(),        }    }    fn run_result(exit_code: Option<i32>, 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(),            subagent: None,            cwd: test_path_buf("/tmp").abs(),            transcript_path: None,            model: "gpt-test".to_string(),            permission_mode: "default".to_string(),            tool_name: "Bash".to_string(),            matcher_aliases: Vec::new(),            tool_use_id: tool_use_id.to_string(),            tool_input: serde_json::json!({ "command": "echo hello" }),        }    }}