xz-agent-hooks 0.1.0

Lifecycle hook contract, ordered registry, and wire parsers for agent extension hosts
Documentation
//! Parse Claude Code / Codex-shaped hook handler output.

use crate::contract::{ContextChannel, HookOutcome};
use serde_json::Value;

/// Parse a handler's stdout + exit code into zero or more outcomes.
///
/// Compatibility rules:
/// - Exit code **2** → [`HookOutcome::Deny`] (reason from structured JSON, else stdout, else stderr).
/// - Exit code **3** → Ask (+ optional mutate/context from JSON stdout); used by some rewriters.
/// - Exit code **0** with empty stdout → [`HookOutcome::Continue`].
/// - Exit code **0** with plain non-JSON text → [`HookOutcome::AdditionalContext`].
/// - Exit code **0** with JSON → Claude/Codex fields (see below).
/// - Other non-zero → empty list (fail-open).
///
/// Recognized JSON shapes:
/// ```json
/// {
///   "hookSpecificOutput": {
///     "permissionDecision": "allow" | "deny" | "ask",
///     "permissionDecisionReason": "...",
///     "updatedInput": { ... },
///     "additionalContext": "..."
///   },
///   "decision": "block",
///   "reason": "..."
/// }
/// ```
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 {
        // Ask with optional rewrite body on stdout.
        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) {
                // malformed JSON — fail-open without plain-text inject of braces
            } else {
                // plain command rewrite string (some tools print rewritten command only)
                // Not args JSON — product must interpret; expose as context.
                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]);
    }
}