Repository navigation
Commit 3ddd3d0
fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) (#21228)
Part of #21207
Clause-②: no (narrowing)
This PR delivers **exit one** of #21207 only. Exit two (the served
content hash) stopped at the claim's stop clause before any code was
written; the reasons are below, and #21207 remains open for it.
## What this does (exit one)
The MCP stdio transport reads stored metadata rows through the engine
only: the stdio bridge's query, get and aggregate verbs, and the record
resource. The engine returns a stored metadata body as stored, so an
administrator's key was served credential material in cleartext from the
stored-row table and the version-history table. Both read paths now join
the stored-metadata-body family:
- **Serve the projection.** Rows of the two stored-metadata tables are
served through the family's one projection (`redactStoredMetadataRow` in
`@objectstack/spec/kernel`), the same projection the generic data door
and every metadata read use. Stored credential material is withheld, a
body that cannot be judged is omitted (fail-closed), and a
credential-free body is served byte-identical. No second projection:
this package does not depend on `@objectstack/metadata-protocol`, and
the projection lives in spec.
- **Refuse the evaluate shapes.** A group, filter, sort or aggregate
member on the stored body column is refused with `INVALID_FIELD` / 400
before the engine runs, the data door's code and envelope (the analytics
door precedent: a door-local refusal built on the spec primitives).
- **The family pin.** `stored-metadata-body-family.pin.test.ts` gains
two rows for this door (seam and refusal), owned by `@objectstack/mcp`.
The new per-package pin classifies every stdio bridge member and
enumerates every engine read call site in `packages/mcp/src` on the
syntax tree, so a future reader on this transport fails its pin instead
of joining silently.
Files: `packages/mcp/src/stdio-data-bridge.ts`,
`packages/mcp/src/plugin.ts` (record resource), the new
`packages/mcp/src/stdio-data-bridge.stored-metadata-body.test.ts`, the
family pin, and `.changeset/21207-mcp-stdio-stored-metadata-body.md`
(`minor`, breaking narrowing under the launch-window convention,
ADR-0087 `not-required (no-migration-prescription)`).
## Before / after (live boot, administrator vs member, classes and
statuses only)
Measured on a real boot (showcase, security, automation, SQLite), with
real API keys resolved the way the stdio door resolves them. Before =
`origin/main` at `097ef80270`; after = this branch with the rebuilt
`@objectstack/mcp`.
| Door on the MCP stdio transport | Body class | Administrator before |
Administrator after | Member before | Member after |
|---|---|---|---|---|---|
| Record resource (resources/read), stored-row table | flow body with
credential | served, credential in cleartext | served as projection, no
credential | refused (engine `PERMISSION_DENIED`), no row | same |
| Record resource and bridge get / query, both tables | datasource body
with credential (historical stock) | served, credential in cleartext |
served as projection, no credential | refused (`PERMISSION_DENIED`) |
same |
| Bridge get / query, both tables | flow body with credential | served,
credential in cleartext | served as projection, no credential | refused
(`PERMISSION_DENIED`) | same |
| All of the above | credential-free body (control) | served | served
byte-identical | refused | same |
| Bridge query: filter on the body column | flow, datasource | answered
a guess about the credential (match vs no match) | refused
`INVALID_FIELD` / 400, engine not asked | refused `PERMISSION_DENIED` |
refused `INVALID_FIELD` / 400 |
| Bridge query: sort; bridge aggregate: group, filter | flow, datasource
| served, body in the answer | refused `INVALID_FIELD` / 400 | refused
`PERMISSION_DENIED` | refused `INVALID_FIELD` / 400 |
| Bridge aggregate: member over the body | flow, datasource | refused by
the engine (`INVALID_FIELD` / 400) | refused here first (`INVALID_FIELD`
/ 400) | refused | refused |
| Tools `query_records` / `get_record` | any | refused by the tool
layer's system-object guard | unchanged | same | same |
Datasource bodies: the save door refuses a credential in a datasource
body (measured `422`), so cleartext at rest is historical stock written
before that refusal; it was measured on such rows. It is affected
exactly as the flow body was, by construction and now by measurement.
## Exit two: stopped at the claim's stop clause (not built)
Measured live, unchanged by this PR: the stored content hash served on
this door and on the generic data door is an unkeyed sha256 over the
whole stored body, credential included, and it equals the save receipt's
version.
The claim asked to list every consumer that compares these hashes and
where the server-held key comes from, and to stop if any consumer
compares across deployments or if the key needs a new secret. The key
source answers that stop clause:
- **No existing keyed primitive reaches the repository.** The crypto
provider contract offers encrypt, decrypt, rotate and an audit digest;
the contract allows that digest to be plain SHA-256, and the local
provider's is unkeyed. The provider is attached to the engine after the
runtime starts and is not exposed to the metadata repository, while
metadata saves already run during boot.
- So a keyed hash needs either a **new secret**, or a **new keyed method
on the crypto provider contract** (a `packages/spec` contract widening
that every provider must implement) together with a boot-ordering
change.
- Beyond the key, the change reaches outside the declared surface: the
repository contract's canonical-hashing invariant and the shared
contract suite (`packages/metadata-core`), the spec's description of the
history checksum as SHA-256, the stored-row checksum column's length
bound, and a second producer of the same columns in `packages/metadata`.
- Cross-deployment comparison: none was found in this repository; the
cloud repository was not measured.
The full consumer list, the coexistence analysis for existing rows and
parent links, and the options are in the os-dev report on #21207.
## Tests and verification (head `ae754f9e3a`)
- `@objectstack/mcp`: `pnpm test` 33 files / 366 tests passed; `pnpm
typecheck` exit 0 (the new test is in the test-layer program, no debt
added).
- `@objectstack/metadata-protocol`: `vitest run` 200 files passed, 3
skipped / 2973 tests passed, 19 skipped; `pnpm typecheck` exit 0.
- Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` derived
61 commands at `ae754f9e3a`; all 61 were run, each exit 0, and `--ran`
reconciled 61 derived / 61 run / 0 not measured.
- `pnpm lint` (repo-wide, `eslint . --no-inline-config`): exit 0 at
`ae754f9e3a`.
- Ablations (fix committed first, mutation through
`scripts/ablation-replace.mjs`, restore proven against `HEAD`):
- A1, projection removed: 5 administrator no-credential pins red (query,
get, unjudgeable body, body-only projection, record resource); 17 green,
controls included.
- A2, evaluate refusal removed: all 9 refusal pins red; 13 green, the
scalar-column control included.
- A3, an extra engine read site added behind a cast: the call-site
enumeration red; 21 green. History, stated rather than hidden: the first
A3 attempt was a no-op (the replacement kept the anchor, so the tool
refused it and nothing ran); re-run with a different anchor, it stayed
green against the then name-based scan, which is why that scan was
replaced by the syntax-tree one. The reading above is against the final
scan.
- A4, an unclassified bridge member added: the member classification
red; 21 green. Its first attempt was likewise a refused no-op, re-run
with a different anchor.
## Acceptance notes
- The record resource is the reachable public door for exit one on
today's stdio composition. The `query_records` / `get_record` /
`aggregate_records` tools refuse the stored-metadata tables through the
tool layer's system-object guard (default on). The bridge itself is now
safe regardless of that guard.
- Pin half not met: exit one still serves the stored hash column as
stored, so "no unkeyed hash" on this door waits on the exit-two
decision. This PR does not narrow the column on one door alone, since
the ruling chose a keyed hash over a narrowed one.
- This door judges a dotted path into the body column by its head
segment. The data door's refusal matches the exact column name; its
behaviour on a dotted path was read from source, not measured.
- For a member, the evaluate shapes now answer `INVALID_FIELD` / 400
before the engine's `PERMISSION_DENIED`. This is the data door's order;
no row is served either way.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 62b90d7 commit 3ddd3d0
5 files changed
Lines changed: 805 additions & 9 deletions
File tree
- .changeset
- packages
- mcp/src
- metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
29 | 34 | | |
30 | 35 | | |
31 | 36 | | |
| |||
601 | 606 | | |
602 | 607 | | |
603 | 608 | | |
604 | | - | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
605 | 615 | | |
606 | 616 | | |
607 | 617 | | |
| |||
0 commit comments