use crate::contract::{ContextChannel, HookOutcome};
use serde_json::Value;
pub fn parse_handler_output(exit_code: i32, stdout: &str, stderr: &str) -> Vec<HookOutcome> {
let stdout = stdout.trim();
let stderr = stderr.trim();
if exit_code == 2 {
return vec![deny_from_streams(stdout, stderr)];
}
if exit_code == 3 {
let mut out = vec![HookOutcome::Ask];
if !stdout.is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(stdout) {
out.extend(parse_decision_json(&v).into_iter().filter(|o| {
!matches!(o, HookOutcome::Ask | HookOutcome::Continue)
}));
} else if looks_like_json_object(stdout) {
} else {
out.push(HookOutcome::AdditionalContext {
text: stdout.to_string(),
channel: ContextChannel::PrePrompt,
});
}
}
return out;
}
if exit_code != 0 {
return vec![];
}
if stdout.is_empty() {
return vec![HookOutcome::Continue];
}
match serde_json::from_str::<Value>(stdout) {
Ok(v) => parse_decision_json(&v),
Err(_) => vec![HookOutcome::AdditionalContext {
text: stdout.to_string(),
channel: ContextChannel::PrePrompt,
}],
}
}
fn looks_like_json_object(s: &str) -> bool {
let t = s.trim_start();
t.starts_with('{') || t.starts_with('[')
}
fn deny_from_streams(stdout: &str, stderr: &str) -> HookOutcome {
if !stdout.is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(stdout) {
if let Some(r) = extract_deny_reason(&v) {
return HookOutcome::deny(r);
}
}
return HookOutcome::deny(stdout.to_string());
}
if !stderr.is_empty() {
return HookOutcome::deny(stderr.to_string());
}
HookOutcome::deny("hook denied (exit 2)")
}
fn extract_deny_reason(v: &Value) -> Option<String> {
if let Some(hso) = v.get("hookSpecificOutput") {
if hso.get("permissionDecision").and_then(|x| x.as_str()) == Some("deny") {
return Some(
hso.get("permissionDecisionReason")
.and_then(|x| x.as_str())
.or_else(|| v.get("reason").and_then(|x| x.as_str()))
.unwrap_or("denied by hook")
.to_string(),
);
}
}
if v.get("decision").and_then(|x| x.as_str()) == Some("block") {
return Some(
v.get("reason")
.and_then(|x| x.as_str())
.unwrap_or("blocked by hook")
.to_string(),
);
}
None
}
fn parse_decision_json(v: &Value) -> Vec<HookOutcome> {
let mut out = Vec::new();
if let Some(reason) = extract_deny_reason(v) {
out.push(HookOutcome::deny(reason));
return out;
}
if let Some(hso) = v.get("hookSpecificOutput") {
match hso.get("permissionDecision").and_then(|x| x.as_str()) {
Some("allow") => out.push(HookOutcome::Allow),
Some("ask") => out.push(HookOutcome::Ask),
Some("deny") => {}
_ => {}
}
if let Some(updated) = hso.get("updatedInput") {
if !updated.is_null() {
out.push(HookOutcome::MutateArgs {
args: updated.clone(),
});
}
}
if let Some(ctx) = hso.get("additionalContext").and_then(|x| x.as_str()) {
if !ctx.is_empty() {
out.push(HookOutcome::AdditionalContext {
text: ctx.to_string(),
channel: ContextChannel::PrePrompt,
});
}
}
}
if let Some(ctx) = v.get("additionalContext").and_then(|x| x.as_str()) {
if !ctx.is_empty() {
out.push(HookOutcome::AdditionalContext {
text: ctx.to_string(),
channel: ContextChannel::PrePrompt,
});
}
}
if let Some(updated) = v.get("updatedInput") {
if updated.is_object() {
out.push(HookOutcome::MutateArgs {
args: updated.clone(),
});
}
}
if out.is_empty() {
out.push(HookOutcome::Continue);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exit_2_denies_stdout() {
let o = parse_handler_output(2, "blocked", "");
assert!(matches!(o.as_slice(), [HookOutcome::Deny { reason }] if reason == "blocked"));
}
#[test]
fn exit_2_denies_stderr_when_no_stdout() {
let o = parse_handler_output(2, "", "nope");
assert!(matches!(o.as_slice(), [HookOutcome::Deny { reason }] if reason == "nope"));
}
#[test]
fn exit_2_structured_json() {
let body = json!({
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "policy"
}
})
.to_string();
let o = parse_handler_output(2, &body, "");
assert!(matches!(o.as_slice(), [HookOutcome::Deny { reason }] if reason == "policy"));
}
#[test]
fn exit_2_decision_block() {
let body = json!({"decision": "block", "reason": "x"}).to_string();
let o = parse_handler_output(2, &body, "");
assert!(matches!(o.as_slice(), [HookOutcome::Deny { reason }] if reason == "x"));
}
#[test]
fn empty_success_continues() {
let o = parse_handler_output(0, "", "");
assert_eq!(o, vec![HookOutcome::Continue]);
}
#[test]
fn plain_text_is_context() {
let o = parse_handler_output(0, "hello world", "");
assert!(matches!(
o.as_slice(),
[HookOutcome::AdditionalContext { text, .. }] if text == "hello world"
));
}
#[test]
fn claude_updated_input() {
let body = json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "rtk git status" },
"additionalContext": "note"
}
})
.to_string();
let o = parse_handler_output(0, &body, "");
assert!(o.iter().any(|x| matches!(x, HookOutcome::Allow)));
assert!(o.iter().any(|x| matches!(
x,
HookOutcome::MutateArgs { args }
if args.get("command").and_then(|c| c.as_str()) == Some("rtk git status")
)));
assert!(o.iter().any(|x| matches!(
x,
HookOutcome::AdditionalContext { text, .. } if text == "note"
)));
}
#[test]
fn top_level_updated_input() {
let body = json!({"updatedInput": {"command": "x"}}).to_string();
let o = parse_handler_output(0, &body, "");
assert!(o.iter().any(|x| matches!(x, HookOutcome::MutateArgs { .. })));
}
#[test]
fn non_zero_fail_open_empty() {
let o = parse_handler_output(1, "warn", "err");
assert!(o.is_empty());
}
#[test]
fn exit_3_ask_with_json_mutate() {
let body = json!({
"hookSpecificOutput": {
"updatedInput": { "command": "safe" }
}
})
.to_string();
let o = parse_handler_output(3, &body, "");
assert!(o.iter().any(|x| matches!(x, HookOutcome::Ask)));
assert!(o.iter().any(|x| matches!(x, HookOutcome::MutateArgs { .. })));
}
#[test]
fn empty_json_object_is_continue() {
let o = parse_handler_output(0, "{}", "");
assert_eq!(o, vec![HookOutcome::Continue]);
}
}