返回 CodeWhale
toml_rules.rs
根目录 / crates / execpolicy / src / toml_rules.rs
1 //! Execpolicy rules loaded from TOML configuration.
2 //!
3 //! The legacy Starlark policy engine (`PolicyParser`, `Policy`, `Rule`, the
4 //! `execpolicy check` CLI verb) was deleted for v0.9.4: the runtime never
5 //! enforced it, so a green `check` meant nothing. The live policy surface is
6 //! the TOML `execpolicy.toml` rules here plus this crate's permission engine.
7 //!
8 //! Where the file lives is the caller's business — the TUI resolves
9 //! `~/.deepseek/execpolicy.toml` and hands the parsed [`ExecPolicyConfig`]
10 //! to its shell tool.
11
12 use std::collections::BTreeMap;
13 use std::path::Path;
14
15 use anyhow::{Context, Result};
16 use serde::Deserialize;
17
18 use crate::command_safety::{prefix_allow_matches, prefix_grant_is_eligible};
19 use crate::matcher::pattern_matches;
20
21 /// Verdict of evaluating a command against the TOML rule sets.
22 ///
23 /// Distinct from [`crate::ExecPolicyDecision`], which is the permission
24 /// engine's structured output; this is the file-rules verdict consumed by
25 /// the shell tool.
26 #[derive(Debug, Clone, PartialEq, Eq)]
27 pub enum RuleDecision {
28 Allow,
29 Deny(String),
30 AskUser(String),
31 }
32
33 #[derive(Debug, Clone, Deserialize, Default)]
34 pub struct ExecPolicyConfig {
35 #[serde(default)]
36 pub rules: BTreeMap<String, RuleSet>,
37 }
38
39 #[derive(Debug, Clone, Deserialize, Default)]
40 pub struct RuleSet {
41 #[serde(default)]
42 pub allow: Vec<String>,
43 #[serde(default)]
44 pub deny: Vec<String>,
45 }
46
47 impl ExecPolicyConfig {
48 pub fn parse(contents: &str) -> Result<Self> {
49 toml::from_str(contents).context("failed to parse execpolicy.toml")
50 }
51
52 pub fn from_path(path: &Path) -> Result<Self> {
53 let contents = std::fs::read_to_string(path)
54 .with_context(|| format!("failed to read execpolicy file {}", path.display()))?;
55 Self::parse(&contents)
56 }
57
58 pub fn evaluate(&self, command: &str) -> RuleDecision {
59 // #security: a deny pattern has to be matched against the commands the
60 // shell would actually run, not against the text as written. Quoting,
61 // command substitution (`` `cmd` ``, `$(cmd)`), grouping, chaining and
62 // wrapper payloads (`bash -c …`, `eval …`, `sudo …`) all produce an
63 // invocation whose text differs from the rule while its effect does
64 // not. `expanded_commands` word-splits the way a shell does and returns
65 // every command line involved, so one deny pattern covers all of the
66 // spellings instead of one string pattern per metacharacter.
67 //
68 // Only the deny loop is widened. The allow loop below still matches the
69 // command as written, so a broader expansion can never turn into a
70 // broader auto-approval.
71 let expansion = crate::shell_expand::expand_command(command);
72 let prefix_eligible = prefix_grant_is_eligible(command, &expansion);
73 let deny_targets = expansion.commands;
74 // A command word only known at run time cannot be checked against a
75 // deny pattern: fail closed while any deny pattern is configured, and
76 // never let an allow pattern written for the outer command approve
77 // nested or unresolved code.
78 if expansion.dynamic && self.rules.values().any(|rules| !rules.deny.is_empty()) {
79 return RuleDecision::Deny(
80 "execpolicy: command word cannot be resolved statically while deny rules are in force"
81 .to_string(),
82 );
83 }
84 // A deny pattern names a command prefix, as the permission engine's
85 // deny rules do: `git push --force` also denies
86 // `git push --force origin main`. The whole-command glob still
87 // applies for patterns that spell out `*` wildcards.
88 for (group, rules) in &self.rules {
89 for pattern in &rules.deny {
90 if deny_targets.iter().any(|target| {
91 crate::denied_prefix_matches(pattern, target)
92 || pattern_matches(pattern, target)
93 }) {
94 return RuleDecision::Deny(format!("execpolicy denied by {group}: {pattern}"));
95 }
96 }
97 }
98
99 if !prefix_eligible {
100 return RuleDecision::AskUser(
101 "execpolicy: command syntax or arguments require an exact approval".to_string(),
102 );
103 }
104 for (group, rules) in &self.rules {
105 for pattern in &rules.allow {
106 // Allow rules use arity-aware prefix matching first so that
107 // `allow = ["git status"]` matches `git status -s` but NOT
108 // `git push origin main`. Fall back to regex-style
109 // `pattern_matches` for wildcard patterns (e.g. `cargo *`).
110 if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) {
111 let _ = group;
112 return RuleDecision::Allow;
113 }
114 }
115 }
116
117 RuleDecision::AskUser("execpolicy: no matching allow rule".to_string())
118 }
119 }
120
121 #[cfg(test)]
122 mod tests {
123 use super::*;
124
125 #[test]
126 fn test_execpolicy_evaluate() {
127 let config = ExecPolicyConfig {
128 rules: BTreeMap::from([
129 (
130 "git".to_string(),
131 RuleSet {
132 allow: vec!["git status".to_string(), "git log *".to_string()],
133 deny: vec!["git push --force".to_string()],
134 },
135 ),
136 (
137 "danger".to_string(),
138 RuleSet {
139 allow: vec![],
140 deny: vec!["rm -rf /".to_string()],
141 },
142 ),
143 ]),
144 };
145
146 assert!(matches!(config.evaluate("git status"), RuleDecision::Allow));
147 assert!(matches!(
148 config.evaluate("git log --oneline"),
149 RuleDecision::Allow
150 ));
151 assert!(matches!(
152 config.evaluate("git push --force"),
153 RuleDecision::Deny(_)
154 ));
155 assert!(matches!(
156 config.evaluate("unknown command"),
157 RuleDecision::AskUser(_)
158 ));
159 }
160
161 #[test]
162 fn test_prefix_rule_allows_git_status_with_flags() {
163 // Arity-aware: `allow = ["git status"]` must match `git status -s`.
164 let config = ExecPolicyConfig {
165 rules: BTreeMap::from([(
166 "git".to_string(),
167 RuleSet {
168 allow: vec!["git status".to_string()],
169 deny: vec![],
170 },
171 )]),
172 };
173
174 assert!(matches!(
175 config.evaluate("git status -s"),
176 RuleDecision::Allow
177 ));
178 assert!(matches!(
179 config.evaluate("git status --porcelain"),
180 RuleDecision::Allow
181 ));
182 // Push must NOT match the "git status" allow rule.
183 assert!(matches!(
184 config.evaluate("git push origin main"),
185 RuleDecision::AskUser(_)
186 ));
187 }
188
189 fn danger_policy() -> ExecPolicyConfig {
190 ExecPolicyConfig {
191 rules: BTreeMap::from([(
192 "danger".to_string(),
193 RuleSet {
194 allow: vec!["echo *".to_string()],
195 deny: vec!["rm -rf /".to_string()],
196 },
197 )]),
198 }
199 }
200
201 /// #security: the deny pattern must survive every way a shell can spell the
202 /// command it names. A whole-string match saw only the text as typed.
203 #[test]
204 fn deny_pattern_covers_every_shell_spelling() {
205 let config = danger_policy();
206 let mut evaded = Vec::new();
207 for command in [
208 "rm -rf /",
209 "ls && rm -rf /",
210 "ls & rm -rf /",
211 "true; rm -rf /",
212 "ls | rm -rf /",
213 "ls\nrm -rf /",
214 "(rm -rf /)",
215 "{ rm -rf /; }",
216 "`rm -rf /`",
217 "echo `rm -rf /`",
218 "echo \"`rm -rf /`\"",
219 "$(rm -rf /)",
220 "echo $(rm -rf /)",
221 "x=$(rm -rf /)",
222 "diff <(rm -rf /) b",
223 "rm -rf \"/\"",
224 "rm -rf '/'",
225 "eval 'rm -rf /'",
226 "bash -c 'rm -rf /'",
227 "sh -lc \"rm -rf /\"",
228 "sudo rm -rf /",
229 "env rm -rf /",
230 "timeout 5 rm -rf /",
231 "xargs rm -rf /",
232 ] {
233 if !matches!(config.evaluate(command), RuleDecision::Deny(_)) {
234 evaded.push(command);
235 }
236 }
237 assert!(evaded.is_empty(), "deny pattern bypassed by: {evaded:#?}");
238 }
239
240 #[test]
241 fn deny_pattern_is_a_command_prefix() {
242 let config = ExecPolicyConfig {
243 rules: BTreeMap::from([(
244 "danger".to_string(),
245 RuleSet {
246 allow: vec!["git *".to_string()],
247 deny: vec!["git push --force".to_string(), "rm -rf /".to_string()],
248 },
249 )]),
250 };
251 for command in [
252 "git push --force",
253 "git push --force origin main",
254 "git push origin main --force",
255 "rm -rf / --no-preserve-root",
256 "git -C . push --force",
257 "git commit -m 'a\nb' && git push --force",
258 ] {
259 assert!(
260 matches!(config.evaluate(command), RuleDecision::Deny(_)),
261 "{command:?} must be denied"
262 );
263 }
264 for command in [
265 "git push origin main",
266 "git push --force-with-lease",
267 "rm -rf ./x",
268 ] {
269 assert!(
270 !matches!(config.evaluate(command), RuleDecision::Deny(_)),
271 "{command:?} must not be denied"
272 );
273 }
274 }
275
276 /// The fix must not deny a command merely for containing a metacharacter.
277 #[test]
278 fn deny_pattern_leaves_harmless_metacharacter_uses_alone() {
279 let config = danger_policy();
280 for command in [
281 // Substitution of something the rule does not name.
282 "echo \"built at $(date)\"",
283 "echo `date`",
284 // Single quotes are literal: this prints the text, runs nothing.
285 "echo '`rm -rf /`'",
286 "echo 'rm -rf /'",
287 ] {
288 assert!(
289 !matches!(config.evaluate(command), RuleDecision::Deny(_)),
290 "harmless command wrongly denied: {command:?}"
291 );
292 }
293 }
294
295 #[test]
296 fn test_prefix_rule_allows_cargo_check_variants() {
297 let config = ExecPolicyConfig {
298 rules: BTreeMap::from([(
299 "cargo".to_string(),
300 RuleSet {
301 allow: vec!["cargo check".to_string()],
302 deny: vec![],
303 },
304 )]),
305 };
306
307 assert!(matches!(
308 config.evaluate("cargo check"),
309 RuleDecision::Allow
310 ));
311 assert!(matches!(
312 config.evaluate("cargo check --workspace"),
313 RuleDecision::Allow
314 ));
315 assert!(matches!(
316 config.evaluate("cargo build --release"),
317 RuleDecision::AskUser(_)
318 ));
319 }
320 }
321
321 lines RUST