Agent failure labs / Guide 53
AI File Deletion Dry Runs: Catch the Prefix Trap
Reproduce an unsafe AI file deletion selection caused by path prefixes, then build an exact dry-run manifest with scope checks, version checks, and human approval.
Illustrative engineering guide. Examples are not client deployments, production-ready integrations, or evidence of measured results. Validate the design with your domain and security owners.

“Remove the generated cache under /work/cache, but keep the backup.” A hypothetical assistant translates that request into a prefix filter. The inventory also contains /work/cache-backup, a symbolic link under the cache directory, and a path containing a parent traversal. The filter selects all of them. A clean-looking explanation cannot undo the fact that the deletion candidate set is already wrong.
This exercise stops before deletion. It teaches how to make a dry run carry exact object identity and version, rather than merely echo a command for a reviewer. The narrow failure is lexical scope confusion: sharing the characters of a directory prefix does not establish membership in that directory or authorization to remove an object.
Inspect the Unsafe Selection
The unsafe mock returns every inventory item whose path begins with the requested string. The fixed selector requires a normalized fixture path beneath the exact root, excludes links and parent traversal, and emits immutable identity-version pairs. An approval comparison then detects a changed object revision without touching a filesystem.
This is a deterministic synthetic simulation of a possible failure, not an actual LLM experiment or filesystem safety test. The inventory is handwritten data. No path is opened, resolved, moved, or deleted. Python’s pathlib documentation distinguishes pure paths, which perform computation without I/O, from concrete filesystem paths; this example deliberately uses the former.
from pathlib import PurePosixPath
root = PurePosixPath("/work/cache")
inventory = [
{"id": "generated", "path": "/work/cache/a.bin", "kind": "file", "rev": 2},
{"id": "backup", "path": "/work/cache-backup/a.bin", "kind": "file", "rev": 1},
{"id": "link", "path": "/work/cache/external", "kind": "symlink", "rev": 1},
{"id": "escape", "path": "/work/cache/../private/a", "kind": "file", "rev": 1},
]
def naive(items):
return [x["id"] for x in items if x["path"].startswith(str(root))]
def dry_run(items):
result = []
for item in items:
path = PurePosixPath(item["path"])
if (item["kind"] == "file" and ".." not in path.parts
and root in path.parents):
result.append((item["id"], item["rev"]))
return tuple(sorted(result))
def approval_valid(manifest, items):
return bool(manifest) and manifest == dry_run(items)
assert "backup" in naive(inventory)
assert "escape" in naive(inventory)
manifest = dry_run(inventory)
assert manifest == (("generated", 2),)
assert approval_valid(manifest, inventory)
changed = [dict(x, rev=3) if x["id"] == "generated" else dict(x) for x in inventory]
assert not approval_valid(manifest, changed)
assert dry_run(list(reversed(inventory))) == manifest
assert len(inventory) == 4
print("Dry run selected one object; backup and escape candidates excluded.")
The same inventory produces four unsafe candidates but only one guarded candidate. The unchanged inventory assertion confirms that the lab is about selection, not mutation. Notice that an empty manifest is not treated as blanket approval. “Nothing selected” is a reportable outcome, not permission to broaden the search until something can be removed.
Why Prompted Caution Is Not Containment
A reminder to “be careful with backups” still leaves the model free to write an unsafe prefix test or choose a broader root. A filename can contain instructions such as “ignore retention policy.” Those characters belong to an object label, not the authorization channel. The execution service should accept neither shell commands nor model-defined cleanup roots.
Begin with a server-side mapping from authenticated principal to allowed storage namespace. OWASP authorization guidance recommends least privilege and checking specific resources on every request. An operator’s access to list a directory must not automatically grant deletion rights, and a tenant identifier inside the assistant’s proposal must not replace the authenticated tenant.
The server also applies retention rules, legal holds, object type restrictions, and maximum object and byte counts. Those are business preconditions separate from candidate membership. The fixture has none of those real controls; its lexical predicate is useful only for showing the prefix error. A genuinely authorized file might still be protected by retention policy.
Make the Dry Run Reviewable
A production manifest should show object identifiers, canonical display paths, versions, sizes, retention status, and exclusion reasons. Compute a digest over the complete sorted manifest and relevant policy revision. The reviewer approves that digest with an expiry and a named scope. Do not allow the assistant to substitute a newly expanded glob after the reviewer clicks approve.
OWASP transaction authorization guidance recommends protecting significant operation data from modification and applying a final authorization gate. For cleanup, the significant data is the exact target set, not the phrase “delete cache.” Adding one object, increasing a byte limit, or changing a version should invalidate approval and produce a new dry run.
The first implementation needs an inventory adapter, a policy function, a manifest store, and a narrowly scoped execution adapter. A model can summarize exclusion reasons or help users refine an ambiguous request. A specialist agent is unnecessary for path comparison. Add a content classifier only if a reviewed evaluation shows that identifying generated artifacts cannot be handled by authoritative build metadata.
Account for Real Filesystem Races
Pure path relationships are lexical. They do not inspect symlinked ancestors, mounts, hard links, case behavior, or a directory replaced between inspection and execution. The Python documentation explicitly notes the string-based nature of pure path operations. Do not present this selector as protection against all filesystem escape attacks.
Production adapters need storage-specific object identity checks and race-resistant operations appropriate to the platform. Prefer versioned object storage or a reversible quarantine workflow where possible. Recheck preconditions at the effect boundary, and retain a bounded restoration window. A backup is useful only when restoration has been tested and ownership of that recovery procedure is clear.
If a cleanup job times out, its outcome is unknown. Reconcile the approved manifest item by item using authoritative storage state. A generic status: success from a wrapper cannot establish that exactly the approved versions were affected. Do not rerun a wildcard because the first process produced no final message; the namespace may have changed while it ran.
The snippet omits the authentication source, database transactions, persistent approvals, state persistence, provider semantics, actual quarantine, and every destructive action. Incomplete inventories and incorrect retention rules remain failure sources. The deterministic guard proves only the fixture’s selection and version comparison, not that any selected real object is safe to remove.
Acceptance Tests
- A sibling named cache-backup must remain excluded when cache is the approved root.
- Parent traversal and symbolic-link candidates must be excluded with distinct operator-visible reasons.
- A new object or changed version between preview and approval must require a fresh manifest.
- An inventory permission error must report incomplete coverage, not a successful empty cleanup.
- A held or cross-tenant object must fail policy even if its path is lexically inside the root.
- A partial execution timeout must reconcile exact object versions without expanding or replaying the original wildcard.
Measure Scope Errors Before Enabling Effects
Build a labeled baseline of cleanup requests with sibling prefixes, hidden entries, links, and changing inventories. Measure unauthorized selections over all proposed objects, plus retained eligible objects over all eligible objects. Report both object counts and bytes: one mistakenly selected archive can matter more than hundreds of harmless cache entries.
Run read-only previews first and have operators compare manifests with existing cleanup procedures. The proposed pilot guardrail is no effect outside an approved manifest, with automatic suspension after any discrepancy. Also track restoration success and unresolved objects; apparent cleanup throughput is not evidence of safe recovery. No deployment results are claimed here.
The IT helpdesk guide places cleanup inside support workflows, while the database update safety lab explores a similar stale-version boundary. DevOps and platform engineering can supply restricted adapters and recovery procedures. Start by exposing the exact candidate manifest, not by giving the assistant a more powerful shell.