Skip to main content

agentsec_core/scan/
inventory.rs

1//! Inventory enumeration: turn the fixed target-root list into hashed
2//! [`PathEntry`] rows.
3//!
4//! ## Target roots
5//!
6//! Hard-coded list in `target_roots`. Two classes:
7//!
8//! - **Home-rooted** — Claude Code config / skills / agents / plugins
9//!   under [`crate::Paths::user_home`] joined with `.claude/`, and
10//!   `<user_home>/.claude.json`.
11//! - **Project-rooted** — `.claude/settings.json`, `.mcp.json`, dependency
12//!   manifests + lockfiles, `.env` resolved relative to the current working
13//!   directory.
14//!
15//! Missing targets are silently skipped (no error). Directories are walked
16//! recursively, with `SKIP_DIRS` / `SKIP_FILES` noise filtered out.
17//! Symlinks are not followed.
18//!
19//! ## `~/.claude.json` decomposition
20//!
21//! `path_entries` treats `local_config` as a special category and splits
22//! the JSON into virtual fragments keyed by `<file>#<json-path>` for the
23//! three security-relevant blocks (`mcpServers`, `hooks`, `permissions`,
24//! both top-level and per-project under `projects.<p>.<key>`). This
25//! prevents unrelated background writes (session counters, cache, last-used
26//! timestamps) from appearing as "modified" in the diff. If the file is
27//! present but unparseable, the entry falls back to a single full-file
28//! hash; if no watched block is present, a single `#(no-watched-block)`
29//! sentinel entry is emitted so creation / deletion is still tracked.
30
31use crate::Paths;
32use crate::error::Result;
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35use std::fs;
36use std::path::{Path, PathBuf};
37
38/// One hashed file or virtual JSON fragment.
39///
40/// For most files this represents `sha256(full bytes)`; for `~/.claude.json`
41/// it represents `sha256(canonical JSON of one watched block)` and the
42/// `path` field carries a `<file>#<fragment>` virtual suffix (see module
43/// docs §`~/.claude.json` decomposition).
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct PathEntry {
46    /// Absolute path of the file, optionally suffixed with `#<fragment>`
47    /// for virtual JSON-block entries.
48    pub path: PathBuf,
49    /// Target-root category label from `target_roots`.
50    pub category: String,
51    /// Lowercase hex SHA-256 of the file bytes (or canonical fragment bytes).
52    pub sha256: String,
53    /// Size in bytes of the hashed payload.
54    pub size: u64,
55}
56
57/// Static list of (category, path) targets that AgentSec inventories by
58/// default. Home-rooted targets are joined under `paths.user_home`;
59/// project-rooted ones are relative to the current working directory.
60/// Missing targets are silently skipped at collect time.
61fn target_roots(paths: &Paths) -> Vec<(&'static str, PathBuf)> {
62    let h = &paths.user_home;
63    vec![
64        // ── Claude Code global config ─────────────────────────────────────
65        ("settings", h.join(".claude/settings.json")),
66        ("settings_local", h.join(".claude/settings.local.json")),
67        ("local_config", h.join(".claude.json")), // project-keyed mcpServers, hooks etc.
68        ("claude_md", h.join(".claude/CLAUDE.md")), // global discipline prompt
69        ("rules", h.join(".claude/rules")),       // rule files imported into CLAUDE.md
70        // ── Claude Code Skill / Agent / Plugin layers ─────────────────────
71        ("skills", h.join(".claude/skills")),
72        ("agents", h.join(".claude/agents")),
73        ("plugins", h.join(".claude/plugins/marketplaces")),
74        // ── Project-local Claude Code config ──────────────────────────────
75        ("settings_project", PathBuf::from(".claude/settings.json")),
76        ("mcp_project", PathBuf::from(".mcp.json")),
77        // ── Dependency manifests + lockfiles (supply chain) ───────────────
78        ("manifest_npm", PathBuf::from("package.json")),
79        ("manifest_cargo", PathBuf::from("Cargo.toml")),
80        ("manifest_python", PathBuf::from("pyproject.toml")),
81        ("lockfile_npm", PathBuf::from("package-lock.json")),
82        ("lockfile_yarn", PathBuf::from("yarn.lock")),
83        ("lockfile_cargo", PathBuf::from("Cargo.lock")),
84        ("lockfile_poetry", PathBuf::from("poetry.lock")),
85        ("lockfile_uv", PathBuf::from("uv.lock")),
86        // ── Secrets dotfile (sha256 only, contents never persisted) ───────
87        ("env_project", PathBuf::from(".env")),
88    ]
89}
90
91/// Walk all `target_roots` and return a sorted list of [`PathEntry`].
92///
93/// `paths.user_home` is used to construct home-rooted absolute paths.
94/// Missing target roots are skipped silently. Files yield one entry each
95/// (except `local_config`, which yields one entry per watched JSON block);
96/// directories are walked recursively with `SKIP_DIRS` / `SKIP_FILES`
97/// filtered out. The returned list is sorted by [`PathEntry::path`] so the
98/// snapshot is reproducible and diffable across runs.
99///
100/// # Errors
101///
102/// Returns [`crate::Error::Io`] if a target exists but cannot be read
103/// (permission denied, vanished mid-walk, etc.).
104pub fn collect(paths: &Paths) -> Result<Vec<PathEntry>> {
105    let mut out = Vec::new();
106    for (category, root) in target_roots(paths) {
107        if !root.exists() {
108            continue;
109        }
110        if root.is_file() {
111            out.extend(path_entries(&root, category)?);
112        } else if root.is_dir() {
113            walk(&root, category, &mut out)?;
114        }
115    }
116    out.sort_by(|a, b| a.path.cmp(&b.path));
117    Ok(out)
118}
119
120/// Directory names we never descend into. Cuts scan noise from VCS / build /
121/// dependency caches that aren't AgentSec's domain.
122const SKIP_DIRS: &[&str] = &[
123    ".git",
124    "node_modules",
125    "target",
126    ".venv",
127    "venv",
128    "__pycache__",
129    ".cache",
130    ".idea",
131    ".vscode",
132    "dist",
133    "build",
134    ".next",
135    ".turbo",
136];
137
138/// File names we never hash.
139const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
140
141fn should_skip(name: &str, is_dir: bool) -> bool {
142    if is_dir {
143        SKIP_DIRS.contains(&name)
144    } else {
145        SKIP_FILES.contains(&name)
146    }
147}
148
149fn walk(dir: &Path, category: &str, out: &mut Vec<PathEntry>) -> Result<()> {
150    for entry in fs::read_dir(dir)? {
151        let entry = entry?;
152        let path = entry.path();
153        let file_type = entry.file_type()?;
154        let name = entry.file_name();
155        let name_str = name.to_string_lossy();
156
157        if should_skip(&name_str, file_type.is_dir()) {
158            continue;
159        }
160
161        if file_type.is_dir() {
162            walk(&path, category, out)?;
163        } else if file_type.is_file() {
164            out.extend(path_entries(&path, category)?);
165        }
166        // symlinks are intentionally skipped (no follow) to keep scan read-only safe.
167    }
168    Ok(())
169}
170
171/// Compute one or more `PathEntry` rows for a file.
172///
173/// For most categories this is a single `sha256(full file bytes)` row. For
174/// `local_config` (`~/.claude.json`) the file is decomposed into virtual
175/// sub-entries — one per *watched* JSON block (`mcpServers`, `hooks`,
176/// `permissions`, both top-level and per-project) — so unrelated background
177/// writes (session counters, timestamps, cache) don't show as Modified noise.
178fn path_entries(path: &Path, category: &str) -> Result<Vec<PathEntry>> {
179    let metadata = fs::metadata(path)?;
180    if !metadata.is_file() {
181        return Ok(Vec::new());
182    }
183    let bytes = fs::read(path)?;
184
185    if category == "local_config" {
186        if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
187            return Ok(extract_local_config_entries(path, &json));
188        }
189        // JSON parse failed: fall through to a single full-file hash so the
190        // file is still tracked, just at the original coarse granularity.
191    }
192
193    // Canonicalize the path so that symlinks and `..` components are resolved
194    // to their absolute real path.  Fall back to the original path on error
195    // (e.g. if the file is a symlink whose target has been removed between
196    // the `metadata` call and `canonicalize`).
197    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
198    Ok(vec![PathEntry {
199        path: canonical_path,
200        category: category.to_string(),
201        sha256: sha256_hex(&bytes),
202        size: metadata.len(),
203    }])
204}
205
206/// JSON keys at the top level of `.claude.json` that we treat as
207/// security-relevant injection surfaces.
208const LOCAL_CONFIG_WATCH_KEYS: &[&str] = &["mcpServers", "hooks", "permissions"];
209
210fn extract_local_config_entries(path: &Path, json: &serde_json::Value) -> Vec<PathEntry> {
211    let mut out = Vec::new();
212
213    // Top-level watched blocks.
214    for key in LOCAL_CONFIG_WATCH_KEYS {
215        if let Some(value) = json.get(key) {
216            out.push(virtual_entry(path, "local_config", key, value));
217        }
218    }
219
220    // Per-project watched blocks: projects.<project>.mcpServers etc.
221    if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
222        for (proj_name, proj_val) in projects {
223            for key in LOCAL_CONFIG_WATCH_KEYS {
224                if let Some(value) = proj_val.get(key) {
225                    let fragment = format!("projects.{proj_name}.{key}");
226                    out.push(virtual_entry(path, "local_config", &fragment, value));
227                }
228            }
229        }
230    }
231
232    if out.is_empty() {
233        // No watched block present — emit a sentinel entry so existence /
234        // creation of the file is still tracked.
235        out.push(virtual_entry(
236            path,
237            "local_config",
238            "(no-watched-block)",
239            &serde_json::Value::Null,
240        ));
241    }
242    out
243}
244
245fn virtual_entry(
246    path: &Path,
247    category: &str,
248    fragment: &str,
249    value: &serde_json::Value,
250) -> PathEntry {
251    let canonical = serde_json::to_string(value).unwrap_or_default();
252    let size = canonical.len() as u64;
253    let sha256 = sha256_hex(canonical.as_bytes());
254    let virtual_path = PathBuf::from(format!("{}#{fragment}", path.display()));
255    PathEntry {
256        path: virtual_path,
257        category: category.to_string(),
258        sha256,
259        size,
260    }
261}
262
263fn sha256_hex(bytes: &[u8]) -> String {
264    let mut h = Sha256::new();
265    h.update(bytes);
266    format!("{:x}", h.finalize())
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::io::Write;
273
274    #[test]
275    fn path_entries_computes_sha256_for_regular_file() {
276        let mut tmp = tempfile::NamedTempFile::new().unwrap();
277        tmp.write_all(b"hello agentsec").unwrap();
278        let entries = path_entries(tmp.path(), "test").unwrap();
279        assert_eq!(entries.len(), 1);
280        let e = &entries[0];
281        assert_eq!(e.size, 14);
282        assert_eq!(e.sha256.len(), 64);
283        assert_eq!(e.category, "test");
284    }
285
286    #[test]
287    fn walk_collects_files_recursively() {
288        let dir = tempfile::tempdir().unwrap();
289        let sub = dir.path().join("sub");
290        fs::create_dir(&sub).unwrap();
291        fs::write(dir.path().join("a.txt"), "a").unwrap();
292        fs::write(sub.join("b.txt"), "bb").unwrap();
293        let mut out = Vec::new();
294        walk(dir.path(), "x", &mut out).unwrap();
295        assert_eq!(out.len(), 2);
296    }
297
298    #[test]
299    fn local_config_emits_virtual_entries_per_watch_block() {
300        // .claude.json shape: top-level mcpServers + projects.<p>.hooks.
301        let body = r#"{
302            "mcpServers": {"a": {"command": "x"}},
303            "permissions": {"allow": []},
304            "lastSessionId": "noise-should-be-ignored",
305            "counters": {"step": 42},
306            "projects": {
307                "/path/p": {
308                    "hooks": {"UserPromptSubmit": []},
309                    "mcpServers": {"b": {"command": "y"}}
310                }
311            }
312        }"#;
313        let mut tmp = tempfile::NamedTempFile::new().unwrap();
314        tmp.write_all(body.as_bytes()).unwrap();
315
316        let entries = path_entries(tmp.path(), "local_config").unwrap();
317        let fragments: Vec<String> = entries
318            .iter()
319            .map(|e| {
320                e.path
321                    .to_string_lossy()
322                    .rsplit_once('#')
323                    .map(|(_, frag)| frag.to_string())
324                    .unwrap_or_default()
325            })
326            .collect();
327        assert!(fragments.contains(&"mcpServers".to_string()));
328        assert!(fragments.contains(&"permissions".to_string()));
329        assert!(fragments.iter().any(|f| f == "projects./path/p.hooks"));
330        assert!(fragments.iter().any(|f| f == "projects./path/p.mcpServers"));
331        // Unwatched keys (lastSessionId / counters) must NOT appear as fragments.
332        assert!(!fragments.iter().any(|f| f == "lastSessionId"));
333        assert!(!fragments.iter().any(|f| f == "counters"));
334    }
335
336    #[test]
337    fn local_config_with_no_watched_block_emits_sentinel() {
338        let body = r#"{"unrelated": 1}"#;
339        let mut tmp = tempfile::NamedTempFile::new().unwrap();
340        tmp.write_all(body.as_bytes()).unwrap();
341        let entries = path_entries(tmp.path(), "local_config").unwrap();
342        assert_eq!(entries.len(), 1);
343        assert!(
344            entries[0]
345                .path
346                .to_string_lossy()
347                .ends_with("#(no-watched-block)")
348        );
349    }
350
351    #[test]
352    fn local_config_with_unparseable_json_falls_back_to_full_hash() {
353        let mut tmp = tempfile::NamedTempFile::new().unwrap();
354        tmp.write_all(b"not json at all").unwrap();
355        let entries = path_entries(tmp.path(), "local_config").unwrap();
356        assert_eq!(entries.len(), 1);
357        // fallback path = original file path, no '#' fragment
358        assert!(!entries[0].path.to_string_lossy().contains('#'));
359    }
360}