use super::{empty, Verdict};
use serde_json::{json, Value};
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
match event {
"pre_tool_use" => pre_tool_use(verdict),
"user_prompt_submit" => user_prompt_submit(verdict),
"session_start" => session_start(verdict),
_ => empty(),
}
}
fn pre_tool_use(verdict: &Verdict<'_>) -> Value {
let (decision, reason_owned): (&str, Option<String>) = match (verdict.decision, verdict.context)
{
("deny", Some(ctx)) => ("deny", Some(format!("{}: {}", ctx.headline, ctx.body))),
("deny", None) => ("deny", verdict.reason.map(str::to_string)),
("ask", _) => ("ask", verdict.reason.map(str::to_string)),
_ => return empty(),
};
let mut specific = json!({
"hookEventName": "PreToolUse",
"permissionDecision": decision,
});
if let Some(reason) = reason_owned {
specific["permissionDecisionReason"] = Value::String(reason);
}
json!({ "hookSpecificOutput": specific })
}
fn session_start(verdict: &Verdict<'_>) -> Value {
let Some(ctx) = verdict.context else {
return empty();
};
let combined = format!("{}\n\n{}", ctx.headline, ctx.body);
json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": combined,
}
})
}
fn user_prompt_submit(verdict: &Verdict<'_>) -> Value {
if verdict.decision != "deny" {
return empty();
}
let reason = verdict.reason.unwrap_or("Blocked by OpenLatch");
json!({ "decision": "block", "reason": reason })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pre_tool_use_allow_is_empty() {
let out = translate("pre_tool_use", &Verdict::allow());
assert_eq!(out, empty());
}
#[test]
fn pre_tool_use_deny_uses_hook_specific_output() {
let v = Verdict {
decision: "deny",
reason: Some("credentials detected"),
context: None,
};
let out = translate("pre_tool_use", &v);
assert_eq!(
out,
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "credentials detected",
}
})
);
}
#[test]
fn pre_tool_use_deny_renders_a_policy_rules_reason_verbatim() {
let v = Verdict {
decision: "deny",
reason: Some("Canary enforce"),
context: None,
};
let out = translate("pre_tool_use", &v);
assert_eq!(
out,
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Canary enforce",
}
})
);
}
#[test]
fn pre_tool_use_ask_surfaces_without_reason() {
let v = Verdict {
decision: "ask",
reason: None,
context: None,
};
let out = translate("pre_tool_use", &v);
assert_eq!(
out,
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
}
})
);
}
#[test]
fn user_prompt_submit_deny_blocks() {
let v = Verdict {
decision: "deny",
reason: Some("prompt injection"),
context: None,
};
let out = translate("user_prompt_submit", &v);
assert_eq!(
out,
json!({ "decision": "block", "reason": "prompt injection" })
);
}
#[test]
fn user_prompt_submit_allow_is_empty() {
let out = translate("user_prompt_submit", &Verdict::allow());
assert_eq!(out, empty());
}
#[test]
fn stop_any_verdict_is_empty() {
for decision in ["allow", "approve", "deny"] {
let v = Verdict {
decision,
reason: Some("irrelevant"),
context: None,
};
assert_eq!(translate("stop", &v), empty(), "decision={decision}");
assert_eq!(
translate("subagent_stop", &v),
empty(),
"decision={decision}"
);
}
}
#[test]
fn post_tool_use_any_verdict_is_empty() {
for decision in ["allow", "approve", "deny"] {
let v = Verdict {
decision,
reason: None,
context: None,
};
assert_eq!(
translate("post_tool_use", &v),
empty(),
"decision={decision}"
);
}
}
#[test]
fn pre_tool_use_deny_with_context_emits_hard_deny() {
let ctx = super::super::VerdictContext {
headline: "Configuration alert pending",
body: "MCP server 'evil' was added; review before running.",
};
let v = Verdict {
decision: "deny",
reason: None,
context: Some(&ctx),
};
let out = translate("pre_tool_use", &v);
assert_eq!(
out["hookSpecificOutput"]["permissionDecision"]
.as_str()
.unwrap(),
"deny",
"deny+context must surface as a hard deny"
);
let reason = out["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap();
assert!(reason.contains("Configuration alert pending"));
assert!(reason.contains("MCP server 'evil'"));
}
#[test]
fn pre_tool_use_allow_with_context_is_empty() {
let ctx = super::super::VerdictContext {
headline: "Heads up",
body: "Two new skills landed.",
};
let v = Verdict {
decision: "allow",
reason: None,
context: Some(&ctx),
};
assert_eq!(translate("pre_tool_use", &v), empty());
}
#[test]
fn session_start_with_context_injects_additional_context() {
let ctx = super::super::VerdictContext {
headline: "Configuration alert",
body: "Two new skills were added since your last session.",
};
let v = Verdict {
decision: "allow",
reason: None,
context: Some(&ctx),
};
let out = translate("session_start", &v);
assert_eq!(
out["hookSpecificOutput"]["hookEventName"].as_str().unwrap(),
"SessionStart"
);
let injected = out["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(injected.contains("Configuration alert"));
assert!(injected.contains("new skills"));
}
#[test]
fn session_start_without_context_is_empty() {
let out = translate("session_start", &Verdict::allow());
assert_eq!(out, empty());
}
#[test]
fn unknown_event_is_empty() {
let out = translate("some_future_event", &Verdict::allow());
assert_eq!(out, empty());
}
#[test]
fn notification_and_session_events_are_empty() {
for ev in [
"notification",
"pre_compact",
"session_start",
"session_end",
] {
assert_eq!(translate(ev, &Verdict::allow()), empty(), "event={ev}");
}
}
}