1use std::future::Future;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use dialoguer::console::style;
6use hashbrown::{HashMap, HashSet};
7use indexmap::IndexMap;
8use regex::Regex;
9
10use super::ToolPolicy;
11use super::alternative::AlternativeToolPolicyConfig;
12use super::mcp::{McpPolicyStore, mcp_standard_logging, parse_mcp_policy_key};
13use super::model::{
14 ApprovalCacheConfig, PermissionPromptHandler, ToolConstraints, ToolExecutionDecision, ToolPolicyConfig,
15};
16use super::shell_approval::{
17 derived_shell_approval_prefixes, shell_command_words_match_prefix, shell_words_lossy, split_shell_approval_entry,
18};
19use super::{APPROVAL_REGEX_CACHE, AUTO_ALLOW_TOOLS, DEFAULT_APPROVAL_SCOPE_SIGNATURE};
20use crate::config::core::tools::ToolsConfig;
21use crate::config::loader::{ConfigManager, VTCodeConfig};
22use crate::config::mcp::{McpAllowListConfig, McpAllowListRules};
23use crate::utils::error_messages::ERR_CREATE_POLICY_DIR;
24use crate::utils::file_utils::{ensure_dir_exists, read_file_with_context, write_file_atomic_with_context};
25use crate::utils::tool_name_parsing::canonical_tool_name;
26use vtcode_commons::VtCodePaths;
27use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic};
28
29pub struct ToolPolicyManager {
31 pub(crate) config_path: PathBuf,
32 pub(crate) config: ToolPolicyConfig,
33 pub(crate) permission_handler: Option<Box<dyn PermissionPromptHandler>>,
34 pub(crate) workspace_root: Option<PathBuf>,
35 pub(crate) private_storage: bool,
36}
37
38impl Clone for ToolPolicyManager {
39 fn clone(&self) -> Self {
40 Self {
43 config_path: self.config_path.clone(),
44 config: self.config.clone(),
45 permission_handler: None, workspace_root: self.workspace_root.clone(),
47 private_storage: self.private_storage,
48 }
49 }
50}
51
52impl ToolPolicyManager {
53 pub async fn new() -> Result<Self> {
55 let config_path = Self::get_config_path().await?;
56 let config = Self::load_or_create_config_with_policy(&config_path, true).await?;
57
58 Ok(Self {
59 config_path,
60 config,
61 permission_handler: None,
62 workspace_root: None,
63 private_storage: true,
64 })
65 }
66
67 pub async fn new_with_workspace(workspace_root: &Path) -> Result<Self> {
69 let config_path = Self::get_workspace_config_path(workspace_root).await?;
70 let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
71
72 Ok(Self {
73 config_path,
74 config,
75 permission_handler: None,
76 workspace_root: Some(workspace_root.to_path_buf()),
77 private_storage: false,
78 })
79 }
80
81 pub async fn new_with_config_path<P: Into<PathBuf>>(config_path: P) -> Result<Self> {
87 let config_path = config_path.into();
88
89 if let Some(parent) = config_path.parent()
90 && !tokio::fs::try_exists(parent).await.unwrap_or(false)
91 {
92 ensure_dir_exists(parent)
93 .await
94 .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
95 }
96
97 let config = Self::load_or_create_config_with_policy(&config_path, false).await?;
98
99 Ok(Self {
100 config_path,
101 config,
102 permission_handler: None,
103 workspace_root: None,
104 private_storage: false,
105 })
106 }
107
108 pub fn set_permission_handler(&mut self, handler: Box<dyn PermissionPromptHandler>) {
110 self.permission_handler = Some(handler);
111 }
112
113 async fn get_config_path() -> Result<PathBuf> {
115 let paths = VtCodePaths::resolve().context("Could not resolve VT Code paths")?;
116 paths.ensure_config_dir().context("Failed to create VT Code config directory")?;
117 paths.config_path("tool-policy.json")
118 }
119
120 async fn get_workspace_config_path(workspace_root: &Path) -> Result<PathBuf> {
122 let workspace_vtcode_dir = workspace_root.join(".vtcode");
123
124 if !tokio::fs::try_exists(&workspace_vtcode_dir).await.unwrap_or(false) {
125 ensure_dir_exists(&workspace_vtcode_dir).await.with_context(|| {
126 format!("Failed to create workspace policy directory at {}", workspace_vtcode_dir.display())
127 })?;
128 }
129
130 Ok(workspace_vtcode_dir.join("tool-policy.json"))
131 }
132
133 pub(crate) async fn load_or_create_config_with_policy(
134 config_path: &Path,
135 private_storage: bool,
136 ) -> Result<ToolPolicyConfig> {
137 let content = if private_storage {
138 match tokio::fs::symlink_metadata(config_path).await {
139 Ok(metadata) if metadata.file_type().is_symlink() => {
140 return Err(anyhow::anyhow!(
141 "Refusing to read symlinked tool policy config: {}",
142 config_path.display()
143 ));
144 }
145 Ok(metadata) if !metadata.is_file() => {
146 return Err(anyhow::anyhow!("Tool policy config is not a regular file: {}", config_path.display()));
147 }
148 Ok(_) => Some(
149 String::from_utf8(read_private_file_no_follow(config_path).await?)
150 .context("Tool policy config is not valid UTF-8")?,
151 ),
152 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
153 Err(error) => {
154 return Err(error).with_context(|| format!("Failed to inspect {}", config_path.display()));
155 }
156 }
157 } else if tokio::fs::try_exists(config_path).await.unwrap_or(false) {
158 Some(
159 read_file_with_context(config_path, "tool policy config")
160 .await
161 .context("Failed to read tool policy config")?,
162 )
163 } else {
164 None
165 };
166
167 if let Some(content) = content {
168 if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
170 return Ok(Self::convert_from_alternative(alt_config));
172 }
173
174 match serde_json::from_str(&content) {
176 Ok(mut config) => {
177 Self::apply_auto_allow_defaults(&mut config);
178 Self::ensure_network_constraints(&mut config);
179 Ok(config)
180 }
181 Err(parse_err) => {
182 tracing::warn!(
183 "Invalid tool policy config at {} ({}). Resetting to defaults.",
184 config_path.display(),
185 parse_err
186 );
187 Self::reset_to_default(config_path, private_storage).await
188 }
189 }
190 } else {
191 let mut config = ToolPolicyConfig::default();
193 Self::apply_auto_allow_defaults(&mut config);
194 Self::ensure_network_constraints(&mut config);
195 Ok(config)
196 }
197 }
198
199 fn apply_auto_allow_defaults(config: &mut ToolPolicyConfig) {
200 for &tool in AUTO_ALLOW_TOOLS {
201 if !config.policies.contains_key(tool) {
202 config.policies.insert(tool.into(), ToolPolicy::Allow);
203 }
204 if !config.available_tools.iter().any(|t| t == tool) {
205 config.available_tools.push(tool.into());
206 }
207 }
208 Self::ensure_network_constraints(config);
209 }
210
211 fn ensure_network_constraints(_config: &mut ToolPolicyConfig) {
212 }
214
215 async fn reset_to_default(config_path: &Path, private_storage: bool) -> Result<ToolPolicyConfig> {
216 let backup_path = config_path.with_extension("json.bak");
217
218 if let Err(err) = tokio::fs::rename(config_path, &backup_path).await {
219 tracing::warn!("Unable to back up invalid tool policy config ({}). {}", config_path.display(), err);
220 } else {
221 tracing::info!("Backed up invalid tool policy config to {}", backup_path.display());
222 }
223
224 let default_config = ToolPolicyConfig::default();
225 Self::write_config_with_policy(config_path, &default_config, private_storage).await?;
226 Ok(default_config)
227 }
228
229 async fn write_config_with_policy(path: &Path, config: &ToolPolicyConfig, private_storage: bool) -> Result<()> {
230 if let Some(parent) = path.parent()
231 && !tokio::fs::try_exists(parent).await.unwrap_or(false)
232 {
233 if private_storage {
234 VtCodePaths::ensure_user_dir(parent)
235 .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
236 } else {
237 ensure_dir_exists(parent)
238 .await
239 .with_context(|| format!("{} at {}", ERR_CREATE_POLICY_DIR, parent.display()))?;
240 }
241 }
242
243 let serialized = serde_json::to_string_pretty(config).context("Failed to serialize tool policy config")?;
244
245 if private_storage {
246 write_private_file_atomic(path, serialized.as_bytes())
247 .await
248 .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
249 } else {
250 write_file_atomic_with_context(path, &serialized, "tool policy config")
251 .await
252 .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
253 }
254 }
255
256 fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
258 let mut policies = IndexMap::new();
259
260 for (tool_name, alt_policy) in alt_config.tools {
262 let policy = if alt_policy.allow {
263 ToolPolicy::Allow
264 } else {
265 ToolPolicy::Deny
266 };
267 policies.insert(tool_name, policy);
268 }
269
270 let mut config = ToolPolicyConfig {
271 version: alt_config.version,
272 available_tools: policies.keys().cloned().collect(),
273 policies,
274 constraints: alt_config.constraints,
275 mcp: McpPolicyStore::default(),
276 approval_cache: ApprovalCacheConfig::default(),
277 };
278 Self::apply_auto_allow_defaults(&mut config);
279 config
280 }
281
282 fn apply_config_policy(&mut self, tool_name: &str, policy: ToolPolicy) {
283 let canonical = canonical_tool_name(tool_name);
284 self.config.policies.insert(canonical.to_owned(), policy);
285 }
286
287 pub(crate) fn resolve_config_policy(tools_config: &ToolsConfig, tool_name: &str) -> ToolPolicy {
288 let canonical = canonical_tool_name(tool_name);
289
290 if let Some(policy) = tools_config.policies.get(canonical) {
291 return policy.clone();
292 }
293
294 tools_config.default_policy.clone()
295 }
296
297 pub async fn apply_tools_config(&mut self, tools_config: &ToolsConfig) -> Result<()> {
303 if self.config.available_tools.is_empty() {
304 return Ok(());
305 }
306
307 let tools: Vec<_> = self.config.available_tools.to_vec();
308 for tool in tools {
309 let canonical = canonical_tool_name(&tool);
310 if tools_config.policies.contains_key(canonical) || !self.config.policies.contains_key(canonical) {
311 self.apply_config_policy(&tool, Self::resolve_config_policy(tools_config, &tool));
312 }
313 }
314
315 self.save_config().await
316 }
317
318 pub async fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
320 let mut canonical_tools = Vec::with_capacity(tools.len());
322 let mut seen = HashSet::with_capacity(tools.len());
323
324 for tool in tools {
325 let canonical = canonical_tool_name(&tool).to_owned();
326 if seen.insert(canonical.clone()) {
327 canonical_tools.push(canonical);
328 }
329 }
330 canonical_tools.sort();
331
332 let current_tools: HashSet<_> = self.config.policies.keys().cloned().collect();
333 let new_tools: HashSet<_> = canonical_tools
334 .iter()
335 .filter(|name| !name.starts_with("mcp::"))
336 .cloned()
337 .collect();
338
339 let mut has_changes = false;
340
341 for tool in canonical_tools
342 .iter()
343 .filter(|tool| !tool.starts_with("mcp::") && !current_tools.contains(*tool))
344 {
345 let default_policy = if AUTO_ALLOW_TOOLS.contains(&tool.as_str()) {
346 ToolPolicy::Allow
347 } else {
348 ToolPolicy::Prompt
349 };
350 self.config.policies.insert(tool.clone(), default_policy);
351 has_changes = true;
352 }
353
354 let tools_to_remove: Vec<_> = self
355 .config
356 .policies
357 .keys()
358 .filter(|tool| !new_tools.contains(*tool))
359 .cloned()
360 .collect();
361
362 for tool in tools_to_remove {
363 self.config.policies.shift_remove(&tool);
364 has_changes = true;
365 }
366
367 let mut sorted_available = self.config.available_tools.clone();
369 sorted_available.sort();
370 if sorted_available != canonical_tools {
371 self.config.available_tools = canonical_tools;
372 has_changes = true;
373 }
374
375 Self::ensure_network_constraints(&mut self.config);
376
377 if has_changes { self.save_config().await } else { Ok(()) }
378 }
379
380 pub async fn update_mcp_tools(&mut self, provider_tools: &HashMap<String, Vec<String>>) -> Result<()> {
382 let stored_providers: HashSet<String> = self.config.mcp.providers.keys().cloned().collect();
383 let mut has_changes = false;
384
385 for (provider, tools) in provider_tools {
387 let entry = self.config.mcp.providers.entry(provider.clone()).or_default();
388
389 let existing_tools: HashSet<_> = entry.tools.keys().cloned().collect();
390 let advertised: HashSet<_> = tools.iter().cloned().collect();
391
392 for tool in tools {
394 if !existing_tools.contains(tool) {
395 entry.tools.insert(tool.clone(), ToolPolicy::Prompt);
396 has_changes = true;
397 }
398 }
399
400 for stale in existing_tools.difference(&advertised) {
402 entry.tools.shift_remove(stale);
403 has_changes = true;
404 }
405 }
406
407 let advertised_providers: HashSet<String> = provider_tools.keys().cloned().collect();
409 for provider in stored_providers.difference(&advertised_providers).cloned().collect::<Vec<_>>() {
410 self.config.mcp.providers.shift_remove(provider.as_str());
411 has_changes = true;
412 }
413
414 for provider in advertised_providers {
418 let rules =
419 self.config
420 .mcp
421 .allowlist
422 .providers
423 .entry(provider.clone())
424 .or_insert_with(|| McpAllowListRules {
425 tools: Some(vec!["*".to_string()]),
426 logging: Some(mcp_standard_logging()),
427 ..Default::default()
428 });
429 if rules.logging.is_none() {
430 rules.logging = Some(mcp_standard_logging());
431 has_changes = true;
432 }
433 }
434
435 let stale_runtime_keys: Vec<_> = self
437 .config
438 .policies
439 .keys()
440 .filter(|name| name.starts_with("mcp::"))
441 .cloned()
442 .collect();
443
444 for key in stale_runtime_keys {
445 self.config.policies.shift_remove(&key);
446 has_changes = true;
447 }
448
449 let mut available: Vec<String> = self
451 .config
452 .available_tools
453 .iter()
454 .filter(|name| !name.starts_with("mcp::"))
455 .cloned()
456 .collect();
457
458 available.extend(
459 self.config
460 .mcp
461 .providers
462 .iter()
463 .flat_map(|(provider, policy)| policy.tools.keys().map(move |tool| format!("mcp::{provider}::{tool}"))),
464 );
465
466 available.sort();
467 available.dedup();
468
469 if self.config.available_tools != available {
471 self.config.available_tools = available;
472 has_changes = true;
473 }
474
475 if has_changes { self.save_config().await } else { Ok(()) }
476 }
477
478 pub fn get_mcp_tool_policy(&self, provider: &str, tool: &str) -> ToolPolicy {
480 self.config
481 .mcp
482 .providers
483 .get(provider)
484 .and_then(|policy| policy.tools.get(tool))
485 .cloned()
486 .unwrap_or(ToolPolicy::Prompt)
487 }
488
489 pub async fn set_mcp_tool_policy(&mut self, provider: &str, tool: &str, policy: ToolPolicy) -> Result<()> {
491 let entry = self.config.mcp.providers.entry(provider.into()).or_default();
493 entry.tools.insert(tool.into(), policy);
494 self.save_config().await
495 }
496
497 pub fn mcp_allowlist(&self) -> &McpAllowListConfig {
499 &self.config.mcp.allowlist
500 }
501
502 pub async fn set_mcp_allowlist(&mut self, allowlist: McpAllowListConfig) -> Result<()> {
504 self.config.mcp.allowlist = allowlist;
505 self.save_config().await
506 }
507
508 pub fn get_policy(&self, tool_name: &str) -> ToolPolicy {
510 let canonical = canonical_tool_name(tool_name);
511 if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
512 return self.get_mcp_tool_policy(&provider, &tool);
513 }
514
515 self.config.policies.get(canonical).cloned().unwrap_or(ToolPolicy::Prompt)
516 }
517
518 pub fn get_constraints(&self, tool_name: &str) -> Option<&ToolConstraints> {
520 let canonical = canonical_tool_name(tool_name);
521 self.config.constraints.get(canonical)
522 }
523
524 pub async fn should_execute_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
526 if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
527 return match self.get_mcp_tool_policy(&provider, &tool) {
528 ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
529 ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
530 ToolPolicy::Prompt => {
531 if ToolPolicyManager::is_auto_allow_tool(tool_name) {
532 self.set_mcp_tool_policy(&provider, &tool, ToolPolicy::Allow).await?;
533 Ok(ToolExecutionDecision::Allowed)
534 } else {
535 if let Some(ref mut handler) = self.permission_handler {
537 handler.prompt_tool_permission(tool_name)
538 } else {
539 tracing::warn!(
540 "Prompt policy for tool '{tool_name}' denied: no permission handler configured"
541 );
542 Ok(ToolExecutionDecision::Denied)
543 }
544 }
545 }
546 };
547 }
548
549 let canonical = canonical_tool_name(tool_name);
550
551 match self.get_policy(canonical) {
552 ToolPolicy::Allow => Ok(ToolExecutionDecision::Allowed),
553 ToolPolicy::Deny => Ok(ToolExecutionDecision::Denied),
554 ToolPolicy::Prompt => {
555 let canonical_name = canonical;
556 if AUTO_ALLOW_TOOLS.contains(&canonical_name) {
557 self.set_policy(canonical_name, ToolPolicy::Allow).await?;
558 return Ok(ToolExecutionDecision::Allowed);
559 }
560 if let Some(ref mut handler) = self.permission_handler {
562 handler.prompt_tool_permission(tool_name)
563 } else {
564 tracing::warn!("Prompt policy for tool '{tool_name}' denied: no permission handler configured");
565 Ok(ToolExecutionDecision::Denied)
566 }
567 }
568 }
569 }
570
571 pub fn is_auto_allow_tool(tool_name: &str) -> bool {
572 let canonical = canonical_tool_name(tool_name);
573 AUTO_ALLOW_TOOLS.contains(&canonical)
574 }
575
576 pub fn prompt_user_for_tool(&mut self, tool_name: &str) -> Result<ToolExecutionDecision> {
582 if let Some(ref mut handler) = self.permission_handler {
583 handler.prompt_tool_permission(tool_name)
584 } else {
585 Ok(ToolExecutionDecision::Allowed)
587 }
588 }
589
590 pub async fn set_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
592 if let Some((provider, tool)) = parse_mcp_policy_key(tool_name) {
593 return self.set_mcp_tool_policy(&provider, &tool, policy).await;
594 }
595
596 let canonical = canonical_tool_name(tool_name).to_owned();
597 self.config.policies.insert(canonical.clone(), policy.clone());
598 self.save_config().await?;
599 self.persist_policy_to_workspace_config(&canonical, policy)
600 }
601
602 pub(crate) async fn seed_default_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
603 let canonical = canonical_tool_name(tool_name).to_owned();
604 self.config.policies.insert(canonical, policy);
605 self.save_config().await
606 }
607
608 pub async fn reset_all_to_prompt(&mut self) -> Result<()> {
610 for policy in self.config.policies.values_mut() {
611 *policy = ToolPolicy::Prompt;
612 }
613 for provider in self.config.mcp.providers.values_mut() {
614 for policy in provider.tools.values_mut() {
615 *policy = ToolPolicy::Prompt;
616 }
617 }
618 self.config.approval_cache.allowed.clear();
619 self.config.approval_cache.prefixes.clear();
620 self.config.approval_cache.regexes.clear();
621 self.save_config().await
622 }
623
624 pub async fn allow_all_tools(&mut self) -> Result<()> {
626 self.allow_all_tools_for_tools(&[]).await
627 }
628
629 pub async fn allow_all_tools_for_tools(&mut self, all_tools: &[String]) -> Result<()> {
630 for policy in self.config.policies.values_mut() {
631 *policy = ToolPolicy::Allow;
632 }
633 for provider in self.config.mcp.providers.values_mut() {
634 for policy in provider.tools.values_mut() {
635 *policy = ToolPolicy::Allow;
636 }
637 }
638 for tool in all_tools {
639 let canonical = canonical_tool_name(tool);
640 if !self.config.policies.contains_key(canonical) {
641 self.config.policies.insert(canonical.to_owned(), ToolPolicy::Allow);
642 }
643 }
644 self.save_config().await
645 }
646
647 pub async fn deny_all_tools(&mut self) -> Result<()> {
649 for policy in self.config.policies.values_mut() {
650 *policy = ToolPolicy::Deny;
651 }
652 for provider in self.config.mcp.providers.values_mut() {
653 for policy in provider.tools.values_mut() {
654 *policy = ToolPolicy::Deny;
655 }
656 }
657 self.config.approval_cache.allowed.clear();
658 self.config.approval_cache.prefixes.clear();
659 self.config.approval_cache.regexes.clear();
660 self.save_config().await
661 }
662
663 pub fn get_policy_summary(&self) -> IndexMap<String, ToolPolicy> {
665 let mut summary = self.config.policies.clone();
666 for (provider, policy) in &self.config.mcp.providers {
667 for (tool, status) in &policy.tools {
668 summary.insert(format!("mcp::{provider}::{tool}"), status.clone());
669 }
670 }
671 summary
672 }
673
674 pub fn has_approval_cache_key(&self, approval_key: &str) -> bool {
681 if self.config.approval_cache.allowed.contains(approval_key) {
683 return true;
684 }
685
686 for cached in &self.config.approval_cache.allowed {
689 if cached.len() < approval_key.len()
690 && approval_key.starts_with(cached.as_str())
691 && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
692 {
693 return true;
694 }
695 }
696
697 let (approval_text, approval_scope) = split_shell_approval_entry(approval_key);
701 let approval_words = shell_words_lossy(approval_text);
702 for cached in &self.config.approval_cache.prefixes {
703 let (prefix_text, prefix_scope) = split_shell_approval_entry(cached.as_str());
704 let prefix_words = shell_words_lossy(prefix_text);
705 let scope_matches = prefix_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE)
706 == approval_scope.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
707 if !prefix_words.is_empty()
708 && scope_matches
709 && prefix_words.len() <= approval_words.len()
710 && prefix_words.iter().zip(approval_words.iter()).all(|(a, b)| a == b)
711 {
712 return true;
713 }
714 }
715
716 let compiled = {
719 let mut cache = APPROVAL_REGEX_CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
720 match &*cache {
721 Some((patterns, regexes)) if *patterns == self.config.approval_cache.regexes => regexes.clone(),
722 _ => {
723 let regexes: Vec<Regex> = self
724 .config
725 .approval_cache
726 .regexes
727 .iter()
728 .filter_map(|pattern| Regex::new(pattern).ok())
729 .collect();
730 *cache = Some((self.config.approval_cache.regexes.clone(), regexes.clone()));
731 regexes
732 }
733 }
734 };
735 compiled.iter().any(|regex| regex.is_match(approval_key))
736 }
737
738 pub fn find_matching_cache_prefix(&self, approval_key: &str) -> Option<String> {
741 let mut best_match: Option<String> = None;
742 let mut best_len = 0usize;
743
744 for cached in &self.config.approval_cache.allowed {
745 if cached.len() < approval_key.len()
746 && approval_key.starts_with(cached.as_str())
747 && approval_key.as_bytes().get(cached.len()) == Some(&b' ')
748 && cached.len() > best_len
749 {
750 best_len = cached.len();
751 best_match = Some(cached.clone());
752 }
753 }
754
755 best_match
756 }
757
758 pub async fn add_approval_cache_key(&mut self, approval_key: impl Into<String>) -> Result<()> {
760 if self.config.approval_cache.allowed.insert(approval_key.into()) {
761 self.config.approval_cache.trim_to_budget();
762 self.save_config().await?;
763 }
764 Ok(())
765 }
766
767 pub async fn add_approval_cache_key_with_segments(&mut self, approval_key: impl Into<String>) -> Result<()> {
773 let key: String = approval_key.into();
774 let mut changed = false;
775
776 if self.config.approval_cache.allowed.insert(key.clone()) {
778 changed = true;
779 }
780
781 for prefix in derived_shell_approval_prefixes(&key) {
782 if self.config.approval_cache.prefixes.insert(prefix) {
783 changed = true;
784 }
785 }
786
787 if changed {
788 self.config.approval_cache.trim_to_budget();
789 self.save_config().await?;
790 }
791 Ok(())
792 }
793
794 pub async fn add_approval_cache_prefix(&mut self, prefix_entry: impl Into<String>) -> Result<()> {
796 if self.config.approval_cache.prefixes.insert(prefix_entry.into()) {
797 self.config.approval_cache.trim_to_budget();
798 self.save_config().await?;
799 }
800 Ok(())
801 }
802
803 pub fn matching_shell_approval_prefix(&self, command_words: &[String], scope_signature: &str) -> Option<String> {
805 self.config.approval_cache.prefixes.iter().find_map(|entry| {
806 let (prefix_text, entry_scope_signature) = split_shell_approval_entry(entry.as_str());
807 let prefix_words = shell_words::split(prefix_text).ok()?;
808 let entry_scope_signature = entry_scope_signature.unwrap_or(DEFAULT_APPROVAL_SCOPE_SIGNATURE);
809 (entry_scope_signature == scope_signature && shell_command_words_match_prefix(command_words, &prefix_words))
810 .then(|| entry.clone())
811 })
812 }
813
814 pub async fn clear_approval_cache(&mut self) -> Result<()> {
816 if !self.config.approval_cache.allowed.is_empty()
817 || !self.config.approval_cache.prefixes.is_empty()
818 || !self.config.approval_cache.regexes.is_empty()
819 {
820 self.config.approval_cache.allowed.clear();
821 self.config.approval_cache.prefixes.clear();
822 self.config.approval_cache.regexes.clear();
823 self.save_config().await?;
824 }
825 Ok(())
826 }
827
828 pub(crate) fn save_config(&self) -> impl Future<Output = Result<()>> + '_ {
830 Self::write_config_with_policy(&self.config_path, &self.config, self.private_storage)
831 }
832
833 pub(crate) fn persist_policy_to_workspace_config(&self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
834 let Some(workspace_root) = self.workspace_root.as_ref() else {
835 return Ok(());
836 };
837
838 let config_path = workspace_root.join("vtcode.toml");
839 let mut config = if config_path.exists() {
840 ConfigManager::load_from_file(&config_path)
841 .with_context(|| {
842 format!("Failed to load config for tool policy persistence at {}", config_path.display())
843 })?
844 .config()
845 .clone()
846 } else {
847 VTCodeConfig::default()
848 };
849
850 config.tools.policies.insert(tool_name.to_string(), policy);
851
852 ConfigManager::save_repository_config_to_path(&config_path, &config)
856 .with_context(|| format!("Failed to persist tool policy to {}", config_path.display()))
857 }
858
859 pub fn print_status(&self) {
861 println!("{}", style("Tool Policy Status").cyan().bold());
862 println!("Config file: {}", self.config_path.display());
863 println!();
864
865 let summary = self.get_policy_summary();
866
867 if summary.is_empty() {
868 println!("No tools configured yet.");
869 return;
870 }
871
872 let mut allow_count = 0;
873 let mut prompt_count = 0;
874 let mut deny_count = 0;
875
876 for (tool, policy) in &summary {
877 let (status, color_name) = match policy {
878 ToolPolicy::Allow => {
879 allow_count += 1;
880 ("ALLOW", "green")
881 }
882 ToolPolicy::Prompt => {
883 prompt_count += 1;
884 ("PROMPT", "cyan")
885 }
886 ToolPolicy::Deny => {
887 deny_count += 1;
888 ("DENY", "red")
889 }
890 };
891
892 let status_styled = match color_name {
893 "green" => style(status).green(),
894 "cyan" => style(status).cyan(),
895 "red" => style(status).red(),
896 _ => style(status),
897 };
898
899 println!(" {} {}", style(format!("{tool:15}")).cyan(), status_styled);
900 }
901
902 println!();
903 println!(
904 "Summary: {} allowed, {} prompt, {} denied",
905 style(allow_count).green(),
906 style(prompt_count).cyan(),
907 style(deny_count).red()
908 );
909 }
910
911 pub fn config_path(&self) -> &Path {
913 &self.config_path
914 }
915}