use std::io::{self, Write};
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::Decision;
use crate::cli::POLICY_LOAD_FAILED_RULE;
use crate::config::Config;
use crate::engine::Engine;
use crate::hook_input::HookInput;
use crate::init::{InstallOutcome, InstallStatus};
use crate::plugin::PluginSet;
pub(crate) const SYNTHETIC_DENY_RULE: &str = "core.filesystem.destructive-rm";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CheckOutcome {
Passed { rule_id: String },
Failed { detail: String },
}
impl CheckOutcome {
pub(crate) fn is_passed(&self) -> bool {
matches!(self, Self::Passed { .. })
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct VerifyReport {
pub synthetic_deny: CheckOutcome,
pub fail_closed: CheckOutcome,
pub warnings: Vec<String>,
}
impl VerifyReport {
pub(crate) fn passed(&self) -> bool {
self.synthetic_deny.is_passed() && self.fail_closed.is_passed()
}
}
pub(crate) fn run() -> VerifyReport {
run_with(synthetic_deny_check, fail_closed_check)
}
pub(crate) fn run_with<F, G>(synth: F, fc: G) -> VerifyReport
where
F: FnOnce() -> CheckOutcome,
G: FnOnce() -> CheckOutcome,
{
VerifyReport {
synthetic_deny: synth(),
fail_closed: fc(),
warnings: Vec::new(),
}
}
pub(crate) fn synthetic_deny_check() -> CheckOutcome {
let input = HookInput {
tool_name: "Bash".to_string(),
tool_input: json!({ "command": "rm -rf /" }),
};
let engine = Engine::with_components(Config::default(), PluginSet::new());
classify_synthetic_deny(engine.decide(&input).decision)
}
fn classify_synthetic_deny(decision: Decision) -> CheckOutcome {
match decision {
Decision::Deny { rule_id, .. } if rule_id == SYNTHETIC_DENY_RULE => {
CheckOutcome::Passed { rule_id }
},
Decision::Deny { rule_id, .. } => CheckOutcome::Failed {
detail: format!(
"engine returned Deny but with unexpected rule_id={rule_id} (expected {SYNTHETIC_DENY_RULE})"
),
},
other => CheckOutcome::Failed {
detail: format!("engine returned {other:?}; expected Deny({SYNTHETIC_DENY_RULE})"),
},
}
}
pub(crate) fn fail_closed_check() -> CheckOutcome {
let nonexistent = PathBuf::from("/__ptuf__verify__nonexistent__plugin__do_not_create__.yaml");
let mut config = Config {
plugin_paths: vec![nonexistent],
..Config::default()
};
config.audit.enabled = false;
classify_fail_closed(Engine::with_config(config).is_err())
}
fn classify_fail_closed(load_failed: bool) -> CheckOutcome {
if load_failed {
CheckOutcome::Passed {
rule_id: POLICY_LOAD_FAILED_RULE.to_string(),
}
} else {
CheckOutcome::Failed {
detail: "engine built successfully despite a missing plugin path".to_string(),
}
}
}
pub(crate) fn render_text<W: Write>(report: &VerifyReport, w: &mut W) -> io::Result<()> {
writeln!(w, "Verify:")?;
writeln!(
w,
" Synthetic deny test: {}",
format_outcome(&report.synthetic_deny)
)?;
writeln!(
w,
" Fail-closed internal error test: {}",
format_outcome(&report.fail_closed)
)?;
if report.warnings.is_empty() {
writeln!(w, " Warnings: none")?;
} else {
writeln!(w, " Warnings:")?;
for warning in &report.warnings {
writeln!(w, " {warning}")?;
}
}
Ok(())
}
fn format_outcome(outcome: &CheckOutcome) -> String {
match outcome {
CheckOutcome::Passed { rule_id } => format!("passed (rule: {rule_id})"),
CheckOutcome::Failed { detail } => format!("FAILED — {detail}"),
}
}
pub(crate) fn render_json(
outcome: &InstallOutcome,
report: &VerifyReport,
rolled_back: bool,
) -> Value {
let paths: Vec<Value> = outcome
.paths
.iter()
.map(|p| {
json!({
"label": p.label,
"path": p.path.to_string_lossy(),
})
})
.collect();
json!({
"schemaVersion": 1,
"agent": outcome.agent,
"installed": matches!(outcome.status, InstallStatus::Installed),
"alreadyPresent": matches!(outcome.status, InstallStatus::AlreadyPresent),
"paths": paths,
"matcher": outcome.matcher,
"command": outcome.command,
"verify": {
"syntheticDeny": outcome_to_json(&report.synthetic_deny),
"failClosed": outcome_to_json(&report.fail_closed),
"warnings": report.warnings,
},
"rolledBack": rolled_back,
})
}
fn outcome_to_json(outcome: &CheckOutcome) -> Value {
match outcome {
CheckOutcome::Passed { rule_id } => json!({"status": "passed", "ruleId": rule_id}),
CheckOutcome::Failed { detail } => json!({"status": "failed", "detail": detail}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::init::InstallPath;
fn passed(rule_id: &str) -> CheckOutcome {
CheckOutcome::Passed {
rule_id: rule_id.to_string(),
}
}
fn failed(detail: &str) -> CheckOutcome {
CheckOutcome::Failed {
detail: detail.to_string(),
}
}
fn sample_outcome() -> InstallOutcome {
InstallOutcome {
status: InstallStatus::Installed,
agent: "claude-code",
paths: vec![InstallPath {
label: "settings",
path: PathBuf::from("/tmp/settings.json"),
}],
matcher: "Bash|Read|Edit|Write|WebFetch|mcp__.*".into(),
command: "/usr/local/bin/ptuf hook claude-code".into(),
}
}
#[test]
fn synthetic_deny_check_passes_for_rm_rf_root() {
let outcome = synthetic_deny_check();
assert_eq!(
outcome,
CheckOutcome::Passed {
rule_id: SYNTHETIC_DENY_RULE.into(),
}
);
}
#[test]
fn fail_closed_check_passes_when_plugin_path_missing() {
let outcome = fail_closed_check();
assert_eq!(
outcome,
CheckOutcome::Passed {
rule_id: POLICY_LOAD_FAILED_RULE.into(),
}
);
}
#[test]
fn run_returns_passing_report_in_default_environment() {
let report = run();
assert!(report.passed(), "verify::run() must pass: {report:?}");
assert!(report.warnings.is_empty());
}
#[test]
fn run_with_uses_provided_closures() {
let report = run_with(|| passed("a"), || passed("b"));
assert_eq!(report.synthetic_deny, passed("a"));
assert_eq!(report.fail_closed, passed("b"));
assert!(report.passed());
}
#[test]
fn passed_helper_reports_pass_only_when_both_pass() {
let both_pass = VerifyReport {
synthetic_deny: passed("x"),
fail_closed: passed("y"),
warnings: Vec::new(),
};
assert!(both_pass.passed());
let synth_failed = VerifyReport {
synthetic_deny: failed("nope"),
fail_closed: passed("y"),
warnings: Vec::new(),
};
assert!(!synth_failed.passed());
let fc_failed = VerifyReport {
synthetic_deny: passed("x"),
fail_closed: failed("nope"),
warnings: Vec::new(),
};
assert!(!fc_failed.passed());
}
#[test]
fn render_text_shows_passed_branches_and_no_warnings() {
let report = VerifyReport {
synthetic_deny: passed("core.filesystem.destructive-rm"),
fail_closed: passed("core.engine.policy-load-failed"),
warnings: Vec::new(),
};
let mut buf = Vec::new();
render_text(&report, &mut buf).expect("render");
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Verify:"));
assert!(s.contains("Synthetic deny test: passed (rule: core.filesystem.destructive-rm)"));
assert!(s.contains(
"Fail-closed internal error test: passed (rule: core.engine.policy-load-failed)"
));
assert!(s.contains("Warnings: none"));
}
#[test]
fn render_text_shows_failed_branches() {
let report = VerifyReport {
synthetic_deny: failed("got Allow"),
fail_closed: passed("core.engine.policy-load-failed"),
warnings: Vec::new(),
};
let mut buf = Vec::new();
render_text(&report, &mut buf).expect("render");
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Synthetic deny test: FAILED — got Allow"));
}
#[test]
fn render_text_lists_warnings_when_present() {
let report = VerifyReport {
synthetic_deny: passed("a"),
fail_closed: passed("b"),
warnings: vec!["audit log path not writable".into()],
};
let mut buf = Vec::new();
render_text(&report, &mut buf).expect("render");
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Warnings:"));
assert!(s.contains("audit log path not writable"));
assert!(!s.contains("Warnings: none"));
}
#[test]
fn render_json_has_expected_shape() {
let outcome = sample_outcome();
let report = VerifyReport {
synthetic_deny: passed("core.filesystem.destructive-rm"),
fail_closed: passed("core.engine.policy-load-failed"),
warnings: Vec::new(),
};
let value = render_json(&outcome, &report, false);
assert_eq!(value["schemaVersion"], 1);
assert_eq!(value["agent"], "claude-code");
assert_eq!(value["installed"], true);
assert_eq!(value["alreadyPresent"], false);
assert_eq!(value["verify"]["syntheticDeny"]["status"], "passed");
assert_eq!(
value["verify"]["syntheticDeny"]["ruleId"],
"core.filesystem.destructive-rm"
);
assert_eq!(value["verify"]["failClosed"]["status"], "passed");
assert_eq!(value["rolledBack"], false);
assert_eq!(value["paths"][0]["label"], "settings");
}
#[test]
fn render_json_marks_failed_status_with_detail() {
let outcome = sample_outcome();
let report = VerifyReport {
synthetic_deny: failed("got Allow"),
fail_closed: passed("core.engine.policy-load-failed"),
warnings: vec!["x".into()],
};
let value = render_json(&outcome, &report, true);
assert_eq!(value["verify"]["syntheticDeny"]["status"], "failed");
assert_eq!(value["verify"]["syntheticDeny"]["detail"], "got Allow");
assert_eq!(value["verify"]["warnings"][0], "x");
assert_eq!(value["rolledBack"], true);
}
#[test]
fn render_json_marks_already_present_status() {
let mut outcome = sample_outcome();
outcome.status = InstallStatus::AlreadyPresent;
let report = VerifyReport {
synthetic_deny: passed("a"),
fail_closed: passed("b"),
warnings: Vec::new(),
};
let value = render_json(&outcome, &report, false);
assert_eq!(value["installed"], false);
assert_eq!(value["alreadyPresent"], true);
}
#[test]
fn classify_synthetic_deny_passes_for_canonical_rule() {
let outcome = classify_synthetic_deny(Decision::Deny {
rule_id: SYNTHETIC_DENY_RULE.into(),
reason: "x".into(),
});
assert_eq!(
outcome,
CheckOutcome::Passed {
rule_id: SYNTHETIC_DENY_RULE.into(),
}
);
}
#[test]
fn classify_synthetic_deny_fails_when_rule_id_differs() {
let outcome = classify_synthetic_deny(Decision::Deny {
rule_id: "core.other".into(),
reason: "x".into(),
});
match outcome {
CheckOutcome::Failed { detail } => {
assert!(detail.contains("unexpected rule_id=core.other"), "{detail}");
assert!(detail.contains(SYNTHETIC_DENY_RULE), "{detail}");
},
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn classify_synthetic_deny_fails_for_non_deny_decision() {
let outcome = classify_synthetic_deny(Decision::Allow);
match outcome {
CheckOutcome::Failed { detail } => {
assert!(detail.contains("Allow"), "{detail}");
assert!(detail.contains(SYNTHETIC_DENY_RULE), "{detail}");
},
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn classify_fail_closed_passes_when_engine_load_failed() {
let outcome = classify_fail_closed(true);
assert_eq!(
outcome,
CheckOutcome::Passed {
rule_id: POLICY_LOAD_FAILED_RULE.into(),
}
);
}
#[test]
fn classify_fail_closed_fails_when_engine_loaded_successfully() {
let outcome = classify_fail_closed(false);
match outcome {
CheckOutcome::Failed { detail } => {
assert!(detail.contains("missing plugin path"), "{detail}");
},
other => panic!("expected Failed, got {other:?}"),
}
}
}