Skip to main content

vtcode_config/core/
permissions.rs

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    /// Default permission for unmatched tool calls.
22    /// Options: `"ask"`, `"allow"`, `"auto"`, `"deny"`.
23    pub default: PermissionDefault,
24
25    /// Rules that allow matching tool calls without prompting.
26    ///
27    /// Recommended semantic rules: `"read"`, `"write"`, `"edit"`, `"bash"`.
28    /// Tool-name rules like `"read_file"` are normalized to semantic rules
29    /// automatically. Supports path specifiers: `"read(/src/**/*.rs)"`.
30    #[serde(default)]
31    pub allow: Vec<String>,
32
33    /// Rules that require an interactive prompt when they match.
34    /// Same syntax as [`Self::allow`].
35    #[serde(default)]
36    pub ask: Vec<String>,
37
38    /// Rules that auto-approve with classifier-backed review.
39    /// Same syntax as [`Self::allow`].
40    #[serde(default)]
41    pub auto: Vec<String>,
42
43    /// Rules that deny matching tool calls.
44    /// Same syntax as [`Self::allow`].
45    #[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
62// ---------------------------------------------------------------------------
63// Permission rule normalization
64// ---------------------------------------------------------------------------
65
66/// Check whether a rule string is already a recognized semantic rule.
67///
68/// Semantic rules operate on `PermissionRequestKind` rather than exact tool
69/// names, which means they work correctly across current and internal helper
70/// routes.
71fn is_semantic_rule(rule: &str) -> bool {
72    matches!(rule.to_ascii_lowercase().as_str(), "read" | "write" | "edit" | "bash" | "webfetch")
73}
74
75/// Normalize a tool-name permission rule to its semantic category.
76///
77/// Raw helper names like `"read_file"` are mapped to semantic rules so they
78/// correctly match the underlying operation.
79fn normalize_tool_name_to_semantic(tool_name: &str) -> String {
80    match tool_name.to_ascii_lowercase().as_str() {
81        // Read operations
82        "read_file" | "read" | "grep_file" | "grep" | "list_files" | "list" | "glob" | "listfiles" | "grepfile"
83        | "code_search" | "codesearch" => "read".to_string(),
84
85        // Write operations
86        "write_file" | "write" | "create_file" | "createfile" | "delete_file" | "deletefile" | "move_file"
87        | "movefile" | "copy_file" | "copyfile" => "write".to_string(),
88
89        // Edit operations
90        "edit_file" | "edit" | "apply_patch" | "applypatch" | "search_replace" | "searchreplace" | "file_op"
91        | "fileop" | "patch" => "edit".to_string(),
92
93        // Bash operations
94        "bash" | "shell" | "command" | "exec_command" | "execcommand" | "run_pty_cmd" | "runptycmd"
95        | "execute_code" | "executecode" => "bash".to_string(),
96
97        // Web fetch operations
98        "webfetch" | "web_fetch" | "fetch" => "webfetch".to_string(),
99
100        // Pass through unknown rules as-is (will become ExactTool)
101        other => other.to_string(),
102    }
103}
104
105/// Parse a rule string into its tool name and optional path specifier.
106///
107/// For example, `"read_file(/src/**/*.rs)"` returns `Some(("read_file", "/src/**/*.rs"))`.
108/// A rule without parentheses returns `None`.
109fn 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/// Normalize a permission rule string to its semantic form.
122///
123/// This transforms raw tool-name rules into semantic rules before compilation.
124/// For example:
125/// - `"read_file"` → `"read"`
126/// - `"read_file(/src/**/*.rs)"` → `"read(/src/**/*.rs)"`
127/// - `"mcp__server__tool"` → unchanged (MCP rules pass through)
128/// - `"read"` → unchanged (already semantic)
129#[must_use]
130pub fn normalize_permission_rule(raw: &str) -> String {
131    let trimmed = raw.trim();
132
133    // Already a semantic rule - normalize to lowercase and pass through
134    if is_semantic_rule(trimmed) {
135        return trimmed.to_ascii_lowercase();
136    }
137
138    // Already an MCP rule - pass through
139    if trimmed.starts_with("mcp__") {
140        return trimmed.to_string();
141    }
142
143    // Has path specifier - normalize the tool name part
144    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    // Raw tool name - normalize to semantic
150    normalize_tool_name_to_semantic(trimmed)
151}
152
153/// Permission system configuration - Controls command resolution, audit logging, and caching
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct PermissionsConfig {
157    /// Classifier-backed auto permission review policy and environment settings.
158    #[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    /// Rules that allow matching tool calls without prompting.
167    #[serde(default)]
168    pub allow: Vec<String>,
169
170    /// Rules that require an interactive prompt when they match.
171    #[serde(default)]
172    pub ask: Vec<String>,
173
174    /// Rules that deny matching tool calls.
175    #[serde(default)]
176    pub deny: Vec<String>,
177
178    /// Enable the enhanced permission system (resolver + audit logger + cache)
179    #[serde(default = "default_enabled")]
180    pub enabled: bool,
181
182    /// Enable command resolution to actual paths (helps identify suspicious commands)
183    #[serde(default = "default_resolve_commands")]
184    pub resolve_commands: bool,
185
186    /// Enable audit logging of all permission decisions
187    #[serde(default = "default_audit_enabled")]
188    pub audit_enabled: bool,
189
190    /// Directory for audit logs (created if not exists)
191    /// Defaults to the VT Code state directory's `audit` subdirectory.
192    #[serde(default = "default_audit_directory")]
193    pub audit_directory: String,
194
195    /// Log allowed commands to audit trail
196    #[serde(default = "default_log_allowed_commands")]
197    pub log_allowed_commands: bool,
198
199    /// Log denied commands to audit trail
200    #[serde(default = "default_log_denied_commands")]
201    pub log_denied_commands: bool,
202
203    /// Log permission prompts (when user is asked for confirmation)
204    #[serde(default = "default_log_permission_prompts")]
205    pub log_permission_prompts: bool,
206
207    /// Enable permission decision caching to avoid redundant evaluations
208    #[serde(default = "default_cache_enabled")]
209    pub cache_enabled: bool,
210
211    /// Cache time-to-live in seconds (how long to cache decisions)
212    /// Default: 300 seconds (5 minutes)
213    #[serde(default = "default_cache_ttl_seconds")]
214    pub cache_ttl_seconds: u64,
215}
216
217/// Classifier-backed auto permission review configuration.
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219#[derive(Debug, Clone, Deserialize, Serialize)]
220pub struct AutoPermissionConfig {
221    /// Optional model override for the transcript reviewer.
222    #[serde(default)]
223    pub model: String,
224
225    /// Optional model override for the prompt-injection probe.
226    #[serde(default)]
227    pub probe_model: String,
228
229    /// Experimental OpenAI Decisions tool-output probe. Direct OpenAI API-key
230    /// TUI or full-auto sessions at the standard endpoint only; billed to your OpenAI API account.
231    /// Inconclusive requests fall back to the generation probe within its deadline.
232    /// Manual approvals and tool permissions remain unchanged; no full-auto acknowledgement is needed in TUI.
233    #[serde(default)]
234    pub use_decisions_probe: bool,
235
236    /// Maximum consecutive denials before auto permission review falls back.
237    #[serde(default = "default_auto_permission_max_consecutive_denials")]
238    pub max_consecutive_denials: u32,
239
240    /// Maximum total denials before auto permission review falls back.
241    #[serde(default = "default_auto_permission_max_total_denials")]
242    pub max_total_denials: u32,
243
244    /// Drop broad code-execution allow rules while auto permission review is active.
245    #[serde(default = "default_auto_permission_drop_broad_allow_rules")]
246    pub drop_broad_allow_rules: bool,
247
248    /// Classifier block rules applied in stage 2 reasoning.
249    #[serde(default = "default_auto_permission_block_rules")]
250    pub block_rules: Vec<String>,
251
252    /// Narrow allow exceptions applied after block rules.
253    #[serde(default = "default_auto_permission_allow_exceptions")]
254    pub allow_exceptions: Vec<String>,
255
256    /// Trusted environment boundaries for the classifier.
257    #[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/// Trust-boundary configuration for auto permission review.
286#[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 // 5 minutes
362}
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        // Unknown fields are silently ignored so that a config written by a newer
468        // vtcode version does not break older binaries.
469        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        // The unknown field is ignored; defaults are used.
477        assert!(config.allow.is_empty());
478
479        // "auto" as a string value for the top-level field is still rejected
480        // because the struct field expects AutoPermissionConfig, not a list.
481        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}