| 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 | // #security: a deny pattern has to be matched against the commands the |
| 46 | // shell would actually run, not against the text as written. Quoting, |
| 47 | // command substitution (`` `cmd` ``, `$(cmd)`), grouping, chaining and |
| 48 | // wrapper payloads (`bash -c …`, `eval …`, `sudo …`) all produce an |
| 49 | // invocation whose text differs from the rule while its effect does |
| 50 | // not. `expanded_commands` word-splits the way a shell does and returns |
| 51 | // every command line involved, so one deny pattern covers all of the |
| 52 | // spellings instead of one string pattern per metacharacter. |
| 53 | // |
| 54 | // Only the deny loop is widened. The allow loop below still matches the |
| 55 | // command as written, so a broader expansion can never turn into a |
| 56 | // broader auto-approval. |
| 57 | let deny_targets = codewhale_execpolicy::shell_expand::expanded_commands(command); |
| 58 | for (group, rules) in &self.rules { |
| 59 | for pattern in &rules.deny { |
| 60 | if deny_targets |
| 61 | .iter() |
| 62 | .any(|target| pattern_matches(pattern, target)) |
| 63 | { |
| 64 | return ExecPolicyDecision::Deny(format!( |
| 65 | "execpolicy denied by {group}: {pattern}" |
| 66 | )); |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | for (group, rules) in &self.rules { |
| 72 | for pattern in &rules.allow { |
| 73 | // Allow rules use arity-aware prefix matching first so that |
| 74 | // `allow = ["git status"]` matches `git status -s` but NOT |
| 75 | // `git push origin main`. Fall back to regex-style |
| 76 | // `pattern_matches` for wildcard patterns (e.g. `cargo *`). |
| 77 | if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) { |
| 78 | let _ = group; |
| 79 | return ExecPolicyDecision::Allow; |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string()) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | pub fn default_execpolicy_path() -> Option<PathBuf> { |
| 89 | crate::config::effective_home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml")) |
| 90 | } |
| 91 | |
| 92 | pub fn load_default_policy() -> Result<Option<ExecPolicyConfig>> { |
| 93 | let Some(path) = default_execpolicy_path() else { |
| 94 | return Ok(None); |
| 95 | }; |
| 96 | if !path.exists() { |
| 97 | return Ok(None); |
| 98 | } |
| 99 | ExecPolicyConfig::from_path(&path).map(Some) |
| 100 | } |
| 101 | |
| 102 | #[cfg(test)] |
| 103 | mod tests { |
| 104 | use super::*; |
| 105 | |
| 106 | #[test] |
| 107 | fn test_execpolicy_evaluate() { |
| 108 | let config = ExecPolicyConfig { |
| 109 | rules: BTreeMap::from([ |
| 110 | ( |
| 111 | "git".to_string(), |
| 112 | RuleSet { |
| 113 | allow: vec!["git status".to_string(), "git log *".to_string()], |
| 114 | deny: vec!["git push --force".to_string()], |
| 115 | }, |
| 116 | ), |
| 117 | ( |
| 118 | "danger".to_string(), |
| 119 | RuleSet { |
| 120 | allow: vec![], |
| 121 | deny: vec!["rm -rf /".to_string()], |
| 122 | }, |
| 123 | ), |
| 124 | ]), |
| 125 | }; |
| 126 | |
| 127 | assert!(matches!( |
| 128 | config.evaluate("git status"), |
| 129 | ExecPolicyDecision::Allow |
| 130 | )); |
| 131 | assert!(matches!( |
| 132 | config.evaluate("git log --oneline"), |
| 133 | ExecPolicyDecision::Allow |
| 134 | )); |
| 135 | assert!(matches!( |
| 136 | config.evaluate("git push --force"), |
| 137 | ExecPolicyDecision::Deny(_) |
| 138 | )); |
| 139 | assert!(matches!( |
| 140 | config.evaluate("unknown command"), |
| 141 | ExecPolicyDecision::AskUser(_) |
| 142 | )); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn test_prefix_rule_allows_git_status_with_flags() { |
| 147 | // Arity-aware: `allow = ["git status"]` must match `git status -s`. |
| 148 | let config = ExecPolicyConfig { |
| 149 | rules: BTreeMap::from([( |
| 150 | "git".to_string(), |
| 151 | RuleSet { |
| 152 | allow: vec!["git status".to_string()], |
| 153 | deny: vec![], |
| 154 | }, |
| 155 | )]), |
| 156 | }; |
| 157 | |
| 158 | assert!(matches!( |
| 159 | config.evaluate("git status -s"), |
| 160 | ExecPolicyDecision::Allow |
| 161 | )); |
| 162 | assert!(matches!( |
| 163 | config.evaluate("git status --porcelain"), |
| 164 | ExecPolicyDecision::Allow |
| 165 | )); |
| 166 | // Push must NOT match the "git status" allow rule. |
| 167 | assert!(matches!( |
| 168 | config.evaluate("git push origin main"), |
| 169 | ExecPolicyDecision::AskUser(_) |
| 170 | )); |
| 171 | } |
| 172 | |
| 173 | fn danger_policy() -> ExecPolicyConfig { |
| 174 | ExecPolicyConfig { |
| 175 | rules: BTreeMap::from([( |
| 176 | "danger".to_string(), |
| 177 | RuleSet { |
| 178 | allow: vec!["echo *".to_string()], |
| 179 | deny: vec!["rm -rf /".to_string()], |
| 180 | }, |
| 181 | )]), |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// #security: the deny pattern must survive every way a shell can spell the |
| 186 | /// command it names. A whole-string match saw only the text as typed. |
| 187 | #[test] |
| 188 | fn deny_pattern_covers_every_shell_spelling() { |
| 189 | let config = danger_policy(); |
| 190 | let mut evaded = Vec::new(); |
| 191 | for command in [ |
| 192 | "rm -rf /", |
| 193 | "ls && rm -rf /", |
| 194 | "ls & rm -rf /", |
| 195 | "true; rm -rf /", |
| 196 | "ls | rm -rf /", |
| 197 | "ls\nrm -rf /", |
| 198 | "(rm -rf /)", |
| 199 | "{ rm -rf /; }", |
| 200 | "`rm -rf /`", |
| 201 | "echo `rm -rf /`", |
| 202 | "echo \"`rm -rf /`\"", |
| 203 | "$(rm -rf /)", |
| 204 | "echo $(rm -rf /)", |
| 205 | "x=$(rm -rf /)", |
| 206 | "diff <(rm -rf /) b", |
| 207 | "rm -rf \"/\"", |
| 208 | "rm -rf '/'", |
| 209 | "eval 'rm -rf /'", |
| 210 | "bash -c 'rm -rf /'", |
| 211 | "sh -lc \"rm -rf /\"", |
| 212 | "sudo rm -rf /", |
| 213 | "env rm -rf /", |
| 214 | "timeout 5 rm -rf /", |
| 215 | "xargs rm -rf /", |
| 216 | ] { |
| 217 | if !matches!(config.evaluate(command), ExecPolicyDecision::Deny(_)) { |
| 218 | evaded.push(command); |
| 219 | } |
| 220 | } |
| 221 | assert!(evaded.is_empty(), "deny pattern bypassed by: {evaded:#?}"); |
| 222 | } |
| 223 | |
| 224 | /// The fix must not deny a command merely for containing a metacharacter. |
| 225 | #[test] |
| 226 | fn deny_pattern_leaves_harmless_metacharacter_uses_alone() { |
| 227 | let config = danger_policy(); |
| 228 | for command in [ |
| 229 | // Substitution of something the rule does not name. |
| 230 | "echo \"built at $(date)\"", |
| 231 | "echo `date`", |
| 232 | // Single quotes are literal: this prints the text, runs nothing. |
| 233 | "echo '`rm -rf /`'", |
| 234 | "echo 'rm -rf /'", |
| 235 | ] { |
| 236 | assert!( |
| 237 | !matches!(config.evaluate(command), ExecPolicyDecision::Deny(_)), |
| 238 | "harmless command wrongly denied: {command:?}" |
| 239 | ); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn test_prefix_rule_allows_cargo_check_variants() { |
| 245 | let config = ExecPolicyConfig { |
| 246 | rules: BTreeMap::from([( |
| 247 | "cargo".to_string(), |
| 248 | RuleSet { |
| 249 | allow: vec!["cargo check".to_string()], |
| 250 | deny: vec![], |
| 251 | }, |
| 252 | )]), |
| 253 | }; |
| 254 | |
| 255 | assert!(matches!( |
| 256 | config.evaluate("cargo check"), |
| 257 | ExecPolicyDecision::Allow |
| 258 | )); |
| 259 | assert!(matches!( |
| 260 | config.evaluate("cargo check --workspace"), |
| 261 | ExecPolicyDecision::Allow |
| 262 | )); |
| 263 | assert!(matches!( |
| 264 | config.evaluate("cargo build --release"), |
| 265 | ExecPolicyDecision::AskUser(_) |
| 266 | )); |
| 267 | } |
| 268 | } |
| 269 |