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::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
29static 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 tools::REQUEST_USER_INPUT,
42 tools::UNIFIED_EXEC,
45 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#[derive(Debug, Clone, PartialEq)]
80pub enum ToolExecutionDecision {
81 Allowed,
83 Denied,
85 DeniedWithFeedback(String),
87}
88
89impl ToolExecutionDecision {
90 pub fn is_allowed(&self) -> bool {
91 matches!(self, Self::Allowed)
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ToolPolicyConfig {
98 pub version: u32,
100 pub available_tools: Vec<String>,
102 pub policies: IndexMap<String, ToolPolicy>,
104 #[serde(default)]
106 pub constraints: IndexMap<String, ToolConstraints>,
107 #[serde(default)]
109 pub mcp: McpPolicyStore,
110 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ApprovalCacheConfig {
131 #[serde(default)]
133 pub allowed: IndexSet<String>,
134 #[serde(default)]
136 pub prefixes: IndexSet<String>,
137 #[serde(default)]
139 pub regexes: IndexSet<String>,
140}
141
142pub const APPROVAL_CACHE_MAX_ENTRIES: usize = 256;
147
148impl ApprovalCacheConfig {
149 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#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct McpPolicyStore {
166 #[serde(default = "default_secure_mcp_allowlist")]
168 pub allowlist: McpAllowListConfig,
169 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
185pub struct McpProviderPolicy {
186 #[serde(default)]
187 pub tools: IndexMap<String, ToolPolicy>,
188}
189
190const 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#[inline]
210fn mcp_standard_logging() -> Vec<String> {
211 MCP_LOGGING_EVENTS.iter().map(|s| (*s).into()).collect()
212}
213
214#[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#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct AlternativeToolPolicyConfig {
328 pub version: u32,
330 pub default: AlternativeDefaultPolicy,
332 pub tools: IndexMap<String, AlternativeToolPolicy>,
334 #[serde(default)]
336 pub constraints: IndexMap<String, ToolConstraints>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct AlternativeDefaultPolicy {
342 pub allow: bool,
344 pub rate_limit_per_run: u32,
346 pub max_concurrent: u32,
348 pub fs_write: bool,
350 pub network: bool,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct AlternativeToolPolicy {
357 pub allow: bool,
359 #[serde(default)]
361 pub fs_write: bool,
362 #[serde(default)]
364 pub network: bool,
365 #[serde(default)]
367 pub args_policy: Option<AlternativeArgsPolicy>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct AlternativeArgsPolicy {
373 pub deny_substrings: Vec<String>,
375}
376
377pub trait PermissionPromptHandler: Send + Sync {
382 fn prompt_tool_permission(&mut self, tool_name: &str) -> Result<ToolExecutionDecision>;
384}
385
386pub 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 Self {
400 config_path: self.config_path.clone(),
401 config: self.config.clone(),
402 permission_handler: None, workspace_root: self.workspace_root.clone(),
404 private_storage: self.private_storage,
405 }
406 }
407}
408
409impl ToolPolicyManager {
410 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 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 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 pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
467 self.permission_handler = Some(handler);
468 }
469
470 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 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 if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
524 return Ok(Self::convert_from_alternative(alt_config));
526 }
527
528 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 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 }
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 fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
612 let mut policies = IndexMap::new();
613
614 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 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 pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
674 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 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 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 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 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 for stale in existing_tools.difference(&advertised) {
756 entry.tools.shift_remove(stale);
757 has_changes = true;
758 }
759 }
760
761 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 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 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 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 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 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 pub async fn set_mcp_tool_policy(&mut self, provider: &str, tool: &str, policy: ToolPolicy) -> Result<()> {
845 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 pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
853 &self.config.mcp.allowlist
854 }
855
856 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 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 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 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 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 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 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.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 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 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 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 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 pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
1035 if self.config.approval_cache.allowed.contains(approval_key) {
1037 return true;
1038 }
1039
1040 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1409pub struct ToolConstraints {
1410 #[serde(default)]
1412 pub allowed_modes: Option<Vec<String>>,
1413 #[serde(default)]
1415 pub max_results_per_call: Option<usize>,
1416 #[serde(default)]
1418 pub max_items_per_call: Option<usize>,
1419 #[serde(default)]
1421 pub default_response_format: Option<String>,
1422 #[serde(default)]
1424 pub max_bytes_per_read: Option<usize>,
1425 #[serde(default)]
1427 pub max_response_bytes: Option<usize>,
1428 #[serde(default)]
1430 pub allowed_url_schemes: Option<Vec<String>>,
1431 #[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 let content = serde_json::to_string_pretty(&config).unwrap();
1486 std::fs::write(&config_path, content).unwrap();
1487
1488 let mut loaded_config = ToolPolicyManager::load_or_create_config_with_policy(&config_path, false)
1490 .await
1491 .unwrap();
1492
1493 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 assert!(!cache.allowed.contains("key-0000"));
1596 assert!(cache.allowed.contains(&format!("key-{:04}", APPROVAL_CACHE_MAX_ENTRIES + 9)));
1597 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 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 assert!(manager.has_approval_cache_key(
1725 "shell-pattern:awk README.md|sandbox_permissions=\"use_default\"|additional_permissions=null"
1726 ));
1727 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}