openlatch-client 0.6.6

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Cursor hook-output translator.
//!
//! A deliberate copy of `codex_cli.rs`'s **shape** and of none of its
//! contents: Cursor's contract shares no field name with the other three.
//! The shapes are vendored at `schemas/vendor/cursor/hook-output.schema.json`,
//! from `cursor.com/docs/hooks.md`.
//!
//! # The event is Cursor's own name
//!
//! Three Cursor events share the wire `pre_tool_use`, and they do not answer
//! alike: `ask` is native on `beforeShellExecution` and `beforeMCPExecution`,
//! and has no place on `preToolUse`. So the hook hands this module the
//! payload's `hook_event_name`, and the wire value only when the payload named
//! none — which is why `pre_tool_use` and `user_prompt_submit` have rows too.
//!
//! | Event | allow / approve / optimize / unknown | ask | block / deny |
//! |---|---|---|---|
//! | `beforeShellExecution`, `beforeMCPExecution` | `{"permission":"allow"}` | `{"permission":"ask",…}` | `{"permission":"deny",…}` |
//! | `preToolUse`, `pre_tool_use` | `{"permission":"allow"}` | `{"permission":"allow"}` (degraded) | `{"permission":"deny",…}` |
//! | `beforeSubmitPrompt`, `user_prompt_submit` | `{"continue":true}` | `{"continue":true}` (degraded) | `{"continue":false,…}` |
//! | every other event | `{}` | `{}` | `{}` |
//!
//! # Never `{}` on the first three rows
//!
//! On a Cursor permission hook, empty or off-schema stdout on exit 0 **blocks**
//! the action (docs). So the allow is the explicit documented one, including
//! on the hook's fail-open path, which renders an allow through this module.
//!
//! # A degraded ask never escalates
//!
//! Where Cursor cannot ask, an ask is delivered as an allow and the daemon
//! stamps `olaskdegraded` on the record. Rendering it as a deny would turn a
//! question into a refusal the developer never saw coming.

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

/// The reason attached to a deny that arrived without one. Cursor shows the
/// `user_message` to the developer and the `agent_message` to the model; an
/// empty one refuses and explains nothing.
const DEFAULT_DENY_REASON: &str = "Blocked by OpenLatch policy";

/// The reason attached to an ask that arrived without one. Not the deny's text:
/// the developer is being asked, not refused.
const DEFAULT_ASK_REASON: &str = "OpenLatch policy asks you to confirm this action";

/// How an event answers.
enum Shape {
    /// `{"permission": …}`; `true` when `ask` is native.
    Permission { asks: bool },
    /// `{"continue": …}`.
    Continue,
    /// Nothing to decide: `{}`.
    Record,
}

/// Every event name Cursor's docs define for a hook. The hook binary trusts a
/// payload's `hook_event_name` only when it is one of these: an unknown or
/// re-cased name would fall to [`Shape::Record`]'s `{}`, which Cursor reads as
/// a refusal on a permission hook, so the installed wire event decides instead.
const NATIVE_EVENTS: &[&str] = &[
    "preToolUse",
    "beforeShellExecution",
    "beforeMCPExecution",
    "beforeSubmitPrompt",
    "sessionStart",
    "sessionEnd",
    "postToolUse",
    "postToolUseFailure",
    "subagentStop",
    "preCompact",
    "stop",
];

/// Is `name` one of Cursor's own event names?
pub fn is_native_event(name: &str) -> bool {
    NATIVE_EVENTS.contains(&name)
}

fn shape(event: &str) -> Shape {
    match event {
        "beforeShellExecution" | "beforeMCPExecution" => Shape::Permission { asks: true },
        "preToolUse" | "pre_tool_use" => Shape::Permission { asks: false },
        "beforeSubmitPrompt" | "user_prompt_submit" => Shape::Continue,
        _ => Shape::Record,
    }
}

/// Translate a verdict for the named Cursor event (see the module table).
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
    let refuses = matches!(verdict.decision, "block" | "deny");
    let asks = verdict.decision == "ask";
    match shape(event) {
        Shape::Permission { .. } if refuses => {
            let reason = reason_or(verdict, DEFAULT_DENY_REASON);
            json!({"permission": "deny", "user_message": reason, "agent_message": reason})
        }
        Shape::Permission { asks: true } if asks => {
            let reason = reason_or(verdict, DEFAULT_ASK_REASON);
            json!({"permission": "ask", "user_message": reason, "agent_message": reason})
        }
        // `allow` carrying a context is a queued config alert, not an ask (see
        // `codex_cli::pre_tool_use`), and `approve`, `optimize` and any future
        // wire value land here too.
        Shape::Permission { .. } => json!({"permission": "allow"}),
        Shape::Continue if refuses => {
            json!({"continue": false, "user_message": reason_or(verdict, DEFAULT_DENY_REASON)})
        }
        Shape::Continue => json!({"continue": true}),
        Shape::Record => empty(),
    }
}

