ptuf 0.1.0

PreToolUseFilter: a generic guardrail layer for coding agents
Documentation
use serde::Serialize;

/// Append a per-adapter "Ask is unavailable" note to a deny reason.
/// Adapters that cannot surface an interactive prompt (Codex, Copilot,
/// Kiro) demote `Ask` to `Deny` and use this helper to produce a
/// human-readable explanation.
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;

    /// Build a Claude Code `hookSpecificOutput` response from a decision.
    /// Returns `None` for `Allow` and `Monitor`, since those produce no
    /// hook-protocol output.
    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.";

    /// Build a Codex `hookSpecificOutput` response from a decision.
    /// `Ask` is mapped to a deny because Codex currently fails open on it.
    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;

    /// Note appended to a deny reason whenever a Copilot `Ask` decision
    /// is demoted to `Deny`. Copilot hooks have no reliable interactive
    /// confirmation channel, so ptuf surfaces the demotion explicitly.
    const ASK_UNAVAILABLE_NOTE: &str = "GitHub Copilot hooks do not reliably process interactive ask decisions; ptuf is blocking this request instead.";

    /// Bare deny envelope expected by GitHub Copilot's `preToolUse` hook.
    /// Unlike Claude Code / Codex, Copilot does **not** wrap the body in
    /// `hookSpecificOutput` and treats non-zero exit codes as hook
    /// failures (skipping the response). The CLI therefore writes this
    /// JSON object directly and uses exit `0`, even for deny.
    #[derive(Debug, Serialize)]
    pub struct CopilotResponse {
        #[serde(rename = "permissionDecision")]
        pub permission_decision: &'static str,
        #[serde(rename = "permissionDecisionReason")]
        pub permission_decision_reason: String,
    }

    /// Build a Copilot deny envelope from a decision.
    /// Returns `None` for `Allow` and `Monitor` (Copilot, like the other
    /// adapters, emits no output for those). `Ask` is demoted to a deny
    /// because Copilot can't surface an interactive prompt reliably.
    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;

    /// Note appended to a deny reason whenever a Kiro `Ask` decision is
    /// demoted to `Deny`. Kiro's `preToolUse` hook protocol does not
    /// expose an interactive ask channel, so ptuf surfaces the demotion
    /// explicitly on stderr.
    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);
        }
    }
}