openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
//! 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"`             |
//! | 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 / SessionStart / SessionEnd | `{}` | `{}` — no deny channel defined by Claude Code |
//! | 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),
        // PostToolUse, Stop, SubagentStop, Notification, PreCompact,
        // SessionStart, SessionEnd, and any future/unknown event all
        // degrade to the universal safe default: {}.
        _ => empty(),
    }
}

fn pre_tool_use(verdict: &Verdict<'_>) -> Value {
    let decision = match verdict.decision {
        "deny" => "deny",
        "ask" => "ask",
        // "allow", "approve", or anything else → silent allow = {}.
        _ => return empty(),
    };
    let mut specific = json!({
        "hookEventName": "PreToolUse",
        "permissionDecision": decision,
    });
    if let Some(reason) = verdict.reason {
        specific["permissionDecisionReason"] = Value::String(reason.to_string());
    }
    json!({ "hookSpecificOutput": specific })
}

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"),
        };
        let out = translate("pre_tool_use", &v);
        assert_eq!(
            out,
            json!({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": "credentials detected",
                }
            })
        );
    }

    #[test]
    fn pre_tool_use_ask_surfaces_without_reason() {
        let v = Verdict {
            decision: "ask",
            reason: 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"),
        };
        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"),
            };
            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,
            };
            assert_eq!(
                translate("post_tool_use", &v),
                empty(),
                "decision={decision}"
            );
        }
    }

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