use super::{empty, Verdict};
use serde_json::{json, Value};
const DEFAULT_DENY_REASON: &str = "Blocked by OpenLatch policy";
const DEFAULT_ASK_REASON: &str = "OpenLatch policy asks you to confirm this action";
enum Shape {
Permission { asks: bool },
Continue,
Record,
}
const NATIVE_EVENTS: &[&str] = &[
"preToolUse",
"beforeShellExecution",
"beforeMCPExecution",
"beforeSubmitPrompt",
"sessionStart",
"sessionEnd",
"postToolUse",
"postToolUseFailure",
"subagentStop",
"preCompact",
"stop",
];
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,
}
}
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})
}
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(),
}
}
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)
}
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,
}
}
#[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});
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}");
}
}
}
#[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
);
}
#[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()
);
}
}