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