use super::{empty, Verdict};
use serde_json::{json, Value};
const DEFAULT_DENY_REASON: &str = "Blocked by OpenLatch policy.";
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
match event {
"pre_tool_use" => pre_tool_use(verdict),
_ => empty(),
}
}
fn pre_tool_use(verdict: &Verdict<'_>) -> Value {
match (verdict.decision, verdict.context) {
("deny", Some(ctx)) => deny(&format!("{}: {}", ctx.headline, ctx.body)),
("deny", None) => deny(verdict.reason.unwrap_or(DEFAULT_DENY_REASON)),
("allow", _) => empty(),
("ask", _) => verdict
.reason
.map_or_else(empty, |r| json!({ "systemMessage": r })),
_ => empty(),
}
}
fn deny(reason: &str) -> Value {
let reason = if reason.trim().is_empty() {
DEFAULT_DENY_REASON
} else {
reason
};
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
})
}
#[cfg(test)]
mod tests {
use super::super::VerdictContext;
use super::*;
const EVENTS: [&str; 13] = [
"pre_tool_use",
"permission_request",
"post_tool_use",
"user_prompt_submit",
"session_start",
"session_end",
"pre_compact",
"post_compact",
"stop",
"subagent_stop",
"subagent_start",
"interrupt",
"future_event_from_next_release",
];
const DECISIONS: [&str; 5] = ["allow", "approve", "deny", "ask", "nonsense"];
fn walk(value: &Value, check: &mut impl FnMut(&str, &Value)) {
match value {
Value::Object(map) => {
for (k, v) in map {
check(k, v);
walk(v, check);
}
}
Value::Array(items) => {
for item in items {
walk(item, check);
}
}
_ => {}
}
}
#[test]
fn codex_never_emits_an_unsupported_blocking_field() {
let ctx = VerdictContext {
headline: "Configuration alert pending",
body: "MCP server 'evil' was added; review before running.",
};
for event in EVENTS {
for decision in DECISIONS {
for reason in [None, Some("test reason")] {
for context in [None, Some(&ctx)] {
let verdict = Verdict {
decision,
reason,
context,
};
let out = translate(event, &verdict);
let label = format!(
"event={event} decision={decision} reason={reason:?} context={}",
context.is_some()
);
walk(&out, &mut |key, value| {
assert!(
!matches!(
key,
"decision" | "continue" | "stopReason" | "suppressOutput"
),
"{label}: emitted unsupported field {key:?} -- Codex parses it, \
marks the hook run failed and CONTINUES the tool call"
);
if key == "permissionDecision" {
assert_eq!(
value.as_str(),
Some("deny"),
"{label}: `deny` is the only permissionDecision Codex \
honours; `ask` and `allow` are both silent fail-opens"
);
}
});
}
}
}
}
}
#[test]
fn codex_deny_carries_the_reason() {
let ctx = VerdictContext {
headline: "Configuration alert pending",
body: "MCP server 'evil' was added; review before running.",
};
let cases: [(&str, Verdict<'_>); 4] = [
(
"reason",
Verdict {
decision: "deny",
reason: Some("credentials detected"),
context: None,
},
),
(
"context",
Verdict {
decision: "deny",
reason: None,
context: Some(&ctx),
},
),
(
"neither",
Verdict {
decision: "deny",
reason: None,
context: None,
},
),
(
"empty reason",
Verdict {
decision: "deny",
reason: Some(" "),
context: None,
},
),
];
for (label, verdict) in cases {
let out = translate("pre_tool_use", &verdict);
let specific = &out["hookSpecificOutput"];
assert_eq!(
specific["hookEventName"].as_str(),
Some("PreToolUse"),
"{label}"
);
assert_eq!(
specific["permissionDecision"].as_str(),
Some("deny"),
"{label}"
);
let rendered = specific["permissionDecisionReason"]
.as_str()
.unwrap_or_else(|| panic!("{label}: a deny must carry permissionDecisionReason"));
assert!(
!rendered.trim().is_empty(),
"{label}: Codex refuses `permissionDecision:deny` without a NON-EMPTY reason, \
and a refused deny fails open"
);
}
let out = translate(
"pre_tool_use",
&Verdict {
decision: "deny",
reason: Some("Canary enforce"),
context: None,
},
);
assert_eq!(
out,
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Canary enforce",
}
})
);
let with_context = translate(
"pre_tool_use",
&Verdict {
decision: "deny",
reason: None,
context: Some(&ctx),
},
);
let rendered = with_context["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.expect("context deny carries a reason");
assert!(rendered.contains("Configuration alert pending"));
assert!(rendered.contains("MCP server 'evil'"));
}
#[test]
fn codex_allow_is_always_empty() {
let ctx = VerdictContext {
headline: "Heads up",
body: "Two new skills landed.",
};
for decision in ["allow", "approve"] {
for reason in [None, Some("a config file changed")] {
for context in [None, Some(&ctx)] {
let verdict = Verdict {
decision,
reason,
context,
};
assert_eq!(
translate("pre_tool_use", &verdict),
empty(),
"decision={decision} reason={reason:?} context={}",
context.is_some()
);
}
}
}
}
#[test]
fn codex_ask_renders_a_system_message() {
let out = translate(
"pre_tool_use",
&Verdict {
decision: "ask",
reason: Some("This touches production credentials."),
context: None,
},
);
assert_eq!(
out,
json!({ "systemMessage": "This touches production credentials." })
);
assert!(
out.get("hookSpecificOutput").is_none(),
"an ask has no permissionDecision to express -- Codex has no ask tier"
);
let silent = translate(
"pre_tool_use",
&Verdict {
decision: "ask",
reason: None,
context: None,
},
);
assert_eq!(silent, empty());
}
#[test]
fn codex_non_pre_tool_use_is_empty() {
let ctx = VerdictContext {
headline: "Configuration alert pending",
body: "MCP server 'evil' was added.",
};
for event in EVENTS.into_iter().filter(|e| *e != "pre_tool_use") {
for decision in DECISIONS {
for reason in [None, Some("test reason")] {
for context in [None, Some(&ctx)] {
let verdict = Verdict {
decision,
reason,
context,
};
assert_eq!(
translate(event, &verdict),
empty(),
"event={event} decision={decision} reason={reason:?} context={}",
context.is_some()
);
}
}
}
}
}
}