Skip to content

fix(security): guard the write-response internal-field property, not one class (#8497) - #8587

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8497-write-response-tripwire-scope
Aug 14, 2026
Merged

fix(security): guard the write-response internal-field property, not one class (#8497)#8587
os-zhuang merged 2 commits into
mainfrom
claude/issue-8497-write-response-tripwire-scope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#8497

Widens the #7823 write-response internal: true guard from "every *Data face on the protocol class" to the property it exists to hold:

no response body an external caller receives from a write carries an internal: true value

Not a redesign. The shipped protocol.write-response-internal-fields.tripwire.test.ts is untouched — its runtime prototype walk and its leakyData negative control stay exactly as they are, and still pass (1245 metadata-protocol tests green). This adds scope, it does not replace mechanism.


⚠️ Widening the guard found a live leak

The card said the rest-server batch mouth is covered by the fix and merely invisible to the guard — correct, and it still is. But the card's own sentence was broader:

…or in any other package that calls the engine directly rather than through metadata-protocol, the tripwire stays green while the new mouth leaks.

That sentence was already true of a second transport. @objectstack/mcp's stdio bridge is engine-only by construction (the long-lived stdio host cannot reuse the runtime's request-shaped callData builder), and its create arm handed engine.insert's result straight back to the MCP caller. Since #7823 the engine deliberately keeps write results whole, so the flagged column rode the tool response verbatim. Measured before the fix, on the bridge the stdio transport actually serves:

{"object":"vault","id":"r1","record":{"name":"row","id":"r1","vault_secret":"INTERNAL-SENTINEL-8497"}}

The file's own header listed its protocol-layer divergences as "deliberate, filed, not security". One limb of that list was security; the header now says so.

Fixing it is inside this card's scope rather than a follow-up: a guard shaped to exclude the one place the property is false would repeat exactly the mistake the card is about.

The three directions, evaluated by measurement

The body offered three. All three were measured before choosing.

1. Source-level check on call sites that write through the engine and return a body. ⛔ Rejected as the primary shape. Measured: 176 direct engine write call sites across 25 packages outside metadata-protocol. Almost all are internal side-effect writes (plugins, services) that answer no external caller — and "returns a body to an external caller" is a semantic property no grep can decide. So this becomes a ~176-entry ledger in which ~175 entries say "not a response", each one a human judgement the check cannot verify, churning on every new plugin write. It guards a proxy (call-site shape), not the property.

2. Assert at the response envelope, independent of which path produced it.Picked — the triage default, and the measurement supports it. External write bodies are produced by transports, and both transports enumerate their own surface at runtime, so the property can be asserted where it is actually true using the same mechanism the shipped tripwire already uses: RestServer.getRoutes() (88 routes, 45 of them write methods) and the McpDataBridge object the stdio factory returns. A new route or a new bridge verb with no disposition fails the suite with instructions — the enumeration grows by itself, exactly as the prototype walk does.

3. Write it down: direct-engine mouths outside metadata-protocol are the author's responsibility. Adopted only as a complement, never as the mechanism — it is the convention-not-mechanism option this repo rejects. It survives as the module header on the relocated helper, which now names all three tripwires and what to do when adding a mouth.

What changed

  • @objectstack/mcp — the stdio bridge's create strips (the leak). update strips too: that arm discards the engine's write result and echoes the read-path row plus the caller's own patch, so no stored value could reach it — but a caller who puts an internal: true key in data would otherwise have it echoed back, using their own bytes as an oracle on a column the flag says is never returned. Read verbs untouched.
  • @objectstack/core — the strip helper moved here from @objectstack/metadata-protocol. It shipped beside the protocol class when that class was its only caller, but rest and mcp both reach the engine directly and neither depends on metadata-protocol, so the old home forced every new mouth to choose between a duck-typed reach through a protocol instance (what rest does, via an optional ?. call that silently no-ops if the method ever disappears) and a private restatement of a security-relevant rule. core is the floor all three already depend on and already hosts this exact class of shared write-path helper (bulk-write.ts, shared by metadata-protocol and rest). No API change: metadata-protocol re-exports both names, verified against the rebuilt dist, not the source.
  • Two new tripwires (rest, mcp), same mechanism as the shipped one: runtime enumeration, total disposition map, negative control.

rest-server.ts itself is unchanged — its mouth already applies the strip correctly. The latent ?. silent-noop seam is now covered by measurement rather than by reading.

Reverse verification

Three experiments, each with the expected direction predicted before running (all three: RED on the driven route — not more-diagnostics, not inverted). The fix was committed first, so every restore came out of a real commit.

ExperimentResult
Delete the strip from the REST POST /batch update arm🔴 RED on POST /api/v1/batch only
Add a second unstripped direct engine mouth (batch create arm to a direct ql.insert)🔴 RED again — the leak moved to the new mouth
Delete the new MCP create strip🔴 RED on create

The acceptance criterion inherited from the family is the middle row, and it discriminates precisely. With the strip deleted from the update arm, the create arm (protocol ingress) stayed clean and only the direct arm leaked:

{"results":[{"name":"CONTROL...","id":"r_2"},
{"id":"row-1","name":"CONTROL...","vault_secret":"INTERNAL-SENTINEL-8497-NEVER-SERIALIZED"}]}

With the second mouth added instead, the leak moved to the first element while the still-stripped update arm stayed clean — the guard names which mouth leaked, not merely that something did.

All three restores proven byte-identical with git hash-object:

  • packages/rest/src/rest-server.ts6a58de8f15a9565f5628553666a4cff8796db305 (baseline, twice)
  • packages/mcp/src/stdio-data-bridge.ts53e08e6aa6136169723b6b7ba7317b77a278a52a (baseline)

Two anti-blindness assertions guard the guard: every driven case demands a control value in the body (so a refusal or a 501 cannot satisfy "no sentinel" by returning nothing), and one test asserts the fixture's stored row really does carry the flagged value — without it, every green could mean the secret was never stored.

Tests and gates

Tests — 4108 green: core 786, mcp 182, metadata-protocol 1245 (incl. the untouched #7823 tripwire), rest 1895.

Gates run locally — derived with scripts/pm/dispatch-gates.mjs against the actual changed paths, plus those judged implicated:

check:nul-bytes · check:changeset-gate-self-tests · check:cross-package-test-inputs · check:durability-log-level · check:kernel-hook-pairs · check:test-source-alias · check:engine-double-contract · check:query-options-erasure · check:type-check-coverage · check:type-check-debt · check:objectui-changeset · eslint · typecheck (mcp, rest) — all PASS.

⚠️check:type-check-debt caught a real regression and is worth naming.packages/rest hides its test files from its own typecheck script, so the package typecheck passed green while the ratchet measured +2 raw tsc errors against the frozen TEST_DEBT entry — both in the new tripwire (a relative import missing its .js extension under NodeNext, and registry.registerObject called without its required packageId). Fixed rather than ledgered: TEST_DEBT is shrink-only and raising it is maintainer-only. Re-measured at 155, exactly the recorded value.

⚠️check:objectui-pin-fresh is pre-existing RED on this repo (.objectui-sha behind objectui main, #3340) and fires on any .changeset/* touch. Not caused by this branch and deliberately not "fixed" here.

Scope

  • Nothing holds the driver-double WHERE matchers correct — sixteen files were fixed across three lanes and reinstating the $or early-return would fail nothing #8494 is not addressed here — same "instances fixed, nothing holds them fixed" class, different substrate, dispatched separately. No files shared.
  • content/docs/releases/ untouched; the changeset is this PR's input to the release notes.
  • The 33 non-data-plane write routes carry declared dispositions (no-record-echo / protocol-ingress) rather than drives. That boundary is stated plainly in the file: only driven is a measurement, the rest are reviewed claims, and the enumeration's job is to force an explicit decision when a route is added. Riskier claims were checked rather than asserted — the import runner's per-row results spread only warnings, never the written row, and the sharing routes echo a grant row from the sharing service, not a row of the :object in the path.

Generated by Claude Code

…one class (#8497)
Widen the #7823 write-response `internal: true` guard from "every `*Data` face
on the protocol class" to the property it exists to hold: no response body an
external caller receives from a write carries an `internal: true` value.
Widening it found a live leak. `@objectstack/mcp`'s stdio bridge is engine-only
by construction and its `create` arm echoed `engine.insert`'s result — whole
since #7823 relocated the strip off the engine — straight back to the MCP
caller. Measured, fixed, and pinned.
- mcp: strip on `create` (the leak) and on `update` (so a caller cannot use
their own patch as an oracle on an internal column).
- core: the shared strip helper moves here from metadata-protocol, which
re-exports it unchanged. `rest` and `mcp` both write through the engine
directly and neither depends on metadata-protocol, so the old home forced
every new mouth to duck-type or restate the rule.
- rest + mcp: two new tripwires, same mechanism as the shipped one (runtime
enumeration, total disposition map, negative control). The shipped protocol
tripwire is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
`packages/rest` hides its test files from its own `typecheck` script, so the
package typecheck passed while `check:type-check-debt` measured +2 raw tsc
errors against the frozen TEST_DEBT entry — both of them in the new tripwire:
a relative import missing its `.js` extension under NodeNext, and
`registry.registerObject` called without its required `packageId`.
Fixed rather than ledgered: TEST_DEBT is a shrink-only ratchet and raising it
is maintainer-only. Re-measured at 155, exactly the recorded value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 12:09am

Request Review

@github-actionsgithub-actionsBot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/mcp, @objectstack/metadata-protocol.

28 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core, @objectstack/mcp)
  • content/docs/ai/agents.mdx(via @objectstack/mcp)
  • content/docs/ai/connect-mcp.mdx(via @objectstack/mcp)
  • content/docs/ai/index.mdx(via @objectstack/mcp)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core, @objectstack/mcp)
  • content/docs/api/index.mdx(via @objectstack/mcp)
  • content/docs/automation/webhooks.mdx(via @objectstack/core)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/concepts/north-star.mdx(via packages/core)
  • content/docs/deployment/environment-variables.mdx(via @objectstack/mcp)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/metadata-protocol)
  • content/docs/kernel/services.mdx(via @objectstack/core)
  • content/docs/permissions/authentication.mdx(via @objectstack/core)
  • content/docs/permissions/authorization.mdx(via packages/core, @objectstack/mcp)
  • content/docs/permissions/system-context.mdx(via packages/mcp)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core)
  • content/docs/plugins/development.mdx(via @objectstack/core)
  • content/docs/plugins/index.mdx(via @objectstack/core)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/mcp)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core)
  • content/docs/protocol/knowledge.mdx(via @objectstack/mcp)

