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
47 .get()
48 .map(|g| g.is_protected(path))
49 .unwrap_or(false)
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ProtectionDecision {
55 Allowed,
57 RequiresConfirmation(ConfirmationRequest),
59 RequiresSecondaryAuth(ConfirmationRequest),
61 Blocked(ProtectionViolation),
63 Denied(ProtectionViolation),
65}
66
67impl ProtectionDecision {
68 pub fn is_allowed(&self) -> bool {
70 matches!(self, ProtectionDecision::Allowed)
71 }
72
73 pub fn requires_confirmation(&self) -> bool {
75 matches!(
76 self,
77 ProtectionDecision::RequiresConfirmation(_)
78 | ProtectionDecision::RequiresSecondaryAuth(_)
79 )
80 }
81
82 pub fn is_blocked(&self) -> bool {
84 matches!(
85 self,
86 ProtectionDecision::Blocked(_) | ProtectionDecision::Denied(_)
87 )
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ConfirmationRequest {
94 pub file_path: String,
96 pub access_type: String,
98 pub proposed_changes: String,
100 pub initiator: String,
102 pub protection_reason: String,
104 pub is_whitelisted: bool,
106 pub warning: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
112#[error("Dotfile protection violation for '{file_path}': {reason}. {suggestion}")]
113pub struct ProtectionViolation {
114 pub file_path: String,
116 pub access_type: String,
118 pub reason: String,
120 pub suggestion: String,
122}
123
124#[derive(Debug, Clone)]
126pub struct AccessContext {
127 pub file_path: PathBuf,
129 pub access_type: AccessType,
131 pub initiator: String,
133 pub session_id: String,
135 pub proposed_changes: Option<String>,
137 pub is_automated: bool,
139 pub is_cascading: bool,
141 pub triggered_by: Option<PathBuf>,
143}
144
145impl AccessContext {
146 pub fn new(
148 file_path: impl Into<PathBuf>,
149 access_type: AccessType,
150 initiator: impl Into<String>,
151 session_id: impl Into<String>,
152 ) -> Self {
153 Self {
154 file_path: file_path.into(),
155 access_type,
156 initiator: initiator.into(),
157 session_id: session_id.into(),
158 proposed_changes: None,
159 is_automated: false,
160 is_cascading: false,
161 triggered_by: None,
162 }
163 }
164
165 pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
167 self.proposed_changes = Some(changes.into());
168 self
169 }
170
171 pub fn as_automated(mut self) -> Self {
173 self.is_automated = true;
174 self
175 }
176
177 pub fn as_cascading(mut self, triggered_by: impl Into<PathBuf>) -> Self {
179 self.is_cascading = true;
180 self.triggered_by = Some(triggered_by.into());
181 self
182 }
183}
184
185#[derive(Clone)]
194pub struct DotfileGuardian {
195 config: DotfileProtectionConfig,
197 audit_log: Option<Arc<AuditLog>>,
199 backup_manager: Option<Arc<BackupManager>>,
201 state: Arc<Mutex<GuardianState>>,
203}
204
205#[derive(Debug, Default)]
207struct GuardianState {
208 modified_files: HashSet<PathBuf>,
210 pending_modifications: HashSet<PathBuf>,
212}
213
214impl DotfileGuardian {
215 fn expand_path(path: &str) -> String {
217 if let Some(stripped) = path.strip_prefix("~/")
218 && let Some(home) = dirs::home_dir()
219 {
220 return home.join(stripped).to_string_lossy().into_owned();
221 }
222 path.to_string()
223 }
224
225 pub async fn new(config: DotfileProtectionConfig) -> Result<Self> {
227 let audit_log = if config.audit_logging_enabled {
228 let log_path = Self::expand_path(&config.audit_log_path);
229 Some(Arc::new(
230 AuditLog::new(&log_path)
231 .await
232 .with_context(|| "Failed to initialize dotfile audit log")?,
233 ))
234 } else {
235 None
236 };
237
238 let backup_manager = if config.create_backups {
239 let backup_dir = Self::expand_path(&config.backup_directory);
240 Some(Arc::new(
241 BackupManager::new(&backup_dir, config.max_backups_per_file)
242 .await
243 .with_context(|| "Failed to initialize dotfile backup manager")?,
244 ))
245 } else {
246 None
247 };
248
249 Ok(Self {
250 config,
251 audit_log,
252 backup_manager,
253 state: Arc::new(Mutex::new(GuardianState::default())),
254 })
255 }
256
257 pub async fn with_defaults() -> Result<Self> {
259 Self::new(DotfileProtectionConfig::default()).await
260 }
261
262 pub fn is_protected(&self, path: &Path) -> bool {
264 let path_str = path.to_string_lossy();
265 self.config.is_protected(&path_str)
266 }
267
268 pub fn is_whitelisted(&self, path: &Path) -> bool {
270 let path_str = path.to_string_lossy();
271 self.config.is_whitelisted(&path_str)
272 }
273
274 pub async fn request_access(&self, context: &AccessContext) -> Result<ProtectionDecision> {
278 if !self.config.enabled {
280 self.log_access(context, AuditOutcome::AllowedUnprotected)
281 .await?;
282 return Ok(ProtectionDecision::Allowed);
283 }
284
285 if !self.is_protected(&context.file_path) {
287 return Ok(ProtectionDecision::Allowed);
288 }
289
290 if self.config.prevent_cascading_modifications && context.is_cascading {
292 let violation = ProtectionViolation {
293 file_path: context.file_path.to_string_lossy().into_owned(),
294 access_type: format!("{}", context.access_type),
295 reason: format!(
296 "Cascading modification blocked. This change was triggered by modifying '{}'",
297 context
298 .triggered_by
299 .as_ref()
300 .map(|p| p.to_string_lossy().into_owned())
301 .unwrap_or_else(|| "unknown".to_string())
302 ),
303 suggestion: "Modify each dotfile independently with explicit confirmation."
304 .to_string(),
305 };
306 self.log_access(context, AuditOutcome::Blocked).await?;
307 return Ok(ProtectionDecision::Blocked(violation));
308 }
309
310 if self.config.block_during_automation && context.is_automated {
312 let violation = ProtectionViolation {
313 file_path: context.file_path.to_string_lossy().into_owned(),
314 access_type: format!("{}", context.access_type),
315 reason: format!(
316 "Dotfile modification blocked during automated operation ({})",
317 context.initiator
318 ),
319 suggestion: "Modify dotfiles manually or use explicit commands.".to_string(),
320 };
321 self.log_access(context, AuditOutcome::Blocked).await?;
322 return Ok(ProtectionDecision::Blocked(violation));
323 }
324
325 let request = ConfirmationRequest {
327 file_path: context.file_path.to_string_lossy().into_owned(),
328 access_type: format!("{}", context.access_type),
329 proposed_changes: context
330 .proposed_changes
331 .clone()
332 .unwrap_or_else(|| "No details provided".to_string()),
333 initiator: context.initiator.clone(),
334 protection_reason: self.get_protection_reason(&context.file_path),
335 is_whitelisted: self.is_whitelisted(&context.file_path),
336 warning: self.build_warning_message(context),
337 };
338
339 {
341 let mut state = self.state.lock().await;
342 state
343 .pending_modifications
344 .insert(context.file_path.clone());
345 }
346
347 if self.is_whitelisted(&context.file_path)
348 && self.config.require_secondary_auth_for_whitelist
349 {
350 Ok(ProtectionDecision::RequiresSecondaryAuth(request))
351 } else if self.config.require_explicit_confirmation {
352 Ok(ProtectionDecision::RequiresConfirmation(request))
353 } else {
354 self.log_access(context, AuditOutcome::AllowedUnprotected)
356 .await?;
357 Ok(ProtectionDecision::Allowed)
358 }
359 }
360
361 pub async fn confirm_modification(
363 &self,
364 context: &AccessContext,
365 is_whitelisted: bool,
366 ) -> Result<()> {
367 if let Some(ref backup_manager) = self.backup_manager
369 && context.file_path.exists()
370 {
371 backup_manager
372 .create_backup(
373 &context.file_path,
374 format!("Before {} by {}", context.access_type, context.initiator),
375 &context.session_id,
376 )
377 .await?;
378 }
379
380 let outcome = if is_whitelisted {
382 AuditOutcome::AllowedViaWhitelist
383 } else {
384 AuditOutcome::AllowedWithConfirmation
385 };
386 self.log_access(context, outcome).await?;
387
388 {
390 let mut state = self.state.lock().await;
391 state.modified_files.insert(context.file_path.clone());
393 state.pending_modifications.remove(&context.file_path);
395 }
396
397 Ok(())
398 }
399
400 pub async fn reject_modification(&self, context: &AccessContext) -> Result<()> {
402 self.log_access(context, AuditOutcome::UserRejected).await?;
403
404 {
406 let mut state = self.state.lock().await;
407 state.pending_modifications.remove(&context.file_path);
408 }
409
410 Ok(())
411 }
412
413 pub async fn would_cascade(&self, file_path: &Path) -> bool {
415 if !self.config.prevent_cascading_modifications {
416 return false;
417 }
418
419 let state = self.state.lock().await;
420 !state.modified_files.is_empty() && self.is_protected(file_path)
421 }
422
423 pub async fn get_latest_backup(
425 &self,
426 file_path: &Path,
427 ) -> Result<Option<super::backup::DotfileBackup>> {
428 match &self.backup_manager {
429 Some(manager) => manager.get_latest_backup(file_path).await,
430 None => Ok(None),
431 }
432 }
433
434 pub async fn restore_from_backup(&self, file_path: &Path) -> Result<()> {
436 let manager = self
437 .backup_manager
438 .as_ref()
439 .ok_or_else(|| anyhow::anyhow!("Backup manager not enabled"))?;
440
441 manager.restore_latest(file_path).await
442 }
443
444 pub async fn get_audit_history(&self, file_path: &str) -> Result<Vec<AuditEntry>> {
446 match &self.audit_log {
447 Some(log) => log.get_entries_for_file(file_path).await,
448 None => Ok(Vec::new()),
449 }
450 }
451
452 pub async fn verify_audit_integrity(&self) -> Result<bool> {
454 match &self.audit_log {
455 Some(log) => log.verify_integrity().await,
456 None => Ok(true),
457 }
458 }
459
460 pub async fn reset_session(&self) {
462 let mut state = self.state.lock().await;
463 state.modified_files.clear();
464 state.pending_modifications.clear();
465 }
466
467 pub async fn get_modified_files(&self) -> Vec<PathBuf> {
469 let state = self.state.lock().await;
470 state.modified_files.iter().cloned().collect()
471 }
472
473 async fn log_access(&self, context: &AccessContext, outcome: AuditOutcome) -> Result<()> {
475 if let Some(ref log) = self.audit_log {
476 let mut entry = AuditEntry::new(
477 context.file_path.to_string_lossy().to_string(),
478 context.access_type,
479 outcome,
480 &context.initiator,
481 &context.session_id,
482 "",
483 );
484
485 if let Some(ref changes) = context.proposed_changes {
486 entry = entry.with_proposed_changes(changes);
487 }
488
489 if context.is_automated {
490 entry = entry.during_automation();
491 }
492
493 if context.is_cascading
494 && let Some(ref triggered_by) = context.triggered_by
495 {
496 entry = entry.with_context(format!(
497 "Cascading from: {}",
498 triggered_by.to_string_lossy()
499 ));
500 }
501
502 log.log(entry).await?;
503 }
504
505 Ok(())
506 }
507
508 fn get_protection_reason(&self, path: &Path) -> String {
510 let filename = path
511 .file_name()
512 .and_then(|n| n.to_str())
513 .unwrap_or("unknown");
514
515 if filename.starts_with(".git") {
516 "Git configuration file - changes may affect repository behavior".to_string()
517 } else if filename.starts_with(".env") {
518 "Environment configuration - may contain secrets or critical settings".to_string()
519 } else if filename.contains("ssh") || filename.contains("gpg") {
520 "Security-sensitive file - may contain credentials or keys".to_string()
521 } else if filename.contains("rc") || filename.contains("profile") {
522 "Shell configuration - changes may affect system behavior".to_string()
523 } else if filename.contains("config") {
524 "Configuration file - changes may affect tool behavior".to_string()
525 } else {
526 "Hidden configuration file - modifications require explicit approval".to_string()
527 }
528 }
529
530 fn build_warning_message(&self, context: &AccessContext) -> String {
532 let filename = context
533 .file_path
534 .file_name()
535 .and_then(|n| n.to_str())
536 .unwrap_or("unknown");
537
538 format!(
539 "DOTFILE PROTECTION WARNING\n\n\
540 The AI agent '{}' is requesting to {} the protected file '{}'.\n\n\
541 This is a hidden configuration file that could affect your system, \
542 development environment, or contain sensitive information.\n\n\
543 Proposed changes:\n{}\n\n\
544 Please review carefully before approving.",
545 context.initiator,
546 context.access_type.to_string().to_lowercase(),
547 filename,
548 context
549 .proposed_changes
550 .as_deref()
551 .unwrap_or("No details provided")
552 )
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use tempfile::tempdir;
560
561 async fn create_test_guardian() -> (DotfileGuardian, tempfile::TempDir) {
562 let dir = tempdir().unwrap();
563 let config = DotfileProtectionConfig {
564 audit_log_path: dir.path().join("audit.log").to_string_lossy().into_owned(),
565 backup_directory: dir.path().join("backups").to_string_lossy().into_owned(),
566 ..Default::default()
567 };
568
569 (DotfileGuardian::new(config).await.unwrap(), dir)
570 }
571
572 #[tokio::test]
573 async fn test_protection_detection() {
574 let (guardian, _dir) = create_test_guardian().await;
575
576 assert!(guardian.is_protected(Path::new(".gitignore")));
577 assert!(guardian.is_protected(Path::new(".env")));
578 assert!(guardian.is_protected(Path::new(".bashrc")));
579 assert!(guardian.is_protected(Path::new("/home/user/.ssh/config")));
580 assert!(!guardian.is_protected(Path::new("README.md")));
581 }
582
583 #[tokio::test]
584 async fn test_requires_confirmation() {
585 let (guardian, _dir) = create_test_guardian().await;
586
587 let context = AccessContext::new(
588 ".gitignore",
589 AccessType::Write,
590 "write_file",
591 "test-session",
592 )
593 .with_proposed_changes("Adding node_modules to ignore list");
594
595 let decision = guardian.request_access(&context).await.unwrap();
596
597 assert!(decision.requires_confirmation());
598 if let ProtectionDecision::RequiresConfirmation(req) = decision {
599 assert_eq!(req.file_path, ".gitignore");
600 assert!(req.warning.contains("DOTFILE PROTECTION WARNING"));
601 } else {
602 panic!("Expected RequiresConfirmation");
603 }
604 }
605
606 #[tokio::test]
607 async fn test_blocks_during_automation() {
608 let (guardian, _dir) = create_test_guardian().await;
609
610 let context =
611 AccessContext::new(".npmrc", AccessType::Write, "npm_install", "test-session")
612 .as_automated();
613
614 let decision = guardian.request_access(&context).await.unwrap();
615
616 assert!(decision.is_blocked());
617 }
618
619 #[tokio::test]
620 async fn test_blocks_cascading() {
621 let (guardian, _dir) = create_test_guardian().await;
622
623 let context1 = AccessContext::new(".gitignore", AccessType::Write, "test", "test-session");
625 let _ = guardian.request_access(&context1).await.unwrap();
626 guardian
627 .confirm_modification(&context1, false)
628 .await
629 .unwrap();
630
631 let context2 =
633 AccessContext::new(".gitattributes", AccessType::Write, "test", "test-session")
634 .as_cascading(".gitignore");
635
636 let decision = guardian.request_access(&context2).await.unwrap();
637 assert!(decision.is_blocked());
638 }
639
640 #[tokio::test]
641 async fn test_non_dotfile_allowed() {
642 let (guardian, _dir) = create_test_guardian().await;
643
644 let context =
645 AccessContext::new("README.md", AccessType::Write, "write_file", "test-session");
646
647 let decision = guardian.request_access(&context).await.unwrap();
648 assert!(decision.is_allowed());
649 }
650
651 #[tokio::test]
652 async fn test_disabled_protection() {
653 let dir = tempdir().unwrap();
654 let config = DotfileProtectionConfig {
655 enabled: false,
656 audit_log_path: dir.path().join("audit.log").to_string_lossy().into_owned(),
657 backup_directory: dir.path().join("backups").to_string_lossy().into_owned(),
658 ..Default::default()
659 };
660
661 let guardian = DotfileGuardian::new(config).await.unwrap();
662
663 let context = AccessContext::new(
664 ".gitignore",
665 AccessType::Write,
666 "write_file",
667 "test-session",
668 );
669
670 let decision = guardian.request_access(&context).await.unwrap();
671 assert!(decision.is_allowed());
672 }
673}