Skip to main content

vtcode_config/core/
dotfile_protection.rs

1//! Dotfile protection configuration.
2//!
3//! Provides comprehensive protection for hidden configuration files (dotfiles)
4//! to prevent automatic or implicit modifications by AI agents or automated tools.
5
6use crate::env_helpers::default_true;
7use indexmap::IndexSet;
8use serde::{Deserialize, Serialize};
9use vtcode_commons::VtCodePaths;
10
11/// Dotfile protection configuration.
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct DotfileProtectionConfig {
15    /// Enable dotfile protection globally.
16    #[serde(default = "default_true")]
17    pub enabled: bool,
18
19    /// Require explicit user confirmation for any dotfile modification.
20    #[serde(default = "default_true")]
21    pub require_explicit_confirmation: bool,
22
23    /// Enable immutable audit logging of all dotfile access attempts.
24    #[serde(default = "default_true")]
25    pub audit_logging_enabled: bool,
26
27    /// Path to the audit log file.
28    #[serde(default = "default_audit_log_path")]
29    pub audit_log_path: String,
30
31    /// Prevent cascading modifications (one dotfile change triggering others).
32    #[serde(default = "default_true")]
33    pub prevent_cascading_modifications: bool,
34
35    /// Create backup before any permitted modification.
36    #[serde(default = "default_true")]
37    pub create_backups: bool,
38
39    /// Directory for storing dotfile backups.
40    #[serde(default = "default_backup_dir")]
41    pub backup_directory: String,
42
43    /// Maximum number of backups to retain per file.
44    #[serde(default = "default_max_backups")]
45    pub max_backups_per_file: usize,
46
47    /// Preserve original file permissions and ownership.
48    #[serde(default = "default_true")]
49    pub preserve_permissions: bool,
50
51    /// Whitelisted dotfiles that can be modified (after secondary confirmation).
52    #[serde(default)]
53    pub whitelist: IndexSet<String>,
54
55    /// Additional dotfile patterns to protect (beyond defaults).
56    #[serde(default)]
57    pub additional_protected_patterns: Vec<String>,
58
59    /// Block modifications during automated operations.
60    #[serde(default = "default_true")]
61    pub block_during_automation: bool,
62
63    /// Operations that trigger extra protection.
64    #[serde(default = "default_blocked_operations")]
65    pub blocked_operations: Vec<String>,
66
67    /// Secondary authentication required for whitelisted files.
68    #[serde(default = "default_true")]
69    pub require_secondary_auth_for_whitelist: bool,
70}
71
72impl Default for DotfileProtectionConfig {
73    fn default() -> Self {
74        Self {
75            enabled: default_true(),
76            require_explicit_confirmation: default_true(),
77            audit_logging_enabled: default_true(),
78            audit_log_path: default_audit_log_path(),
79            prevent_cascading_modifications: default_true(),
80            create_backups: default_true(),
81            backup_directory: default_backup_dir(),
82            max_backups_per_file: default_max_backups(),
83            preserve_permissions: default_true(),
84            whitelist: IndexSet::new(),
85            additional_protected_patterns: Vec::new(),
86            block_during_automation: default_true(),
87            blocked_operations: default_blocked_operations(),
88            require_secondary_auth_for_whitelist: default_true(),
89        }
90    }
91}
92
93/// Default protected dotfile patterns.
94///
95/// These patterns match common configuration files that should never be
96/// modified automatically by AI agents or automated tools.
97const DEFAULT_PROTECTED_DOTFILES: &[&str] = &[
98    // Git configuration
99    ".gitignore",
100    ".gitattributes",
101    ".gitmodules",
102    ".gitconfig",
103    ".git-credentials",
104    // Editor configuration
105    ".editorconfig",
106    ".vscode/*",
107    ".idea/*",
108    ".cursor/*",
109    // Environment files
110    ".env",
111    ".env.local",
112    ".env.development",
113    ".env.production",
114    ".env.test",
115    ".env.*",
116    // Docker
117    ".dockerignore",
118    ".docker/*",
119    // Node.js/JavaScript
120    ".npmignore",
121    ".npmrc",
122    ".nvmrc",
123    ".yarnrc",
124    ".yarnrc.yml",
125    ".pnpmrc",
126    // Code formatting
127    ".prettierrc",
128    ".prettierrc.json",
129    ".prettierrc.yml",
130    ".prettierrc.yaml",
131    ".prettierrc.js",
132    ".prettierrc.cjs",
133    ".prettierignore",
134    // Linting
135    ".eslintrc",
136    ".eslintrc.json",
137    ".eslintrc.yml",
138    ".eslintrc.yaml",
139    ".eslintrc.js",
140    ".eslintrc.cjs",
141    ".eslintignore",
142    ".stylelintrc",
143    ".stylelintrc.json",
144    // Build tools
145    ".babelrc",
146    ".babelrc.json",
147    ".babelrc.js",
148    ".swcrc",
149    ".tsbuildinfo",
150    // Shell configuration
151    ".zshrc",
152    ".bashrc",
153    ".bash_profile",
154    ".bash_history",
155    ".bash_logout",
156    ".profile",
157    ".zprofile",
158    ".zshenv",
159    ".zsh_history",
160    ".shrc",
161    ".kshrc",
162    ".cshrc",
163    ".tcshrc",
164    ".fishrc",
165    ".config/fish/*",
166    // Editor configurations
167    ".vimrc",
168    ".vim/*",
169    ".nvim/*",
170    ".config/nvim/*",
171    ".emacs",
172    ".emacs.d/*",
173    ".nanorc",
174    // Terminal multiplexers
175    ".tmux.conf",
176    ".screenrc",
177    // SSH and security
178    ".ssh/*",
179    ".ssh/config",
180    ".ssh/known_hosts",
181    ".ssh/authorized_keys",
182    ".gnupg/*",
183    ".gpg/*",
184    // Cloud credentials
185    ".aws/*",
186    ".aws/config",
187    ".aws/credentials",
188    ".azure/*",
189    ".config/gcloud/*",
190    ".kube/*",
191    ".kube/config",
192    // Package managers and tools
193    ".cargo/*",
194    ".cargo/config.toml",
195    ".cargo/credentials.toml",
196    ".rustup/*",
197    ".gem/*",
198    ".bundle/*",
199    ".pip/*",
200    ".pypirc",
201    ".poetry/*",
202    ".pdm.toml",
203    ".python-version",
204    ".ruby-version",
205    ".node-version",
206    ".go-version",
207    ".tool-versions",
208    // Database
209    ".pgpass",
210    ".my.cnf",
211    ".mongorc.js",
212    ".rediscli_history",
213    // Misc configuration
214    ".netrc",
215    ".curlrc",
216    ".wgetrc",
217    ".htaccess",
218    ".htpasswd",
219    // VT Code specific
220    ".vtcode/*",
221    ".vtcodegitignore",
222    ".vtcode.toml",
223    // Claude/AI
224    ".claude/*",
225    ".claude.json",
226    ".agent/*",
227    // Other common dotfiles
228    ".inputrc",
229    ".dircolors",
230    ".mailrc",
231    ".gitkeep",
232    ".keep",
233];
234
235/// Operations that should never automatically modify dotfiles.
236fn default_blocked_operations() -> Vec<String> {
237    vec![
238        "dependency_installation".into(),
239        "code_formatting".into(),
240        "git_operations".into(),
241        "project_initialization".into(),
242        "build_operations".into(),
243        "test_execution".into(),
244        "linting".into(),
245        "auto_fix".into(),
246    ]
247}
248
249#[inline]
250fn default_audit_log_path() -> String {
251    VtCodePaths::resolve()
252        .and_then(|paths| paths.state_path("audit/dotfiles.log"))
253        .map(|path| path.display().to_string())
254        .unwrap_or_else(|_| String::from("state/vtcode/audit/dotfiles.log"))
255}
256
257#[inline]
258fn default_backup_dir() -> String {
259    VtCodePaths::resolve()
260        .and_then(|paths| paths.state_path("backups/dotfiles"))
261        .map(|path| path.display().to_string())
262        .unwrap_or_else(|_| String::from("state/vtcode/backups/dotfiles"))
263}
264
265#[inline]
266const fn default_max_backups() -> usize {
267    10
268}
269
270impl DotfileProtectionConfig {
271    /// Check if a file path matches a protected dotfile pattern.
272    pub fn is_protected(&self, path: &str) -> bool {
273        if !self.enabled {
274            return false;
275        }
276
277        let filename = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
278
279        // Check if it's a dotfile (starts with . or contains /. or is in a dotfile directory)
280        let is_dotfile = filename.starts_with('.')
281            || path.contains("/.")
282            || path.starts_with('.')
283            || Self::is_in_dotfile_directory(path);
284
285        if !is_dotfile {
286            return false;
287        }
288
289        // Check against default patterns
290        for pattern in DEFAULT_PROTECTED_DOTFILES {
291            if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
292                return true;
293            }
294        }
295
296        // Check against additional patterns
297        for pattern in &self.additional_protected_patterns {
298            if Self::matches_pattern(path, pattern) || Self::matches_pattern(filename, pattern) {
299                return true;
300            }
301        }
302
303        // Default: protect any file starting with .
304        filename.starts_with('.') || Self::is_in_dotfile_directory(path)
305    }
306
307    /// Check if a path is inside a dotfile directory like .ssh, .aws, etc.
308    fn is_in_dotfile_directory(path: &str) -> bool {
309        let components: Vec<&str> = path.split('/').collect();
310        for component in &components {
311            if component.starts_with('.') && !component.is_empty() && *component != "." && *component != ".." {
312                return true;
313            }
314        }
315        false
316    }
317
318    /// Check if a file is in the whitelist.
319    pub fn is_whitelisted(&self, path: &str) -> bool {
320        let filename = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
321
322        self.whitelist.contains(path) || self.whitelist.contains(filename)
323    }
324
325    /// Simple pattern matching with wildcard support.
326    fn matches_pattern(path: &str, pattern: &str) -> bool {
327        if pattern.contains('*') {
328            // Handle wildcard patterns
329            if let Some(prefix) = pattern.strip_suffix("/*") {
330                path.starts_with(prefix) || path.contains(&format!("/{}/", prefix.trim_start_matches('.')))
331            } else if pattern.ends_with(".*") {
332                let prefix = &pattern[..pattern.len() - 1];
333                path.starts_with(prefix)
334            } else {
335                // Simple glob matching
336                let parts: Vec<&str> = pattern.split('*').collect();
337                if parts.len() == 2 {
338                    path.starts_with(parts[0]) && path.ends_with(parts[1])
339                } else {
340                    path == pattern
341                }
342            }
343        } else {
344            path == pattern || path.ends_with(&format!("/{pattern}"))
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_default_protection() {
355        let config = DotfileProtectionConfig::default();
356
357        // Should be protected
358        assert!(config.is_protected(".gitignore"));
359        assert!(config.is_protected(".env"));
360        assert!(config.is_protected(".env.local"));
361        assert!(config.is_protected(".bashrc"));
362        assert!(config.is_protected(".ssh/config"));
363        assert!(config.is_protected("/home/user/.npmrc"));
364
365        // Should not be protected (not dotfiles)
366        assert!(!config.is_protected("README.md"));
367        assert!(!config.is_protected("src/main.rs"));
368    }
369
370    #[test]
371    fn test_whitelist() {
372        let mut config = DotfileProtectionConfig::default();
373        config.whitelist.insert(".gitignore".into());
374
375        assert!(config.is_whitelisted(".gitignore"));
376        assert!(!config.is_whitelisted(".env"));
377    }
378
379    #[test]
380    fn test_disabled_protection() {
381        let config = DotfileProtectionConfig { enabled: false, ..Default::default() };
382
383        assert!(!config.is_protected(".gitignore"));
384        assert!(!config.is_protected(".env"));
385    }
386
387    #[test]
388    fn test_pattern_matching() {
389        assert!(DotfileProtectionConfig::matches_pattern(".env.local", ".env.*"));
390        assert!(DotfileProtectionConfig::matches_pattern(".env.production", ".env.*"));
391        assert!(DotfileProtectionConfig::matches_pattern(".vscode/settings.json", ".vscode/*"));
392    }
393}