1use crate::tools::command_security::bash::extract_command_prefix;
10use crate::tools::command_security::bash::prefix::PrefixResult;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ShellKind {
15 Bash,
16 PowerShell,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RuleDecision {
22 Allow,
24 Deny,
26 Ask,
28}
29
30#[derive(Debug, Clone)]
32pub struct PermissionRule {
33 pub tool_name: String,
35 pub decision: RuleDecision,
37 pub command_prefix: Option<String>,
39 pub cwd_prefix: Option<std::path::PathBuf>,
41}
42
43impl PermissionRule {
44 pub fn allow_tool(name: impl Into<String>) -> Self {
46 Self {
47 tool_name: name.into(),
48 decision: RuleDecision::Allow,
49 command_prefix: None,
50 cwd_prefix: None,
51 }
52 }
53
54 pub fn deny_tool(name: impl Into<String>) -> Self {
56 Self {
57 tool_name: name.into(),
58 decision: RuleDecision::Deny,
59 command_prefix: None,
60 cwd_prefix: None,
61 }
62 }
63
64 pub fn ask_tool(name: impl Into<String>) -> Self {
66 Self {
67 tool_name: name.into(),
68 decision: RuleDecision::Ask,
69 command_prefix: None,
70 cwd_prefix: None,
71 }
72 }
73
74 pub fn command_prefix(mut self, prefix: impl Into<String>) -> Self {
76 self.command_prefix = Some(prefix.into());
77 self
78 }
79
80 pub fn cwd_prefix(mut self, prefix: impl Into<std::path::PathBuf>) -> Self {
82 self.cwd_prefix = Some(prefix.into());
83 self
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct PermissionEngine {
90 rules: Vec<PermissionRule>,
91}
92
93impl Default for PermissionEngine {
94 fn default() -> Self {
95 Self::new()
96 }
97}
98
99impl PermissionEngine {
100 pub fn new() -> Self {
101 Self { rules: Vec::new() }
102 }
103
104 pub fn add_rule(&mut self, rule: PermissionRule) {
106 self.rules.push(rule);
107 }
108
109 pub fn evaluate(&self, tool_name: &str) -> Option<RuleDecision> {
113 self.evaluate_call(tool_name, &serde_json::Value::Null, std::path::Path::new("."))
114 }
115
116 pub fn evaluate_call(
118 &self,
119 tool_name: &str,
120 arguments: &serde_json::Value,
121 cwd: &std::path::Path,
122 ) -> Option<RuleDecision> {
123 self.evaluate_call_any(&[tool_name], arguments, cwd)
124 }
125
126 pub fn evaluate_call_any(
131 &self,
132 tool_names: &[&str],
133 arguments: &serde_json::Value,
134 cwd: &std::path::Path,
135 ) -> Option<RuleDecision> {
136 let mut result = None;
137 for rule in &self.rules {
138 if tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name))
139 && Self::match_command_prefix(rule, arguments)
140 && Self::match_cwd_prefix(rule, cwd)
141 {
142 result = Some(rule.decision.clone());
143 }
144 }
145 result
146 }
147
148 pub fn evaluate_shell_call(
159 &self,
160 shell_kind: ShellKind,
161 tool_names: &[&str],
162 command: &str,
163 _arguments: &serde_json::Value,
164 cwd: &std::path::Path,
165 ) -> Option<RuleDecision> {
166 let _ = _arguments;
168
169 let extracted = match shell_kind {
170 ShellKind::Bash => match extract_command_prefix(command) {
171 PrefixResult::Prefix(p) => Some(p),
172 PrefixResult::None => None,
173 PrefixResult::NeedsReview => {
174 return self.evaluate_needs_review_shell(tool_names, cwd);
175 }
176 },
177 ShellKind::PowerShell => {
178 match crate::tools::command_security::powershell::extract_command_prefix(command) {
179 crate::tools::command_security::powershell::PrefixResult::Prefix(p) => Some(p),
180 crate::tools::command_security::powershell::PrefixResult::None => None,
181 crate::tools::command_security::powershell::PrefixResult::NeedsReview => {
182 return self.evaluate_needs_review_shell(tool_names, cwd);
183 }
184 }
185 }
186 };
187
188 let mut result = None;
189 for rule in &self.rules {
190 if !tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name)) {
191 continue;
192 }
193 if !Self::match_cwd_prefix(rule, cwd) {
194 continue;
195 }
196 if let Some(prefix) = &rule.command_prefix {
197 let haystack = extracted.as_deref().unwrap_or_else(|| command.trim_start());
198 let matches_prefix = match shell_kind {
199 ShellKind::Bash => haystack.starts_with(prefix),
200 ShellKind::PowerShell => {
201 haystack.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase())
202 }
203 };
204 if !matches_prefix {
205 continue;
206 }
207 }
208 result = Some(rule.decision.clone());
209 }
210 result
211 }
212
213 fn evaluate_needs_review_shell(
214 &self,
215 tool_names: &[&str],
216 cwd: &std::path::Path,
217 ) -> Option<RuleDecision> {
218 let mut result = None;
224 for rule in &self.rules {
225 if tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name))
226 && rule.command_prefix.is_none()
227 && Self::match_cwd_prefix(rule, cwd)
228 {
229 result = Some(rule.decision.clone());
230 }
231 }
232 result.or(Some(RuleDecision::Deny))
233 }
234
235 fn match_name(pattern: &str, name: &str) -> bool {
237 if pattern == "*" {
238 return true;
239 }
240 if let Some(prefix) = pattern.strip_suffix('*') {
241 return name.starts_with(prefix);
242 }
243 pattern == name
244 }
245
246 fn match_command_prefix(rule: &PermissionRule, arguments: &serde_json::Value) -> bool {
248 let Some(prefix) = &rule.command_prefix else {
249 return true;
250 };
251 arguments
252 .get("command")
253 .and_then(|value| value.as_str())
254 .map(|command| command.trim_start().starts_with(prefix))
255 .unwrap_or(false)
256 }
257
258 fn match_cwd_prefix(rule: &PermissionRule, cwd: &std::path::Path) -> bool {
260 let Some(prefix) = &rule.cwd_prefix else {
261 return true;
262 };
263 cwd.starts_with(prefix)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use serde_json::json;
271
272 #[test]
275 fn wildcard_star_matches_any_name() {
276 assert!(PermissionEngine::match_name("*", "anything"));
277 assert!(PermissionEngine::match_name("*", ""));
278 }
279
280 #[test]
281 fn prefix_wildcard_matches() {
282 assert!(PermissionEngine::match_name("Bash*", "Bash"));
283 assert!(PermissionEngine::match_name("Bash*", "BashExtended"));
284 assert!(!PermissionEngine::match_name("Bash*", "bash_lowercase"));
285 assert!(!PermissionEngine::match_name("Bash*", "XxxBash"));
286 }
287
288 #[test]
289 fn exact_match_no_wildcard() {
290 assert!(PermissionEngine::match_name("Write", "Write"));
291 assert!(!PermissionEngine::match_name("Write", "write"));
292 assert!(!PermissionEngine::match_name("Write", "WriteTool"));
293 }
294
295 #[test]
298 fn evaluate_returns_none_when_no_rules() {
299 let engine = PermissionEngine::new();
300 assert_eq!(engine.evaluate("Read"), None);
301 }
302
303 #[test]
304 fn evaluate_returns_last_matching_rule() {
305 let mut engine = PermissionEngine::new();
306 engine.add_rule(PermissionRule::deny_tool("*"));
307 engine.add_rule(PermissionRule::allow_tool("Read"));
308 assert_eq!(engine.evaluate("Read"), Some(RuleDecision::Allow));
309 assert_eq!(engine.evaluate("Write"), Some(RuleDecision::Deny));
310 }
311
312 #[test]
313 fn evaluate_falls_through_when_no_match() {
314 let mut engine = PermissionEngine::new();
315 engine.add_rule(PermissionRule::allow_tool("Read"));
316 assert_eq!(engine.evaluate("Write"), None);
317 }
318
319 #[test]
322 fn command_prefix_matches_when_set() {
323 let mut engine = PermissionEngine::new();
324 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git "));
325 assert_eq!(
326 engine.evaluate_call(
327 "Bash",
328 &json!({"command": "git status"}),
329 std::path::Path::new(".")
330 ),
331 Some(RuleDecision::Allow)
332 );
333 }
334
335 #[test]
336 fn command_prefix_no_match_when_wrong_command() {
337 let mut engine = PermissionEngine::new();
338 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("ls "));
339 assert_eq!(
340 engine.evaluate_call(
341 "Bash",
342 &json!({"command": "rm -rf /"}),
343 std::path::Path::new(".")
344 ),
345 None
346 );
347 }
348
349 #[test]
350 fn command_prefix_does_not_match_without_command_key() {
351 let mut engine = PermissionEngine::new();
352 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("ls "));
353 assert_eq!(engine.evaluate_call("Bash", &json!({}), std::path::Path::new(".")), None);
354 }
355
356 #[test]
359 fn cwd_prefix_matches_when_cwd_under_prefix() {
360 let mut engine = PermissionEngine::new();
361 engine.add_rule(
362 PermissionRule::allow_tool("Write").cwd_prefix(std::path::PathBuf::from("/safe")),
363 );
364 assert_eq!(
365 engine.evaluate_call("Write", &json!({}), std::path::Path::new("/safe/sub/dir")),
366 Some(RuleDecision::Allow)
367 );
368 }
369
370 #[test]
371 fn cwd_prefix_rejects_cwd_outside_prefix() {
372 let mut engine = PermissionEngine::new();
373 engine.add_rule(
374 PermissionRule::allow_tool("Write").cwd_prefix(std::path::PathBuf::from("/safe")),
375 );
376 assert_eq!(
377 engine.evaluate_call("Write", &json!({}), std::path::Path::new("/unsafe")),
378 None
379 );
380 }
381
382 #[test]
385 fn command_and_cwd_both_applied() {
386 let mut engine = PermissionEngine::new();
387 engine.add_rule(
388 PermissionRule::deny_tool("Bash")
389 .command_prefix("rm ")
390 .cwd_prefix(std::path::PathBuf::from("/protected")),
391 );
392 assert_eq!(
394 engine.evaluate_call(
395 "Bash",
396 &json!({"command": "rm file"}),
397 std::path::Path::new("/tmp")
398 ),
399 None
400 );
401 assert_eq!(
403 engine.evaluate_call(
404 "Bash",
405 &json!({"command": "ls"}),
406 std::path::Path::new("/protected/dir")
407 ),
408 None
409 );
410 assert_eq!(
412 engine.evaluate_call(
413 "Bash",
414 &json!({"command": "rm file"}),
415 std::path::Path::new("/protected/dir")
416 ),
417 Some(RuleDecision::Deny)
418 );
419 }
420
421 #[test]
424 fn shell_call_matches_extracted_prefix() {
425 let mut engine = PermissionEngine::new();
426 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
427 assert_eq!(
428 engine.evaluate_shell_call(
429 ShellKind::Bash,
430 &["Bash"],
431 "git status --short",
432 &json!({"command": "git status --short"}),
433 std::path::Path::new(".")
434 ),
435 Some(RuleDecision::Allow)
436 );
437 }
438
439 #[test]
440 fn shell_call_strips_redirects_for_prefix() {
441 let mut engine = PermissionEngine::new();
442 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
443 assert_eq!(
444 engine.evaluate_shell_call(
445 ShellKind::Bash,
446 &["Bash"],
447 "git status 2>&1",
448 &json!({"command": "git status 2>&1"}),
449 std::path::Path::new(".")
450 ),
451 Some(RuleDecision::Allow)
452 );
453 }
454
455 #[test]
456 fn shell_call_rejects_injection_for_prefix_rules() {
457 let mut engine = PermissionEngine::new();
458 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
459 assert_eq!(
463 engine.evaluate_shell_call(
464 ShellKind::Bash,
465 &["Bash"],
466 "git status; rm -rf /",
467 &json!({"command": "git status; rm -rf /"}),
468 std::path::Path::new(".")
469 ),
470 Some(RuleDecision::Deny)
471 );
472 }
473
474 #[test]
475 fn shell_call_general_tool_rule_still_applies_to_injection() {
476 let mut engine = PermissionEngine::new();
477 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
478 engine.add_rule(PermissionRule::deny_tool("Bash"));
479 assert_eq!(
480 engine.evaluate_shell_call(
481 ShellKind::Bash,
482 &["Bash"],
483 "git status; rm -rf /",
484 &json!({"command": "git status; rm -rf /"}),
485 std::path::Path::new(".")
486 ),
487 Some(RuleDecision::Deny)
488 );
489 }
490
491 #[test]
492 fn powershell_shell_call_matches_prefix_case_insensitively() {
493 let mut engine = PermissionEngine::new();
494 engine.add_rule(PermissionRule::allow_tool("PowerShell").command_prefix("get-process"));
495 assert_eq!(
496 engine.evaluate_shell_call(
497 ShellKind::PowerShell,
498 &["PowerShell"],
499 "Get-Process -Name pwsh",
500 &json!({"command": "Get-Process -Name pwsh"}),
501 std::path::Path::new(".")
502 ),
503 Some(RuleDecision::Allow)
504 );
505 }
506
507 #[test]
508 fn powershell_alias_matches_canonical_deny_rule() {
509 let mut engine = PermissionEngine::new();
510 engine.add_rule(PermissionRule::deny_tool("PowerShell").command_prefix("Remove-Item"));
511 assert_eq!(
512 engine.evaluate_shell_call(
513 ShellKind::PowerShell,
514 &["PowerShell"],
515 "rm ./file.txt",
516 &json!({"command": "rm ./file.txt"}),
517 std::path::Path::new(".")
518 ),
519 Some(RuleDecision::Deny)
520 );
521 }
522
523 #[test]
524 fn bash_and_powershell_rules_are_separate() {
525 let mut engine = PermissionEngine::new();
526 engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("Get-Process"));
527 assert_eq!(
528 engine.evaluate_shell_call(
529 ShellKind::PowerShell,
530 &["PowerShell"],
531 "Get-Process",
532 &json!({"command": "Get-Process"}),
533 std::path::Path::new(".")
534 ),
535 None
536 );
537 }
538}