Skip to content

v2.1.33 — make the defenses actually enforce

Choose a tag to compare

@agent-kay-it agent-kay-it released this 08 Aug 16:31
· 40 commits to main since this release
273aa79

bkit had several protections that detected correctly and then did nothing.

It said It did
Destructive command audit entry result: 'blocked' ran the command
Denied file path described the denial to the model wrote the file
Memory Enforcer block a full reason with rule and source sent the model the string "deny"
Failing test suite exited 0
Sprint completion report → archived that sprint had never been created

This release adds no features. It makes the existing ones true.

Found through /bkit:cc-version-analysis cycle #34 while checking Claude Code v2.1.224 → v2.1.225 for breaking changes. Upstream had none. The defects were bkit's own.


Highlights

1. Blocks now say why

The Memory Enforcer called outputBlock('deny', reason, 'PreToolUse') against a one-parameter function. JavaScript bound reason to the literal 'deny' and discarded the rest — the directive text, the rule, the source, the matched pattern. What actually reached the model was:

{"decision":"block","reason":"deny"}

This is bkit differentiation #1, and it had been emitting that since v2.1.14. The consequence was not cosmetic: with no stated cause, retrying is the model's rational move, and Claude Code's auto mode pauses after 3 consecutive blocks — aborting outright in headless -p runs.

2. Commands aimed at the filesystem root were invisible

The destructive detector was called with { command } where a string was expected, so its rules matched against {"command":"…"} and every anchored pattern silently failed. Measured before the fix:

detect('Bash', 'chmod 777 /')              →  G-008 critical
detect('Bash', { command: 'chmod 777 /' }) →  not detected

chmod 777 /, chown root / and mv /etc/passwd / were not detected at all in production. And even when a critical rule did match, nothing blocked — the branch wrote an audit entry and returned.

3. The heredoc bypass defense was defeated by an absolute path

… | bash was blocked. … | /bin/bash was not:

| bash          →  critical  (blocked)
| /bin/bash     →  warning   (allowed)
| nice bash     →  warning   (allowed)
| command bash  →  warning   (allowed)
| "bash"        →  warning   (allowed)
| \bash         →  warning   (allowed)
| $SHELL        →  warning   (allowed)

Warning severity is allowed through with an audit entry. Eight literal interpreter rules became three tolerant ones covering paths, quotes, backslashes, wrapper commands, and interpreters resolved at runtime.

4. Secrets were only denied at the repository root

.env*, *.key and *.pem were effectively root-anchored — the glob expands * to [^/]*, so none could match a path containing a slash. At L4, src/.env was allowed. At L0 the same file was refused, but by the automation-level allowlist rather than the deny rule, so widening that allowlist would have opened it silently.

Paths are also matched after resolution now, so docs/../.env no longer presents an allowed spelling for a denied location.

5. Your PDCA backup was being overwritten by other projects

${CLAUDE_PLUGIN_DATA}/backup carried no project segment, while CLAUDE_PLUGIN_DATA is namespaced per plugin install. Projects sharing a marketplace slot wrote to the same file on every savePdcaStatus() — continuously, and silently.

Observed on a real machine: one slot held tene-studio's state, another held bkit-claude-code's. Whichever project lost the race lost its backup permanently. Backups are now namespaced per project.

6. The quality gate could not be failed — or passed

matchRate accepted anything with typeof === 'number', which admits NaN. That does not make the gate fail; it makes it undecidable. Both >= 90 and < 90 evaluate false, so a workflow branching on either falls straight through. This drives the iterate loop's exit condition and M1_matchRate.passed — the gate bkit is built around.

7. CI could not go red

Three independent mechanisms each made a failing suite report success: the aggregator had no process.exit at all; the workflow piped it through | tail -10 without pipefail, so the step's status came from tail; and the plugin-schema release gate carried continue-on-error: true while its own comment had promised strict mode since v2.1.21.

Proven by injecting a deliberate failure and observing both behaviours side by side:

clean                        exit 0
with failure                 exit 1     ← the aggregator gates
shell: bash (pipefail)       exit 1     ← the CI step propagates
old default (no pipefail)    exit 0     ← the defect, reproduced

CI coverage went from 188 to 354 test files. The directory list was hand-maintained and the file walk was not recursive, so 143 files across 13 directories — including the entire test/security suite — had never run in CI. All of them passed; they were simply never wired in.


User experience changes

bkit no longer names your session

ui.sessionTitle.enabled now defaults to false.

