1use hashbrown::HashSet;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use anyhow::{Context, Result};
11use once_cell::sync::OnceCell;
12use serde::{Deserialize, Serialize};
13use tokio::sync::Mutex;
14
15use super::audit::{AccessType, AuditEntry, AuditLog, AuditOutcome};
16use super::backup::BackupManager;
17use vtcode_config::core::DotfileProtectionConfig;
18
19static GLOBAL_GUARDIAN: OnceCell<Arc<DotfileGuardian>> = OnceCell::new();
21
22pub async fn init_global_guardian(config: DotfileProtectionConfig) -> Result<()> {
26 if GLOBAL_GUARDIAN.get().is_some() {
27 return Ok(());
28 }
29
30 let guardian = DotfileGuardian::new(config).await?;
31 let _ = GLOBAL_GUARDIAN.set(Arc::new(guardian));
32 Ok(())
33}
34
35pub fn get_global_guardian() -> Option<Arc<DotfileGuardian>> {
39 GLOBAL_GUARDIAN.get().cloned()
40}
41
42pub fn is_protected_dotfile(path: &Path) -> bool {
46 GLOBAL_GUARDIAN.get().map(|g| g.is_protected(path)).unwrap_or(false)
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ProtectionDecision {
52 Allowed,
54 RequiresConfirmation(ConfirmationRequest),
56 RequiresSecondaryAuth(ConfirmationRequest),
58 Blocked(ProtectionViolation),
60 Denied(ProtectionViolation),
62}
63
64impl ProtectionDecision {
65 pub fn is_allowed(&self) -> bool {
67 matches!(self, ProtectionDecision::Allowed)
68 }
69
70 pub fn requires_confirmation(&self) -> bool {
72 matches!(self, ProtectionDecision::RequiresConfirmation(_) | ProtectionDecision::RequiresSecondaryAuth(_))
73 }
74
75 pub fn is_blocked(&self) -> bool {
77 matches!(self, ProtectionDecision::Blocked(_) | ProtectionDecision::Denied(_))
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ConfirmationRequest {
84 pub file_path: String,
86 pub access_type: String,
88 pub proposed_changes: String,
90 pub initiator: String,
92 pub protection_reason: String,
94 pub is_whitelisted: bool,
96 pub warning: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
102#[error("Dotfile protection violation for '{file_path}': {reason}. {suggestion}")]
103pub struct ProtectionViolation {
104 pub file_path: String,
106 pub access_type: String,
108 pub reason: String,
110 pub suggestion: String,
112}
113
114#[derive(Debug, Clone)]
116pub struct AccessContext {
117 pub file_path: PathBuf,
119 pub access_type: AccessType,
121 pub initiator: String,
123 pub session_id: String,
125 pub proposed_changes: Option<String>,
127 pub is_automated: bool,
129 pub is_cascading: bool,
131 pub triggered_by: Option<PathBuf>,
133}
134
135impl AccessContext {
136 pub fn new(
138 file_path: impl Into<PathBuf>,
139 access_type: AccessType,
140 initiator: impl Into<String>,
141 session_id: impl Into<String>,
142 ) -> Self {
143 Self {
144 file_path: file_path.into(),
145 access_type,
146 initiator: initiator.into(),
147 session_id: session_id.into(),
148 proposed_changes: None,
149 is_automated: false,
150 is_cascading: false,
151 triggered_by: None,
152 }
153 }
154
155 pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
157 self.proposed_changes = Some(changes.into());
158 self
159 }
160
161 pub fn as_automated(mut self) -> Self {
163 self.is_automated = true;
164 self
165 }
166
167 pub fn as_cascading(mut self, triggered_by: impl Into<PathBuf>) -> Self {
169 self.is_cascading = true;
170 self.triggered_by = Some(triggered_by.into());
171 self
172 }
173}
174
175#[derive(Clone)]
184pub struct DotfileGuardian {
185 config: DotfileProtectionConfig,
187 audit_log: Option<Arc<AuditLog>>,
189 backup_manager: Option<Arc<BackupManager>>,
191 state: Arc<Mutex<GuardianState>>,
193}
194
195#[derive(Debug, Default)]
197struct GuardianState {
198 modified_files: HashSet<PathBuf>,
200 pending_modifications: HashSet<PathBuf>,
202}
203
204impl DotfileGuardian {
205 fn expand_path(path: &str) -> String {
207 if let Some(stripped) = path.strip_prefix("~/")
208 && let Some(home) = dirs::home_dir()
209 {
210 return home.join(stripped).to_string_lossy().into_owned();
211 }
212 path.to_string()
213 }
214
215 pub async fn new(config: DotfileProtectionConfig) -> Result<Self> {
217 let audit_log = if config.audit_logging_enabled {
218 let log_path = Self::expand_path(&config.audit_log_path);
219 Some(Arc::new(
220 AuditLog::new(&log_path)
221 .await
222 .with_context(|| "Failed to initialize dotfile audit log")?,
223 ))
224 } else {
225 None
226 };
227
228 let backup_manager = if config.create_backups {
229 let backup_dir = Self::expand_path(&config.backup_directory);
230 Some(Arc::new(
231 BackupManager::new(&backup_dir, config.max_backups_per_file)
232 .await
233 .with_context(|| "Failed to initialize dotfile backup manager")?,
234 ))
235 } else {
236 None
237 };
238
239 Ok(Self {
240 config,
241 audit_log,
242 backup_manager,
243 state: Arc::new(Mutex::new(GuardianState::default())),
244 })
245 }
246
247 pub async fn with_defaults() -> Result<Self> {
249 Self::new(DotfileProtectionConfig::default()).await
250 }
251
252 pub fn is_protected(&self, path: &Path) -> bool {
254 let path_str = path.to_string_lossy();
255 self.config.is_protected(&path_str)
256 }
257
258 pub fn is_whitelisted(&self, path: &Path) -> bool {
260 let path_str = path.to_string_lossy();
261 self.config.is_whitelisted(&path_str)
262 }
263
264 pub async fn request_access(&self, context: &AccessContext) -> Result<ProtectionDecision> {
268 if !self.config.enabled {
270 self.log_access(context, AuditOutcome::AllowedUnprotected).await?;
271 return Ok(ProtectionDecision::Allowed);
272 }
273
274 if !self.is_protected(&context.file_path) {
276 return Ok(ProtectionDecision::Allowed);
277 }
278
279 if self.config.prevent_cascading_modifications && context.is_cascading {
281 let violation = ProtectionViolation {
282 file_path: context.file_path.to_string_lossy().into_owned(),
283 access_type: context.access_type.to_string(),
284 reason: format!(
285 "Cascading modification blocked. This change was triggered by modifying '{}'",
286 context
287 .triggered_by
288 .as_ref()
289 .map(|p| p.to_string_lossy().into_owned())
290 .unwrap_or_else(|| "unknown".to_string())
291 ),
292 suggestion: "Modify each dotfile independently with explicit confirmation.".to_string(),
293 };
294 self.log_access(context, AuditOutcome::Blocked).await?;
295 return Ok(ProtectionDecision::Blocked(violation));
296 }
297
298 if self.config.block_during_automation && context.is_automated {
300 let violation = ProtectionViolation {
301 file_path: context.file_path.to_string_lossy().into_owned(),
302 access_type: context.access_type.to_string(),
303 reason: format!("Dotfile modification blocked during automated operation ({})", context.initiator),
304 suggestion: "Modify dotfiles manually or use explicit commands.".to_string(),
305 };
306 self.log_access(context, AuditOutcome::Blocked).await?;
307 return Ok(ProtectionDecision::Blocked(violation));
308 }
309
310 let request = ConfirmationRequest {
312 file_path: context.file_path.to_string_lossy().into_owned(),
313 access_type: context.access_type.to_string(),
314 proposed_changes: context
315 .proposed_changes
316 .clone()
317 .unwrap_or_else(|| "No details provided".to_string()),
318 initiator: context.initiator.clone(),
319 protection_reason: self.get_protection_reason(&context.file_path),
320 is_whitelisted: self.is_whitelisted(&context.file_path),
321 warning: self.build_warning_message(context),
322 };
323
324 {
326 let mut state = self.state.lock().await;
327 state.pending_modifications.insert(context.file_path.clone());
328 }
329
330 if self.is_whitelisted(&context.file_path) && self.config.require_secondary_auth_for_whitelist {
331 Ok(ProtectionDecision::RequiresSecondaryAuth(request))
332 } else if self.config.require_explicit_confirmation {
333 Ok(ProtectionDecision::RequiresConfirmation(request))
334 } else {
335 self.log_access(context, AuditOutcome::AllowedUnprotected).await?;
337 Ok(ProtectionDecision::Allowed)
338 }
339 }
340
341 pub async fn confirm_modification(&self, context: &AccessContext, is_whitelisted: bool) -> Result<()> {
343 if let Some(ref backup_manager) = self.backup_manager
345 && tokio::fs::try_exists(&context.file_path).await.unwrap_or(false)
346 {
347 backup_manager
348 .create_backup(
349 &context.file_path,
350 format!("Before {} by {}", context.access_type, context.initiator),
351 &context.session_id,
352 )
353 .await?;
354 }
355
356 let outcome = if is_whitelisted {
358 AuditOutcome::AllowedViaWhitelist
359 } else {
360 AuditOutcome::AllowedWithConfirmation
361 };
362 self.log_access(context, outcome).await?;
363
364 {
366 let mut state = self.state.lock().await;
367 state.modified_files.insert(context.file_path.clone());
369 state.pending_modifications.remove(&context.file_path);
371 }
372
373 Ok(())
374 }
375
376 pub async fn reject_modification(&self, context: &AccessContext) -> Result<()> {
378 self.log_access(context, AuditOutcome::UserRejected).await?;
379
380 {
382 let mut state = self.state.lock().await;
383 state.pending_modifications.remove(&context.file_path);
384 }
385
386 Ok(())
387 }
388
389 pub async fn would_cascade(&self, file_path: &Path) -> bool {
391 if !self.config.prevent_cascading_modifications {
392 return false;
393 }
394
395 let state = self.state.lock().await;
396 !state.modified_files.is_empty() && self.is_protected(file_path)
397 }
398
399 pub async fn get_latest_backup(&self, file_path: &Path) -> Result<Option<super::backup::DotfileBackup>> {
401 match &self.backup_manager {
402 Some(manager) => manager.get_latest_backup(file_path).await,
403 None => Ok(None),
404 }
405 }
406
407 pub async fn restore_from_backup(&self, file_path: &Path) -> Result<()> {
409 let manager = self
410 .backup_manager
411 .as_ref()
412 .ok_or_else(|| anyhow::anyhow!("Backup manager not enabled"))?;
413
414 manager.restore_latest(file_path).await
415 }
416
417 pub async fn get_audit_history(&self, file_path: &str) -> Result<Vec<AuditEntry>> {
419 match &self.audit_log {
420 Some(log) => log.get_entries_for_file(file_path).await,
421 None => Ok(Vec::new()),
422 }
423 }
424
425 pub async fn verify_audit_integrity(&self) -> Result<bool> {
427 match &self.audit_log {
428 Some(log) => log.verify_integrity().await,
429 None => Ok(true),
430 }
431 }
432
433 pub async fn reset_session(&self) {
435 let mut state = self.state.lock().await;
436 state.modified_files.clear();
437 state.pending_modifications.clear();
438 }
439
440 pub async fn get_modified_files(&self) -> Vec<PathBuf> {
442 let state = self.state.lock().await;
443 state.modified_files.iter().cloned().collect()
444 }
445
446 async fn log_access(&self, context: &AccessContext, outcome: AuditOutcome) -> Result<()> {
448 if let Some(ref log) = self.audit_log {
449 let mut entry = AuditEntry::new(
450 context.file_path.to_string_lossy(),
451 context.access_type,
452 outcome,
453 &context.initiator,
454 &context.session_id,
455 "",
456 );
457
458 if let Some(ref changes) = context.proposed_changes {
459 entry = entry.with_proposed_changes(changes);
460 }
461
462 if context.is_automated {
463 entry = entry.during_automation();
464 }
465
466 if context.is_cascading
467 && let Some(ref triggered_by) = context.triggered_by
468 {
469 entry = entry.with_context(format!("Cascading from: {}", triggered_by.to_string_lossy()));
470 }
471
472 log.log(entry).await?;
473 }
474
475 Ok(())
476 }
477
478 fn get_protection_reason(&self, path: &Path) -> String {
480 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
481
482 if filename.starts_with(".git") {
483 "Git configuration file - changes may affect repository behavior".to_string()
484 } else if filename.starts_with(".env") {
485 "Environment configuration - may contain secrets or critical settings".to_string()
486 } else if filename.contains("ssh") || filename.contains("gpg") {
487 "Security-sensitive file - may contain credentials or keys".to_string()
488 } else if filename.contains("rc") || filename.contains("profile") {
489 "Shell configuration - changes may affect system behavior".to_string()
490 } else if filename.contains("config") {
491 "Configuration file - changes may affect tool behavior".to_string()
492 } else {
493 "Hidden configuration file - modifications require explicit approval".to_string()
494 }
495 }
496
497 fn build_warning_message(&self, context: &AccessContext) -> String {
499 let filename = context.file_path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
500
501 format!(
502 "DOTFILE PROTECTION WARNING\n\n\
503 The AI agent '{}' is requesting to {} the protected file '{}'.\n\n\
504 This is a hidden configuration file that could affect your system, \
505 development environment, or contain sensitive information.\n\n\
506 Proposed changes:\n{}\n\n\
507 Please review carefully before approving.",
508 context.initiator,
509 context.access_type.to_string().to_lowercase(),
510 filename,
511 context.proposed_changes.as_deref().unwrap_or("No details provided")
512 )
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use tempfile::tempdir;
520
521 async fn create_test_guardian() -> (DotfileGuardian, tempfile::TempDir) {
522 let dir = tempdir().unwrap();
523 let config = DotfileProtectionConfig {
524 audit_log_path: dir.path().join("audit.log").to_string_lossy().into_owned(),
525 backup_directory: dir.path().join("backups").to_string_lossy().into_owned(),
526 ..Default::default()
527 };
528
529 (DotfileGuardian::new(config).await.unwrap(), dir)
530 }
531
532 #[tokio::test]
533 async fn test_protection_detection() {
534 let (guardian, _dir) = create_test_guardian().await;
535
536 assert!(guardian.is_protected(Path::new(".gitignore")));
537 assert!(guardian.is_protected(Path::new(".env")));
538 assert!(guardian.is_protected(Path::new(".bashrc")));
539 assert!(guardian.is_protected(Path::new("/home/user/.ssh/config")));
540 assert!(!guardian.is_protected(Path::new("README.md")));
541 }
542
543 #[tokio::test]
544 async fn test_requires_confirmation() {
545 let (guardian, _dir) = create_test_guardian().await;
546
547 let context = AccessContext::new(".gitignore", AccessType::Write, "write_file", "test-session")
548 .with_proposed_changes("Adding node_modules to ignore list");
549
550 let decision = guardian.request_access(&context).await.unwrap();
551
552 assert!(decision.requires_confirmation());
553 if let ProtectionDecision::RequiresConfirmation(req) = decision {
554 assert_eq!(req.file_path, ".gitignore");
555 assert!(req.warning.contains("DOTFILE PROTECTION WARNING"));
556 } else {
557 panic!("Expected RequiresConfirmation");
558 }
559 }
560
561 #[tokio::test]
562 async fn test_blocks_during_automation() {
563 let (guardian, _dir) = create_test_guardian().await;
564
565 let context = AccessContext::new(".npmrc", AccessType::Write, "npm_install", "test-session").as_automated();
566
567 let decision = guardian.request_access(&context).await.unwrap();
568
569 assert!(decision.is_blocked());
570 }
571
572 #[tokio::test]
573 async fn test_blocks_cascading() {
574 let (guardian, _dir) = create_test_guardian().await;
575
576 let context1 = AccessContext::new(".gitignore", AccessType::Write, "test", "test-session");
578 let _ = guardian.request_access(&context1).await.unwrap();
579 guardian.confirm_modification(&context1, false).await.unwrap();
580
581 let context2 =
583 AccessContext::new(".gitattributes", AccessType::Write, "test", "test-session").as_cascading(".gitignore");
584
585 let decision = guardian.request_access(&context2).await.unwrap();
586 assert!(decision.is_blocked());
587 }
588
589 #[tokio::test]
590 async fn test_non_dotfile_allowed() {
591 let (guardian, _dir) = create_test_guardian().await;
592
593 let context = AccessContext::new("README.md", AccessType::Write, "write_file", "test-session");
594
595 let decision = guardian.request_access(&context).await.unwrap();
596 assert!(decision.is_allowed());
597 }
598
599 #[tokio::test]
600 async fn test_disabled_protection() {
601 let dir = tempdir().unwrap();
602 let config = DotfileProtectionConfig {
603 enabled: false,
604 audit_log_path: dir.path().join("audit.log").to_string_lossy().into_owned(),
605 backup_directory: dir.path().join("backups").to_string_lossy().into_owned(),
606 ..Default::default()
607 };
608
609 let guardian = DotfileGuardian::new(config).await.unwrap();
610
611 let context = AccessContext::new(".gitignore", AccessType::Write, "write_file", "test-session");
612
613 let decision = guardian.request_access(&context).await.unwrap();
614 assert!(decision.is_allowed());
615 }
616}