#!/usr/bin/env python3 """loadout scan — find the AI-tool data on this Mac that nothing is backing up. Read-only. Makes no network calls. Prints a summary and writes an HTML report to the current folder. curl -fsSL https://loadout.works/scan | python3 python3 scan.py [--out report.html] https://loadout.works · source-visible, (c) Clara Consulting — see loadout.works/license """ import json import os import re import subprocess import sys import time from pathlib import Path HOME = Path.home() APPSUP = HOME / "Library" / "Application Support" # ---------------------------------------------------------------- registry # (tool, label, path, risk_class, notes) # risk_class: 5=irreplaceable conversations/memory, 4=credentialed/tedious, # 3=workspace state, 1=re-downloadable REGISTRY = [ ("Claude Code", "Session transcripts", HOME / ".claude" / "projects", 5, "Full JSONL history of every Claude Code session"), ("Claude Code", "Skills, agents & memory", HOME / ".claude" / "skills", 5, ""), ("Claude Code", "Custom agents", HOME / ".claude" / "agents", 5, ""), ("Claude Code", "Global instructions", HOME / ".claude" / "CLAUDE.md", 5, ""), ("Claude Code", "Global config (MCP servers)", HOME / ".claude.json", 4, "Often contains credentials"), ("Claude Desktop", "Config (MCP servers)", APPSUP / "Claude" / "claude_desktop_config.json", 4, "Often contains credentials"), ("Cursor", "Chat & composer history", APPSUP / "Cursor" / "User" / "globalStorage", 5, "state.vscdb holds all Cursor conversations"), ("Cursor", "Workspace state", APPSUP / "Cursor" / "User" / "workspaceStorage", 3, ""), ("Cursor", "MCP config", HOME / ".cursor" / "mcp.json", 4, "Often contains credentials"), ("Devin (was Windsurf)", "Editor state", APPSUP / "Windsurf" / "User" / "globalStorage", 5, "Pre-rename install path; Windsurf became Devin Desktop June 2026"), ("Devin (was Windsurf)", "Cascade state & memories", HOME / ".codeium" / "windsurf", 5, ""), ("Devin Desktop", "Editor state", APPSUP / "Devin" / "User" / "globalStorage", 5, "Post-rename install path"), ("Devin Desktop", "Devin Local agent state", HOME / ".devin", 5, ""), ("Devin Desktop", "Cognition config", HOME / ".cognition", 4, ""), ("Antigravity", "Chats & editor state", APPSUP / "Antigravity" / "User", 5, "Google's agentic IDE; state.vscdb holds conversations"), ("VS Code", "Copilot chat sessions", APPSUP / "Code" / "User" / "workspaceStorage", 3, "Per-workspace chatSessions"), ("VS Code", "Settings & keybindings", APPSUP / "Code" / "User" / "settings.json", 3, ""), ("Aider", "Chat history", HOME / ".aider.chat.history.md", 5, ""), ("Continue", "Sessions & config", HOME / ".continue", 4, ""), ("Codex CLI", "Sessions & config", HOME / ".codex", 4, ""), ("Ollama", "Models & keys", HOME / ".ollama", 1, "Models re-downloadable; id_ed25519 keypair is not"), ("oMLX", "Settings & API key", HOME / ".omlx", 4, ""), ("LM Studio", "Conversations", HOME / ".lmstudio", 5, ""), ("Local models", "MLX model weights", HOME / "models", 1, "Re-downloadable"), ] SECRET_PATTERNS = re.compile( r"(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|xox[bap]-[A-Za-z0-9-]{10,}" r"|AKIA[A-Z0-9]{16}|api[_-]?key\"?\s*[:=]\s*\"[^\"]{12,})", re.I) # ---------------------------------------------------------------- helpers def run(cmd): try: return subprocess.run(cmd, capture_output=True, text=True, timeout=20).stdout except Exception: return "" def sizeof(path: Path): """(bytes, file_count, newest_mtime) — stat only, never opens files.""" if not path.exists(): return None if path.is_file(): st = path.stat() return st.st_size, 1, st.st_mtime total = count = newest = 0 for root, dirs, files in os.walk(path, onerror=lambda e: None): for f in files: try: st = os.stat(os.path.join(root, f), follow_symlinks=False) total += st.st_size count += 1 newest = max(newest, st.st_mtime) except OSError: pass return total, count, newest def human(n): for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024 or unit == "TB": return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B" n /= 1024 def count_secrets(path: Path): """Count secret-shaped strings in a small config file. Never records values.""" try: if path.is_file() and path.stat().st_size < 2_000_000: return len(SECRET_PATTERNS.findall(path.read_text(errors="ignore"))) except OSError: pass return 0 # ---------------------------------------------------------------- coverage def coverage_probes(): cov = {} tm = run(["tmutil", "destinationinfo"]) cov["time_machine"] = ("none" if "No destinations" in tm or not tm.strip() else "configured") icloud = HOME / "Library" / "Mobile Documents" / "com~apple~CloudDocs" cov["icloud_drive"] = "present" if icloud.exists() else "absent" # Desktop sync is real only if ~/Desktop *is* the CloudDocs Desktop try: cov["icloud_desktop_sync"] = ( "on" if (icloud / "Desktop").exists() and os.stat(HOME / "Desktop").st_ino == os.stat(icloud / "Desktop").st_ino else "off") except OSError: cov["icloud_desktop_sync"] = "unknown" cov["backblaze"] = "present" if Path("/Library/Backblaze.bzpkg").exists() else "absent" cov["arq"] = ("present" if (APPSUP / "ArqAgent").exists() or Path("/Applications/Arq.app").exists() else "absent") cov["carbonite"] = ("present" if Path("/Applications/Carbonite.app").exists() else "absent") return cov def exposure(cov): """0.0 (covered) .. 1.0 (nothing). v0: any full-disk agent counts as partial — presence is not proof of coverage, and iCloud never covers ~/Library or dotfiles.""" if cov["time_machine"] == "configured": return 0.3 if "present" in (cov["backblaze"], cov["arq"], cov["carbonite"]): return 0.5 return 1.0 # ---------------------------------------------------------------- report def grade(score): return ("A" if score < 5 else "B" if score < 15 else "C" if score < 30 else "D" if score < 60 else "F") def status(msg): """Progress to stderr so it shows immediately even when stdout is piped.""" print(msg, file=sys.stderr, flush=True) def main(): out_path = Path("loadout-report.html") if "--out" in sys.argv: idx = sys.argv.index("--out") if idx + 1 >= len(sys.argv): print("error: --out needs a filename", file=sys.stderr) return 1 out_path = Path(sys.argv[idx + 1]) status("\n loadout scan · read-only · no network calls") status(" Checking backup coverage (Time Machine, iCloud, backup agents)…") cov = coverage_probes() exp = exposure(cov) rows, total_risk, secrets_total = [], 0.0, 0 status(" Measuring AI-tool data (big chat databases can take a minute)…") for tool, label, path, risk_class, notes in REGISTRY: status(f" · {tool} — {label}") info = sizeof(path) if not info or info[0] == 0: continue size, files, newest = info secrets = count_secrets(path) if risk_class == 4 else 0 secrets_total += secrets size_factor = min(1.0, size / 100_000_000) if risk_class > 1 else 0.05 risk = risk_class * exp * (0.4 + 0.6 * size_factor) * 4 total_risk += risk rows.append({ "tool": tool, "label": label, "path": str(path).replace(str(HOME), "~"), "size": human(size), "files": files, "modified": time.strftime("%Y-%m-%d", time.localtime(newest)), "risk_class": risk_class, "secrets": secrets, "level": ("HIGH" if risk_class >= 5 and exp >= 0.5 else "MED" if risk_class >= 3 and exp >= 0.5 else "LOW"), }) rows.sort(key=lambda r: (-r["risk_class"], r["tool"])) g = grade(total_risk) # ---- terminal summary print(f"\n Loadout scan — AI data grade: {g}") print(f" Time Machine: {cov['time_machine']} " f"iCloud Desktop sync: {cov['icloud_desktop_sync']} " f"Cloud backup agent: " f"{'yes' if exp < 1.0 and cov['time_machine'] == 'none' else 'TM' if cov['time_machine'] == 'configured' else 'none'}") if secrets_total: print(f" ⚠ {secrets_total} secret-shaped strings found in AI tool configs " f"(values not read further)") print() for r in rows: flag = {"HIGH": "✗", "MED": "△", "LOW": "·"}[r["level"]] print(f" {flag} [{r['level']:4}] {r['tool']:14} {r['label']:32} " f"{r['size']:>9} {r['path']}") # ---- html report cards = "".join( f"
AI Data at Risk report · generated locally · nothing left this machine
| Tool | Data | Where | Size | Modified | Risk |
|---|
Risk = irreplaceability × backup exposure. Model weights score LOW — they are re-downloadable. Conversations, memory and skills are not.
""" out_path.write_text(html) print(f"\n Report written to {out_path}\n") return 0 if __name__ == "__main__": sys.exit(main())