mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
16d85e2708
# What When a normal hook fires inside a thread-spawned subagent, Codex now includes these optional top-level fields in the hook input: - `agent_id`: the child thread id - `agent_type`: the subagent role Root-agent hook inputs omit these fields. `SubagentStart` and `SubagentStop` keep their existing required `agent_id` and `agent_type` fields because those events are inherently subagent-scoped. This does not change matcher behavior. Tool hooks still match on tool name, compact hooks still match on trigger, and `UserPromptSubmit` still ignores matchers. Only `SubagentStart` and `SubagentStop` match on `agent_type`.
337 lines
12 KiB
Rust
337 lines
12 KiB
Rust
//! 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);
|
|
}
|
|
}
|