$ cat index.mdx$ cat ~/blogs/security/fail-closed-ai-agent-guardrails/index.mdx # I put my name on this
Fail Closed AI Agent Guardrails: Why Empty Is Not Safe
On October 14, at 12:54 in the afternoon, a reviewer agent in my fleet ran this (simplified from the session log):
H=$(ls -d $S/big.* | head -1)
export HOME=$H
yoke notifications --jsonThe glob matched nothing.
zsh printed no matches found, H stayed empty, and HOME became an empty string.
A minute later the same agent reported that my real yoke database had been migrated from schema 6 to schema 12.
Its words: "I caused it."
Two hours later, a different agent force-deleted four git worktrees that still held uncommitted work. It was cleaning up disk space because I asked it to.
Neither agent ignored an instruction.
Both had a safety measure in place, and both measures said go.
Each one read nothing as safe.
An empty HOME meant "use the default home", and an empty git status meant "only caches in here".
My take: any check that gates a destructive action has to treat empty, missing or failed input as a reason to stop. Guardrail guides call this failing closed, and the top results for it give the idea one sentence at most. This post is what skipping that sentence cost me in one afternoon, plus the five rules my agents now follow. It is also my second post about an agent damaging my own setup. The first was the day an AI agent opened my server to the internet.
What fail-closed guardrails mean for an AI agent
A check fails closed when every error, timeout or empty answer leads to the safe outcome: keep the file, or deny the action. It fails open when the same doubt leads to the risky outcome. For agent actions that delete or migrate anything, only failing closed is acceptable.
Most code fails open by accident. An empty string is a perfectly valid string, so nothing complains when one shows up where a path should be. Agents make it worse, because they write and run glue code faster than anyone reads its default branch.
Problem #1: an empty HOME is not a missing HOME
Yoke is a tool I'm building that runs inside my Codex hooks and keeps its state in a SQLite file at ~/.yoke/state.db.
The reviewer agent, one worker in the multi-agent Herdr setup I run, was load-testing it.
The question: does the first yoke notifications --json sync on a 600,000-row database starve a hook that runs at the same moment?
To keep the test away from my real state, it pointed HOME at a throwaway fixture directory.
That directory had already been deleted.
So yoke started with HOME="" and asked Rust where home was, through std::env::home_dir().
Since Rust 1.90, home_dir() on Unix treats an empty HOME exactly like an unset one.
The standard library line is .filter(|s| !s.is_empty()).or_else(fallback), and the fallback reads the account's home from the user database.
On my Mac that is /Users/easinarafat.
The isolation the agent had set up was gone, and the dev build opened my real database and ran six migrations on it.
I wanted to know whether this was a Rust quirk, so I ran the same empty HOME through four runtimes on my Mac:
| Runtime | HOME="" | HOME unset |
|---|---|---|
Rust 1.95, std::env::home_dir() | /Users/easinarafat | /Users/easinarafat |
Python 3.14.7, Path.home() | / | /Users/easinarafat |
Node 24.12, os.homedir() | "" | /Users/easinarafat |
Go 1.26.5, os.UserHomeDir() | error: $HOME is not defined | error: $HOME is not defined |
Four runtimes gave four different answers to the same empty string. Only Go refuses.
Rust silently returns the real home, the one place an isolated test must never reach.
Python returns /, so state goes to /.yoke/state.db.
On macOS that write dies with OSError: [Errno 30] Read-only file system: '/.yoke', which saves you by accident.
Node returns an empty string, and path.join(os.homedir(), ".yoke", "state.db") becomes the relative path .yoke/state.db.
Your "home" state then appears quietly in whatever folder the process happened to start in.
Here is what the migration would have cost.
The hook binary my Codex setup actually runs only understands schema 6.
Given a schema 12 database, it bails with state database schema 12 is newer than supported 6.
Every hook call would have hit that error.
I confirmed that failure on a copy, not live: the only decision recorded in the real database after 12:54 was the probe's own.
The recovery took seven minutes:
- 12:55. The reviewer noticed it itself, by reading the file header and finding
user_version: 12. - 12:59. The database was backed up through a read-only connection, and the rollback was rehearsed on a copy.
- 13:01. I said go, and the down migrations for 12 back to 7 ran in a single transaction. Result: schema 6, integrity check ok, zero foreign-key violations, identical row counts.
The fix shipped at 15:28 the same day. Yoke no longer asks Rust where home is:
/// The home Yoke keeps its state under, from `HOME` only. An unset, empty or relative value
/// fails instead of falling back to the account's home, which an isolated run must never use.
pub fn home() -> Result<PathBuf> {
home_from(std::env::var_os("HOME"))
}
pub fn home_from(value: Option<std::ffi::OsString>) -> Result<PathBuf> {
let home = PathBuf::from(value.context("HOME is not set; Yoke needs it to find its state")?);
ensure!(
home.is_absolute(),
"HOME must be an absolute path; Yoke never falls back to another home directory"
);
Ok(home)
}An empty string is not an absolute path, so the empty case now ends in an error message instead of a migration.
Problem #2: a check whose default answer was "delete"
At 14:45 I asked my main agent to free up disk space taken by old git worktrees. At 14:47 its own inventory listed four worktrees with real edits in them: 8, 7, 3 and 2 changed files. At 14:48 it started a cleanup loop built on this check (reformatted from one line):
junk_only() {
git -C "$1" status --porcelain 2>/dev/null \
| grep -qvE '^\?\? (\.codegraph/|\.playwright-cli/|test-results/)$' && return 1
return 0
}
junk_only "$d" && force="--force"
git worktree remove $force "$d"Read it the way the shell does.
grep -v looks for any line that is not a known cache folder.
If it finds one, the function returns 1: real work, no force.
If it finds none, it falls through to return 0: caches only, add --force.
"Finds none" covers three different situations:
- the worktree really holds only caches;
git statusprinted nothing;git statusfailed, and2>/dev/nullthrew the error away.
Only the first one is safe.
The function gives all three the same answer, and that answer unlocks --force.
The loop ran from 14:48:40 to 14:51:04 and removed 60 worktrees. It forced 13 of them as "caches only". Four of those 13 were the worktrees with real edits.
The part I still can't explain: one minute earlier, git status saw those edits.
At removal time it returned nothing for them, or failed with its error hidden, and the agent could not work out which.
Rerunning the check on the worktrees it kept behaved correctly.
I don't need the root cause to see the bug. A check that can't tell "I looked and found nothing" from "I couldn't look" will eventually get the second one.
To see it fail on demand, I rebuilt the pattern in a scratch repo with git 2.52.0.
I moved the main repository, which breaks the link from every worktree back to it, and left one unsaved file in wt-dirty:
$ ./fail_open.sh wt-clean wt-dirty
wt-clean: clean -> would run: git worktree remove --force wt-clean
wt-dirty: clean -> would run: git worktree remove --force wt-dirtywt-dirty holds unsaved work, and the check calls it clean.
Behind the scenes, git status died with fatal: not a git repository, and nobody saw it.
I had no Time Machine snapshots. Committed work was safe, because removing a worktree leaves its branch alone. The uncommitted edits were gone from disk.
The backup I didn't know I had was the agent transcripts.
Claude Code logs every edit an agent makes.
A recovery agent replayed each Edit, Write, heredoc and perl -i from those sessions and rebuilt every worktree's changes as patch files.
I spot-checked one patch against the original command in the log, and it matched.
Anything I typed by hand in those worktrees, outside an agent, is gone for good.
When fail open is the right call
Fail closed has a price, and yoke shows it too. Its schema check is itself fail closed: a binary that finds a newer schema refuses to touch the database. If nobody had noticed the migration, that refusal would have made every hook in my Codex setup error out. Failing closed turned a silent data change into a loud outage.
For a check that deletes or migrates, take the outage every time. An outage is visible and you can undo it; a deleted worktree with no snapshot is neither. For a check that only logs or notifies, failing open is usually the better call. Blocking the agent costs more than one missing log line.
Both of my checks failed open without anyone deciding that they should.
Five rules for fail-closed agent checks
These now live in my agent rules. They get copied word for word into the brief of every subagent that runs code against real state.
1. Treat empty as missing, and missing as fatal.
In shell, ${VAR:?} aborts when the variable is unset or empty, and a second test before each run catches the rest:
T=$(mktemp -d) && export HOME="${T:?}"
test -n "$HOME" && test "$HOME" != "/Users/easinarafat" && test -d "$HOME" || exit 1In code, read the variable yourself and validate it, the way the yoke fix does. Don't ask a library for a "home" that has a fallback built in.
2. Never hide stderr in a check that gates deletion.
2>/dev/null and || true turn errors into empty output, and empty output is exactly what a fail-open check reads as safe.
Check the exit status instead:
if ! out=$(git -C "$wt" status --porcelain); then
echo "$wt: git status FAILED -> keep"
continue
fiOn the broken scratch repo, this version prints git status FAILED -> keep for both worktrees.
3. Make the fall-through answer "keep".
In junk_only, the last line was return 0, the answer that unlocked --force.
Every path you didn't think of ends at that last line, so it has to be the harmless one.
Prove safety with positive evidence, and let everything else fall to keep.
4. Never let a heuristic unlock --force.
Delete the folders you know are disposable by exact name, then run the tool's own removal without force and let it refuse:
rm -rf -- "$wt/.codegraph" "$wt/.playwright-cli" "$wt/test-results"
git worktree remove "$wt"On a worktree with unsaved work, git answers fatal: 'wt-dirty' contains modified or untracked files, use --force to delete it and exits 128.
That refusal is the last gate.
If your own check ever misreads an empty git status with a clean exit code, git's check still stands between the loop and the work.
5. Dry run first, and keep the receipt.
Print the exact list of what will be removed, with the reason each item is safe, before removing anything.
Afterwards, compare what happened against that list.
In my case, the 14:47 inventory already showed changes in the four worktrees that the loop later forced as "caches only".
A dry run compared against that inventory would have caught the contradiction before the first --force.
Open your agent hooks and cleanup scripts today and search for three things: 2>/dev/null, || true, and any --force assembled from a variable.
Each hit is a place where a check can say yes after seeing nothing.