use serde::Deserialize;
use crate::types::{Class, Decision, Mode, Verdict};
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Policy {
#[serde(default)]
pub mode: Option<Mode>,
#[serde(default)]
pub threshold: Option<u8>,
#[serde(default)]
pub rules: Rules,
}
pub const DEFAULT_THRESHOLD: u8 = 50;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Rules {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyAction {
None,
Allow,
Deny,
}
impl Policy {
pub fn parse(toml_str: &str) -> Result<Self, toml::de::Error> {
toml::from_str(toml_str)
}
pub fn merge(global: Policy, repo: Policy) -> Policy {
let mut allow = global.rules.allow;
allow.extend(repo.rules.allow);
let mut deny = global.rules.deny;
deny.extend(repo.rules.deny);
Policy {
mode: repo.mode.or(global.mode),
threshold: repo.threshold.or(global.threshold),
rules: Rules { allow, deny },
}
}
pub fn risk_threshold(&self) -> u8 {
self.threshold.unwrap_or(DEFAULT_THRESHOLD)
}
pub fn action_for(&self, command: &str) -> PolicyAction {
let cmd = command.trim();
if self.rules.deny.iter().any(|p| matches(p, cmd)) {
return PolicyAction::Deny;
}
if self.rules.allow.iter().any(|p| matches(p, cmd)) {
return PolicyAction::Allow;
}
PolicyAction::None
}
}
pub fn adjust_for_policy(mut verdict: Verdict, action: PolicyAction, mode: Mode) -> Verdict {
match action {
PolicyAction::None => verdict,
PolicyAction::Deny => {
match mode {
Mode::Attended => verdict.decision = Decision::Hold,
Mode::Unattended => verdict.decision = Decision::Deny,
Mode::Notify => {} }
verdict.reason = format!("policy:deny ({})", verdict.reason);
verdict
}
PolicyAction::Allow => {
if verdict.class == Class::Catastrophic {
verdict.reason = format!("policy:allow-ignored-catastrophic ({})", verdict.reason);
verdict
} else {
verdict.decision = Decision::Allow;
verdict.reason = format!("policy:allow ({})", verdict.reason);
verdict
}
}
}
}
pub fn matches(pattern: &str, command: &str) -> bool {
let pattern = pattern.trim();
let command = command.trim();
if pattern.is_empty() {
return false;
}
if pattern.contains('*') {
glob_match(pattern, command)
} else {
command == pattern || command.starts_with(&format!("{pattern} "))
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
let anchored_start = !pattern.starts_with('*');
let anchored_end = !pattern.ends_with('*');
let mut pos = 0usize;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
match text[pos..].find(part) {
Some(idx) => {
let abs = pos + idx;
if i == 0 && anchored_start && abs != 0 {
return false;
}
pos = abs + part.len();
}
None => return false,
}
}
if anchored_end {
if let Some(last) = parts.iter().rev().find(|p| !p.is_empty()) {
return text.ends_with(last);
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_mode_and_rules() {
let p = Policy::parse(
r#"
mode = "notify"
[rules]
allow = ["cargo run"]
deny = ["git push *", "rm -rf"]
"#,
)
.unwrap();
assert_eq!(p.mode, Some(Mode::Notify));
assert_eq!(p.rules.allow, vec!["cargo run"]);
assert_eq!(p.rules.deny.len(), 2);
}
#[test]
fn empty_policy_parses() {
let p = Policy::parse("").unwrap();
assert_eq!(p, Policy::default());
}
#[test]
fn prefix_matching() {
assert!(matches("git push", "git push --force origin main"));
assert!(matches("git push", "git push"));
assert!(!matches("git push", "git pushing")); assert!(!matches("git push", "git status"));
}
#[test]
fn glob_matching() {
assert!(matches("rm *", "rm file.txt"));
assert!(matches("*secret*", "cat my-secret-file"));
assert!(matches("git * --force", "git push --force"));
assert!(!matches("git * --force", "git push origin"));
}
#[test]
fn deny_takes_precedence_over_allow() {
let p = Policy {
mode: None,
threshold: None,
rules: Rules {
allow: vec!["deploy".into()],
deny: vec!["deploy".into()],
},
};
assert_eq!(p.action_for("deploy now"), PolicyAction::Deny);
assert_eq!(p.risk_threshold(), DEFAULT_THRESHOLD);
}
#[test]
fn merge_repo_overrides_mode_and_extends_rules() {
let global = Policy::parse("mode = \"attended\"\n[rules]\nallow=[\"a\"]").unwrap();
let repo = Policy::parse("mode = \"notify\"\n[rules]\ndeny=[\"b\"]").unwrap();
let merged = Policy::merge(global, repo);
assert_eq!(merged.mode, Some(Mode::Notify));
assert_eq!(merged.rules.allow, vec!["a"]);
assert_eq!(merged.rules.deny, vec!["b"]);
}
#[test]
fn deny_escalates_safe_to_hold_in_attended() {
let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Attended);
assert_eq!(adjusted.decision, Decision::Hold);
assert!(adjusted.reason.starts_with("policy:deny"));
}
#[test]
fn deny_in_notify_does_not_block() {
let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Notify);
assert_eq!(adjusted.decision, Decision::Allow);
}
#[test]
fn allow_never_downgrades_catastrophic() {
let v = Verdict::rules(Class::Catastrophic, Decision::Hold, "rm:recursive");
let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
assert_eq!(adjusted.decision, Decision::Hold, "hard floor must stand");
}
#[test]
fn allow_tames_ambiguous() {
let v = Verdict::rules(Class::Ambiguous, Decision::Hold, "ambiguous:make");
let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
assert_eq!(adjusted.decision, Decision::Allow);
}
}