Agent-First Data v0.33.0: A Refusal That Stops the Verb

by Agent-First Kit Contributors

Quoting decides how a string reaches a command, never whether the string is a disaster. This release adds a guard for the values that flow into rm and mv — and, because a correct refusal that the shell discards is not a guard at all, the two verbs that carry its verdict.

Shell quoting is a solved problem, and shell tooling is very good at it. shellcheck will tell you that rm -rf $dir splits on whitespace. Adding the quotes fixes it. What nothing tells you is that rm -rf -- "$dir" is a perfectly quoted command that will delete your home directory the moment $dir holds it.

That is not an oversight in the linters. Quoting rules govern how a string is passed along, and they are complete on their own terms. Whether the string is a disaster is a different question, asked of the value rather than the syntax, and /, $HOME, a project root and a mktemp -d scratch directory are all equally well-formed non-empty strings.

Every script that has ever worried about this answered it privately, in some combination of [ -n "$dir" ], a case on a leading /, and a comment. The shape of the problem is that “is this value safe to hand to a destructive verb” has no vocabulary — so each script invents one, badly, once.

The guard reads; the verb stays in the shell

$ afdata guard tmp_path "$work_dir"
/var/folders/8v/9y4qr5xn0lz3/T/build.XkP2

afdata guard <path|tmp_path|cwd_path> <VALUE> [--under ROOT] validates a value and prints its normalized absolute path. It does not delete anything. This library reads and validates; the verb belongs to the shell that knows what it means to do.

The output is raw bytes with no envelope, on the same finite-read contract value already established: a rejection writes a structured error to stderr and leaves stdout empty. That is the property the whole thing rests on. A guard that failed by printing a diagnostic to stdout would hand the verb a filename made of prose.

The reject set is shared by all three types and needs no configuration to be right: an empty or all-whitespace value, a value carrying a newline, the filesystem root, $HOME itself or any ancestor of it, the current directory itself or any ancestor of it. A .. segment anywhere in VALUE is refused outright as guard_traversal_segment rather than normalized away — traversal is never how a destructive operand is legitimately addressed, and normalizing it first is exactly how a lawful-looking path one level up gets produced.

tmp_path adds containment under the system temp area, and the interesting part is where that root comes from. It is never TMPDIR, TMP, or TEMP. It comes from platform-fixed structure or a system API — confstr on macOS, /run/user/<uid> on Linux, SHGetKnownFolderPath on Windows through a dependency-free FFI shim. A guard that read the environment for its own containment root would be trusting whatever the caller’s environment was redirected to, which is to say it would structurally trust a wrong directory on request. cwd_path mirrors this against the real current directory.

The module is CLI-only, and deliberately absent from the four-language SDK surface. This is a shell-shaped problem: it exists because a shell discards exit statuses in argument position and because rm -f is defined to succeed on nothing. A language with exceptions and typed paths does not have it.

The final path segment is never resolved. That is a requirement, not a simplification: rm and mv act on a link, not on what it points at, and a guard that resolved the last segment would quietly upgrade the verb’s blast radius to wherever the link led.

But containment cannot be decided that way. A symlink sitting inside the temp area and pointing at $HOME satisfies tmp_path on its own path, and >, cp, and chmod all follow it. So the two questions were separated: containment is decided on the link’s target (for a dangling link, on where it would create), while the path printed for the verb stays the link itself. A link that sits inside the root and points outside it is refused as guard_symlink_escapes_containment.

In the same pass, $HOME was going into the reject set uncanonicalized while the current directory was not — so a home reached through a symlink, which is routine under CI and sandboxes, silently dropped that entry from the reject set.

The root belongs to the call site

--under ROOT is an additional containment root, intersecting with the type rather than replacing it, for a target anchored to a directory the type vocabulary cannot name — build output under a checkout, say, which cwd_path only matches when the script happens to be invoked from the right place. A blank root is refused instead of silently degrading to “under the current directory”.

Choosing that root turns out to be where the judgment lives, and the first rounds of using this in anger produced two rules worth stating.

A root is the directory whose entire contents you would accept losing. Not a sandbox boundary. A checkout root reads as safe and is the wrong answer: it leaves every sibling directory reachable. The rule caught a live instance the moment it was written — a site build guarding its output directory under the project root, where the output directory is built as a literal suffix of the project root. The containment held by construction and asserted nothing, while reading, to every future reader, as protection. Anchored at the deploy directory, whose entire contents are generated, it means something. A wide root is not merely weaker; it stops the author from looking for the tight one that exists.

