ai_agent/tools/powershell/
mode_validation.rs1use 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
14static 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
24static 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
33fn 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
39fn 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
48pub 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 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 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 let param = param_raw.replace('`', "");
87
88 if !is_item_type_param_abbrev(¶m) {
89 i += 1;
90 continue;
91 }
92
93 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 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#[derive(Debug, Clone)]
121pub enum PermissionBehavior {
122 Allow,
123 Deny,
124 Ask,
125 Passthrough,
126}
127
128#[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
165pub fn check_permission_mode(command: &str, mode: &str) -> PermissionModeResult {
167 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 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 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 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 let first_word = command.trim().split_whitespace().next().unwrap_or("");
224 if is_accept_edits_allowed_cmdlet(first_word) {
225 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 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}