Codex Handbook
hooks/src/events/permission_request.rs 336 lines
//! Permission-request hook execution.//!//! This event runs in the approval path, before guardian or user approval UI is//! shown. Unlike `pre_tool_use`, handlers do not rewrite tool input or block by//! stopping execution outright; instead they can return a concrete allow/deny//! decision, or decline to decide and let the normal approval flow continue.//!//! The event also mirrors the rest of the hook system's lifecycle://!//! 1. Preview matching handlers so the UI can render pending hook rows.//! 2. Execute every matching handler in precedence order.//! 3. Parse each handler into transcript-visible output plus an optional//!    decision.//! 4. Fold the decisions conservatively: any deny wins, otherwise the last//!    allow wins, otherwise there is no hook verdict.use std::path::PathBuf;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::PermissionRequestCommandInput;use crate::schema::SubagentCommandInputFields;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 serde_json::Value;#[derive(Debug, Clone)]pub struct PermissionRequestRequest {    pub session_id: ThreadId,    pub turn_id: String,    pub subagent: Option<common::SubagentHookContext>,    pub cwd: PathBuf,    pub transcript_path: Option<PathBuf>,    pub model: String,    pub permission_mode: String,    pub tool_name: String,    pub matcher_aliases: Vec<String>,    pub run_id_suffix: String,    pub tool_input: Value,}#[derive(Debug, Clone, PartialEq, Eq)]pub enum PermissionRequestDecision {    Allow,    Deny { message: String },}#[derive(Debug)]pub struct PermissionRequestOutcome {    pub hook_events: Vec<HookCompletedEvent>,    pub decision: Option<PermissionRequestDecision>,}#[derive(Debug, Default, PartialEq, Eq)]struct PermissionRequestHandlerData {    decision: Option<PermissionRequestDecision>,}pub(crate) fn preview(    handlers: &[ConfiguredHandler],    request: &PermissionRequestRequest,) -> Vec<HookRunSummary> {    let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);    dispatcher::select_handlers_for_matcher_inputs(        handlers,        HookEventName::PermissionRequest,        &matcher_inputs,    )    .into_iter()    .map(|handler| {        common::hook_run_for_tool_use(            dispatcher::running_summary(&handler),            &request.run_id_suffix,        )    })    .collect()}pub(crate) async fn run(    handlers: &[ConfiguredHandler],    shell: &CommandShell,    request: PermissionRequestRequest,) -> PermissionRequestOutcome {    let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);    let matched = dispatcher::select_handlers_for_matcher_inputs(        handlers,        HookEventName::PermissionRequest,        &matcher_inputs,    );    if matched.is_empty() {        return PermissionRequestOutcome {            hook_events: Vec::new(),            decision: None,        };    }    let input_json = match serde_json::to_string(&build_command_input(&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 permission request hook input: {error}"),                &request.run_id_suffix,            );            return PermissionRequestOutcome {                hook_events,                decision: None,            };        }    };    let results = dispatcher::execute_handlers(        shell,        matched,        input_json,        request.cwd.as_path(),        Some(request.turn_id.clone()),        parse_completed,    )    .await;    // Preserve the most specific matching allow, but treat any deny as final so    // broader policy layers cannot accidentally overrule a more specific block.    let decision = resolve_permission_request_decision(        results            .iter()            .filter_map(|result| result.data.decision.as_ref()),    );    PermissionRequestOutcome {        hook_events: results            .into_iter()            .map(|result| {                common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix)            })            .collect(),        decision,    }}/// Resolve matching hook decisions conservatively: any deny wins immediately;/// otherwise keep the highest-precedence allow so more specific handlers/// override broader ones.fn resolve_permission_request_decision<'a>(    decisions: impl IntoIterator<Item = &'a PermissionRequestDecision>,) -> Option<PermissionRequestDecision> {    let mut resolved_allow = None;    for decision in decisions {        match decision {            PermissionRequestDecision::Allow => {                resolved_allow = Some(PermissionRequestDecision::Allow);            }            PermissionRequestDecision::Deny { message } => {                return Some(PermissionRequestDecision::Deny {                    message: message.clone(),                });            }        }    }    resolved_allow}fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput {    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());    PermissionRequestCommandInput {        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: "PermissionRequest".to_string(),        model: request.model.clone(),        permission_mode: request.permission_mode.clone(),        tool_name: request.tool_name.clone(),        tool_input: request.tool_input.clone(),    }}fn parse_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,) -> dispatcher::ParsedHandler<PermissionRequestHandlerData> {    let mut entries = Vec::new();    let mut status = HookRunStatus::Completed;    let mut decision = 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_permission_request(&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(parsed_decision) = parsed.decision {                        match parsed_decision {                            output_parser::PermissionRequestDecision::Allow => {                                decision = Some(PermissionRequestDecision::Allow);                            }                            output_parser::PermissionRequestDecision::Deny { message } => {                                status = HookRunStatus::Blocked;                                entries.push(HookOutputEntry {                                    kind: HookOutputEntryKind::Feedback,                                    text: message.clone(),                                });                                decision = Some(PermissionRequestDecision::Deny { message });                            }                        }                    }                } else if output_parser::looks_like_json(&run_result.stdout) {                    status = HookRunStatus::Failed;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Error,                        text: "hook returned invalid permission-request JSON output".to_string(),                    });                }            }            Some(2) => {                if let Some(message) = common::trimmed_non_empty(&run_result.stderr) {                    status = HookRunStatus::Blocked;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Feedback,                        text: message.clone(),                    });                    decision = Some(PermissionRequestDecision::Deny { message });                } else {                    status = HookRunStatus::Failed;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Error,                        text: "PermissionRequest hook exited with code 2 but did not write a denial 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: PermissionRequestHandlerData { decision },        completion_order: 0,    }}#[cfg(test)]mod tests {    use pretty_assertions::assert_eq;    use super::PermissionRequestDecision;    use super::resolve_permission_request_decision;    #[test]    fn permission_request_deny_overrides_earlier_allow() {        let decisions = [            PermissionRequestDecision::Allow,            PermissionRequestDecision::Deny {                message: "repo deny".to_string(),            },        ];        assert_eq!(            resolve_permission_request_decision(decisions.iter()),            Some(PermissionRequestDecision::Deny {                message: "repo deny".to_string(),            })        );    }    #[test]    fn permission_request_returns_allow_when_no_handler_denies() {        let decisions = [            PermissionRequestDecision::Allow,            PermissionRequestDecision::Allow,        ];        assert_eq!(            resolve_permission_request_decision(decisions.iter()),            Some(PermissionRequestDecision::Allow)        );    }    #[test]    fn permission_request_returns_none_when_no_handler_decides() {        let decisions = Vec::<PermissionRequestDecision>::new();        assert_eq!(resolve_permission_request_decision(decisions.iter()), None);    }}