Three faults in one guard, the only thing standing between a credential and a
pushed commit. Found while planning Batch 17; closes#235.
1. grep was called without -e, at both call sites. The private-key pattern
starts with dashes, so grep read it as an option, exited 2, and the beside-it
2>/dev/null threw the complaint away. A staged private key passed the
pre-commit hook, and had since the rule was written. Proven in a scratch
repository before and after.
2. The 2026-08-29 pattern, committed as-is in 7415e19, flagged two comment
lines that exist to show the shape of a credential. verify was therefore red,
and release.sh would have died after bumping package.json, package-lock.json
and the Dockerfile, leaving a tree that pre-push then refuses.
3. verify never scanned dist/, though SECURITY_CHECKLIST.md has listed the
bundle scan as a release check for months. A key can reach the bundle from an
environment variable inlined at build time without ever being committed.
What changed, beyond -e: every pattern is compiled against empty input before
the scan and an unreadable one exits 2, because silence from a broken matcher
looks exactly like a clean tree. The NAME=value pattern now also catches quoted
values, which is how a real secret is usually written down and which it has
always missed. A line that must show a credential shape carries `secrets-ok:`
and a reason, which excuses that line alone and stays visible to review and to
grep. Guard 20-secrets runs --tracked and --built dist/.
Proven by mutation, per GUARDS.md: a staged PEM header, a bare API_KEY=, a
quoted API_KEY=, an exported secret and an AWS key id each exit 1; a ${VAR}
value, a <placeholder>, an excused line and a delete-only commit each exit 0; an
unreadable pattern exits 2 with content staged and with none; a key planted in
dist/ fails guard 20-secrets alone, masked in the report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>