The root belongs to the call site, not to the function being called. A helper whose callers legitimately write all over a checkout cannot name one, and the checkout root is not the fallback. Use path, which still refuses an unset value, /, $HOME, and the current directory, and give the root in the callers that have a tight one. “No honest root exists here” is a real answer; a wide root that looks like compliance is not.

One more, from the same rounds: put the guard ahead of the first command that touches the filesystem, including the mkdir -p that creates the directory about to be written. The guard accepts a path that does not exist yet, so the order is free — while the reverse order lets the least informative failure win, and an unset variable dies as mkdir: : No such file or directory instead of as guard_empty_value.

The refusal the shell throws away

Here is the spelling everyone writes first, and it cannot be made to work:

rm -rf -- "$(afdata guard tmp_path "$dir")"    # do not write this

In an argument position, a command substitution’s exit status is discarded. And rm -f is defined to exit 0 when its operand names nothing. So when the guard correctly refuses — printing nothing, exactly as designed — rm receives an empty operand list, succeeds, and the script continues as though the cleanup had happened. The guard worked. The verdict evaporated between the parentheses.

That is not a platform quirk to route around. -f suppressing “it wasn’t there” is what -f means. The fix is to put the guard’s status in a command position, which is what the Bash kit’s two new functions do:

afdata_remove tmp_path "$work_dir"
afdata_trash path "$document" --under "$library"

TYPE and --under are forwarded to guard untouched, so the call reads exactly like the guard it wraps and the kit never holds a second opinion about the vocabulary. The function’s exit status is the guard’s, so set -e stops on a rejection. (AFDATA_BASH_API_VERSION is 2.) For any other verb — a truncating redirect, cp, chmod — assign first and use the variable; two steps, and the status is a command’s again.

Which of the two removals to use is decided in the source, never guessed at runtime. Build output and mktemp scratch belong to afdata_remove: they are meant to disappear, and trashing every run’s scratch fills a person’s trash with things nobody will restore. A person’s own files belong to afdata_trash, where the system owns expiry and restore.

afdata_trash never falls back to deleting. When an entry cannot be made reversible it fails with trash_unavailable and touches nothing. Work that must proceed anyway wanted afdata_remove and should say so.

Windows had to be assembled rather than named

On the Unix platforms the trash entry point is the platform’s own — trash, gio trash, trash-put — and each fails closed by itself. Windows ships no trash command, so its branch had to be built. Writing it against a real host found two defects that would otherwise have shipped as silent, irreversible data loss.

The first is that the recipe every search returns is wrong. Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(..., SendToRecycleBin) permanently deletes in any session without a desktop — which is every session a script runs in — and reports success. Nothing reaches the Recycle Bin. The entry point drives IFileOperation instead, the interface Explorer itself uses, which recycles headlessly.

The second is in the delivery. powershell -Command - executes a piped script as it reads it, and on reaching the end it silently discarded the trailing statements — the recycle among them — and exited 0. The normal cases “passed” while touching nothing. The script is handed over with -File now, and success is no longer read from the exit code alone: the entry point prints a completion token.

Underneath both is a structural gap. Windows provides no call that fails when it cannot recycle; every route that cannot reach the bin deletes outright and reports success. So each condition is decided before anything is touched — UNC paths, non-fixed drives, the per-volume NukeOnDelete switch, the NoRecycleFiles policy, and entries over the volume’s quota — each measured on a real host, each in a fresh process, because the shell caches its bin configuration per process and a same-process measurement lies. A volume whose settings cannot be read at all is refused rather than assumed: a guarantee that cannot be checked is not one.

Three error codes, because the caller’s next move differs. trash_unavailable: nothing was touched, and afdata_remove is the honest verb if deletion is what was meant. trash_not_reversible: the entry is gone and cannot be restored. trash_incomplete: the run stopped somewhere unknown and the target’s state has to be checked. Neither of the last two is a retry; both want a person to look.

Windows also changed what guard prints there. Canonicalization produces the \\?\ extended-length form, which is a real path — but the shell’s own item API refuses it and .NET reads its leading backslashes as a UNC share, which made the trash entry point refuse every Windows path handed to it. An operand no downstream verb can use is not one this command should hand out, so it prints the drive form.

What this release was about

The last release was about a fact routed somewhere nothing could read it. This one is about a verdict routed somewhere nothing could act on it.

Getting the answer right was never the hard part; [ -n "$dir" ] gets a fair share of it right. The hard part is that the answer has to survive the trip to the verb — through a substitution that eats exit codes, through a flag that turns “nothing there” into success, through a platform whose delete-to-trash call deletes for real and says it didn’t. A refusal that does not stop the verb is decoration.