1use 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 "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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum ToolPolicy {
48 Allow,
50 #[default]
52 Prompt,
53 Deny,
55}
56
57#[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#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolPolicyConfig {
74 pub version: u32,
76 pub available_tools: Vec<String>,
78 pub policies: IndexMap<String, ToolPolicy>,
80 #[serde(default)]
82 pub constraints: IndexMap<String, ToolConstraints>,
83 #[serde(default)]
85 pub mcp: McpPolicyStore,
86 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ApprovalCacheConfig {
107 #[serde(default)]
109 pub allowed: IndexSet<String>,
110 #[serde(default)]
112 pub prefixes: IndexSet<String>,
113 #[serde(default)]
115 pub regexes: IndexSet<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpPolicyStore {
121 #[serde(default = "default_secure_mcp_allowlist")]
123 pub allowlist: McpAllowListConfig,
124 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct McpProviderPolicy {
141 #[serde(default)]
142 pub tools: IndexMap<String, ToolPolicy>,
143}
144
145const 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#[inline]
165fn mcp_standard_logging() -> Vec<String> {
166 MCP_LOGGING_EVENTS.iter().map(|s| (*s).into()).collect()
167}
168
169#[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#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct AlternativeToolPolicyConfig {
314 pub version: u32,
316 pub default: AlternativeDefaultPolicy,
318 pub tools: IndexMap<String, AlternativeToolPolicy>,
320 #[serde(default)]
322 pub constraints: IndexMap<String, ToolConstraints>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct AlternativeDefaultPolicy {
328 pub allow: bool,
330 pub rate_limit_per_run: u32,
332 pub max_concurrent: u32,
334 pub fs_write: bool,
336 pub network: bool,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct AlternativeToolPolicy {
343 pub allow: bool,
345 #[serde(default)]
347 pub fs_write: bool,
348 #[serde(default)]
350 pub network: bool,
351 #[serde(default)]
353 pub args_policy: Option<AlternativeArgsPolicy>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct AlternativeArgsPolicy {
359 pub deny_substrings: Vec<String>,
361}
362
363pub trait PermissionPromptHandler: Send + Sync {
368 fn prompt_tool_permission(&mut self, tool_name: &str) -> Result<ToolExecutionDecision>;
370}
371
372pub 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 Self {
385 config_path: self.config_path.clone(),
386 config: self.config.clone(),
387 permission_handler: None, workspace_root: self.workspace_root.clone(),
389 }
390 }
391}
392
393impl ToolPolicyManager {
394 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 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 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 pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
448 self.permission_handler = Some(handler);
449 }
450
451 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 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 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 if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
495 return Ok(Self::convert_from_alternative(alt_config));
497 }
498
499 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 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 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 }
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 fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
583 let mut policies = IndexMap::new();
584
585 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 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 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 pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
657 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 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 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 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 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 for stale in existing_tools.difference(&advertised) {
751 entry.tools.shift_remove(stale);
752 has_changes = true;
753 }
754 }
755
756 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 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 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 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 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 pub async fn set_mcp_tool_policy(
832 &mut self,
833 provider: &str,
834 tool: &str,
835 policy: ToolPolicy,
836 ) -> Result<()> {
837 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 pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
850 &self.config.mcp.allowlist
851 }
852
853 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 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 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 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 if let Some(ref mut handler) = self.permission_handler {
893 handler.prompt_tool_permission(tool_name)
894 } else {
895 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 if let Some(ref mut handler) = self.permission_handler {
916 handler.prompt_tool_permission(tool_name)
917 } else {
918 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 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 Ok(ToolExecutionDecision::Allowed)
941 }
942 }
943
944 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 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 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 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 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 pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
1031 if self.config.approval_cache.allowed.contains(approval_key) {
1033 return true;
1034 }
1035
1036 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 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 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 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 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 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 if self.config.approval_cache.allowed.insert(key.clone()) {
1119 changed = true;
1120 }
1121
1122 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 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 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 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 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 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1320pub struct ToolConstraints {
1321 #[serde(default)]
1323 pub allowed_modes: Option<Vec<String>>,
1324 #[serde(default)]
1326 pub max_results_per_call: Option<usize>,
1327 #[serde(default)]
1329 pub max_items_per_call: Option<usize>,
1330 #[serde(default)]
1332 pub default_response_format: Option<String>,
1333 #[serde(default)]
1335 pub max_bytes_per_read: Option<usize>,
1336 #[serde(default)]
1338 pub max_response_bytes: Option<usize>,
1339 #[serde(default)]
1341 pub allowed_url_schemes: Option<Vec<String>>,
1342 #[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 let content = serde_json::to_string_pretty(&config).unwrap();
1393 std::fs::write(&config_path, content).unwrap();
1394
1395 let mut loaded_config = ToolPolicyManager::load_or_create_config(&config_path)
1397 .await
1398 .unwrap();
1399
1400 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}