openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Claude Code hook-output translator.
//!
//! Implements the Claude Code stdout contract documented in the upstream
//! hook-output JSON schema (vendored at
//! `schemas/vendor/claude-code/hook-output.schema.json`).
//!
//! Mapping rationale per event:
//!
//! | Event             | allow / approve | deny                                                         |
//! |-------------------|-----------------|--------------------------------------------------------------|
//! | PreToolUse        | `{}`            | `hookSpecificOutput.permissionDecision = "deny"` (reason inlined when `context` is present) |
//! | UserPromptSubmit  | `{}`            | `decision: "block"` (top-level)                              |
//! | PostToolUse       | `{}`            | `{}` — the tool already ran; deny has no actionable effect   |
//! | Stop / SubagentStop | `{}`          | `{}` — denying a stop would force a runaway loop             |
//! | Notification / PreCompact / SessionEnd | `{}`           | `{}` — no deny channel defined by Claude Code |
//! | SessionStart      | `additionalContext` when `context` present, else `{}` | `{}` — no deny channel |
//! | Unknown(_)        | `{}`            | `{}`                                                         |
//!
//! The `"ask"` decision is only meaningful for PreToolUse.
//!
//! Claude Code treats `{}` as "hook passed, continue normally" for every
//! event type — so unknown events or degraded denies never break the
//! agent.

use super::{empty, Verdict};
use serde_json::{json, Value};

/// Translate a Claude Code verdict for the named event type.
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
    match event {
        "pre_tool_use" => pre_tool_use(verdict),
        "user_prompt_submit" => user_prompt_submit(verdict),
        "session_start" => session_start(verdict),
        // PostToolUse, Stop, SubagentStop, Notification, PreCompact,
        // SessionEnd, and any future/unknown event all degrade to the
        // universal safe default: {}.
        _ => empty(),
    }
}

fn pre_tool_use(verdict: &Verdict<'_>) -> Value {
    // deny+context inlines "headline: body" as permissionDecisionReason so
    // the cloud's intent travels on the wire even though Claude Code shows
    // its generic "blocked" message. allow+context has no surface here —
    // SessionStart owns additionalContext for that.
    let (decision, reason_owned): (&str, Option<String>) = match (verdict.decision, verdict.context)
    {
        ("deny", Some(ctx)) => ("deny", Some(format!("{}: {}", ctx.headline, ctx.body))),
        ("deny", None) => ("deny", verdict.reason.map(str::to_string)),
        ("ask", _) => ("ask", verdict.reason.map(str::to_string)),
        _ => return empty(),
    };
    let mut specific = json!({
        "hookEventName": "PreToolUse",
        "permissionDecision": decision,
    });
    if let Some(reason) = reason_owned {
        specific["permissionDecisionReason"] = Value::String(reason);
    }
    json!({ "hookSpecificOutput": specific })
}

/// SessionStart has no native deny channel — only `additionalContext`
/// injection (per Claude Code spec). When the cloud returns a `context`
/// payload we surface it here so the model is aware of pending alerts /
/// configuration state at the start of the session. Without context, the
/// translator emits `{}` and the session proceeds silently.
fn session_start(verdict: &Verdict<'_>) -> Value {
    let Some(ctx) = verdict.context else {
        return empty();
    };
    let combined = format!("{}\n\n{}", ctx.headline, ctx.body);
    json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": combined,
        }
    })
}

