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 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 copilot_ask_serialises_as_deny_with_demote_note() {
let d = Decision::Ask {
rule_id: "core.a".into(),
reason: "confirm please".into(),
};
let resp = copilot::from_decision(&d).expect("response");
let json = serde_json::to_string(&resp).expect("serialise");
assert!(json.contains("\"permissionDecision\":\"deny\""));
assert!(json.contains("GitHub Copilot hooks do not reliably process interactive ask"));
}
#[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 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"));
}
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);
}
}
}