| 1 | //! Execpolicy rules loaded from TOML configuration. |
| 2 | |
| 3 | use std::collections::BTreeMap; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | |
| 6 | use anyhow::{Context, Result}; |
| 7 | use serde::Deserialize; |
| 8 | |
| 9 | use super::matcher::pattern_matches; |
| 10 | use crate::command_safety::prefix_allow_matches; |
| 11 | |
| 12 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 13 | pub enum ExecPolicyDecision { |
| 14 | Allow, |
| 15 | Deny(String), |
| 16 | AskUser(String), |
| 17 | } |
| 18 | |
| 19 | #[derive(Debug, Deserialize, Default)] |
| 20 | pub struct ExecPolicyConfig { |
| 21 | #[serde(default)] |
| 22 | pub rules: BTreeMap<String, RuleSet>, |
| 23 | } |
| 24 | |
| 25 | #[derive(Debug, Deserialize, Default)] |
| 26 | pub struct RuleSet { |
| 27 | #[serde(default)] |
| 28 | pub allow: Vec<String>, |
| 29 | #[serde(default)] |
| 30 | pub deny: Vec<String>, |
| 31 | } |
| 32 | |
| 33 | impl ExecPolicyConfig { |
| 34 | pub fn from_str(contents: &str) -> Result<Self> { |
| 35 | toml::from_str(contents).context("failed to parse execpolicy.toml") |
| 36 | } |
| 37 | |
| 38 | pub fn from_path(path: &Path) -> Result<Self> { |
| 39 | let contents = std::fs::read_to_string(path) |
| 40 | .with_context(|| format!("failed to read execpolicy file {}", path.display()))?; |
| 41 | Self::from_str(&contents) |
| 42 | } |
| 43 | |
| 44 | pub fn evaluate(&self, command: &str) -> ExecPolicyDecision { |
| 45 | for (group, rules) in &self.rules { |
| 46 | for pattern in &rules.deny { |
| 47 | if pattern_matches(pattern, command) { |
| 48 | return ExecPolicyDecision::Deny(format!( |
| 49 | "execpolicy denied by {group}: {pattern}" |
| 50 | )); |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | for (group, rules) in &self.rules { |
| 56 | for pattern in &rules.allow { |
| 57 | // Allow rules use arity-aware prefix matching first so that |
| 58 | // `allow = ["git status"]` matches `git status -s` but NOT |
| 59 | // `git push origin main`. Fall back to regex-style |
| 60 | // `pattern_matches` for wildcard patterns (e.g. `cargo *`). |
| 61 | if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) { |
| 62 | let _ = group; |
| 63 | return ExecPolicyDecision::Allow; |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string()) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | pub fn default_execpolicy_path() -> Option<PathBuf> { |
| 73 | dirs::home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml")) |
| 74 | } |
| 75 | |
| 76 | pub fn load_default_policy() -> Result<Option<ExecPolicyConfig>> { |
| 77 | let Some(path) = default_execpolicy_path() else { |
| 78 | return Ok(None); |
| 79 | }; |
| 80 | if !path.exists() { |
| 81 | return Ok(None); |
| 82 | } |
| 83 | ExecPolicyConfig::from_path(&path).map(Some) |
| 84 | } |
| 85 | |
| 86 | #[cfg(test)] |
| 87 | mod tests { |
| 88 | use super::*; |
| 89 | |
| 90 | #[test] |
| 91 | fn test_execpolicy_evaluate() { |
| 92 | let config = ExecPolicyConfig { |
| 93 | rules: BTreeMap::from([ |
| 94 | ( |
| 95 | "git".to_string(), |
| 96 | RuleSet { |
| 97 | allow: vec!["git status".to_string(), "git log *".to_string()], |
| 98 | deny: vec!["git push --force".to_string()], |
| 99 | }, |
| 100 | ), |
| 101 | ( |
| 102 | "danger".to_string(), |
| 103 | RuleSet { |
| 104 | allow: vec![], |
| 105 | deny: vec!["rm -rf /".to_string()], |
| 106 | }, |
| 107 | ), |
| 108 | ]), |
| 109 | }; |
| 110 | |
| 111 | assert!(matches!( |
| 112 | config.evaluate("git status"), |
| 113 | ExecPolicyDecision::Allow |
| 114 | )); |
| 115 | assert!(matches!( |
| 116 | config.evaluate("git log --oneline"), |
| 117 | ExecPolicyDecision::Allow |
| 118 | )); |
| 119 | assert!(matches!( |
| 120 | config.evaluate("git push --force"), |
| 121 | ExecPolicyDecision::Deny(_) |
| 122 | )); |
| 123 | assert!(matches!( |
| 124 | config.evaluate("unknown command"), |
| 125 | ExecPolicyDecision::AskUser(_) |
| 126 | )); |
| 127 | } |
| 128 | |
| 129 | #[test] |
| 130 | fn test_prefix_rule_allows_git_status_with_flags() { |
| 131 | // Arity-aware: `allow = ["git status"]` must match `git status -s`. |
| 132 | let config = ExecPolicyConfig { |
| 133 | rules: BTreeMap::from([( |
| 134 | "git".to_string(), |
| 135 | RuleSet { |
| 136 | allow: vec!["git status".to_string()], |
| 137 | deny: vec![], |
| 138 | }, |
| 139 | )]), |
| 140 | }; |
| 141 | |
| 142 | assert!(matches!( |
| 143 | config.evaluate("git status -s"), |
| 144 | ExecPolicyDecision::Allow |
| 145 | )); |
| 146 | assert!(matches!( |
| 147 | config.evaluate("git status --porcelain"), |
| 148 | ExecPolicyDecision::Allow |
| 149 | )); |
| 150 | // Push must NOT match the "git status" allow rule. |
| 151 | assert!(matches!( |
| 152 | config.evaluate("git push origin main"), |
| 153 | ExecPolicyDecision::AskUser(_) |
| 154 | )); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn test_prefix_rule_allows_cargo_check_variants() { |
| 159 | let config = ExecPolicyConfig { |
| 160 | rules: BTreeMap::from([( |
| 161 | "cargo".to_string(), |
| 162 | RuleSet { |
| 163 | allow: vec!["cargo check".to_string()], |
| 164 | deny: vec![], |
| 165 | }, |
| 166 | )]), |
| 167 | }; |
| 168 | |
| 169 | assert!(matches!( |
| 170 | config.evaluate("cargo check"), |
| 171 | ExecPolicyDecision::Allow |
| 172 | )); |
| 173 | assert!(matches!( |
| 174 | config.evaluate("cargo check --workspace"), |
| 175 | ExecPolicyDecision::Allow |
| 176 | )); |
| 177 | assert!(matches!( |
| 178 | config.evaluate("cargo build --release"), |
| 179 | ExecPolicyDecision::AskUser(_) |
| 180 | )); |
| 181 | } |
| 182 | } |
| 183 |