返回 CodeWhale
rules.rs
根目录 / crates / tui / src / execpolicy / rules.rs
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
269 lines RUST