Every office building runs fire drills. Nobody expects a fire on drill day. The point is to find out, while nothing is burning, that the stairwell door sticks and half the floor doesn't know where to meet. Almost nobody runs that drill for the place where their AI agents work.
I write for KarmaBox, which sells a workspace that runs on hardware you own, so a post that ends with "your agent's state is scattered" is convenient for me. Here is what I did to keep it checkable. Every number below comes from four real runs on 5 November 2026, on a copy of one real avatar workspace (an avatar is our name for an AI agent with its own job, files and permissions), on an Apple M4 Mac mini with 24 GB of memory. The full script is in this post. Where I couldn't find something out, I say so.
An exit drill is a timed rehearsal of leaving a tool: you inventory what your agents depend on, pack it, restore it somewhere fresh, check it, and write down what didn't come along. It isn't a backup test, because a backup test asks whether your files survived; a drill asks whether your work can.
Why I ran it today
On 4 November, TechCrunch reported that Meta had acquired Conductor, a tool for running several AI coding agents side by side. The announcement, quoted word for word in the Hacker News thread, gave users three dates: sign-ups and downloads closed that day, the cloud would go read-only on 13 November, and on 20 November it would shut down and cloud data deletion would begin. That is 16 days from announcement to deletion, and 9 of them allow normal work. (If you're facing a notice like that right now, our guide on what to do when an AI tool shuts down covers the whole sequence, from reading the notice to the deletion date. This post is only about the rehearsal.)
The comments that stuck with me weren't about files. One user listed what they'd miss: "cloud webhooks + workspace/session API + isolated VMs". None of that is something you download. It made me wonder how much of my own agent's workspace I could actually pack up, and how much I only thought I could.
So I wrote a script and ran it. Then I ran it again, and again. It took four runs before the result meant anything.
What the script does
It's deliberately boring. Standard library only, because a drill you can't run on a fresh machine isn't much of a drill. It takes two arguments: a copy of the workspace, and a small JSON file listing things the workspace depends on that don't live in the folder.
Five steps:
Inventory. Walk the folder, hash every file with SHA-256, and sort it into a layer (config, scripts, content, assets) by simple path rules. Hold back anything that looks like a secret.
Export. Write one
.tar.gzper layer, so an incomplete layer is obvious and can be handed to a different person.Restore. Extract every archive into a brand-new temporary directory created with
mkdtemp. Never into a shared folder.Verify. Re-hash every restored file and compare.
Report gaps. Differences, held-back secrets, symlinks that point outside the folder, and anything from the "outside" list that isn't present.
The "outside" file is the honest part. A script can only check what it's told exists. For my workspace I listed five things: conversation history from a cloud engine that ran part of this project earlier today, local session transcripts, the shared skills the agent uses, the agent's memory, and scheduled jobs.
[ {"name": "conversation history from the cloud engine (before 09:19 today)", "layer": "transcripts", "path": ""}, {"name": "local session transcripts", "layer": "transcripts", "path": "~/.karma/sessions"}, {"name": "shared skills the symlinks point to", "layer": "config", "path": "~/.karma/skills"}, {"name": "avatar memory", "layer": "memory", "path": "~/.karma/memory"}, {"name": "scheduled jobs", "layer": "integrations", "path": "~/.karma/cron"} ] Before running anything, I copied the workspace into a fresh directory under /tmp with rsync -a, leaving out temporary folders. The script never writes inside its source. I'd learned that one earlier the same day: during a quick manual drill this morning I unpacked an archive straight into /tmp instead of a new folder. If anything had already been there, the comparison would have proved nothing.
Here is the final version, all 151 lines:
#!/usr/bin/env python3 """Exit drill for an AI agent workspace (D07 Dev.to piece). Usage: python3 -I d07p_exit_drill.py <workspace_copy> <outside.json> Inventory -> export one archive per layer -> restore into a fresh empty dir -> verify every file by sha256 -> print a gap report. Standard library only. Run it on a COPY of the workspace; it never writes inside the source. """ import hashlib, json, os, re, sys, tarfile, tempfile, time LAYERS = [ # first match wins ("config", lambda p: os.path.basename(p) in ("CLAUDE.md", "settings.json", "settings.local.json") or p.startswith(".claude/")), ("scripts", lambda p: p.endswith((".py", ".sh"))), ("content", lambda p: p.endswith((".md", ".txt", ".html", ".docx", ".pdf"))), ("assets", lambda p: p.endswith((".png", ".svg", ".jpg", ".jpeg", ".zip"))), ("other", lambda p: True), ] # Case-sensitive on purpose: with re.I, "AKIA..." matches random base64 in embedded images. SECRET = re.compile(rb"sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|" rb"(?i:(api[_-]?key|secret|token)\s*[:=]\s*['\"][A-Za-z0-9_\-]{16,})") SKIP_DIRS = {".git", "__pycache__"} def sha(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def inventory(src): files, secrets, links = {}, [], [] real_src = os.path.realpath(src) for root, dirs, names in os.walk(src): dirs[:] = [d for d in dirs if d not in SKIP_DIRS] # os.walk lists symlinked directories under dirs and never descends into them, # so they would vanish from the inventory without a word. Record them here. for d in dirs: full = os.path.join(root, d) if os.path.islink(full): target = os.path.realpath(full) if not target.startswith(real_src + os.sep): links.append((os.path.relpath(full, src), os.readlink(full))) for n in names: full = os.path.join(root, n) rel = os.path.relpath(full, src) if os.path.islink(full): if not os.path.realpath(full).startswith(real_src + os.sep): links.append((rel, os.readlink(full))) continue with open(full, "rb") as f: head = f.read(2 << 20) if SECRET.search(head): secrets.append(rel) # never archived continue layer = next(name for name, rule in LAYERS if rule(rel)) files[rel] = (layer, sha(full), "file") return files, secrets, links def export(src, files, outdir): archives = {} for layer, _ in LAYERS: members = [r for r, (l, _, _) in files.items() if l == layer] if not members: continue path = os.path.join(outdir, f"{layer}.tar.gz") with tarfile.open(path, "w:gz") as t: for rel in sorted(members): t.add(os.path.join(src, rel), arcname=rel, recursive=False) archives[layer] = (path, len(members)) return archives def restore(archives, dest): # tarfile's filter="data" only exists on newer Pythons (macOS ships 3.9), # so refuse absolute paths and ../ ourselves before extracting. root = os.path.realpath(dest) for layer, (path, _) in archives.items(): with tarfile.open(path) as t: for m in t.getmembers(): target = os.path.realpath(os.path.join(root, m.name)) if not target.startswith(root + os.sep): raise SystemExit(f"unsafe path in {layer}: {m.name}") t.extractall(root) def verify(files, dest): diffs = [] for rel, (_, digest, kind) in files.items(): p = os.path.join(dest, rel) if not os.path.exists(p): diffs.append((rel, "missing")) elif sha(p) != digest: diffs.append((rel, "content differs")) return diffs def outside(spec): """Things the workspace depends on that do not live in the folder.""" report = [] for item in json.load(open(spec)): where = os.path.expanduser(item.get("path", "")) present = bool(where) and os.path.exists(where) and ( not os.path.isdir(where) or any(os.scandir(where))) report.append((item["name"], item["layer"], "found locally" if present else "empty or missing here")) return report def main(src, spec): t = {} work = tempfile.mkdtemp(prefix="exit-drill-") # fresh, empty, ours out, dest = os.path.join(work, "archives"), os.path.join(work, "restored") os.makedirs(out); os.makedirs(dest) s = time.perf_counter(); files, secrets, links = inventory(src); t["inventory"] = time.perf_counter() - s s = time.perf_counter(); archives = export(src, files, out); t["export"] = time.perf_counter() - s s = time.perf_counter(); restore(archives, dest); t["restore"] = time.perf_counter() - s s = time.perf_counter(); diffs = verify(files, dest); t["verify"] = time.perf_counter() - s ext = outside(spec) size = sum(os.path.getsize(os.path.join(src, r)) for r, v in files.items() if v[2] == "file") packed = sum(os.path.getsize(p) for p, _ in archives.values()) print(f"workdir {work}") print(f"files {len(files)} ({size/1e6:.1f} MB raw, {packed/1e6:.1f} MB packed)") for layer, (p, n) in archives.items(): print(f" {layer:8} {n:4} files {os.path.getsize(p)/1e6:7.2f} MB") print("timing " + " ".join(f"{k} {v:.2f}s" for k, v in t.items())) print(f"diffs {len(diffs)}") for rel, why in diffs[:10]: print(f" - {rel}: {why}") print(f"secrets {len(secrets)} file(s) held back from the archive") for rel in secrets: print(f" - {rel}") print(f"links {len(links)} symlink(s) point outside the folder (archive would hold dangling links)") for rel, target in links[:3]: print(f" - {rel} -> {target}") if len(links) > 3: print(f" ... and {len(links) - 3} more") print("outside the folder:") for name, layer, status in ext: print(f" - [{layer}] {name}: {status}") gaps = len(diffs) + len(secrets) + len(links) + sum(s != "found locally" for *_, s in ext) print(f"GAPS {gaps}") if __name__ == "__main__": main(*sys.argv[1:3]) Run it with python3 -I, which stops Python from importing anything sitting next to the script or in the current directory. That matters when you're pointing a script at a folder full of files you didn't write. (It's also the wrong setting for the task you run afterwards, as I found out below.)
Run 1: it crashed
The first version restored archives with t.extractall(dest, filter="data"). That filter refuses absolute paths and ../tricks, which is exactly what you want when unpacking an archive. It arrived with PEP 706 for Python 3.12 and was backported to some older patch releases.
The Mac's system Python is 3.9.6. It doesn't have it:
TypeError: extractall() got an unexpected keyword argument 'filter' Small thing, but it's the kind of small thing a real migration hits on the new machine, at the worst moment. A drill that only works on the laptop where you wrote it hasn't tested anything. The fix was ten lines that check every member's resolved path stays inside the destination before extracting.
A guard you've never seen fire is a hope, so later I tested it. In a throwaway folder I built a one-file archive whose only member was named ../escaped.txt, the classic way an archive writes outside the folder you meant to unpack into. Then I handed it to the restore function from the script. It stopped with unsafe path in evil: ../escaped.txt, the destination folder stayed empty, and no escaped.txt appeared next to it. The check runs before extractall, so nothing is written at all when one bad member is found. That's stricter than skipping the bad member, and I prefer it here. An archive with a path like that in it is not an archive I want to half-trust.
Run 2: four "secrets" and a silent hole
The second run finished. It reported zero differences, which felt good for about a minute.
Then I read the rest. It had held back four files as secrets. All four were preview HTML pages, the ones I generate every day with images embedded as base64. My regex had re.I on it, so the AWS key pattern AKIA plus sixteen uppercase characters became case-insensitive, and somewhere in 3 MB of base64 there were sequences like Akia5mE7JtVMcCymlWul. The fix was to make the key patterns case-sensitive and keep (?i:...) only around the api_key = "..."style pattern.
I reran both versions of the pattern against the 16:30 copy to see how big the difference really is. Seventeen files in that copy carry embedded base64 images. The old, case-insensitive pattern flagged five files. Four were preview pages. The fifth was this article, because by then the draft quoted Akia5mE7JtVMcCymlWul as an example, and that string is exactly what the old pattern was wrongly looking for. The corrected pattern flagged none. A scanner that holds back your own write-up of its bug is a good reminder that false positives aren't harmless. Every file held back is a file that silently doesn't move.
The worse problem didn't produce any output at all. The config layer had two files. That couldn't be right, because the agent has dozens of skill folders under .claude/skills. They weren't in the inventory, they weren't in the archive, and nothing said so.
The Python docs for os.walk explain it in one line: dirnames includes symlinks to directories, and with the default followlinks=False the walk never goes into them. Every one of those skill folders is a symlink. They point to ~/.karma/skills, outside the workspace. My drill had skipped all 62 of them and reported success.
If I'd stopped at run 2, I would have told you this workspace moves cleanly.
Run 4: zero diffs, 65 gaps
Run 3 added symlink detection and fixed the regex. Run 4 corrected one entry in the outside list, which I'd pointed at an empty folder instead of where the symlinks actually lead. Here is what it printed:
files 557 (161.1 MB raw, 133.6 MB packed) config 2 files 0.01 MB scripts 61 files 0.27 MB content 132 files 39.56 MB assets 362 files 93.78 MB timing inventory 3.03s export 3.99s restore 0.82s verify 0.09s diffs 0 secrets 0 file(s) held back from the archive links 62 symlink(s) point outside the folder (archive would hold dangling links) outside the folder: - [transcripts] conversation history from the cloud engine (before 09:19 today): empty or missing here - [transcripts] local session transcripts: empty or missing here - [config] shared skills the symlinks point to: found locally - [memory] avatar memory: found locally - [integrations] scheduled jobs: empty or missing here GAPS 65 
Wall clock was 7.99 seconds (local disk to local disk, no network, Apple M4 Mac mini, 24 GB). Every file that was in the folder came back byte for byte. By the measure most people use, that's a perfect result.
The gap count tells a different story, so here is what the 65 actually are:
So 62 of the 65 come from one design decision: skills are shared across avatars and linked in rather than copied. That's a sensible choice on one machine. It's a migration trap the moment you move. One is a genuine loss, the cloud conversation. One is my own ignorance about where a thing is stored, which a drill is supposed to surface. And one, scheduled jobs, turned out to be a blind spot rather than a false alarm: the agent's folder really is empty, but the machine's own crontab holds a daily job for another project, in a place my outside list never looked. A gap report you have to interpret is still more honest than one that hides its blind spots.
Two ways to pack a symlink, and why the wrong one looks fine
Later that afternoon I made a fresh copy of the workspace and tried the obvious fix for the 62 folders: let tar deal with them. macOS ships bsdtar 3.5.3. By default it stores a symbolic link as a link. With -L (or its synonym -h), it follows every link and archives whatever the link points to. Python's tarfile has the same switch, dereference, and it also defaults to off.
So I packed just the skills folder both ways:
tar -czf links.tgz -C ws .claude/skills 1,496 bytes 60 entries tar -czhf deref.tgz -C ws .claude/skills 213,671 bytes 170 entries The first archive is tiny because it holds nothing but 59 pointers and the folder they sit in. The second holds the skills themselves.
Here's the trap. I unpacked links.tgz into a new empty folder and checked one of the links. It resolved. SKILL.md was right there. On this machine every absolute path in that archive points at something that exists, so the restore looks perfect. Run the same check on a clean laptop and you'd get 59 links to nothing. A drill that restores on the same machine it packed on will never show you this, which is why my script now flags links instead of trusting them.
Dereferencing isn't automatically the right answer either. It cost about 210 KB here, which is nothing. But a copied skill stops being the shared skill. If someone improves the original next week, the copy in your archive won't know. Whether the skills folder belongs to the workspace or to the machine is a decision, and both answers are reasonable. What matters is making it on purpose and writing it down.
One more thing showed up in that copy. At 14:28 the workspace had 62 linked skills. At 16:30 it had 59. I don't know which three went or why. It was a normal afternoon of work on the same machine. That's the quiet argument for drilling more than once: the thing you're measuring keeps moving.
Does byte-for-byte mean the same file?
A matching SHA-256 tells you the contents survived. It doesn't tell you about anything else. So on the 16:30 copy I compared what the hash ignores.
Across all 642 restored files, the permission bits matched the source in every case, and so did the modification times, to the second. None of the files carried extended attributes, the macOS metadata that can mark a download as quarantined. For this workspace, at least, the archive carried more than just contents.
Then I looked at what the files need in order to work. The answer wasn't in any of them. None of the Python scripts read an environment variable, which I'd half expected to be the weak spot. Instead, eight of them call qlmanage, a macOS Quick Look tool, to turn SVG charts into PNGs. Thirteen hard-code font paths under /System/Library/Fonts. Seven point at a PingFang font file inside a system asset folder whose name contains a long hash.
Every one of those files restored byte for byte. On a machine without qlmanage, or a Mac that stores that font somewhere else, the figure pipeline would stop. Nothing in the gap report would mention it, because nothing is missing from the folder. The dependency is the machine.
This is the case for the last step of the drill, running one real task after the restore. A file count can't find a font that isn't installed. Rendering one chart on the target machine can.
Running one real task on the restored copy
Everything so far compares files. The drill's last step is different: take the restored copy and do one ordinary piece of work with it. For this workspace the most ordinary task is drawing a chart, so I went into the restored folder from the third 16:30 run and asked it to render one of the figures in this series.
The first attempt failed in under a tenth of a second:
ModuleNotFoundError: No module named 'd07_figs' Nothing was missing. The module was sitting right next to the script. The problem was my own advice. I'd been running everything with python3 -I, which keeps the script's directory and the current directory off Python's import path. That's the right call when you point a tool at a folder full of files you didn't write. It's the wrong call for your own pipeline, which expects to import its neighbours. The drill script and the task you run afterwards need different settings, and I'd used the same one for both.
Without -I, the chart rendered in 0.42 seconds. I compared the PNG with the one in the original workspace using cmp, and they were identical, byte for byte. On this machine, the restored workspace can do its job.
Then I took away the one thing the earlier section worried about. I ran the same command with PATH pointing at an empty folder, so qlmanage couldn't be found. It didn't print FAILED and carry on, as I'd guessed from reading the code. It crashed:
FileNotFoundError: [Errno 2] No such file or directory: 'qlmanage' The FAILED branch only runs when qlmanage exists but produces no image. When the tool is missing entirely, subprocess.run raises before the script gets that far. I'd read my own code and predicted the wrong failure. That's a small embarrassment and a fair summary of why the drill ends with running something rather than reasoning about it.
I can't test a Linux machine from here, so I won't claim to know exactly how the pipeline behaves on one. What the empty-PATH run shows is the shape of it: the files arrive intact, and the first real task stops on a tool the folder never contained.
How much the timing moves
The 16:30 copy was bigger than the one I'd drilled at lunchtime: 642 files, 188.6 MB raw and 155.9 MB packed, mostly the drafts and charts written in between. I ran the final script on it three times in a row.
run inventory export restore verify wall clock 1 3.57s 4.63s 0.87s 0.10s 9.24s 2 3.38s 4.66s 0.89s 0.10s 9.11s 3 3.45s 4.64s 0.88s 0.09s 9.14s Wall clock varied by 0.13 seconds across the three runs. Inventory moved the most, because it reads and hashes every file and depends on what else the disk is doing. Export was steady, because it's mostly compression. Restore and verify were steady too.
The point of writing these down is the comparison, not the absolute number. All of it is local disk to local disk. A real move adds a network. As a rough guide, and this is arithmetic on assumptions rather than a measurement: at an assumed 100 Mbit/s, 155.9 MB of archives takes about 12.5 seconds to send (155.9 × 8 ÷ 100). At an assumed 10 Mbit/s upload, it's a little over two minutes. A vendor's own export tool could be much slower than either, and I have no number for that.
None of those figures comes close to the time it would take to rebuild 59 skills, a font setup and a conversation history nobody saved. The clock isn't the bottleneck. The gaps are.
What I'd tell anyone running their own

Run it on a different machine, or at least a different Python, from the one you wrote it on. Your migration target won't have your shell history.
Treat "zero diffs" as the start of the report, not the end. Comparing what's in the archive against what was in the folder can only find problems inside the folder. The expensive problems are the ones outside it: symlinks, conversation history in some cloud engine, settings in a vendor's UI.
Look at your links before you pack anything. find . -type l -exec readlink {} \; lists every symlink and where it points. I hadn't run it once on this workspace before today, and it would have shown me the skills problem before I wrote a line of the drill. If most of the targets sit in one shared folder, as mine did, decide whether that folder is part of the workspace or part of the machine, and write the answer down.
Write the outside list by hand and keep it in the repo. I have to admit the irony here. When I went to commit mine, git rev-parse told me this workspace isn't a git repository at all. Its scripts, drafts and charts have no history except the files themselves, and the only record of the four runs above is the output I saved by hand. For a drill that's an inconvenience. For a migration it's a bigger question, because there's no remote copy to compare against and no log of what changed between 14:28 and 16:30. Putting the workspace under version control is the cheapest improvement this drill turned up, and it isn't one the script could have suggested.
So: write the outside list by hand and keep it in version control. It's the part of the drill that forces you to name what your agents depend on. Mine was wrong in run 3 and the script couldn't have known.
Make the doubtful lines visible instead of tuning them away. I nearly dismissed the scheduled-jobs line as noise. The job it should have pointed me to was sitting in the system crontab the whole time.
Time it. My 7.99 seconds says nothing about your setup over a network, or about a vendor's export tool. It does give me a baseline to compare against next time, which is the whole point of a drill.
What this does and doesn't cover
A drill like this addresses data-exit risk: it tells you, before anyone sends you a date, which parts of your workspace would come with you. It doesn't address software-continuity risk. Conductor's local app keeps working for now and winds down in early 2027, and having every file on your own disk doesn't change that. It doesn't address model-supplier risk either, since an agent that calls an outside model still depends on that provider. And it certainly doesn't fix the 62 links. It only made me look at them.
Running agents on hardware you own, which is what KarmaBox sells, moves the deletion date into your hands. It doesn't move the skills folder into the workspace. That's still a decision someone has to make, and this drill is how I found out it needed making.
If Conductor's 16-day email had landed in your inbox this morning, which line of your gap report would you not have known to write?
Sources
TechCrunch, Meta acquires AI coding startup Conductor, 4 November 2026.
Hacker News, Conductor is joining Meta (discussion quoting Conductor's announcement dates), 4 November 2026.
Python Software Foundation, os — Miscellaneous operating system interfaces (os.walk), accessed 5 November 2026.
Python Software Foundation, tarfile — Read and write tar archive files (dereference parameter), accessed 5 November 2026.
FreeBSD Project, tar(1) manual page (bsdtar; -L, --dereference), accessed 5 November 2026.
Python Software Foundation, PEP 706 – Filter for tarfile.extractall, created 9 February 2023.



