Skip to main content

codex_hooks/events/
permission_request.rs

1//! Permission-request hook execution.
2//!
3//! This event runs in the approval path, before guardian or user approval UI is
4//! shown. Unlike `pre_tool_use`, handlers do not rewrite tool input or block by
5//! stopping execution outright; instead they can return a concrete allow/deny
6//! decision, or decline to decide and let the normal approval flow continue.
7//!
8//! The event also mirrors the rest of the hook system's lifecycle:
9//!
10//! 1. Preview matching handlers so the UI can render pending hook rows.
11//! 2. Execute every matching handler in precedence order.
12//! 3. Parse each handler into transcript-visible output plus an optional
13//!    decision.
14//! 4. Fold the decisions conservatively: any deny wins, otherwise the last
15//!    allow wins, otherwise there is no hook verdict.
16use std::path::PathBuf;
17
18use super::common;
19use crate::engine::CommandShell;
20use crate::engine::ConfiguredHandler;
21use crate::engine::command_runner::CommandRunResult;
22use crate::engine::dispatcher;
23use crate::engine::output_parser;
24use crate::schema::PermissionRequestCommandInput;
25use crate::schema::SubagentCommandInputFields;
26use codex_protocol::ThreadId;
27use codex_protocol::protocol::HookCompletedEvent;
28use codex_protocol::protocol::HookEventName;
29use codex_protocol::protocol::HookOutputEntry;
30use codex_protocol::protocol::HookOutputEntryKind;
31use codex_protocol::protocol::HookRunStatus;
32use codex_protocol::protocol::HookRunSummary;
33use serde_json::Value;
34
35#[derive(Debug, Clone)]
36pub struct PermissionRequestRequest {
37    pub session_id: ThreadId,
38    pub turn_id: String,
39    pub subagent: Option<common::SubagentHookContext>,
40    pub cwd: PathBuf,
41    pub transcript_path: Option<PathBuf>,
42    pub model: String,
43    pub permission_mode: String,
44    pub tool_name: String,
45    pub matcher_aliases: Vec<String>,
46    pub run_id_suffix: String,
47    pub tool_input: Value,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum PermissionRequestDecision {
52    Allow,
53    Deny { message: String },
54}
55
56#[derive(Debug)]
57pub struct PermissionRequestOutcome {
58    pub hook_events: Vec<HookCompletedEvent>,
59    pub decision: Option<PermissionRequestDecision>,
60}
61
62#[derive(Debug, Default, PartialEq, Eq)]
63struct PermissionRequestHandlerData {
64    decision: Option<PermissionRequestDecision>,
65}
66
67pub(crate) fn preview(
68    handlers: &[ConfiguredHandler],
69    request: &PermissionRequestRequest,
70) -> Vec<HookRunSummary> {
71    let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
72    dispatcher::select_handlers_for_matcher_inputs(
73        handlers,
74        HookEventName::PermissionRequest,
75        &matcher_inputs,
76    )
77    .into_iter()
78    .map(|handler| {
79        common::hook_run_for_tool_use(
80            dispatcher::running_summary(&handler),
81            &request.run_id_suffix,
82        )
83    })
84    .collect()
85}
86
87pub(crate) async fn run(
88    handlers: &[ConfiguredHandler],
89    shell: &CommandShell,
90    request: PermissionRequestRequest,
91) -> PermissionRequestOutcome {
92    let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
93    let matched = dispatcher::select_handlers_for_matcher_inputs(
94        handlers,
95        HookEventName::PermissionRequest,
96        &matcher_inputs,
97    );
98    if matched.is_empty() {
99        return PermissionRequestOutcome {
100            hook_events: Vec::new(),
101            decision: None,
102        };
103    }
104
105    let input_json = match serde_json::to_string(&build_command_input(&request)) {
106        Ok(input_json) => input_json,
107        Err(error) => {
108            let hook_events = common::serialization_failure_hook_events_for_tool_use(
109                matched,
110                Some(request.turn_id.clone()),
111                format!("failed to serialize permission request hook input: {error}"),
112                &request.run_id_suffix,
113            );
114            return PermissionRequestOutcome {
115                hook_events,
116                decision: None,
117            };
118        }
119    };
120
121    let results = dispatcher::execute_handlers(
122        shell,
123        matched,
124        input_json,
125        request.cwd.as_path(),
126        Some(request.turn_id.clone()),
127        parse_completed,
128    )
129    .await;
130
131    // Preserve the most specific matching allow, but treat any deny as final so
132    // broader policy layers cannot accidentally overrule a more specific block.
133    let decision = resolve_permission_request_decision(
134        results
135            .iter()
136            .filter_map(|result| result.data.decision.as_ref()),
137    );
138
139    PermissionRequestOutcome {
140        hook_events: results
141            .into_iter()
142            .map(|result| {
143                common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix)
144            })
145            .collect(),
146        decision,
147    }
148}
149
150/// Resolve matching hook decisions conservatively: any deny wins immediately;
151/// otherwise keep the highest-precedence allow so more specific handlers
152/// override broader ones.
153fn resolve_permission_request_decision<'a>(
154    decisions: impl IntoIterator<Item = &'a PermissionRequestDecision>,
155) -> Option<PermissionRequestDecision> {
156    let mut resolved_allow = None;
157    for decision in decisions {
158        match decision {
159            PermissionRequestDecision::Allow => {
160                resolved_allow = Some(PermissionRequestDecision::Allow);
161            }
162            PermissionRequestDecision::Deny { message } => {
163                return Some(PermissionRequestDecision::Deny {
164                    message: message.clone(),
165                });
166            }
167        }
168    }
169    resolved_allow
170}
171
172fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput {
173    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
174    PermissionRequestCommandInput {
175        session_id: request.session_id.to_string(),
176        turn_id: request.turn_id.clone(),
177        agent_id: subagent.agent_id,
178        agent_type: subagent.agent_type,
179        transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
180        cwd: request.cwd.display().to_string(),
181        hook_event_name: "PermissionRequest".to_string(),
182        model: request.model.clone(),
183        permission_mode: request.permission_mode.clone(),
184        tool_name: request.tool_name.clone(),
185        tool_input: request.tool_input.clone(),
186    }
187}
188
189fn parse_completed(
190    handler: &ConfiguredHandler,
191    run_result: CommandRunResult,
192    turn_id: Option<String>,
193) -> dispatcher::ParsedHandler<PermissionRequestHandlerData> {
194    let mut entries = Vec::new();
195    let mut status = HookRunStatus::Completed;
196    let mut decision = None;
197
198    match run_result.error.as_deref() {
199        Some(error) => {
200            status = HookRunStatus::Failed;
201            entries.push(HookOutputEntry {
202                kind: HookOutputEntryKind::Error,
203                text: error.to_string(),
204            });
205        }
206        None => match run_result.exit_code {
207            Some(0) => {
208                let trimmed_stdout = run_result.stdout.trim();
209                if trimmed_stdout.is_empty() {
210                } else if let Some(parsed) =
211                    output_parser::parse_permission_request(&run_result.stdout)
212                {
213                    if let Some(system_message) = parsed.universal.system_message {
214                        entries.push(HookOutputEntry {
215                            kind: HookOutputEntryKind::Warning,
216                            text: system_message,
217                        });
218                    }
219                    if let Some(invalid_reason) = parsed.invalid_reason {
220                        status = HookRunStatus::Failed;
221                        entries.push(HookOutputEntry {
222                            kind: HookOutputEntryKind::Error,
223                            text: invalid_reason,
224                        });
225                    } else if let Some(parsed_decision) = parsed.decision {
226                        match parsed_decision {
227                            output_parser::PermissionRequestDecision::Allow => {
228                                decision = Some(PermissionRequestDecision::Allow);
229                            }
230                            output_parser::PermissionRequestDecision::Deny { message } => {
231                                status = HookRunStatus::Blocked;
232                                entries.push(HookOutputEntry {
233                                    kind: HookOutputEntryKind::Feedback,
234                                    text: message.clone(),
235                                });
236                                decision = Some(PermissionRequestDecision::Deny { message });
237                            }
238                        }
239                    }
240                } else if output_parser::looks_like_json(&run_result.stdout) {
241                    status = HookRunStatus::Failed;
242                    entries.push(HookOutputEntry {
243                        kind: HookOutputEntryKind::Error,
244                        text: "hook returned invalid permission-request JSON output".to_string(),
245                    });
246                }
247            }
248            Some(2) => {
249                if let Some(message) = common::trimmed_non_empty(&run_result.stderr) {
250                    status = HookRunStatus::Blocked;
251                    entries.push(HookOutputEntry {
252                        kind: HookOutputEntryKind::Feedback,
253                        text: message.clone(),
254                    });
255                    decision = Some(PermissionRequestDecision::Deny { message });
256                } else {
257                    status = HookRunStatus::Failed;
258                    entries.push(HookOutputEntry {
259                        kind: HookOutputEntryKind::Error,
260                        text: "PermissionRequest hook exited with code 2 but did not write a denial reason to stderr".to_string(),
261                    });
262                }
263            }
264            Some(exit_code) => {
265                status = HookRunStatus::Failed;
266                entries.push(HookOutputEntry {
267                    kind: HookOutputEntryKind::Error,
268                    text: format!("hook exited with code {exit_code}"),
269                });
270            }
271            None => {
272                status = HookRunStatus::Failed;
273                entries.push(HookOutputEntry {
274                    kind: HookOutputEntryKind::Error,
275                    text: "hook exited without a status code".to_string(),
276                });
277            }
278        },
279    }
280
281    let completed = HookCompletedEvent {
282        turn_id,
283        run: dispatcher::completed_summary(handler, &run_result, status, entries),
284    };
285
286    dispatcher::ParsedHandler {
287        completed,
288        data: PermissionRequestHandlerData { decision },
289        completion_order: 0,
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use pretty_assertions::assert_eq;
296
297    use super::PermissionRequestDecision;
298    use super::resolve_permission_request_decision;
299
300    #[test]
301    fn permission_request_deny_overrides_earlier_allow() {
302        let decisions = [
303            PermissionRequestDecision::Allow,
304            PermissionRequestDecision::Deny {
305                message: "repo deny".to_string(),
306            },
307        ];
308
309        assert_eq!(
310            resolve_permission_request_decision(decisions.iter()),
311            Some(PermissionRequestDecision::Deny {
312                message: "repo deny".to_string(),
313            })
314        );
315    }
316
317    #[test]
318    fn permission_request_returns_allow_when_no_handler_denies() {
319        let decisions = [
320            PermissionRequestDecision::Allow,
321            PermissionRequestDecision::Allow,
322        ];
323
324        assert_eq!(
325            resolve_permission_request_decision(decisions.iter()),
326            Some(PermissionRequestDecision::Allow)
327        );
328    }
329
330    #[test]
331    fn permission_request_returns_none_when_no_handler_decides() {
332        let decisions = Vec::<PermissionRequestDecision>::new();
333
334        assert_eq!(resolve_permission_request_decision(decisions.iter()), None);
335    }
336}