agentsec-core 0.2.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Inventory enumeration: turn the fixed target-root list into hashed
//! [`PathEntry`] rows.
//!
//! ## Target roots
//!
//! Hard-coded list in `target_roots`. Two classes:
//!
//! - **Home-rooted** — Claude Code config / skills / agents / plugins
//!   under [`crate::Paths::user_home`] joined with `.claude/`, and
//!   `<user_home>/.claude.json`.
//! - **Project-rooted** — `.claude/settings.json`, `.mcp.json`, dependency
//!   manifests + lockfiles, `.env` resolved relative to the current working
//!   directory.
//!
//! Missing targets are silently skipped (no error). Directories are walked
//! recursively, with `SKIP_DIRS` / `SKIP_FILES` noise filtered out.
//! Symlinks are not followed.
//!
//! ## `~/.claude.json` decomposition
//!
//! `path_entries` treats `local_config` as a special category and splits
//! the JSON into virtual fragments keyed by `<file>#<json-path>` for the
//! three security-relevant blocks (`mcpServers`, `hooks`, `permissions`,
//! both top-level and per-project under `projects.<p>.<key>`). This
//! prevents unrelated background writes (session counters, cache, last-used
//! timestamps) from appearing as "modified" in the diff. If the file is
//! present but unparseable, the entry falls back to a single full-file
//! hash; if no watched block is present, a single `#(no-watched-block)`
//! sentinel entry is emitted so creation / deletion is still tracked.

use crate::Paths;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

/// One hashed file or virtual JSON fragment.
///
/// For most files this represents `sha256(full bytes)`; for `~/.claude.json`
/// it represents `sha256(canonical JSON of one watched block)` and the
/// `path` field carries a `<file>#<fragment>` virtual suffix (see module
/// docs §`~/.claude.json` decomposition).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PathEntry {
    /// Absolute path of the file, optionally suffixed with `#<fragment>`
    /// for virtual JSON-block entries.
    pub path: PathBuf,
    /// Target-root category label from `target_roots`.
    pub category: String,
    /// Lowercase hex SHA-256 of the file bytes (or canonical fragment bytes).
    pub sha256: String,
    /// Size in bytes of the hashed payload.
    pub size: u64,
}

/// Static list of (category, path) targets that AgentSec inventories by
/// default. Home-rooted targets are joined under `paths.user_home`;
/// project-rooted ones are relative to the current working directory.
/// Missing targets are silently skipped at collect time.
fn target_roots(paths: &Paths) -> Vec<(&'static str, PathBuf)> {
    let h = &paths.user_home;
    vec![
        // ── Claude Code global config ─────────────────────────────────────
        ("settings", h.join(".claude/settings.json")),
        ("settings_local", h.join(".claude/settings.local.json")),
        ("local_config", h.join(".claude.json")), // project-keyed mcpServers, hooks etc.
        ("claude_md", h.join(".claude/CLAUDE.md")), // global discipline prompt
        ("rules", h.join(".claude/rules")),       // rule files imported into CLAUDE.md
        // ── Claude Code Skill / Agent / Plugin layers ─────────────────────
        ("skills", h.join(".claude/skills")),
        ("agents", h.join(".claude/agents")),
        ("plugins", h.join(".claude/plugins/marketplaces")),
        // ── Project-local Claude Code config ──────────────────────────────
        ("settings_project", PathBuf::from(".claude/settings.json")),
        ("mcp_project", PathBuf::from(".mcp.json")),
        // ── Dependency manifests + lockfiles (supply chain) ───────────────
        ("manifest_npm", PathBuf::from("package.json")),
        ("manifest_cargo", PathBuf::from("Cargo.toml")),
        ("manifest_python", PathBuf::from("pyproject.toml")),
        ("lockfile_npm", PathBuf::from("package-lock.json")),
        ("lockfile_yarn", PathBuf::from("yarn.lock")),
        ("lockfile_cargo", PathBuf::from("Cargo.lock")),
        ("lockfile_poetry", PathBuf::from("poetry.lock")),
        ("lockfile_uv", PathBuf::from("uv.lock")),
        // ── Secrets dotfile (sha256 only, contents never persisted) ───────
        ("env_project", PathBuf::from(".env")),
    ]
}

