v2.1.36 — Guardrail Precision
bkit v2.1.36 — Guardrail Precision
A guard that refuses correct commands is a guard people switch off, and then it
protects nothing. bkit's own code said that in a comment. This release makes it
true.
What changed for you
Your chained commands stop being refused. Until now, every Destructive
Detector rule was matched against your whole input, so a command later in a
chain could implicate an earlier, harmless one. Real examples that were blocked:
git push origin feature-x && rm -f /tmp/scratch/note.txt # denied — rm's -f read as a force push
cp a.txt b.txt && ls / # denied — the trailing / read as a delete target
curl -o pkg.tgz https://example.com/pkg.tgz && cat ./install.sh | sh # denied
git merge-base origin/master HEAD # asked — read-only, and `merge` matched `merge-base`
ls -la ./certs/server.pem # asked — listing a key is not reading oneAll of these now run. Rules are matched against a single command segment, so
one step in a chain can no longer borrow another's tokens.
If you run bkit unattended, this is the release that matters. A guardrail
block asks a question, and an unattended run has nobody to answer it — so the
agent stalled silently rather than failing. That is how this was found: the
reporter lost ~15 minutes twice in one sprint, caught only because an idle-stall
monitor was attached.
A scoped find … -delete now asks instead of refusing. It used to be denied
outright while a scoped rm -rf was merely confirmed — the narrower operation
treated more harshly, with advice to "scope the command" you had already scoped.
The refusal message no longer sends you somewhere that does not exist. It
used to say "adjust guardrail settings in bkit.config.json or use manual
override." Neither route was real. It now lists only what works: narrow the
target, split the chain, or state your intent and confirm.
Your bkit.config.json edits either take effect or say why they don't. Five
settings the code read had no matching key in the file, so your value was
silently ignored. Twenty-seven keys in the file were read by nobody. Twelve are
now wired; the rest state in the file itself whether they are a recommendation,
a reserved name, or a duplicate.
The part we did not expect
The same defect ran in the dangerous direction too. Appending a harmless
command pushed an end-anchor out of reach or satisfied a negative lookahead:
chmod 777 / ; ls # detected by NOTHING
DELETE FROM audit_log; SELECT 1 FROM t WHERE x=1 # unscoped DELETE hidden by a later WHERE
DELETE FROM audit_log -- WHERE # hidden by a commented-out keyword
bash <<'EOF' # detected by NOTHING — the body executes
rm -rf /
EOFThe last one is the clearest illustration of why one module is not enough. The
detector strips heredoc bodies before matching, by design. The heredoc guard
graded a plain bash <<TAG as a warning, which is audited and permitted. Each
module was behaving exactly as written. The payload went between them.
chmod 777 / is the command G-008's own comment cites as its reason to exist.
Appending ; ls defeated it completely. All four are closed.
This is why the release is a correctness fix, not a comfort fix — and why it
shipped with the false-positive work rather than after it.
What the report exposed beyond the three rules
Fixing the reported rules left the detector correct and the product still wrong.
Feeding 28 ordinary developer commands and 8 ordinary file writes to the real
hook processes — the surface you actually meet — turned up seven more defects
that no module-level test could express:
git push origin feature-x && rm -f note.txtwas still refused after the
detector was fixed, by a different guard that scanned the whole line for force
flags.- The refusal advice was identical for every rule and led with "Scope the command
to a specific path" — meaningless aftercurl … | sh,DROP TABLE usersor
dd of=/dev/disk0, none of which have a path to scope. git push origin mainwas refused rather than confirmed: the guard
computed an "ask" and the hook emitted it through the deny call..env.examplewas refused as a secret — the file whose purpose is to be
committed so the next person knows which variables to set.- A force push to your own topic branch was denied as harshly as one to
main.
And one honest correction: an earlier commit in this release claimed to have
fixed the refusal message. It had rewritten a function with no production
callers. The text you actually see is assembled elsewhere, and nothing had
asserted that the "fixed" function was ever reached.
Measured
| before | after | |
|---|---|---|
| False positives (16-rule audit) | 12 | 1 — the intended grading change above |
| Reporter's 12-case harness | 4 FP / 0 missed | 0 FP / 0 missed |
| False negatives | 4 | 0 |
| Config keys read by nobody | 27 of 115 | 13, each documented |
| Config paths that never resolved | 5 | 1 — verified benign |
| Local suite | 3794/3798, 0 FAIL | 4360/4364, 0 FAIL |
Widest suite (qa-aggregate, 375 files) |
6964 PASS / 8 FAIL / 3 errored | 6977 PASS / 0 FAIL / 0 errored |
| Hook handlers probed as processes | — | 27 of 28 clean |
| Live QA, all four layers | — | 138/140 |
New tests were verified against the pre-fix tree first: 19 of 29 regression
assertions and 4 of 12 harness cases failed there. A test written after a fix
passes on arrival and proves nothing.
Decisions worth knowing about
Guardrail rules cannot be switched off at runtime, and that is deliberate
(ADR 0016). disableRule()
never worked, and a test asserts the inertness on purpose: the detector runs
inside the same agent loop whose commands it inspects, so an in-session disable
is a request the agent could issue itself. The pressure for an off-switch came
from false positives — answered here with precision instead.
One live-QA assertion was pinned to wording nobody controls. It expected
bkit's refusal text in the model's own prose. An ask goes to the permission
layer, not the transcript, so the case failed while the hook worked perfectly and
the target survived. It now reads the hook's decision directly.
For contributors
node test/run-all.js and CI disagreed about what "all tests" means: thirteen
contract tests, both host-integration suites among them, ran in CI and nowhere
else. A green local run looked complete and was not — this release found that by
pushing and watching CI fail. All thirteen are now in the local runner too, which
is why the suite total moves 3870 → 4360.
The audit method is locked as test/regression/enh-459-463-hook-path-guards.test.js
(34 cases, 13 of which fail against the pre-fix tree). It spawns the hooks as
processes and reads their JSON, because that is the only layer where several of
this release's defects were visible.
Upgrading
No migration. No configuration change required. If you had worked around a false
positive by splitting a command, you no longer need to.
Credits
@Sinclair-Seo
— reproduction script with negative controls, precise file:line root-cause
analysis for all three reported rules, and the observation that made the severity
clear.
Those negative controls earned their place: while fixing this, an over-eager SQL
comment stripper read the shell flag --command as a comment and silently
removed a real DROP TABLE from the matched text. The controls caught it before
it left the working tree. A "zero false positives" reading means nothing unless
destructive commands are still caught in the same run — a point the reporter made
after first measuring a bogus green themselves.
Their harness now ships as
test/e2e/external-dogfood/sinclair-seo-148-guardrail-precision.test.js.
Full changelog: CHANGELOG.md ·
Recommended Claude Code: v2.1.220 · Install minimum: v2.1.143