Skip to main content

vtcode_core/tools/registry/
plan_mode_checks.rs

1//! Plan mode and mutation detection helpers for ToolRegistry.
2
3use serde_json::Value;
4
5use super::ToolRegistry;
6
7impl ToolRegistry {
8    /// Check if a tool is mutating (modifies files or environment).
9    ///
10    /// Returns true if the tool is mutating or unknown (conservative default).
11    pub fn is_mutating_tool(&self, name: &str) -> bool {
12        let resolved_name = self
13            .resolve_public_tool_name_sync(name)
14            .ok()
15            .unwrap_or_else(|| name.to_string());
16
17        if let Some(reg) = self.inventory.get_registration(&resolved_name) {
18            if let Some(behavior) = reg.metadata().behavior() {
19                return !matches!(
20                    behavior.mutation_model,
21                    crate::tools::tool_intent::ToolMutationModel::ReadOnly
22                );
23            }
24
25            if let super::ToolHandler::TraitObject(tool) = reg.handler() {
26                return tool.is_mutating();
27            }
28        }
29
30        if let Some(reg) = self.inventory.get_registration(name)
31            && let super::ToolHandler::TraitObject(tool) = reg.handler()
32        {
33            return tool.is_mutating();
34        }
35
36        // Conservative default: unknown tools are considered mutating
37        true
38    }
39
40    /// Check if a tool is allowed to run in plan mode without switching modes.
41    ///
42    /// Returns true for non-mutating tools and plan-safe exceptions like
43    /// writing to active plan storage (`/tmp/vtcode-plans/` by default) or read-only unified tool actions.
44    pub fn is_plan_mode_allowed(&self, tool_name: &str, args: &Value) -> bool {
45        use crate::config::constants::tools;
46        use crate::tools::names::canonical_tool_name;
47
48        // Keep adaptive task tracker available in all modes; retain plan alias.
49        let canonical = canonical_tool_name(tool_name);
50        match canonical {
51            tools::TASK_TRACKER => return true,
52            tools::PLAN_TASK_TRACKER => return true,
53            _ => {}
54        }
55
56        let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
57        if !intent.mutating {
58            return true;
59        }
60
61        let allowed_plan_write = self.is_plan_file_operation(tool_name, args);
62        let allowed_unified_readonly = intent.readonly_unified_action;
63
64        allowed_plan_write || allowed_unified_readonly
65    }
66
67    /// Check whether a tool invocation is safe to retry.
68    ///
69    /// Retries are allowed for read-only operations and for unified tools when
70    /// their specific action is read-only (`unified_file:read`, `unified_exec:poll|list`).
71    pub fn is_retry_safe_call(&self, tool_name: &str, args: &Value) -> bool {
72        crate::tools::tool_intent::classify_tool_intent(tool_name, args).retry_safe
73    }
74
75    /// Check if a tool operation is targeting the plans directory.
76    /// In plan mode, writes to active plan storage are allowed for the agent to write its plan.
77    pub(super) fn is_plan_file_operation(&self, tool_name: &str, args: &Value) -> bool {
78        use crate::config::constants::tools as tool_names;
79        use crate::tools::names::canonical_tool_name;
80
81        let canonical = canonical_tool_name(tool_name);
82        let normalized = canonical;
83
84        // Only check file-writing tools
85        let file_writing_tools = [
86            tool_names::WRITE_FILE,
87            tool_names::UNIFIED_FILE,
88            tool_names::CREATE_FILE,
89            tool_names::EDIT_FILE,
90            tool_names::SEARCH_REPLACE,
91        ];
92
93        if !file_writing_tools.contains(&normalized) {
94            return false;
95        }
96
97        // Extract file path from arguments
98        let path_str = args
99            .get("path")
100            .or_else(|| args.get("file_path"))
101            .or_else(|| args.get("filePath"))
102            .or_else(|| args.get("destination"))
103            .or_else(|| args.get("destination_path"))
104            .and_then(|v| v.as_str());
105
106        let Some(path_str) = path_str else {
107            return false;
108        };
109        let path = std::path::Path::new(path_str);
110
111        // Legacy workspace-scoped plan path (.vtcode/plans/) compatibility.
112        let plans_suffix = std::path::Path::new(".vtcode").join("plans");
113
114        // Check if path contains .vtcode/plans/
115        if path_str.contains(".vtcode/plans/") || path_str.contains(".vtcode\\plans\\") {
116            return true;
117        }
118
119        // Also check if it's a relative path under plans directory
120        if path.starts_with(&plans_suffix) {
121            return true;
122        }
123
124        // Check absolute legacy path against workspace root
125        let workspace = self.inventory.workspace_root();
126        let plans_dir = workspace.join(".vtcode").join("plans");
127        if path.starts_with(&plans_dir) {
128            return true;
129        }
130
131        // Default ephemeral plan storage path under /tmp.
132        if path_str.contains("/tmp/vtcode-plans/") || path_str.contains("\\tmp\\vtcode-plans\\") {
133            return true;
134        }
135        let tmp_plans = std::env::temp_dir().join("vtcode-plans");
136        if path.starts_with(&tmp_plans) {
137            return true;
138        }
139
140        false
141    }
142
143    /// Check if a unified tool call represents a read-only action.
144    /// Allows `unified_file` with action "read" and `unified_exec` with read-only actions
145    /// (poll/list/inspect/continue without input) plus allowlisted run commands or `--dry-run`.
146    #[expect(dead_code)]
147    pub(super) fn is_readonly_unified_action(&self, tool_name: &str, args: &Value) -> bool {
148        crate::tools::tool_intent::classify_tool_intent(tool_name, args).readonly_unified_action
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::ToolRegistry;
155    use crate::config::constants::tools;
156    use anyhow::Result;
157    use serde_json::json;
158    use tempfile::TempDir;
159
160    #[tokio::test]
161    async fn retry_safe_for_readonly_calls() -> Result<()> {
162        let temp_dir = TempDir::new()?;
163        let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
164
165        assert!(registry.is_retry_safe_call(
166            tools::UNIFIED_FILE,
167            &json!({"action": "read", "path": "README.md"})
168        ));
169        assert!(registry.is_retry_safe_call(
170            tools::UNIFIED_EXEC,
171            &json!({"action": "poll", "session_id": 42})
172        ));
173        assert!(registry.is_retry_safe_call(
174            tools::UNIFIED_EXEC,
175            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
176        ));
177        assert!(registry.is_retry_safe_call(tools::READ_FILE, &json!({"path": "README.md"})));
178
179        Ok(())
180    }
181
182    #[tokio::test]
183    async fn retry_unsafe_for_mutating_calls() -> Result<()> {
184        let temp_dir = TempDir::new()?;
185        let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
186
187        assert!(!registry.is_retry_safe_call(
188            tools::UNIFIED_FILE,
189            &json!({"action": "write", "path": "foo.txt", "content": "x"})
190        ));
191        assert!(!registry.is_retry_safe_call(
192            tools::UNIFIED_EXEC,
193            &json!({"action": "run", "command": "echo hi"})
194        ));
195        assert!(!registry.is_retry_safe_call(
196            tools::WRITE_FILE,
197            &json!({"path": "foo.txt", "content": "x"})
198        ));
199
200        Ok(())
201    }
202
203    #[tokio::test]
204    async fn plan_mode_allows_adaptive_task_tracker_and_plan_alias() -> Result<()> {
205        let temp_dir = TempDir::new()?;
206        let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
207        registry.enable_plan_mode();
208
209        assert!(registry.is_plan_mode_allowed(tools::TASK_TRACKER, &json!({"action": "list"})));
210        assert!(
211            registry.is_plan_mode_allowed(tools::PLAN_TASK_TRACKER, &json!({"action": "list"}))
212        );
213
214        Ok(())
215    }
216
217    #[tokio::test]
218    async fn plan_mode_allows_readonly_unified_exec_runs() -> Result<()> {
219        let temp_dir = TempDir::new()?;
220        let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
221        registry.enable_plan_mode();
222
223        assert!(registry.is_plan_mode_allowed(
224            tools::UNIFIED_EXEC,
225            &json!({"action": "run", "command": "ls -la"})
226        ));
227        assert!(registry.is_plan_mode_allowed(
228            tools::UNIFIED_EXEC,
229            &json!({"action": "run", "command": "npm install --dry-run"})
230        ));
231        assert!(!registry.is_plan_mode_allowed(
232            tools::UNIFIED_EXEC,
233            &json!({"action": "run", "command": "echo hi"})
234        ));
235
236        Ok(())
237    }
238}