Skip to main content

kintsugi_core/
policy.rs

1//! Project and global policy (`.kintsugi.toml`).
2//!
3//! A repo may commit an `.kintsugi.toml` to add allow/deny rules and set the mode;
4//! global defaults live under the user's config dir. Repo settings override
5//! global ones. This module is pure: parsing, merging, matching, and applying a
6//! policy to a verdict. Loading the files from disk is the daemon's job.
7//!
8//! Security spine: policy may always *add* caution (a `deny` rule escalates any
9//! command to Hold/Deny). A policy `allow` may tame the ambiguous band, but it
10//! **never downgrades a rule-based catastrophic block** — that hard floor stands.
11
12use serde::Deserialize;
13
14use crate::types::{Class, Decision, Mode, Verdict};
15
16/// A parsed policy document.
17#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
18pub struct Policy {
19    /// Optional operating mode for this scope.
20    #[serde(default)]
21    pub mode: Option<Mode>,
22    /// Risk threshold (0..=100) for the graduated unattended band: an ambiguous
23    /// command scored at/above this is denied+queued, below it is allowed.
24    #[serde(default)]
25    pub threshold: Option<u8>,
26    /// Allow/deny rule lists.
27    #[serde(default)]
28    pub rules: Rules,
29}
30
31/// Default risk threshold for the ambiguous band when none is configured.
32pub const DEFAULT_THRESHOLD: u8 = 50;
33
34/// The allow/deny rule lists.
35#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
36pub struct Rules {
37    /// Commands to treat as auto-allow (tames the ambiguous band; never
38    /// downgrades a catastrophic block).
39    #[serde(default)]
40    pub allow: Vec<String>,
41    /// Commands to force to Hold/Deny regardless of class.
42    #[serde(default)]
43    pub deny: Vec<String>,
44}
45
46/// What a policy says about a specific command.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PolicyAction {
49    /// No rule matched.
50    None,
51    /// An allow rule matched.
52    Allow,
53    /// A deny rule matched (takes precedence over allow).
54    Deny,
55}
56
57impl Policy {
58    /// Parse a policy from TOML text.
59    pub fn parse(toml_str: &str) -> Result<Self, toml::de::Error> {
60        toml::from_str(toml_str)
61    }
62
63    /// Merge `repo` over `global`: repo mode wins; rule lists are concatenated
64    /// (global first, then repo).
65    pub fn merge(global: Policy, repo: Policy) -> Policy {
66        let mut allow = global.rules.allow;
67        allow.extend(repo.rules.allow);
68        let mut deny = global.rules.deny;
69        deny.extend(repo.rules.deny);
70        Policy {
71            mode: repo.mode.or(global.mode),
72            threshold: repo.threshold.or(global.threshold),
73            rules: Rules { allow, deny },
74        }
75    }
76
77    /// The effective risk threshold for the ambiguous band.
78    pub fn risk_threshold(&self) -> u8 {
79        self.threshold.unwrap_or(DEFAULT_THRESHOLD)
80    }
81
82    /// Decide what this policy says about a command. Deny wins over allow.
83    pub fn action_for(&self, command: &str) -> PolicyAction {
84        let cmd = command.trim();
85        if self.rules.deny.iter().any(|p| matches(p, cmd)) {
86            return PolicyAction::Deny;
87        }
88        if self.rules.allow.iter().any(|p| matches(p, cmd)) {
89            return PolicyAction::Allow;
90        }
91        PolicyAction::None
92    }
93}
94
95/// Apply a policy action to a verdict under a mode.
96///
97/// - `Deny` escalates: Attended→Hold, Unattended→Deny, Notify→unchanged (notify
98///   never blocks). The class is preserved.
99/// - `Allow` downgrades Safe/Ambiguous to Allow, but leaves a Catastrophic
100///   command held — the hard floor is never lifted by static config.
101/// - `None` leaves the verdict untouched.
102pub fn adjust_for_policy(mut verdict: Verdict, action: PolicyAction, mode: Mode) -> Verdict {
103    match action {
104        PolicyAction::None => verdict,
105        PolicyAction::Deny => {
106            match mode {
107                Mode::Attended => verdict.decision = Decision::Hold,
108                Mode::Unattended => verdict.decision = Decision::Deny,
109                Mode::Notify => {} // visibility-first: record but never block
110            }
111            verdict.reason = format!("policy:deny ({})", verdict.reason);
112            verdict
113        }
114        PolicyAction::Allow => {
115            if verdict.class == Class::Catastrophic {
116                // Hard floor: never auto-allow a catastrophic command via config.
117                verdict.reason = format!("policy:allow-ignored-catastrophic ({})", verdict.reason);
118                verdict
119            } else {
120                verdict.decision = Decision::Allow;
121                verdict.reason = format!("policy:allow ({})", verdict.reason);
122                verdict
123            }
124        }
125    }
126}
127
128/// Match a policy pattern against a command.
129///
130/// `*` is a wildcard. Without a wildcard, a pattern matches the whole command or
131/// a token-prefix of it (so `git push` matches `git push --force origin main`).
132pub fn matches(pattern: &str, command: &str) -> bool {
133    let pattern = pattern.trim();
134    let command = command.trim();
135    if pattern.is_empty() {
136        return false;
137    }
138    if pattern.contains('*') {
139        glob_match(pattern, command)
140    } else {
141        command == pattern || command.starts_with(&format!("{pattern} "))
142    }
143}
144
145/// A tiny glob matcher supporting only `*` (matches any run of characters).
146fn glob_match(pattern: &str, text: &str) -> bool {
147    let parts: Vec<&str> = pattern.split('*').collect();
148    let anchored_start = !pattern.starts_with('*');
149    let anchored_end = !pattern.ends_with('*');
150
151    let mut pos = 0usize;
152    for (i, part) in parts.iter().enumerate() {
153        if part.is_empty() {
154            continue;
155        }
156        match text[pos..].find(part) {
157            Some(idx) => {
158                let abs = pos + idx;
159                if i == 0 && anchored_start && abs != 0 {
160                    return false;
161                }
162                pos = abs + part.len();
163            }
164            None => return false,
165        }
166    }
167    if anchored_end {
168        if let Some(last) = parts.iter().rev().find(|p| !p.is_empty()) {
169            return text.ends_with(last);
170        }
171    }
172    true
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn parses_mode_and_rules() {
181        let p = Policy::parse(
182            r#"
183            mode = "notify"
184            [rules]
185            allow = ["cargo run"]
186            deny = ["git push *", "rm -rf"]
187            "#,
188        )
189        .unwrap();
190        assert_eq!(p.mode, Some(Mode::Notify));
191        assert_eq!(p.rules.allow, vec!["cargo run"]);
192        assert_eq!(p.rules.deny.len(), 2);
193    }
194
195    #[test]
196    fn empty_policy_parses() {
197        let p = Policy::parse("").unwrap();
198        assert_eq!(p, Policy::default());
199    }
200
201    #[test]
202    fn prefix_matching() {
203        assert!(matches("git push", "git push --force origin main"));
204        assert!(matches("git push", "git push"));
205        assert!(!matches("git push", "git pushing")); // token boundary
206        assert!(!matches("git push", "git status"));
207    }
208
209    #[test]
210    fn glob_matching() {
211        assert!(matches("rm *", "rm file.txt"));
212        assert!(matches("*secret*", "cat my-secret-file"));
213        assert!(matches("git * --force", "git push --force"));
214        assert!(!matches("git * --force", "git push origin"));
215    }
216
217    #[test]
218    fn deny_takes_precedence_over_allow() {
219        let p = Policy {
220            mode: None,
221            threshold: None,
222            rules: Rules {
223                allow: vec!["deploy".into()],
224                deny: vec!["deploy".into()],
225            },
226        };
227        assert_eq!(p.action_for("deploy now"), PolicyAction::Deny);
228        assert_eq!(p.risk_threshold(), DEFAULT_THRESHOLD);
229    }
230
231    #[test]
232    fn merge_repo_overrides_mode_and_extends_rules() {
233        let global = Policy::parse("mode = \"attended\"\n[rules]\nallow=[\"a\"]").unwrap();
234        let repo = Policy::parse("mode = \"notify\"\n[rules]\ndeny=[\"b\"]").unwrap();
235        let merged = Policy::merge(global, repo);
236        assert_eq!(merged.mode, Some(Mode::Notify));
237        assert_eq!(merged.rules.allow, vec!["a"]);
238        assert_eq!(merged.rules.deny, vec!["b"]);
239    }
240
241    #[test]
242    fn deny_escalates_safe_to_hold_in_attended() {
243        let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
244        let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Attended);
245        assert_eq!(adjusted.decision, Decision::Hold);
246        assert!(adjusted.reason.starts_with("policy:deny"));
247    }
248
249    #[test]
250    fn deny_in_notify_does_not_block() {
251        let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
252        let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Notify);
253        assert_eq!(adjusted.decision, Decision::Allow);
254    }
255
256    #[test]
257    fn allow_never_downgrades_catastrophic() {
258        let v = Verdict::rules(Class::Catastrophic, Decision::Hold, "rm:recursive");
259        let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
260        assert_eq!(adjusted.decision, Decision::Hold, "hard floor must stand");
261    }
262
263    #[test]
264    fn allow_tames_ambiguous() {
265        let v = Verdict::rules(Class::Ambiguous, Decision::Hold, "ambiguous:make");
266        let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
267        assert_eq!(adjusted.decision, Decision::Allow);
268    }
269}