fix: four audit defects — MCP stdio, ambient store rebinding, git option injection, and --local in the global namespace - #266
Conversation
`runSkillPackage` wrote its banner with `console.log`, and it has two callers that disagree about what stdout is. On the CLI it is the operator's terminal. Under `knowl serve` it is the JSON-RPC frame stream: `knowl_skill_run` calls the same function inside a stdio MCP server, so the banner was interleaved into the protocol and the client failed to parse the response to a call whose skill had actually run -- an action taken, reported as a transport error. `knowl serve` already makes this choice for its own startup banner, and every host surfaces a subprocess's stderr, so nothing is lost on the CLI path. The test asserts on the STREAM rather than the string. The existing coverage in tests/skills/global-layer.test.ts checks what the banner says and stayed green throughout, because a banner's content and its destination are separate claims and only one of them was ever checked. Note for anyone extending it: spying on `process.stdout.write` alone does not catch a `console.log` regression under vitest, which intercepts the console first -- the first version of this test passed against the mutant. Both writers are watched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ensureGlobalStore` bootstrapped ~/.knowl/global.db through `initDbPath`, which assigns the module-level `globalContext` -- the handle every UNSCOPED store operation resolves through. On the CLI that is invisible because the process exits. Under `knowl serve` the process outlives the call, so a single `knowl_store` with `namespace: 'global'` rebound the ambient database to the global store and every later project write in that session went to the wrong file while reporting success. Bootstrap only ever needed a scoped handle. `withDbPath` runs it through AsyncLocalStorage without touching the ambient, which is the same distinction the module already draws for namespace hops. Safe for every caller because none of them read the ambient afterwards: both global write paths re-enter explicitly with their own `withDbPath(globalStorePath(), ...)` -- src/mcp/tools.ts and the `--namespace global` branch of src/cli/program.ts. The test asks the connection itself which file it is attached to, via PRAGMA database_list, so it pins the routing rather than a proxy for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`listChangedFilesSince` and `listRenamedPathsSince` interpolate a commit into an argv OPTION position, and the caller is not always the engine: `knowl_drift` exposes `since` as an MCP tool argument whose schema accepts any string up to 200 characters, so a prompt-injected agent chooses it. `spawnSync` runs without a shell, so this was never command injection. It was git-option injection, which is enough: `git diff --output=<path>` exits 0 and writes the diff over the named file. The separator is `--end-of-options` and deliberately NOT `--`. To `git diff` a bare `--` means "pathspecs follow", so it would demote the range to a path and make every drift check silently return nothing -- a correctness regression wearing the shape of a security fix. The third test in the new file is what tells those apart: under a `--` mutant it fails while the security assertions still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The project branch of `knowl store` pairs `excludeFromPublish` with `unstagePublish` after the write. The global branch printed the same "Marked local. It will not be published." and did neither, so the promise was made by the message alone. It reaches data rather than only wording, because the global namespace is not exempt from the staging seam: `maybeAutoStage` skips exactly one namespace, `session`, and the machine store can itself be cloud-connected. So an atom someone marked local was queued for the team while being told it never would be. Of every flag in this tool, `--local` is the one that has to be true. The exclusion runs inside `withDbPath` deliberately -- the cloud_excluded row belongs in the global store beside the atom it excludes, not in whatever project the shell happened to be standing in. Tested end to end through the built CLI, because the missing code was missing from a commander branch rather than from the helper it should have called: no unit test could have seen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ady does Follows the banner move to stderr two commits back. The assertion captured `execSync`'s return value, which is stdout alone, so it was pinning the banner's STREAM without meaning to -- the same test's own failure paths at lines 145 and 179 already concatenate stderr and stdout, because what it cares about is that the operator sees the resolved command before it runs. Switched to spawnSync so both streams are available, and added a status check that the earlier form got for free by throwing. Deliberately reads the pair rather than asserting stderr specifically: enforcing the destination is tests/skills/run-banner-stream.test.ts's job, and duplicating it here would couple a lifecycle test to a transport decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI half of this shipped two commits back. `knowl_store` has the same defect in a different shape, and it is the surface an agent actually uses. The write runs inside `withDbPath`/`withNamespaceDatabase`; the exclusion ran OUTSIDE it, against the ambient handle. So for every non-project namespace -- global, organization, session -- the atom landed in the namespace store and its `cloud_excluded` row landed in the project store. The two never meet: the namespace store's publisher reads its own exclusion table, finds nothing, and stages an atom the caller was told would never be published. Measured against the built engine, storing with `namespace: 'global', local: true` through the real tool surface: the row appears in the project store, and the global store's `cloud_excluded` is empty. This is also why it could not be left where it was. Before c15e0a0, `ensureGlobalStore` rebound the process to the global store on its way past, so the exclusion happened to land in the right file -- by accident, and only for `global`, and only after a bootstrap. Scoping that call, correctly, removes the accident and turns a latent bug into a live one for every namespace at once. Moved inside the callback rather than duplicated per branch, so `project`, `global` and `namespaceDescriptor` all get it from one place. Guarded on `action !== 'duplicate'` because a duplicate write returns the EXISTING item, and excluding that would let a `local: true` call retroactively unpublish someone else's shared atom by restating it. The test drives the real MCP server over an in-memory transport, the shape tests/mcp/act-as-repo.test.ts established, and asserts on both stores: present in the global one, absent from the project one. Against the unfixed engine it fails on exactly that pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`## Unreleased` does not exist on main -- the 5.22.0 release commit renamed it in place, and nothing recreates it -- so this recreates it above that heading rather than concluding the repo skips changelogs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed all four fixes and verified each against a mutant rather than reading the diff. Three are correct as written; the fourth was correct on the CLI and had a twin on the MCP surface that this branch made worse, so I pushed a fifth commit fixing it. Detail below. Verified, not just readEach claim in the PR body I could check, I checked by breaking the fix and confirming which tests notice:
I also confirmed the git-option injection is real on this machine rather than theoretical: The note about 🔴 The
|
Four defects from an audit of
main, each one small, independent, and covered by a test that waschecked against a mutant before being trusted. Four commits so any one can be reverted alone.
Found by a 16-dimension sweep over the engine at
ee251fb; every finding in it faced threeindependent refutation attempts, and these four are the ones whose blast radius is a wrong result
rather than a missing nicety. The rest of the sweep is not in this PR.
1.
fix(skills): the run banner belongs on stderr, not the MCP transportrunSkillPackagewrites its banner withconsole.log. It has two callers that disagree about whatstdout is: the CLI, where it is a terminal, and
knowl_skill_run, where it is the JSON-RPC framestream of a stdio MCP server. On that path the banner is interleaved into the protocol and the
client fails to parse the response to a call whose skill actually ran — an action taken, reported
as a transport error.
knowl servealready makes this exact choice for its own startup banner.The test asserts on the stream, not the string.
tests/skills/global-layer.test.tscovers whatthe banner says and stayed green throughout, because a banner's content and its destination are
separate claims and only one was ever checked. Worth noting for the review: spying on
process.stdout.writealone does not catch aconsole.logregression under vitest, which theconsole intercepts first — the first version of this test passed against the mutant. Both writers
are watched now.
2.
fix(store): creating the global store must not rebind the processensureGlobalStorebootstraps~/.knowl/global.dbthroughinitDbPath, which assigns themodule-level
globalContext— the handle every unscoped store operation resolves through. On theCLI that is invisible because the process exits. Under
knowl servethe process outlives the call,so one
knowl_storewithnamespace: 'global'rebound the ambient database and every laterproject write in that session went to the wrong file while reporting success.
Bootstrap only ever needed a scoped handle, so this moves to
withDbPath— the same distinctionthe module already draws for namespace hops. Safe for every caller: both global write paths
re-enter explicitly with their own
withDbPath(globalStorePath(), ...), so none of them read theambient afterwards.
3.
fix(store): a caller-chosen revision must reach git as an operandlistChangedFilesSinceandlistRenamedPathsSinceinterpolate a commit into an argv optionposition, and the caller is not always the engine —
knowl_driftexposessinceas an MCP toolargument accepting any string up to 200 characters.
spawnSyncruns without a shell, so this wasnever command injection; it was git-option injection, which is enough, because
git diff --output=<path>exits 0 and writes the diff over the named file.The separator is
--end-of-optionsand deliberately not--: togit diffa bare--means"pathspecs follow", which would demote the range to a path and make every drift check silently
return nothing. The third test in the file is what tells those two apart, and it fails under a
--mutant while the security assertions still pass.
4.
fix(cli): --local must exclude in the global namespace tooThe project branch of
knowl storepairsexcludeFromPublishwithunstagePublishafter thewrite. The global branch printed the same
Marked local. It will not be published.and didneither, so the promise was made by the message alone.
It reaches data because the global namespace is not exempt from the staging seam:
maybeAutoStageskips exactly one namespace,
session, and the machine store can now itself be cloud-connected. Soan atom someone marked local was queued for the team while being told it never would be. Tested end
to end through the built CLI, because the missing code was missing from a commander branch rather
than from the helper it should have called — no unit test could see it.
Verification.
npx tsc --noEmitclean,npm run lintclean, full suite green. Each fix wasreverted and re-run to confirm its test fails without it; the two-mutant check on #3 covers both
halves of the separator choice.
Not fixed here, flagged for you. The same sweep found that
capture.events' correction half isunreachable on Claude, Codex and Hermes and works only on OpenClaw, because the installed prompt
hook routes to
agent-reminder, which never runs the lifecycle, and the Hermes plugin buries theprompt under
extrawherehost-hook.tsreads a root field. That one changes behaviour peopledepend on, so it wants your call on the shape rather than a patch from me.
🤖 Generated with Claude Code