openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! Hook-output translation: OpenLatch verdict → agent-specific stdout JSON.
//!
//! Each AI agent that openlatch-client supports has its own hook-output
//! protocol — Claude Code expects `{hookSpecificOutput: {...}}` or
//! `{decision: "block", reason: ...}`; Cursor, Windsurf, and the others
//! publish their own shapes. The daemon speaks OpenLatch's agent-neutral
//! `VerdictResponse`; the `openlatch-hook` binary translates that into
//! whatever the caller agent expects before writing stdout.
//!
//! Design invariants (documented in `.claude/rules/envelope-format.md`):
//!
//! 1. **Empty `{}` is the universal fail-safe.** Every agent's hook
//!    protocol treats an empty object as "continue normally". Any unknown
//!    `(agent, event)` tuple degrades to `{}` so a new agent or event
//!    never produces invalid output.
//! 2. **Deny enforcement is event-scoped.** Only pre-action events
//!    (PreToolUse, UserPromptSubmit on Claude Code) have a place to
//!    surface a deny back to the agent. Post-action or notification
//!    events degrade `deny` verdicts to `{}` — the cloud-side audit
//!    still has the record.
//! 3. **Pure functions, no I/O.** Translation is one match on agent,
//!    one match on event, one `serde_json::json!`. No allocation beyond
//!    the output JSON.

use serde_json::{Map, Value};

pub mod claude_code;

/// Verdict in its minimal form for translators — a decision string plus an
/// optional human-readable reason. Deliberately decoupled from
/// [`crate::envelope::VerdictResponse`] so this module compiles into the
/// `openlatch-hook` binary without the full-cli feature set.
#[derive(Debug, Clone, Copy)]
pub struct Verdict<'a> {
    /// One of `"allow"`, `"approve"`, `"deny"`, `"ask"`.
    pub decision: &'a str,
    /// Optional human-readable reason — surfaced to the user on deny.
    pub reason: Option<&'a str>,
}

impl Verdict<'_> {
    /// The silent "do nothing" verdict — fail-open default.
    pub const fn allow() -> Self {
        Self {
            decision: "allow",
            reason: None,
        }
    }
}

/// Translate a verdict into the agent-specific hook-output JSON value.
///
/// `agent` is the CloudEvent `source` wire string (`"claude-code"`, etc.)
/// and `event` is the CloudEvent `type` wire string (`"pre_tool_use"`,
/// etc.). Unknown agents return `{}`; unknown events within a known agent
/// also return `{}`.
pub fn translate(agent: &str, event: &str, verdict: &Verdict<'_>) -> Value {
    match agent {
        "claude-code" => claude_code::translate(event, verdict),
        _ => empty(),
    }
}

/// Empty JSON object — the universal "continue normally" signal.
#[inline]
pub fn empty() -> Value {
    Value::Object(Map::new())
}

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

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

    #[test]
    fn empty_is_literally_empty_object() {
        let s = serde_json::to_string(&empty()).unwrap();
        assert_eq!(s, "{}");
    }
}