Skip to main content

vtcode_core/dotfile_protection/
guardian.rs

1//! Dotfile Guardian - The core protection mechanism.
2//!
3//! Provides comprehensive protection decisions for dotfile access,
4//! integrating audit logging, backup management, and cascade prevention.
5
6use 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
19/// Global dotfile guardian instance.
20static GLOBAL_GUARDIAN: OnceCell<Arc<DotfileGuardian>> = OnceCell::new();
21
22/// Initialize the global dotfile guardian.
23///
24/// Should be called once at application startup. Subsequent calls are ignored.
25pub 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
35/// Get the global dotfile guardian.
36///
37/// Returns None if the guardian hasn't been initialized.
38pub fn get_global_guardian() -> Option<Arc<DotfileGuardian>> {
39    GLOBAL_GUARDIAN.get().cloned()
40}
41
42/// Check if a path is a protected dotfile using the global guardian.
43///
44/// Returns false if the guardian hasn't been initialized.
45pub 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/// Decision from the dotfile guardian.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ProtectionDecision {
55    /// Access allowed (file is not a dotfile or protection is disabled).
56    Allowed,
57    /// Access requires explicit user confirmation.
58    RequiresConfirmation(ConfirmationRequest),
59    /// Access requires secondary authentication (for whitelisted files).
60    RequiresSecondaryAuth(ConfirmationRequest),
61    /// Access is blocked (during automation or cascading modification).
62    Blocked(ProtectionViolation),
63    /// Access is denied (policy violation).
64    Denied(ProtectionViolation),
65}
66
67impl ProtectionDecision {
68    /// Check if access is allowed without any user interaction.
69    pub fn is_allowed(&self) -> bool {
70        matches!(self, ProtectionDecision::Allowed)
71    }
72
73    /// Check if any form of confirmation is required.
74    pub fn requires_confirmation(&self) -> bool {
75        matches!(
76            self,
77            ProtectionDecision::RequiresConfirmation(_)
78                | ProtectionDecision::RequiresSecondaryAuth(_)
79        )
80    }
81
82    /// Check if access is blocked or denied.
83    pub fn is_blocked(&self) -> bool {
84        matches!(
85            self,
86            ProtectionDecision::Blocked(_) | ProtectionDecision::Denied(_)
87        )
88    }
89}
90
91/// Request for user confirmation.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ConfirmationRequest {
94    /// Path to the dotfile.
95    pub file_path: String,
96    /// Type of access being requested.
97    pub access_type: String,
98    /// Detailed description of proposed changes.
99    pub proposed_changes: String,
100    /// Tool or operation requesting access.
101    pub initiator: String,
102    /// Why this file is protected.
103    pub protection_reason: String,
104    /// Whether this is a whitelisted file (requires secondary auth).
105    pub is_whitelisted: bool,
106    /// Warning message for the user.
107    pub warning: String,
108}
109
110/// A protection violation.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
112#[error("Dotfile protection violation for '{file_path}': {reason}. {suggestion}")]
113pub struct ProtectionViolation {
114    /// Path to the dotfile.
115    pub file_path: String,
116    /// Type of access attempted.
117    pub access_type: String,
118    /// Reason for the violation.
119    pub reason: String,
120    /// Suggested action.
121    pub suggestion: String,
122}
123
124/// Context for a dotfile access request.
125#[derive(Debug, Clone)]
126pub struct AccessContext {
127    /// Path to the dotfile being accessed.
128    pub file_path: PathBuf,
129    /// Type of access being requested.
130    pub access_type: AccessType,
131    /// Tool or operation requesting access.
132    pub initiator: String,
133    /// Session identifier.
134    pub session_id: String,
135    /// Description of proposed changes.
136    pub proposed_changes: Option<String>,
137    /// Whether this is during an automated operation.
138    pub is_automated: bool,
139    /// Whether this is a cascading modification.
140    pub is_cascading: bool,
141    /// Parent file that triggered this modification (if cascading).
142    pub triggered_by: Option<PathBuf>,
143}
144
145impl AccessContext {
146    /// Create a new access context.
147    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    /// Set proposed changes.
166    pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
167        self.proposed_changes = Some(changes.into());
168        self
169    }
170
171    /// Mark as automated operation.
172    pub fn as_automated(mut self) -> Self {
173        self.is_automated = true;
174        self
175    }
176
177    /// Mark as cascading modification.
178    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/// The Dotfile Guardian.
186///
187/// Central protection mechanism that:
188/// - Detects protected dotfiles
189/// - Enforces confirmation requirements
190/// - Logs all access attempts
191/// - Manages backups
192/// - Prevents cascading modifications
193#[derive(Clone)]
194pub struct DotfileGuardian {
195    /// Configuration.
196    config: DotfileProtectionConfig,
197    /// Audit log.
198    audit_log: Option<Arc<AuditLog>>,
199    /// Backup manager.
200    backup_manager: Option<Arc<BackupManager>>,
201    /// Protected state
202    state: Arc<Mutex<GuardianState>>,
203}
204
205/// Inner state for DotfileGuardian
206#[derive(Debug, Default)]
207struct GuardianState {
208    /// Files modified in current session (for cascade detection).
209    modified_files: HashSet<PathBuf>,
210    /// Pending modifications (waiting for confirmation).
211    pending_modifications: HashSet<PathBuf>,
212}
213
214impl DotfileGuardian {
215    /// Expand tilde (~) in paths to home directory.
216    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    /// Create a new dotfile guardian with the given configuration.
226    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    /// Create a guardian with default configuration.
258    pub async fn with_defaults() -> Result<Self> {
259        Self::new(DotfileProtectionConfig::default()).await
260    }
261
262    /// Check if a file path is a protected dotfile.
263    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    /// Check if a file is whitelisted.
269    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    /// Request access to a dotfile.
275    ///
276    /// Returns a protection decision that must be handled by the caller.
277    pub async fn request_access(&self, context: &AccessContext) -> Result<ProtectionDecision> {
278        // Check if protection is enabled
279        if !self.config.enabled {
280            self.log_access(context, AuditOutcome::AllowedUnprotected)
281                .await?;
282            return Ok(ProtectionDecision::Allowed);
283        }
284
285        // Check if this is a protected file
286        if !self.is_protected(&context.file_path) {
287            return Ok(ProtectionDecision::Allowed);
288        }
289
290        // Check for cascading modification
291        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        // Check if blocked during automation
311        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        // Build confirmation request
326        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        // Track pending modification
340        {
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            // Protection enabled but no confirmation required (unusual config)
355            self.log_access(context, AuditOutcome::AllowedUnprotected)
356                .await?;
357            Ok(ProtectionDecision::Allowed)
358        }
359    }
360
361    /// Record that user confirmed the modification.
362    pub async fn confirm_modification(
363        &self,
364        context: &AccessContext,
365        is_whitelisted: bool,
366    ) -> Result<()> {
367        // Create backup before modification
368        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        // Log the confirmed access
381        let outcome = if is_whitelisted {
382            AuditOutcome::AllowedViaWhitelist
383        } else {
384            AuditOutcome::AllowedWithConfirmation
385        };
386        self.log_access(context, outcome).await?;
387
388        // Update state
389        {
390            let mut state = self.state.lock().await;
391            // Track modified file (for cascade detection)
392            state.modified_files.insert(context.file_path.clone());
393            // Remove from pending
394            state.pending_modifications.remove(&context.file_path);
395        }
396
397        Ok(())
398    }
399
400    /// Record that user rejected the modification.
401    pub async fn reject_modification(&self, context: &AccessContext) -> Result<()> {
402        self.log_access(context, AuditOutcome::UserRejected).await?;
403
404        // Remove from pending
405        {
406            let mut state = self.state.lock().await;
407            state.pending_modifications.remove(&context.file_path);
408        }
409
410        Ok(())
411    }
412
413    /// Check if modifying a file would trigger a cascade.
414    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    /// Get the most recent backup for a file.
424    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    /// Restore a file from its most recent backup.
435    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    /// Get audit entries for a file.
445    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    /// Verify audit log integrity.
453    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    /// Reset session state (for new conversation).
461    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    /// Get list of files modified in this session.
468    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    /// Log an access attempt.
474    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    /// Get a human-readable reason why a file is protected.
509    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    /// Build a warning message for the user.
531    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        // First modification
624        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        // Cascading modification
632        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}