Skip to main content

vtcode_core/tool_policy/
manager.rs

1use std::future::Future;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use dialoguer::console::style;
6use hashbrown::{HashMap, HashSet};
7use indexmap::IndexMap;
8use regex::Regex;
9
10use super::ToolPolicy;
11use super::alternative::AlternativeToolPolicyConfig;
12use super::mcp::{McpPolicyStore, mcp_standard_logging, parse_mcp_policy_key};
13use super::model::{
14    ApprovalCacheConfig, PermissionPromptHandler, ToolConstraints, ToolExecutionDecision, ToolPolicyConfig,
15};
16use super::shell_approval::{
17    derived_shell_approval_prefixes, shell_command_words_match_prefix, shell_words_lossy, split_shell_approval_entry,
18};
19use super::{APPROVAL_REGEX_CACHE, AUTO_ALLOW_TOOLS, DEFAULT_APPROVAL_SCOPE_SIGNATURE};
20use crate::config::core::tools::ToolsConfig;
21use crate::config::loader::{ConfigManager, VTCodeConfig};
22use crate::config::mcp::{McpAllowListConfig, McpAllowListRules};
23use crate::utils::error_messages::ERR_CREATE_POLICY_DIR;
24use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context, write_file_atomic_with_context};
25use crate::utils::tool_name_parsing::canonical_tool_name;
26use vtcode_commons::VtCodePaths;
27use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic};
28
29/// Tool policy manager
30pub struct ToolPolicyManager {
31    pub(crate) config_path: PathBuf,
32    pub(crate) config: ToolPolicyConfig,
33    pub(crate) permission_handler: Option<Box<dyn PermissionPromptHandler>>,
34    pub(crate) workspace_root: Option<PathBuf>,
35    pub(crate) private_storage: bool,
36}
37
38impl Clone for ToolPolicyManager {
39    fn clone(&self) -> Self {
40        // Note: Permission handler is not cloned - this is intentional as handlers
41        // typically contain UI state that shouldn't be duplicated
42        Self {
43            config_path: self.config_path.clone(),
44            config: self.config.clone(),
45            permission_handler: None, // Handler is not cloned
46            workspace_root: self.workspace_root.clone(),
47            private_storage: self.private_storage,
48        }
49    }
50}
51
52impl ToolPolicyManager {
53    /// Create a new tool policy manager
54    pub async fn new() -> Result<Self> {
55        let config_path = Self::get_config_path().await?;
56        let config = Self::load_or_create_config_with_policy(&config_path, true).await?;
57
58        Ok(Self {
59            config_path,
60            config,
61            permission_handler: None,
62            workspace_root: None,
63            private_storage: true,
64        })
65    }
66
67    /// Create a new tool policy manager with workspace-specific config
68    pub async fn new_with_workspace(workspace_root: &Path) -> Result<Self> {
69        let config_path = Self::get_workspace_config_path(workspace_root).await?;
70        let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
71
72        Ok(Self {
73            config_path,
74            config,
75            permission_handler: None,
76            workspace_root: Some(workspace_root.to_path_buf()),
77            private_storage: false,
78        })
79    }
80
81    /// Create a new tool policy manager backed by a custom configuration path.
82    ///
83    /// This helper allows downstream consumers to store policy data alongside
84    /// their own configuration hierarchy instead of writing to the default
85    /// `.vtcode` directory.
86    pub async fn new_with_config_path<P: Into<PathBuf>>(config_path: P) -> Result<Self> {
87        let config_path = config_path.into();
88
89        if let Some(parent) = config_path.parent()
90            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
91        {
92            ensure_dir_exists(parent)
93                .await
94                .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
95        }
96
97        let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
98
99        Ok(Self {
100            config_path,
101            config,
102            permission_handler: None,
103            workspace_root: None,
104            private_storage: false,
105        })
106    }
107
108    /// Set the permission handler for this manager
109    pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
110        self.permission_handler = Some(handler);
111    }
112
113    /// Get the path to the tool policy configuration file
114    async fn get_config_path() -> Result<PathBuf> {
115        let paths = VtCodePaths::resolve().context("Could not resolve VT Code paths")?;
116        paths.ensure_config_dir().context("Failed to create VT Code config directory")?;
117        paths.config_path("tool-policy.json")
118    }
119
120    /// Get the path to the workspace-specific tool policy configuration file
121    async fn get_workspace_config_path(workspace_root: &Path) -> Result<PathBuf> {
122        let workspace_vtcode_dir = workspace_root.join(".vtcode");
123
124        if !tokio::fs::try_exists(&workspace_vtcode_dir).await.unwrap_or(false) {
125            ensure_dir_exists(&workspace_vtcode_dir).await.with_context(|| {
126                format!("Failed to create workspace policy directory at {}", workspace_vtcode_dir.display())
127            })?;
128        }
129
130        Ok(workspace_vtcode_dir.join("tool-policy.json"))
131    }
132
133    pub(crate) async fn load_or_create_config_with_policy(
134        config_path: &Path,
135        private_storage: bool,
136    ) -> Result<ToolPolicyConfig> {
137        let content = if private_storage {
138            match tokio::fs::symlink_metadata(config_path).await {
139                Ok(metadata) if metadata.file_type().is_symlink() => {
140                    return Err(anyhow::anyhow!(
141                        "Refusing to read symlinked tool policy config: {}",
142                        config_path.display()
143                    ));
144                }
145                Ok(metadata) if !metadata.is_file() => {
146                    return Err(anyhow::anyhow!("Tool policy config is not a regular file: {}", config_path.display()));
147                }
148                Ok(_) => Some(
149                    String::from_utf8(read_private_file_no_follow(config_path).await?)
150                        .context("Tool policy config is not valid UTF-8")?,
151                ),
152                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
153                Err(error) => {
154                    return Err(error).with_context(|| format!("Failed to inspect {}", config_path.display()));
155                }
156            }
157        } else if tokio::fs::try_exists(config_path).await.unwrap_or(false) {
158            Some(
159                read_file_with_context(config_path, "tool policy config")
160                    .await
161                    .context("Failed to read tool policy config")?,
162            )
163        } else {
164            None
165        };
166
167        if let Some(content) = content {
168            // Try to parse as alternative format first
169            if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
170                // Convert alternative format to standard format
171                return Ok(Self::convert_from_alternative(alt_config));
172            }
173
174            // Fall back to standard format with graceful recovery on parse errors
175            match serde_json::from_str(&content) {
176                Ok(mut config) => {
177                    Self::apply_auto_allow_defaults(&mut config);
178                    Self::ensure_network_constraints(&mut config);
179                    Ok(config)
180                }
181                Err(parse_err) => {
182                    tracing::warn!(
183                        "Invalid tool policy config at {} ({}). Resetting to defaults.",
184                        config_path.display(),
185                        parse_err
186                    );
187                    Self::reset_to_default(config_path, private_storage).await
188                }
189            }
190        } else {
191            // Create new config with empty tools list
192            let mut config = ToolPolicyConfig::default();
193            Self::apply_auto_allow_defaults(&mut config);
194            Self::ensure_network_constraints(&mut config);
195            Ok(config)
196        }
197    }
198
199    fn apply_auto_allow_defaults(config: &mut ToolPolicyConfig) {
200        for &tool in AUTO_ALLOW_TOOLS {
201            if !config.policies.contains_key(tool) {
202                config.policies.insert(tool.into(), ToolPolicy::Allow);
203            }
204            if !config.available_tools.iter().any(|t| t == tool) {
205                config.available_tools.push(tool.into());
206            }
207        }
208        Self::ensure_network_constraints(config);
209    }
210
211    fn ensure_network_constraints(_config: &mut ToolPolicyConfig) {
212        // Network constraints removed with curl tool removal
213    }
214
215    async fn reset_to_default(config_path: &Path, private_storage: bool) -> Result<ToolPolicyConfig> {
216        let backup_path = config_path.with_extension("json.bak");
217
218        if let Err(err) = tokio::fs::rename(config_path, &backup_path).await {
219            tracing::warn!("Unable to back up invalid tool policy config ({}). {}", config_path.display(), err);
220        } else {
221            tracing::info!("Backed up invalid tool policy config to {}", backup_path.display());
222        }
223
224        let default_config = ToolPolicyConfig::default();
225        Self::write_config_with_policy(config_path, &default_config, private_storage).await?;
226        Ok(default_config)
227    }
228
229    async fn write_config_with_policy(path: &Path, config: &ToolPolicyConfig, private_storage: bool) -> Result<()> {
230        if let Some(parent) = path.parent()
231            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
232        {
233            if private_storage {
234                VtCodePaths::ensure_user_dir(parent)
235                    .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
236            } else {
237                ensure_dir_exists(parent)
238                    .await
239                    .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
240            }
241        }
242
243        let serialized = serde_json::to_string_pretty(config).context("Failed to serialize tool policy config")?;
244
245        if private_storage {
246            write_private_file_atomic(path, serialized.as_bytes())
247                .await
248                .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
249        } else {
250            write_file_atomic_with_context(path, &serialized, "tool policy config")
251                .await
252                .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
253        }
254    }
255
256    /// Convert alternative format to standard format
257    fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
258        let mut policies = IndexMap::new();
259
260        // Convert tool policies
261        for (tool_name, alt_policy) in alt_config.tools {
262            let policy = if alt_policy.allow {
263                ToolPolicy::Allow
264            } else {
265                ToolPolicy::Deny
266            };
267            policies.insert(tool_name, policy);
268        }
269
270        let mut config = ToolPolicyConfig {
271            version: alt_config.version,
272            available_tools: policies.keys().cloned().collect(),
273            policies,
274            constraints: alt_config.constraints,
275            mcp: McpPolicyStore::default(),
276            approval_cache: ApprovalCacheConfig::default(),
277        };
278        Self::apply_auto_allow_defaults(&mut config);
279        config
280    }
281
282    fn apply_config_policy(&mut self, tool_name: &str, policy: ToolPolicy) {
283        let canonical = canonical_tool_name(tool_name);
284        self.config.policies.insert(canonical.to_owned(), policy);
285    }
286
287    pub(crate) fn resolve_config_policy(tools_config: &ToolsConfig, tool_name: &str) -> ToolPolicy {
288        let canonical = canonical_tool_name(tool_name);
289
290        if let Some(policy) = tools_config.policies.get(canonical) {
291            return policy.clone();
292        }
293
294        tools_config.default_policy.clone()
295    }
296
297    /// Apply policies defined in vtcode.toml to the runtime policy manager.
298    ///
299    /// Auto-allow defaults are NOT re-applied here — they are seeded once
300    /// during `load_or_create_config`. Re-applying them after user overrides
301    /// would silently revert explicit Deny/Prompt settings in vtcode.toml.
302    pub async fn apply_tools_config(&mut self, tools_config: &ToolsConfig) -> Result<()> {
303        if self.config.available_tools.is_empty() {
304            return Ok(());
305        }
306
307        let tools: Vec<_> = self.config.available_tools.to_vec();
308        for tool in tools {
309            let canonical = canonical_tool_name(&tool);
310            if tools_config.policies.contains_key(canonical) || !self.config.policies.contains_key(canonical) {
311                self.apply_config_policy(&tool, Self::resolve_config_policy(tools_config, &tool));
312            }
313        }
314
315        self.save_config().await
316    }
317
318    /// Update the tool list and save configuration
319    pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
320        // OPTIMIZATION: Use HashSet for deduplication, then convert to sorted Vec
321        let mut canonical_tools = Vec::with_capacity(tools.len());
322        let mut seen = HashSet::with_capacity(tools.len());
323
324        for tool in tools {
325            let canonical = canonical_tool_name(&tool).to_owned();
326            if seen.insert(canonical.clone()) {
327                canonical_tools.push(canonical);
328            }
329        }
330        canonical_tools.sort();
331
332        let current_tools: HashSet<_> = self.config.policies.keys().cloned().collect();
333        let new_tools: HashSet<_> = canonical_tools
334            .iter()
335            .filter(|name| !name.starts_with("mcp::"))
336            .cloned()
337            .collect();
338
339        let mut has_changes = false;
340
341        for tool in canonical_tools
342            .iter()
343            .filter(|tool| !tool.starts_with("mcp::") && !current_tools.contains(*tool))
344        {
345            let default_policy = if AUTO_ALLOW_TOOLS.contains(&tool.as_str()) {
346                ToolPolicy::Allow
347            } else {
348                ToolPolicy::Prompt
349            };
350            self.config.policies.insert(tool.clone(), default_policy);
351            has_changes = true;
352        }
353
354        let tools_to_remove: Vec<_> = self
355            .config
356            .policies
357            .keys()
358            .filter(|tool| !new_tools.contains(*tool))
359            .cloned()
360            .collect();
361
362        for tool in tools_to_remove {
363            self.config.policies.shift_remove(&tool);
364            has_changes = true;
365        }
366
367        // Only clone if we need to compare/sort
368        let mut sorted_available = self.config.available_tools.clone();
369        sorted_available.sort();
370        if sorted_available != canonical_tools {
371            self.config.available_tools = canonical_tools;
372            has_changes = true;
373        }
374
375        Self::ensure_network_constraints(&mut self.config);
376
377        if has_changes { self.save_config().await } else { Ok(()) }
378    }
379
380    /// Synchronize MCP provider tool lists with persisted policies
381    pub async fn update_mcp_tools(&mut self, provider_tools: &HashMap<String, Vec<String>>) -> Result<()> {
382        let stored_providers: HashSet<String> = self.config.mcp.providers.keys().cloned().collect();
383        let mut has_changes = false;
384
385        // Update or insert provider entries
386        for (provider, tools) in provider_tools {
387            let entry = self.config.mcp.providers.entry(provider.clone()).or_default();
388
389            let existing_tools: HashSet<_> = entry.tools.keys().cloned().collect();
390            let advertised: HashSet<_> = tools.iter().cloned().collect();
391
392            // Add new tools with default Prompt policy
393            for tool in tools {
394                if !existing_tools.contains(tool) {
395                    entry.tools.insert(tool.clone(), ToolPolicy::Prompt);
396                    has_changes = true;
397                }
398            }
399
400            // Remove tools no longer advertised
401            for stale in existing_tools.difference(&advertised) {
402                entry.tools.shift_remove(stale);
403                has_changes = true;
404            }
405        }
406
407        // Remove providers that are no longer present
408        let advertised_providers: HashSet<String> = provider_tools.keys().cloned().collect();
409        for provider in stored_providers.difference(&advertised_providers).cloned().collect::<Vec<_>>() {
410            self.config.mcp.providers.shift_remove(provider.as_str());
411            has_changes = true;
412        }
413
414        // Seed allowlist rules for newly advertised providers so a persisted
415        // enforce-mode allowlist cannot permanently hide a connected provider.
416        // Per-tool Prompt policies still gate execution after discovery.
417        for provider in advertised_providers {
418            let rules =
419                self.config
420                    .mcp
421                    .allowlist
422                    .providers
423                    .entry(provider.clone())
424                    .or_insert_with(|| McpAllowListRules {
425                        tools: Some(vec!["*".to_string()]),
426                        logging: Some(mcp_standard_logging()),
427                        ..Default::default()
428                    });
429            if rules.logging.is_none() {
430                rules.logging = Some(mcp_standard_logging());
431                has_changes = true;
432            }
433        }
434
435        // Remove any stale MCP keys from the primary policy map
436        let stale_runtime_keys: Vec<_> = self
437            .config
438            .policies
439            .keys()
440            .filter(|name| name.starts_with("mcp::"))
441            .cloned()
442            .collect();
443
444        for key in stale_runtime_keys {
445            self.config.policies.shift_remove(&key);
446            has_changes = true;
447        }
448
449        // Refresh available tools list with MCP entries included
450        let mut available: Vec<String> = self
451            .config
452            .available_tools
453            .iter()
454            .filter(|name| !name.starts_with("mcp::"))
455            .cloned()
456            .collect();
457
458        available.extend(
459            self.config
460                .mcp
461                .providers
462                .iter()
463                .flat_map(|(provider, policy)| policy.tools.keys().map(move |tool| format!("mcp::{provider}::{tool}"))),
464        );
465
466        available.sort();
467        available.dedup();
468
469        // Check if the available tools list has actually changed
470        if self.config.available_tools != available {
471            self.config.available_tools = available;
472            has_changes = true;
473        }
474
475        if has_changes { self.save_config().await } else { Ok(()) }
476    }
477
478    /// Retrieve policy for a specific MCP tool
479    pub fn get_mcp_tool_policy(&self, provider: &str, tool: &str) -> ToolPolicy {
480        self.config
481            .mcp
482            .providers
483            .get(provider)
484            .and_then(|policy| policy.tools.get(tool))
485            .cloned()
486            .unwrap_or(ToolPolicy::Prompt)
487    }
488
489    /// Update policy for a specific MCP tool
490    pub async fn set_mcp_tool_policy(&mut self, provider: &str, tool: &str, policy: ToolPolicy) -> Result<()> {
491        // OPTIMIZATION: Use into() for cleaner conversion
492        let entry = self.config.mcp.providers.entry(provider.into()).or_default();
493        entry.tools.insert(tool.into(), policy);
494        self.save_config().await
495    }
496
497    /// Access the persisted MCP allow list configuration
498    pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
499        &self.config.mcp.allowlist
500    }
501
502    /// Replace the persisted MCP allow list configuration
503    pub async fn set_mcp_allowlist(&mut self, allowlist: McpAllowListConfig) -> Result<()> {
504        self.config.mcp.allowlist = allowlist;
505        self.save_config().await
506    }
507
508    /// Get policy for a specific tool
509    pub fn get_policy(&self, tool_name: &str) -> ToolPolicy {
510        let canonical = canonical_tool_name(tool_name);
511        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
512            return self.get_mcp_tool_policy(&provider, &tool);
513        }
514
515        self.config.policies.get(canonical).cloned().unwrap_or(ToolPolicy::Prompt)
516    }
517
518    /// Get optional constraints for a specific tool
519    pub fn get_constraints(&self, tool_name: &str) -> Option<&ToolConstraints> {
520        let canonical = canonical_tool_name(tool_name);
521        self.config.constraints.get(canonical)
522    }
523
524    /// Check if tool should be executed based on policy
525    pub async fn should_execute_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
526        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
527            return match self.get_mcp_tool_policy(&provider, &tool) {
528                ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
529                ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
530                ToolPolicy::Prompt => {
531                    if ToolPolicyManager::is_auto_allow_tool(tool_name) {
532                        self.set_mcp_tool_policy(&provider, &tool, ToolPolicy::Allow).await?;
533                        Ok(ToolExecutionDecision::Allowed)
534                    } else {
535                        // Use permission handler if available
536                        if let Some(ref mut handler) = self.permission_handler {
537                            handler.prompt_tool_permission(tool_name)
538                        } else {
539                            tracing::warn!(
540                                "Prompt policy for tool '{tool_name}' denied: no permission handler configured"
541                            );
542                            Ok(ToolExecutionDecision::Denied)
543                        }
544                    }
545                }
546            };
547        }
548
549        let canonical = canonical_tool_name(tool_name);
550
551        match self.get_policy(canonical) {
552            ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
553            ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
554            ToolPolicy::Prompt => {
555                let canonical_name = canonical;
556                if AUTO_ALLOW_TOOLS.contains(&canonical_name) {
557                    self.set_policy(canonical_name, ToolPolicy::Allow).await?;
558                    return Ok(ToolExecutionDecision::Allowed);
559                }
560                // Use permission handler if available
561                if let Some(ref mut handler) = self.permission_handler {
562                    handler.prompt_tool_permission(tool_name)
563                } else {
564                    tracing::warn!("Prompt policy for tool '{tool_name}' denied: no permission handler configured");
565                    Ok(ToolExecutionDecision::Denied)
566                }
567            }
568        }
569    }
570
571    pub fn is_auto_allow_tool(tool_name: &str) -> bool {
572        let canonical = canonical_tool_name(tool_name);
573        AUTO_ALLOW_TOOLS.contains(&canonical)
574    }
575
576    /// Prompt user for tool execution permission using the configured handler.
577    ///
578    /// This function delegates to the PermissionPromptHandler if one is configured.
579    /// In TUI mode, the handler should be set to use TUI-based prompts via the
580    /// permission handler mechanism.
581    pub fn prompt_user_for_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
582        if let Some(ref mut handler) = self.permission_handler {
583            handler.prompt_tool_permission(tool_name)
584        } else {
585            // Default behavior if no handler is configured: allow through
586            Ok(ToolExecutionDecision::Allowed)
587        }
588    }
589
590    /// Set policy for a specific tool
591    pub async fn set_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
592        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
593            return self.set_mcp_tool_policy(&provider, &tool, policy).await;
594        }
595
596        let canonical = canonical_tool_name(tool_name).to_owned();
597        self.config.policies.insert(canonical.clone(), policy.clone());
598        self.save_config().await?;
599        self.persist_policy_to_workspace_config(&canonical, policy)
600    }
601
602    pub(crate) async fn seed_default_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
603        let canonical = canonical_tool_name(tool_name).to_owned();
604        self.config.policies.insert(canonical, policy);
605        self.save_config().await
606    }
607
608    /// Reset all tools to prompt
609    pub async fn reset_all_to_prompt(&mut self) -> Result<()> {
610        for policy in self.config.policies.values_mut() {
611            *policy = ToolPolicy::Prompt;
612        }
613        for provider in self.config.mcp.providers.values_mut() {
614            for policy in provider.tools.values_mut() {
615                *policy = ToolPolicy::Prompt;
616            }
617        }
618        self.config.approval_cache.allowed.clear();
619        self.config.approval_cache.prefixes.clear();
620        self.config.approval_cache.regexes.clear();
621        self.save_config().await
622    }
623
624    /// Allow all tools
625    pub async fn allow_all_tools(&mut self) -> Result<()> {
626        self.allow_all_tools_for_tools(&[]).await
627    }
628
629    pub async fn allow_all_tools_for_tools(&mut self, all_tools: &[String]) -> Result<()> {
630        for policy in self.config.policies.values_mut() {
631            *policy = ToolPolicy::Allow;
632        }
633        for provider in self.config.mcp.providers.values_mut() {
634            for policy in provider.tools.values_mut() {
635                *policy = ToolPolicy::Allow;
636            }
637        }
638        for tool in all_tools {
639            let canonical = canonical_tool_name(tool);
640            if !self.config.policies.contains_key(canonical) {
641                self.config.policies.insert(canonical.to_owned(), ToolPolicy::Allow);
642            }
643        }
644        self.save_config().await
645    }
646
647    /// Deny all tools
648    pub async fn deny_all_tools(&mut self) -> Result<()> {
649        for policy in self.config.policies.values_mut() {
650            *policy = ToolPolicy::Deny;
651        }
652        for provider in self.config.mcp.providers.values_mut() {
653            for policy in provider.tools.values_mut() {
654                *policy = ToolPolicy::Deny;
655            }
656        }
657        self.config.approval_cache.allowed.clear();
658        self.config.approval_cache.prefixes.clear();
659        self.config.approval_cache.regexes.clear();
660        self.save_config().await
661    }
662
663    /// Get summary of current policies
664    pub fn get_policy_summary(&self) -> IndexMap<String, ToolPolicy> {
665        let mut summary = self.config.policies.clone();
666        for (provider, policy) in &self.config.mcp.providers {
667            for (tool, status) in &policy.tools {
668                summary.insert(format!("mcp::{provider}::{tool}"), status.clone());
669            }
670        }
671        summary
672    }
673
674    /// Check whether an explicit approval key is remembered for this workspace.
675    ///
676    /// Performs three levels of matching:
677    /// 1. Exact match against persisted allowed keys
678    /// 2. Word-prefix match: checks if the approval key starts with any persisted prefix
679    /// 3. Regex match against persisted regex patterns
680    pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
681        // Exact match: simplest and fastest
682        if self.config.approval_cache.allowed.contains(approval_key) {
683            return true;
684        }
685
686        // Word-prefix match: check if any cached key is a word-prefix of the approval_key
687        // e.g., "cargo check" matches "cargo check --target x86_64"
688        for cached in &self.config.approval_cache.allowed {
689            if cached.len() < approval_key.len()
690                && approval_key.starts_with(cached.as_str())
691                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
692            {
693                return true;
694            }
695        }
696
697        // Word-prefix match against cached prefixes. Prefer shell tokenization
698        // so persisted command approvals preserve argument/option boundaries
699        // instead of raw whitespace quirks from display/rendering.
700        let (approval_text, approval_scope) = split_shell_approval_entry(approval_key);
701        let approval_words = shell_words_lossy(approval_text);
702        for cached in &self.config.approval_cache.prefixes {
703            let (prefix_text, prefix_scope) = split_shell_approval_entry(cached.as_str());
704            let prefix_words = shell_words_lossy(prefix_text);
705            let scope_matches = prefix_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE)
706                == approval_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
707            if !prefix_words.is_empty()
708                && scope_matches
709                && prefix_words.len() <= approval_words.len()
710                && prefix_words.iter().zip(approval_words.iter()).all(|(a, b)| a == b)
711            {
712                return true;
713            }
714        }
715
716        // Regex match against persisted regex patterns. Compiled regexes are
717        // memoized per unique pattern list so we don't recompile on every check.
718        let compiled = {
719            let mut cache = APPROVAL_REGEX_CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
720            match &*cache {
721                Some((patterns, regexes)) if *patterns == self.config.approval_cache.regexes => regexes.clone(),
722                _ => {
723                    let regexes: Vec<Regex> = self
724                        .config
725                        .approval_cache
726                        .regexes
727                        .iter()
728                        .filter_map(|pattern| Regex::new(pattern).ok())
729                        .collect();
730                    *cache = Some((self.config.approval_cache.regexes.clone(), regexes.clone()));
731                    regexes
732                }
733            }
734        };
735        compiled.iter().any(|regex| regex.is_match(approval_key))
736    }
737
738    /// Find the best matching cache key for a given approval key using fuzzy prefix matching.
739    /// Returns the longest matching prefix key if one exists.
740    pub fn find_matching_cache_prefix(&self, approval_key: &str) -> Option<String> {
741        let mut best_match: Option<String> = None;
742        let mut best_len = 0usize;
743
744        for cached in &self.config.approval_cache.allowed {
745            if cached.len() < approval_key.len()
746                && approval_key.starts_with(cached.as_str())
747                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
748                && cached.len() > best_len
749            {
750                best_len = cached.len();
751                best_match = Some(cached.clone());
752            }
753        }
754
755        best_match
756    }
757
758    /// Persist an explicit approval key for future prompts in this workspace.
759    pub async fn add_approval_cache_key(&mut self, approval_key: impl Into<String>) -> Result<()> {
760        if self.config.approval_cache.allowed.insert(approval_key.into()) {
761            self.config.approval_cache.trim_to_budget();
762            self.save_config().await?;
763        }
764        Ok(())
765    }
766
767    /// Persist an approval key and automatically derive shorter segment-prefix keys
768    /// so that future similar commands also match without re-prompting.
769    ///
770    /// For example, approving "cargo check --target x86_64" also caches
771    /// "cargo check" as a segment prefix, so "cargo check --release" also matches.
772    pub async fn add_approval_cache_key_with_segments(&mut self, approval_key: impl Into<String>) -> Result<()> {
773        let key: String = approval_key.into();
774        let mut changed = false;
775
776        // Add the exact key
777        if self.config.approval_cache.allowed.insert(key.clone()) {
778            changed = true;
779        }
780
781        for prefix in derived_shell_approval_prefixes(&key) {
782            if self.config.approval_cache.prefixes.insert(prefix) {
783                changed = true;
784            }
785        }
786
787        if changed {
788            self.config.approval_cache.trim_to_budget();
789            self.save_config().await?;
790        }
791        Ok(())
792    }
793
794    /// Persist a shell prefix approval entry for future prompts in this workspace.
795    pub async fn add_approval_cache_prefix(&mut self, prefix_entry: impl Into<String>) -> Result<()> {
796        if self.config.approval_cache.prefixes.insert(prefix_entry.into()) {
797            self.config.approval_cache.trim_to_budget();
798            self.save_config().await?;
799        }
800        Ok(())
801    }
802
803    /// Check whether a persisted shell prefix approval matches the command words and scope.
804    pub fn matching_shell_approval_prefix(&self, command_words: &[String], scope_signature: &str) -> Option<String> {
805        self.config.approval_cache.prefixes.iter().find_map(|entry| {
806            let (prefix_text, entry_scope_signature) = split_shell_approval_entry(entry.as_str());
807            let prefix_words = shell_words::split(prefix_text).ok()?;
808            let entry_scope_signature = entry_scope_signature.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
809            (entry_scope_signature == scope_signature && shell_command_words_match_prefix(command_words, &prefix_words))
810                .then(|| entry.clone())
811        })
812    }
813
814    /// Remove all persisted approval cache entries.
815    pub async fn clear_approval_cache(&mut self) -> Result<()> {
816        if !self.config.approval_cache.allowed.is_empty()
817            || !self.config.approval_cache.prefixes.is_empty()
818            || !self.config.approval_cache.regexes.is_empty()
819        {
820            self.config.approval_cache.allowed.clear();
821            self.config.approval_cache.prefixes.clear();
822            self.config.approval_cache.regexes.clear();
823            self.save_config().await?;
824        }
825        Ok(())
826    }
827
828    /// Save configuration to file
829    pub(crate) fn save_config(&self) -> impl Future<Output = Result<()>> + '_ {
830        Self::write_config_with_policy(&self.config_path, &self.config, self.private_storage)
831    }
832
833    pub(crate) fn persist_policy_to_workspace_config(&self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
834        let Some(workspace_root) = self.workspace_root.as_ref() else {
835            return Ok(());
836        };
837
838        let config_path = workspace_root.join("vtcode.toml");
839        let mut config = if config_path.exists() {
840            ConfigManager::load_from_file(&config_path)
841                .with_context(|| {
842                    format!("Failed to load config for tool policy persistence at {}", config_path.display())
843                })?
844                .config()
845                .clone()
846        } else {
847            VTCodeConfig::default()
848        };
849
850        config.tools.policies.insert(tool_name.to_string(), policy);
851
852        // Policy changes are repository-controlled writes. Preserve the
853        // trusted provider settings in their user layer instead of flattening
854        // them into the workspace config while entering or leaving plan mode.
855        ConfigManager::save_repository_config_to_path(&config_path, &config)
856            .with_context(|| format!("Failed to persist tool policy to {}", config_path.display()))
857    }
858
859    /// Print current policy status
860    pub fn print_status(&self) {
861        println!("{}", style("Tool Policy Status").cyan().bold());
862        println!("Config file: {}", self.config_path.display());
863        println!();
864
865        let summary = self.get_policy_summary();
866
867        if summary.is_empty() {
868            println!("No tools configured yet.");
869            return;
870        }
871
872        let mut allow_count = 0;
873        let mut prompt_count = 0;
874        let mut deny_count = 0;
875
876        for (tool, policy) in &summary {
877            let (status, color_name) = match policy {
878                ToolPolicy::Allow => {
879                    allow_count += 1;
880                    ("ALLOW", "green")
881                }
882                ToolPolicy::Prompt => {
883                    prompt_count += 1;
884                    ("PROMPT", "cyan")
885                }
886                ToolPolicy::Deny => {
887                    deny_count += 1;
888                    ("DENY", "red")
889                }
890            };
891
892            let status_styled = match color_name {
893                "green" => style(status).green(),
894                "cyan" => style(status).cyan(),
895                "red" => style(status).red(),
896                _ => style(status),
897            };
898
899            println!("  {} {}", style(format!("{tool:15}")).cyan(), status_styled);
900        }
901
902        println!();
903        println!(
904            "Summary: {} allowed, {} prompt, {} denied",
905            style(allow_count).green(),
906            style(prompt_count).cyan(),
907            style(deny_count).red()
908        );
909    }
910
911    /// Expose path of the underlying policy configuration file
912    pub fn config_path(&self) -> &Path {
913        &self.config_path
914    }
915}