use serde::Serialize;
fn append_demote_note(reason: &str, note: &str) -> String {
format!("{reason}\n\n{note}")
}
#[derive(Debug, Serialize)]
pub struct HookResponse {
#[serde(rename = "hookSpecificOutput")]
pub hook_specific_output: HookSpecificOutput,
}
#[derive(Debug, Serialize)]
pub struct HookSpecificOutput {
#[serde(rename = "hookEventName")]
pub hook_event_name: &'static str,
#[serde(rename = "permissionDecision")]
pub permission_decision: &'static str,
#[serde(rename = "permissionDecisionReason")]
pub permission_decision_reason: String,
}
pub mod claude_code {
use super::{HookResponse, HookSpecificOutput};
use crate::Decision;
pub fn from_decision(decision: &Decision) -> Option<HookResponse> {
let (verdict, reason) = match decision {
Decision::Allow | Decision::Monitor { .. } => return None,
Decision::Ask { reason, .. } => ("ask", reason.clone()),
Decision::Deny { reason, .. } => ("deny", reason.clone()),
};
Some(HookResponse {
hook_specific_output: HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: verdict,
permission_decision_reason: reason,
},
})
}
}
pub mod codex {
use super::{HookResponse, HookSpecificOutput, append_demote_note};
use crate::Decision;
const ASK_UNAVAILABLE_NOTE: &str =
"Codex PreToolUse cannot prompt interactively, so ptuf is blocking this request.";
pub fn from_decision(decision: &Decision) -> Option<HookResponse> {
let reason = match decision {
Decision::Allow | Decision::Monitor { .. } => return None,
Decision::Ask { reason, .. } => deny_reason_for_ask(reason),
Decision::Deny { reason, .. } => reason.clone(),
};
Some(HookResponse {
hook_specific_output: HookSpecificOutput {
hook_event_name: "PreToolUse",
permission_decision: "deny",
permission_decision_reason: reason,
},
})
}
pub fn deny_reason_for_ask(reason: &str) -> String {
append_demote_note(reason, ASK_UNAVAILABLE_NOTE)
}
}
pub mod copilot {
use serde::Serialize;
use super::append_demote_note;
use crate::Decision;
const ASK_UNAVAILABLE_NOTE: &str = "GitHub Copilot hooks do not reliably process interactive ask decisions; ptuf is blocking this request instead.";
#[derive(Debug, Serialize)]
pub struct CopilotResponse {
#[serde(rename = "permissionDecision")]
pub permission_decision: &'static str,
#[serde(rename = "permissionDecisionReason")]
pub permission_decision_reason: String,
}
pub fn from_decision(decision: &Decision) -> Option<CopilotResponse> {
let reason = match decision {
Decision::Allow | Decision::Monitor { .. } => return None,
Decision::Ask { reason, .. } => deny_reason_for_ask(reason),
Decision::Deny { reason, .. } => reason.clone(),
};
Some(CopilotResponse {
permission_decision: "deny",
permission_decision_reason: reason,
})
}
pub fn deny_reason_for_ask(reason: &str) -> String {
append_demote_note(reason, ASK_UNAVAILABLE_NOTE)
}
}
pub mod kiro {
use super::append_demote_note;
const ASK_UNAVAILABLE_NOTE: &str = "Kiro CLI PreToolUse hooks do not define an interactive ask channel; ptuf is blocking this request instead.";
pub fn deny_reason_for_ask(reason: &str) -> String {
append_demote_note(reason, ASK_UNAVAILABLE_NOTE)
}
}
pub mod cline {
use serde::Serialize;
use super::append_demote_note;
use crate::Decision;
const ASK_UNAVAILABLE_NOTE: &str = "Cline PreToolUse file hooks do not currently provide a uniformly reliable interactive review channel; ptuf is blocking this request instead.";
#[derive(Debug, Serialize)]
pub struct ClineResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub cancel: Option<bool>,
#[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(
rename = "contextModification",
skip_serializing_if = "Option::is_none"
)]
pub context_modification: Option<String>,
}
impl ClineResponse {
fn empty() -> Self {
Self {
cancel: None,
error_message: None,
context: None,
context_modification: None,
}
}
fn cancel(reason: String) -> Self {
Self {
cancel: Some(true),
error_message: Some(reason.clone()),
context: Some(reason.clone()),
context_modification: Some(reason),
}
}
}
pub fn from_decision(decision: &Decision) -> ClineResponse {
match decision {
Decision::Allow | Decision::Monitor { .. } => ClineResponse::empty(),
Decision::Ask { reason, .. } => ClineResponse::cancel(deny_reason_for_ask(reason)),
Decision::Deny { reason, .. } => ClineResponse::cancel(reason.clone()),
}
}
pub fn deny_reason_for_ask(reason: &str) -> String {
append_demote_note(reason, ASK_UNAVAILABLE_NOTE)
}
}
pub mod pi {
use serde::Serialize;
use crate::Decision;
#[derive(Debug, Serialize)]
pub struct PiHookResponse {
pub decision: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub fn from_decision(decision: &Decision) -> PiHookResponse {
match decision {
Decision::Allow => PiHookResponse {
decision: "allow",
rule_id: None,
reason: None,
},
Decision::Monitor { rule_id } => PiHookResponse {
decision: "monitor",
rule_id: Some(rule_id.clone()),
reason: None,
},
Decision::Ask { rule_id, reason } => PiHookResponse {
decision: "ask",
rule_id: Some(rule_id.clone()),
reason: Some(reason.clone()),
},
Decision::Deny { rule_id, reason } => PiHookResponse {
decision: "deny",
rule_id: Some(rule_id.clone()),
reason: Some(reason.clone()),
},
}
}
}
pub mod opencode {
use super::append_demote_note;
use super::pi::{self, PiHookResponse};
use crate::Decision;
const ASK_UNAVAILABLE_NOTE: &str = "OpenCode has a permission.ask plugin hook, but it is \
known to fail silently in some builds, and tool.execute.before cannot start an \
interactive confirmation; ptuf is blocking this request instead.";
pub type OpencodeHookResponse = PiHookResponse;
pub fn from_decision(decision: &Decision) -> PiHookResponse {
pi::from_decision(decision)
}
pub fn deny_reason_for_ask(reason: &str) -> String {
append_demote_note(reason, ASK_UNAVAILABLE_NOTE)
}
}
pub mod cursor {
use serde::Serialize;
use crate::Decision;
#[derive(Debug, Serialize)]
pub struct CursorResponse {
pub permission: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_message: Option<String>,
}
pub fn from_decision(decision: &Decision) -> CursorResponse {
let (permission, reason) = match decision {
Decision::Allow | Decision::Monitor { .. } => ("allow", None),
Decision::Ask { reason, .. } => ("ask", Some(reason.clone())),
Decision::Deny { reason, .. } => ("deny", Some(reason.clone())),
};
CursorResponse {
permission,
user_message: reason.clone(),
agent_message: reason,
}
}
}
pub use claude_code::from_decision;
#[cfg(test)]
mod tests {
use super::*;
use crate::Decision;
#[test]
fn allow_produces_no_response() {
assert!(claude_code::from_decision(&Decision::Allow).is_none());
assert!(codex::from_decision(&Decision::Allow).is_none());
assert!(copilot::from_decision(&Decision::Allow).is_none());
}
#[test]
fn monitor_produces_no_response() {
let d = Decision::Monitor {
rule_id: "core.m".into(),
};
assert!(claude_code::from_decision(&d).is_none());
assert!(codex::from_decision(&d).is_none());
assert!(copilot::from_decision(&d).is_none());
}
#[test]
fn copilot_deny_serialises_bare_envelope() {
let d = Decision::Deny {
rule_id: "core.x".into(),
reason: "Blocked by ptuf rule core.x.\n".into(),
};
let resp = copilot::from_decision(&d).expect("response");
let json = serde_json::to_string(&resp).expect("serialise");
assert!(
!json.contains("hookSpecificOutput"),
"Copilot must not wrap response: {json}"
);
assert!(json.contains("\"permissionDecision\":\"deny\""));
assert!(json.contains("\"permissionDecisionReason\":\"Blocked by ptuf rule core.x.\\n\""));
}
#[test]
fn claude_deny_serialises_expected_shape() {
let d = Decision::Deny {
rule_id: "core.x".into(),
reason: "Blocked by ptuf rule core.x.\n".into(),
};
let resp = claude_code::from_decision(&d).expect("response");
let json = serde_json::to_string(&resp).expect("serialise");
assert!(json.contains("\"hookSpecificOutput\""));
assert!(json.contains("\"hookEventName\":\"PreToolUse\""));
assert!(json.contains("\"permissionDecision\":\"deny\""));
assert!(json.contains("\"permissionDecisionReason\":\"Blocked by ptuf rule core.x.\\n\""));
}
#[test]
fn claude_ask_serialises_with_ask_verdict() {
let d = Decision::Ask {
rule_id: "core.a".into(),
reason: "confirm please".into(),
};
let resp = claude_code::from_decision(&d).expect("response");
let json = serde_json::to_string(&resp).expect("serialise");
assert!(json.contains("\"permissionDecision\":\"ask\""));
assert!(json.contains("\"permissionDecisionReason\":\"confirm please\""));
}
#[test]
fn kiro_deny_reason_for_ask_appends_note() {
let s = kiro::deny_reason_for_ask("please confirm");
assert!(s.starts_with("please confirm"));
assert!(s.contains("Kiro CLI PreToolUse hooks do not define an interactive ask channel"));
}
#[test]
fn cline_allow_outputs_empty_object() {
let resp = cline::from_decision(&Decision::Allow);
let json = serde_json::to_string(&resp).expect("serialise");
assert_eq!(json, "{}");
}
#[test]
fn cline_monitor_outputs_empty_object() {
let d = Decision::Monitor {
rule_id: "core.m".into(),
};
let resp = cline::from_decision(&d);
let json = serde_json::to_string(&resp).expect("serialise");
assert_eq!(json, "{}");
}
#[test]
fn cline_deny_outputs_cancel_json() {
let d = Decision::Deny {
rule_id: "core.x".into(),
reason: "blocked".into(),
};
let resp = cline::from_decision(&d);
let json = serde_json::to_value(resp).expect("serialise");
assert_eq!(json["cancel"], true);
assert_eq!(json["errorMessage"], "blocked");
assert_eq!(json["context"], "blocked");
assert_eq!(json["contextModification"], "blocked");
assert!(json.get("shouldContinue").is_none());
assert!(json.get("review").is_none());
}
#[test]
fn cline_ask_is_demoted_to_cancel() {
let d = Decision::Ask {
rule_id: "core.x".into(),
reason: "confirm".into(),
};
let resp = cline::from_decision(&d);
let json = serde_json::to_value(resp).expect("serialise");
assert_eq!(json["cancel"], true);
let msg = json["errorMessage"].as_str().expect("errorMessage string");
assert!(msg.starts_with("confirm"));
assert!(msg.contains("Cline PreToolUse file hooks"));
}
#[test]
fn codex_ask_serialises_as_deny_with_note() {
let d = Decision::Ask {
rule_id: "core.a".into(),
reason: "confirm please".into(),
};
let resp = codex::from_decision(&d).expect("response");
let json = serde_json::to_string(&resp).expect("serialise");
assert!(json.contains("\"permissionDecision\":\"deny\""));
assert!(json.contains("Codex PreToolUse cannot prompt interactively"));
}
#[test]
fn cursor_allow_and_monitor_emit_explicit_allow() {
let allow =
serde_json::to_value(cursor::from_decision(&Decision::Allow)).expect("serialise allow");
assert_eq!(allow["permission"], "allow");
assert!(allow.get("user_message").is_none());
assert!(allow.get("agent_message").is_none());
let monitor = serde_json::to_value(cursor::from_decision(&Decision::Monitor {
rule_id: "core.m".into(),
}))
.expect("serialise monitor");
assert_eq!(monitor["permission"], "allow");
assert!(monitor.get("user_message").is_none());
assert!(monitor.get("agent_message").is_none());
}
#[test]
fn cursor_deny_serialises_bare_permission_envelope() {
let d = Decision::Deny {
rule_id: "core.x".into(),
reason: "blocked".into(),
};
let resp = cursor::from_decision(&d);
let json = serde_json::to_value(&resp).expect("serialise");
assert!(
json.get("hookSpecificOutput").is_none(),
"Cursor must not wrap response: {json}"
);
assert_eq!(json["permission"], "deny");
assert_eq!(json["user_message"], "blocked");
assert_eq!(json["agent_message"], "blocked");
}
#[test]
fn cursor_ask_is_preserved_not_demoted() {
let d = Decision::Ask {
rule_id: "core.a".into(),
reason: "confirm please".into(),
};
let resp = cursor::from_decision(&d);
let json = serde_json::to_value(&resp).expect("serialise");
assert_eq!(
json["permission"], "ask",
"Cursor has an ask channel; Ask must not demote to deny"
);
assert_eq!(json["user_message"], "confirm please");
}
#[test]
fn pi_allow_serialises_bare_decision_envelope() {
let resp = pi::from_decision(&Decision::Allow);
let json = serde_json::to_value(&resp).expect("serialise");
assert_eq!(json["decision"], "allow");
assert!(json.get("rule_id").is_none());
assert!(json.get("reason").is_none());
assert!(json.get("hookSpecificOutput").is_none());
}
#[test]
fn pi_ask_is_preserved_not_demoted() {
let d = Decision::Ask {
rule_id: "core.a".into(),
reason: "confirm please".into(),
};
let resp = pi::from_decision(&d);
let json = serde_json::to_value(&resp).expect("serialise");
assert_eq!(json["decision"], "ask");
assert_eq!(json["rule_id"], "core.a");
assert_eq!(json["reason"], "confirm please");
}
#[test]
fn pi_deny_includes_rule_id_and_reason() {
let d = Decision::Deny {
rule_id: "core.x".into(),
reason: "blocked".into(),
};
let resp = pi::from_decision(&d);
let json = serde_json::to_value(&resp).expect("serialise");
assert_eq!(json["decision"], "deny");
assert_eq!(json["rule_id"], "core.x");
assert_eq!(json["reason"], "blocked");
assert!(json.get("hookSpecificOutput").is_none());
}
#[test]
fn pi_monitor_serialises_bare_decision_envelope() {
let resp = pi::from_decision(&Decision::Monitor {
rule_id: "core.m".into(),
});
let json = serde_json::to_value(&resp).expect("serialise");
assert_eq!(json["decision"], "monitor");
assert_eq!(json["rule_id"], "core.m");
}
use crate::testing::proptest::{decision, reason_text, rule_id};
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_claude_allow_and_monitor_yield_none(rule_id in rule_id()) {
let allow_response = claude_code::from_decision(&Decision::Allow);
let monitor_response = claude_code::from_decision(&Decision::Monitor { rule_id });
prop_assert!(allow_response.is_none());
prop_assert!(monitor_response.is_none());
}
#[test]
fn pbt_claude_ask_emits_ask_verdict(id in rule_id(), reason in reason_text()) {
let d = Decision::Ask {
rule_id: id,
reason: reason.clone(),
};
let resp = claude_code::from_decision(&d).expect("ask emits a response");
prop_assert_eq!(resp.hook_specific_output.permission_decision, "ask");
prop_assert_eq!(resp.hook_specific_output.hook_event_name, "PreToolUse");
prop_assert_eq!(resp.hook_specific_output.permission_decision_reason, reason);
}
#[test]
fn pbt_codex_maps_ask_to_deny(id in rule_id(), reason in reason_text()) {
let d = Decision::Ask {
rule_id: id,
reason: reason.clone(),
};
let resp = codex::from_decision(&d).expect("ask emits a response");
prop_assert_eq!(resp.hook_specific_output.permission_decision, "deny");
prop_assert!(resp.hook_specific_output.permission_decision_reason.contains(&reason));
}
#[test]
fn pbt_deny_emits_deny_verdict_in_both_adapters(id in rule_id(), reason in reason_text()) {
let d = Decision::Deny {
rule_id: id,
reason: reason.clone(),
};
let claude = claude_code::from_decision(&d).expect("deny emits a response");
let codex = codex::from_decision(&d).expect("deny emits a response");
prop_assert_eq!(claude.hook_specific_output.permission_decision, "deny");
prop_assert_eq!(codex.hook_specific_output.permission_decision, "deny");
prop_assert_eq!(claude.hook_specific_output.permission_decision_reason, reason.clone());
prop_assert_eq!(codex.hook_specific_output.permission_decision_reason, reason);
}
#[test]
fn pbt_response_presence_matches_variant(d in decision()) {
let expects_response = matches!(d, Decision::Ask { .. } | Decision::Deny { .. });
prop_assert_eq!(claude_code::from_decision(&d).is_some(), expects_response);
prop_assert_eq!(codex::from_decision(&d).is_some(), expects_response);
prop_assert_eq!(copilot::from_decision(&d).is_some(), expects_response);
}
#[test]
fn pbt_copilot_maps_ask_to_deny(id in rule_id(), reason in reason_text()) {
let d = Decision::Ask {
rule_id: id,
reason: reason.clone(),
};
let resp = copilot::from_decision(&d).expect("ask emits a response");
prop_assert_eq!(resp.permission_decision, "deny");
prop_assert!(resp.permission_decision_reason.contains(&reason));
}
#[test]
fn pbt_copilot_deny_keeps_reason_verbatim(id in rule_id(), reason in reason_text()) {
let d = Decision::Deny {
rule_id: id,
reason: reason.clone(),
};
let resp = copilot::from_decision(&d).expect("deny emits a response");
prop_assert_eq!(resp.permission_decision, "deny");
prop_assert_eq!(resp.permission_decision_reason, reason);
}
}
}