1use crate::env_helpers::default_enabled;
2use serde::Deserializer;
3use serde::de::{MapAccess, Visitor};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use vtcode_commons::VtCodePaths;
7
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum PermissionDefault {
12 Ask,
13 Allow,
14 Auto,
15 Deny,
16}
17
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
20pub struct AgentPermissionsConfig {
21 pub default: PermissionDefault,
24
25 #[serde(default)]
31 pub allow: Vec<String>,
32
33 #[serde(default)]
36 pub ask: Vec<String>,
37
38 #[serde(default)]
41 pub auto: Vec<String>,
42
43 #[serde(default)]
46 pub deny: Vec<String>,
47}
48
49impl AgentPermissionsConfig {
50 #[must_use]
51 pub fn new(default: PermissionDefault) -> Self {
52 Self {
53 default,
54 allow: Vec::new(),
55 ask: Vec::new(),
56 auto: Vec::new(),
57 deny: Vec::new(),
58 }
59 }
60}
61
62fn is_semantic_rule(rule: &str) -> bool {
72 matches!(rule.to_ascii_lowercase().as_str(), "read" | "write" | "edit" | "bash" | "webfetch")
73}
74
75fn normalize_tool_name_to_semantic(tool_name: &str) -> String {
80 match tool_name.to_ascii_lowercase().as_str() {
81 "read_file" | "read" | "grep_file" | "grep" | "list_files" | "list" | "glob" | "listfiles" | "grepfile"
83 | "code_search" | "codesearch" => "read".to_string(),
84
85 "write_file" | "write" | "create_file" | "createfile" | "delete_file" | "deletefile" | "move_file"
87 | "movefile" | "copy_file" | "copyfile" => "write".to_string(),
88
89 "edit_file" | "edit" | "apply_patch" | "applypatch" | "search_replace" | "searchreplace" | "file_op"
91 | "fileop" | "patch" => "edit".to_string(),
92
93 "bash" | "shell" | "command" | "exec_command" | "execcommand" | "run_pty_cmd" | "runptycmd"
95 | "execute_code" | "executecode" => "bash".to_string(),
96
97 "webfetch" | "web_fetch" | "fetch" => "webfetch".to_string(),
99
100 other => other.to_string(),
102 }
103}
104
105fn parse_tool_specifier_parts(rule: &str) -> Option<(&str, &str)> {
110 let trimmed = rule.trim();
111 if let Some(open) = trimmed.find('(') {
112 let tool_part = trimmed[..open].trim();
113 let specifier = trimmed[open + 1..].strip_suffix(')').map(str::trim)?;
114 if !tool_part.is_empty() && !specifier.is_empty() {
115 return Some((tool_part, specifier));
116 }
117 }
118 None
119}
120
121#[must_use]
130pub fn normalize_permission_rule(raw: &str) -> String {
131 let trimmed = raw.trim();
132
133 if is_semantic_rule(trimmed) {
135 return trimmed.to_ascii_lowercase();
136 }
137
138 if trimmed.starts_with("mcp__") {
140 return trimmed.to_string();
141 }
142
143 if let Some((tool_part, specifier)) = parse_tool_specifier_parts(trimmed) {
145 let normalized_tool = normalize_tool_name_to_semantic(tool_part);
146 return format!("{normalized_tool}({specifier})");
147 }
148
149 normalize_tool_name_to_semantic(trimmed)
151}
152
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct PermissionsConfig {
157 #[serde(
159 default,
160 rename = "auto",
161 alias = "auto_permission",
162 deserialize_with = "deserialize_auto_permission_config"
163 )]
164 pub auto_permission: AutoPermissionConfig,
165
166 #[serde(default)]
168 pub allow: Vec<String>,
169
170 #[serde(default)]
172 pub ask: Vec<String>,
173
174 #[serde(default)]
176 pub deny: Vec<String>,
177
178 #[serde(default = "default_enabled")]
180 pub enabled: bool,
181
182 #[serde(default = "default_resolve_commands")]
184 pub resolve_commands: bool,
185
186 #[serde(default = "default_audit_enabled")]
188 pub audit_enabled: bool,
189
190 #[serde(default = "default_audit_directory")]
193 pub audit_directory: String,
194
195 #[serde(default = "default_log_allowed_commands")]
197 pub log_allowed_commands: bool,
198
199 #[serde(default = "default_log_denied_commands")]
201 pub log_denied_commands: bool,
202
203 #[serde(default = "default_log_permission_prompts")]
205 pub log_permission_prompts: bool,
206
207 #[serde(default = "default_cache_enabled")]
209 pub cache_enabled: bool,
210
211 #[serde(default = "default_cache_ttl_seconds")]
214 pub cache_ttl_seconds: u64,
215}
216
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219#[derive(Debug, Clone, Deserialize, Serialize)]
220pub struct AutoPermissionConfig {
221 #[serde(default)]
223 pub model: String,
224
225 #[serde(default)]
227 pub probe_model: String,
228
229 #[serde(default = "default_auto_permission_max_consecutive_denials")]
231 pub max_consecutive_denials: u32,
232
233 #[serde(default = "default_auto_permission_max_total_denials")]
235 pub max_total_denials: u32,
236
237 #[serde(default = "default_auto_permission_drop_broad_allow_rules")]
239 pub drop_broad_allow_rules: bool,
240
241 #[serde(default = "default_auto_permission_block_rules")]
243 pub block_rules: Vec<String>,
244
245 #[serde(default = "default_auto_permission_allow_exceptions")]
247 pub allow_exceptions: Vec<String>,
248
249 #[serde(default)]
251 pub environment: AutoPermissionEnvironmentConfig,
252}
253
254fn deserialize_auto_permission_config<'de, D>(deserializer: D) -> Result<AutoPermissionConfig, D::Error>
255where
256 D: Deserializer<'de>,
257{
258 struct AutoPermissionConfigVisitor;
259
260 impl<'de> Visitor<'de> for AutoPermissionConfigVisitor {
261 type Value = AutoPermissionConfig;
262
263 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter.write_str("a table of auto permission settings")
265 }
266
267 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
268 where
269 M: MapAccess<'de>,
270 {
271 AutoPermissionConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))
272 }
273 }
274
275 deserializer.deserialize_map(AutoPermissionConfigVisitor)
276}
277
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Debug, Clone, Default, Deserialize, Serialize)]
281pub struct AutoPermissionEnvironmentConfig {
282 #[serde(default)]
283 pub trusted_paths: Vec<String>,
284
285 #[serde(default)]
286 pub trusted_domains: Vec<String>,
287
288 #[serde(default)]
289 pub trusted_git_hosts: Vec<String>,
290
291 #[serde(default)]
292 pub trusted_git_orgs: Vec<String>,
293
294 #[serde(default)]
295 pub trusted_services: Vec<String>,
296}
297
298impl Default for AutoPermissionConfig {
299 fn default() -> Self {
300 Self {
301 model: String::new(),
302 probe_model: String::new(),
303 max_consecutive_denials: default_auto_permission_max_consecutive_denials(),
304 max_total_denials: default_auto_permission_max_total_denials(),
305 drop_broad_allow_rules: default_auto_permission_drop_broad_allow_rules(),
306 block_rules: default_auto_permission_block_rules(),
307 allow_exceptions: default_auto_permission_allow_exceptions(),
308 environment: AutoPermissionEnvironmentConfig::default(),
309 }
310 }
311}
312
313#[inline]
314const fn default_resolve_commands() -> bool {
315 default_enabled()
316}
317
318#[inline]
319const fn default_audit_enabled() -> bool {
320 default_enabled()
321}
322
323#[inline]
324fn default_audit_directory() -> String {
325 VtCodePaths::resolve()
326 .and_then(|paths| paths.state_path("audit"))
327 .map(|path| path.display().to_string())
328 .unwrap_or_else(|_| String::from("state/vtcode/audit"))
329}
330
331#[inline]
332const fn default_log_allowed_commands() -> bool {
333 true
334}
335
336#[inline]
337const fn default_log_denied_commands() -> bool {
338 true
339}
340
341#[inline]
342const fn default_log_permission_prompts() -> bool {
343 true
344}
345
346#[inline]
347const fn default_cache_enabled() -> bool {
348 true
349}
350
351#[inline]
352const fn default_cache_ttl_seconds() -> u64 {
353 300 }
355
356#[inline]
357const fn default_auto_permission_max_consecutive_denials() -> u32 {
358 3
359}
360
361#[inline]
362const fn default_auto_permission_max_total_denials() -> u32 {
363 20
364}
365
366#[inline]
367const fn default_auto_permission_drop_broad_allow_rules() -> bool {
368 true
369}
370
371fn default_auto_permission_block_rules() -> Vec<String> {
372 vec![
373 "Block destructive source-control actions such as force-pushes, direct pushes to protected branches, or remote branch deletion unless the user explicitly authorized that exact blast radius.".to_string(),
374 "Block remote code download-and-execute flows, including curl-or-wget pipes into interpreters or shells, and running code from freshly cloned external repositories.".to_string(),
375 "Block sending workspace contents, credentials, environment variables, or other sensitive data to external endpoints unless the user explicitly named both the destination and the data.".to_string(),
376 "Block credential hunting, secret discovery, or scanning config and environment stores for alternative tokens unless the user explicitly asked for that credential workflow.".to_string(),
377 "Block commands that bypass safety checks, disable verification, install persistence, or weaken the host security posture.".to_string(),
378 "Block destructive or production/shared-infrastructure actions unless the user explicitly authorized the target and the action.".to_string(),
379 "Block destructive actions against inferred, fuzzy-matched, or agent-selected targets when the user did not name the exact target.".to_string(),
380 ]
381}
382
383fn default_auto_permission_allow_exceptions() -> Vec<String> {
384 vec![
385 "Allow read-only tools and read-only browsing/search actions.".to_string(),
386 "Allow file edits and writes inside the current workspace when the path is not protected.".to_string(),
387 "Allow pushes only to the current session branch or configured git remotes inside the trusted environment."
388 .to_string(),
389 ]
390}
391
392impl Default for PermissionsConfig {
393 fn default() -> Self {
394 Self {
395 auto_permission: AutoPermissionConfig::default(),
396 allow: Vec::new(),
397 ask: Vec::new(),
398 deny: Vec::new(),
399 enabled: default_enabled(),
400 resolve_commands: default_resolve_commands(),
401 audit_enabled: default_audit_enabled(),
402 audit_directory: default_audit_directory(),
403 log_allowed_commands: default_log_allowed_commands(),
404 log_denied_commands: default_log_denied_commands(),
405 log_permission_prompts: default_log_permission_prompts(),
406 cache_enabled: default_cache_enabled(),
407 cache_ttl_seconds: default_cache_ttl_seconds(),
408 }
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::{AgentPermissionsConfig, PermissionDefault, PermissionsConfig};
415
416 #[test]
417 fn parses_agent_permission_defaults_and_empty_buckets() {
418 for (value, expected) in [
419 ("ask", PermissionDefault::Ask),
420 ("allow", PermissionDefault::Allow),
421 ("auto", PermissionDefault::Auto),
422 ("deny", PermissionDefault::Deny),
423 ] {
424 let config: AgentPermissionsConfig =
425 toml::from_str(&format!(r#"default = "{value}""#)).expect("agent permissions");
426 assert_eq!(config.default, expected);
427 assert!(config.allow.is_empty());
428 assert!(config.ask.is_empty());
429 assert!(config.auto.is_empty());
430 assert!(config.deny.is_empty());
431 }
432
433 let err = toml::from_str::<AgentPermissionsConfig>(r#"default = "plan""#).unwrap_err();
434 assert!(err.to_string().contains("unknown variant"));
435 }
436
437 #[test]
438 fn parses_exact_tool_rules() {
439 let config: PermissionsConfig = toml::from_str(
440 r#"
441 allow = ["read_file", "code_search"]
442 deny = ["exec_command"]
443 "#,
444 )
445 .expect("permissions config");
446
447 assert_eq!(config.allow, vec!["read_file".to_string(), "code_search".to_string()]);
448 assert_eq!(config.deny, vec!["exec_command".to_string()]);
449 }
450
451 #[test]
452 fn normalizes_code_search_rules_to_read_semantics() {
453 assert_eq!(super::normalize_permission_rule("code_search"), "read");
454 assert_eq!(super::normalize_permission_rule("code_search(/src/**)"), "read(/src/**)");
455 }
456
457 #[test]
458 fn ignores_unknown_fields_for_forward_compatibility() {
459 let removed_field = format!("default_{}", "mode");
462 let input = format!(
463 r#"
464 {removed_field} = "ask"
465 "#,
466 );
467 let config: PermissionsConfig = toml::from_str(&input).unwrap();
468 assert!(config.allow.is_empty());
470
471 let err = toml::from_str::<PermissionsConfig>(
474 r#"
475 auto = ["exec_command"]
476 "#,
477 )
478 .unwrap_err();
479 assert!(err.to_string().contains("invalid type"));
480 }
481
482 #[test]
483 fn parses_auto_permission_settings_from_canonical_auto_table() {
484 let config: PermissionsConfig = toml::from_str(
485 r#"
486 [auto]
487 model = "gpt-5-mini"
488 max_consecutive_denials = 2
489 drop_broad_allow_rules = false
490
491 [auto.environment]
492 trusted_paths = ["/work/project"]
493 trusted_domains = ["example.com"]
494 "#,
495 )
496 .expect("permissions config");
497
498 assert_eq!(config.auto_permission.model, "gpt-5-mini");
499 assert_eq!(config.auto_permission.max_consecutive_denials, 2);
500 assert!(!config.auto_permission.drop_broad_allow_rules);
501 assert_eq!(config.auto_permission.environment.trusted_paths, vec!["/work/project".to_string()]);
502 assert_eq!(config.auto_permission.environment.trusted_domains, vec!["example.com".to_string()]);
503 }
504
505 #[test]
506 fn auto_permission_defaults_are_conservative() {
507 let config = PermissionsConfig::default();
508
509 assert_eq!(config.auto_permission.max_consecutive_denials, 3);
510 assert_eq!(config.auto_permission.max_total_denials, 20);
511 assert!(config.auto_permission.drop_broad_allow_rules);
512 assert!(!config.auto_permission.block_rules.is_empty());
513 assert!(!config.auto_permission.allow_exceptions.is_empty());
514 assert!(config.auto_permission.environment.trusted_paths.is_empty());
515 }
516
517 #[test]
518 fn normalizes_read_tool_names_to_semantic_rule() {
519 for input in ["read_file", "Read_File", "READ_FILE", "read", "Read"] {
520 assert_eq!(super::normalize_permission_rule(input), "read", "input: {input}");
521 }
522 }
523
524 #[test]
525 fn normalizes_write_tool_names_to_semantic_rule() {
526 for input in ["write_file", "Write_File", "create_file", "delete_file"] {
527 assert_eq!(super::normalize_permission_rule(input), "write", "input: {input}");
528 }
529 }
530
531 #[test]
532 fn normalizes_edit_tool_names_to_semantic_rule() {
533 for input in ["edit_file", "Edit_File", "apply_patch", "file_op"] {
534 assert_eq!(super::normalize_permission_rule(input), "edit", "input: {input}");
535 }
536 }
537
538 #[test]
539 fn normalizes_bash_tool_names_to_semantic_rule() {
540 for input in ["bash", "Bash", "exec_command", "run_pty_cmd"] {
541 assert_eq!(super::normalize_permission_rule(input), "bash", "input: {input}");
542 }
543 }
544
545 #[test]
546 fn public_permission_tool_names_use_semantic_rules_when_known() {
547 assert_eq!(super::normalize_permission_rule("exec_command"), "bash");
548 assert_eq!(super::normalize_permission_rule("apply_patch"), "edit");
549 assert_eq!(super::normalize_permission_rule("code_search"), "read");
550 }
551
552 #[test]
553 fn normalizes_tool_name_with_path_specifier() {
554 assert_eq!(super::normalize_permission_rule("read_file(/src/**/*.rs)"), "read(/src/**/*.rs)");
555 assert_eq!(super::normalize_permission_rule("write_file(/docs/**)"), "write(/docs/**)");
556 }
557
558 #[test]
559 fn mcp_rules_pass_through_unchanged() {
560 assert_eq!(super::normalize_permission_rule("mcp__server__tool"), "mcp__server__tool");
561 assert_eq!(super::normalize_permission_rule("mcp__context7__*"), "mcp__context7__*");
562 }
563
564 #[test]
565 fn semantic_rules_pass_through_unchanged() {
566 for input in ["read", "write", "edit", "bash", "webfetch"] {
567 assert_eq!(super::normalize_permission_rule(input), input, "input: {input}");
568 }
569 }
570
571 #[test]
572 fn unknown_rules_pass_through_unchanged() {
573 assert_eq!(super::normalize_permission_rule("some_custom_tool"), "some_custom_tool");
574 }
575}