ai_agent/tools/powershell/
mode_validation.rs1use 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
12static 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
22static 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
31fn 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
37fn 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
45pub 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 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 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 let param = param_raw.replace('`', "");
79
80 if !is_item_type_param_abbrev(¶m) {
81 i += 1;
82 continue;
83 }
84
85 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 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#[derive(Debug, Clone)]
107pub enum PermissionBehavior {
108 Allow,
109 Deny,
110 Ask,
111 Passthrough,
112}
113
114#[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
151pub fn check_permission_mode(
153 command: &str,
154 mode: &str,
155) -> PermissionModeResult {
156 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 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 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 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 let first_word = command.trim().split_whitespace().next().unwrap_or("");
210 if is_accept_edits_allowed_cmdlet(first_word) {
211 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 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}