Skip to content

fix: four audit defects — MCP stdio, ambient store rebinding, git option injection, and --local in the global namespace - #266

Merged
dat999zx merged 7 commits into
dat999zx:mainfrom
williamttruong:fix/audit-2026-09-07
Sep 7, 2026
Merged

fix: four audit defects — MCP stdio, ambient store rebinding, git option injection, and --local in the global namespace#266
dat999zx merged 7 commits into
dat999zx:mainfrom
williamttruong:fix/audit-2026-09-07

Conversation

@williamttruong

Copy link
Copy Markdown
Contributor

Four defects from an audit of main, each one small, independent, and covered by a test that was
checked 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 three
independent 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 transport

runSkillPackage writes its banner with console.log. It has two callers that disagree about what
stdout is: the CLI, where it is a terminal, and knowl_skill_run, where it is the JSON-RPC frame
stream 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 serve already makes this exact choice for its own startup banner.

The test asserts on the stream, not the string. tests/skills/global-layer.test.ts covers what
the 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.write alone does not catch a console.log regression under vitest, which the
console 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 process

ensureGlobalStore bootstraps ~/.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 one knowl_store with namespace: 'global' rebound the ambient database and every later
project 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 distinction
the 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 the
ambient afterwards.

3. fix(store): a caller-chosen revision must reach git as an operand

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 accepting any string up to 200 characters. spawnSync runs without a shell, so this was
never 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-options and deliberately not --: to git diff a 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 too

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 because the global namespace is not exempt from the staging seam: maybeAutoStage
skips exactly one namespace, session, and the machine store can now itself be cloud-connected. So
an 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 --noEmit clean, npm run lint clean, full suite green. Each fix was
reverted 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 is
unreachable 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 the
prompt under extra where host-hook.ts reads a root field. That one changes behaviour people
depend on, so it wants your call on the shape rather than a patch from me.


🤖 Generated with Claude Code

williamttruong and others added 7 commits September 7, 2026 02:19
`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>
@dat999zx

dat999zx commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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 read

Each claim in the PR body I could check, I checked by breaking the fix and confirming which tests notice:

mutant result
END_OF_OPTIONS'--' 2 of 3 fail: "refuses the hostile value" and "still reports a real range". The --output write test still passes. Exactly the split the PR body predicts — the third test is what separates the security fix from the correctness regression.
drop END_OF_OPTIONS from listChangedFilesSince a different 2 of 3 fail: the --output write test and the refusal test. The range test passes.
writeRunBannerconsole.log both banner-stream tests fail.
withDbPathinitDbPath in ensureGlobalStore 2 of 3 ambient tests fail; the "still creates the store" test passes, which is right — bootstrap is not what broke.

I also confirmed the git-option injection is real on this machine rather than theoretical: git diff --name-only "--output=<path>" HEAD~1..HEAD exits 0 and writes the file. --end-of-options needs git ≥ 2.24 (Nov 2019); worth noting in case anything still targets older, but every CI runner here is well past it.

The note about process.stdout.write spies not catching console.log under vitest is accurate and worth the paragraph it got — I'd have written the weaker test.


🔴 The --local fix is half of the defect

src/mcp/tools.ts:690 — same bug, other surface, and this branch turns it from latent to live.

knowl_store writes the atom inside withDbPath/withNamespaceDatabase and runs the exclusion outside it, against the ambient handle. Both excludeFromPublish and unstagePublish go through getClient(), so for every non-project namespace the atom lands in one database and its cloud_excluded row lands in another. The namespace store's publisher reads its own exclusion table, finds nothing, and stages an atom the caller was told would never be published — the same failure commit 4 describes for the CLI, reached through the tool an agent actually calls.

Measured through the real MCP server with namespace: 'global', local: true:

base (main):  EXCLUDED IN GLOBAL ["d9a5dc19..."]   EXCLUDED IN PROJECT []
this branch:  EXCLUDED IN GLOBAL []                EXCLUDED IN PROJECT ["7e4e4de0..."]

Note which column is which. On main it lands correctly — by accident. ensureGlobalStore rebound the process to the global store on its way past, so the later ambient write happened to hit the right file. Commit 2 removes that rebinding, correctly, and in doing so removes the accident. So commit 2 is right and commit 4 is incomplete, and together they ship a regression for the one namespace that used to work.

session and organization were broken on main too and stay broken either way — commit 4 only fixes global, and only on the CLI.

Fixed in 59fa0d3: the exclusion moves inside the store callback, so all three routing branches get it from one place rather than three. Two things fell out of doing it there:

  • Guarded on action !== 'duplicate'. A duplicate write returns the existing item, so excluding it would let a local: true call retroactively unpublish someone else's shared atom by restating its content. That hole is on main today.
  • The test drives the real server over an in-memory transport (the tests/mcp/act-as-repo.test.ts shape) and asserts on both stores — present in global, absent from project. Asserting only the first passes against the unfixed engine on main, for the accidental-rebinding reason above.

🔵 Small things, take or leave

  • src/store/drift.ts:99END_OF_OPTIONS is scoped to this module, but spawnSync('git', ...) with caller-influenced argv also appears in src/cloud/publish-gate.ts:19 and src/store/session-evidence.ts:19. Both are engine-controlled today (session-evidence has no importer at all), so nothing is exploitable — but if this ever becomes a repo-wide rule, runGit is the place, not the two call sites.
  • Commit 5's message says "lines 145 and 179"; line numbers in a commit body go stale on the next edit to that file. The sentence works without them.

Verification

Full suite 421 files / 3955 tests green on Windows, plus eslint, tsc --noEmit, docs:check and check-version-sync.

CHANGELOG.md had no ## Unreleased — the 5.22.0 release commit renames it in place and nothing recreates it, so its absence is not "this repo skips changelogs". Recreated it with all five fixes (36c6f58).

Four independently revertable commits was the right call, and the commit bodies are the standard this repo should hold to. Good PR; it just needed its own fourth finding applied to the surface that matters most.

@dat999zx
dat999zx merged commit 78be241 into dat999zx:main Sep 7, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants