1use anyhow::{Context, Result};
8use console::{Color as ConsoleColor, Style as ConsoleStyle, style};
9use dialoguer::{Confirm, theme::ColorfulTheme};
10use indexmap::IndexMap;
11use is_terminal::IsTerminal;
12use serde::{Deserialize, Serialize};
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::ui::theme;
17use crate::utils::ansi::{AnsiRenderer, MessageStyle};
18
19use crate::config::constants::tools;
20use crate::config::core::tools::{ToolPolicy as ConfigToolPolicy, ToolsConfig};
21
22const AUTO_ALLOW_TOOLS: &[&str] = &["run_terminal_cmd", "bash"];
23const DEFAULT_CURL_MAX_RESPONSE_BYTES: usize = 64 * 1024;
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum ToolPolicy {
29 Allow,
31 Prompt,
33 Deny,
35}
36
37impl Default for ToolPolicy {
38 fn default() -> Self {
39 ToolPolicy::Prompt
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ToolPolicyConfig {
46 pub version: u32,
48 pub available_tools: Vec<String>,
50 pub policies: IndexMap<String, ToolPolicy>,
52 #[serde(default)]
54 pub constraints: IndexMap<String, ToolConstraints>,
55}
56
57impl Default for ToolPolicyConfig {
58 fn default() -> Self {
59 Self {
60 version: 1,
61 available_tools: Vec::new(),
62 policies: IndexMap::new(),
63 constraints: IndexMap::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AlternativeToolPolicyConfig {
71 pub version: u32,
73 pub default: AlternativeDefaultPolicy,
75 pub tools: IndexMap<String, AlternativeToolPolicy>,
77 #[serde(default)]
79 pub constraints: IndexMap<String, ToolConstraints>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct AlternativeDefaultPolicy {
85 pub allow: bool,
87 pub rate_limit_per_run: u32,
89 pub max_concurrent: u32,
91 pub fs_write: bool,
93 pub network: bool,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AlternativeToolPolicy {
100 pub allow: bool,
102 #[serde(default)]
104 pub fs_write: bool,
105 #[serde(default)]
107 pub network: bool,
108 #[serde(default)]
110 pub args_policy: Option<AlternativeArgsPolicy>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct AlternativeArgsPolicy {
116 pub deny_substrings: Vec<String>,
118}
119
120#[derive(Clone)]
122pub struct ToolPolicyManager {
123 config_path: PathBuf,
124 config: ToolPolicyConfig,
125}
126
127impl ToolPolicyManager {
128 pub fn new() -> Result<Self> {
130 let config_path = Self::get_config_path()?;
131 let config = Self::load_or_create_config(&config_path)?;
132
133 Ok(Self {
134 config_path,
135 config,
136 })
137 }
138
139 pub fn new_with_workspace(workspace_root: &PathBuf) -> Result<Self> {
141 let config_path = Self::get_workspace_config_path(workspace_root)?;
142 let config = Self::load_or_create_config(&config_path)?;
143
144 Ok(Self {
145 config_path,
146 config,
147 })
148 }
149
150 fn get_config_path() -> Result<PathBuf> {
152 let home_dir = dirs::home_dir().context("Could not determine home directory")?;
153
154 let vtcode_dir = home_dir.join(".vtcode");
155 if !vtcode_dir.exists() {
156 fs::create_dir_all(&vtcode_dir).context("Failed to create ~/.vtcode directory")?;
157 }
158
159 Ok(vtcode_dir.join("tool-policy.json"))
160 }
161
162 fn get_workspace_config_path(workspace_root: &PathBuf) -> Result<PathBuf> {
164 let workspace_vtcode_dir = workspace_root.join(".vtcode");
165
166 if !workspace_vtcode_dir.exists() {
167 fs::create_dir_all(&workspace_vtcode_dir).with_context(|| {
168 format!(
169 "Failed to create workspace policy directory at {}",
170 workspace_vtcode_dir.display()
171 )
172 })?;
173 }
174
175 Ok(workspace_vtcode_dir.join("tool-policy.json"))
176 }
177
178 fn load_or_create_config(config_path: &PathBuf) -> Result<ToolPolicyConfig> {
180 if config_path.exists() {
181 let content =
182 fs::read_to_string(config_path).context("Failed to read tool policy config")?;
183
184 if let Ok(alt_config) = serde_json::from_str::<AlternativeToolPolicyConfig>(&content) {
186 return Ok(Self::convert_from_alternative(alt_config));
188 }
189
190 match serde_json::from_str(&content) {
192 Ok(mut config) => {
193 Self::apply_auto_allow_defaults(&mut config);
194 Self::ensure_network_constraints(&mut config);
195 Ok(config)
196 }
197 Err(parse_err) => {
198 eprintln!(
199 "Warning: Invalid tool policy config at {} ({}). Resetting to defaults.",
200 config_path.display(),
201 parse_err
202 );
203 Self::reset_to_default(config_path)
204 }
205 }
206 } else {
207 let mut config = ToolPolicyConfig::default();
209 Self::apply_auto_allow_defaults(&mut config);
210 Self::ensure_network_constraints(&mut config);
211 Ok(config)
212 }
213 }
214
215 fn apply_auto_allow_defaults(config: &mut ToolPolicyConfig) {
216 for tool in AUTO_ALLOW_TOOLS {
217 config
218 .policies
219 .entry((*tool).to_string())
220 .and_modify(|policy| *policy = ToolPolicy::Allow)
221 .or_insert(ToolPolicy::Allow);
222 if !config.available_tools.contains(&tool.to_string()) {
223 config.available_tools.push(tool.to_string());
224 }
225 }
226 Self::ensure_network_constraints(config);
227 }
228
229 fn ensure_network_constraints(config: &mut ToolPolicyConfig) {
230 let entry = config
231 .constraints
232 .entry(tools::CURL.to_string())
233 .or_insert_with(ToolConstraints::default);
234
235 if entry.max_response_bytes.is_none() {
236 entry.max_response_bytes = Some(DEFAULT_CURL_MAX_RESPONSE_BYTES);
237 }
238 if entry.allowed_url_schemes.is_none() {
239 entry.allowed_url_schemes = Some(vec!["https".to_string()]);
240 }
241 if entry.denied_url_hosts.is_none() {
242 entry.denied_url_hosts = Some(vec![
243 "localhost".to_string(),
244 "127.0.0.1".to_string(),
245 "0.0.0.0".to_string(),
246 "::1".to_string(),
247 ".localhost".to_string(),
248 ".local".to_string(),
249 ".internal".to_string(),
250 ".lan".to_string(),
251 ]);
252 }
253 }
254
255 fn reset_to_default(config_path: &PathBuf) -> Result<ToolPolicyConfig> {
256 let backup_path = config_path.with_extension("json.bak");
257
258 if let Err(err) = fs::rename(config_path, &backup_path) {
259 eprintln!(
260 "Warning: Unable to back up invalid tool policy config ({}). {}",
261 config_path.display(),
262 err
263 );
264 } else {
265 eprintln!(
266 "Backed up invalid tool policy config to {}",
267 backup_path.display()
268 );
269 }
270
271 let default_config = ToolPolicyConfig::default();
272 Self::write_config(config_path.as_path(), &default_config)?;
273 Ok(default_config)
274 }
275
276 fn write_config(path: &Path, config: &ToolPolicyConfig) -> Result<()> {
277 if let Some(parent) = path.parent() {
278 if !parent.exists() {
279 fs::create_dir_all(parent).with_context(|| {
280 format!(
281 "Failed to create directory for tool policy config at {}",
282 parent.display()
283 )
284 })?;
285 }
286 }
287
288 let serialized = serde_json::to_string_pretty(config)
289 .context("Failed to serialize tool policy config")?;
290
291 fs::write(path, serialized)
292 .with_context(|| format!("Failed to write tool policy config: {}", path.display()))
293 }
294
295 fn convert_from_alternative(alt_config: AlternativeToolPolicyConfig) -> ToolPolicyConfig {
297 let mut policies = IndexMap::new();
298
299 for (tool_name, alt_policy) in alt_config.tools {
301 let policy = if alt_policy.allow {
302 ToolPolicy::Allow
303 } else {
304 ToolPolicy::Deny
305 };
306 policies.insert(tool_name, policy);
307 }
308
309 let mut config = ToolPolicyConfig {
310 version: alt_config.version,
311 available_tools: policies.keys().cloned().collect(),
312 policies,
313 constraints: alt_config.constraints,
314 };
315 Self::apply_auto_allow_defaults(&mut config);
316 config
317 }
318
319 fn apply_config_policy(&mut self, tool_name: &str, policy: ConfigToolPolicy) {
320 let runtime_policy = match policy {
321 ConfigToolPolicy::Allow => ToolPolicy::Allow,
322 ConfigToolPolicy::Prompt => ToolPolicy::Prompt,
323 ConfigToolPolicy::Deny => ToolPolicy::Deny,
324 };
325
326 self.config
327 .policies
328 .insert(tool_name.to_string(), runtime_policy);
329 }
330
331 fn resolve_config_policy(tools_config: &ToolsConfig, tool_name: &str) -> ConfigToolPolicy {
332 if let Some(policy) = tools_config.policies.get(tool_name) {
333 return policy.clone();
334 }
335
336 match tool_name {
337 tools::LIST_FILES => tools_config
338 .policies
339 .get("list_dir")
340 .or_else(|| tools_config.policies.get("list_directory"))
341 .cloned(),
342 _ => None,
343 }
344 .unwrap_or_else(|| tools_config.default_policy.clone())
345 }
346
347 pub fn apply_tools_config(&mut self, tools_config: &ToolsConfig) -> Result<()> {
349 if self.config.available_tools.is_empty() {
350 return Ok(());
351 }
352
353 for tool in self.config.available_tools.clone() {
354 let config_policy = Self::resolve_config_policy(tools_config, &tool);
355 self.apply_config_policy(&tool, config_policy);
356 }
357
358 Self::apply_auto_allow_defaults(&mut self.config);
359 self.save_config()
360 }
361
362 pub fn update_available_tools(&mut self, tools: Vec<String>) -> Result<()> {
364 let current_tools: std::collections::HashSet<_> =
365 self.config.policies.keys().cloned().collect();
366 let new_tools: std::collections::HashSet<_> = tools.iter().cloned().collect();
367
368 for tool in tools.iter().filter(|tool| !current_tools.contains(*tool)) {
370 let default_policy = if AUTO_ALLOW_TOOLS.contains(&tool.as_str()) {
371 ToolPolicy::Allow
372 } else {
373 ToolPolicy::Prompt
374 };
375 self.config.policies.insert(tool.clone(), default_policy);
376 }
377
378 let tools_to_remove: Vec<_> = self
380 .config
381 .policies
382 .keys()
383 .filter(|tool| !new_tools.contains(*tool))
384 .cloned()
385 .collect();
386
387 for tool in tools_to_remove {
388 self.config.policies.shift_remove(&tool);
389 }
390
391 self.config.available_tools = tools;
393
394 Self::ensure_network_constraints(&mut self.config);
395
396 self.save_config()
397 }
398
399 pub fn get_policy(&self, tool_name: &str) -> ToolPolicy {
401 self.config
402 .policies
403 .get(tool_name)
404 .cloned()
405 .unwrap_or(ToolPolicy::Prompt)
406 }
407
408 pub fn get_constraints(&self, tool_name: &str) -> Option<&ToolConstraints> {
410 self.config.constraints.get(tool_name)
411 }
412
413 pub fn should_execute_tool(&mut self, tool_name: &str) -> Result<bool> {
415 match self.get_policy(tool_name) {
416 ToolPolicy::Allow => Ok(true),
417 ToolPolicy::Deny => Ok(false),
418 ToolPolicy::Prompt => {
419 if AUTO_ALLOW_TOOLS.contains(&tool_name) {
420 self.set_policy(tool_name, ToolPolicy::Allow)?;
421 return Ok(true);
422 }
423 let should_execute = self.prompt_user_for_tool(tool_name)?;
424 Ok(should_execute)
425 }
426 }
427 }
428
429 pub fn is_auto_allow_tool(tool_name: &str) -> bool {
430 AUTO_ALLOW_TOOLS.contains(&tool_name)
431 }
432
433 fn prompt_user_for_tool(&mut self, tool_name: &str) -> Result<bool> {
435 let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
436 let mut renderer = AnsiRenderer::stdout();
437 let banner_style = theme::banner_style();
438
439 if !interactive {
440 let message = format!(
441 "Non-interactive environment detected. Auto-approving '{}' tool.",
442 tool_name
443 );
444 renderer.line_with_style(banner_style, &message)?;
445 return Ok(true);
446 }
447
448 let header = format!("Tool Permission Request: {}", tool_name);
449 renderer.line_with_style(banner_style, &header)?;
450 renderer.line_with_style(
451 banner_style,
452 &format!("The agent wants to use the '{}' tool.", tool_name),
453 )?;
454 renderer.line_with_style(banner_style, "")?;
455 renderer.line_with_style(
456 banner_style,
457 "This decision applies to the current request only.",
458 )?;
459 renderer.line_with_style(
460 banner_style,
461 "Update the policy file or use CLI flags to change the default.",
462 )?;
463 renderer.line_with_style(banner_style, "")?;
464
465 if AUTO_ALLOW_TOOLS.contains(&tool_name) {
466 renderer.line_with_style(
467 banner_style,
468 &format!(
469 "Auto-approving '{}' tool (default trusted tool).",
470 tool_name
471 ),
472 )?;
473 return Ok(true);
474 }
475
476 let rgb = theme::banner_color();
477 let to_ansi_256 = |value: u8| -> u8 {
478 if value < 48 {
479 0
480 } else if value < 114 {
481 1
482 } else {
483 ((value - 35) / 40).min(5)
484 }
485 };
486 let rgb_to_index = |r: u8, g: u8, b: u8| -> u8 {
487 let r_idx = to_ansi_256(r);
488 let g_idx = to_ansi_256(g);
489 let b_idx = to_ansi_256(b);
490 16 + 36 * r_idx + 6 * g_idx + b_idx
491 };
492 let color_index = rgb_to_index(rgb.0, rgb.1, rgb.2);
493 let dialog_color = ConsoleColor::Color256(color_index);
494 let tinted_style = ConsoleStyle::new().for_stderr().fg(dialog_color);
495
496 let mut dialog_theme = ColorfulTheme::default();
497 dialog_theme.prompt_style = tinted_style;
498 dialog_theme.prompt_prefix = style("—".to_string()).for_stderr().fg(dialog_color);
499 dialog_theme.prompt_suffix = style("—".to_string()).for_stderr().fg(dialog_color);
500 dialog_theme.hint_style = ConsoleStyle::new().for_stderr().fg(dialog_color);
501 dialog_theme.defaults_style = dialog_theme.hint_style.clone();
502 dialog_theme.success_prefix = style("✓".to_string()).for_stderr().fg(dialog_color);
503 dialog_theme.success_suffix = style("·".to_string()).for_stderr().fg(dialog_color);
504 dialog_theme.error_prefix = style("✗".to_string()).for_stderr().fg(dialog_color);
505 dialog_theme.error_style = ConsoleStyle::new().for_stderr().fg(dialog_color);
506 dialog_theme.values_style = ConsoleStyle::new().for_stderr().fg(dialog_color);
507
508 let prompt_text = format!("Allow the agent to use '{}'?", tool_name);
509
510 match Confirm::with_theme(&dialog_theme)
511 .with_prompt(prompt_text)
512 .default(false)
513 .interact()
514 {
515 Ok(confirmed) => {
516 let message = if confirmed {
517 format!("✓ Approved: '{}' tool will run now", tool_name)
518 } else {
519 format!("✗ Denied: '{}' tool will not run", tool_name)
520 };
521 let style = if confirmed {
522 MessageStyle::Tool
523 } else {
524 MessageStyle::Error
525 };
526 renderer.line(style, &message)?;
527 Ok(confirmed)
528 }
529 Err(e) => {
530 renderer.line(
531 MessageStyle::Error,
532 &format!("Failed to read confirmation: {}", e),
533 )?;
534 Ok(false)
535 }
536 }
537 }
538
539 pub fn set_policy(&mut self, tool_name: &str, policy: ToolPolicy) -> Result<()> {
541 self.config.policies.insert(tool_name.to_string(), policy);
542 self.save_config()
543 }
544
545 pub fn reset_all_to_prompt(&mut self) -> Result<()> {
547 for policy in self.config.policies.values_mut() {
548 *policy = ToolPolicy::Prompt;
549 }
550 self.save_config()
551 }
552
553 pub fn allow_all_tools(&mut self) -> Result<()> {
555 for policy in self.config.policies.values_mut() {
556 *policy = ToolPolicy::Allow;
557 }
558 self.save_config()
559 }
560
561 pub fn deny_all_tools(&mut self) -> Result<()> {
563 for policy in self.config.policies.values_mut() {
564 *policy = ToolPolicy::Deny;
565 }
566 self.save_config()
567 }
568
569 pub fn get_policy_summary(&self) -> IndexMap<String, ToolPolicy> {
571 self.config.policies.clone()
572 }
573
574 fn save_config(&self) -> Result<()> {
576 Self::write_config(&self.config_path, &self.config)
577 }
578
579 pub fn print_status(&self) {
581 println!("{}", style("Tool Policy Status").cyan().bold());
582 println!("Config file: {}", self.config_path.display());
583 println!();
584
585 if self.config.policies.is_empty() {
586 println!("No tools configured yet.");
587 return;
588 }
589
590 let mut allow_count = 0;
591 let mut prompt_count = 0;
592 let mut deny_count = 0;
593
594 for (tool, policy) in &self.config.policies {
595 let (status, color_name) = match policy {
596 ToolPolicy::Allow => {
597 allow_count += 1;
598 ("ALLOW", "green")
599 }
600 ToolPolicy::Prompt => {
601 prompt_count += 1;
602 ("PROMPT", "yellow")
603 }
604 ToolPolicy::Deny => {
605 deny_count += 1;
606 ("DENY", "red")
607 }
608 };
609
610 let status_styled = match color_name {
611 "green" => style(status).green(),
612 "yellow" => style(status).yellow(),
613 "red" => style(status).red(),
614 _ => style(status),
615 };
616
617 println!(
618 " {} {}",
619 style(format!("{:15}", tool)).cyan(),
620 status_styled
621 );
622 }
623
624 println!();
625 println!(
626 "Summary: {} allowed, {} prompt, {} denied",
627 style(allow_count).green(),
628 style(prompt_count).yellow(),
629 style(deny_count).red()
630 );
631 }
632
633 pub fn config_path(&self) -> &Path {
635 &self.config_path
636 }
637}
638
639#[derive(Debug, Clone, Default, Serialize, Deserialize)]
641pub struct ToolConstraints {
642 #[serde(default)]
644 pub allowed_modes: Option<Vec<String>>,
645 #[serde(default)]
647 pub max_results_per_call: Option<usize>,
648 #[serde(default)]
650 pub max_items_per_call: Option<usize>,
651 #[serde(default)]
653 pub default_response_format: Option<String>,
654 #[serde(default)]
656 pub max_bytes_per_read: Option<usize>,
657 #[serde(default)]
659 pub max_response_bytes: Option<usize>,
660 #[serde(default)]
662 pub allowed_url_schemes: Option<Vec<String>>,
663 #[serde(default)]
665 pub denied_url_hosts: Option<Vec<String>>,
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::config::constants::tools;
672 use tempfile::tempdir;
673
674 #[test]
675 fn test_tool_policy_config_serialization() {
676 let mut config = ToolPolicyConfig::default();
677 config.available_tools = vec![tools::READ_FILE.to_string(), tools::WRITE_FILE.to_string()];
678 config
679 .policies
680 .insert(tools::READ_FILE.to_string(), ToolPolicy::Allow);
681 config
682 .policies
683 .insert(tools::WRITE_FILE.to_string(), ToolPolicy::Prompt);
684
685 let json = serde_json::to_string_pretty(&config).unwrap();
686 let deserialized: ToolPolicyConfig = serde_json::from_str(&json).unwrap();
687
688 assert_eq!(config.available_tools, deserialized.available_tools);
689 assert_eq!(config.policies, deserialized.policies);
690 }
691
692 #[test]
693 fn test_policy_updates() {
694 let dir = tempdir().unwrap();
695 let config_path = dir.path().join("tool-policy.json");
696
697 let mut config = ToolPolicyConfig::default();
698 config.available_tools = vec!["tool1".to_string()];
699 config
700 .policies
701 .insert("tool1".to_string(), ToolPolicy::Prompt);
702
703 let content = serde_json::to_string_pretty(&config).unwrap();
705 fs::write(&config_path, content).unwrap();
706
707 let mut loaded_config = ToolPolicyManager::load_or_create_config(&config_path).unwrap();
709
710 let new_tools = vec!["tool1".to_string(), "tool2".to_string()];
712 let current_tools: std::collections::HashSet<_> =
713 loaded_config.available_tools.iter().collect();
714
715 for tool in &new_tools {
716 if !current_tools.contains(tool) {
717 loaded_config
718 .policies
719 .insert(tool.clone(), ToolPolicy::Prompt);
720 }
721 }
722
723 loaded_config.available_tools = new_tools;
724
725 assert_eq!(loaded_config.policies.len(), 2);
726 assert_eq!(
727 loaded_config.policies.get("tool2"),
728 Some(&ToolPolicy::Prompt)
729 );
730 }
731}