fn user_prompt_submit(verdict: &Verdict<'_>) -> Value {
    // UserPromptSubmit's only agent-blocking channel is top-level
    // `decision: "block"`. `hookSpecificOutput.additionalContext` is for
    // context injection, which the forwarder never does — context flows
    // from the agent through the cloud, not the other way.
    if verdict.decision != "deny" {
        return empty();
    }
    let reason = verdict.reason.unwrap_or("Blocked by OpenLatch");
    json!({ "decision": "block", "reason": reason })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pre_tool_use_allow_is_empty() {
        let out = translate("pre_tool_use", &Verdict::allow());
        assert_eq!(out, empty());
    }

    #[test]
    fn pre_tool_use_deny_uses_hook_specific_output() {
        let v = Verdict {
            decision: "deny",
            reason: Some("credentials detected"),
            context: None,
        };
        let out = translate("pre_tool_use", &v);
        assert_eq!(
            out,
            json!({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": "credentials detected",
                }
            })
        );
    }

    /// D13 confirmation: a local policy deny renders into Claude Code's native
    /// deny shape with the deciding rule's `reason` **verbatim** — no prefix, no
    /// rewrite. This path never ran in production before local policy existed,
    /// so it is asserted rather than assumed. `deny` is the only agent surface
    /// v1 ships (the other four agents in the PRD's frozen list fall through to
    /// `{}` and are v1.1, together with their translators).
    #[test]
    fn pre_tool_use_deny_renders_a_policy_rules_reason_verbatim() {
        let v = Verdict {
            decision: "deny",
            reason: Some("Canary enforce"),
            context: None,
        };
        let out = translate("pre_tool_use", &v);
        assert_eq!(
            out,
            json!({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": "Canary enforce",
                }
            })
        );
    }

    #[test]
    fn pre_tool_use_ask_surfaces_without_reason() {
        let v = Verdict {
            decision: "ask",
            reason: None,
            context: None,
        };
        let out = translate("pre_tool_use", &v);
        assert_eq!(
            out,
            json!({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "ask",
                }
            })
        );
    }

    #[test]
    fn user_prompt_submit_deny_blocks() {
        let v = Verdict {
            decision: "deny",
            reason: Some("prompt injection"),
            context: None,
        };
        let out = translate("user_prompt_submit", &v);
        assert_eq!(
            out,
            json!({ "decision": "block", "reason": "prompt injection" })
        );
    }

    #[test]
    fn user_prompt_submit_allow_is_empty() {
        let out = translate("user_prompt_submit", &Verdict::allow());
        assert_eq!(out, empty());
    }

    #[test]
    fn stop_any_verdict_is_empty() {
        // Stop hook: approve/allow/deny all degrade to {} so Claude
        // behaves exactly as it would without OpenLatch in the loop.
        for decision in ["allow", "approve", "deny"] {
            let v = Verdict {
                decision,
                reason: Some("irrelevant"),
                context: None,
            };
            assert_eq!(translate("stop", &v), empty(), "decision={decision}");
            assert_eq!(
                translate("subagent_stop", &v),
                empty(),
                "decision={decision}"
            );
        }
    }

    #[test]
    fn post_tool_use_any_verdict_is_empty() {
        for decision in ["allow", "approve", "deny"] {
            let v = Verdict {
                decision,
                reason: None,
                context: None,
            };
            assert_eq!(
                translate("post_tool_use", &v),
                empty(),
                "decision={decision}"
            );
        }
    }

    #[test]
    fn pre_tool_use_deny_with_context_emits_hard_deny() {
        let ctx = super::super::VerdictContext {
            headline: "Configuration alert pending",
            body: "MCP server 'evil' was added; review before running.",
        };
        let v = Verdict {
            decision: "deny",
            reason: None,
            context: Some(&ctx),
        };
        let out = translate("pre_tool_use", &v);
        assert_eq!(
            out["hookSpecificOutput"]["permissionDecision"]
                .as_str()
                .unwrap(),
            "deny",
            "deny+context must surface as a hard deny"
        );
        let reason = out["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()
            .unwrap();
        assert!(reason.contains("Configuration alert pending"));
        assert!(reason.contains("MCP server 'evil'"));
    }

    #[test]
    fn pre_tool_use_allow_with_context_is_empty() {
        // allow + context on PreToolUse is not a surface Claude Code can use
        // (no additionalContext channel here); we degrade to silent allow.
        let ctx = super::super::VerdictContext {
            headline: "Heads up",
            body: "Two new skills landed.",
        };
        let v = Verdict {
            decision: "allow",
            reason: None,
            context: Some(&ctx),
        };
        assert_eq!(translate("pre_tool_use", &v), empty());
    }

    #[test]
    fn session_start_with_context_injects_additional_context() {
        let ctx = super::super::VerdictContext {
            headline: "Configuration alert",
            body: "Two new skills were added since your last session.",
        };
        let v = Verdict {
            decision: "allow",
            reason: None,
            context: Some(&ctx),
        };
        let out = translate("session_start", &v);
        assert_eq!(
            out["hookSpecificOutput"]["hookEventName"].as_str().unwrap(),
            "SessionStart"
        );
        let injected = out["hookSpecificOutput"]["additionalContext"]
            .as_str()
            .unwrap();
        assert!(injected.contains("Configuration alert"));
        assert!(injected.contains("new skills"));
    }

    #[test]
    fn session_start_without_context_is_empty() {
        let out = translate("session_start", &Verdict::allow());
        assert_eq!(out, empty());
    }

    #[test]
    fn unknown_event_is_empty() {
        let out = translate("some_future_event", &Verdict::allow());
        assert_eq!(out, empty());
    }

    #[test]
    fn notification_and_session_events_are_empty() {
        for ev in [
            "notification",
            "pre_compact",
            "session_start",
            "session_end",
        ] {
            assert_eq!(translate(ev, &Verdict::allow()), empty(), "event={ev}");
        }
    }
}