A hash with a hole in it
Our change detector digests inode metadata to decide whether to look. Leave one field out of that digest and an agent can edit a file without any checkpoint ever noticing.
agentfs snapshots an agent’s work during a run, not only at the end. The
property it is trying to provide is narrow and worth stating precisely: the
agent cannot prevent a checkpoint.
On our FUSE backend, knowing when to snapshot is free. The server sees every
mutating operation, so it bumps an AtomicU64 and the watcher reads it. Exact,
no tree walk, no inotify registration, and no false positives from our own
snapshot activity.
The kernel-overlayfs backend has no server. Nobody is in the path to bump
anything. So the question “did anything change?” has to be answered by
looking, and looking on every 500 ms tick has to be cheap — which means
stat, not content hashing.
The tempting minimal digest
Walk the upper layer, digest each entry’s inode metadata, compare the digest to last tick’s. The obvious fields are inode number, size, and mtime. That is what most “has this changed?” caches use, and for a build system it is fine.
Here it has a hole. Consider an agent that wants to edit a file without being snapshotted:
- Write the new contents in place, at exactly the same length.
utimensatthe mtime back to what it was.
Inode number: unchanged. Size: unchanged. Mtime: restored. The digest matches, the watcher concludes nothing happened, and it does not walk.
That last clause is what makes this a real failure rather than a latency bug. A missed bump on the FUSE counter delays a checkpoint; the next mutation bumps it again and the walk records everything. Here the walk is gated on the probe, so a digest that fails to notice a change produces a suppressed checkpoint, not a late one. The property at the top of this note quietly stops holding, and nothing reports an error.
st_ctime closes it
Add ctime to the digest and the trick stops working. Ctime moves on any inode
change — including the utimensat intended to cover the tracks — and
userspace cannot set it. Forging it requires CAP_SYS_TIME and a clock
change, which is a different and much louder kind of attack.
The general shape is worth keeping: when a digest is the input to a decision about whether to look, every field you leave out is an action someone can take invisibly. Mtime is advisory metadata that the owner of a file is allowed to rewrite. Ctime is bookkeeping the kernel keeps for itself. Only one of those belongs in a security-relevant fingerprint.
What it costs
About 1% of an agent-shaped workload — twelve rounds of edit, build, git add, commit, run inside the sandbox:
| watcher off | watcher on | |
|---|---|---|
| mean | 10.177 s | 10.293 s |
| spread | 0.312 s | 0.444 s |
+1.1%, with the ranges overlapping — the off arm peaked above the on
arm’s minimum, so at n=4 this is not separable from noise. We trust it because
it matches the cost model rather than because the delta is convincing: a
10.2 s run at a 500 ms interval is ~20 probes, the walk measures 6.3 ms on a
corpus with a full .git, so predicted 126 ms against 116 ms measured.
The scaling rule is tax ≈ walk_cost / 500 ms, which means what governs
it is the number of non-ignored files, not the workload. The same walk is
0.5 ms on source alone, 6.3 ms with .git, and 80 ms — 16%, extremely
visible — if target/ were not excluded.
So the ignore list is doing load-bearing performance work, and it is the first thing to check if this tax ever appears. It also has to be exactly the same predicate the snapshot uses. Watch a path the snapshot skips and every build fires a checkpoint that records nothing at all.