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.get().map(|g| g.is_protected(path)).unwrap_or(false)
47}
48
49/// Decision from the dotfile guardian.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum ProtectionDecision {
52    /// Access allowed (file is not a dotfile or protection is disabled).
53    Allowed,
54    /// Access requires explicit user confirmation.
55    RequiresConfirmation(ConfirmationRequest),
56    /// Access requires secondary authentication (for whitelisted files).
57    RequiresSecondaryAuth(ConfirmationRequest),
58    /// Access is blocked (during automation or cascading modification).
59    Blocked(ProtectionViolation),
60    /// Access is denied (policy violation).
61    Denied(ProtectionViolation),
62}
63
64impl ProtectionDecision {
65    /// Check if access is allowed without any user interaction.
66    pub fn is_allowed(&self) -> bool {
67        matches!(self, ProtectionDecision::Allowed)
68    }
69
70    /// Check if any form of confirmation is required.
71    pub fn requires_confirmation(&self) -> bool {
72        matches!(self, ProtectionDecision::RequiresConfirmation(_) | ProtectionDecision::RequiresSecondaryAuth(_))
73    }
74
75    /// Check if access is blocked or denied.
76    pub fn is_blocked(&self) -> bool {
77        matches!(self, ProtectionDecision::Blocked(_) | ProtectionDecision::Denied(_))
78    }
79}
80
81/// Request for user confirmation.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ConfirmationRequest {
84    /// Path to the dotfile.
85    pub file_path: String,
86    /// Type of access being requested.
87    pub access_type: String,
88    /// Detailed description of proposed changes.
89    pub proposed_changes: String,
90    /// Tool or operation requesting access.
91    pub initiator: String,
92    /// Why this file is protected.
93    pub protection_reason: String,
94    /// Whether this is a whitelisted file (requires secondary auth).
95    pub is_whitelisted: bool,
96    /// Warning message for the user.
97    pub warning: String,
98}
99
100/// A protection violation.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
102#[error("Dotfile protection violation for '{file_path}': {reason}. {suggestion}")]
103pub struct ProtectionViolation {
104    /// Path to the dotfile.
105    pub file_path: String,
106    /// Type of access attempted.
107    pub access_type: String,
108    /// Reason for the violation.
109    pub reason: String,
110    /// Suggested action.
111    pub suggestion: String,
112}
113
114/// Context for a dotfile access request.
115#[derive(Debug, Clone)]
116pub struct AccessContext {
117    /// Path to the dotfile being accessed.
118    pub file_path: PathBuf,
119    /// Type of access being requested.
120    pub access_type: AccessType,
121    /// Tool or operation requesting access.
122    pub initiator: String,
123    /// Session identifier.
124    pub session_id: String,
125    /// Description of proposed changes.
126    pub proposed_changes: Option<String>,
127    /// Whether this is during an automated operation.
128    pub is_automated: bool,
129    /// Whether this is a cascading modification.
130    pub is_cascading: bool,
131    /// Parent file that triggered this modification (if cascading).
132    pub triggered_by: Option<PathBuf>,
133}
134
135impl AccessContext {
136    /// Create a new access context.
137    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    /// Set proposed changes.
156    pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
157        self.proposed_changes = Some(changes.into());
158        self
159    }
160
161    /// Mark as automated operation.
162    pub fn as_automated(mut self) -> Self {
163        self.is_automated = true;
164        self
165    }
166
167    /// Mark as cascading modification.
168    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/// The Dotfile Guardian.
176///
177/// Central protection mechanism that:
178/// - Detects protected dotfiles
179/// - Enforces confirmation requirements
180/// - Logs all access attempts
181/// - Manages backups
182/// - Prevents cascading modifications
183#[derive(Clone)]
184pub struct DotfileGuardian {
185    /// Configuration.
186    config: DotfileProtectionConfig,
187    /// Audit log.
188    audit_log: Option<Arc<AuditLog>>,
189    /// Backup manager.
190    backup_manager: Option<Arc<BackupManager>>,
191    /// Protected state
192    state: Arc<Mutex<GuardianState>>,
193}
194
195/// Inner state for DotfileGuardian
196#[derive(Debug, Default)]
197struct GuardianState {
198    /// Files modified in current session (for cascade detection).
199    modified_files: HashSet<PathBuf>,
200    /// Pending modifications (waiting for confirmation).
201    pending_modifications: HashSet<PathBuf>,
202}
203
204impl DotfileGuardian {
205    /// Expand tilde (~) in paths to home directory.
206    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    /// Create a new dotfile guardian with the given configuration.
216    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    /// Create a guardian with default configuration.
248    pub async fn with_defaults() -> Result<Self> {
249        Self::new(DotfileProtectionConfig::default()).await
250    }
251
252    /// Check if a file path is a protected dotfile.
253    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    /// Check if a file is whitelisted.
259    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    /// Request access to a dotfile.
265    ///
266    /// Returns a protection decision that must be handled by the caller.
267    pub async fn request_access(&self, context: &AccessContext) -> Result<ProtectionDecision> {
268        // Check if protection is enabled
269        if !self.config.enabled {
270            self.log_access(context, AuditOutcome::AllowedUnprotected).await?;
271            return Ok(ProtectionDecision::Allowed);
272        }
273
274        // Check if this is a protected file
275        if !self.is_protected(&context.file_path) {
276            return Ok(ProtectionDecision::Allowed);
277        }
278
279        // Check for cascading modification
280        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        // Check if blocked during automation
299        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        // Build confirmation request
311        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        // Track pending modification
325        {
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            // Protection enabled but no confirmation required (unusual config)
336            self.log_access(context, AuditOutcome::AllowedUnprotected).await?;
337            Ok(ProtectionDecision::Allowed)
338        }
339    }
340
341    /// Record that user confirmed the modification.
342    pub async fn confirm_modification(&self, context: &AccessContext, is_whitelisted: bool) -> Result<()> {
343        // Create backup before modification
344        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        // Log the confirmed access
357        let outcome = if is_whitelisted {
358            AuditOutcome::AllowedViaWhitelist
359        } else {
360            AuditOutcome::AllowedWithConfirmation
361        };
362        self.log_access(context, outcome).await?;
363
364        // Update state
365        {
366            let mut state = self.state.lock().await;
367            // Track modified file (for cascade detection)
368            state.modified_files.insert(context.file_path.clone());
369            // Remove from pending
370            state.pending_modifications.remove(&context.file_path);
371        }
372
373        Ok(())
374    }
375
376    /// Record that user rejected the modification.
377    pub async fn reject_modification(&self, context: &AccessContext) -> Result<()> {
378        self.log_access(context, AuditOutcome::UserRejected).await?;
379
380        // Remove from pending
381        {
382            let mut state = self.state.lock().await;
383            state.pending_modifications.remove(&context.file_path);
384        }
385
386        Ok(())
387    }
388
389    /// Check if modifying a file would trigger a cascade.
390    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    /// Get the most recent backup for a file.
400    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    /// Restore a file from its most recent backup.
408    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    /// Get audit entries for a file.
418    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    /// Verify audit log integrity.
426    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    /// Reset session state (for new conversation).
434    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    /// Get list of files modified in this session.
441    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    /// Log an access attempt.
447    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    /// Get a human-readable reason why a file is protected.
479    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    /// Build a warning message for the user.
498    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        // First modification
577        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        // Cascading modification
582        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}