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//! the canonical user config directory's `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::ToolsConfig;
20
21pub use crate::config::core::tools::ToolPolicy;
22use crate::config::loader::{ConfigManager, VTCodeConfig};
23use crate::config::mcp::{McpAllowListConfig, McpAllowListRules};
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, parse_canonical_mcp_tool_name};
26use vtcode_commons::VtCodePaths;
27use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic};
28
29/// Memoized compiled approval regexes, keyed by the exact pattern list, so we
30/// don't recompile `Regex::new` on every approval-policy check.
31static APPROVAL_REGEX_CACHE: std::sync::Mutex<Option<(IndexSet<String>, Vec<Regex>)>> = std::sync::Mutex::new(None);
32
33const AUTO_ALLOW_TOOLS: &[&str] = &[
34    tools::START_PLANNING,
35    tools::TASK_TRACKER,
36    tools::MATRIX,
37    tools::RECORD_DECISION,
38    tools::READ_FILE,
39    // This tool dispatches to the interactive front-end wizard. Asking the
40    // user is safe and must not be intercepted by a second permission prompt.
41    tools::REQUEST_USER_INPUT,
42    // Whitelist the core execution tool itself; individual shell commands remain
43    // gated by command/sandbox approval policy.
44    tools::UNIFIED_EXEC,
45    // Internal PTY helpers used by tests and internal workflows.
46    tools::RUN_PTY_CMD,
47    tools::CREATE_PTY_SESSION,
48    tools::LIST_PTY_SESSIONS,
49    tools::SEND_PTY_INPUT,
50    tools::CLOSE_PTY_SESSION,
51    "cargo_check",
52    "cargo_test",
53    "git_status",
54    "git_diff",
55    "git_log",
56];
57
58const SHELL_APPROVAL_SCOPE_MARKER: &str = "|sandbox_permissions=";
59const DEFAULT_APPROVAL_SCOPE_SIGNATURE: &str = "sandbox_permissions=\"use_default\"|additional_permissions=null";
60const KNOWN_MUTATING_COMMANDS: &[&str] = &[
61    "awk", "cargo", "chmod", "chown", "cp", "curl", "dd", "install", "ln", "mkdir", "mv", "perl", "python", "python3",
62    "rm", "rmdir", "rsync", "ruby", "sh", "bash", "zsh", "tee", "touch", "truncate", "wget",
63];
64const MUTATING_OPTION_HINTS: &[&str] = &[
65    "--delete",
66    "--exec",
67    "--in-place",
68    "--output",
69    "--remove",
70    "--write",
71    "-delete",
72    "-exec",
73    "-execdir",
74    "-i",
75    "-o",
76];
77
78/// Decision result for tool execution
79#[derive(Debug, Clone, PartialEq)]
80pub enum ToolExecutionDecision {
81    /// The tool is allowed to execute.
82    Allowed,
83    /// The tool execution is denied.
84    Denied,
85    /// The tool execution is denied with a feedback message.
86    DeniedWithFeedback(String),
87}
88
89impl ToolExecutionDecision {
90    pub fn is_allowed(&self) -> bool {
91        matches!(self, Self::Allowed)
92    }
93}
94
95/// Tool policy configuration stored in the canonical user config directory.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ToolPolicyConfig {
98    /// Configuration version for future compatibility
99    pub version: u32,
100    /// Available tools at time of last update
101    pub available_tools: Vec<String>,
102    /// Policy for each tool
103    pub policies: IndexMap<String, ToolPolicy>,
104    /// Optional per-tool constraints to scope permissions and enforce safety
105    #[serde(default)]
106    pub constraints: IndexMap<String, ToolConstraints>,
107    /// MCP-specific policy configuration
108    #[serde(default)]
109    pub mcp: McpPolicyStore,
110    /// Explicit remembered approvals for future prompts in this workspace
111    #[serde(default)]
112    pub approval_cache: ApprovalCacheConfig,
113}
114
115impl Default for ToolPolicyConfig {
116    fn default() -> Self {
117        Self {
118            version: 1,
119            available_tools: Vec::new(),
120            policies: IndexMap::new(),
121            constraints: IndexMap::new(),
122            mcp: McpPolicyStore::default(),
123            approval_cache: ApprovalCacheConfig::default(),
124        }
125    }
126}
127
128/// Persisted approval cache stored alongside tool policies
129#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ApprovalCacheConfig {
131    /// Stable approval keys that should bypass future prompts
132    #[serde(default)]
133    pub allowed: IndexSet<String>,
134    /// Shell command prefixes that should bypass future prompts in the same scope
135    #[serde(default)]
136    pub prefixes: IndexSet<String>,
137    /// Regex patterns matched against approval keys for advanced manual policy tuning
138    #[serde(default)]
139    pub regexes: IndexSet<String>,
140}
141
142/// Maximum remembered approval entries per cache collection. Unbounded growth
143/// turned `tool-policy.json` into a multi-hundred-kilobyte file after a few
144/// long sessions (see session-vtcode-20260925T234343Z: 294 KB). Oldest
145/// insertions are dropped first so recent approvals survive.
146pub const APPROVAL_CACHE_MAX_ENTRIES: usize = 256;
147
148impl ApprovalCacheConfig {
149    /// Drop the oldest entries so each collection stays within
150    /// [`APPROVAL_CACHE_MAX_ENTRIES`]. Returns `true` when anything was trimmed.
151    pub fn trim_to_budget(&mut self) -> bool {
152        let mut trimmed = false;
153        for collection in [&mut self.allowed, &mut self.prefixes, &mut self.regexes] {
154            while collection.len() > APPROVAL_CACHE_MAX_ENTRIES {
155                let _ = collection.shift_remove_index(0);
156                trimmed = true;
157            }
158        }
159        trimmed
160    }
161}
162
163/// Stored MCP policy state, persisted alongside standard tool policies
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct McpPolicyStore {
166    /// Active MCP allow list configuration
167    #[serde(default = "default_secure_mcp_allowlist")]
168    pub allowlist: McpAllowListConfig,
169    /// Provider-specific tool policies (allow/prompt/deny)
170    #[serde(default)]
171    pub providers: IndexMap<String, McpProviderPolicy>,
172}
173
174impl Default for McpPolicyStore {
175    fn default() -> Self {
176        Self {
177            allowlist: default_secure_mcp_allowlist(),
178            providers: IndexMap::new(),
179        }
180    }
181}
182
183/// MCP provider policy entry containing per-tool permissions
184#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct McpProviderPolicy {
186    #[serde(default)]
187    pub tools: IndexMap<String, ToolPolicy>,
188}
189
190// Helper constants to reduce allocations in MCP allowlist configuration
191const MCP_LOGGING_EVENTS: &[&str] = &[
192    "mcp.tool_execution",
193    "mcp.tool_failed",
194    "mcp.tool_denied",
195    "mcp.tool_filtered",
196    "mcp.provider_initialized",
197];
198
199const MCP_DEFAULT_LOGGING_EVENTS: &[&str] = &[
200    "mcp.provider_initialized",
201    "mcp.provider_initialization_failed",
202    "mcp.tool_filtered",
203    "mcp.tool_execution",
204    "mcp.tool_failed",
205    "mcp.tool_denied",
206];
207
208/// Helper to create standard MCP logging configuration
209#[inline]
210fn mcp_standard_logging() -> Vec<String> {
211    MCP_LOGGING_EVENTS.iter().map(|s| (*s).into()).collect()
212}
213
214/// Helper to create provider configuration with max_concurrent_requests
215#[inline]
216fn mcp_provider_config_with(extra: (&str, Vec<&str>)) -> BTreeMap<String, Vec<String>> {
217    BTreeMap::from([
218        ("provider".into(), vec!["max_concurrent_requests".into()]),
219        (extra.0.into(), extra.1.into_iter().map(Into::into).collect()),
220    ])
221}
222
223fn default_secure_mcp_allowlist() -> McpAllowListConfig {
224    let default_logging = Some(MCP_DEFAULT_LOGGING_EVENTS.iter().map(|s| (*s).into()).collect());
225
226    let default_configuration = Some(BTreeMap::from([
227        (
228            "client".into(),
229            vec![
230                "max_concurrent_connections".into(),
231                "request_timeout_seconds".into(),
232                "retry_attempts".into(),
233                "startup_timeout_seconds".into(),
234                "tool_timeout_seconds".into(),
235                "experimental_use_rmcp_client".into(),
236            ],
237        ),
238        ("ui".into(), vec!["mode".into(), "max_events".into(), "show_provider_names".into()]),
239        (
240            "server".into(),
241            vec![
242                "enabled".into(),
243                "bind_address".into(),
244                "port".into(),
245                "transport".into(),
246                "name".into(),
247                "version".into(),
248            ],
249        ),
250    ]));
251
252    let time_rules = McpAllowListRules {
253        tools: Some(vec![
254            "get_*".into(),
255            "list_*".into(),
256            "convert_timezone".into(),
257            "describe_timezone".into(),
258            "time_*".into(),
259        ]),
260        resources: Some(vec!["timezone:*".into(), "location:*".into()]),
261        logging: Some(mcp_standard_logging()),
262        configuration: Some(mcp_provider_config_with(("time", vec!["local_timezone_override"]))),
263        ..Default::default()
264    };
265
266    let context_rules = McpAllowListRules {
267        tools: Some(vec![
268            "search_*".into(),
269            "fetch_*".into(),
270            "list_*".into(),
271            "context7_*".into(),
272            "get_*".into(),
273        ]),
274        resources: Some(vec![
275            "docs::*".into(),
276            "snippets::*".into(),
277            "repositories::*".into(),
278            "context7::*".into(),
279        ]),
280        prompts: Some(vec!["context7::*".into(), "support::*".into(), "docs::*".into()]),
281        logging: Some(mcp_standard_logging()),
282        configuration: Some(mcp_provider_config_with(("context7", vec!["workspace", "search_scope", "max_results"]))),
283    };
284
285    let seq_rules = McpAllowListRules {
286        tools: Some(vec![
287            "plan".into(),
288            "critique".into(),
289            "reflect".into(),
290            "decompose".into(),
291            "sequential_*".into(),
292        ]),
293        resources: None,
294        prompts: Some(vec![
295            "sequential-thinking::*".into(),
296            "plan".into(),
297            "reflect".into(),
298            "critique".into(),
299        ]),
300        logging: Some(mcp_standard_logging()),
301        configuration: Some(mcp_provider_config_with(("sequencing", vec!["max_depth", "max_branches"]))),
302    };
303
304    let mut allowlist = McpAllowListConfig {
305        enforce: true,
306        default: McpAllowListRules {
307            logging: default_logging,
308            configuration: default_configuration,
309            ..Default::default()
310        },
311        ..Default::default()
312    };
313
314    allowlist.providers.insert("time".into(), time_rules);
315    allowlist.providers.insert("context7".into(), context_rules);
316    allowlist.providers.insert("sequential-thinking".into(), seq_rules);
317
318    allowlist
319}
320
321fn parse_mcp_policy_key(tool_name: &str) -> Option<(String, String)> {
322    parse_canonical_mcp_tool_name(tool_name).map(|(provider, tool)| (provider.to_string(), tool.to_string()))
323}
324
325/// Alternative tool policy configuration format (user's format)
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct AlternativeToolPolicyConfig {
328    /// Configuration version for future compatibility
329    pub version: u32,
330    /// Default policy settings
331    pub default: AlternativeDefaultPolicy,
332    /// Tool-specific policies
333    pub tools: IndexMap<String, AlternativeToolPolicy>,
334    /// Optional per-tool constraints (ignored if absent)
335    #[serde(default)]
336    pub constraints: IndexMap<String, ToolConstraints>,
337}
338
339/// Default policy in alternative format
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct AlternativeDefaultPolicy {
342    /// Whether to allow by default
343    pub allow: bool,
344    /// Rate limit per run
345    pub rate_limit_per_run: u32,
346    /// Max concurrent executions
347    pub max_concurrent: u32,
348    /// Allow filesystem writes
349    pub fs_write: bool,
350    /// Allow network access
351    pub network: bool,
352}
353
354/// Tool policy in alternative format
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct AlternativeToolPolicy {
357    /// Whether to allow this tool
358    pub allow: bool,
359    /// Allow filesystem writes (optional)
360    #[serde(default)]
361    pub fs_write: bool,
362    /// Allow network access (optional)
363    #[serde(default)]
364    pub network: bool,
365    /// Arguments policy (optional)
366    #[serde(default)]
367    pub args_policy: Option<AlternativeArgsPolicy>,
368}
369
370/// Arguments policy in alternative format
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct AlternativeArgsPolicy {
373    /// Substrings to deny
374    pub deny_substrings: Vec<String>,
375}
376
377/// Handler for tool permission prompts
378///
379/// This trait allows different UI modes (CLI, TUI) to provide their own
380/// implementation for prompting users about tool execution.
381pub trait PermissionPromptHandler: Send + Sync {
382    /// Prompt the user for tool execution permission
383    fn prompt_tool_permission(&mut self, tool_name: &str) -> Result<ToolExecutionDecision>;
384}
385
386/// Tool policy manager
387pub struct ToolPolicyManager {
388    config_path: PathBuf,
389    config: ToolPolicyConfig,
390    permission_handler: Option<Box<dyn PermissionPromptHandler>>,
391    workspace_root: Option<PathBuf>,
392    private_storage: bool,
393}
394
395impl Clone for ToolPolicyManager {
396    fn clone(&self) -> Self {
397        // Note: Permission handler is not cloned - this is intentional as handlers
398        // typically contain UI state that shouldn't be duplicated
399        Self {
400            config_path: self.config_path.clone(),
401            config: self.config.clone(),
402            permission_handler: None, // Handler is not cloned
403            workspace_root: self.workspace_root.clone(),
404            private_storage: self.private_storage,
405        }
406    }
407}
408
409impl ToolPolicyManager {
410    /// Create a new tool policy manager
411    pub async fn new() -> Result<Self> {
412        let config_path = Self::get_config_path().await?;
413        let config = Self::load_or_create_config_with_policy(&config_path, true).await?;
414
415        Ok(Self {
416            config_path,
417            config,
418            permission_handler: None,
419            workspace_root: None,
420            private_storage: true,
421        })
422    }
423
424    /// Create a new tool policy manager with workspace-specific config
425    pub async fn new_with_workspace(workspace_root: &Path) -> Result<Self> {
426        let config_path = Self::get_workspace_config_path(workspace_root).await?;
427        let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
428
429        Ok(Self {
430            config_path,
431            config,
432            permission_handler: None,
433            workspace_root: Some(workspace_root.to_path_buf()),
434            private_storage: false,
435        })
436    }
437
438    /// Create a new tool policy manager backed by a custom configuration path.
439    ///
440    /// This helper allows downstream consumers to store policy data alongside
441    /// their own configuration hierarchy instead of writing to the default
442    /// `.vtcode` directory.
443    pub async fn new_with_config_path<P: Into<PathBuf>>(config_path: P) -> Result<Self> {
444        let config_path = config_path.into();
445
446        if let Some(parent) = config_path.parent()
447            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
448        {
449            ensure_dir_exists(parent)
450                .await
451                .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
452        }
453
454        let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
455
456        Ok(Self {
457            config_path,
458            config,
459            permission_handler: None,
460            workspace_root: None,
461            private_storage: false,
462        })
463    }
464
465    /// Set the permission handler for this manager
466    pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
467        self.permission_handler = Some(handler);
468    }
469
470    /// Get the path to the tool policy configuration file
471    async fn get_config_path() -> Result<PathBuf> {
472        let paths = VtCodePaths::resolve().context("Could not resolve VT Code paths")?;
473        paths.ensure_config_dir().context("Failed to create VT Code config directory")?;
474        paths.config_path("tool-policy.json")
475    }
476
477    /// Get the path to the workspace-specific tool policy configuration file
478    async fn get_workspace_config_path(workspace_root: &Path) -> Result<PathBuf> {
479        let workspace_vtcode_dir = workspace_root.join(".vtcode");
480
481        if !tokio::fs::try_exists(&workspace_vtcode_dir).await.unwrap_or(false) {
482            ensure_dir_exists(&workspace_vtcode_dir).await.with_context(|| {
483                format!("Failed to create workspace policy directory at {}", workspace_vtcode_dir.display())
484            })?;
485        }
486
487        Ok(workspace_vtcode_dir.join("tool-policy.json"))
488    }
489
490    async fn load_or_create_config_with_policy(config_path: &Path, private_storage: bool) -> Result<ToolPolicyConfig> {
491        let content = if private_storage {
492            match tokio::fs::symlink_metadata(config_path).await {
493                Ok(metadata) if metadata.file_type().is_symlink() => {
494                    return Err(anyhow::anyhow!(
495                        "Refusing to read symlinked tool policy config: {}",
496                        config_path.display()
497                    ));
498                }
499                Ok(metadata) if !metadata.is_file() => {
500                    return Err(anyhow::anyhow!("Tool policy config is not a regular file: {}", config_path.display()));
501                }
502                Ok(_) => Some(
503                    String::from_utf8(read_private_file_no_follow(config_path).await?)
504                        .context("Tool policy config is not valid UTF-8")?,
505                ),
506                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
507                Err(error) => {
508                    return Err(error).with_context(|| format!("Failed to inspect {}", config_path.display()));
509                }
510            }
511        } else if tokio::fs::try_exists(config_path).await.unwrap_or(false) {
512            Some(
513                read_file_with_context(config_path, "tool policy config")
514                    .await
515                    .context("Failed to read tool policy config")?,
516            )
517        } else {
518            None
519        };
520
521        if let Some(content) = content {
522            // Try to parse as alternative format first
523            if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
524                // Convert alternative format to standard format
525                return Ok(Self::convert_from_alternative(alt_config));
526            }
527
528            // Fall back to standard format with graceful recovery on parse errors
529            match serde_json::from_str(&content) {
530                Ok(mut config) => {
531                    Self::apply_auto_allow_defaults(&mut config);
532                    Self::ensure_network_constraints(&mut config);
533                    Ok(config)
534                }
535                Err(parse_err) => {
536                    tracing::warn!(
537                        "Invalid tool policy config at {} ({}). Resetting to defaults.",
538                        config_path.display(),
539                        parse_err
540                    );
541                    Self::reset_to_default(config_path, private_storage).await
542                }
543            }
544        } else {
545            // Create new config with empty tools list
546            let mut config = ToolPolicyConfig::default();
547            Self::apply_auto_allow_defaults(&mut config);
548            Self::ensure_network_constraints(&mut config);
549            Ok(config)
550        }
551    }
552
553    fn apply_auto_allow_defaults(config: &mut ToolPolicyConfig) {
554        for &tool in AUTO_ALLOW_TOOLS {
555            if !config.policies.contains_key(tool) {
556                config.policies.insert(tool.into(), ToolPolicy::Allow);
557            }
558            if !config.available_tools.iter().any(|t| t == tool) {
559                config.available_tools.push(tool.into());
560            }
561        }
562        Self::ensure_network_constraints(config);
563    }
564
565    fn ensure_network_constraints(_config: &mut ToolPolicyConfig) {
566        // Network constraints removed with curl tool removal
567    }
568
569    async fn reset_to_default(config_path: &Path, private_storage: bool) -> Result<ToolPolicyConfig> {
570        let backup_path = config_path.with_extension("json.bak");
571
572        if let Err(err) = tokio::fs::rename(config_path, &backup_path).await {
573            tracing::warn!("Unable to back up invalid tool policy config ({}). {}", config_path.display(), err);
574        } else {
575            tracing::info!("Backed up invalid tool policy config to {}", backup_path.display());
576        }
577
578        let default_config = ToolPolicyConfig::default();
579        Self::write_config_with_policy(config_path, &default_config, private_storage).await?;
580        Ok(default_config)
581    }
582
583    async fn write_config_with_policy(path: &Path, config: &ToolPolicyConfig, private_storage: bool) -> Result<()> {
584        if let Some(parent) = path.parent()
585            && !tokio::fs::try_exists(parent).await.unwrap_or(false)
586        {
587            if private_storage {
588                VtCodePaths::ensure_user_dir(parent)
589                    .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
590            } else {
591                ensure_dir_exists(parent)
592                    .await
593                    .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
594            }
595        }
596
597        let serialized = serde_json::to_string_pretty(config).context("Failed to serialize tool policy config")?;
598
599        if private_storage {
600            write_private_file_atomic(path, serialized.as_bytes())
601                .await
602                .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
603        } else {
604            write_file_atomic_with_context(path, &serialized, "tool policy config")
605                .await
606                .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
607        }
608    }
609
610    /// Convert alternative format to standard format
611    fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
612        let mut policies = IndexMap::new();
613
614        // Convert tool policies
615        for (tool_name, alt_policy) in alt_config.tools {
616            let policy = if alt_policy.allow {
617                ToolPolicy::Allow
618            } else {
619                ToolPolicy::Deny
620            };
621            policies.insert(tool_name, policy);
622        }
623
624        let mut config = ToolPolicyConfig {
625            version: alt_config.version,
626            available_tools: policies.keys().cloned().collect(),
627            policies,
628            constraints: alt_config.constraints,
629            mcp: McpPolicyStore::default(),
630            approval_cache: ApprovalCacheConfig::default(),
631        };
632        Self::apply_auto_allow_defaults(&mut config);
633        config
634    }
635
636    fn apply_config_policy(&mut self, tool_name: &str, policy: ToolPolicy) {
637        let canonical = canonical_tool_name(tool_name);
638        self.config.policies.insert(canonical.to_owned(), policy);
639    }
640
641    fn resolve_config_policy(tools_config: &ToolsConfig, tool_name: &str) -> ToolPolicy {
642        let canonical = canonical_tool_name(tool_name);
643
644        if let Some(policy) = tools_config.policies.get(canonical) {
645            return policy.clone();
646        }
647
648        tools_config.default_policy.clone()
649    }
650
651    /// Apply policies defined in vtcode.toml to the runtime policy manager.
652    ///
653    /// Auto-allow defaults are NOT re-applied here — they are seeded once
654    /// during `load_or_create_config`. Re-applying them after user overrides
655    /// would silently revert explicit Deny/Prompt settings in vtcode.toml.
656    pub async fn apply_tools_config(&mut self, tools_config: &ToolsConfig) -> Result<()> {
657        if self.config.available_tools.is_empty() {
658            return Ok(());
659        }
660
661        let tools: Vec<_> = self.config.available_tools.to_vec();
662        for tool in tools {
663            let canonical = canonical_tool_name(&tool);
664            if tools_config.policies.contains_key(canonical) || !self.config.policies.contains_key(canonical) {
665                self.apply_config_policy(&tool, Self::resolve_config_policy(tools_config, &tool));
666            }
667        }
668
669        self.save_config().await
670    }
671
672    /// Update the tool list and save configuration
673    pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
674        // OPTIMIZATION: Use HashSet for deduplication, then convert to sorted Vec
675        let mut canonical_tools = Vec::with_capacity(tools.len());
676        let mut seen = HashSet::with_capacity(tools.len());
677
678        for tool in tools {
679            let canonical = canonical_tool_name(&tool).to_owned();
680            if seen.insert(canonical.clone()) {
681                canonical_tools.push(canonical);
682            }
683        }
684        canonical_tools.sort();
685
686        let current_tools: HashSet<_> = self.config.policies.keys().cloned().collect();
687        let new_tools: HashSet<_> = canonical_tools
688            .iter()
689            .filter(|name| !name.starts_with("mcp::"))
690            .cloned()
691            .collect();
692
693        let mut has_changes = false;
694
695        for tool in canonical_tools
696            .iter()
697            .filter(|tool| !tool.starts_with("mcp::") && !current_tools.contains(*tool))
698        {
699            let default_policy = if AUTO_ALLOW_TOOLS.contains(&tool.as_str()) {
700                ToolPolicy::Allow
701            } else {
702                ToolPolicy::Prompt
703            };
704            self.config.policies.insert(tool.clone(), default_policy);
705            has_changes = true;
706        }
707
708        let tools_to_remove: Vec<_> = self
709            .config
710            .policies
711            .keys()
712            .filter(|tool| !new_tools.contains(*tool))
713            .cloned()
714            .collect();
715
716        for tool in tools_to_remove {
717            self.config.policies.shift_remove(&tool);
718            has_changes = true;
719        }
720
721        // Only clone if we need to compare/sort
722        let mut sorted_available = self.config.available_tools.clone();
723        sorted_available.sort();
724        if sorted_available != canonical_tools {
725            self.config.available_tools = canonical_tools;
726            has_changes = true;
727        }
728
729        Self::ensure_network_constraints(&mut self.config);
730
731        if has_changes { self.save_config().await } else { Ok(()) }
732    }
733
734    /// Synchronize MCP provider tool lists with persisted policies
735    pub async fn update_mcp_tools(&mut self, provider_tools: &HashMap<String, Vec<String>>) -> Result<()> {
736        let stored_providers: HashSet<String> = self.config.mcp.providers.keys().cloned().collect();
737        let mut has_changes = false;
738
739        // Update or insert provider entries
740        for (provider, tools) in provider_tools {
741            let entry = self.config.mcp.providers.entry(provider.clone()).or_default();
742
743            let existing_tools: HashSet<_> = entry.tools.keys().cloned().collect();
744            let advertised: HashSet<_> = tools.iter().cloned().collect();
745
746            // Add new tools with default Prompt policy
747            for tool in tools {
748                if !existing_tools.contains(tool) {
749                    entry.tools.insert(tool.clone(), ToolPolicy::Prompt);
750                    has_changes = true;
751                }
752            }
753
754            // Remove tools no longer advertised
755            for stale in existing_tools.difference(&advertised) {
756                entry.tools.shift_remove(stale);
757                has_changes = true;
758            }
759        }
760
761        // Remove providers that are no longer present
762        let advertised_providers: HashSet<String> = provider_tools.keys().cloned().collect();
763        for provider in stored_providers.difference(&advertised_providers).cloned().collect::<Vec<_>>() {
764            self.config.mcp.providers.shift_remove(provider.as_str());
765            has_changes = true;
766        }
767
768        // Seed allowlist rules for newly advertised providers so a persisted
769        // enforce-mode allowlist cannot permanently hide a connected provider.
770        // Per-tool Prompt policies still gate execution after discovery.
771        for provider in advertised_providers {
772            let rules =
773                self.config
774                    .mcp
775                    .allowlist
776                    .providers
777                    .entry(provider.clone())
778                    .or_insert_with(|| McpAllowListRules {
779                        tools: Some(vec!["*".to_string()]),
780                        logging: Some(mcp_standard_logging()),
781                        ..Default::default()
782                    });
783            if rules.logging.is_none() {
784                rules.logging = Some(mcp_standard_logging());
785                has_changes = true;
786            }
787        }
788
789        // Remove any stale MCP keys from the primary policy map
790        let stale_runtime_keys: Vec<_> = self
791            .config
792            .policies
793            .keys()
794            .filter(|name| name.starts_with("mcp::"))
795            .cloned()
796            .collect();
797
798        for key in stale_runtime_keys {
799            self.config.policies.shift_remove(&key);
800            has_changes = true;
801        }
802
803        // Refresh available tools list with MCP entries included
804        let mut available: Vec<String> = self
805            .config
806            .available_tools
807            .iter()
808            .filter(|name| !name.starts_with("mcp::"))
809            .cloned()
810            .collect();
811
812        available.extend(
813            self.config
814                .mcp
815                .providers
816                .iter()
817                .flat_map(|(provider, policy)| policy.tools.keys().map(move |tool| format!("mcp::{provider}::{tool}"))),
818        );
819
820        available.sort();
821        available.dedup();
822
823        // Check if the available tools list has actually changed
824        if self.config.available_tools != available {
825            self.config.available_tools = available;
826            has_changes = true;
827        }
828
829        if has_changes { self.save_config().await } else { Ok(()) }
830    }
831
832    /// Retrieve policy for a specific MCP tool
833    pub fn get_mcp_tool_policy(&self, provider: &str, tool: &str) -> ToolPolicy {
834        self.config
835            .mcp
836            .providers
837            .get(provider)
838            .and_then(|policy| policy.tools.get(tool))
839            .cloned()
840            .unwrap_or(ToolPolicy::Prompt)
841    }
842
843    /// Update policy for a specific MCP tool
844    pub async fn set_mcp_tool_policy(&mut self, provider: &str, tool: &str, policy: ToolPolicy) -> Result<()> {
845        // OPTIMIZATION: Use into() for cleaner conversion
846        let entry = self.config.mcp.providers.entry(provider.into()).or_default();
847        entry.tools.insert(tool.into(), policy);
848        self.save_config().await
849    }
850
851    /// Access the persisted MCP allow list configuration
852    pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
853        &self.config.mcp.allowlist
854    }
855
856    /// Replace the persisted MCP allow list configuration
857    pub async fn set_mcp_allowlist(&mut self, allowlist: McpAllowListConfig) -> Result<()> {
858        self.config.mcp.allowlist = allowlist;
859        self.save_config().await
860    }
861
862    /// Get policy for a specific tool
863    pub fn get_policy(&self, tool_name: &str) -> ToolPolicy {
864        let canonical = canonical_tool_name(tool_name);
865        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
866            return self.get_mcp_tool_policy(&provider, &tool);
867        }
868
869        self.config.policies.get(canonical).cloned().unwrap_or(ToolPolicy::Prompt)
870    }
871
872    /// Get optional constraints for a specific tool
873    pub fn get_constraints(&self, tool_name: &str) -> Option<&ToolConstraints> {
874        let canonical = canonical_tool_name(tool_name);
875        self.config.constraints.get(canonical)
876    }
877
878    /// Check if tool should be executed based on policy
879    pub async fn should_execute_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
880        if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
881            return match self.get_mcp_tool_policy(&provider, &tool) {
882                ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
883                ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
884                ToolPolicy::Prompt => {
885                    if ToolPolicyManager::is_auto_allow_tool(tool_name) {
886                        self.set_mcp_tool_policy(&provider, &tool, ToolPolicy::Allow).await?;
887                        Ok(ToolExecutionDecision::Allowed)
888                    } else {
889                        // Use permission handler if available
890                        if let Some(ref mut handler) = self.permission_handler {
891                            handler.prompt_tool_permission(tool_name)
892                        } else {
893                            tracing::warn!(
894                                "Prompt policy for tool '{tool_name}' denied: no permission handler configured"
895                            );
896                            Ok(ToolExecutionDecision::Denied)
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                    tracing::warn!("Prompt policy for tool '{tool_name}' denied: no permission handler configured");
919                    Ok(ToolExecutionDecision::Denied)
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.policies.insert(canonical.clone(), policy.clone());
952        self.save_config().await?;
953        self.persist_policy_to_workspace_config(&canonical, policy)
954    }
955
956    pub(crate) async fn seed_default_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
957        let canonical = canonical_tool_name(tool_name).to_owned();
958        self.config.policies.insert(canonical, policy);
959        self.save_config().await
960    }
961
962    /// Reset all tools to prompt
963    pub async fn reset_all_to_prompt(&mut self) -> Result<()> {
964        for policy in self.config.policies.values_mut() {
965            *policy = ToolPolicy::Prompt;
966        }
967        for provider in self.config.mcp.providers.values_mut() {
968            for policy in provider.tools.values_mut() {
969                *policy = ToolPolicy::Prompt;
970            }
971        }
972        self.config.approval_cache.allowed.clear();
973        self.config.approval_cache.prefixes.clear();
974        self.config.approval_cache.regexes.clear();
975        self.save_config().await
976    }
977
978    /// Allow all tools
979    pub async fn allow_all_tools(&mut self) -> Result<()> {
980        self.allow_all_tools_for_tools(&[]).await
981    }
982
983    pub async fn allow_all_tools_for_tools(&mut self, all_tools: &[String]) -> Result<()> {
984        for policy in self.config.policies.values_mut() {
985            *policy = ToolPolicy::Allow;
986        }
987        for provider in self.config.mcp.providers.values_mut() {
988            for policy in provider.tools.values_mut() {
989                *policy = ToolPolicy::Allow;
990            }
991        }
992        for tool in all_tools {
993            let canonical = canonical_tool_name(tool);
994            if !self.config.policies.contains_key(canonical) {
995                self.config.policies.insert(canonical.to_owned(), ToolPolicy::Allow);
996            }
997        }
998        self.save_config().await
999    }
1000
1001    /// Deny all tools
1002    pub async fn deny_all_tools(&mut self) -> Result<()> {
1003        for policy in self.config.policies.values_mut() {
1004            *policy = ToolPolicy::Deny;
1005        }
1006        for provider in self.config.mcp.providers.values_mut() {
1007            for policy in provider.tools.values_mut() {
1008                *policy = ToolPolicy::Deny;
1009            }
1010        }
1011        self.config.approval_cache.allowed.clear();
1012        self.config.approval_cache.prefixes.clear();
1013        self.config.approval_cache.regexes.clear();
1014        self.save_config().await
1015    }
1016
1017    /// Get summary of current policies
1018    pub fn get_policy_summary(&self) -> IndexMap<String, ToolPolicy> {
1019        let mut summary = self.config.policies.clone();
1020        for (provider, policy) in &self.config.mcp.providers {
1021            for (tool, status) in &policy.tools {
1022                summary.insert(format!("mcp::{provider}::{tool}"), status.clone());
1023            }
1024        }
1025        summary
1026    }
1027
1028    /// Check whether an explicit approval key is remembered for this workspace.
1029    ///
1030    /// Performs three levels of matching:
1031    /// 1. Exact match against persisted allowed keys
1032    /// 2. Word-prefix match: checks if the approval key starts with any persisted prefix
1033    /// 3. Regex match against persisted regex patterns
1034    pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
1035        // Exact match: simplest and fastest
1036        if self.config.approval_cache.allowed.contains(approval_key) {
1037            return true;
1038        }
1039
1040        // Word-prefix match: check if any cached key is a word-prefix of the approval_key
1041        // e.g., "cargo check" matches "cargo check --target x86_64"
1042        for cached in &self.config.approval_cache.allowed {
1043            if cached.len() < approval_key.len()
1044                && approval_key.starts_with(cached.as_str())
1045                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
1046            {
1047                return true;
1048            }
1049        }
1050
1051        // Word-prefix match against cached prefixes. Prefer shell tokenization
1052        // so persisted command approvals preserve argument/option boundaries
1053        // instead of raw whitespace quirks from display/rendering.
1054        let (approval_text, approval_scope) = split_shell_approval_entry(approval_key);
1055        let approval_words = shell_words_lossy(approval_text);
1056        for cached in &self.config.approval_cache.prefixes {
1057            let (prefix_text, prefix_scope) = split_shell_approval_entry(cached.as_str());
1058            let prefix_words = shell_words_lossy(prefix_text);
1059            let scope_matches = prefix_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE)
1060                == approval_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
1061            if !prefix_words.is_empty()
1062                && scope_matches
1063                && prefix_words.len() <= approval_words.len()
1064                && prefix_words.iter().zip(approval_words.iter()).all(|(a, b)| a == b)
1065            {
1066                return true;
1067            }
1068        }
1069
1070        // Regex match against persisted regex patterns. Compiled regexes are
1071        // memoized per unique pattern list so we don't recompile on every check.
1072        let compiled = {
1073            let mut cache = APPROVAL_REGEX_CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1074            match &*cache {
1075                Some((patterns, regexes)) if *patterns == self.config.approval_cache.regexes => regexes.clone(),
1076                _ => {
1077                    let regexes: Vec<Regex> = self
1078                        .config
1079                        .approval_cache
1080                        .regexes
1081                        .iter()
1082                        .filter_map(|pattern| Regex::new(pattern).ok())
1083                        .collect();
1084                    *cache = Some((self.config.approval_cache.regexes.clone(), regexes.clone()));
1085                    regexes
1086                }
1087            }
1088        };
1089        compiled.iter().any(|regex| regex.is_match(approval_key))
1090    }
1091
1092    /// Find the best matching cache key for a given approval key using fuzzy prefix matching.
1093    /// Returns the longest matching prefix key if one exists.
1094    pub fn find_matching_cache_prefix(&self, approval_key: &str) -> Option<String> {
1095        let mut best_match: Option<String> = None;
1096        let mut best_len = 0usize;
1097
1098        for cached in &self.config.approval_cache.allowed {
1099            if cached.len() < approval_key.len()
1100                && approval_key.starts_with(cached.as_str())
1101                && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
1102                && cached.len() > best_len
1103            {
1104                best_len = cached.len();
1105                best_match = Some(cached.clone());
1106            }
1107        }
1108
1109        best_match
1110    }
1111
1112    /// Persist an explicit approval key for future prompts in this workspace.
1113    pub async fn add_approval_cache_key(&mut self, approval_key: impl Into<String>) -> Result<()> {
1114        if self.config.approval_cache.allowed.insert(approval_key.into()) {
1115            self.config.approval_cache.trim_to_budget();
1116            self.save_config().await?;
1117        }
1118        Ok(())
1119    }
1120
1121    /// Persist an approval key and automatically derive shorter segment-prefix keys
1122    /// so that future similar commands also match without re-prompting.
1123    ///
1124    /// For example, approving "cargo check --target x86_64" also caches
1125    /// "cargo check" as a segment prefix, so "cargo check --release" also matches.
1126    pub async fn add_approval_cache_key_with_segments(&mut self, approval_key: impl Into<String>) -> Result<()> {
1127        let key: String = approval_key.into();
1128        let mut changed = false;
1129
1130        // Add the exact key
1131        if self.config.approval_cache.allowed.insert(key.clone()) {
1132            changed = true;
1133        }
1134
1135        for prefix in derived_shell_approval_prefixes(&key) {
1136            if self.config.approval_cache.prefixes.insert(prefix) {
1137                changed = true;
1138            }
1139        }
1140
1141        if changed {
1142            self.config.approval_cache.trim_to_budget();
1143            self.save_config().await?;
1144        }
1145        Ok(())
1146    }
1147
1148    /// Persist a shell prefix approval entry for future prompts in this workspace.
1149    pub async fn add_approval_cache_prefix(&mut self, prefix_entry: impl Into<String>) -> Result<()> {
1150        if self.config.approval_cache.prefixes.insert(prefix_entry.into()) {
1151            self.config.approval_cache.trim_to_budget();
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(&self, command_words: &[String], scope_signature: &str) -> Option<String> {
1159        self.config.approval_cache.prefixes.iter().find_map(|entry| {
1160            let (prefix_text, entry_scope_signature) = split_shell_approval_entry(entry.as_str());
1161            let prefix_words = shell_words::split(prefix_text).ok()?;
1162            let entry_scope_signature = entry_scope_signature.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
1163            (entry_scope_signature == scope_signature && shell_command_words_match_prefix(command_words, &prefix_words))
1164                .then(|| entry.clone())
1165        })
1166    }
1167
1168    /// Remove all persisted approval cache entries.
1169    pub async fn clear_approval_cache(&mut self) -> Result<()> {
1170        if !self.config.approval_cache.allowed.is_empty()
1171            || !self.config.approval_cache.prefixes.is_empty()
1172            || !self.config.approval_cache.regexes.is_empty()
1173        {
1174            self.config.approval_cache.allowed.clear();
1175            self.config.approval_cache.prefixes.clear();
1176            self.config.approval_cache.regexes.clear();
1177            self.save_config().await?;
1178        }
1179        Ok(())
1180    }
1181
1182    /// Save configuration to file
1183    fn save_config(&self) -> impl Future<Output = Result<()>> + '_ {
1184        Self::write_config_with_policy(&self.config_path, &self.config, self.private_storage)
1185    }
1186
1187    fn persist_policy_to_workspace_config(&self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
1188        let Some(workspace_root) = self.workspace_root.as_ref() else {
1189            return Ok(());
1190        };
1191
1192        let config_path = workspace_root.join("vtcode.toml");
1193        let mut config = if config_path.exists() {
1194            ConfigManager::load_from_file(&config_path)
1195                .with_context(|| {
1196                    format!("Failed to load config for tool policy persistence at {}", config_path.display())
1197                })?
1198                .config()
1199                .clone()
1200        } else {
1201            VTCodeConfig::default()
1202        };
1203
1204        config.tools.policies.insert(tool_name.to_string(), policy);
1205
1206        // Policy changes are repository-controlled writes. Preserve the
1207        // trusted provider settings in their user layer instead of flattening
1208        // them into the workspace config while entering or leaving plan mode.
1209        ConfigManager::save_repository_config_to_path(&config_path, &config)
1210            .with_context(|| format!("Failed to persist tool policy to {}", config_path.display()))
1211    }
1212
1213    /// Print current policy status
1214    pub fn print_status(&self) {
1215        println!("{}", style("Tool Policy Status").cyan().bold());
1216        println!("Config file: {}", self.config_path.display());
1217        println!();
1218
1219        let summary = self.get_policy_summary();
1220
1221        if summary.is_empty() {
1222            println!("No tools configured yet.");
1223            return;
1224        }
1225
1226        let mut allow_count = 0;
1227        let mut prompt_count = 0;
1228        let mut deny_count = 0;
1229
1230        for (tool, policy) in &summary {
1231            let (status, color_name) = match policy {
1232                ToolPolicy::Allow => {
1233                    allow_count += 1;
1234                    ("ALLOW", "green")
1235                }
1236                ToolPolicy::Prompt => {
1237                    prompt_count += 1;
1238                    ("PROMPT", "cyan")
1239                }
1240                ToolPolicy::Deny => {
1241                    deny_count += 1;
1242                    ("DENY", "red")
1243                }
1244            };
1245
1246            let status_styled = match color_name {
1247                "green" => style(status).green(),
1248                "cyan" => style(status).cyan(),
1249                "red" => style(status).red(),
1250                _ => style(status),
1251            };
1252
1253            println!("  {} {}", style(format!("{tool:15}")).cyan(), status_styled);
1254        }
1255
1256        println!();
1257        println!(
1258            "Summary: {} allowed, {} prompt, {} denied",
1259            style(allow_count).green(),
1260            style(prompt_count).cyan(),
1261            style(deny_count).red()
1262        );
1263    }
1264
1265    /// Expose path of the underlying policy configuration file
1266    pub fn config_path(&self) -> &Path {
1267        &self.config_path
1268    }
1269}
1270
1271fn split_shell_approval_entry(entry: &str) -> (&str, Option<&str>) {
1272    if let Some(index) = entry.find(SHELL_APPROVAL_SCOPE_MARKER) {
1273        let (prefix, scoped) = entry.split_at(index);
1274        (prefix, Some(&scoped[1..]))
1275    } else {
1276        (entry, None)
1277    }
1278}
1279
1280fn shell_words_lossy(text: &str) -> Vec<String> {
1281    shell_words::split(text).unwrap_or_else(|_| text.split_whitespace().map(str::to_owned).collect())
1282}
1283
1284fn shell_command_words_for_approval(text: &str) -> Vec<String> {
1285    crate::command_safety::shell_parser::parse_shell_commands_tree_sitter(text)
1286        .ok()
1287        .and_then(|commands| {
1288            if commands.len() == 1 {
1289                commands.into_iter().next()
1290            } else {
1291                None
1292            }
1293        })
1294        .filter(|command| !command.is_empty())
1295        .unwrap_or_else(|| shell_words_lossy(text))
1296}
1297
1298fn is_probable_workspace_path(word: &str) -> bool {
1299    if word.is_empty() || word.starts_with('-') || word.starts_with('~') || word == "." {
1300        return false;
1301    }
1302    let trimmed = word.trim_end_matches('/');
1303    if trimmed.is_empty() {
1304        return false;
1305    }
1306
1307    let parts = if trimmed.starts_with('/') {
1308        trimmed.split('/').skip(1).collect::<Vec<_>>()
1309    } else {
1310        trimmed.split('/').collect::<Vec<_>>()
1311    };
1312
1313    !parts.is_empty()
1314        && parts
1315            .iter()
1316            .all(|part| !part.is_empty() && *part != "." && *part != ".." && !part.contains('\0'))
1317}
1318
1319fn command_looks_like_readonly_path_query(program: &str, words: &[String]) -> bool {
1320    if program.is_empty()
1321        || KNOWN_MUTATING_COMMANDS.contains(&program)
1322        || words.iter().skip(1).any(|word| MUTATING_OPTION_HINTS.contains(&word.as_str()))
1323    {
1324        return false;
1325    }
1326
1327    words.iter().skip(1).any(|word| is_probable_workspace_path(word))
1328}
1329
1330fn command_path_args<'a>(program: &str, words: &'a [String]) -> Vec<&'a str> {
1331    if !command_looks_like_readonly_path_query(program, words) {
1332        return Vec::new();
1333    }
1334
1335    words
1336        .iter()
1337        .skip(1)
1338        .filter(|word| is_probable_workspace_path(word))
1339        .map(String::as_str)
1340        .collect()
1341}
1342
1343fn derived_shell_approval_prefixes(key: &str) -> Vec<String> {
1344    let (command_text, scope_signature) = split_shell_approval_entry(key);
1345    // Opaque learned-family keys (`shell-pattern:<program> <family>`) are not
1346    // shell command text. Running the shell tokenizer on them would treat
1347    // `shell-pattern:awk README.md` as program `shell-pattern:awk` and derive a
1348    // `shell-pattern:awk` prefix that matches *every* awk family — silently
1349    // widening a scoped family approval. Family keys are matched by their exact
1350    // persisted value, so derive nothing.
1351    if command_text.starts_with("shell-pattern:") {
1352        return Vec::new();
1353    }
1354    let words = shell_command_words_for_approval(command_text);
1355    if words.len() < 2 {
1356        return Vec::new();
1357    }
1358
1359    let append_scope = |prefix: String| {
1360        if let Some(scope_signature) = scope_signature {
1361            format!("{prefix}|{scope_signature}")
1362        } else {
1363            prefix
1364        }
1365    };
1366
1367    let mut prefixes = Vec::new();
1368    if let Some(program) = words.first().map(String::as_str) {
1369        let path_args = command_path_args(program, &words);
1370        if !path_args.is_empty() {
1371            let option_words = words[1..]
1372                .iter()
1373                .filter(|word| !is_probable_workspace_path(word))
1374                .map(String::as_str)
1375                .collect::<Vec<_>>();
1376            let mut prefix_words = Vec::with_capacity(1 + option_words.len());
1377            prefix_words.push(program);
1378            prefix_words.extend(option_words);
1379            prefixes.push(append_scope(shell_words::join(prefix_words)));
1380            prefixes.push(append_scope(program.to_string()));
1381        }
1382
1383        match program {
1384            "sed" if words.len() >= 3 && words.get(1).is_some_and(|arg| arg == "-n") => {
1385                prefixes.push(append_scope("sed -n".to_string()));
1386            }
1387            "cargo" | "git" if words.len() >= 2 => {
1388                prefixes.push(append_scope(shell_words::join(words[..2].iter().map(String::as_str))));
1389            }
1390            _ => {}
1391        }
1392    }
1393
1394    prefixes.sort();
1395    prefixes.dedup();
1396    prefixes
1397}
1398
1399fn shell_command_words_match_prefix(command_words: &[String], prefix_words: &[String]) -> bool {
1400    command_words.len() >= prefix_words.len()
1401        && prefix_words
1402            .iter()
1403            .zip(command_words.iter())
1404            .all(|(prefix, command)| prefix == command)
1405}
1406
1407/// Scoped, optional constraints for a tool to align with safe defaults
1408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1409pub struct ToolConstraints {
1410    /// Whitelisted modes for tools that support modes (e.g., 'terminal')
1411    #[serde(default)]
1412    pub allowed_modes: Option<Vec<String>>,
1413    /// Cap on results for list/search-like tools
1414    #[serde(default)]
1415    pub max_results_per_call: Option<usize>,
1416    /// Cap on items scanned for file listing
1417    #[serde(default)]
1418    pub max_items_per_call: Option<usize>,
1419    /// Default response format if unspecified by caller
1420    #[serde(default)]
1421    pub default_response_format: Option<String>,
1422    /// Cap maximum bytes when reading files
1423    #[serde(default)]
1424    pub max_bytes_per_read: Option<usize>,
1425    /// Cap maximum bytes when fetching over the network
1426    #[serde(default)]
1427    pub max_response_bytes: Option<usize>,
1428    /// Allowed URL schemes for network tools
1429    #[serde(default)]
1430    pub allowed_url_schemes: Option<Vec<String>>,
1431    /// Denied URL hosts or suffixes for network tools
1432    #[serde(default)]
1433    pub denied_url_hosts: Option<Vec<String>>,
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439    use crate::config::constants::tools;
1440    use tempfile::tempdir;
1441
1442    #[test]
1443    fn test_tool_policy_config_serialization() {
1444        let mut config = ToolPolicyConfig {
1445            available_tools: vec![tools::READ_FILE.to_owned(), tools::WRITE_FILE.to_owned()],
1446            ..Default::default()
1447        };
1448        config.policies.insert(tools::READ_FILE.to_owned(), ToolPolicy::Allow);
1449        config.policies.insert(tools::WRITE_FILE.to_owned(), ToolPolicy::Prompt);
1450        config.approval_cache.allowed.insert("command_session:cargo test".to_string());
1451
1452        let json = serde_json::to_string_pretty(&config).unwrap();
1453        let deserialized: ToolPolicyConfig = serde_json::from_str(&json).unwrap();
1454
1455        assert_eq!(config.available_tools, deserialized.available_tools);
1456        assert_eq!(config.policies, deserialized.policies);
1457        assert_eq!(config.approval_cache, deserialized.approval_cache);
1458    }
1459
1460    #[test]
1461    fn code_search_policy_uses_default_and_explicit_override() {
1462        let mut tools_config = ToolsConfig {
1463            default_policy: ToolPolicy::Prompt,
1464            ..Default::default()
1465        };
1466
1467        assert_eq!(ToolPolicyManager::resolve_config_policy(&tools_config, tools::CODE_SEARCH), ToolPolicy::Allow);
1468
1469        tools_config.policies.insert(tools::CODE_SEARCH.to_string(), ToolPolicy::Deny);
1470        assert_eq!(ToolPolicyManager::resolve_config_policy(&tools_config, tools::CODE_SEARCH), ToolPolicy::Deny);
1471    }
1472
1473    #[tokio::test]
1474    async fn test_policy_updates() {
1475        let dir = tempdir().unwrap();
1476        let config_path = dir.path().join("tool-policy.json");
1477
1478        let mut config = ToolPolicyConfig {
1479            available_tools: vec!["tool1".to_owned()],
1480            ..Default::default()
1481        };
1482        config.policies.insert("tool1".to_owned(), ToolPolicy::Prompt);
1483
1484        // Save initial config
1485        let content = serde_json::to_string_pretty(&config).unwrap();
1486        std::fs::write(&config_path, content).unwrap();
1487
1488        // Load and update
1489        let mut loaded_config = ToolPolicyManager::load_or_create_config_with_policy(&config_path, false)
1490            .await
1491            .unwrap();
1492
1493        // Add new tool
1494        let new_tools = vec!["tool1".to_owned(), "tool2".to_owned()];
1495        let current_tools: HashSet<_> = loaded_config.available_tools.iter().cloned().collect();
1496
1497        for tool in &new_tools {
1498            if !current_tools.contains(tool) {
1499                loaded_config.policies.insert(tool.clone(), ToolPolicy::Prompt);
1500            }
1501        }
1502
1503        loaded_config.available_tools = new_tools;
1504
1505        assert!(loaded_config.policies.len() >= 2);
1506        assert_eq!(loaded_config.policies.get("tool2"), Some(&ToolPolicy::Prompt));
1507        assert_eq!(loaded_config.policies.get("tool1"), Some(&ToolPolicy::Prompt));
1508    }
1509
1510    #[test]
1511    fn workspace_policy_persistence_does_not_copy_trusted_provider_settings() {
1512        let dir = tempdir().expect("temp dir");
1513        let workspace = dir.path().join("workspace");
1514        std::fs::create_dir_all(&workspace).expect("workspace directory");
1515        let workspace_config = workspace.join("vtcode.toml");
1516        std::fs::write(
1517            &workspace_config,
1518            r#"
1519[[custom_providers]]
1520name = "stale-provider"
1521display_name = "Stale Provider"
1522base_url = "https://stale.example/v1"
1523model = "stale-model"
1524
1525[provider_overrides.openai]
1526models = ["stale-model"]
1527base_url = "https://stale.example/v1"
1528api_key_env = "STALE_API_KEY"
1529"#,
1530        )
1531        .expect("write workspace config");
1532
1533        let manager = ToolPolicyManager {
1534            config_path: dir.path().join("tool-policy.json"),
1535            config: ToolPolicyConfig::default(),
1536            permission_handler: None,
1537            workspace_root: Some(workspace),
1538            private_storage: false,
1539        };
1540
1541        manager
1542            .persist_policy_to_workspace_config("start_planning", ToolPolicy::Deny)
1543            .expect("persist workspace policy");
1544
1545        let saved: toml::Value =
1546            toml::from_str(&std::fs::read_to_string(workspace_config).expect("read workspace config"))
1547                .expect("parse workspace config");
1548        assert!(saved.get("custom_providers").is_none());
1549        assert!(
1550            saved
1551                .get("provider_overrides")
1552                .and_then(|value| value.get("openai"))
1553                .and_then(|value| value.get("base_url"))
1554                .is_none()
1555        );
1556        assert_eq!(
1557            saved
1558                .get("tools")
1559                .and_then(|value| value.get("policies"))
1560                .and_then(|value| value.get("start_planning"))
1561                .and_then(toml::Value::as_str),
1562            Some("deny")
1563        );
1564    }
1565
1566    #[tokio::test]
1567    async fn approval_cache_keys_round_trip() {
1568        let dir = tempdir().unwrap();
1569        let config_path = dir.path().join("tool-policy.json");
1570        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1571
1572        manager
1573            .add_approval_cache_key("cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null")
1574            .await
1575            .expect("persist approval");
1576
1577        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1578            .await
1579            .expect("reload manager");
1580        assert!(
1581            reloaded
1582                .has_approval_cache_key("cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null")
1583        );
1584    }
1585
1586    #[test]
1587    fn approval_cache_trim_drops_oldest_entries() {
1588        let mut cache = ApprovalCacheConfig::default();
1589        for index in 0..(APPROVAL_CACHE_MAX_ENTRIES + 10) {
1590            cache.allowed.insert(format!("key-{index:04}"));
1591        }
1592        assert!(cache.trim_to_budget());
1593        assert_eq!(cache.allowed.len(), APPROVAL_CACHE_MAX_ENTRIES);
1594        // Oldest entries are dropped first; the most recent survive.
1595        assert!(!cache.allowed.contains("key-0000"));
1596        assert!(cache.allowed.contains(&format!("key-{:04}", APPROVAL_CACHE_MAX_ENTRIES + 9)));
1597        // Idempotent when already within budget.
1598        assert!(!cache.trim_to_budget());
1599    }
1600
1601    #[tokio::test]
1602    async fn approval_cache_persist_trims_to_budget() {
1603        let dir = tempdir().unwrap();
1604        let config_path = dir.path().join("tool-policy.json");
1605        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1606
1607        for index in 0..(APPROVAL_CACHE_MAX_ENTRIES + 5) {
1608            manager
1609                .add_approval_cache_key(format!("persist-key-{index:04}"))
1610                .await
1611                .expect("persist approval");
1612        }
1613
1614        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1615            .await
1616            .expect("reload manager");
1617        // Oldest keys were trimmed; the most recent survive.
1618        assert!(!reloaded.has_approval_cache_key("persist-key-0000"), "oldest approval keys must be trimmed");
1619        assert!(
1620            reloaded.has_approval_cache_key(&format!("persist-key-{:04}", APPROVAL_CACHE_MAX_ENTRIES + 4)),
1621            "newest approval keys must survive the trim"
1622        );
1623    }
1624
1625    #[tokio::test]
1626    async fn approval_cache_prefixes_match_shell_prefixes() {
1627        let dir = tempdir().unwrap();
1628        let config_path = dir.path().join("tool-policy.json");
1629        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1630
1631        manager
1632            .add_approval_cache_prefix("cargo test|sandbox_permissions=\"use_default\"|additional_permissions=null")
1633            .await
1634            .expect("persist prefix");
1635
1636        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1637            .await
1638            .expect("reload manager");
1639        let command_words = vec![
1640            "cargo".to_string(),
1641            "test".to_string(),
1642            "-p".to_string(),
1643            "vtcode-core".to_string(),
1644        ];
1645
1646        assert!(
1647            reloaded
1648                .matching_shell_approval_prefix(
1649                    &command_words,
1650                    "sandbox_permissions=\"use_default\"|additional_permissions=null",
1651                )
1652                .is_some()
1653        );
1654    }
1655
1656    #[tokio::test]
1657    async fn approval_cache_key_persists_shell_token_prefixes_with_scope() {
1658        let dir = tempdir().unwrap();
1659        let config_path = dir.path().join("tool-policy.json");
1660        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1661
1662        manager
1663            .add_approval_cache_key_with_segments(
1664                "sed -n 87,140p crates/codegen/vtcode-core/src/core/agent/features.rs|sandbox_permissions=\"use_default\"|additional_permissions=null",
1665            )
1666            .await
1667            .expect("persist key");
1668
1669        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1670            .await
1671            .expect("reload manager");
1672        let command_words = vec![
1673            "sed".to_string(),
1674            "-n".to_string(),
1675            "109,250p".to_string(),
1676            "crates/codegen/vtcode-core/src/tools/tool_intent.rs".to_string(),
1677        ];
1678
1679        assert!(
1680            reloaded
1681                .matching_shell_approval_prefix(
1682                    &command_words,
1683                    "sandbox_permissions=\"use_default\"|additional_permissions=null",
1684                )
1685                .is_some()
1686        );
1687        assert!(reloaded.has_approval_cache_key(
1688            "sed -n 109,250p crates/codegen/vtcode-core/src/tools/tool_intent.rs|sandbox_permissions=\"use_default\"|additional_permissions=null"
1689        ));
1690    }
1691
1692    #[tokio::test]
1693    async fn approval_cache_path_command_options_also_persist_base_family() {
1694        let dir = tempdir().unwrap();
1695        let config_path = dir.path().join("tool-policy.json");
1696        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1697
1698        manager
1699            .add_approval_cache_key_with_segments(
1700                "ls -la /Users/me/project|sandbox_permissions=\"use_default\"|additional_permissions=null",
1701            )
1702            .await
1703            .expect("persist key");
1704
1705        assert!(manager.has_approval_cache_key(
1706            "ls /Users/me/project/docs|sandbox_permissions=\"use_default\"|additional_permissions=null"
1707        ));
1708    }
1709
1710    #[tokio::test]
1711    async fn opaque_family_keys_do_not_derive_shell_prefixes() {
1712        let dir = tempdir().unwrap();
1713        let config_path = dir.path().join("tool-policy.json");
1714        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1715
1716        manager
1717            .add_approval_cache_key_with_segments(
1718                "shell-pattern:awk README.md|sandbox_permissions=\"use_default\"|additional_permissions=null",
1719            )
1720            .await
1721            .expect("persist family key");
1722
1723        // The exact family matches...
1724        assert!(manager.has_approval_cache_key(
1725            "shell-pattern:awk README.md|sandbox_permissions=\"use_default\"|additional_permissions=null"
1726        ));
1727        // ...but a different family must not inherit a derived `shell-pattern:awk` prefix.
1728        assert!(!manager.has_approval_cache_key(
1729            "shell-pattern:awk src|sandbox_permissions=\"use_default\"|additional_permissions=null"
1730        ));
1731    }
1732
1733    #[tokio::test]
1734    async fn approval_cache_key_does_not_cross_permission_scope() {
1735        let dir = tempdir().unwrap();
1736        let config_path = dir.path().join("tool-policy.json");
1737        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1738
1739        manager
1740            .add_approval_cache_key_with_segments(
1741                "sed -n 87,140p crates/codegen/vtcode-core/src/core/agent/features.rs|sandbox_permissions=\"use_default\"|additional_permissions=null",
1742            )
1743            .await
1744            .expect("persist key");
1745
1746        assert!(!manager.has_approval_cache_key(
1747            "sed -n 109,250p crates/codegen/vtcode-core/src/tools/tool_intent.rs|sandbox_permissions=\"require_escalated\"|additional_permissions=null"
1748        ));
1749    }
1750
1751    #[test]
1752    fn approval_prefix_derivation_uses_bash_parser_for_quoted_args() {
1753        let prefixes = derived_shell_approval_prefixes(
1754            "sed -n '87,140p' crates/codegen/vtcode-core/src/core/agent/features.rs|sandbox_permissions=\"use_default\"|additional_permissions=null",
1755        );
1756
1757        assert!(
1758            prefixes
1759                .iter()
1760                .any(|prefix| { prefix == "sed -n|sandbox_permissions=\"use_default\"|additional_permissions=null" })
1761        );
1762    }
1763
1764    #[test]
1765    fn approval_prefix_derivation_handles_absolute_ls_path() {
1766        let prefixes = derived_shell_approval_prefixes(
1767            "ls /Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/.claude/|sandbox_permissions=\"use_default\"|additional_permissions=null",
1768        );
1769
1770        assert!(
1771            prefixes
1772                .iter()
1773                .any(|prefix| { prefix == "ls|sandbox_permissions=\"use_default\"|additional_permissions=null" })
1774        );
1775    }
1776
1777    #[test]
1778    fn approval_prefix_derivation_generalizes_non_mutating_path_commands() {
1779        let prefixes = derived_shell_approval_prefixes(
1780            "wc -l src/lib.rs README.md|sandbox_permissions=\"use_default\"|additional_permissions=null",
1781        );
1782
1783        assert!(
1784            prefixes
1785                .iter()
1786                .any(|prefix| { prefix == "wc|sandbox_permissions=\"use_default\"|additional_permissions=null" })
1787        );
1788        assert!(
1789            derived_shell_approval_prefixes(
1790                "rm src/lib.rs|sandbox_permissions=\"use_default\"|additional_permissions=null"
1791            )
1792            .is_empty()
1793        );
1794        assert!(
1795            derived_shell_approval_prefixes(
1796                "perl -i -pe 's/a/b/' src/lib.rs|sandbox_permissions=\"use_default\"|additional_permissions=null"
1797            )
1798            .is_empty()
1799        );
1800    }
1801
1802    #[tokio::test]
1803    async fn approval_cache_regexes_match_keys() {
1804        let dir = tempdir().unwrap();
1805        let config_path = dir.path().join("tool-policy.json");
1806        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1807
1808        manager
1809            .config
1810            .approval_cache
1811            .regexes
1812            .insert("^cargo (check|fmt)\\|sandbox_permissions=\\\"use_default\\\".*$".to_string());
1813        manager.save_config().await.expect("save regex");
1814
1815        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1816            .await
1817            .expect("reload manager");
1818        assert!(
1819            reloaded
1820                .has_approval_cache_key("cargo check|sandbox_permissions=\"use_default\"|additional_permissions=null")
1821        );
1822    }
1823
1824    #[tokio::test]
1825    async fn reset_to_prompt_clears_approval_cache() {
1826        let dir = tempdir().unwrap();
1827        let config_path = dir.path().join("tool-policy.json");
1828        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1829
1830        manager.add_approval_cache_key("read_file").await.expect("persist approval");
1831        manager
1832            .add_approval_cache_prefix("cargo check|sandbox_permissions=\"use_default\"|additional_permissions=null")
1833            .await
1834            .expect("persist prefix");
1835        manager.config.approval_cache.regexes.insert("^cargo check.*$".to_string());
1836        manager.reset_all_to_prompt().await.expect("reset policies");
1837
1838        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1839            .await
1840            .expect("reload manager");
1841        assert!(!reloaded.has_approval_cache_key("read_file"));
1842        assert!(reloaded.config.approval_cache.prefixes.is_empty());
1843        assert!(reloaded.config.approval_cache.regexes.is_empty());
1844    }
1845
1846    #[tokio::test]
1847    async fn apply_tools_config_preserves_existing_policies_for_unconfigured_tools() {
1848        let dir = tempdir().unwrap();
1849        let config_path = dir.path().join("tool-policy.json");
1850        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1851
1852        manager.config.available_tools = vec![tools::EXEC_COMMAND.to_owned(), tools::CODE_SEARCH.to_owned()];
1853        manager
1854            .config
1855            .policies
1856            .insert(tools::EXEC_COMMAND.to_owned(), ToolPolicy::Allow);
1857        manager.config.policies.insert(tools::CODE_SEARCH.to_owned(), ToolPolicy::Allow);
1858        manager.save_config().await.expect("save initial config");
1859
1860        let tools_config = ToolsConfig {
1861            default_policy: ToolPolicy::Prompt,
1862            ..Default::default()
1863        };
1864
1865        manager.apply_tools_config(&tools_config).await.expect("apply config");
1866
1867        assert_eq!(manager.get_policy(tools::EXEC_COMMAND), ToolPolicy::Allow);
1868        assert_eq!(manager.get_policy(tools::CODE_SEARCH), ToolPolicy::Allow);
1869    }
1870
1871    #[tokio::test]
1872    async fn apply_tools_config_applies_explicit_overrides_from_vtcode_toml() {
1873        let dir = tempdir().unwrap();
1874        let config_path = dir.path().join("tool-policy.json");
1875        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1876
1877        manager.config.available_tools = vec![tools::EXEC_COMMAND.to_owned()];
1878        manager
1879            .config
1880            .policies
1881            .insert(tools::EXEC_COMMAND.to_owned(), ToolPolicy::Allow);
1882        manager.save_config().await.expect("save initial config");
1883
1884        let mut tools_config = ToolsConfig::default();
1885        tools_config.policies.insert(tools::EXEC_COMMAND.to_string(), ToolPolicy::Deny);
1886
1887        manager.apply_tools_config(&tools_config).await.expect("apply config");
1888
1889        assert_eq!(manager.get_policy(tools::EXEC_COMMAND), ToolPolicy::Deny);
1890    }
1891
1892    #[tokio::test]
1893    async fn update_mcp_tools_seeds_allowlist_rules_for_new_provider() {
1894        let dir = tempdir().unwrap();
1895        let config_path = dir.path().join("tool-policy.json");
1896        let mut manager = ToolPolicyManager::new_with_config_path(&config_path).await.expect("manager");
1897        assert!(manager.mcp_allowlist().enforce);
1898        assert!(!manager.mcp_allowlist().is_tool_allowed("deepwiki", "ask_question"));
1899
1900        let mut provider_tools = HashMap::new();
1901        provider_tools.insert("deepwiki".to_string(), vec!["ask_question".to_string()]);
1902
1903        manager.update_mcp_tools(&provider_tools).await.expect("update MCP tools");
1904
1905        assert!(manager.mcp_allowlist().is_tool_allowed("deepwiki", "ask_question"));
1906        assert!(manager.mcp_allowlist().is_tool_allowed("deepwiki", "read_wiki_structure"));
1907
1908        let reloaded = ToolPolicyManager::new_with_config_path(&config_path)
1909            .await
1910            .expect("reload manager");
1911        assert!(reloaded.mcp_allowlist().is_tool_allowed("deepwiki", "ask_question"));
1912    }
1913}