Skip to content

Snapshots

Capsem takes periodic copy-on-write snapshots of the shared workspace so AI agents can recover files, compare changes, and roll back mistakes. Snapshots use APFS clonefile on macOS (reflinks on Linux), consuming almost no extra disk space until files diverge.

flowchart TB
  subgraph Host["Host (macOS / Linux)"]
    Timer["Timer<br/>(5 min interval)"]
    MCP["MITM MCP endpoint<br/>framed vsock:5002"]
    Sched["AutoSnapshotScheduler"]
    FS["Session dir<br/>auto_snapshots/{slot}/"]
    IPC["capsem-process IPC<br/>snapshot status/list"]
    FM["FsMonitor<br/>(FSEvents / inotify)"]
    DB["session.db<br/>activity ledger"]
    WS["workspace/"]
  end

  subgraph Guest["Guest VM"]
    Agent["AI Agent<br/>(Claude, Gemini, Codex)"]
  end

  Agent -- "snapshots_create<br/>snapshots_revert<br/>snapshots_changes" --> MCP
  MCP --> Sched
  Timer --> Sched
  Sched -- "clonefile / reflink" --> FS
  Sched -- "in-memory status" --> IPC
  MCP -- "explicit tool call" --> DB
  MCP -- "FileEvent (restored)" --> DB
  FM -- "FileEvent (created/modified/deleted)" --> DB
  Agent -- "file I/O via VirtioFS" --> WS
  FM -- "watches" --> WS

Every session has two snapshot pools managed by the AutoSnapshotScheduler:

Pool Slots Managed by Behavior
Auto 10 (default) Timer Rolling ring buffer, taken every 5 minutes. Oldest overwritten when full.
Manual 12 (default) AI agent Named checkpoints created on demand via MCP tools. Persist until deleted.

Auto slots are numbered 0..max_auto-1, manual slots max_auto..max_auto+max_manual-1. The ring buffer advances next_auto_slot = (next_auto_slot + 1) % max_auto after each auto snapshot.

~/.capsem/sessions/<session-id>/
workspace/ # Live VirtioFS shared directory
auto_snapshots/
0/ # auto slot 0
workspace/ # CoW clone of session workspace
system/ # CoW clone of system image
metadata.json # {slot, timestamp, epoch_secs, epoch_millis, origin, name, hash}
1/ # auto slot 1
...
10/ # manual slot 0 (offset by auto_max)

Each metadata.json contains:

Field Type Notes
slot usize Absolute slot index
timestamp String ISO 8601
epoch_secs u64 Unix seconds
epoch_millis u128 Precise milliseconds for sort order
origin "auto" or "manual" Pool membership
name String? Human label (manual only)
hash String? Blake3 of workspace manifest (manual only, skipped for auto)

When an AI agent calls snapshots_revert, the following sequence occurs:

sequenceDiagram
    participant Agent as AI Agent (Guest)
    participant GW as MITM MCP Endpoint
    participant Sched as AutoSnapshotScheduler
    participant FS as Host Filesystem
    participant DB as session.db

    Agent->>GW: tools/call snapshots_revert<br/>{path, checkpoint?}
    GW->>Sched: get_snapshot(slot)
    Sched-->>GW: SnapshotInfo{workspace_path}

    alt checkpoint omitted
        GW->>Sched: list_snapshots()
        Note over GW: scan newest-first,<br/>find first containing file
    end

    GW->>FS: symlink_metadata(current_file)
    Note over GW: validate path stays<br/>inside workspace

    alt file exists in snapshot
        GW->>FS: copy(snap_file, current_file)
        GW->>FS: set_permissions from snapshot
        GW->>DB: FileEvent{action: restored,<br/>path: "file (from cp-N)"}
    else file was created after checkpoint
        GW->>FS: remove_file(current_file)
        GW->>DB: FileEvent{action: restored,<br/>path: "file (from cp-N)"}
    end

    GW-->>Agent: {reverted: true, action, checkpoint}

Key properties:

  • Reverts are logged as restored file events in the session DB, including which checkpoint was used
  • Path validation uses canonicalize() to prevent symlink escape (see Symlink Safety)
  • File permissions are restored from the snapshot metadata

Snapshots are host recovery state, not user/security activity. The automatic snapshot scheduler does not write snapshot lifecycle rows to session.db, and snapshot.event is not a security-event type.

Snapshot state is exposed through VM routes:

Route Source
GET /vms/{id}/snapshots/status Running VM: capsem-process in-memory scheduler over IPC. Stopped VM: that VM’s snapshot metadata loaded on demand.
GET /vms/{id}/snapshots/list Same source as status, returned as a compact list.

The session ledger still records real user/security activity around snapshots:

  • Explicit MCP snapshot tool calls are tool_calls rows with origin = 'mcp'.
  • File changes caused by snapshots_revert are fs_events with action restored.
  • Automatic background snapshot captures emit structured process logs, not session DB rows.
CREATE TABLE fs_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
action TEXT NOT NULL, -- 'created', 'modified', 'deleted', 'restored'
path TEXT NOT NULL,
size INTEGER
);

The action field has four values:

Action Source Description
created FsMonitor New file detected in workspace
modified FsMonitor Existing file content changed
deleted FsMonitor File removed from workspace
restored MITM MCP endpoint File reverted from a snapshot checkpoint

For restored events, the path field includes the source checkpoint: "src/main.py (from cp-3)". This makes it easy to trace which snapshot was used for recovery.

Snapshot creation calls clone_directory() which dispatches to a platform-specific backend:

Platform Backend Mechanism
macOS APFS clonefile() syscall – instant CoW clone, zero extra disk until divergence
Linux Reflink FICLONE ioctl – falls back to byte copy if filesystem doesn’t support reflinks
flowchart LR
  CD["clone_directory()"] --> |macOS| APFS["clonefile()<br/>syscall"]
  CD --> |Linux| RF["FICLONE<br/>ioctl"]
  RF --> |unsupported fs| CP["std::fs::copy()<br/>byte copy fallback"]
  APFS --> SNAP["Snapshot slot<br/>workspace/ + system/"]
  RF --> SNAP
  CP --> SNAP

All host-side code that operates on workspace paths uses symlink_metadata() instead of metadata() to avoid following symlinks created by the guest. This prevents:

  • Infinite recursion from symlink loops (e.g., .venv/lib64 -> lib)
  • Sandbox escape from absolute symlinks (e.g., host_root -> /)
  • Information disclosure from symlinks to host files

The snapshots_revert handler additionally validates that the resolved path stays within the workspace root using canonicalize() + starts_with().

Code path Protection
disk_usage_bytes / dir_size symlink_metadata() – never follows links
snapshots_revert canonicalize() + workspace boundary check
FsMonitor Should use symlink_metadata() (tracked for hardening)
clone_directory / compact Should skip or preserve symlinks (tracked for hardening)
Setting Default Description
vm.snapshots.auto_max 10 Maximum auto snapshot slots
vm.snapshots.manual_max 12 Maximum manual snapshot slots
vm.snapshots.auto_interval 300 Seconds between auto snapshots

Settings are loaded from the merged config at VM boot time and cannot be changed mid-session.

Snapshot tools are “builtin” MCP tools routed by the gateway without an external server. They run on spawn_blocking threads to avoid starving the tokio runtime (directory cloning and walkdir are blocking I/O). See the usage guide for the CLI and MCP tool reference.