Issue #77 reported that bkit overwrote the session title on essentially every turn, so a name set in the Claude app or with /rename came back as [bkit] <PHASE> <feature> moments later. It was closed in v2.1.21 by adding a per-session tag, which fixed parallel windows showing identical titles but not the overwriting. Users kept renaming sessions and watching bkit rename them back.

Two causes, both fixed:

  • bkit never read session_title. Claude Code supplies the current title to hooks and its documentation names this exact use — "A hook that emits sessionTitle can check session_title first to avoid overwriting a title the user set explicitly." bkit had zero references to it.
  • The dedup cache compared action. A skill Stop hook published with action: 'PLAN'; the next user prompt published with none; the values differed, so it republished — and again on the next skill stop. Alternating between working and typing was enough to rename your session.

If you want the labels back: set ui.sessionTitle.enabled: true in bkit.config.json. Even then, bkit will not overwrite a title you set with --name or /rename.

When bkit blocks something, you can act on it

Before, a blocked command told the model "deny" and nothing else. Now it names the rule, the source file, the matched pattern, and offers concrete alternatives — so the next attempt is a different command rather than the same one again.

Destructive commands are stopped, not just recorded

If you asked bkit to run rm -rf on a broad target, the audit log said it was blocked and the command ran anyway. It is now stopped, and the blast radius is named in the refusal.

Writes to secrets and VCS internals are refused

.env files anywhere in your tree, private keys, .git/ internals. Previously these were described to the model as a concern and then written. Automation-level scope (NOT_IN_SCOPE) stays advisory on purpose — L0's allowlist is narrow enough that blocking on it would refuse ordinary edits.

Sprint reports you can trust

Running /sprint master-plan on a new project printed Sprint "<id>" — report → archived with another sprint's summary, for a sprint whose state file did not exist. Three causes: the header id was not corrected when the fallback loaded a different sprint, master-plan was missing from the read-only action list, and advancePhase never settled status on reaching the terminal phase — leaving 6 of 7 sprints permanently marked active.

Privacy documentation corrected

PRIVACY.md claimed bkit "does not make network requests of any kind". That stopped being true when the opt-in OpenTelemetry exporter was added. The exporter is inert unless you set OTEL_EXPORTER_OTLP_ENDPOINT yourself, and it sends to a collector you choose — never to POPUP STUDIO. The page now says so, and also notes what Claude Code's feedback survey includes if you consent to it.


Verification

Test suite    354 files · 6,397 assertions · 0 fail · 0 errors
CI            354 files · 6,394 assertions · 0 fail · 0 errors
QA            30/30 — 17 static full-surface + 13 live
Gates         16/16 CI steps pass

Session baseline for comparison: 165 files, 4,308 assertions, 3 failures, 1 error — behind a gate that could not report any of it.

The QA was not sampled. Every one of the 44 skills, 34 agents, 22 hook events with their 26 handlers, both MCP servers (19 tools, real stdio handshake) and all 195 library modules were exercised. Live verification ran real claude -p --plugin-dir . sessions and asserted the effect, not the message — the guarded directory still exists, the secret file was never written.

Two fixes proved themselves by blocking the author mid-release: the destructive detector refused a test command, and the heredoc guard refused a commit message that quoted the bypass forms.

Both harnesses ship with the release (test/qa-harness-full-surface.js, test/qa-harness-live-claude-p.sh) and the full report is at docs/05-qa/v2133-defect-response.qa-report.en.md.


Known limitations

Stated rather than omitted:

  • The widened heredoc guard matches a pipe anywhere inside the heredoc body, so writing about these bypass forms in a heredoc trips it.
  • PATH_TRAVERSAL as implemented means "outside process.cwd()", which covers ordinary work in another directory, so it stays advisory rather than blocking.
  • audit-logger.js validates action names with ACTION_TYPES.includes(x) ? x : x — both branches are identical, so the check does nothing.
  • Timing budgets in test/performance/ widen under the aggregator. They are smoke checks against pathological regressions, not benchmarks.

Not done, deliberately

RECOMMENDED_VERSION stays at 2.1.220. npm stable is exactly 2.1.220, Claude Code v2.1.225 resolved none of the upstream issues bkit tracks, and raising it would import the #84892 (hook env stripped silently) and #84925 (conditional hooks misfiring) regressions.

Upgrading

claude plugin update bkit

No migration steps beyond the session-title default described above. Hook payload schemas, frontmatter, MCP protocol and the plugin manifest are unchanged.

Full changelog: https://github.com/popup-studio-ai/bkit-claude-code/blob/main/CHANGELOG.md
Pull request: #143