5 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/mcp)
  • content/docs/releases/v12.mdx(via @objectstack/core)
  • content/docs/releases/v15.mdx(via @objectstack/core)
  • content/docs/releases/v17.mdx(via @objectstack/core)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review (domain:engine-core seat, #6019): ACCEPT. Ready + auto-merge (squash). All 26 checks green or skipped, mergeable_state: clean.

⚠️Maintainer visibility — this card changed class.#8497 was filed and graded as a guard-scope widening whose body said, twice and in bold, that ⛔ nothing leaks today. That premise was false for a second transport: @objectstack/mcp's stdio bridge create arm echoed engine.insert's result, and since #7823 the engine deliberately keeps write results whole — so an internal: true column rode the MCP tool response verbatim, measured with a sentinel before the fix. A shipped transport was leaking. The fix is in this PR rather than deferred, and the reasoning is the card's own: a guard shaped to exclude the one place the property is false would reproduce exactly the mistake the card exists to correct.

⭐ Worth stating plainly, because it is the argument for this whole family of cards: the leak was found only by widening the guard. The card predicted an invisible-mouth risk and the enumeration turned up an actual one, in a file whose header asserted its divergences were "deliberate, filed, not security."

Checked against the tree, not the description:

  • The relocation is invisible to consumers.metadata-protocol/src/write-response-internal-fields.ts is now a pure re-export of both names from @objectstack/core; index.ts still re-exports them and protocol.ts still imports the strip from there. No package.json changed, and Build Core + TypeScript Type Check are green, so the mcp → core edge already existed rather than being added.
  • Both MCP arms go through the shared helper, not a restatement — which is the point of moving it to the floor all three transports share. Read verbs untouched.
  • The update arm's strip is justified rather than reflexive. That arm discards the engine's write result, so no stored flagged value can reach it; the strip covers the remaining path — a caller who puts an internal: true key in data would otherwise get it echoed, using their own bytes as an oracle against a column the flag says is never returned.
  • Triage's binding constraint honoured: the shipped Check whether sys_session.token — a live session credential — serializes over the data API (ADR-0100 channel 3 has no read protection) #7823 tripwire is untouched — prototype walk and leakyData negative control intact, 1245 metadata-protocol tests still green. This adds scope; it does not replace mechanism.
  • Direction predicted before each of the three reverse-verification runs, and the middle one is the family's inherited acceptance criterion: adding a second unstripped mouth moves the RED to that mouth, so the guard names which one leaked rather than merely that something did. Two anti-blindness assertions stop a refusal or an unstored fixture from satisfying "no sentinel" vacuously.

One boundary the PR states rather than hides, and I agree with recording it: of the 45 write routes, only the driven ones are measurements — the other 33 carry reviewed dispositions (no-record-echo / protocol-ingress). The enumeration's job there is to force an explicit decision when a route is added, not to prove those 33 by execution.


Generated by Claude Code

Merged via the queue into main with commit 3556b67Aug 14, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8497-write-response-tripwire-scope branch August 14, 2026 00:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-zhuang@claude