●COWORK — Cowork now reaches web, iOS, and Android, with sessions and files following you across devices. The doubled beta usage limits run through August 5●APPROVE — Approvals can now come from your phone, which shortens the stretches where a background run or scheduled task simply sat waiting for permission●SANDBOX — Version 2.1.216 lets you control filesystem isolation separately from network isolation, so you can tune the sandbox per workload●A11Y — Version 2.1.208 added screen reader support and vim insert mode remapping, so shortcuts like jj to escape now work●STREAM — Subagent output streams as it is produced, so long delegations show their progress. Background agent reliability improved alongside it●PRICE — Sonnet 5 promotional pricing of $2/$10 per million tokens runs through August 31. Standard pricing of $3/$15 takes effect September 1●COWORK — Cowork now reaches web, iOS, and Android, with sessions and files following you across devices. The doubled beta usage limits run through August 5●APPROVE — Approvals can now come from your phone, which shortens the stretches where a background run or scheduled task simply sat waiting for permission●SANDBOX — Version 2.1.216 lets you control filesystem isolation separately from network isolation, so you can tune the sandbox per workload●A11Y — Version 2.1.208 added screen reader support and vim insert mode remapping, so shortcuts like jj to escape now work●STREAM — Subagent output streams as it is produced, so long delegations show their progress. Background agent reliability improved alongside it●PRICE — Sonnet 5 promotional pricing of $2/$10 per million tokens runs through August 31. Standard pricing of $3/$15 takes effect September 1
Tightening Filesystem Isolation Separately from the Network — Collect the Paths, Then Squeeze the Write Surface
Claude Code v2.1.216 lets you control filesystem isolation independently from network isolation. Before tightening anything, I traced what a real job actually touches, split reads from writes, and measured how stable the path set is across repeated runs. The numbers changed how I wrote the allowlist.
A week after locking down outbound traffic, I was looking at the working directory of a finished job and stopped.
There were files sitting outside the project. Network egress was fenced in, but the filesystem was still wide open. I had tightened one side and felt safe about both.
For an indie developer running unattended jobs overnight, how far writes can reach should have been one of the first values I pinned down.
Claude Code v2.1.216 made filesystem isolation controllable separately from the network side, which means the granularity can differ per use case. Changing the granularity, though, requires knowing what the job actually reaches for today.
An allowlist written from memory doesn't fail the moment you deploy it. It fails three weeks later, in the middle of the night.
So this is a field note about the reconnaissance step, not the configuration step. Most of the space goes to what I measured and what the numbers made me decide.
One lesson stuck from that round: an allowlist assembled from memory always has holes. The host list I counted in my head turned out to be short the moment I ran anything real.
The same failure was waiting on the filesystem side, only worse. Outbound hosts number in the dozens at most. The paths a job touches run into the hundreds. That is not a set you enumerate from memory.
So I started with collection instead of configuration.
Collecting What the Job Touches
strace does the collecting. With -e trace=file it keeps only the syscalls that take a pathname, and -f follows child processes.
strace -f -e trace=file -o trace.log bash job.sh
That produces raw output, not an answer. Turning 1,589 lines into something decidable means splitting reads from writes — specifically, checking whether openat was called with O_WRONLY or O_CREAT in its flags.
Here is the script I actually used. It runs as-is.
#!/usr/bin/env python3"""Collect file access from strace output, split into read and write surfaces."""import re, sys, os, json, collections# openat(AT_FDCWD, "/path", FLAGS...) = fdOPENAT = re.compile(r'openat\(([^,]+),\s*"([^"]*)",\s*([^)]*)\)\s*=\s*(-?\d+)')# stat("/path", ...), access("/path", ...), and friendsPATHCALL = re.compile( r'\b(stat|lstat|newfstatat|access|readlink|unlink|unlinkat|mkdir' r'|mkdirat|rename|renameat2?|statx|execve)\((?:[^,]+,\s*)?"([^"]*)"')WRITE_FLAGS = ("O_WRONLY", "O_RDWR", "O_CREAT", "O_TRUNC", "O_APPEND")MUTATORS = {"unlink", "unlinkat", "mkdir", "mkdirat", "rename", "renameat", "renameat2"}def classify(line): m = OPENAT.search(line) if m: _, path, flags, ret = m.groups() kind = "write" if any(f in flags for f in WRITE_FLAGS) else "read" return path, kind, int(ret) >= 0 m = PATHCALL.search(line) if m: call, path = m.groups() kind = "write" if call in MUTATORS else "read" # Anything ending in ENOENT is a probe that missed, not a real read ok = not re.search(r'=\s*-1\s+ENOENT', line) return path, kind, ok return Nonedef normalize(p, cwd): if not p.startswith("/"): p = os.path.join(cwd, p) return os.path.normpath(p)def compact(paths, depth=3): """Fold paths to a prefix of the given depth. This is your allowlist length.""" out = collections.Counter() for p in paths: parts = [x for x in p.split("/") if x] out["/" + "/".join(parts[:depth])] += 1 return outdef main(trace_path, cwd): reads, writes, misses = set(), set(), set() with open(trace_path, errors="replace") as fh: for line in fh: r = classify(line) if not r: continue path, kind, ok = r path = normalize(path, cwd) (writes if kind == "write" else reads).add(path) if not ok: misses.add(path) # Written paths shouldn't be double-counted as reads reads -= writes misses -= writes print(json.dumps({ "read_paths": len(reads - misses), "probe_misses": len(misses), "write_paths": len(writes), "read_prefixes_d2": len(compact(reads - misses, 2)), "read_prefixes_d3": len(compact(reads - misses, 3)), "write_prefixes_d3": len(compact(writes, 3)), "write_prefix_list": sorted(compact(writes, 3)), }, indent=2, ensure_ascii=False))if __name__ == "__main__": main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else os.getcwd())
The job I measured mirrors a real pipeline: git init through commit, with a Node script and a Python aggregation in between. Nothing exotic — the shape most automation actually has.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Path sets from three identical runs agreed only 78.3% of the time; folded to prefixes, agreement hit 100%
✦175 read paths versus a write surface that folds into 3 prefixes — why tightening writes costs almost nothing
✦A runnable script that splits strace output into a read surface and a write surface, ready to paste
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My first pass counted 276 reads. Looking at the breakdown, 101 of them were lookups for paths that were never there. I'll come back to those.
The line that caught me was the last one. Seventy-three write paths fold into three prefixes at depth 3:
Prefix
What it is
The project working directory
Build output, `.git` internals, temp files
`/dev/null`
Where output goes to be discarded
`/dev/tty`
Direct terminal output
Outside the project directory and two device files, this job wrote nothing at all.
The read surface needs 40 prefixes at depth 3, and still 15 lines folded all the way to depth 2 — the Python standard library, locale data, the shared files under git-core. Obvious in hindsight. Tools can't run unless the places they live in are readable.
That gave me the decision. Tightening reads has poor economics; tightening writes has excellent economics. Constraining reads means maintaining 15 to 40 lines that grow every time you add a dependency. Constraining writes takes three lines and directly blocks the thing you most want blocked: an accidental write outside the workspace.
Once collection works, it's tempting to paste the result straight into an allowlist. I nearly did.
For safety, I ran the same job twice more and compared the sets. That's where my expectation broke.
Run
Paths collected
First
333
Second
349
Third
349
Union
396
Present in all three
310
Same script, same environment, three runs back to back. Only 310 paths appeared every time, against a union of 396 — 78.3% agreement. Better than one path in five was new on some run.
Temporary names git generates while writing objects, plus content-addressed object hashes. The first are random by construction; the second shift whenever a commit timestamp does. They were never going to match.
So I folded the same three runs to prefixes and compared again.
Granularity
Union
In all three
Agreement
Full paths
396
310
78.3%
Prefix, depth 2
23
23
100%
Prefix, depth 3
64
64
100%
Prefix, depth 4
114
114
100%
At every depth from 2 to 4, all three runs matched exactly.
I don't think that's luck. Random names are generated at the leaves; the directories that hold them are fixed by the tool's design. Individual paths move, but the places paths belong to do not.
Which means an allowlist has to be written in prefixes. Paste one run's path list verbatim and the second run falls over. Had I not repeated the collection three times, I would have written that configuration without ever seeing the problem.
Squeezing the Write Surface
Given all that, the only thing I actually tightened was writes. In .claude/settings.json, deny broadly under permissions, then carve out the working directory.
Deny broadly first. Make "can't write" the default, then open what's needed. Build it the other way around and anything you forget stays quietly open.
Allow by prefix. Per the previous section, leaf paths won't hold. Close with /**.
Don't forget /tmp. Even when it doesn't show up in a trace, plenty of tools reach for a temp directory under certain conditions. Skip it and you get the worst kind of failure — passes normally, fails only at a particular input size.
One caveat on the sandbox-side filesystem settings: key names move between versions. The changelog records that v2.1.216 made isolation independently specifiable from the network side, but confirm the exact keys against the changelog for the version you're on. My testing here ran on the v2.1.220 line.
"Not Found" and "Not Allowed" Fail Differently
Back to those 101 entries.
Of the 276 read accesses collected, 101 were lookups for paths that did not exist — roughly 37% of the total. The biggest sources:
Prefix
Misses
What it is
Under the working directory
31
Config file candidate search
`/usr/lib/python3.10`
13
Module resolution candidates
`/usr/lib/locale`
12
Locale definition candidates
`/usr/local/sbin`, `/usr/local/bin`
12
`PATH` traversal
None of that is pathological. Python walks candidate paths on every import and moves on when one isn't there. A shell scans PATH from the front. Missing is part of the design.
And that's exactly where tightening gets subtle.
When a path is absent, the syscall returns ENOENT. The caller reads that as "not here" and tries the next candidate. When policy blocks it, the return is EACCES or EPERM. Same failure to open, different reason on the wire.
Plenty of candidate-search loops handle ENOENT and treat everything else as fatal. Those tools stop dead the first time they hit a denial instead of falling through.
So pasting collected paths into an allowlist is wrong twice over. Allowing paths that don't exist buys nothing, and pulling previously-probed locations into a deny scope turns a silent miss into a hard error.
My rule now: don't tighten reads at all. Tighten writes, plus the narrow read surface that holds secrets. A broad read restriction collides head-on with probing, which is normal behavior.
The Order I Run It In
The sequence I've settled on:
Pick one representative job. Don't try to measure everything. Take the one that reaches furthest — usually the pipeline that spans clone through build.
Run it at least three times. One run isn't enough, as the numbers above show. Three was enough for prefixes to settle.
Separate reads from writes. Look only at write_prefix_list in the output. If it's short, the hard part is already done.
Copy the write prefixes into the allowlist. Use depth 2 or 3. Depth 4 and beyond added maintenance without meaningfully changing safety.
Write deny first, allow second. Close the default, then open.
Run the same job again under the tightened config. Whatever breaks marks what collection missed.
Record why things failed. Logging that distinguishes EACCES from ENOENT saves the next debugging session entirely.
Skipping step 6 is tempting. I skipped it once. Whether a tightened config actually holds is simply not knowable until you run it.
What This Still Doesn't Solve
The method has limits worth naming.
strace sees only the code paths a run actually took. Error branches, or the backup routine that fires at month end, go uncollected if they don't happen to execute. The rarer a path, the more likely you discover it by breaking it after tightening.
For now I patch that by deliberately triggering the main failure modes and collecting again — crude, but it works. If there's a better approach, I'd like to hear it.
The other limit is that this depends on Linux strace. macOS won't do the same thing, so you can't collect on a dev laptop and write production config from it. Collect in an environment that matches production.
Even so, learning that the write surface folds into three prefixes changed how the task felt. While I was still trying to constrain reads perfectly as well, I never actually started.
Deciding to tighten a narrow thing completely is what let the work move.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.