agentsec-core 0.3.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Inventory scan over agent config / dependency manifests / secrets dotfile.
//! Covers the **L1 × V3** cell (cf. *crate root §Threat surface × vector*).
//!
//! ## Pipeline
//!
//! [`run`] composes the three sub-modules in order:
//!
//! 1. [`inventory::collect`] — walk a fixed list of target roots, hash each
//!    file with SHA-256, decompose `~/.claude.json` into virtual fragments
//!    so background writes do not show as modifications.
//! 2. [`snapshot::save`] — persist the report as
//!    `<home>/snapshots/<UTC-ts>.json` (cf. *crate root §Runtime data root*).
//! 3. [`diff::compute`] — if a previous snapshot exists, classify the new
//!    report into added / modified / removed.
//!
//! The function is **idempotent under no-change**: re-running [`run`] when no
//! tracked file has changed produces a new snapshot file but an empty
//! [`diff::DiffReport`].
//!
//! ## Read-only invariant
//!
//! No path under [`inventory::collect`]'s target roots is ever mutated.
//! Writes are scoped to `<home>/snapshots/`. Symlinks are not followed.

pub mod diff;
pub mod inventory;
pub mod snapshot;
pub mod unknown;

use crate::Paths;
use crate::error::Result;
use crate::platform::PlatformProbe;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// One scan's worth of inventory data, before any diffing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScanReport {
    /// UTC timestamp of when this report was constructed.
    pub scanned_at: chrono::DateTime<chrono::Utc>,
    /// All path entries collected, sorted by [`inventory::PathEntry::path`].
    pub paths: Vec<inventory::PathEntry>,
}

/// Full output of one [`run`] call: the inventory, the path of the persisted
/// snapshot, and (if a previous snapshot existed) the diff against it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOutcome {
    /// The inventory report just produced.
    pub report: ScanReport,
    /// Absolute path of the snapshot file written under
    /// `<home>/snapshots/<UTC-ts>.json`.
    pub snapshot_path: PathBuf,
    /// `Some` when a previous snapshot was found and compared against;
    /// `None` for the first-ever scan (baseline run).
    pub diff: Option<diff::DiffReport>,
}

/// Run a full scan: enumerate inventory, persist a snapshot, and (if a
/// previous snapshot exists) compute a diff against it.
///
/// `paths.user_home` drives inventory target roots; `paths.home` drives
/// snapshot persistence. `probes` enumerates the per-platform target
/// roots (e.g. `&[&ClaudeCodePlatform::new()]` for Claude Code only).
///
/// # Errors
///
/// Returns [`crate::Error::Io`] if the snapshot directory cannot be
/// created, [`crate::Error::Json`] on snapshot serialization failure, or
/// [`crate::Error::Scan`] on snapshot parse failure when loading the
/// previous snapshot.
pub fn run(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<ScanOutcome> {
    let prev = snapshot::load_latest(paths)?;
    let report = ScanReport {
        scanned_at: chrono::Utc::now(),
        paths: inventory::collect(paths, probes)?,
    };
    let snapshot_path = snapshot::save(paths, &report)?;
    let diff = prev.as_ref().map(|p| diff::compute(p, &report));
    Ok(ScanOutcome {
        report,
        snapshot_path,
        diff,
    })
}

/// Compute the diff between the current inventory and the latest snapshot
/// **without persisting a new snapshot**.
///
/// Use this when you want a read-only "what changed since the last
/// `scan::run`" view — repeated calls compare against the same baseline.
/// Returns `Ok(None)` when no previous snapshot exists yet.
///
/// `paths.user_home` drives inventory target roots; `paths.home` drives
/// snapshot lookup. No I/O is performed under `paths.home/snapshots/`.
///
/// # Errors
///
/// Returns [`crate::Error::Io`] on inventory walk failure or
/// [`crate::Error::Scan`] on snapshot parse failure.
pub fn diff_against_latest(
    paths: &Paths,
    probes: &[&dyn PlatformProbe],
) -> Result<Option<diff::DiffReport>> {
    let Some(prev) = snapshot::load_latest(paths)? else {
        return Ok(None);
    };
    let curr = ScanReport {
        scanned_at: chrono::Utc::now(),
        paths: inventory::collect(paths, probes)?,
    };
    Ok(Some(diff::compute(&prev, &curr)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::platform::McpServerEntry;
    use std::path::{Path, PathBuf};

    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
        Paths {
            home: tmp.path().to_path_buf(),
            user_home: tmp.path().to_path_buf(),
        }
    }

    /// Empty-output stub probe. These tests exercise the snapshot /
    /// diff plumbing on an isolated temp directory with no platform
    /// config files present, so the probe only needs to satisfy the
    /// trait surface — returning empty roots/configs is enough.
    struct TestNoopProbe;

    impl PlatformProbe for TestNoopProbe {
        fn id(&self) -> &'static str {
            "noop"
        }
        fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
            Vec::new()
        }
        fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
            Vec::new()
        }
        fn extract_mcp_servers(&self, _content: &str, _path: &Path) -> Result<Vec<McpServerEntry>> {
            Ok(Vec::new())
        }
    }

    fn noop_only(probe: &TestNoopProbe) -> [&dyn PlatformProbe; 1] {
        [probe as &dyn PlatformProbe]
    }

    #[test]
    fn diff_against_latest_returns_none_for_baseline() {
        let tmp = tempfile::tempdir().unwrap();
        let paths = paths_for(&tmp);
        let probe = TestNoopProbe;
        // No snapshot has been written yet ⇒ no baseline to diff against.
        assert!(
            diff_against_latest(&paths, &noop_only(&probe))
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn diff_against_latest_does_not_create_new_snapshot() {
        let tmp = tempfile::tempdir().unwrap();
        let paths = paths_for(&tmp);
        let probe = TestNoopProbe;
        // Run once to establish a baseline; record the snapshot count.
        run(&paths, &noop_only(&probe)).unwrap();
        let snapshot_dir = paths.snapshots();
        let before = std::fs::read_dir(&snapshot_dir).unwrap().count();
        // diff_against_latest should NOT add another snapshot file.
        let diff = diff_against_latest(&paths, &noop_only(&probe)).unwrap();
        let after = std::fs::read_dir(&snapshot_dir).unwrap().count();
        assert_eq!(before, after, "diff_against_latest must not persist");
        // And the diff itself should be Some(empty), not None, because a
        // baseline now exists and nothing has changed.
        let d = diff.expect("diff exists when baseline exists");
        assert!(d.is_empty(), "no-op diff must be empty");
    }
}