1use gestura_core_foundation::permissions::PermissionLevel;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ToolConfirmationInfo {
16 pub description: String,
18 pub risk_level: u8,
20 pub category: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ToolCallDecision {
27 Allowed,
29 RequiresConfirmation(ToolConfirmationInfo),
31 Blocked { reason: String },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ToolPolicyEvaluation {
38 pub is_write_operation: bool,
40 pub decision: ToolCallDecision,
42}
43
44pub fn evaluate_tool_call(
51 permission_level: PermissionLevel,
52 tool_name: &str,
53 arguments: &str,
54) -> ToolPolicyEvaluation {
55 let is_write = is_write_operation(tool_name, arguments);
56
57 if permission_level.blocks(is_write) {
58 let reason = format!(
59 "Tool '{}' blocked: write operations are not allowed in Sandbox mode",
60 tool_name
61 );
62 return ToolPolicyEvaluation {
63 is_write_operation: is_write,
64 decision: ToolCallDecision::Blocked { reason },
65 };
66 }
67
68 if permission_level.requires_confirmation(is_write) {
69 let info = ToolConfirmationInfo {
71 description: format!("Tool '{}' wants to perform a write operation", tool_name),
72 risk_level: 2,
73 category: "write".to_string(),
74 };
75
76 return ToolPolicyEvaluation {
77 is_write_operation: is_write,
78 decision: ToolCallDecision::RequiresConfirmation(info),
79 };
80 }
81
82 ToolPolicyEvaluation {
83 is_write_operation: is_write,
84 decision: ToolCallDecision::Allowed,
85 }
86}
87
88pub fn is_action_allowed(permission_level: PermissionLevel, is_write_operation: bool) -> bool {
96 !permission_level.blocks(is_write_operation)
97}
98
99pub fn requires_confirmation(permission_level: PermissionLevel, is_write_operation: bool) -> bool {
104 permission_level.requires_confirmation(is_write_operation)
105}
106
107pub fn is_write_operation(tool_name: &str, arguments: &str) -> bool {
116 match tool_name {
117 "screenshot" | "screen_record" => true,
121
122 "shell" | "bash" | "execute" => is_shell_command_write_operation(arguments),
124
125 "file" | "write_file" | "edit_file" => {
128 if matches!(tool_name, "write_file" | "edit_file") {
129 return true;
130 }
131
132 if let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) {
133 let op = args
135 .get("operation")
136 .and_then(|v| v.as_str())
137 .unwrap_or_else(|| {
138 if args.get("content").is_some() {
139 "write"
140 } else {
141 "read"
142 }
143 });
144 matches!(op, "write" | "edit")
145 } else {
146 true
148 }
149 }
150 "read_file" => false,
151
152 "git" => {
154 if let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) {
155 let op = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
156 matches!(
157 op,
158 "commit"
159 | "push"
160 | "pull"
161 | "checkout"
162 | "merge"
163 | "rebase"
164 | "reset"
165 | "stash"
166 | "branch"
167 | "add"
168 | "rm"
169 )
170 } else {
171 false
172 }
173 }
174
175 "web" | "web_search" => false,
177
178 "code" => {
181 if let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) {
182 let op = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
183 matches!(op, "batch_edit" | "lint" | "test")
184 } else {
185 false
186 }
187 }
188
189 "mcp" => {
192 if let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments) {
193 let op = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
194 matches!(op, "install" | "enable" | "disable" | "remove")
195 } else {
196 false
197 }
198 }
199
200 _ => false,
202 }
203}
204
205pub fn is_shell_command_write_operation(arguments: &str) -> bool {
210 let command: String = match serde_json::from_str::<serde_json::Value>(arguments) {
211 Ok(args) => args
212 .get("command")
213 .and_then(|v| v.as_str())
214 .map(|s| s.to_string())
215 .unwrap_or_else(|| arguments.to_string()),
216 Err(_) => arguments.to_string(),
217 };
218
219 let cmd = command.trim();
220 if cmd.is_empty() {
221 return true;
222 }
223
224 let suspicious_tokens = [">>", ">", "<", "|", ";", "&&", "||", "\n", "\r", "`", "$("];
227 if suspicious_tokens.iter().any(|t| cmd.contains(t)) {
228 return true;
229 }
230
231 let first = cmd.split_whitespace().next().unwrap_or("");
233
234 let is_allowlisted_read = matches!(
237 first,
238 "pwd"
239 | "ls"
240 | "cat"
241 | "head"
242 | "tail"
243 | "wc"
244 | "stat"
245 | "file"
246 | "which"
247 | "whoami"
248 | "uname"
249 | "echo"
250 | "date"
251 | "env"
252 | "printenv"
253 | "rg"
254 | "grep"
255 );
256
257 !is_allowlisted_read
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn evaluate_blocks_writes_in_sandbox() {
266 let write = serde_json::json!({"operation": "write", "path": "foo.txt", "content": "hi"})
267 .to_string();
268
269 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "file", &write);
270 assert!(eval.is_write_operation);
271 assert!(matches!(eval.decision, ToolCallDecision::Blocked { .. }));
272 }
273
274 #[test]
275 fn evaluate_requires_confirmation_for_writes_in_restricted() {
276 let write = serde_json::json!({"operation": "write", "path": "foo.txt", "content": "hi"})
277 .to_string();
278
279 let eval = evaluate_tool_call(PermissionLevel::Restricted, "file", &write);
280 assert!(eval.is_write_operation);
281 assert!(matches!(
282 eval.decision,
283 ToolCallDecision::RequiresConfirmation(_)
284 ));
285 }
286
287 #[test]
288 fn evaluate_allows_reads_in_sandbox() {
289 let read = serde_json::json!({"operation": "read", "path": "foo.txt"}).to_string();
290
291 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "file", &read);
292 assert!(!eval.is_write_operation);
293 assert_eq!(eval.decision, ToolCallDecision::Allowed);
294 }
295
296 #[test]
297 fn evaluate_blocks_screen_capture_in_sandbox() {
298 let args = serde_json::json!({"output_path": "./artifacts/screen.png"}).to_string();
299
300 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "screenshot", &args);
301 assert!(eval.is_write_operation);
302 assert!(matches!(eval.decision, ToolCallDecision::Blocked { .. }));
303 }
304
305 #[test]
306 fn evaluate_requires_confirmation_for_screen_capture_in_restricted() {
307 let args = serde_json::json!({"output_path": "./artifacts/screen.png"}).to_string();
308
309 let eval = evaluate_tool_call(PermissionLevel::Restricted, "screenshot", &args);
310 assert!(eval.is_write_operation);
311 assert!(matches!(
312 eval.decision,
313 ToolCallDecision::RequiresConfirmation(_)
314 ));
315 }
316
317 #[test]
318 fn evaluate_blocks_screen_record_in_sandbox() {
319 let args = serde_json::json!({"operation": "start", "output_path": "./artifacts/rec.mp4"})
320 .to_string();
321
322 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "screen_record", &args);
323 assert!(eval.is_write_operation);
324 assert!(matches!(eval.decision, ToolCallDecision::Blocked { .. }));
325 }
326
327 #[test]
328 fn coarse_helpers_match_permission_level_semantics() {
329 assert!(is_action_allowed(PermissionLevel::Sandbox, false));
330 assert!(!is_action_allowed(PermissionLevel::Sandbox, true));
331
332 assert!(is_action_allowed(PermissionLevel::Restricted, false));
333 assert!(is_action_allowed(PermissionLevel::Restricted, true));
334
335 assert!(requires_confirmation(PermissionLevel::Restricted, true));
336 assert!(!requires_confirmation(PermissionLevel::Restricted, false));
337
338 assert!(is_action_allowed(PermissionLevel::Full, true));
339 assert!(!requires_confirmation(PermissionLevel::Full, true));
340 }
341
342 #[test]
345 fn code_batch_edit_is_write_operation() {
346 let args = serde_json::json!({"operation": "batch_edit", "edits": []}).to_string();
347 assert!(is_write_operation("code", &args));
348 }
349
350 #[test]
351 fn code_batch_edit_blocked_in_sandbox() {
352 let args = serde_json::json!({"operation": "batch_edit", "edits": []}).to_string();
353 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "code", &args);
354 assert!(eval.is_write_operation);
355 assert!(matches!(eval.decision, ToolCallDecision::Blocked { .. }));
356 }
357
358 #[test]
359 fn code_batch_edit_requires_confirmation_in_restricted() {
360 let args = serde_json::json!({"operation": "batch_edit", "edits": []}).to_string();
361 let eval = evaluate_tool_call(PermissionLevel::Restricted, "code", &args);
362 assert!(eval.is_write_operation);
363 assert!(matches!(
364 eval.decision,
365 ToolCallDecision::RequiresConfirmation(_)
366 ));
367 }
368
369 #[test]
370 fn code_lint_is_write_operation() {
371 let args = serde_json::json!({"operation": "lint", "path": "."}).to_string();
372 assert!(is_write_operation("code", &args));
373 }
374
375 #[test]
376 fn code_test_is_write_operation() {
377 let args = serde_json::json!({"operation": "test", "path": "."}).to_string();
378 assert!(is_write_operation("code", &args));
379 }
380
381 #[test]
382 fn code_glob_is_read_only() {
383 let args = serde_json::json!({"operation": "glob", "pattern": "**/*.rs"}).to_string();
384 assert!(!is_write_operation("code", &args));
385 }
386
387 #[test]
388 fn code_grep_is_read_only_in_sandbox() {
389 let args =
390 serde_json::json!({"operation": "grep", "pattern": "fn main", "path": "."}).to_string();
391 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "code", &args);
392 assert!(!eval.is_write_operation);
393 assert_eq!(eval.decision, ToolCallDecision::Allowed);
394 }
395
396 #[test]
397 fn code_symbols_is_read_only() {
398 let args = serde_json::json!({"operation": "symbols", "path": "src/main.rs"}).to_string();
399 assert!(!is_write_operation("code", &args));
400 }
401
402 #[test]
405 fn mcp_install_is_write_operation() {
406 let args =
407 serde_json::json!({"operation": "install", "server_id": "io.github.test/server"})
408 .to_string();
409 assert!(is_write_operation("mcp", &args));
410 }
411
412 #[test]
413 fn mcp_install_blocked_in_sandbox() {
414 let args =
415 serde_json::json!({"operation": "install", "server_id": "io.github.test/server"})
416 .to_string();
417 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "mcp", &args);
418 assert!(eval.is_write_operation);
419 assert!(matches!(eval.decision, ToolCallDecision::Blocked { .. }));
420 }
421
422 #[test]
423 fn mcp_install_requires_confirmation_in_restricted() {
424 let args =
425 serde_json::json!({"operation": "install", "server_id": "io.github.test/server"})
426 .to_string();
427 let eval = evaluate_tool_call(PermissionLevel::Restricted, "mcp", &args);
428 assert!(eval.is_write_operation);
429 assert!(matches!(
430 eval.decision,
431 ToolCallDecision::RequiresConfirmation(_)
432 ));
433 }
434
435 #[test]
436 fn mcp_enable_is_write_operation() {
437 let args = serde_json::json!({"operation": "enable", "name": "my-server"}).to_string();
438 assert!(is_write_operation("mcp", &args));
439 }
440
441 #[test]
442 fn mcp_disable_is_write_operation() {
443 let args = serde_json::json!({"operation": "disable", "name": "my-server"}).to_string();
444 assert!(is_write_operation("mcp", &args));
445 }
446
447 #[test]
448 fn mcp_remove_is_write_operation() {
449 let args = serde_json::json!({"operation": "remove", "name": "my-server"}).to_string();
450 assert!(is_write_operation("mcp", &args));
451 }
452
453 #[test]
454 fn mcp_search_is_read_only() {
455 let args = serde_json::json!({"operation": "search", "query": "filesystem"}).to_string();
456 assert!(!is_write_operation("mcp", &args));
457 }
458
459 #[test]
460 fn mcp_search_allowed_in_sandbox() {
461 let args = serde_json::json!({"operation": "search", "query": "filesystem"}).to_string();
462 let eval = evaluate_tool_call(PermissionLevel::Sandbox, "mcp", &args);
463 assert!(!eval.is_write_operation);
464 assert_eq!(eval.decision, ToolCallDecision::Allowed);
465 }
466
467 #[test]
468 fn mcp_evaluate_is_read_only() {
469 let args =
470 serde_json::json!({"operation": "evaluate", "server_id": "io.github.test/server"})
471 .to_string();
472 assert!(!is_write_operation("mcp", &args));
473 }
474
475 #[test]
476 fn mcp_list_is_read_only() {
477 let args = serde_json::json!({"operation": "list"}).to_string();
478 assert!(!is_write_operation("mcp", &args));
479 }
480}