Skip to main content

ai_agent/tools/powershell/
mode_validation.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/tools/PowerShellTool/modeValidation.ts
2//! PowerShell permission mode validation.
3//!
4//! Checks if commands should be auto-allowed based on the current permission mode.
5//! In acceptEdits mode, filesystem-modifying PowerShell cmdlets are auto-allowed.
6
7use once_cell::sync::Lazy;
8use std::collections::HashSet;
9
10use super::read_only_validation::{is_cwd_changing_cmdlet, is_safe_output_command, resolve_to_canonical};
11
12/// Filesystem-modifying cmdlets that are auto-allowed in acceptEdits mode
13static ACCEPT_EDITS_ALLOWED_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
14    let mut set = HashSet::new();
15    set.insert("set-content");
16    set.insert("add-content");
17    set.insert("remove-item");
18    set.insert("clear-content");
19    set
20});
21
22/// Link-creating -ItemType values
23static LINK_ITEM_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
24    let mut set = HashSet::new();
25    set.insert("symboliclink");
26    set.insert("junction");
27    set.insert("hardlink");
28    set
29});
30
31/// Check if cmdlet is allowed in acceptEdits mode
32fn is_accept_edits_allowed_cmdlet(name: &str) -> bool {
33    let canonical = resolve_to_canonical(name);
34    ACCEPT_EDITS_ALLOWED_CMDLETS.contains(canonical.as_str())
35}
36
37/// Check if a lowered, dash-normalized arg is an unambiguous PowerShell
38/// abbreviation of New-Item's -ItemType or -Type param.
39fn is_item_type_param_abbrev(param: &str) -> bool {
40    let lower = param.to_lowercase();
41    (lower.len() >= 3 && lower.starts_with("-it")) ||
42    (lower.len() >= 3 && (lower == "-ty" || lower.starts_with("-typ") || lower.starts_with("-type")))
43}
44
45/// Detects New-Item creating a filesystem link
46pub fn is_symlink_creating_command(name: &str, args: &[String]) -> bool {
47    let canonical = resolve_to_canonical(name);
48    if canonical != "new-item" {
49        return false;
50    }
51
52    let mut i = 0;
53    while i < args.len() {
54        let raw = &args[i];
55        if raw.is_empty() {
56            i += 1;
57            continue;
58        }
59
60        // Normalize dash prefixes
61        let normalized = if raw.starts_with('-') || raw.starts_with('–') || raw.starts_with('—') || raw.starts_with('―') || raw.starts_with('/') {
62            format!("-{}", &raw[1..])
63        } else {
64            raw.clone()
65        };
66
67        let lower = normalized.to_lowercase();
68
69        // Split colon-bound value: -it:SymbolicLink
70        let colon_idx = lower[1..].find(':').map(|p| p + 1).unwrap_or(0);
71        let param_raw = if colon_idx > 0 {
72            lower.get(1..=colon_idx).unwrap_or(&lower).to_string()
73        } else {
74            lower.clone()
75        };
76
77        // Strip backtick escapes
78        let param = param_raw.replace('`', "");
79
80        if !is_item_type_param_abbrev(&param) {
81            i += 1;
82            continue;
83        }
84
85        // Get value
86        let raw_val = if colon_idx > 0 {
87            lower.get(colon_idx + 1..).unwrap_or("").to_string()
88        } else {
89            args.get(i + 1).map(|s| s.to_lowercase()).unwrap_or_default()
90        };
91
92        // Strip backtick and quotes
93        let val = raw_val.replace('`', "").trim_matches('"').trim_matches('\'').to_string();
94
95        if LINK_ITEM_TYPES.contains(val.as_str()) {
96            return true;
97        }
98
99        i += 1;
100    }
101
102    false
103}
104
105/// Permission result behavior
106#[derive(Debug, Clone)]
107pub enum PermissionBehavior {
108    Allow,
109    Deny,
110    Ask,
111    Passthrough,
112}
113
114/// Permission result
115#[derive(Debug, Clone)]
116pub struct PermissionModeResult {
117    pub behavior: PermissionBehavior,
118    pub message: String,
119}
120
121impl PermissionModeResult {
122    pub fn allow() -> Self {
123        Self {
124            behavior: PermissionBehavior::Allow,
125            message: "Auto-allowed in acceptEdits mode".to_string(),
126        }
127    }
128
129    pub fn deny(message: &str) -> Self {
130        Self {
131            behavior: PermissionBehavior::Deny,
132            message: message.to_string(),
133        }
134    }
135
136    pub fn ask(message: &str) -> Self {
137        Self {
138            behavior: PermissionBehavior::Ask,
139            message: message.to_string(),
140        }
141    }
142
143    pub fn passthrough(message: &str) -> Self {
144        Self {
145            behavior: PermissionBehavior::Passthrough,
146            message: message.to_string(),
147        }
148    }
149}
150
151/// Checks if commands should be handled differently based on the current permission mode
152pub fn check_permission_mode(
153    command: &str,
154    mode: &str,
155) -> PermissionModeResult {
156    // Skip bypass and dontAsk modes
157    if mode == "bypassPermissions" || mode == "dontAsk" {
158        return PermissionModeResult::passthrough("Mode is handled in main permission flow");
159    }
160
161    if mode != "acceptEdits" {
162        return PermissionModeResult::passthrough("No mode-specific validation required");
163    }
164
165    // Check for security concerns that require approval
166    use super::read_only_validation::has_sync_security_concerns;
167    if has_sync_security_concerns(command) {
168        return PermissionModeResult::passthrough(
169            "Command contains subexpressions, script blocks, or member invocations that require approval"
170        );
171    }
172
173    // Check for compound command with cwd change
174    let parts: Vec<&str> = command.split(|c| c == ';' || c == '|').collect();
175    if parts.len() > 1 {
176        let mut has_cd = false;
177        let mut has_write = false;
178        let mut has_symlink = false;
179
180        for part in &parts {
181            let first_word = part.trim().split_whitespace().next().unwrap_or("");
182            if is_cwd_changing_cmdlet(first_word) {
183                has_cd = true;
184            }
185            if is_accept_edits_allowed_cmdlet(first_word) {
186                has_write = true;
187            }
188            // Check for symlink creation
189            let args: Vec<String> = part.trim().split_whitespace().skip(1).map(String::from).collect();
190            if is_symlink_creating_command(first_word, &args) {
191                has_symlink = true;
192            }
193        }
194
195        if has_cd && has_write {
196            return PermissionModeResult::passthrough(
197                "Compound command contains a directory-changing command with a write operation"
198            );
199        }
200
201        if has_symlink {
202            return PermissionModeResult::passthrough(
203                "Compound command creates a filesystem link"
204            );
205        }
206    }
207
208    // Check if the command is an acceptEdits-allowed cmdlet
209    let first_word = command.trim().split_whitespace().next().unwrap_or("");
210    if is_accept_edits_allowed_cmdlet(first_word) {
211        // Additional checks for safe arguments
212        use super::read_only_validation::arg_leaks_value;
213
214        let args: Vec<&str> = command.trim().split_whitespace().skip(1).collect();
215        for arg in args {
216            // Skip flags
217            if arg.starts_with('-') {
218                continue;
219            }
220            if arg_leaks_value(arg) {
221                return PermissionModeResult::passthrough("Command contains potentially unsafe arguments");
222            }
223        }
224
225        return PermissionModeResult::allow();
226    }
227
228    PermissionModeResult::passthrough("Command not in acceptEdits allowlist")
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_is_accept_edits_allowed_cmdlet() {
237        assert!(is_accept_edits_allowed_cmdlet("set-content"));
238        assert!(is_accept_edits_allowed_cmdlet("remove-item"));
239        assert!(!is_accept_edits_allowed_cmdlet("get-content"));
240    }
241
242    #[test]
243    fn test_is_symlink_creating_command() {
244        assert!(is_symlink_creating_command("new-item", &["-ItemType".to_string(), "SymbolicLink".to_string()]));
245        assert!(is_symlink_creating_command("ni", &["-ItemType".to_string(), "Junction".to_string()]));
246        assert!(!is_symlink_creating_command("new-item", &["-ItemType".to_string(), "File".to_string()]));
247        assert!(!is_symlink_creating_command("get-content", &[]));
248    }
249
250    #[test]
251    fn test_check_permission_mode() {
252        let result = check_permission_mode("Get-Content test.txt", "readOnly");
253        assert!(matches!(result.behavior, PermissionBehavior::Passthrough));
254
255        let result = check_permission_mode("Set-Content test.txt 'hello'", "acceptEdits");
256        assert!(matches!(result.behavior, PermissionBehavior::Allow));
257
258        let result = check_permission_mode("$(malicious)", "acceptEdits");
259        assert!(matches!(result.behavior, PermissionBehavior::Passthrough));
260    }
261}