/// Walk all `target_roots` and return a sorted list of [`PathEntry`].
///
/// `paths.user_home` is used to construct home-rooted absolute paths.
/// Missing target roots are skipped silently. Files yield one entry each
/// (except `local_config`, which yields one entry per watched JSON block);
/// directories are walked recursively with `SKIP_DIRS` / `SKIP_FILES`
/// filtered out. The returned list is sorted by [`PathEntry::path`] so the
/// snapshot is reproducible and diffable across runs.
///
/// # Errors
///
/// Returns [`crate::Error::Io`] if a target exists but cannot be read
/// (permission denied, vanished mid-walk, etc.).
pub fn collect(paths: &Paths) -> Result<Vec<PathEntry>> {
    let mut out = Vec::new();
    for (category, root) in target_roots(paths) {
        if !root.exists() {
            continue;
        }
        if root.is_file() {
            out.extend(path_entries(&root, category)?);
        } else if root.is_dir() {
            walk(&root, category, &mut out)?;
        }
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(out)
}

/// Directory names we never descend into. Cuts scan noise from VCS / build /
/// dependency caches that aren't AgentSec's domain.
const SKIP_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    "target",
    ".venv",
    "venv",
    "__pycache__",
    ".cache",
    ".idea",
    ".vscode",
    "dist",
    "build",
    ".next",
    ".turbo",
];

/// File names we never hash.
const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];

fn should_skip(name: &str, is_dir: bool) -> bool {
    if is_dir {
        SKIP_DIRS.contains(&name)
    } else {
        SKIP_FILES.contains(&name)
    }
}

fn walk(dir: &Path, category: &str, out: &mut Vec<PathEntry>) -> Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let file_type = entry.file_type()?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        if should_skip(&name_str, file_type.is_dir()) {
            continue;
        }

        if file_type.is_dir() {
            walk(&path, category, out)?;
        } else if file_type.is_file() {
            out.extend(path_entries(&path, category)?);
        }
        // symlinks are intentionally skipped (no follow) to keep scan read-only safe.
    }
    Ok(())
}

/// Compute one or more `PathEntry` rows for a file.
///
/// For most categories this is a single `sha256(full file bytes)` row. For
/// `local_config` (`~/.claude.json`) the file is decomposed into virtual
/// sub-entries — one per *watched* JSON block (`mcpServers`, `hooks`,
/// `permissions`, both top-level and per-project) — so unrelated background
/// writes (session counters, timestamps, cache) don't show as Modified noise.
fn path_entries(path: &Path, category: &str) -> Result<Vec<PathEntry>> {
    let metadata = fs::metadata(path)?;
    if !metadata.is_file() {
        return Ok(Vec::new());
    }
    let bytes = fs::read(path)?;

    if category == "local_config" {
        if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
            return Ok(extract_local_config_entries(path, &json));
        }
        // JSON parse failed: fall through to a single full-file hash so the
        // file is still tracked, just at the original coarse granularity.
    }

    // Canonicalize the path so that symlinks and `..` components are resolved
    // to their absolute real path.  Fall back to the original path on error
    // (e.g. if the file is a symlink whose target has been removed between
    // the `metadata` call and `canonicalize`).
    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    Ok(vec![PathEntry {
        path: canonical_path,
        category: category.to_string(),
        sha256: sha256_hex(&bytes),
        size: metadata.len(),
    }])
}

/// JSON keys at the top level of `.claude.json` that we treat as
/// security-relevant injection surfaces.
const LOCAL_CONFIG_WATCH_KEYS: &[&str] = &["mcpServers", "hooks", "permissions"];

