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    /// Maximum consecutive denials before auto permission review falls back.
230    #[serde(default = "default_auto_permission_max_consecutive_denials")]
231    pub max_consecutive_denials: u32,
232
233    /// Maximum total denials before auto permission review falls back.
234    #[serde(default = "default_auto_permission_max_total_denials")]
235    pub max_total_denials: u32,
236
237    /// Drop broad code-execution allow rules while auto permission review is active.
238    #[serde(default = "default_auto_permission_drop_broad_allow_rules")]
239    pub drop_broad_allow_rules: bool,
240
241    /// Classifier block rules applied in stage 2 reasoning.
242    #[serde(default = "default_auto_permission_block_rules")]
243    pub block_rules: Vec<String>,
244
245    /// Narrow allow exceptions applied after block rules.
246    #[serde(default = "default_auto_permission_allow_exceptions")]
247    pub allow_exceptions: Vec<String>,
248
249    /// Trusted environment boundaries for the classifier.
250    #[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/// Trust-boundary configuration for auto permission review.
279#[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 // 5 minutes
354}
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        // Unknown fields are silently ignored so that a config written by a newer
460        // vtcode version does not break older binaries.
461        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        // The unknown field is ignored; defaults are used.
469        assert!(config.allow.is_empty());
470
471        // "auto" as a string value for the top-level field is still rejected
472        // because the struct field expects AutoPermissionConfig, not a list.
473        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}