1use crate::env_helpers::default_true;
7use indexmap::IndexSet;
8use serde::{Deserialize, Serialize};
9use vtcode_commons::VtCodePaths;
10
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct DotfileProtectionConfig {
15 #[serde(default = "default_true")]
17 pub enabled: bool,
18
19 #[serde(default = "default_true")]
21 pub require_explicit_confirmation: bool,
22
23 #[serde(default = "default_true")]
25 pub audit_logging_enabled: bool,
26
27 #[serde(default = "default_audit_log_path")]
29 pub audit_log_path: String,
30
31 #[serde(default = "default_true")]
33 pub prevent_cascading_modifications: bool,
34
35 #[serde(default = "default_true")]
37 pub create_backups: bool,
38
39 #[serde(default = "default_backup_dir")]
41 pub backup_directory: String,
42
43 #[serde(default = "default_max_backups")]
45 pub max_backups_per_file: usize,
46
47 #[serde(default = "default_true")]
49 pub preserve_permissions: bool,
50
51 #[serde(default)]
53 pub whitelist: IndexSet<String>,
54
55 #[serde(default)]
57 pub additional_protected_patterns: Vec<String>,
58
59 #[serde(default = "default_true")]
61 pub block_during_automation: bool,
62
63 #[serde(default = "default_blocked_operations")]
65 pub blocked_operations: Vec<String>,
66
67 #[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
93const DEFAULT_PROTECTED_DOTFILES: &[&str] = &[
98 ".gitignore",
100 ".gitattributes",
101 ".gitmodules",
102 ".gitconfig",
103 ".git-credentials",
104 ".editorconfig",
106 ".vscode/*",
107 ".idea/*",
108 ".cursor/*",
109 ".env",
111 ".env.local",
112 ".env.development",
113 ".env.production",
114 ".env.test",
115 ".env.*",
116 ".dockerignore",
118 ".docker/*",
119 ".npmignore",
121 ".npmrc",
122 ".nvmrc",
123 ".yarnrc",
124 ".yarnrc.yml",
125 ".pnpmrc",
126 ".prettierrc",
128 ".prettierrc.json",
129 ".prettierrc.yml",
130 ".prettierrc.yaml",
131 ".prettierrc.js",
132 ".prettierrc.cjs",
133 ".prettierignore",
134 ".eslintrc",
136 ".eslintrc.json",
137 ".eslintrc.yml",
138 ".eslintrc.yaml",
139 ".eslintrc.js",
140 ".eslintrc.cjs",
141 ".eslintignore",
142 ".stylelintrc",
143 ".stylelintrc.json",
144 ".babelrc",
146 ".babelrc.json",
147 ".babelrc.js",
148 ".swcrc",
149 ".tsbuildinfo",
150 ".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 ".vimrc",
168 ".vim/*",
169 ".nvim/*",
170 ".config/nvim/*",
171 ".emacs",
172 ".emacs.d/*",
173 ".nanorc",
174 ".tmux.conf",
176 ".screenrc",
177 ".ssh/*",
179 ".ssh/config",
180 ".ssh/known_hosts",
181 ".ssh/authorized_keys",
182 ".gnupg/*",
183 ".gpg/*",
184 ".aws/*",
186 ".aws/config",
187 ".aws/credentials",
188 ".azure/*",
189 ".config/gcloud/*",
190 ".kube/*",
191 ".kube/config",
192 ".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 ".pgpass",
210 ".my.cnf",
211 ".mongorc.js",
212 ".rediscli_history",
213 ".netrc",
215 ".curlrc",
216 ".wgetrc",
217 ".htaccess",
218 ".htpasswd",
219 ".vtcode/*",
221 ".vtcodegitignore",
222 ".vtcode.toml",
223 ".claude/*",
225 ".claude.json",
226 ".agent/*",
227 ".inputrc",
229 ".dircolors",
230 ".mailrc",
231 ".gitkeep",
232 ".keep",
233];
234
235fn 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 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 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 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 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 filename.starts_with('.') || Self::is_in_dotfile_directory(path)
305 }
306
307 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 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 fn matches_pattern(path: &str, pattern: &str) -> bool {
327 if pattern.contains('*') {
328 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 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 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 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}