A Check That Cannot Look
- The shape
- The scanner that gave up
- Two suites that could not score
- Two lines in a subshell
- The flag that did nothing
- Twelve verbs in the usage log
- Four crashes that were me
- The review that was never taken
- A hundred files and a flag nobody read
- Why this shape
- What I do about it now
- The uncomfortable part
- Further reading
I have been building a personal agent. The interesting part is not the agent. It is that every serious bug in it has been the same bug in a different coat: a check that reported clean because it could not look.
The shape
A check runs. Something stops it from seeing what it was meant to see: a missing file, a timeout, a flag that was ignored, a variable that does not survive its own subshell. The check does not fail. It reports success, because nothing it could see was wrong.
That is worse than no check at all. No check is an absence you can feel. A check that cannot look is an absence you have paid for, written down, and started trusting.
Nine now on one small codebase, five in the first week and four since. Every one was found by looking again at something already green.
The scanner that gave up
The repository scans everything it tracks for credential shapes. It ran on every commit and always passed. It always passed because it took seven minutes and the caller had a timeout. A timed-out scan is not a clean scan. It is no scan, reported as a clean one. One pass over the tracked files instead of one per shape fixed the timeout, but that is not the lesson. “The gate is green” meant “the gate did not finish” for as long as nobody timed it.
Two suites that could not score
A test file for the risk classifier printed “clean” and exited zero while
twenty-five of its cases died with command not found. A helper had been
renamed and those cases never ran. They did not fail, because a case that
cannot run does not fail. It is not there, and the summary counts what ran.
Then the same class one level up. I added a fixture helper to the main suite,
called bad(). The failure reporter was already called bad(), so from that
line down every failing assertion rebuilt a fixture instead of reporting a
failure. A third of the suite was scored by a function that could not score,
and it said “all clean” throughout. Renaming it surfaced one real failure
sitting there quietly.
Two lines in a subshell
I made the suite run its sections in parallel. Sections became functions,
functions ran in background subshells, and the tally lived in shell variables.
A variable set in a subshell dies with the subshell. Two assertions failed,
printed FAIL in plain sight, and the summary line underneath said all
clean. The screen contradicted itself and I nearly scrolled past it.
The flag that did nothing
Six scripts launched the model with a settings string meant to turn off the machine’s hooks:
--settings '{"disableAllHooks":true,"statusLine":{}}'
statusLine in that schema needs a type and a command. An empty object fails
validation, and claude --help, under -p, says what happens then in a
sentence worth running the command to read for yourself: “Settings files that
fail validation are silently ignored in this mode (no error dialog is shown).”
The file was dropped, the flag did nothing, and six scripts ran with every
hook firing for weeks. No log said so, because an ignored flag produces no
output at all.
Four runs against the hook’s own log settled it, and the first was the
control: a plain run added two lines, that string added two, the string
without statusLine added none. Without the control, that zero could not tell
a working flag from a hook that never registered.
Twelve verbs in the usage log
The agent logs its own usage so I can see which surfaces I touch. The writer had an allowlist of twelve verbs and was called from the eight places that open a surface, so of fourteen verbs, two ever reached the log. When I read it for what I never use, it handed me the other twelve: what it could not see, read as what I do not do. Every call is counted at dispatch now, and the same four weeks read eighty-six asks instead of a handful.
Four crashes that were me
A health check counts crashes by reading the core dump list. It reported four for the password manager in one week. All four were the desktop shell restarting, three times by me and once by the updater, and the service manager stopping the password manager within five seconds of each. Its exit path traps, so the dump list records a signal and no core, which the system journal knew and the counter never asked. It counted every dump as a crash, so an exit it could not read fell into the alarming column. It asks the journal now and says which it found.
The review that was never taken
A timer types the review prompt into a running session. The readiness check before the send could not tell a busy session from an idle one, because the footer reads the same either way, so one afternoon’s review was pasted into a turn already in progress and never ran. Nothing on either side checked that the prompt had been taken, so the review left nothing behind, and nothing behind looks exactly like a review I never scheduled. The sender now waits while the pane says the conversation is working and confirms the prompt was taken, so a scheduled run that left nothing is reported, not blank.
A hundred files and a flag nobody read
The search tool returns at most a hundred files, with a truncation flag when there are more. The model asked it for something, got a hundred files and the flag, and answered that the fact was not in the repository. Truncated is not empty. The flag was right there in the output, and that is the part I keep relearning: displayed is not handled.
Why this shape
Two reasons. Only the second is about AI.
In shell and in glue code, absence and failure look identical. A missing file
and an empty file are the same read. $? from the wrong command is the same
integer as success. You have to decide, at every check, what “I could not
tell” looks like and make it different from “nothing was wrong”.
The second is that a model writing code optimizes for the green result, and so does a person under time pressure. Nobody sets out to write a check that cannot fail. You write one by fixing a failing check the cheapest way that made it stop.
What I do about it now
A check that could not run says so. Not silence, not a pass. “Cannot tell” is a third answer, distinct from yes and from no.
Every protection gets a mutation. Delete the behavior the test protects, watch it go red, put it back. One batch caught five assertions out of about thirty that would have passed with the feature gone entirely.
Every claim about a flag gets a control arm. If I cannot show you the measurement where it does not work, I have not shown you that it works.
The count lives where the work does not. Any tally that has to survive a subprocess goes in a file, where the reporter can read it after the work has died.
The uncomfortable part
I did not find any of these by reading the code. I found them by measuring something for an unrelated reason, or by a reviewer reading a diff I was pleased with. The suite was green for every one. Green was the symptom.
So the question I keep asking about my own build, and would ask about yours: when your checks pass, do you know they looked?
Further reading
claude --help, the-pentry: the sentence about settings files that fail validation being silently ignored. One command, and the whole reason the flag did nothing for weeks.- Command execution environment, the Bash manual: why a variable set in a subshell never reaches the parent.
- DeMillo, Lipton and Sayward, Hints on test data selection, IEEE Computer, 1978: the paper usually credited with introducing mutation testing, which is what “every protection gets a mutation” is.