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)]
234 pub use_decisions_probe: bool,
235
236 #[serde(default = "default_auto_permission_max_consecutive_denials")]
238 pub max_consecutive_denials: u32,
239
240 #[serde(default = "default_auto_permission_max_total_denials")]
242 pub max_total_denials: u32,
243
244 #[serde(default = "default_auto_permission_drop_broad_allow_rules")]
246 pub drop_broad_allow_rules: bool,
247
248 #[serde(default = "default_auto_permission_block_rules")]
250 pub block_rules: Vec<String>,
251
252 #[serde(default = "default_auto_permission_allow_exceptions")]
254 pub allow_exceptions: Vec<String>,
255
256 #[serde(default)]
258 pub environment: AutoPermissionEnvironmentConfig,
259}
260
261fn deserialize_auto_permission_config<'de, D>(deserializer: D) -> Result<AutoPermissionConfig, D::Error>
262where
263 D: Deserializer<'de>,
264{
265 struct AutoPermissionConfigVisitor;
266
267 impl<'de> Visitor<'de> for AutoPermissionConfigVisitor {
268 type Value = AutoPermissionConfig;
269
270 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271 formatter.write_str("a table of auto permission settings")
272 }
273
274 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
275 where
276 M: MapAccess<'de>,
277 {
278 AutoPermissionConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))
279 }
280 }
281
282 deserializer.deserialize_map(AutoPermissionConfigVisitor)
283}
284
285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287#[derive(Debug, Clone, Default, Deserialize, Serialize)]
288pub struct AutoPermissionEnvironmentConfig {
289 #[serde(default)]
290 pub trusted_paths: Vec<String>,
291
292 #[serde(default)]
293 pub trusted_domains: Vec<String>,
294
295 #[serde(default)]
296 pub trusted_git_hosts: Vec<String>,
297
298 #[serde(default)]
299 pub trusted_git_orgs: Vec<String>,
300
301 #[serde(default)]
302 pub trusted_services: Vec<String>,
303}
304
305impl Default for AutoPermissionConfig {
306 fn default() -> Self {
307 Self {
308 model: String::new(),
309 probe_model: String::new(),
310 use_decisions_probe: false,
311 max_consecutive_denials: default_auto_permission_max_consecutive_denials(),
312 max_total_denials: default_auto_permission_max_total_denials(),
313 drop_broad_allow_rules: default_auto_permission_drop_broad_allow_rules(),
314 block_rules: default_auto_permission_block_rules(),
315 allow_exceptions: default_auto_permission_allow_exceptions(),
316 environment: AutoPermissionEnvironmentConfig::default(),
317 }
318 }
319}
320
321#[inline]
322const fn default_resolve_commands() -> bool {
323 default_enabled()
324}
325
326#[inline]
327const fn default_audit_enabled() -> bool {
328 default_enabled()
329}
330
331#[inline]
332fn default_audit_directory() -> String {
333 VtCodePaths::resolve()
334 .and_then(|paths| paths.state_path("audit"))
335 .map(|path| path.display().to_string())
336 .unwrap_or_else(|_| String::from("state/vtcode/audit"))
337}
338
339#[inline]
340const fn default_log_allowed_commands() -> bool {
341 true
342}
343
344#[inline]
345const fn default_log_denied_commands() -> bool {
346 true
347}
348
349#[inline]
350const fn default_log_permission_prompts() -> bool {
351 true
352}
353
354#[inline]
355const fn default_cache_enabled() -> bool {
356 true
357}
358
359#[inline]
360const fn default_cache_ttl_seconds() -> u64 {
361 300 }
363
364#[inline]
365const fn default_auto_permission_max_consecutive_denials() -> u32 {
366 3
367}
368
369#[inline]
370const fn default_auto_permission_max_total_denials() -> u32 {
371 20
372}
373
374#[inline]
375const fn default_auto_permission_drop_broad_allow_rules() -> bool {
376 true
377}
378
379fn default_auto_permission_block_rules() -> Vec<String> {
380 vec![
381 "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(),
382 "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(),
383 "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(),
384 "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(),
385 "Block commands that bypass safety checks, disable verification, install persistence, or weaken the host security posture.".to_string(),
386 "Block destructive or production/shared-infrastructure actions unless the user explicitly authorized the target and the action.".to_string(),
387 "Block destructive actions against inferred, fuzzy-matched, or agent-selected targets when the user did not name the exact target.".to_string(),
388 ]
389}
390
391fn default_auto_permission_allow_exceptions() -> Vec<String> {
392 vec![
393 "Allow read-only tools and read-only browsing/search actions.".to_string(),
394 "Allow file edits and writes inside the current workspace when the path is not protected.".to_string(),
395 "Allow pushes only to the current session branch or configured git remotes inside the trusted environment."
396 .to_string(),
397 ]
398}
399
400impl Default for PermissionsConfig {
401 fn default() -> Self {
402 Self {
403 auto_permission: AutoPermissionConfig::default(),
404 allow: Vec::new(),
405 ask: Vec::new(),
406 deny: Vec::new(),
407 enabled: default_enabled(),
408 resolve_commands: default_resolve_commands(),
409 audit_enabled: default_audit_enabled(),
410 audit_directory: default_audit_directory(),
411 log_allowed_commands: default_log_allowed_commands(),
412 log_denied_commands: default_log_denied_commands(),
413 log_permission_prompts: default_log_permission_prompts(),
414 cache_enabled: default_cache_enabled(),
415 cache_ttl_seconds: default_cache_ttl_seconds(),
416 }
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::{AgentPermissionsConfig, PermissionDefault, PermissionsConfig};
423
424 #[test]
425 fn parses_agent_permission_defaults_and_empty_buckets() {
426 for (value, expected) in [
427 ("ask", PermissionDefault::Ask),
428 ("allow", PermissionDefault::Allow),
429 ("auto", PermissionDefault::Auto),
430 ("deny", PermissionDefault::Deny),
431 ] {
432 let config: AgentPermissionsConfig =
433 toml::from_str(&format!(r#"default = "{value}""#)).expect("agent permissions");
434 assert_eq!(config.default, expected);
435 assert!(config.allow.is_empty());
436 assert!(config.ask.is_empty());
437 assert!(config.auto.is_empty());
438 assert!(config.deny.is_empty());
439 }
440
441 let err = toml::from_str::<AgentPermissionsConfig>(r#"default = "plan""#).unwrap_err();
442 assert!(err.to_string().contains("unknown variant"));
443 }
444
445 #[test]
446 fn parses_exact_tool_rules() {
447 let config: PermissionsConfig = toml::from_str(
448 r#"
449 allow = ["read_file", "code_search"]
450 deny = ["exec_command"]
451 "#,
452 )
453 .expect("permissions config");
454
455 assert_eq!(config.allow, vec!["read_file".to_string(), "code_search".to_string()]);
456 assert_eq!(config.deny, vec!["exec_command".to_string()]);
457 }
458
459 #[test]
460 fn normalizes_code_search_rules_to_read_semantics() {
461 assert_eq!(super::normalize_permission_rule("code_search"), "read");
462 assert_eq!(super::normalize_permission_rule("code_search(/src/**)"), "read(/src/**)");
463 }
464
465 #[test]
466 fn ignores_unknown_fields_for_forward_compatibility() {
467 let removed_field = format!("default_{}", "mode");
470 let input = format!(
471 r#"
472 {removed_field} = "ask"
473 "#,
474 );
475 let config: PermissionsConfig = toml::from_str(&input).unwrap();
476 assert!(config.allow.is_empty());
478
479 let err = toml::from_str::<PermissionsConfig>(
482 r#"
483 auto = ["exec_command"]
484 "#,
485 )
486 .unwrap_err();
487 assert!(err.to_string().contains("invalid type"));
488 }
489
490 #[test]
491 fn parses_auto_permission_settings_from_canonical_auto_table() {
492 let config: PermissionsConfig = toml::from_str(
493 r#"
494 [auto]
495 model = "gpt-5-mini"
496 use_decisions_probe = true
497 max_consecutive_denials = 2
498 drop_broad_allow_rules = false
499
500 [auto.environment]
501 trusted_paths = ["/work/project"]
502 trusted_domains = ["example.com"]
503 "#,
504 )
505 .expect("permissions config");
506
507 assert_eq!(config.auto_permission.model, "gpt-5-mini");
508 assert!(config.auto_permission.use_decisions_probe);
509 let serialized = toml::to_string(&config).expect("serialize permissions");
510 assert!(serialized.contains("[auto]"));
511 assert!(serialized.contains("use_decisions_probe = true"));
512 let roundtrip: PermissionsConfig = toml::from_str(&serialized).expect("roundtrip permissions");
513 assert!(roundtrip.auto_permission.use_decisions_probe);
514 assert_eq!(config.auto_permission.max_consecutive_denials, 2);
515 assert!(!config.auto_permission.drop_broad_allow_rules);
516 assert_eq!(config.auto_permission.environment.trusted_paths, vec!["/work/project".to_string()]);
517 assert_eq!(config.auto_permission.environment.trusted_domains, vec!["example.com".to_string()]);
518 }
519
520 #[test]
521 fn auto_permission_defaults_are_conservative() {
522 let config = PermissionsConfig::default();
523 assert!(!config.auto_permission.use_decisions_probe);
524
525 assert_eq!(config.auto_permission.max_consecutive_denials, 3);
526 assert_eq!(config.auto_permission.max_total_denials, 20);
527 assert!(config.auto_permission.drop_broad_allow_rules);
528 assert!(!config.auto_permission.block_rules.is_empty());
529 assert!(!config.auto_permission.allow_exceptions.is_empty());
530 assert!(config.auto_permission.environment.trusted_paths.is_empty());
531 }
532
533 #[cfg(feature = "schema")]
534 #[test]
535 fn decisions_probe_schema_exposes_default_off_toggle() {
536 let schema = serde_json::to_value(schemars::schema_for!(super::AutoPermissionConfig)).expect("schema");
537 let field = &schema["properties"]["use_decisions_probe"];
538 assert_eq!(field["type"], "boolean");
539 assert_eq!(field["default"], false);
540 }
541
542 #[test]
543 fn normalizes_read_tool_names_to_semantic_rule() {
544 for input in ["read_file", "Read_File", "READ_FILE", "read", "Read"] {
545 assert_eq!(super::normalize_permission_rule(input), "read", "input: {input}");
546 }
547 }
548
549 #[test]
550 fn normalizes_write_tool_names_to_semantic_rule() {
551 for input in ["write_file", "Write_File", "create_file", "delete_file"] {
552 assert_eq!(super::normalize_permission_rule(input), "write", "input: {input}");
553 }
554 }
555
556 #[test]
557 fn normalizes_edit_tool_names_to_semantic_rule() {
558 for input in ["edit_file", "Edit_File", "apply_patch", "file_op"] {
559 assert_eq!(super::normalize_permission_rule(input), "edit", "input: {input}");
560 }
561 }
562
563 #[test]
564 fn normalizes_bash_tool_names_to_semantic_rule() {
565 for input in ["bash", "Bash", "exec_command", "run_pty_cmd"] {
566 assert_eq!(super::normalize_permission_rule(input), "bash", "input: {input}");
567 }
568 }
569
570 #[test]
571 fn public_permission_tool_names_use_semantic_rules_when_known() {
572 assert_eq!(super::normalize_permission_rule("exec_command"), "bash");
573 assert_eq!(super::normalize_permission_rule("apply_patch"), "edit");
574 assert_eq!(super::normalize_permission_rule("code_search"), "read");
575 }
576
577 #[test]
578 fn normalizes_tool_name_with_path_specifier() {
579 assert_eq!(super::normalize_permission_rule("read_file(/src/**/*.rs)"), "read(/src/**/*.rs)");
580 assert_eq!(super::normalize_permission_rule("write_file(/docs/**)"), "write(/docs/**)");
581 }
582
583 #[test]
584 fn mcp_rules_pass_through_unchanged() {
585 assert_eq!(super::normalize_permission_rule("mcp__server__tool"), "mcp__server__tool");
586 assert_eq!(super::normalize_permission_rule("mcp__context7__*"), "mcp__context7__*");
587 }
588
589 #[test]
590 fn semantic_rules_pass_through_unchanged() {
591 for input in ["read", "write", "edit", "bash", "webfetch"] {
592 assert_eq!(super::normalize_permission_rule(input), input, "input: {input}");
593 }
594 }
595
596 #[test]
597 fn unknown_rules_pass_through_unchanged() {
598 assert_eq!(super::normalize_permission_rule("some_custom_tool"), "some_custom_tool");
599 }
600}