kit 6.4.0
Added
-
[policy.agent_writes]now reaches the plugin write surfaces — the last
ops with no choke point in kit.resolve_issue,create_releaseand
trigger_deploywere the three ROADMAP named; the arc registered and gated
every mutating plugin surface instead, addingenv_unset(separate from
env_setbecause setting a variable is recoverable by setting it again while
deleting one destroys the only copy of a value and takes down whatever reads
it),api_token_revoke,webhook_create,webhook_delete, and
scoped_key_revoke— a third Supabase op that was in neither the
elevation-scope split nor the registry, becausesecrets-rotate-cli.tsonly
ever asks about--mode.The plugins are standalone zero-dependency packages that must not import
kit-core, so the enforcement point cannot be a kit function call. What
crosses the boundary is the resolved DECISION, not the config: kit runs every
registry op through the onepolicyDecisionand exports the refusals as
KIT_POLICY_DENY(installPolicyEnv, called frommain()beside
installPolicyHash, and extracted from the boot block so that dropping
either install fails a test). The plugin-side guard is a membership test with
no rule in it — nothing to collapse, no empty list to misread, no
absent-vendor case to get backwards, because all four states resolved before
the value was written. Serialising the config instead would have put seven
independent copies of the four-state rule in seven packages.Two limits, stated rather than implied: the channel is exactly as strong as
theKIT_READ_ONLYcontract and no stronger — a process that never ran kit
sees no denials, and absence must mean "no denial" or every repo not using
the block goes offline the moment a plugin runs outside a kit invocation — and
a plugin-side refusal is not audited, because a plugin has no path to the
governed project's log.enforcePolicy()still audits the ops kit gates
itself.
Fixed
-
kit-plugin-supabasehonored no containment gate at all — including on the
JWT-secret roll. Its three write surfaces (rollJwtSecret,
revokeScopedKey,mintScopedKey) had noassertNotReadOnly(), while all
six sibling plugins did, andcli.ts+THREAT_MODEL.mdboth stated that
read-only was honored by "every kit-plugin write surface". Measured with
KIT_READ_ONLY=1set in the process and the client pointed at a local
listener: all three sent their request, and the roll — which invalidates
every anon, service_role, signed-URL and session token at once — returned a
rolled secret. The guard is inline per package because plugins must not
import kit-core, sosrc/plugin-write-gates.test.tsnow pins all seven
copies byte-identical and derives the write surfaces from the plugin sources:
any function issuing a mutating request, or building a GraphQLmutation
routed through a shared transport, must carry both guards, in the documented
order, naming an op the registry knows. Exemptions are listed with reasons
and asserted to still match something. Mutation-proved seven ways (drop
either guard, swap their order, gate the token revoke onenv_set, add a new
ungated mutation through the transport, loosen one plugin's read-only test to
"1"only, remove the registry row the revoke depends on — 1 fail each).The seven changed plugins are bumped to 0.1.1, and that bump is part of the
fix rather than bookkeeping:publish_wsinpublish.ymlskips any package
whose version is already on npm, so a containment fix left at 0.1.0 would ship
to the monorepo and leave every installed copy vulnerable — the same way the
scanner plugins existed but were unreachable to anyone who had not cloned the
repo until 6.3.1 put them on npm. -
unknownPolicyEntries()reported to nobody.config.ts,
docs/OWASP_2025.md, ROADMAP and CHANGELOG all described it as surfacing an
[policy.agent_writes]entry that names an op kit never asks about; it had
no production caller. Measured: a repo withvercel = ["env-set"]produced a
fullkit checkmentioning neither the typo nor its consequence, exit 0 —
and the typo INVERTS the operator's intent, because declaring the vendor
refuses every op not listed, so a misspelling turns a pre-approval into a
blanket denial for that vendor. This is trap 5 of the enforcement arc ("tests
over the decision function are not evidence of a working control") recurring
one module later. Now thepolicy agent-writesrow
(src/check-policy-ops.ts), wired intocheckSecurity()rather than
runCheckGatesokit ciandkit healsee it too; it names the real ops
for the vendor, states the consequence, and on the clean path reports how
many of the registry's ops the policy refuses. Mutation-proved: removing the
wiring fails only the wiring test while all six function-level tests keep
passing, which is precisely why they were not enough. -
kit knobsadvertised an op the registry rejected. The knob description
readsentry = ["resolve_issue"]whileresolve_issuewas not in
POLICY_OPS, so kit's own help text described config kit's own checker
flags. The drift test written for exactly this class hard-codedconfig.ts;
it now scans every source file that mentions the block. The historical
examples inconfig.ts/policy.tsare cited by op name instead of in the
livevendor = [...]syntax, because a past example written in the current
syntax is indistinguishable from a live one to any scanner. -
The plugin test suites never ran.
scripts/test.mjscollected only the
rootdist/, so 11 compiled test files and 76 tests under
packages/*/dist/— including everyKIT_READ_ONLY=1refusal test the
plugins do have — had never been executed bynpm testor CI. They pass;
nobody was watching. A containment test that does not run is worse than no
test, because it reads as coverage. -
gate-bashread here-document bodies as commands, and blocked kit's own PR
description.SEGMENT_SPLITincludes\n, so every line of a here-document
was scanned as its own command. Measured: agh pr create --body "$(cat <<'EOF' … EOF)"whose body contained the prose`npx tsc --noEmit`was refused
withBLOCKED — Triage: npm tsc, because a backtick in a sentence became a
nested command. A false block in a security gate is not a harmless annoyance —
it is what teaches people to pass--no-verify, and this repo already carries
one bypassed-hook record.splitHeredocs()now separates body from command following what the shell
actually does: a body fed to a shell (bash <<EOF,cat <<EOF | bash) or
written to a*.sh-shaped file is scanned as a script; anything else is data.
The exception that keeps this from being the cheap fix: with an unquoted
delimiter the shell performs command substitution while building the document,
so a substitution in the body really runs and is still gated, while the same
body under<<'EOF'is inert text. An unterminated here-document is scanned
rather than trusted, and the terminator is matched leniently, because absorbing
the commands that follow it is the one error direction that could hide a real
install.Verified by driving the real hook rather than the parser: the exact payload that
blocked the PR now exits 0, whilebash <<EOF,cat <<EOF | bash, the
unquoted-substitution form, script authoring and the plain install all still
exit 2.src/install-gate-heredoc.test.tspins both sides of that table —
mutation-proved seven ways, including the cheap fix of treating every body as
data (3 fail), dropping the unquoted delimiter's substitutions (2), treating a
quoted delimiter as expanding (2), and failing to find the terminator (4).Named limitation, previously covered only by accident: a body written to a file
without a shell-script extension and executed later is invisible to this gate,
as is the easier spelling of the same bypass — aWritetool call, which
gate-bashnever sees. -
The dependency-surface numbers said 120 installed; the tree is 94. The
count was measured against the SDK 1.29 tree and never re-measured after the
1.30 bump moved it (@hono/node-server1.x → 2.x). It had been copied into
four places — the ROADMAP heading and body,docs/DATA_FLOW.md, the OWASP A06
row, and the guard test's own docstring — so the number was wrong everywhere it
was cited, including in a draft aimed at an upstream maintainer. Re-measured at
1.30.0: 94 production packages, 90 of them reachable only through the MCP
SDK, 9 loaded at runtime. The loaded count did not move, which is why it is
now gated:mcp-dependency-surface.test.tsparses the ROADMAP heading and
fails if the advertised number and the trace disagree (mutation-proved by
editing the heading to 8 → 1 fail). Only the loaded count is pinned — the
install count depends on lockfile hoisting, so pinning it would fail on an
unrelated bump and teach people to edit the assertion instead of reading it. -
The ROADMAP recorded a limitation without its condition. The upstream ask
about the SDK's HTTP/OAuth dependencies was marked "cannot be filed from kit's
own tooling — any repository outside the allowlist answers 403". Measured:
gh issue list --repo modelcontextprotocol/typescript-sdkexits 0. The 403
belonged to a different constraint (a cloud session's GitHub token is scoped to
its one attached repo) and had been copied onto a local-session item where it
never applied — so an actionable item sat parked as impossible. The ask also
already existed upstream as
typescript-sdk#1924,
so kit contributed its runtime trace to that thread instead of filing a
duplicate: the issue and its comment both argue from install counts, and nobody
had shown which packages execute. -
docs/VERIFY.mddocumented agh attestation verifyinvocation that
exits 1. Both the copy-pasteable block and the CI snippet passed
--owner sandstream --repo kit; the two flags are alternatives and
--repowants the fullowner/repo, so gh 2.96 answers
invalid value provided for repo: kit. It survived because the
surrounding caveat said the attestation did not exist yet, so nobody
expected the command to succeed — a broken instruction hiding behind a
true caveat.src/docs-verify-commands.test.tsnow gates the flag
grammar inVERIFY.md,README.mdandpublish.yml(offline and
deterministic; a doc test needing Sigstore would be skipped in CI and
therefore worthless), mutation-proved by restoring the old form: 2 fail.
Changed
- The GitHub artifact attestation is measured, so the docs stop hedging.
gh attestation verify sandstream-kit-6.3.2.tgz --repo sandstream/kit
exits 0 with exactly one subject,digest.sha256 = de2f6328…85dd,
predicateType: https://slsa.dev/provenance/v1, and abuildSignerURIof
publish.yml@refs/tags/v6.3.2— verified against a tarball whose sha512
equals npm's owndist.integrity, so the GitHub attestation and the npm
provenance describe the same bytes (run30890753307). Accordingly:
VERIFY.md's caveat is reduced to the fact consumers still need (nothing to
verify on ≤6.3.1) and now carries the measurement; the CI snippet's
attestation gate is blocking again, with the|| echo "::warning::…"
removed on the grounds that a warning in a CI log is indistinguishable
from a passing check to anyone not reading the log; and the OWASP A08 row
stops citing "the step is in publish.yml" as evidence — the step was
always there and always errored, which is exactly the reasoning that let
it survive.
Full changelog: https://github.com/sandstream/kit/blob/v6.4.0/CHANGELOG.md
Verify this release:
git tag -v v6.4.0
npm audit signatures