openlatch-client 0.6.14

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! OpenCode's verdict line (AGT-5 C-2).
//!
//! The OpenLatch plugin reads exactly one line from `openlatch-hook` and knows
//! two shapes: `{"decision":"allow"}` returns normally, and
//! `{"decision":"block","reason":R}` with a non-empty `R` throws (and, for a
//! prompt, toasts). Anything else is Allow. The translator therefore emits only
//! those two shapes, on every event, so a malformed line can never reach a
//! plugin that would have to guess.
//!
//! It never emits Ask: OpenCode cannot prompt and cannot hold. The daemon has
//! already applied the policy author's fallback for non-prompting agents, so an
//! Ask arriving here is rendered as Allow (and stamped `olaskdegraded` by the
//! daemon), never as a Block.

use serde_json::{json, Value};

use super::Verdict;

/// The reason on a Block that carried none: a bare refusal reads to the model
/// as a transient failure and invites a retry of the same call.
const DEFAULT_DENY_REASON: &str = "Blocked by OpenLatch policy.";

/// The only events the plugin waits on; every other invocation is
/// capture-only and its output is never read.
const DECIDING: &[&str] = &["pre_tool_use", "user_prompt_submit"];

/// Render a verdict as the plugin's one line.
#[must_use]
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
    if !DECIDING.contains(&event) {
        return allow();
    }
    match (verdict.decision, verdict.context) {
        ("block" | "deny", Some(ctx)) => block(&format!("{}: {}", ctx.headline, ctx.body)),
        ("block" | "deny", None) => block(
            verdict
                .reason
                .filter(|r| !r.trim().is_empty())
                .unwrap_or(DEFAULT_DENY_REASON),
        ),
        _ => allow(),
    }
}

/// The live hook's dispatch. OpenCode has no channel for context, a notice, a
/// defer or an argument rewrite, so delivery is the translation itself.
#[must_use]
pub fn translate_delivery(event: &str, verdict: &Verdict<'_>) -> Value {
    translate(event, verdict)
}

fn allow() -> Value {
    json!({ "decision": "allow" })
}

fn block(reason: &str) -> Value {
    json!({ "decision": "block", "reason": reason })
}

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

    fn v<'a>(decision: &'a str, reason: Option<&'a str>) -> Verdict<'a> {
        Verdict {
            decision,
            reason,
            context: None,
        }
    }

    #[test]
    fn a_block_on_a_deciding_event_carries_its_reason() {
        for event in DECIDING {
            assert_eq!(
                translate(event, &v("block", Some("R-rm"))),
                json!({"decision":"block","reason":"R-rm"})
            );
            assert_eq!(
                translate(event, &v("deny", None)),
                json!({"decision":"block","reason":DEFAULT_DENY_REASON})
            );
            assert_eq!(
                translate(event, &v("block", Some("  "))),
                json!({"decision":"block","reason":DEFAULT_DENY_REASON})
            );
        }
    }

    #[test]
    fn ask_and_everything_else_is_allow_never_a_block() {
        for decision in ["allow", "ask", "optimize", "approve", "whatever"] {
            assert_eq!(
                translate("pre_tool_use", &v(decision, Some("r"))),
                json!({"decision":"allow"})
            );
        }
    }

    #[test]
    fn a_record_only_event_never_blocks() {
        for event in [
            "post_tool_use",
            "session_start",
            "stop",
            "session_end",
            "plugin_loaded",
        ] {
            assert_eq!(
                translate(event, &v("block", Some("r"))),
                json!({"decision":"allow"})
            );
        }
    }
}