Skip to main content

vtcode_core/
tool_policy.rs

1//! Tool policy management system
2//!
3//! This module manages user preferences for tool usage, storing choices in
4//! ~/.vtcode/tool-policy.json to minimize repeated prompts while maintaining
5//! user control overwhich tools the agent can use.
6
7use crate::utils::error_messages::ERR_CREATE_POLICY_DIR;
8use anyhow::{Context, Result};
9use dialoguer::console::style;
10use hashbrown::{HashMap, HashSet};
11use indexmap::{IndexMap, IndexSet};
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::future::Future;
16use std::path::{Path, PathBuf};
17
18use crate::config::constants::tools;
19use crate::config::core::tools::{ToolPolicy as ConfigToolPolicy, ToolsConfig};
20use crate::config::loader::{ConfigManager, VTCodeConfig};
21use crate::config::mcp::{McpAllowListConfig, McpAllowListRules};
22use crate::tools::mcp::parse_canonical_mcp_tool_name;
23use crate::tools::names::canonical_tool_name;
24use crate::utils::file_utils::{
25    ensure_dir_exists, read_file_with_context, write_file_with_context,
26};
27
28const AUTO_ALLOW_TOOLS: &[&str] = &[
29    tools::UNIFIED_SEARCH,
30    tools::READ_FILE,
31    // Unified exec remains prompt-gated; legacy PTY helpers stay compatibility-only
32    // and are no longer auto-seeded into default policy files.
33    "cargo_check",
34    "cargo_test",
35    "git_status",
36    "git_diff",
37    "git_log",
38];
39
40const SHELL_APPROVAL_SCOPE_MARKER: &str = "|sandbox_permissions=";
41const DEFAULT_APPROVAL_SCOPE_SIGNATURE: &str =
42    "sandbox_permissions=\"use_default\"|additional_permissions=null";
43
44/// Tool execution policy
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum ToolPolicy {
48    /// Allow tool execution without prompting
49    Allow,
50    /// Prompt user for confirmation each time
51    #[default]
52    Prompt,
53    /// Never allow tool execution
54    Deny,
55}
56
57/// Decision result for tool execution
58#[derive(Debug, Clone, PartialEq)]
59pub enum ToolExecutionDecision {
60    Allowed,
61    Denied,
62    DeniedWithFeedback(String),
63}
64
65impl ToolExecutionDecision {
66    pub fn is_allowed(&self) -> bool {
67        matches!(self, Self::Allowed)
68    }
69}
70
71/// Tool policy configuration stored in ~/.vtcode/tool-policy.json
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolPolicyConfig {
74    /// Configuration version for future compatibility
75    pub version: u32,
76    /// Available tools at time of last update
77    pub available_tools: Vec<String>,
78    /// Policy for each tool
79    pub policies: IndexMap<String, ToolPolicy>,
80    /// Optional per-tool constraints to scope permissions and enforce safety
81    #[serde(default)]
82    pub constraints: IndexMap<String, ToolConstraints>,
83    /// MCP-specific policy configuration
84    #[serde(default)]
85    pub mcp: McpPolicyStore,
86    /// Explicit remembered approvals for future prompts in this workspace
87    #[serde(default)]
88    pub approval_cache: ApprovalCacheConfig,
89}
90
91impl Default for ToolPolicyConfig {
92    fn default() -> Self {
93        Self {
94            version: 1,
95            available_tools: Vec::new(),
96            policies: IndexMap::new(),
97            constraints: IndexMap::new(),
98            mcp: McpPolicyStore::default(),
99            approval_cache: ApprovalCacheConfig::default(),
100        }
101    }
102}
103
104/// Persisted approval cache stored alongside tool policies
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ApprovalCacheConfig {
107    /// Stable approval keys that should bypass future prompts
108    #[serde(default)]
109    pub allowed: IndexSet<String>,
110    /// Shell command prefixes that should bypass future prompts in the same scope
111    #[serde(default)]
112    pub prefixes: IndexSet<String>,
113    /// Regex patterns matched against approval keys for advanced manual policy tuning
114    #[serde(default)]
115    pub regexes: IndexSet<String>,
116}
117
118/// Stored MCP policy state, persisted alongside standard tool policies
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpPolicyStore {
121    /// Active MCP allow list configuration
122    #[serde(default = "default_secure_mcp_allowlist")]
123    pub allowlist: McpAllowListConfig,
124    /// Provider-specific tool policies (allow/prompt/deny)
125    #[serde(default)]
126    pub providers: IndexMap<String, McpProviderPolicy>,
127}
128
129impl Default for McpPolicyStore {
130    fn default() -> Self {
131        Self {
132            allowlist: default_secure_mcp_allowlist(),
133            providers: IndexMap::new(),
134        }
135    }
136}
137
138/// MCP provider policy entry containing per-tool permissions
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct McpProviderPolicy {
141    #[serde(default)]
142    pub tools: IndexMap<String, ToolPolicy>,
143}
144
145// Helper constants to reduce allocations in MCP allowlist configuration
146const MCP_LOGGING_EVENTS: &[&str] = &[
147    "mcp.tool_execution",
148    "mcp.tool_failed",
149    "mcp.tool_denied",
150    "mcp.tool_filtered",
151    "mcp.provider_initialized",
152];
153
154const MCP_DEFAULT_LOGGING_EVENTS: &[&str] = &[
155    "mcp.provider_initialized",
156    "mcp.provider_initialization_failed",
157    "mcp.tool_filtered",
158    "mcp.tool_execution",
159    "mcp.tool_failed",
160    "mcp.tool_denied",
161];
162
163/// Helper to create standard MCP logging configuration
164#[inline]
165fn mcp_standard_logging() -> Vec<String> {
166    MCP_LOGGING_EVENTS.iter().map(|s| (*s).into()).collect()
167}
168
169/// Helper to create provider configuration with max_concurrent_requests
170#[inline]
171fn mcp_provider_config_with(extra: (&str, Vec<&str>)) -> BTreeMap<String, Vec<String>> {
172    BTreeMap::from([
173        ("provider".into(), vec!["max_concurrent_requests".into()]),
174        (
175            extra.0.into(),
176            extra.1.into_iter().map(Into::into).collect(),
177        ),
178    ])
179}
180
181fn default_secure_mcp_allowlist() -> McpAllowListConfig {
182    let default_logging = Some(
183        MCP_DEFAULT_LOGGING_EVENTS
184            .iter()
185            .map(|s| (*s).into())
186            .collect(),
187    );
188
189    let default_configuration = Some(BTreeMap::from([
190        (
191            "client".into(),
192            vec![
193                "max_concurrent_connections".into(),
194                "request_timeout_seconds".into(),
195                "retry_attempts".into(),
196                "startup_timeout_seconds".into(),
197                "tool_timeout_seconds".into(),
198                "experimental_use_rmcp_client".into(),
199            ],
200        ),
201        (
202            "ui".into(),
203            vec![
204                "mode".into(),
205                "max_events".into(),
206                "show_provider_names".into(),
207            ],
208        ),
209        (
210            "server".into(),
211            vec![
212                "enabled".into(),
213                "bind_address".into(),
214                "port".into(),
215                "transport".into(),
216                "name".into(),
217                "version".into(),
218            ],
219        ),
220    ]));
221
222    let time_rules = McpAllowListRules {
223        tools: Some(vec![
224            "get_*".into(),
225            "list_*".into(),
226            "convert_timezone".into(),
227            "describe_timezone".into(),
228            "time_*".into(),
229        ]),
230        resources: Some(vec!["timezone:*".into(), "location:*".into()]),
231        logging: Some(mcp_standard_logging()),
232        configuration: Some(mcp_provider_config_with((
233            "time",
234            vec!["local_timezone_override"],
235        ))),
236        ..Default::default()
237    };
238
239    let context_rules = McpAllowListRules {
240        tools: Some(vec![
241            "search_*".into(),
242            "fetch_*".into(),
243            "list_*".into(),
244            "context7_*".into(),
245            "get_*".into(),
246        ]),
247        resources: Some(vec![
248            "docs::*".into(),
249            "snippets::*".into(),
250            "repositories::*".into(),
251            "context7::*".into(),
252        ]),
253        prompts: Some(vec![
254            "context7::*".into(),
255            "support::*".into(),
256            "docs::*".into(),
257        ]),
258        logging: Some(mcp_standard_logging()),
259        configuration: Some(mcp_provider_config_with((
260            "context7",
261            vec!["workspace", "search_scope", "max_results"],
262        ))),
263    };
264
265    let seq_rules = McpAllowListRules {
266        tools: Some(vec![
267            "plan".into(),
268            "critique".into(),
269            "reflect".into(),
270            "decompose".into(),
271            "sequential_*".into(),
272        ]),
273        resources: None,
274        prompts: Some(vec![
275            "sequential-thinking::*".into(),
276            "plan".into(),
277            "reflect".into(),
278            "critique".into(),
279        ]),
280        logging: Some(mcp_standard_logging()),
281        configuration: Some(mcp_provider_config_with((
282            "sequencing",
283            vec!["max_depth", "max_branches"],
284        ))),
285    };
286
287    let mut allowlist = McpAllowListConfig {
288        enforce: true,
289        default: McpAllowListRules {
290            logging: default_logging,
291            configuration: default_configuration,
292            ..Default::default()
293        },
294        ..Default::default()
295    };
296
297    allowlist.providers.insert("time".into(), time_rules);
298    allowlist.providers.insert("context7".into(), context_rules);
299    allowlist
300        .providers
301        .insert("sequential-thinking".into(), seq_rules);
302
303    allowlist
304}
305
306fn parse_mcp_policy_key(tool_name: &str) -> Option<(String, String)> {
307    parse_canonical_mcp_tool_name(tool_name)
308        .map(|(provider, tool)| (provider.to_string(), tool.to_string()))
309}
310
311/// Alternative tool policy configuration format (user's format)
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct AlternativeToolPolicyConfig {
314    /// Configuration version for future compatibility
315    pub version: u32,
316    /// Default policy settings
317    pub default: AlternativeDefaultPolicy,
318    /// Tool-specific policies
319    pub tools: IndexMap<String, AlternativeToolPolicy>,
320    /// Optional per-tool constraints (ignored if absent)
321    #[serde(default)]
322    pub constraints: IndexMap<String, ToolConstraints>,
323}
324
325/// Default policy in alternative format
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct AlternativeDefaultPolicy {
328    /// Whether to allow by default
329    pub allow: bool,
330    /// Rate limit per run
331    pub rate_limit_per_run: u32,
332    /// Max concurrent executions
333    pub max_concurrent: u32,
334    /// Allow filesystem writes
335    pub fs_write: bool,
336    /// Allow network access
337    pub network: bool,
338}
339
340/// Tool policy in alternative format
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct AlternativeToolPolicy {
343    /// Whether to allow this tool
344    pub allow: bool,
345    /// Allow filesystem writes (optional)
346    #[serde(default)]
347    pub fs_write: bool,
348    /// Allow network access (optional)
349    #[serde(default)]
350    pub network: bool,
351    /// Arguments policy (optional)
352    #[serde(default)]
353    pub args_policy: Option<AlternativeArgsPolicy>,
354}
355
356/// Arguments policy in alternative format
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct AlternativeArgsPolicy {
359    /// Substrings to deny
360    pub deny_substrings: Vec<String>,
361}
362
363/// Handler for tool permission prompts
364///
365/// This trait allows different UI modes (CLI, TUI) to provide their own
366/// implementation for prompting users about tool execution.
367pub trait PermissionPromptHandler: Send + Sync {
368    /// Prompt the user for tool execution permission
369    fn prompt_tool_permission(&mut self, tool_name: &str) -> Result<ToolExecutionDecision>;
370}
371
372/// Tool policy manager
373pub struct ToolPolicyManager {
374    config_path: PathBuf,
375    config: ToolPolicyConfig,
376    permission_handler: Option<Box<dyn PermissionPromptHandler>>,
377    workspace_root: Option<PathBuf>,
378}
379
380impl Clone for ToolPolicyManager {
381    fn clone(&self) -> Self {
382        // Note: Permission handler is not cloned - this is intentional as handlers
383        // typically contain UI state that shouldn't be duplicated
384        Self {
385            config_path: self.config_path.clone(),
386            config: self.config.clone(),
387            permission_handler: None, // Handler is not cloned
388            workspace_root: self.workspace_root.clone(),
389        }
390    }
391}
392
393impl ToolPolicyManager {
394    /// Create a new tool policy manager
395    pub async fn new() -> Result<Self> {
396        let config_path = Self::get_config_path().await?;
397        let config = Self::load_or_create_config(&config_path).await?;
398
399        Ok(Self {
400            config_path,
401            config,
402            permission_handler: None,
403            workspace_root: None,
404        })
405    }
406
407    /// Create a new tool policy manager with workspace-specific config
408    pub async fn new_with_workspace(workspace_root: &Path) -> Result<Self> {
409        let config_path = Self::get_workspace_config_path(workspace_root).await?;
410        let config = Self::load_or_create_config(&config_path).await?;
411
412        Ok(Self {
413            config_path,
414            config,
415            permission_handler: None,
416            workspace_root: Some(workspace_root.to_path_buf()),
417        })
418    }
419
420    /// Create a new tool policy manager backed by a custom configuration path.
421    ///
422    /// This helper allows downstream consumers to store policy data alongside
423    /// their own configuration hierarchy instead of writing to the default
424    /// `.vtcode` directory.
425    pub async fn new_with_config_path<P: Into<PathBuf>>(config_path: P) -> Result<Self> {
426        let config_path = config_path.into();
427
428        if let Some(parent) = config_path.parent()
429            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
430        {
431            ensure_dir_exists(parent)
432                .await
433                .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
434        }
435
436        let config = Self::load_or_create_config(&config_path).await?;
437
438        Ok(Self {
439            config_path,
440            config,
441            permission_handler: None,
442            workspace_root: None,
443        })
444    }
445
446    /// Set the permission handler for this manager
447    pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
448        self.permission_handler = Some(handler);
449    }
450
451    /// Get the path to the tool policy configuration file
452    async fn get_config_path() -> Result<PathBuf> {
453        let home_dir = dirs::home_dir().context("Could not determine home directory")?;
454
455        let vtcode_dir = home_dir.join(".vtcode");
456        if !tokio::fs::try_exists(&vtcode_dir).await.unwrap_or(false) {
457            ensure_dir_exists(&vtcode_dir)
458                .await
459                .context("Failed to create ~/.vtcode directory")?;
460        }
461
462        Ok(vtcode_dir.join("tool-policy.json"))
463    }
464
465    /// Get the path to the workspace-specific tool policy configuration file
466    async fn get_workspace_config_path(workspace_root: &Path) -> Result<PathBuf> {
467        let workspace_vtcode_dir = workspace_root.join(".vtcode");
468
469        if !tokio::fs::try_exists(&workspace_vtcode_dir)
470            .await
471            .unwrap_or(false)
472        {
473            ensure_dir_exists(&workspace_vtcode_dir)
474                .await
475                .with_context(|| {
476                    format!(
477                        "Failed to create workspace policy directory at {}",
478                        workspace_vtcode_dir.display()
479                    )
480                })?;
481        }
482
483        Ok(workspace_vtcode_dir.join("tool-policy.json"))
484    }
485
486    /// Load existing config or create new one with all tools as "prompt"
487    async fn load_or_create_config(config_path: &PathBuf) -> Result<ToolPolicyConfig> {
488        if tokio::fs::try_exists(config_path).await.unwrap_or(false) {
489            let content = read_file_with_context(config_path, "tool policy config")
490                .await
491                .context("Failed to read tool policy config")?;
492
493            // Try to parse as alternative format first
494            if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
495                // Convert alternative format to standard format
496                return Ok(Self::convert_from_alternative(alt_config));
497            }
498
499            // Fall back to standard format with graceful recovery on parse errors
500            match serde_json::from_str(&content) {
501                Ok(mut config) => {
502                    Self::apply_auto_allow_defaults(&mut config);
503                    Self::ensure_network_constraints(&mut config);
504                    Ok(config)
505                }
506                Err(parse_err) => {
507                    tracing::warn!(
508                        "Invalid tool policy config at {} ({}). Resetting to defaults.",
509                        config_path.display(),
510                        parse_err
511                    );
512                    Self::reset_to_default(config_path).await
513                }
514            }
515        } else {
516            // Create new config with empty tools list
517            let mut config = ToolPolicyConfig::default();
518            Self::apply_auto_allow_defaults(&mut config);
519            Self::ensure_network_constraints(&mut config);
520            Ok(config)
521        }
522    }
523
524    fn apply_auto_allow_defaults(config: &mut ToolPolicyConfig) {
525        // OPTIMIZATION: Avoid unnecessary allocations in loop
526        for &tool in AUTO_ALLOW_TOOLS {
527            config
528                .policies
529                .entry(tool.into())
530                .and_modify(|policy| *policy = ToolPolicy::Allow)
531                .or_insert(ToolPolicy::Allow);
532            if !config.available_tools.iter().any(|t| t == tool) {
533                config.available_tools.push(tool.into());
534            }
535        }
536        Self::ensure_network_constraints(config);
537    }
538
539    fn ensure_network_constraints(_config: &mut ToolPolicyConfig) {
540        // Network constraints removed with curl tool removal
541    }
542
543    async fn reset_to_default(config_path: &PathBuf) -> Result<ToolPolicyConfig> {
544        let backup_path = config_path.with_extension("json.bak");
545
546        if let Err(err) = tokio::fs::rename(config_path, &backup_path).await {
547            tracing::warn!(
548                "Unable to back up invalid tool policy config ({}). {}",
549                config_path.display(),
550                err
551            );
552        } else {
553            tracing::info!(
554                "Backed up invalid tool policy config to {}",
555                backup_path.display()
556            );
557        }
558
559        let default_config = ToolPolicyConfig::default();
560        Self::write_config(config_path.as_path(), &default_config).await?;
561        Ok(default_config)
562    }
563
564    async fn write_config(path: &Path, config: &ToolPolicyConfig) -> Result<()> {
565        if let Some(parent) = path.parent()
566            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
567        {
568            ensure_dir_exists(parent)
569                .await
570                .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
571        }
572
573        let serialized = serde_json::to_string_pretty(config)
574            .context("Failed to serialize tool policy config")?;
575
576        write_file_with_context(path, &serialized, "tool policy config")
577            .await
578            .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
579    }
580
581    /// Convert alternative format to standard format
582    fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
583        let mut policies = IndexMap::new();
584
585        // Convert tool policies
586        for (tool_name, alt_policy) in alt_config.tools {
587            let policy = if alt_policy.allow {
588                ToolPolicy::Allow
589            } else {
590                ToolPolicy::Deny
591            };
592            policies.insert(tool_name, policy);
593        }
594
595        let mut config = ToolPolicyConfig {
596            version: alt_config.version,
597            available_tools: policies.keys().cloned().collect(),
598            policies,
599            constraints: alt_config.constraints,
600            mcp: McpPolicyStore::default(),
601            approval_cache: ApprovalCacheConfig::default(),
602        };
603        Self::apply_auto_allow_defaults(&mut config);
604        config
605    }
606
607    fn apply_config_policy(&mut self, tool_name: &str, policy: ConfigToolPolicy) {
608        let canonical = canonical_tool_name(tool_name);
609        let runtime_policy = match policy {
610            ConfigToolPolicy::Allow => ToolPolicy::Allow,
611            ConfigToolPolicy::Prompt => ToolPolicy::Prompt,
612            ConfigToolPolicy::Deny => ToolPolicy::Deny,
613        };
614
615        self.config
616            .policies
617            .insert(canonical.to_owned(), runtime_policy);
618    }
619
620    fn resolve_config_policy(tools_config: &ToolsConfig, tool_name: &str) -> ConfigToolPolicy {
621        let canonical = canonical_tool_name(tool_name);
622
623        if let Some(policy) = tools_config.policies.get(canonical) {
624            return *policy;
625        }
626
627        match tool_name {
628            tools::UNIFIED_SEARCH => tools_config
629                .policies
630                .get("list_dir")
631                .or_else(|| tools_config.policies.get("list_directory"))
632                .cloned(),
633            _ => None,
634        }
635        .unwrap_or(tools_config.default_policy)
636    }
637
638    /// Apply policies defined in vtcode.toml to the runtime policy manager
639    pub async fn apply_tools_config(&mut self, tools_config: &ToolsConfig) -> Result<()> {
640        if self.config.available_tools.is_empty() {
641            return Ok(());
642        }
643
644        // Clone once to avoid borrow issues with self.apply_config_policy
645        let tools: Vec<_> = self.config.available_tools.to_vec();
646        for tool in tools {
647            let config_policy = Self::resolve_config_policy(tools_config, &tool);
648            self.apply_config_policy(&tool, config_policy);
649        }
650
651        Self::apply_auto_allow_defaults(&mut self.config);
652        self.save_config().await
653    }
654
655    /// Update the tool list and save configuration
656    pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
657        // OPTIMIZATION: Use HashSet for deduplication, then convert to sorted Vec
658        let mut canonical_tools = Vec::with_capacity(tools.len());
659        let mut seen = HashSet::with_capacity(tools.len());
660
661        for tool in tools {
662            let canonical = canonical_tool_name(&tool).to_owned();
663            if seen.insert(canonical.clone()) {
664                canonical_tools.push(canonical);
665            }
666        }
667        canonical_tools.sort();
668
669        let current_tools: HashSet<_> = self.config.policies.keys().cloned().collect();
670        let new_tools: HashSet<_> = canonical_tools
671            .iter()
672            .filter(|name| !name.starts_with("mcp::"))
673            .cloned()
674            .collect();
675
676        let mut has_changes = false;
677
678        for tool in canonical_tools
679            .iter()
680            .filter(|tool| !tool.starts_with("mcp::") && !current_tools.contains(*tool))
681        {
682            let default_policy = if AUTO_ALLOW_TOOLS.contains(&tool.as_str()) {
683                ToolPolicy::Allow
684            } else {
685                ToolPolicy::Prompt
686            };
687            self.config.policies.insert(tool.clone(), default_policy);
688            has_changes = true;
689        }
690
691        let tools_to_remove: Vec<_> = self
692            .config
693            .policies
694            .keys()
695            .filter(|tool| !new_tools.contains(*tool))
696            .cloned()
697            .collect();
698
699        for tool in tools_to_remove {
700            self.config.policies.shift_remove(&tool);
701            has_changes = true;
702        }
703
704        // Only clone if we need to compare/sort
705        let mut sorted_available = self.config.available_tools.clone();
706        sorted_available.sort();
707        if sorted_available != canonical_tools {
708            self.config.available_tools = canonical_tools;
709            has_changes = true;
710        }
711
712        Self::ensure_network_constraints(&mut self.config);
713
714        if has_changes {
715            self.save_config().await
716        } else {
717            Ok(())
718        }
719    }
720
721    /// Synchronize MCP provider tool lists with persisted policies
722    pub async fn update_mcp_tools(
723        &mut self,
724        provider_tools: &HashMap<String, Vec<String>>,
725    ) -> Result<()> {
726        let stored_providers: HashSet<String> = self.config.mcp.providers.keys().cloned().collect();
727        let mut has_changes = false;
728
729        // Update or insert provider entries
730        for (provider, tools) in provider_tools {
731            let entry = self
732                .config
733                .mcp
734                .providers
735                .entry(provider.clone())
736                .or_default();
737
738            let existing_tools: HashSet<_> = entry.tools.keys().cloned().collect();
739            let advertised: HashSet<_> = tools.iter().cloned().collect();
740
741            // Add new tools with default Prompt policy
742            for tool in tools {
743                if !existing_tools.contains(tool) {
744                    entry.tools.insert(tool.clone(), ToolPolicy::Prompt);
745                    has_changes = true;
746                }
747            }
748
749            // Remove tools no longer advertised
750            for stale in existing_tools.difference(&advertised) {
751                entry.tools.shift_remove(stale);
752                has_changes = true;
753            }
754        }
755
756        // Remove providers that are no longer present
757        let advertised_providers: HashSet<String> = provider_tools.keys().cloned().collect();
758        for provider in stored_providers
759            .difference(&advertised_providers)
760            .cloned()
761            .collect::<Vec<_>>()
762        {
763            self.config.mcp.providers.shift_remove(provider.as_str());
764            has_changes = true;
765        }
766
767        // Remove any stale MCP keys from the primary policy map
768        let stale_runtime_keys: Vec<_> = self
769            .config
770            .policies
771            .keys()
772            .filter(|name| name.starts_with("mcp::"))
773            .cloned()
774            .collect();
775
776        for key in stale_runtime_keys {
777            self.config.policies.shift_remove(&key);
778            has_changes = true;
779        }
780
781        // Refresh available tools list with MCP entries included
782        let mut available: Vec<String> = self
783            .config
784            .available_tools
785            .iter()
786            .filter(|name| !name.starts_with("mcp::"))
787            .cloned()
788            .collect();
789
790        available.extend(
791            self.config
792                .mcp
793                .providers
794                .iter()
795                .flat_map(|(provider, policy)| {
796                    policy
797                        .tools
798                        .keys()
799                        .map(move |tool| format!("mcp::{}::{}", provider, tool))
800                }),
801        );
802
803        available.sort();
804        available.dedup();
805
806        // Check if the available tools list has actually changed
807        if self.config.available_tools != available {
808            self.config.available_tools = available;
809            has_changes = true;
810        }
811
812        if has_changes {
813            self.save_config().await
814        } else {
815            Ok(())
816        }
817    }
818
819    /// Retrieve policy for a specific MCP tool
820    pub fn get_mcp_tool_policy(&self, provider: &str, tool: &str) -> ToolPolicy {
821        self.config
822            .mcp
823            .providers
824            .get(provider)
825            .and_then(|policy| policy.tools.get(tool))
826            .cloned()
827            .unwrap_or(ToolPolicy::Prompt)
828    }
829
830    /// Update policy for a specific MCP tool
831    pub async fn set_mcp_tool_policy(
832        &mut self,
833        provider: &str,
834        tool: &str,
835        policy: ToolPolicy,
836    ) -> Result<()> {
837        // OPTIMIZATION: Use into() for cleaner conversion
838        let entry = self
839            .config
840            .mcp
841            .providers
842            .entry(provider.into())
843            .or_default();
844        entry.tools.insert(tool.into(), policy);
845        self.save_config().await
846    }
847
848    /// Access the persisted MCP allow list configuration
849    pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
850        &self.config.mcp.allowlist
851    }
852
853    /// Replace the persisted MCP allow list configuration
854    pub async fn set_mcp_allowlist(&mut self, allowlist: McpAllowListConfig) -> Result<()> {
855        self.config.mcp.allowlist = allowlist;
856        self.save_config().await
857    }
858
859    /// Get policy for a specific tool
860    pub fn get_policy(&self, tool_name: &str) -> ToolPolicy {
861        let canonical = canonical_tool_name(tool_name);
862        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
863            return self.get_mcp_tool_policy(&provider, &tool);
864        }
865
866        self.config
867            .policies
868            .get(canonical)
869            .cloned()
870            .unwrap_or(ToolPolicy::Prompt)
871    }
872
873    /// Get optional constraints for a specific tool
874    pub fn get_constraints(&self, tool_name: &str) -> Option<&ToolConstraints> {
875        let canonical = canonical_tool_name(tool_name);
876        self.config.constraints.get(canonical)
877    }
878
879    /// Check if tool should be executed based on policy
880    pub async fn should_execute_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
881        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
882            return match self.get_mcp_tool_policy(&provider, &tool) {
883                ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
884                ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
885                ToolPolicy::Prompt => {
886                    if ToolPolicyManager::is_auto_allow_tool(tool_name) {
887                        self.set_mcp_tool_policy(&provider, &tool, ToolPolicy::Allow)
888                            .await?;
889                        Ok(ToolExecutionDecision::Allowed)
890                    } else {
891                        // Use permission handler if available
892                        if let Some(ref mut handler) = self.permission_handler {
893                            handler.prompt_tool_permission(tool_name)
894                        } else {
895                            // Default: allow through (for backward compatibility)
896                            Ok(ToolExecutionDecision::Allowed)
897                        }
898                    }
899                }
900            };
901        }
902
903        let canonical = canonical_tool_name(tool_name);
904
905        match self.get_policy(canonical) {
906            ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
907            ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
908            ToolPolicy::Prompt => {
909                let canonical_name = canonical;
910                if AUTO_ALLOW_TOOLS.contains(&canonical_name) {
911                    self.set_policy(canonical_name, ToolPolicy::Allow).await?;
912                    return Ok(ToolExecutionDecision::Allowed);
913                }
914                // Use permission handler if available
915                if let Some(ref mut handler) = self.permission_handler {
916                    handler.prompt_tool_permission(tool_name)
917                } else {
918                    // Default: allow through (for backward compatibility)
919                    Ok(ToolExecutionDecision::Allowed)
920                }
921            }
922        }
923    }
924
925    pub fn is_auto_allow_tool(tool_name: &str) -> bool {
926        let canonical = canonical_tool_name(tool_name);
927        AUTO_ALLOW_TOOLS.contains(&canonical)
928    }
929
930    /// Prompt user for tool execution permission using the configured handler.
931    ///
932    /// This function delegates to the PermissionPromptHandler if one is configured.
933    /// In TUI mode, the handler should be set to use TUI-based prompts via the
934    /// permission handler mechanism.
935    pub fn prompt_user_for_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
936        if let Some(ref mut handler) = self.permission_handler {
937            handler.prompt_tool_permission(tool_name)
938        } else {
939            // Default behavior if no handler is configured: allow through
940            Ok(ToolExecutionDecision::Allowed)
941        }
942    }
943
944    /// Set policy for a specific tool
945    pub async fn set_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
946        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
947            return self.set_mcp_tool_policy(&provider, &tool, policy).await;
948        }
949
950        let canonical = canonical_tool_name(tool_name).to_owned();
951        self.config
952            .policies
953            .insert(canonical.clone(), policy.clone());
954        self.save_config().await?;
955        self.persist_policy_to_workspace_config(&canonical, policy)
956    }
957
958    pub(crate) async fn seed_default_policy(
959        &mut self,
960        tool_name: &str,
961        policy: ToolPolicy,
962    ) -> Result<()> {
963        let canonical = canonical_tool_name(tool_name).to_owned();
964        self.config.policies.insert(canonical, policy);
965        self.save_config().await
966    }
967
968    /// Reset all tools to prompt
969    pub async fn reset_all_to_prompt(&mut self) -> Result<()> {
970        for policy in self.config.policies.values_mut() {
971            *policy = ToolPolicy::Prompt;
972        }
973        for provider in self.config.mcp.providers.values_mut() {
974            for policy in provider.tools.values_mut() {
975                *policy = ToolPolicy::Prompt;
976            }
977        }
978        self.config.approval_cache.allowed.clear();
979        self.config.approval_cache.prefixes.clear();
980        self.config.approval_cache.regexes.clear();
981        self.save_config().await
982    }
983
984    /// Allow all tools
985    pub async fn allow_all_tools(&mut self) -> Result<()> {
986        for policy in self.config.policies.values_mut() {
987            *policy = ToolPolicy::Allow;
988        }
989        for provider in self.config.mcp.providers.values_mut() {
990            for policy in provider.tools.values_mut() {
991                *policy = ToolPolicy::Allow;
992            }
993        }
994        self.save_config().await
995    }
996
997    /// Deny all tools
998    pub async fn deny_all_tools(&mut self) -> Result<()> {
999        for policy in self.config.policies.values_mut() {
1000            *policy = ToolPolicy::Deny;
1001        }
1002        for provider in self.config.mcp.providers.values_mut() {
1003            for policy in provider.tools.values_mut() {
1004                *policy = ToolPolicy::Deny;
1005            }
1006        }
1007        self.config.approval_cache.allowed.clear();
1008        self.config.approval_cache.prefixes.clear();
1009        self.config.approval_cache.regexes.clear();
1010        self.save_config().await
1011    }
1012
1013    /// Get summary of current policies
1014    pub fn get_policy_summary(&self) -> IndexMap<String, ToolPolicy> {
1015        let mut summary = self.config.policies.clone();
1016        for (provider, policy) in &self.config.mcp.providers {
1017            for (tool, status) in &policy.tools {
1018                summary.insert(format!("mcp::{}::{}", provider, tool), status.clone());
1019            }
1020        }
1021        summary
1022    }
1023
1024    /// Check whether an explicit approval key is remembered for this workspace.
1025    ///
1026    /// Performs three levels of matching:
1027    /// 1. Exact match against persisted allowed keys
1028    /// 2. Word-prefix match: checks if the approval key starts with any persisted prefix
1029    /// 3. Regex match against persisted regex patterns
1030    pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
1031        // Exact match: simplest and fastest
1032        if self.config.approval_cache.allowed.contains(approval_key) {
1033            return true;
1034        }
1035
1036        // Word-prefix match: check if any cached key is a word-prefix of the approval_key
1037        // e.g., "cargo check" matches "cargo check --target x86_64"
1038        for cached in &self.config.approval_cache.allowed {
1039            if cached.len() < approval_key.len()
1040                && approval_key.starts_with(cached.as_str())
1041                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
1042            {
1043                return true;
1044            }
1045        }
1046
1047        // Word-prefix match against cached prefixes
1048        let approval_words: Vec<&str> = approval_key.split_whitespace().collect();
1049        for cached in &self.config.approval_cache.prefixes {
1050            let (prefix_text, _) = split_shell_approval_entry(cached.as_str());
1051            let prefix_words: Vec<&str> = prefix_text.split_whitespace().collect();
1052            if !prefix_words.is_empty()
1053                && prefix_words.len() <= approval_words.len()
1054                && prefix_words
1055                    .iter()
1056                    .zip(approval_words.iter())
1057                    .all(|(a, b)| a == b)
1058            {
1059                return true;
1060            }
1061        }
1062
1063        // Regex match against persisted regex patterns
1064        self.config
1065            .approval_cache
1066            .regexes
1067            .iter()
1068            .filter_map(|pattern| Regex::new(pattern).ok())
1069            .any(|regex| regex.is_match(approval_key))
1070    }
1071
1072    /// Find the best matching cache key for a given approval key using fuzzy prefix matching.
1073    /// Returns the longest matching prefix key if one exists.
1074    pub fn find_matching_cache_prefix(&self, approval_key: &str) -> Option<String> {
1075        let mut best_match: Option<String> = None;
1076        let mut best_len = 0usize;
1077
1078        for cached in &self.config.approval_cache.allowed {
1079            if cached.len() < approval_key.len()
1080                && approval_key.starts_with(cached.as_str())
1081                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
1082                && cached.len() > best_len
1083            {
1084                best_len = cached.len();
1085                best_match = Some(cached.clone());
1086            }
1087        }
1088
1089        best_match
1090    }
1091
1092    /// Persist an explicit approval key for future prompts in this workspace.
1093    pub async fn add_approval_cache_key(&mut self, approval_key: impl Into<String>) -> Result<()> {
1094        if self
1095            .config
1096            .approval_cache
1097            .allowed
1098            .insert(approval_key.into())
1099        {
1100            self.save_config().await?;
1101        }
1102        Ok(())
1103    }
1104
1105    /// Persist an approval key and automatically derive shorter segment-prefix keys
1106    /// so that future similar commands also match without re-prompting.
1107    ///
1108    /// For example, approving "cargo check --target x86_64" also caches
1109    /// "cargo check" as a segment prefix, so "cargo check --release" also matches.
1110    pub async fn add_approval_cache_key_with_segments(
1111        &mut self,
1112        approval_key: impl Into<String>,
1113    ) -> Result<()> {
1114        let key: String = approval_key.into();
1115        let mut changed = false;
1116
1117        // Add the exact key
1118        if self.config.approval_cache.allowed.insert(key.clone()) {
1119            changed = true;
1120        }
1121
1122        // Derive segment prefixes for shell commands (3+ words):
1123        // "cargo check --target x86_64" → also caches word prefixes:
1124        // "cargo", "cargo check", "cargo check --target"
1125        let words: Vec<&str> = key.split_whitespace().collect();
1126        if words.len() >= 3 {
1127            for i in (1..words.len()).rev() {
1128                let prefix: String = words[..i].join(" ");
1129                if self.config.approval_cache.prefixes.insert(prefix) {
1130                    changed = true;
1131                }
1132            }
1133        }
1134
1135        if changed {
1136            self.save_config().await?;
1137        }
1138        Ok(())
1139    }
1140
1141    /// Persist a shell prefix approval entry for future prompts in this workspace.
1142    pub async fn add_approval_cache_prefix(
1143        &mut self,
1144        prefix_entry: impl Into<String>,
1145    ) -> Result<()> {
1146        if self
1147            .config
1148            .approval_cache
1149            .prefixes
1150            .insert(prefix_entry.into())
1151        {
1152            self.save_config().await?;
1153        }
1154        Ok(())
1155    }
1156
1157    /// Check whether a persisted shell prefix approval matches the command words and scope.
1158    pub fn matching_shell_approval_prefix(
1159        &self,
1160        command_words: &[String],
1161        scope_signature: &str,
1162    ) -> Option<String> {
1163        self.config
1164            .approval_cache
1165            .prefixes
1166            .iter()
1167            .find_map(|entry| {
1168                let (prefix_text, entry_scope_signature) =
1169                    split_shell_approval_entry(entry.as_str());
1170                let prefix_words = shell_words::split(prefix_text).ok()?;
1171                let entry_scope_signature =
1172                    entry_scope_signature.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
1173                (entry_scope_signature == scope_signature
1174                    && shell_command_words_match_prefix(command_words, &prefix_words))
1175                .then(|| entry.clone())
1176            })
1177    }
1178
1179    /// Remove all persisted approval cache entries.
1180    pub async fn clear_approval_cache(&mut self) -> Result<()> {
1181        if !self.config.approval_cache.allowed.is_empty()
1182            || !self.config.approval_cache.prefixes.is_empty()
1183            || !self.config.approval_cache.regexes.is_empty()
1184        {
1185            self.config.approval_cache.allowed.clear();
1186            self.config.approval_cache.prefixes.clear();
1187            self.config.approval_cache.regexes.clear();
1188            self.save_config().await?;
1189        }
1190        Ok(())
1191    }
1192
1193    /// Save configuration to file
1194    fn save_config(&self) -> impl Future<Output = Result<()>> + '_ {
1195        Self::write_config(&self.config_path, &self.config)
1196    }
1197
1198    fn persist_policy_to_workspace_config(
1199        &self,
1200        tool_name: &str,
1201        policy: ToolPolicy,
1202    ) -> Result<()> {
1203        let Some(workspace_root) = self.workspace_root.as_ref() else {
1204            return Ok(());
1205        };
1206
1207        let config_path = workspace_root.join("vtcode.toml");
1208        let mut config = if config_path.exists() {
1209            ConfigManager::load_from_file(&config_path)
1210                .with_context(|| {
1211                    format!(
1212                        "Failed to load config for tool policy persistence at {}",
1213                        config_path.display()
1214                    )
1215                })?
1216                .config()
1217                .clone()
1218        } else {
1219            VTCodeConfig::default()
1220        };
1221
1222        config
1223            .tools
1224            .policies
1225            .insert(tool_name.to_string(), Self::to_config_policy(policy));
1226
1227        ConfigManager::save_config_to_path(&config_path, &config)
1228            .with_context(|| format!("Failed to persist tool policy to {}", config_path.display()))
1229    }
1230
1231    fn to_config_policy(policy: ToolPolicy) -> ConfigToolPolicy {
1232        match policy {
1233            ToolPolicy::Allow => ConfigToolPolicy::Allow,
1234            ToolPolicy::Prompt => ConfigToolPolicy::Prompt,
1235            ToolPolicy::Deny => ConfigToolPolicy::Deny,
1236        }
1237    }
1238
1239    /// Print current policy status
1240    pub fn print_status(&self) {
1241        println!("{}", style("Tool Policy Status").cyan().bold());
1242        println!("Config file: {}", self.config_path.display());
1243        println!();
1244
1245        let summary = self.get_policy_summary();
1246
1247        if summary.is_empty() {
1248            println!("No tools configured yet.");
1249            return;
1250        }
1251
1252        let mut allow_count = 0;
1253        let mut prompt_count = 0;
1254        let mut deny_count = 0;
1255
1256        for (tool, policy) in &summary {
1257            let (status, color_name) = match policy {
1258                ToolPolicy::Allow => {
1259                    allow_count += 1;
1260                    ("ALLOW", "green")
1261                }
1262                ToolPolicy::Prompt => {
1263                    prompt_count += 1;
1264                    ("PROMPT", "cyan")
1265                }
1266                ToolPolicy::Deny => {
1267                    deny_count += 1;
1268                    ("DENY", "red")
1269                }
1270            };
1271
1272            let status_styled = match color_name {
1273                "green" => style(status).green(),
1274                "cyan" => style(status).cyan(),
1275                "red" => style(status).red(),
1276                _ => style(status),
1277            };
1278
1279            println!(
1280                "  {} {}",
1281                style(format!("{:15}", tool)).cyan(),
1282                status_styled
1283            );
1284        }
1285
1286        println!();
1287        println!(
1288            "Summary: {} allowed, {} prompt, {} denied",
1289            style(allow_count).green(),
1290            style(prompt_count).cyan(),
1291            style(deny_count).red()
1292        );
1293    }
1294
1295    /// Expose path of the underlying policy configuration file
1296    pub fn config_path(&self) -> &Path {
1297        &self.config_path
1298    }
1299}
1300
1301fn split_shell_approval_entry(entry: &str) -> (&str, Option<&str>) {
1302    if let Some(index) = entry.find(SHELL_APPROVAL_SCOPE_MARKER) {
1303        let (prefix, scoped) = entry.split_at(index);
1304        (prefix, Some(&scoped[1..]))
1305    } else {
1306        (entry, None)
1307    }
1308}
1309
1310fn shell_command_words_match_prefix(command_words: &[String], prefix_words: &[String]) -> bool {
1311    command_words.len() >= prefix_words.len()
1312        && prefix_words
1313            .iter()
1314            .zip(command_words.iter())
1315            .all(|(prefix, command)| prefix == command)
1316}
1317
1318/// Scoped, optional constraints for a tool to align with safe defaults
1319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1320pub struct ToolConstraints {
1321    /// Whitelisted modes for tools that support modes (e.g., 'terminal')
1322    #[serde(default)]
1323    pub allowed_modes: Option<Vec<String>>,
1324    /// Cap on results for list/search-like tools
1325    #[serde(default)]
1326    pub max_results_per_call: Option<usize>,
1327    /// Cap on items scanned for file listing
1328    #[serde(default)]
1329    pub max_items_per_call: Option<usize>,
1330    /// Default response format if unspecified by caller
1331    #[serde(default)]
1332    pub default_response_format: Option<String>,
1333    /// Cap maximum bytes when reading files
1334    #[serde(default)]
1335    pub max_bytes_per_read: Option<usize>,
1336    /// Cap maximum bytes when fetching over the network
1337    #[serde(default)]
1338    pub max_response_bytes: Option<usize>,
1339    /// Allowed URL schemes for network tools
1340    #[serde(default)]
1341    pub allowed_url_schemes: Option<Vec<String>>,
1342    /// Denied URL hosts or suffixes for network tools
1343    #[serde(default)]
1344    pub denied_url_hosts: Option<Vec<String>>,
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350    use crate::config::constants::tools;
1351    use tempfile::tempdir;
1352
1353    #[test]
1354    fn test_tool_policy_config_serialization() {
1355        let mut config = ToolPolicyConfig {
1356            available_tools: vec![tools::READ_FILE.to_owned(), tools::WRITE_FILE.to_owned()],
1357            ..Default::default()
1358        };
1359        config
1360            .policies
1361            .insert(tools::READ_FILE.to_owned(), ToolPolicy::Allow);
1362        config
1363            .policies
1364            .insert(tools::WRITE_FILE.to_owned(), ToolPolicy::Prompt);
1365        config
1366            .approval_cache
1367            .allowed
1368            .insert("unified_exec:cargo test".to_string());
1369
1370        let json = serde_json::to_string_pretty(&config).unwrap();
1371        let deserialized: ToolPolicyConfig = serde_json::from_str(&json).unwrap();
1372
1373        assert_eq!(config.available_tools, deserialized.available_tools);
1374        assert_eq!(config.policies, deserialized.policies);
1375        assert_eq!(config.approval_cache, deserialized.approval_cache);
1376    }
1377
1378    #[tokio::test]
1379    async fn test_policy_updates() {
1380        let dir = tempdir().unwrap();
1381        let config_path = dir.path().join("tool-policy.json");
1382
1383        let mut config = ToolPolicyConfig {
1384            available_tools: vec!["tool1".to_owned()],
1385            ..Default::default()
1386        };
1387        config
1388            .policies
1389            .insert("tool1".to_owned(), ToolPolicy::Prompt);
1390
1391        // Save initial config
1392        let content = serde_json::to_string_pretty(&config).unwrap();
1393        std::fs::write(&config_path, content).unwrap();
1394
1395        // Load and update
1396        let mut loaded_config = ToolPolicyManager::load_or_create_config(&config_path)
1397            .await
1398            .unwrap();
1399
1400        // Add new tool
1401        let new_tools = vec!["tool1".to_owned(), "tool2".to_owned()];
1402        let current_tools: HashSet<_> = loaded_config.available_tools.iter().cloned().collect();
1403
1404        for tool in &new_tools {
1405            if !current_tools.contains(tool) {
1406                loaded_config
1407                    .policies
1408                    .insert(tool.clone(), ToolPolicy::Prompt);
1409            }
1410        }
1411
1412        loaded_config.available_tools = new_tools;
1413
1414        assert!(loaded_config.policies.len() >= 2);
1415        assert_eq!(
1416            loaded_config.policies.get("tool2"),
1417            Some(&ToolPolicy::Prompt)
1418        );
1419        assert_eq!(
1420            loaded_config.policies.get("tool1"),
1421            Some(&ToolPolicy::Prompt)
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn approval_cache_keys_round_trip() {
1427        let dir = tempdir().unwrap();
1428        let config_path = dir.path().join("tool-policy.json");
1429        let mut manager = ToolPolicyManager::new_with_config_path(&config_path)
1430            .await
1431            .expect("manager");
1432
1433        manager
1434            .add_approval_cache_key(
1435                "cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null",
1436            )
1437            .await
1438            .expect("persist approval");
1439
1440        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1441            .await
1442            .expect("reload manager");
1443        assert!(reloaded.has_approval_cache_key(
1444            "cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null"
1445        ));
1446    }
1447
1448    #[tokio::test]
1449    async fn approval_cache_prefixes_match_shell_prefixes() {
1450        let dir = tempdir().unwrap();
1451        let config_path = dir.path().join("tool-policy.json");
1452        let mut manager = ToolPolicyManager::new_with_config_path(&config_path)
1453            .await
1454            .expect("manager");
1455
1456        manager
1457            .add_approval_cache_prefix(
1458                "cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null",
1459            )
1460            .await
1461            .expect("persist prefix");
1462
1463        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1464            .await
1465            .expect("reload manager");
1466        let command_words = vec![
1467            "cargo".to_string(),
1468            "test".to_string(),
1469            "-p".to_string(),
1470            "vtcode-core".to_string(),
1471        ];
1472
1473        assert!(
1474            reloaded
1475                .matching_shell_approval_prefix(
1476                    &command_words,
1477                    "sandbox_permissions=\"use_default\"|additional_permissions=null",
1478                )
1479                .is_some()
1480        );
1481    }
1482
1483    #[tokio::test]
1484    async fn approval_cache_regexes_match_keys() {
1485        let dir = tempdir().unwrap();
1486        let config_path = dir.path().join("tool-policy.json");
1487        let mut manager = ToolPolicyManager::new_with_config_path(&config_path)
1488            .await
1489            .expect("manager");
1490
1491        manager
1492            .config
1493            .approval_cache
1494            .regexes
1495            .insert("^cargo (check|fmt)\\|sandbox_permissions=\\\"use_default\\\".*$".to_string());
1496        manager.save_config().await.expect("save regex");
1497
1498        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1499            .await
1500            .expect("reload manager");
1501        assert!(reloaded.has_approval_cache_key(
1502            "cargo check|sandbox_permissions=\"use_default\"|additional_permissions=null"
1503        ));
1504    }
1505
1506    #[tokio::test]
1507    async fn reset_to_prompt_clears_approval_cache() {
1508        let dir = tempdir().unwrap();
1509        let config_path = dir.path().join("tool-policy.json");
1510        let mut manager = ToolPolicyManager::new_with_config_path(&config_path)
1511            .await
1512            .expect("manager");
1513
1514        manager
1515            .add_approval_cache_key("read_file")
1516            .await
1517            .expect("persist approval");
1518        manager
1519            .add_approval_cache_prefix(
1520                "cargo check|sandbox_permissions=\"use_default\"|additional_permissions=null",
1521            )
1522            .await
1523            .expect("persist prefix");
1524        manager
1525            .config
1526            .approval_cache
1527            .regexes
1528            .insert("^cargo check.*$".to_string());
1529        manager.reset_all_to_prompt().await.expect("reset policies");
1530
1531        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1532            .await
1533            .expect("reload manager");
1534        assert!(!reloaded.has_approval_cache_key("read_file"));
1535        assert!(reloaded.config.approval_cache.prefixes.is_empty());
1536        assert!(reloaded.config.approval_cache.regexes.is_empty());
1537    }
1538}