/// Delivery dispatch. Cursor cannot rewrite a tool call's arguments or take
/// context for the model (`can_mutate_arguments: false`), so `updated_input`
/// and `additional_context` are not expressed. A `defer` degrades exactly as
/// `codex_cli::translate_delivery`'s does: the action continues, with the
/// message, on an event where the daemon may defer at all.
pub fn translate_delivery(
    event: &str,
    verdict: &Verdict<'_>,
    system_message: Option<&str>,
    defer: bool,
) -> Value {
    if defer && matches!(shape(event), Shape::Permission { .. }) {
        return match system_message.or(verdict.reason) {
            Some(message) if !message.trim().is_empty() => {
                json!({"permission": "allow", "user_message": message})
            }
            _ => json!({"permission": "allow"}),
        };
    }
    translate(event, verdict)
}

/// The verdict's reason, or its context as `headline: body`, or `default` when
/// both are blank — a message the developer reads has to say something.
fn reason_or(verdict: &Verdict<'_>, default: &'static str) -> String {
    if let Some(ctx) = verdict.context {
        return format!("{}: {}", ctx.headline, ctx.body);
    }
    verdict
        .reason
        .filter(|reason| !reason.trim().is_empty())
        .unwrap_or(default)
        .to_string()
}

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

    const DECISIONS: [&str; 7] = [
        "allow", "approve", "optimize", "ask", "block", "deny", "nonsense",
    ];

    fn verdict(decision: &str) -> Verdict<'_> {
        Verdict {
            decision,
            reason: Some("rm -rf is not allowed"),
            context: None,
        }
    }

    /// Every cell of the table, including the wire-value fallbacks.
    #[test]
    fn cursor_translator_matrix() {
        let r = "rm -rf is not allowed";
        let allow = json!({"permission": "allow"});
        let deny = json!({"permission": "deny", "user_message": r, "agent_message": r});
        let ask = json!({"permission": "ask", "user_message": r, "agent_message": r});
        let go = json!({"continue": true});
        let stop = json!({"continue": false, "user_message": r});
        // decision → (beforeShell/MCPExecution, preToolUse, beforeSubmitPrompt)
        for (decision, asks, tool, prompt) in [
            ("allow", &allow, &allow, &go),
            ("approve", &allow, &allow, &go),
            ("optimize", &allow, &allow, &go),
            ("nonsense", &allow, &allow, &go),
            ("ask", &ask, &allow, &go),
            ("block", &deny, &deny, &stop),
            ("deny", &deny, &deny, &stop),
        ] {
            for (events, expected) in [
                (["beforeShellExecution", "beforeMCPExecution"], asks),
                (["preToolUse", "pre_tool_use"], tool),
                (["beforeSubmitPrompt", "user_prompt_submit"], prompt),
            ] {
                for event in events {
                    assert_eq!(
                        &translate(event, &verdict(decision)),
                        expected,
                        "{event} {decision}"
                    );
                }
            }
        }
        for event in ["sessionStart", "postToolUse", "stop", "futureEvent"] {
            for decision in DECISIONS {
                assert_eq!(translate(event, &verdict(decision)), empty(), "{event}");
            }
        }
    }

    /// The prime invariant: on a permission event no decision, reason or
    /// delivery flag ever answers `{}`, which Cursor reads as a refusal.
    #[test]
    fn permission_events_never_answer_empty() {
        let ctx = VerdictContext {
            headline: "Configuration alert pending",
            body: "MCP server 'evil' was added",
        };
        for event in [
            "beforeShellExecution",
            "beforeMCPExecution",
            "preToolUse",
            "pre_tool_use",
            "beforeSubmitPrompt",
            "user_prompt_submit",
        ] {
            for decision in DECISIONS {
                for reason in [None, Some(""), Some("  "), Some("r")] {
                    for context in [None, Some(&ctx)] {
                        let v = Verdict {
                            decision,
                            reason,
                            context,
                        };
                        for defer in [false, true] {
                            let out = translate_delivery(event, &v, None, defer);
                            assert_ne!(out, empty(), "{event} {decision} {reason:?}");
                            for key in ["user_message", "agent_message"] {
                                if let Some(message) = out.get(key) {
                                    assert!(
                                        !message.as_str().unwrap_or_default().trim().is_empty(),
                                        "{event} {decision}: blank {key} in {out}"
                                    );
                                }
                            }
                        }
                    }
                }
            }
        }
        assert_eq!(
            translate("preToolUse", &Verdict::allow()),
            json!({"permission": "allow"}),
            "the fail-open allow is the explicit one"
        );
    }

    #[test]
    fn a_blank_reason_gets_the_default() {
        let blank = Verdict {
            decision: "block",
            reason: Some(" "),
            context: None,
        };
        assert_eq!(
            translate("beforeShellExecution", &blank)["user_message"],
            DEFAULT_DENY_REASON
        );
        assert_eq!(
            translate("beforeSubmitPrompt", &blank)["user_message"],
            DEFAULT_DENY_REASON
        );
    }

    /// Cursor cannot rewrite or take context: both are dropped, the verdict
    /// stands, and a defer continues the action with its message.
    #[test]
    fn delivery_ignores_what_cursor_cannot_express() {
        assert_eq!(
            translate_delivery("preToolUse", &verdict("block"), Some("note"), false),
            translate("preToolUse", &verdict("block"))
        );
        assert_eq!(
            translate_delivery(
                "beforeShellExecution",
                &Verdict::allow(),
                Some("wait"),
                true
            ),
            json!({"permission": "allow", "user_message": "wait"})
        );
        assert_eq!(
            translate_delivery("stop", &Verdict::allow(), Some("wait"), true),
            empty()
        );
    }
}