1use crate::env_helpers::default_true;
7use indexmap::IndexSet;
8use serde::{Deserialize, Serialize};
9
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct DotfileProtectionConfig {
14 #[serde(default = "default_true")]
16 pub enabled: bool,
17
18 #[serde(default = "default_true")]
20 pub require_explicit_confirmation: bool,
21
22 #[serde(default = "default_true")]
24 pub audit_logging_enabled: bool,
25
26 #[serde(default = "default_audit_log_path")]
28 pub audit_log_path: String,
29
30 #[serde(default = "default_true")]
32 pub prevent_cascading_modifications: bool,
33
34 #[serde(default = "default_true")]
36 pub create_backups: bool,
37
38 #[serde(default = "default_backup_dir")]
40 pub backup_directory: String,
41
42 #[serde(default = "default_max_backups")]
44 pub max_backups_per_file: usize,
45
46 #[serde(default = "default_true")]
48 pub preserve_permissions: bool,
49
50 #[serde(default)]
52 pub whitelist: IndexSet<String>,
53
54 #[serde(default)]
56 pub additional_protected_patterns: Vec<String>,
57
58 #[serde(default = "default_true")]
60 pub block_during_automation: bool,
61
62 #[serde(default = "default_blocked_operations")]
64 pub blocked_operations: Vec<String>,
65
66 #[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
92pub const DEFAULT_PROTECTED_DOTFILES: &[&str] = &[
97 ".gitignore",
99 ".gitattributes",
100 ".gitmodules",
101 ".gitconfig",
102 ".git-credentials",
103 ".editorconfig",
105 ".vscode/*",
106 ".idea/*",
107 ".cursor/*",
108 ".env",
110 ".env.local",
111 ".env.development",
112 ".env.production",
113 ".env.test",
114 ".env.*",
115 ".dockerignore",
117 ".docker/*",
118 ".npmignore",
120 ".npmrc",
121 ".nvmrc",
122 ".yarnrc",
123 ".yarnrc.yml",
124 ".pnpmrc",
125 ".prettierrc",
127 ".prettierrc.json",
128 ".prettierrc.yml",
129 ".prettierrc.yaml",
130 ".prettierrc.js",
131 ".prettierrc.cjs",
132 ".prettierignore",
133 ".eslintrc",
135 ".eslintrc.json",
136 ".eslintrc.yml",
137 ".eslintrc.yaml",
138 ".eslintrc.js",
139 ".eslintrc.cjs",
140 ".eslintignore",
141 ".stylelintrc",
142 ".stylelintrc.json",
143 ".babelrc",
145 ".babelrc.json",
146 ".babelrc.js",
147 ".swcrc",
148 ".tsbuildinfo",
149 ".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 ".vimrc",
167 ".vim/*",
168 ".nvim/*",
169 ".config/nvim/*",
170 ".emacs",
171 ".emacs.d/*",
172 ".nanorc",
173 ".tmux.conf",
175 ".screenrc",
176 ".ssh/*",
178 ".ssh/config",
179 ".ssh/known_hosts",
180 ".ssh/authorized_keys",
181 ".gnupg/*",
182 ".gpg/*",
183 ".aws/*",
185 ".aws/config",
186 ".aws/credentials",
187 ".azure/*",
188 ".config/gcloud/*",
189 ".kube/*",
190 ".kube/config",
191 ".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 ".pgpass",
209 ".my.cnf",
210 ".mongorc.js",
211 ".rediscli_history",
212 ".netrc",
214 ".curlrc",
215 ".wgetrc",
216 ".htaccess",
217 ".htpasswd",
218 ".vtcode/*",
220 ".vtcodegitignore",
221 ".vtcode.toml",
222 ".claude/*",
224 ".claude.json",
225 ".agent/*",
226 ".inputrc",
228 ".dircolors",
229 ".mailrc",
230 ".gitkeep",
231 ".keep",
232];
233
234fn 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 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 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 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 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 filename.starts_with('.') || Self::is_in_dotfile_directory(path)
301 }
302
303 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 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 fn matches_pattern(path: &str, pattern: &str) -> bool {
330 if pattern.contains('*') {
331 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 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 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 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}