#[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()) ); }}