fn extract_local_config_entries(path: &Path, json: &serde_json::Value) -> Vec<PathEntry> {
    let mut out = Vec::new();

    // Top-level watched blocks.
    for key in LOCAL_CONFIG_WATCH_KEYS {
        if let Some(value) = json.get(key) {
            out.push(virtual_entry(path, "local_config", key, value));
        }
    }

    // Per-project watched blocks: projects.<project>.mcpServers etc.
    if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
        for (proj_name, proj_val) in projects {
            for key in LOCAL_CONFIG_WATCH_KEYS {
                if let Some(value) = proj_val.get(key) {
                    let fragment = format!("projects.{proj_name}.{key}");
                    out.push(virtual_entry(path, "local_config", &fragment, value));
                }
            }
        }
    }

    if out.is_empty() {
        // No watched block present — emit a sentinel entry so existence /
        // creation of the file is still tracked.
        out.push(virtual_entry(
            path,
            "local_config",
            "(no-watched-block)",
            &serde_json::Value::Null,
        ));
    }
    out
}

fn virtual_entry(
    path: &Path,
    category: &str,
    fragment: &str,
    value: &serde_json::Value,
) -> PathEntry {
    let canonical = serde_json::to_string(value).unwrap_or_default();
    let size = canonical.len() as u64;
    let sha256 = sha256_hex(canonical.as_bytes());
    let virtual_path = PathBuf::from(format!("{}#{fragment}", path.display()));
    PathEntry {
        path: virtual_path,
        category: category.to_string(),
        sha256,
        size,
    }
}

fn sha256_hex(bytes: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(bytes);
    format!("{:x}", h.finalize())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn path_entries_computes_sha256_for_regular_file() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(b"hello agentsec").unwrap();
        let entries = path_entries(tmp.path(), "test").unwrap();
        assert_eq!(entries.len(), 1);
        let e = &entries[0];
        assert_eq!(e.size, 14);
        assert_eq!(e.sha256.len(), 64);
        assert_eq!(e.category, "test");
    }

    #[test]
    fn walk_collects_files_recursively() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("sub");
        fs::create_dir(&sub).unwrap();
        fs::write(dir.path().join("a.txt"), "a").unwrap();
        fs::write(sub.join("b.txt"), "bb").unwrap();
        let mut out = Vec::new();
        walk(dir.path(), "x", &mut out).unwrap();
        assert_eq!(out.len(), 2);
    }

    #[test]
    fn local_config_emits_virtual_entries_per_watch_block() {
        // .claude.json shape: top-level mcpServers + projects.<p>.hooks.
        let body = r#"{
            "mcpServers": {"a": {"command": "x"}},
            "permissions": {"allow": []},
            "lastSessionId": "noise-should-be-ignored",
            "counters": {"step": 42},
            "projects": {
                "/path/p": {
                    "hooks": {"UserPromptSubmit": []},
                    "mcpServers": {"b": {"command": "y"}}
                }
            }
        }"#;
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(body.as_bytes()).unwrap();

        let entries = path_entries(tmp.path(), "local_config").unwrap();
        let fragments: Vec<String> = entries
            .iter()
            .map(|e| {
                e.path
                    .to_string_lossy()
                    .rsplit_once('#')
                    .map(|(_, frag)| frag.to_string())
                    .unwrap_or_default()
            })
            .collect();
        assert!(fragments.contains(&"mcpServers".to_string()));
        assert!(fragments.contains(&"permissions".to_string()));
        assert!(fragments.iter().any(|f| f == "projects./path/p.hooks"));
        assert!(fragments.iter().any(|f| f == "projects./path/p.mcpServers"));
        // Unwatched keys (lastSessionId / counters) must NOT appear as fragments.
        assert!(!fragments.iter().any(|f| f == "lastSessionId"));
        assert!(!fragments.iter().any(|f| f == "counters"));
    }

    #[test]
    fn local_config_with_no_watched_block_emits_sentinel() {
        let body = r#"{"unrelated": 1}"#;
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(body.as_bytes()).unwrap();
        let entries = path_entries(tmp.path(), "local_config").unwrap();
        assert_eq!(entries.len(), 1);
        assert!(
            entries[0]
                .path
                .to_string_lossy()
                .ends_with("#(no-watched-block)")
        );
    }

    #[test]
    fn local_config_with_unparseable_json_falls_back_to_full_hash() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(b"not json at all").unwrap();
        let entries = path_entries(tmp.path(), "local_config").unwrap();
        assert_eq!(entries.len(), 1);
        // fallback path = original file path, no '#' fragment
        assert!(!entries[0].path.to_string_lossy().contains('#'));
    }
}