mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
a576be2b73
# Why Fixes #23993. Hook command output schemas are published as the contract for hook authors and schema-driven tooling. The event-specific output schemas previously described `hookSpecificOutput.hookEventName` as the global `HookEventNameWire` enum, so a `pre-tool-use.command.output` schema would validate mismatched values like `PostToolUse`. That made the schemas less precise than the intended event-specific contract. # What Constrain each hook-specific output schema to the matching literal `hookEventName` value, mirroring the existing input-schema shape. Also split `SubagentStartHookSpecificOutputWire` from the session-start output wire so `subagent-start.command.output.schema.json` can emit `const: "SubagentStart"` instead of sharing the session-start definition. # Verification - `cargo nextest run -p codex-hooks` - `just fix -p codex-hooks` - `just argument-comment-lint -p codex-hooks -- --all-targets`
592 lines
19 KiB
Rust
592 lines
19 KiB
Rust
#[derive(Debug, Clone)]
|
|
pub(crate) struct UniversalOutput {
|
|
pub continue_processing: bool,
|
|
pub stop_reason: Option<String>,
|
|
pub suppress_output: bool,
|
|
pub system_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct SessionStartOutput {
|
|
pub universal: UniversalOutput,
|
|
pub additional_context: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PreToolUseOutput {
|
|
pub universal: UniversalOutput,
|
|
pub block_reason: Option<String>,
|
|
pub additional_context: Option<String>,
|
|
pub updated_input: Option<serde_json::Value>,
|
|
pub invalid_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum PermissionRequestDecision {
|
|
Allow,
|
|
Deny { message: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PermissionRequestOutput {
|
|
pub universal: UniversalOutput,
|
|
pub decision: Option<PermissionRequestDecision>,
|
|
pub invalid_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PostToolUseOutput {
|
|
pub universal: UniversalOutput,
|
|
pub should_block: bool,
|
|
pub reason: Option<String>,
|
|
pub invalid_block_reason: Option<String>,
|
|
pub additional_context: Option<String>,
|
|
pub invalid_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct UserPromptSubmitOutput {
|
|
pub universal: UniversalOutput,
|
|
pub should_block: bool,
|
|
pub reason: Option<String>,
|
|
pub invalid_block_reason: Option<String>,
|
|
pub additional_context: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct StopOutput {
|
|
pub universal: UniversalOutput,
|
|
pub should_block: bool,
|
|
pub reason: Option<String>,
|
|
pub invalid_block_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PreCompactOutput {
|
|
pub universal: UniversalOutput,
|
|
pub invalid_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct StatelessHookOutput {
|
|
pub universal: UniversalOutput,
|
|
pub invalid_reason: Option<String>,
|
|
}
|
|
|
|
use crate::schema::BlockDecisionWire;
|
|
use crate::schema::HookUniversalOutputWire;
|
|
use crate::schema::PermissionRequestBehaviorWire;
|
|
use crate::schema::PermissionRequestCommandOutputWire;
|
|
use crate::schema::PermissionRequestDecisionWire;
|
|
use crate::schema::PostCompactCommandOutputWire;
|
|
use crate::schema::PostToolUseCommandOutputWire;
|
|
use crate::schema::PreCompactCommandOutputWire;
|
|
use crate::schema::PreToolUseCommandOutputWire;
|
|
use crate::schema::PreToolUseDecisionWire;
|
|
use crate::schema::PreToolUsePermissionDecisionWire;
|
|
use crate::schema::SessionStartCommandOutputWire;
|
|
use crate::schema::StopCommandOutputWire;
|
|
use crate::schema::SubagentStartCommandOutputWire;
|
|
use crate::schema::SubagentStopCommandOutputWire;
|
|
use crate::schema::UserPromptSubmitCommandOutputWire;
|
|
|
|
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
|
|
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
|
|
Some(session_start_output(
|
|
wire.universal,
|
|
wire.hook_specific_output
|
|
.and_then(|output| output.additional_context),
|
|
))
|
|
}
|
|
|
|
pub(crate) fn parse_subagent_start(stdout: &str) -> Option<SessionStartOutput> {
|
|
let wire: SubagentStartCommandOutputWire = parse_json(stdout)?;
|
|
Some(session_start_output(
|
|
wire.universal,
|
|
wire.hook_specific_output
|
|
.and_then(|output| output.additional_context),
|
|
))
|
|
}
|
|
|
|
fn session_start_output(
|
|
universal: HookUniversalOutputWire,
|
|
additional_context: Option<String>,
|
|
) -> SessionStartOutput {
|
|
SessionStartOutput {
|
|
universal: UniversalOutput::from(universal),
|
|
additional_context,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
|
|
let PreToolUseCommandOutputWire {
|
|
universal: universal_wire,
|
|
decision,
|
|
reason,
|
|
hook_specific_output,
|
|
} = parse_json(stdout)?;
|
|
let universal = UniversalOutput::from(universal_wire);
|
|
let hook_specific_output = hook_specific_output.as_ref();
|
|
let additional_context =
|
|
hook_specific_output.and_then(|output| output.additional_context.clone());
|
|
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
|
|
output.permission_decision.is_some()
|
|
|| output.permission_decision_reason.is_some()
|
|
|| output.updated_input.is_some()
|
|
});
|
|
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
|
|
if use_hook_specific_decision {
|
|
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
|
|
} else {
|
|
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
|
|
}
|
|
});
|
|
let block_reason = if invalid_reason.is_none() {
|
|
if use_hook_specific_decision {
|
|
hook_specific_output.and_then(|output| match output.permission_decision {
|
|
Some(PreToolUsePermissionDecisionWire::Deny) => output
|
|
.permission_decision_reason
|
|
.as_deref()
|
|
.and_then(trimmed_reason),
|
|
_ => None,
|
|
})
|
|
} else {
|
|
match decision.as_ref() {
|
|
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
|
|
Some(PreToolUseDecisionWire::Approve) | None => None,
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let updated_input = if invalid_reason.is_none() {
|
|
hook_specific_output.and_then(|output| {
|
|
matches!(
|
|
output.permission_decision,
|
|
Some(PreToolUsePermissionDecisionWire::Allow)
|
|
)
|
|
.then(|| output.updated_input.clone())
|
|
.flatten()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(PreToolUseOutput {
|
|
universal,
|
|
block_reason,
|
|
additional_context,
|
|
updated_input,
|
|
invalid_reason,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_permission_request(stdout: &str) -> Option<PermissionRequestOutput> {
|
|
let wire: PermissionRequestCommandOutputWire = parse_json(stdout)?;
|
|
let universal = UniversalOutput::from(wire.universal);
|
|
let hook_specific_output = wire.hook_specific_output.as_ref();
|
|
let decision = hook_specific_output.and_then(|output| output.decision.as_ref());
|
|
let invalid_reason = unsupported_permission_request_universal(&universal).or_else(|| {
|
|
hook_specific_output.and_then(|output| {
|
|
unsupported_permission_request_hook_specific_output(output.decision.as_ref())
|
|
})
|
|
});
|
|
let decision = if invalid_reason.is_none() {
|
|
decision.map(permission_request_decision)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(PermissionRequestOutput {
|
|
universal,
|
|
decision,
|
|
invalid_reason,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
|
|
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
|
|
let universal = UniversalOutput::from(wire.universal);
|
|
let invalid_reason = unsupported_post_tool_use_universal(&universal).or_else(|| {
|
|
wire.hook_specific_output
|
|
.as_ref()
|
|
.and_then(unsupported_post_tool_use_hook_specific_output)
|
|
});
|
|
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
|
|
let invalid_block_reason = if should_block
|
|
&& match wire.reason.as_deref() {
|
|
Some(reason) => reason.trim().is_empty(),
|
|
None => true,
|
|
} {
|
|
Some(invalid_block_message("PostToolUse"))
|
|
} else if !should_block && universal.continue_processing && wire.reason.is_some() {
|
|
Some("PostToolUse hook returned reason without decision".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
let additional_context = wire
|
|
.hook_specific_output
|
|
.and_then(|output| output.additional_context);
|
|
|
|
Some(PostToolUseOutput {
|
|
universal,
|
|
should_block: should_block && invalid_reason.is_none() && invalid_block_reason.is_none(),
|
|
reason: wire.reason,
|
|
invalid_block_reason,
|
|
additional_context,
|
|
invalid_reason,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_pre_compact(stdout: &str) -> Option<PreCompactOutput> {
|
|
let wire: PreCompactCommandOutputWire = parse_json(stdout)?;
|
|
let universal = UniversalOutput::from(wire.universal);
|
|
Some(PreCompactOutput {
|
|
universal,
|
|
invalid_reason: None,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_post_compact(stdout: &str) -> Option<StatelessHookOutput> {
|
|
let wire: PostCompactCommandOutputWire = parse_json(stdout)?;
|
|
let universal = UniversalOutput::from(wire.universal);
|
|
Some(StatelessHookOutput {
|
|
universal,
|
|
invalid_reason: None,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
|
|
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
|
|
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
|
|
let invalid_block_reason = if should_block
|
|
&& match wire.reason.as_deref() {
|
|
Some(reason) => reason.trim().is_empty(),
|
|
None => true,
|
|
} {
|
|
Some(invalid_block_message("UserPromptSubmit"))
|
|
} else {
|
|
None
|
|
};
|
|
let additional_context = wire
|
|
.hook_specific_output
|
|
.and_then(|output| output.additional_context);
|
|
Some(UserPromptSubmitOutput {
|
|
universal: UniversalOutput::from(wire.universal),
|
|
should_block: should_block && invalid_block_reason.is_none(),
|
|
reason: wire.reason,
|
|
invalid_block_reason,
|
|
additional_context,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
|
|
let wire: StopCommandOutputWire = parse_json(stdout)?;
|
|
Some(stop_output(
|
|
wire.universal,
|
|
wire.decision,
|
|
wire.reason,
|
|
"Stop",
|
|
))
|
|
}
|
|
|
|
pub(crate) fn parse_subagent_stop(stdout: &str) -> Option<StopOutput> {
|
|
let wire: SubagentStopCommandOutputWire = parse_json(stdout)?;
|
|
Some(stop_output(
|
|
wire.universal,
|
|
wire.decision,
|
|
wire.reason,
|
|
"SubagentStop",
|
|
))
|
|
}
|
|
|
|
fn stop_output(
|
|
universal: HookUniversalOutputWire,
|
|
decision: Option<BlockDecisionWire>,
|
|
reason: Option<String>,
|
|
event_name: &str,
|
|
) -> StopOutput {
|
|
let should_block = matches!(decision, Some(BlockDecisionWire::Block));
|
|
let invalid_block_reason = if should_block
|
|
&& match reason.as_deref() {
|
|
Some(reason) => reason.trim().is_empty(),
|
|
None => true,
|
|
} {
|
|
Some(invalid_block_message(event_name))
|
|
} else {
|
|
None
|
|
};
|
|
StopOutput {
|
|
universal: UniversalOutput::from(universal),
|
|
should_block: should_block && invalid_block_reason.is_none(),
|
|
reason,
|
|
invalid_block_reason,
|
|
}
|
|
}
|
|
|
|
impl From<HookUniversalOutputWire> for UniversalOutput {
|
|
fn from(value: HookUniversalOutputWire) -> Self {
|
|
Self {
|
|
continue_processing: value.r#continue,
|
|
stop_reason: value.stop_reason,
|
|
suppress_output: value.suppress_output,
|
|
system_message: value.system_message,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_json<T>(stdout: &str) -> Option<T>
|
|
where
|
|
T: for<'de> serde::Deserialize<'de>,
|
|
{
|
|
let trimmed = stdout.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
|
|
if !value.is_object() {
|
|
return None;
|
|
}
|
|
serde_json::from_value(value).ok()
|
|
}
|
|
|
|
pub(crate) fn looks_like_json(stdout: &str) -> bool {
|
|
let trimmed = stdout.trim_start();
|
|
trimmed.starts_with('{') || trimmed.starts_with('[')
|
|
}
|
|
|
|
fn invalid_block_message(event_name: &str) -> String {
|
|
format!("{event_name} hook returned decision:block without a non-empty reason")
|
|
}
|
|
|
|
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
|
|
if !universal.continue_processing {
|
|
Some("PreToolUse hook returned unsupported continue:false".to_string())
|
|
} else if universal.stop_reason.is_some() {
|
|
Some("PreToolUse hook returned unsupported stopReason".to_string())
|
|
} else if universal.suppress_output {
|
|
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn unsupported_permission_request_universal(universal: &UniversalOutput) -> Option<String> {
|
|
if !universal.continue_processing {
|
|
Some("PermissionRequest hook returned unsupported continue:false".to_string())
|
|
} else if universal.stop_reason.is_some() {
|
|
Some("PermissionRequest hook returned unsupported stopReason".to_string())
|
|
} else if universal.suppress_output {
|
|
Some("PermissionRequest hook returned unsupported suppressOutput".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
|
|
if universal.suppress_output {
|
|
Some("PostToolUse hook returned unsupported suppressOutput".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn unsupported_permission_request_hook_specific_output(
|
|
decision: Option<&PermissionRequestDecisionWire>,
|
|
) -> Option<String> {
|
|
let decision = decision?;
|
|
if decision.updated_input.is_some() {
|
|
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
|
|
} else if decision.updated_permissions.is_some() {
|
|
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
|
|
} else if decision.interrupt {
|
|
Some("PermissionRequest hook returned unsupported interrupt:true".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn permission_request_decision(
|
|
decision: &PermissionRequestDecisionWire,
|
|
) -> PermissionRequestDecision {
|
|
match decision.behavior {
|
|
PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow,
|
|
PermissionRequestBehaviorWire::Deny => PermissionRequestDecision::Deny {
|
|
message: decision
|
|
.message
|
|
.as_deref()
|
|
.and_then(trimmed_reason)
|
|
.unwrap_or_else(|| "PermissionRequest hook denied approval".to_string()),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn unsupported_post_tool_use_hook_specific_output(
|
|
output: &crate::schema::PostToolUseHookSpecificOutputWire,
|
|
) -> Option<String> {
|
|
if output.updated_mcp_tool_output.is_some() {
|
|
Some("PostToolUse hook returned unsupported updatedMCPToolOutput".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn unsupported_pre_tool_use_hook_specific_output(
|
|
output: &crate::schema::PreToolUseHookSpecificOutputWire,
|
|
) -> Option<String> {
|
|
if output.updated_input.is_some()
|
|
&& !matches!(
|
|
output.permission_decision,
|
|
Some(PreToolUsePermissionDecisionWire::Allow)
|
|
)
|
|
{
|
|
Some("PreToolUse hook returned updatedInput without permissionDecision:allow".to_string())
|
|
} else {
|
|
match output.permission_decision {
|
|
Some(PreToolUsePermissionDecisionWire::Allow) => {
|
|
output.updated_input.is_none().then(|| {
|
|
"PreToolUse hook returned unsupported permissionDecision:allow".to_string()
|
|
})
|
|
}
|
|
Some(PreToolUsePermissionDecisionWire::Ask) => {
|
|
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
|
|
}
|
|
Some(PreToolUsePermissionDecisionWire::Deny) => {
|
|
if output
|
|
.permission_decision_reason
|
|
.as_deref()
|
|
.and_then(trimmed_reason)
|
|
.is_none()
|
|
{
|
|
Some(invalid_pre_tool_use_reason_message())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
None => {
|
|
if output.permission_decision_reason.is_some() {
|
|
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unsupported_pre_tool_use_legacy_decision(
|
|
decision: Option<&PreToolUseDecisionWire>,
|
|
reason: Option<&str>,
|
|
) -> Option<String> {
|
|
match decision {
|
|
Some(PreToolUseDecisionWire::Approve) => {
|
|
Some("PreToolUse hook returned unsupported decision:approve".to_string())
|
|
}
|
|
Some(PreToolUseDecisionWire::Block) => {
|
|
if reason.and_then(trimmed_reason).is_none() {
|
|
Some(invalid_block_message("PreToolUse"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
None => {
|
|
if reason.is_some() {
|
|
Some("PreToolUse hook returned reason without decision".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn invalid_pre_tool_use_reason_message() -> String {
|
|
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
|
|
.to_string()
|
|
}
|
|
|
|
fn trimmed_reason(reason: &str) -> Option<String> {
|
|
let trimmed = reason.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
use super::parse_permission_request;
|
|
|
|
#[test]
|
|
fn permission_request_rejects_reserved_updated_input_field() {
|
|
let parsed = parse_permission_request(
|
|
&json!({
|
|
"continue": true,
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {
|
|
"behavior": "allow",
|
|
"updatedInput": {}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("permission request hook output should parse");
|
|
|
|
assert_eq!(
|
|
parsed.invalid_reason,
|
|
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_request_rejects_reserved_updated_permissions_field() {
|
|
let parsed = parse_permission_request(
|
|
&json!({
|
|
"continue": true,
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {
|
|
"behavior": "allow",
|
|
"updatedPermissions": {}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("permission request hook output should parse");
|
|
|
|
assert_eq!(
|
|
parsed.invalid_reason,
|
|
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_request_rejects_reserved_interrupt_field() {
|
|
let parsed = parse_permission_request(
|
|
&json!({
|
|
"continue": true,
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PermissionRequest",
|
|
"decision": {
|
|
"behavior": "allow",
|
|
"interrupt": true
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("permission request hook output should parse");
|
|
|
|
assert_eq!(
|
|
parsed.invalid_reason,
|
|
Some("PermissionRequest hook returned unsupported interrupt:true".to_string())
|
|
);
|
|
}
|
|
}
|