返回 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;
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 deny_targets = crate::shell_expand::expanded_commands(command);
72 for (group, rules) in &self.rules {
73 for pattern in &rules.deny {
74 if deny_targets
75 .iter()
76 .any(|target| pattern_matches(pattern, target))
77 {
78 return RuleDecision::Deny(format!("execpolicy denied by {group}: {pattern}"));
79 }
80 }
81 }
82
83 for (group, rules) in &self.rules {
84 for pattern in &rules.allow {
85 // Allow rules use arity-aware prefix matching first so that
86 // `allow = ["git status"]` matches `git status -s` but NOT
87 // `git push origin main`. Fall back to regex-style
88 // `pattern_matches` for wildcard patterns (e.g. `cargo *`).
89 if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) {
90 let _ = group;
91 return RuleDecision::Allow;
92 }
93 }
94 }
95
96 RuleDecision::AskUser("execpolicy: no matching allow rule".to_string())
97 }
98 }
99
100 #[cfg(test)]
101 mod tests {
102 use super::*;
103
104 #[test]
105 fn test_execpolicy_evaluate() {
106 let config = ExecPolicyConfig {
107 rules: BTreeMap::from([
108 (
109 "git".to_string(),
110 RuleSet {
111 allow: vec!["git status".to_string(), "git log *".to_string()],
112 deny: vec!["git push --force".to_string()],
113 },
114 ),
115 (
116 "danger".to_string(),
117 RuleSet {
118 allow: vec![],
119 deny: vec!["rm -rf /".to_string()],
120 },
121 ),
122 ]),
123 };
124
125 assert!(matches!(config.evaluate("git status"), RuleDecision::Allow));
126 assert!(matches!(
127 config.evaluate("git log --oneline"),
128 RuleDecision::Allow
129 ));
130 assert!(matches!(
131 config.evaluate("git push --force"),
132 RuleDecision::Deny(_)
133 ));
134 assert!(matches!(
135 config.evaluate("unknown command"),
136 RuleDecision::AskUser(_)
137 ));
138 }
139
140 #[test]
141 fn test_prefix_rule_allows_git_status_with_flags() {
142 // Arity-aware: `allow = ["git status"]` must match `git status -s`.
143 let config = ExecPolicyConfig {
144 rules: BTreeMap::from([(
145 "git".to_string(),
146 RuleSet {
147 allow: vec!["git status".to_string()],
148 deny: vec![],
149 },
150 )]),
151 };
152
153 assert!(matches!(
154 config.evaluate("git status -s"),
155 RuleDecision::Allow
156 ));
157 assert!(matches!(
158 config.evaluate("git status --porcelain"),
159 RuleDecision::Allow
160 ));
161 // Push must NOT match the "git status" allow rule.
162 assert!(matches!(
163 config.evaluate("git push origin main"),
164 RuleDecision::AskUser(_)
165 ));
166 }
167
168 fn danger_policy() -> ExecPolicyConfig {
169 ExecPolicyConfig {
170 rules: BTreeMap::from([(
171 "danger".to_string(),
172 RuleSet {
173 allow: vec!["echo *".to_string()],
174 deny: vec!["rm -rf /".to_string()],
175 },
176 )]),
177 }
178 }
179
180 /// #security: the deny pattern must survive every way a shell can spell the
181 /// command it names. A whole-string match saw only the text as typed.
182 #[test]
183 fn deny_pattern_covers_every_shell_spelling() {
184 let config = danger_policy();
185 let mut evaded = Vec::new();
186 for command in [
187 "rm -rf /",
188 "ls && rm -rf /",
189 "ls & rm -rf /",
190 "true; rm -rf /",
191 "ls | rm -rf /",
192 "ls\nrm -rf /",
193 "(rm -rf /)",
194 "{ rm -rf /; }",
195 "`rm -rf /`",
196 "echo `rm -rf /`",
197 "echo \"`rm -rf /`\"",
198 "$(rm -rf /)",
199 "echo $(rm -rf /)",
200 "x=$(rm -rf /)",
201 "diff <(rm -rf /) b",
202 "rm -rf \"/\"",
203 "rm -rf '/'",
204 "eval 'rm -rf /'",
205 "bash -c 'rm -rf /'",
206 "sh -lc \"rm -rf /\"",
207 "sudo rm -rf /",
208 "env rm -rf /",
209 "timeout 5 rm -rf /",
210 "xargs rm -rf /",
211 ] {
212 if !matches!(config.evaluate(command), RuleDecision::Deny(_)) {
213 evaded.push(command);
214 }
215 }
216 assert!(evaded.is_empty(), "deny pattern bypassed by: {evaded:#?}");
217 }
218
219 /// The fix must not deny a command merely for containing a metacharacter.
220 #[test]
221 fn deny_pattern_leaves_harmless_metacharacter_uses_alone() {
222 let config = danger_policy();
223 for command in [
224 // Substitution of something the rule does not name.
225 "echo \"built at $(date)\"",
226 "echo `date`",
227 // Single quotes are literal: this prints the text, runs nothing.
228 "echo '`rm -rf /`'",
229 "echo 'rm -rf /'",
230 ] {
231 assert!(
232 !matches!(config.evaluate(command), RuleDecision::Deny(_)),
233 "harmless command wrongly denied: {command:?}"
234 );
235 }
236 }
237
238 #[test]
239 fn test_prefix_rule_allows_cargo_check_variants() {
240 let config = ExecPolicyConfig {
241 rules: BTreeMap::from([(
242 "cargo".to_string(),
243 RuleSet {
244 allow: vec!["cargo check".to_string()],
245 deny: vec![],
246 },
247 )]),
248 };
249
250 assert!(matches!(
251 config.evaluate("cargo check"),
252 RuleDecision::Allow
253 ));
254 assert!(matches!(
255 config.evaluate("cargo check --workspace"),
256 RuleDecision::Allow
257 ));
258 assert!(matches!(
259 config.evaluate("cargo build --release"),
260 RuleDecision::AskUser(_)
261 ));
262 }
263 }
264
264 lines RUST