gestura_core_tools/
policy.rs

1//! Tool policy helpers.
2//!
3//! This module centralizes tool write-classification and permission-level decisions.
4//!
5//! The pipeline, CLI, and GUI should rely on these helpers rather than duplicating
6//! their own logic for determining whether a tool call is:
7//! - blocked (e.g. Sandbox write),
8//! - requires confirmation (e.g. Restricted write), or
9//! - allowed.
10
11use gestura_core_foundation::permissions::PermissionLevel;
12
13/// A user-facing description of a tool confirmation prompt.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ToolConfirmationInfo {
16    /// Human-readable description of what the tool intends to do.
17    pub description: String,
18    /// Risk level hint (0-3) for UI severity.
19    pub risk_level: u8,
20    /// Category label used by the UI.
21    pub category: String,
22}
23
24/// Decision for a tool call at a given permission level.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ToolCallDecision {
27    /// The tool call is allowed to execute.
28    Allowed,
29    /// The tool call requires user confirmation before it can execute.
30    RequiresConfirmation(ToolConfirmationInfo),
31    /// The tool call is blocked and will not execute.
32    Blocked { reason: String },
33}
34
35/// Evaluation of a tool call.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ToolPolicyEvaluation {
38    /// Whether the tool call is classified as a write/side-effecting operation.
39    pub is_write_operation: bool,
40    /// The policy decision.
41    pub decision: ToolCallDecision,
42}
43
44/// Evaluate a tool call against a [`PermissionLevel`].
45///
46/// Behavior is intentionally aligned with the pipeline runtime gating:
47/// - Sandbox blocks write operations
48/// - Restricted requires confirmation for write operations
49/// - Full allows all operations
50pub 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        // Maintain existing UI semantics.
70        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
88/// Return whether an action should be allowed for a session at the given permission level.
89///
90/// This helper exists for UI layers (CLI/GUI) that want to pre-flight an operation using a
91/// coarse “write vs read” flag.
92///
93/// The pipeline remains the source of truth for runtime enforcement; this function is a
94/// convenience wrapper around [`PermissionLevel::blocks`].
95pub fn is_action_allowed(permission_level: PermissionLevel, is_write_operation: bool) -> bool {
96    !permission_level.blocks(is_write_operation)
97}
98
99/// Return whether an action should require confirmation for a session at the given permission level.
100///
101/// This is primarily used by UI layers to decide whether to show a confirmation prompt before
102/// sending a request/tool call.
103pub fn requires_confirmation(permission_level: PermissionLevel, is_write_operation: bool) -> bool {
104    permission_level.requires_confirmation(is_write_operation)
105}
106
107/// Determine if a tool operation is a write operation based on tool name and arguments.
108///
109/// Write operations include:
110/// - shell/bash/execute: commands that appear to modify state (best-effort classifier)
111/// - file: write/edit operations (or implicit write when `content` exists)
112/// - git: operations that modify repository state (commit/push/etc)
113///
114/// This classifier is intentionally conservative.
115pub fn is_write_operation(tool_name: &str, arguments: &str) -> bool {
116    match tool_name {
117        // Screen capture / recording is privacy-sensitive and produces artifacts on disk.
118        // Treat as write/side-effecting so it is blocked in Sandbox and requires confirmation
119        // in Restricted.
120        "screenshot" | "screen_record" => true,
121
122        // Shell commands can be read-only (e.g. `pwd`, `ls`). Use a conservative classifier.
123        "shell" | "bash" | "execute" => is_shell_command_write_operation(arguments),
124
125        // File operations depend on the operation type.
126        // IMPORTANT: Keep this aligned with `execute_file_tool` and the tool schema.
127        "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                // Mirror `execute_file_tool` defaulting behavior.
134                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                // If we can't parse, assume write for safety.
147                true
148            }
149        }
150        "read_file" => false,
151
152        // Git operations depend on the operation type.
153        "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 tools are always read-only (fetch / search).
176        "web" | "web_search" => false,
177
178        // Code tool: most operations are read-only analysis, but batch_edit writes to disk
179        // and lint/test spawn subprocesses that can modify state (fix, test output artifacts).
180        "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 manager: read operations (search/evaluate/info/list) are safe;
190        // write operations (install/enable/disable/remove) modify .mcp.json on disk.
191        "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        // Unknown tools are considered read-only by default.
201        _ => false,
202    }
203}
204
205/// Conservatively determine whether a shell tool call is likely to perform a write.
206///
207/// This function is intentionally biased toward safety: if we can't confidently
208/// classify a command as read-only, we treat it as a write operation.
209pub 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    // If the command uses shell control operators or redirection, treat as write.
225    // This avoids having to fully parse multi-command pipelines.
226    let suspicious_tokens = [">>", ">", "<", "|", ";", "&&", "||", "\n", "\r", "`", "$("];
227    if suspicious_tokens.iter().any(|t| cmd.contains(t)) {
228        return true;
229    }
230
231    // Extract the executable name (first token).
232    let first = cmd.split_whitespace().next().unwrap_or("");
233
234    // Allowlist of common read-only commands we want to work in Restricted mode.
235    // Anything not in this list is treated as write.
236    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    // ── Code tool policy ──────────────────────────────────────────────────────
343
344    #[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    // ── MCP manager tool policy ───────────────────────────────────────────────
403
404    #[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}