Skip to main content

vtcode_memory/
pack.rs

1//! Digest-verified audit packs for sessions.
2//!
3//! An audit pack is a self-describing manifest of one session's canonical
4//! store: every file under the session directory with its byte count and
5//! SHA-256, plus a snapshot of the manifest counters. It is the portable
6//! artifact behind VT Code's "auditable agent" story — a reviewer can take
7//! `events.jsonl` + `manifest.json` + derived views, re-run
8//! [`verify_audit_pack`], and confirm the artifacts are exactly the ones the
9//! pack describes (nothing mutated, nothing missing, anything new reported as
10//! unaccounted).
11//!
12//! Packs only reference file paths relative to the session directory; paths
13//! are validated on load so a hand-edited pack cannot make verification read
14//! outside the session directory.
15
16use std::collections::HashSet;
17use std::path::{Component, Path, PathBuf};
18
19use chrono::Utc;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use walkdir::WalkDir;
23
24use crate::error::SessionStoreError;
25use crate::{ensure_private_directory, session_dir};
26
27/// Reserved file name for audit packs inside a session directory; excluded
28/// from pack walks so a pack never describes itself.
29const AUDIT_PACK_FILE_NAME: &str = "audit-pack.json";
30
31/// One digest-verified file entry, relative to the session directory.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct AuditPackEntry {
34    /// Slash-separated path relative to the session directory (never
35    /// absolute, never containing `..`).
36    pub path: String,
37    /// File size in bytes.
38    pub bytes: u64,
39    /// SHA-256 hex digest of the file contents.
40    pub sha256: String,
41}
42
43/// Point-in-time digest manifest of a session's store.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct SessionAuditPack {
46    /// Pack schema version (see [`AUDIT_PACK_SCHEMA_VERSION`]).
47    pub schema_version: u32,
48    /// Session the pack describes.
49    pub session_id: String,
50    /// RFC3339 creation time; pins the snapshot point.
51    pub generated_at: String,
52    /// Manifest status snapshot (`active` / `completed`).
53    pub status: String,
54    /// Manifest turn-count snapshot.
55    pub turn_count: u64,
56    /// Manifest event-count snapshot.
57    pub event_count: u64,
58    /// File entries, sorted by `path` for deterministic, diffable output.
59    pub entries: Vec<AuditPackEntry>,
60}
61
62/// Outcome of verifying a pack against the current session contents.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct AuditVerification {
65    /// True when every listed file matches its digest and none are missing.
66    pub verified: bool,
67    /// Files whose current digest differs from the pack.
68    pub mismatches: Vec<String>,
69    /// Files listed in the pack that no longer exist.
70    pub missing: Vec<String>,
71    /// Files present in the session directory that the pack does not describe
72    /// (informational: created after the pack, or excluded on purpose).
73    pub unaccounted: Vec<String>,
74}
75
76/// Schema version for [`SessionAuditPack`].
77pub const AUDIT_PACK_SCHEMA_VERSION: u32 = 1;
78
79/// Default pack location inside the session store:
80/// `<session>/derived/audit-pack.json`.
81#[must_use]
82pub fn audit_pack_path(workspace: &Path, session_id: &str) -> PathBuf {
83    session_dir(workspace, session_id)
84        .join(crate::DERIVED_DIR)
85        .join(AUDIT_PACK_FILE_NAME)
86}
87
88/// Build a digest manifest of `session_id`'s store.
89///
90/// # Errors
91/// Returns [`SessionStoreError`] when the session directory or its manifest
92/// cannot be read, or when any file cannot be digested.
93pub fn create_audit_pack(workspace: &Path, session_id: &str) -> Result<SessionAuditPack, SessionStoreError> {
94    let dir = session_dir(workspace, session_id);
95    let manifest_path = dir.join("manifest.json");
96    let manifest_bytes =
97        std::fs::read(&manifest_path).map_err(|error| SessionStoreError::io(manifest_path.clone(), error))?;
98    let summary: crate::query::SessionSummary = serde_json::from_slice(&manifest_bytes)?;
99
100    let mut entries = Vec::new();
101    for file in walk_files(&dir)? {
102        let relative = file
103            .strip_prefix(&dir)
104            .map_err(|error| SessionStoreError::io(file.clone(), std::io::Error::other(error)))?
105            .components()
106            .map(|component| component.as_os_str().to_string_lossy())
107            .collect::<Vec<_>>()
108            .join("/");
109        let (bytes, sha256) = digest_file(&file)?;
110        entries.push(AuditPackEntry { path: relative, bytes, sha256 });
111    }
112    entries.sort_by(|a, b| a.path.cmp(&b.path));
113
114    Ok(SessionAuditPack {
115        schema_version: AUDIT_PACK_SCHEMA_VERSION,
116        session_id: session_id.to_string(),
117        generated_at: Utc::now().to_rfc3339(),
118        status: summary.status,
119        turn_count: summary.turn_count,
120        event_count: summary.event_count,
121        entries,
122    })
123}
124
125/// Write a pack as pretty JSON, atomically and with private permissions.
126///
127/// Defaults to [`audit_pack_path`] when `output` is `None`. The pack file is
128/// excluded from the pack itself.
129///
130/// # Errors
131/// Returns [`SessionStoreError`] from pack creation or the write.
132pub fn write_audit_pack(
133    workspace: &Path,
134    session_id: &str,
135    output: Option<&Path>,
136) -> Result<(SessionAuditPack, PathBuf), SessionStoreError> {
137    let pack = create_audit_pack(workspace, session_id)?;
138    let destination = output.map_or_else(|| audit_pack_path(workspace, session_id), Path::to_path_buf);
139    if let Some(parent) = destination.parent() {
140        ensure_private_directory(parent)?;
141    }
142    let bytes = serde_json::to_vec_pretty(&pack)?;
143    vtcode_commons::VtCodePaths::write_private_file_atomic(&destination, &bytes)
144        .map_err(|error| SessionStoreError::io(destination.clone(), std::io::Error::other(error)))?;
145    Ok((pack, destination))
146}
147
148/// Load a pack from disk, validating its schema.
149///
150/// # Errors
151/// Returns [`SessionStoreError::InvalidPack`] for unknown schema versions and
152/// [`SessionStoreError::Io`]/[`SessionStoreError::Json`] for read or parse
153/// failures.
154pub fn read_audit_pack(path: &Path) -> Result<SessionAuditPack, SessionStoreError> {
155    let bytes = std::fs::read(path).map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
156    let pack: SessionAuditPack = serde_json::from_slice(&bytes)?;
157    if pack.schema_version != AUDIT_PACK_SCHEMA_VERSION {
158        return Err(SessionStoreError::InvalidPack(format!(
159            "unsupported schema_version {} (expected {AUDIT_PACK_SCHEMA_VERSION})",
160            pack.schema_version
161        )));
162    }
163    Ok(pack)
164}
165
166/// Verify a pack against the session's current contents.
167///
168/// A `false` `verified` means the store drifted from the pack: files changed
169/// (mismatches), disappeared (missing), or — informationally — appeared after
170/// the pack (unaccounted). Traversal-style entry paths are rejected outright.
171///
172/// # Errors
173/// Returns [`SessionStoreError::InvalidPack`] for unsafe entry paths and
174/// [`SessionStoreError`] for walk/digest IO failures.
175pub fn verify_audit_pack(
176    workspace: &Path,
177    session_id: &str,
178    pack: &SessionAuditPack,
179) -> Result<AuditVerification, SessionStoreError> {
180    let dir = session_dir(workspace, session_id);
181    let mut mismatches = Vec::new();
182    let mut missing = Vec::new();
183    let mut listed = HashSet::new();
184
185    for entry in &pack.entries {
186        validate_entry_path(&entry.path)?;
187        listed.insert(entry.path.clone());
188        let absolute = dir.join(&entry.path);
189        let (bytes, sha256) = match digest_file(&absolute) {
190            Ok(digest) => digest,
191            Err(error) if matches!(&error, SessionStoreError::Io { .. } if is_not_found(&error)) => {
192                missing.push(entry.path.clone());
193                continue;
194            }
195            Err(error) => return Err(error),
196        };
197        if bytes != entry.bytes || sha256 != entry.sha256 {
198            mismatches.push(entry.path.clone());
199        }
200    }
201
202    let mut unaccounted = Vec::new();
203    for file in walk_files(&dir)? {
204        let relative = file
205            .strip_prefix(&dir)
206            .map(|relative| {
207                relative
208                    .components()
209                    .map(|component| component.as_os_str().to_string_lossy())
210                    .collect::<Vec<_>>()
211                    .join("/")
212            })
213            .map_err(|error| SessionStoreError::io(file.clone(), std::io::Error::other(error)))?;
214        if !listed.contains(&relative) {
215            unaccounted.push(relative);
216        }
217    }
218    unaccounted.sort();
219
220    Ok(AuditVerification {
221        verified: mismatches.is_empty() && missing.is_empty(),
222        mismatches,
223        missing,
224        unaccounted,
225    })
226}
227
228/// Walk every regular file under `dir`, deterministically, skipping audit
229/// packs themselves.
230fn walk_files(dir: &Path) -> Result<Vec<PathBuf>, SessionStoreError> {
231    let mut files = Vec::new();
232    for entry in WalkDir::new(dir).sort_by_file_name() {
233        let entry = entry.map_err(|error| SessionStoreError::io(dir.to_path_buf(), std::io::Error::other(error)))?;
234        if !entry.file_type().is_file() {
235            continue;
236        }
237        if entry.file_name().to_string_lossy() == AUDIT_PACK_FILE_NAME {
238            continue;
239        }
240        files.push(entry.into_path());
241    }
242    Ok(files)
243}
244
245/// Digest a file, returning `(bytes, sha256-hex)`.
246fn digest_file(path: &Path) -> Result<(u64, String), SessionStoreError> {
247    use std::io::Read;
248
249    let mut file = std::fs::File::open(path).map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
250    let mut hasher = Sha256::new();
251    let mut bytes = 0u64;
252    let mut buffer = vec![0u8; 64 * 1024];
253    loop {
254        let read = file
255            .read(&mut buffer)
256            .map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
257        if read == 0 {
258            break;
259        }
260        match buffer.get(..read) {
261            Some(chunk) => hasher.update(chunk),
262            // Unreachable: `read` never exceeds the buffer length.
263            None => break,
264        }
265        bytes += u64::try_from(read).unwrap_or(u64::MAX);
266    }
267    let hex = hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>();
268    Ok((bytes, hex))
269}
270
271/// Reject absolute paths, traversal components, and empty paths in packs.
272fn validate_entry_path(relative: &str) -> Result<(), SessionStoreError> {
273    if relative.is_empty() {
274        return Err(SessionStoreError::InvalidPack("entry path is empty".to_string()));
275    }
276    let path = Path::new(relative);
277    if path.is_absolute() {
278        return Err(SessionStoreError::InvalidPack(format!("entry path {relative:?} is absolute")));
279    }
280    for component in path.components() {
281        match component {
282            Component::Normal(_) => {}
283            other => {
284                return Err(SessionStoreError::InvalidPack(format!(
285                    "entry path {relative:?} contains a forbidden component ({other:?})"
286                )));
287            }
288        }
289    }
290    Ok(())
291}
292
293fn is_not_found(error: &SessionStoreError) -> bool {
294    matches!(
295        error,
296        SessionStoreError::Io { source, .. }
297            if source.kind() == std::io::ErrorKind::NotFound
298    )
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use tempfile::TempDir;
305
306    /// Seed a session directory with a manifest and two files (root + derived)
307    /// so pack walks cover both depths.
308    fn seed_session(workspace: &Path, session_id: &str) -> PathBuf {
309        let dir = session_dir(workspace, session_id);
310        ensure_private_directory(&dir).expect("session dir");
311        ensure_private_directory(&dir.join(crate::DERIVED_DIR)).expect("derived dir");
312        std::fs::write(
313            dir.join("manifest.json"),
314            serde_json::json!({
315                "session_id": session_id,
316                "schema_version": 1,
317                "created_at": "2026-01-01T00:00:00Z",
318                "updated_at": "2026-01-01T00:00:00Z",
319                "turn_count": 2,
320                "event_count": 7,
321                "status": "completed"
322            })
323            .to_string(),
324        )
325        .expect("manifest");
326        std::fs::write(dir.join("events.jsonl"), "{\"event\":1}\n{\"event\":2}\n").expect("events");
327        std::fs::write(dir.join(crate::DERIVED_DIR).join("memory.json"), "{\"facts\":[]}").expect("derived");
328        dir
329    }
330
331    #[test]
332    fn pack_round_trips_and_verifies_clean() {
333        let workspace = TempDir::new().expect("workspace");
334        seed_session(workspace.path(), "audit-a");
335
336        let pack = create_audit_pack(workspace.path(), "audit-a").expect("create pack");
337        // The derived/memory.json file name and manifest must be present; a
338        // pack missing either would verify nothing meaningful.
339        let paths: Vec<&str> = pack.entries.iter().map(|entry| entry.path.as_str()).collect();
340        assert!(paths.contains(&"manifest.json"), "entries: {paths:?}");
341        assert!(paths.contains(&"events.jsonl"), "entries: {paths:?}");
342        assert!(paths.contains(&"derived/memory.json"), "entries: {paths:?}");
343
344        // JSON round-trip must preserve the pack exactly (verify loads a file).
345        let bytes = serde_json::to_vec_pretty(&pack).expect("serialize");
346        let loaded: SessionAuditPack = serde_json::from_slice(&bytes).expect("deserialize");
347        assert_eq!(loaded, pack);
348
349        let report = verify_audit_pack(workspace.path(), "audit-a", &loaded).expect("verify");
350        assert!(report.verified, "fresh pack must verify: {report:?}");
351        assert!(report.mismatches.is_empty() && report.missing.is_empty() && report.unaccounted.is_empty());
352        assert_eq!(
353            report,
354            AuditVerification {
355                verified: true,
356                mismatches: Vec::new(),
357                missing: Vec::new(),
358                unaccounted: Vec::new(),
359            }
360        );
361    }
362
363    #[test]
364    fn single_byte_tamper_fails_verification() {
365        let workspace = TempDir::new().expect("workspace");
366        seed_session(workspace.path(), "audit-b");
367        let pack = create_audit_pack(workspace.path(), "audit-b").expect("create pack");
368
369        // Flip one byte of the canonical log: digest must change.
370        let events = workspace.path().join(".vtcode/sessions/audit-b/events.jsonl");
371        let contents = std::fs::read_to_string(&events).expect("read");
372        std::fs::write(&events, contents.replace("{\"event\":1}", "{\"event\":9}")).expect("tamper");
373
374        let report = verify_audit_pack(workspace.path(), "audit-b", &pack).expect("verify");
375        assert!(!report.verified);
376        assert_eq!(report.mismatches, vec!["events.jsonl".to_string()]);
377        assert!(report.missing.is_empty());
378    }
379
380    #[test]
381    fn appended_file_is_unaccounted_but_verifies() {
382        let workspace = TempDir::new().expect("workspace");
383        seed_session(workspace.path(), "audit-c");
384        let pack = create_audit_pack(workspace.path(), "audit-c").expect("create pack");
385
386        // A new file after the pack is informational, not a failure: the pack
387        // pins its snapshot, it does not forbid later writes.
388        std::fs::write(workspace.path().join(".vtcode/sessions/audit-c/derived/progress.json"), "{}")
389            .expect("new file");
390
391        let report = verify_audit_pack(workspace.path(), "audit-c", &pack).expect("verify");
392        assert!(report.verified, "additions must not fail verification: {report:?}");
393        assert_eq!(report.unaccounted, vec!["derived/progress.json".to_string()]);
394    }
395
396    #[test]
397    fn deleted_file_is_reported_missing() {
398        let workspace = TempDir::new().expect("workspace");
399        seed_session(workspace.path(), "audit-d");
400        let pack = create_audit_pack(workspace.path(), "audit-d").expect("create pack");
401
402        std::fs::remove_file(workspace.path().join(".vtcode/sessions/audit-d/derived/memory.json"))
403            .expect("delete derived file");
404
405        let report = verify_audit_pack(workspace.path(), "audit-d", &pack).expect("verify");
406        assert!(!report.verified);
407        assert_eq!(report.missing, vec!["derived/memory.json".to_string()]);
408        assert!(report.mismatches.is_empty(), "missing must not double-report as mismatched");
409    }
410
411    #[test]
412    fn traversal_paths_in_loaded_packs_are_rejected() {
413        let workspace = TempDir::new().expect("workspace");
414        seed_session(workspace.path(), "audit-e");
415
416        let malicious = |path: &str| SessionAuditPack {
417            schema_version: AUDIT_PACK_SCHEMA_VERSION,
418            session_id: "audit-e".to_string(),
419            generated_at: "2026-01-01T00:00:00Z".to_string(),
420            status: "completed".to_string(),
421            turn_count: 0,
422            event_count: 0,
423            entries: vec![AuditPackEntry {
424                path: path.to_string(),
425                bytes: 1,
426                sha256: "0".repeat(64),
427            }],
428        };
429        for evil in ["../outside.json", "/etc/passwd", "derived/../../escape", ""] {
430            let error = verify_audit_pack(workspace.path(), "audit-e", &malicious(evil))
431                .expect_err("traversal pack must be rejected");
432            assert!(
433                matches!(error, SessionStoreError::InvalidPack(_)),
434                "path {evil:?} must be an InvalidPack error, got {error:?}"
435            );
436        }
437    }
438
439    #[test]
440    fn missing_session_fails_pack_creation() {
441        let workspace = TempDir::new().expect("workspace");
442        let error = create_audit_pack(workspace.path(), "ghost").expect_err("missing session");
443        assert!(matches!(error, SessionStoreError::Io { .. }));
444    }
445
446    #[test]
447    fn packs_are_deterministic_apart_from_timestamp() {
448        let workspace = TempDir::new().expect("workspace");
449        seed_session(workspace.path(), "audit-f");
450        let first = create_audit_pack(workspace.path(), "audit-f").expect("first");
451        let second = create_audit_pack(workspace.path(), "audit-f").expect("second");
452        assert_eq!(first.entries, second.entries, "file inventory must be stable and sorted");
453        let sorted: Vec<String> = second.entries.iter().map(|entry| entry.path.clone()).collect();
454        let mut expected = sorted.clone();
455        expected.sort();
456        assert_eq!(sorted, expected);
457    }
458
459    #[test]
460    fn write_and_read_round_trip_through_default_location() {
461        let workspace = TempDir::new().expect("workspace");
462        seed_session(workspace.path(), "audit-g");
463        let (pack, path) = write_audit_pack(workspace.path(), "audit-g", None).expect("write pack");
464        assert_eq!(path, audit_pack_path(workspace.path(), "audit-g"));
465
466        let loaded = read_audit_pack(&path).expect("read pack");
467        assert_eq!(loaded, pack);
468        assert_eq!(loaded.schema_version, AUDIT_PACK_SCHEMA_VERSION);
469        // The pack file itself must not appear in its own inventory.
470        assert!(
471            loaded.entries.iter().all(|entry| !entry.path.ends_with(AUDIT_PACK_FILE_NAME)),
472            "pack must exclude itself: {:?}",
473            loaded.entries
474        );
475
476        // Schema drift is rejected at load, not at verify time.
477        let mut drifted = loaded.clone();
478        drifted.schema_version = 99;
479        std::fs::write(&path, serde_json::to_vec(&drifted).expect("serialize")).expect("write drifted");
480        let error = read_audit_pack(&path).expect_err("schema drift");
481        assert!(matches!(error, SessionStoreError::InvalidPack(_)));
482    }
483}