openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! 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 `block` 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;
pub mod cline;
pub mod codex_cli;

/// 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 HookOutput<'a> {
    /// One of the four frozen verdicts — `"allow"`, `"ask"`, `"block"`,
    /// `"optimize"` — or a `"deny"` / `"approve"` read-alias from a daemon
    /// predating D14.
    ///
    /// The two aliases are NOT read the same way here, and that is deliberate.
    /// `deny` is honoured as a refusal; `approve` falls through to the
    /// allow-through default, because from a pre-D14 daemon it meant "the user
    /// already confirmed this" rather than the authored verdict that
    /// `core::policy::normalise_verdict` maps to `Ask`. The rationale is on
    /// `claude_code::pre_tool_use`.
    pub decision: &'a str,
    /// Optional human-readable reason — surfaced to the user on deny.
    pub reason: Option<&'a str>,
    /// Optional structured context (P2: pending-alert injection). Populated
    /// when the cloud's verdict carries `headline` + `body` for a
    /// SessionStart additionalContext or a PreToolUse `ask` reason.
    pub context: Option<&'a VerdictContext<'a>>,
}

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

/// Compatibility alias for call sites that still name the old carrier.
pub type Verdict<'a> = HookOutput<'a>;

/// Structured context carried alongside a verdict. The headline / body are
/// surfaced via the agent's hook output (Claude Code: `additionalContext`
/// for SessionStart, `permissionDecisionReason` for PreToolUse `ask`).
#[derive(Debug, Clone, Copy)]
pub struct VerdictContext<'a> {
    /// ≤120 chars — short label for the alert.
    pub headline: &'a str,
    /// ≤500 chars — full body.
    pub body: &'a str,
}

/// 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),
        "cline" => cline::translate(event, verdict),
        "codex-cli" => codex_cli::translate(event, verdict),
        _ => empty(),
    }
}

/// Translate optional runtime delivery fields without putting an agent-specific
/// representation in the shared verdict carrier.
pub fn translate_delivery(
    agent: &str,
    event: &str,
    verdict: &Verdict<'_>,
    updated_input: Option<&serde_json::Map<String, Value>>,
    additional_context: Option<&str>,
    system_message: Option<&str>,
    defer: bool,
) -> Value {
    match agent {
        "claude-code" => claude_code::translate_delivery(
            event,
            verdict,
            updated_input,
            additional_context,
            system_message,
            defer,
        ),
        // Cline expresses none of the delivery channels — no defer, no
        // deliverable input rewrite, no notice surface — so delivery is the
        // verdict alone. The arm is still written out: this dispatch is the one
        // the live hook binary calls, and the vendor-schema gate exercises only
        // `translate`, so an agent missing here enforces in tests and nowhere
        // else. `cline::translate_delivery` carries the rationale.
        "cline" => cline::translate_delivery(event, verdict),
        "codex-cli" => {
            codex_cli::translate_delivery(event, verdict, updated_input, system_message, defer)
        }
        _ => 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());
    }

    /// **The arm the vendor guard misses.**
    /// `every_vendored_agent_has_an_arm_and_validates_every_combination`
    /// exercises `translate` only, while `app/openlatch_hook/main.rs` — the
    /// live path, the one a developer's tool call actually takes — calls
    /// `translate_delivery`. A `cline` arm in the first and not the second
    /// leaves every real deny rendering `{}` with a green test suite.
    #[test]
    fn delivery_dispatch_knows_cline() {
        let denied = translate_delivery(
            "cline",
            "pre_tool_use",
            &Verdict {
                decision: "block",
                reason: Some("credential detected"),
                context: None,
            },
            None,
            None,
            None,
            false,
        );
        assert_eq!(
            denied,
            serde_json::json!({ "skip": true, "reason": "credential detected" }),
            "the live hook path dropped a Cline deny"
        );

        // And it never escalates: delivery fields Cline cannot express leave
        // the verdict where it was, which for an allow is the tool running.
        let updated = Map::from_iter([("command".to_string(), serde_json::json!("safe"))]);
        assert_eq!(
            translate_delivery(
                "cline",
                "pre_tool_use",
                &Verdict::allow(),
                Some(&updated),
                Some("an alert is queued"),
                Some("a note"),
                true,
            ),
            empty()
        );
    }

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