1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4
5use crate::constants::{defaults, tools};
6use crate::core::plugins::PluginRuntimeConfig;
7
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct ToolsConfig {
12 #[serde(default = "default_tool_policy")]
14 pub default_policy: ToolPolicy,
15
16 #[serde(default)]
18 #[cfg_attr(
19 feature = "schema",
20 schemars(with = "std::collections::BTreeMap<String, ToolPolicy>")
21 )]
22 pub policies: IndexMap<String, ToolPolicy>,
23
24 #[serde(default = "default_max_tool_loops")]
31 pub max_tool_loops: usize,
32
33 #[serde(default = "default_max_repeated_tool_calls")]
36 pub max_repeated_tool_calls: usize,
37
38 #[serde(default = "default_max_consecutive_blocked_tool_calls_per_turn")]
41 pub max_consecutive_blocked_tool_calls_per_turn: usize,
42
43 #[serde(default = "default_max_tool_rate_per_second")]
46 pub max_tool_rate_per_second: Option<usize>,
47
48 #[serde(default = "default_max_sequential_spool_chunk_reads")]
51 pub max_sequential_spool_chunk_reads: usize,
52
53 #[serde(default)]
55 pub web_fetch: WebFetchConfig,
56
57 #[serde(default)]
59 pub plugins: PluginRuntimeConfig,
60
61 #[serde(default)]
63 pub editor: EditorToolConfig,
64
65 #[serde(default)]
69 pub loop_thresholds: IndexMap<String, usize>,
70}
71
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct EditorToolConfig {
76 #[serde(default = "default_editor_enabled")]
78 pub enabled: bool,
79
80 #[serde(default)]
82 pub preferred_editor: String,
83
84 #[serde(default = "default_editor_suspend_tui")]
86 pub suspend_tui: bool,
87}
88
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct WebFetchConfig {
93 #[serde(default = "default_web_fetch_mode")]
95 pub mode: String,
96
97 #[serde(default)]
99 pub dynamic_blocklist_enabled: bool,
100
101 #[serde(default)]
103 pub dynamic_blocklist_path: String,
104
105 #[serde(default)]
107 pub dynamic_whitelist_enabled: bool,
108
109 #[serde(default)]
111 pub dynamic_whitelist_path: String,
112
113 #[serde(default)]
115 pub blocked_domains: Vec<String>,
116
117 #[serde(default)]
119 pub allowed_domains: Vec<String>,
120
121 #[serde(default)]
123 pub blocked_patterns: Vec<String>,
124
125 #[serde(default)]
127 pub enable_audit_logging: bool,
128
129 #[serde(default)]
131 pub audit_log_path: String,
132
133 #[serde(default = "default_strict_https")]
135 pub strict_https_only: bool,
136}
137
138impl Default for ToolsConfig {
139 fn default() -> Self {
140 let policies = DEFAULT_TOOL_POLICIES
141 .iter()
142 .map(|(tool, policy)| ((*tool).into(), *policy))
143 .collect::<IndexMap<_, _>>();
144 Self {
145 default_policy: default_tool_policy(),
146 policies,
147 max_tool_loops: default_max_tool_loops(),
148 max_repeated_tool_calls: default_max_repeated_tool_calls(),
149 max_consecutive_blocked_tool_calls_per_turn:
150 default_max_consecutive_blocked_tool_calls_per_turn(),
151 max_tool_rate_per_second: default_max_tool_rate_per_second(),
152 max_sequential_spool_chunk_reads: default_max_sequential_spool_chunk_reads(),
153 web_fetch: WebFetchConfig::default(),
154 plugins: PluginRuntimeConfig::default(),
155 editor: EditorToolConfig::default(),
156 loop_thresholds: IndexMap::new(),
157 }
158 }
159}
160
161impl ToolsConfig {
162 #[inline]
163 pub fn tool_loop_limit_reached(&self, completed_tool_loops: usize) -> bool {
164 tool_loop_limit_reached(completed_tool_loops, self.max_tool_loops)
165 }
166
167 #[inline]
168 pub fn tool_call_delay(&self) -> Option<Duration> {
169 tool_call_delay_for_rate(self.max_tool_rate_per_second)
170 }
171}
172
173#[inline]
174pub const fn tool_loop_limit_reached(completed_tool_loops: usize, max_tool_loops: usize) -> bool {
175 max_tool_loops > 0 && completed_tool_loops >= max_tool_loops
176}
177
178#[inline]
179pub fn tool_call_delay_for_rate(max_per_second: Option<usize>) -> Option<Duration> {
180 let rate = max_per_second?;
181 if rate == 0 {
182 return None;
183 }
184
185 let nanos = 1_000_000_000u64.saturating_div(rate as u64).max(1);
186 Some(Duration::from_nanos(nanos))
187}
188
189const DEFAULT_BLOCKLIST_PATH: &str = "~/.vtcode/web_fetch_blocklist.json";
190const DEFAULT_WHITELIST_PATH: &str = "~/.vtcode/web_fetch_whitelist.json";
191const DEFAULT_AUDIT_LOG_PATH: &str = "~/.vtcode/web_fetch_audit.log";
192
193impl Default for WebFetchConfig {
194 fn default() -> Self {
195 Self {
196 mode: default_web_fetch_mode(),
197 dynamic_blocklist_enabled: false,
198 dynamic_blocklist_path: DEFAULT_BLOCKLIST_PATH.into(),
199 dynamic_whitelist_enabled: false,
200 dynamic_whitelist_path: DEFAULT_WHITELIST_PATH.into(),
201 blocked_domains: Vec::new(),
202 allowed_domains: Vec::new(),
203 blocked_patterns: Vec::new(),
204 enable_audit_logging: false,
205 audit_log_path: DEFAULT_AUDIT_LOG_PATH.into(),
206 strict_https_only: true,
207 }
208 }
209}
210
211impl Default for EditorToolConfig {
212 fn default() -> Self {
213 Self {
214 enabled: default_editor_enabled(),
215 preferred_editor: String::new(),
216 suspend_tui: default_editor_suspend_tui(),
217 }
218 }
219}
220
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
224#[serde(rename_all = "lowercase")]
225pub enum ToolPolicy {
226 Allow,
228 Prompt,
230 Deny,
232}
233
234#[inline]
235const fn default_tool_policy() -> ToolPolicy {
236 ToolPolicy::Prompt
237}
238
239#[inline]
240const fn default_max_tool_loops() -> usize {
241 defaults::DEFAULT_MAX_TOOL_LOOPS
242}
243
244#[inline]
245const fn default_max_repeated_tool_calls() -> usize {
246 defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS
247}
248
249#[inline]
250const fn default_max_consecutive_blocked_tool_calls_per_turn() -> usize {
251 defaults::DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN
252}
253
254#[inline]
255const fn default_max_tool_rate_per_second() -> Option<usize> {
256 None
257}
258
259#[inline]
260const fn default_max_sequential_spool_chunk_reads() -> usize {
261 defaults::DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN
262}
263
264#[inline]
265fn default_web_fetch_mode() -> String {
266 "restricted".into()
267}
268
269fn default_strict_https() -> bool {
270 true
271}
272
273#[inline]
274const fn default_editor_enabled() -> bool {
275 true
276}
277
278#[inline]
279const fn default_editor_suspend_tui() -> bool {
280 true
281}
282
283const DEFAULT_TOOL_POLICIES: &[(&str, ToolPolicy)] = &[
284 (tools::UNIFIED_SEARCH, ToolPolicy::Allow),
286 (tools::READ_FILE, ToolPolicy::Allow),
288 (tools::WRITE_FILE, ToolPolicy::Allow),
290 (tools::EDIT_FILE, ToolPolicy::Allow),
291 (tools::CREATE_FILE, ToolPolicy::Allow),
292 (tools::DELETE_FILE, ToolPolicy::Prompt),
294 (tools::APPLY_PATCH, ToolPolicy::Prompt),
295 (tools::SEARCH_REPLACE, ToolPolicy::Prompt),
296 (tools::UNIFIED_EXEC, ToolPolicy::Prompt),
298];
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn editor_config_defaults_are_enabled() {
306 let config = ToolsConfig::default();
307 assert!(config.editor.enabled);
308 assert!(config.editor.preferred_editor.is_empty());
309 assert!(config.editor.suspend_tui);
310 }
311
312 #[test]
313 fn disabled_tool_loop_limit_never_trips() {
314 assert!(!tool_loop_limit_reached(1, 0));
315 assert!(!tool_loop_limit_reached(32, 0));
316 assert!(tool_loop_limit_reached(2, 2));
317 }
318
319 #[test]
320 fn tools_config_reports_tool_loop_limit() {
321 let config = ToolsConfig {
322 max_tool_loops: 2,
323 ..Default::default()
324 };
325
326 assert!(!config.tool_loop_limit_reached(1));
327 assert!(config.tool_loop_limit_reached(2));
328 }
329
330 #[test]
331 fn tool_call_delay_for_rate_ignores_unset_or_zero_limits() {
332 assert_eq!(tool_call_delay_for_rate(None), None);
333 assert_eq!(tool_call_delay_for_rate(Some(0)), None);
334 }
335
336 #[test]
337 fn tool_call_delay_for_rate_uses_per_second_interval() {
338 assert_eq!(
339 tool_call_delay_for_rate(Some(4)),
340 Some(Duration::from_millis(250))
341 );
342 }
343
344 #[test]
345 fn default_tool_policies_only_seed_canonical_exec_surface() {
346 let config = ToolsConfig::default();
347
348 assert_eq!(
349 config.policies.get(tools::UNIFIED_EXEC),
350 Some(&ToolPolicy::Prompt)
351 );
352 for legacy_tool in [
353 tools::RUN_PTY_CMD,
354 tools::READ_PTY_SESSION,
355 tools::LIST_PTY_SESSIONS,
356 tools::SEND_PTY_INPUT,
357 tools::CLOSE_PTY_SESSION,
358 tools::EXECUTE_CODE,
359 ] {
360 assert!(!config.policies.contains_key(legacy_tool));
361 }
362 }
363
364 #[test]
365 fn editor_config_deserializes_from_toml() {
366 let config: ToolsConfig = toml::from_str(
367 r#"
368default_policy = "prompt"
369
370[editor]
371enabled = false
372preferred_editor = "code --wait"
373suspend_tui = false
374"#,
375 )
376 .expect("tools config should parse");
377
378 assert!(!config.editor.enabled);
379 assert_eq!(config.editor.preferred_editor, "code --wait");
380 assert!(!config.editor.suspend_tui);
381 }
382}