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