Uh oh!
There was an error while loading. Please reload this page.
fix(mcp): the stdio record resource honours the ADR-0049 apiEnabled / apiMethods exposure declaration (#8266) - #8439
Conversation
… apiMethods exposure declaration (#8266) The six object-CRUD verbs all flow through `createStdioDataBridge` and have been gated since #8083. The ADR-0101 record resource does not use that bridge — its reader is a separate closure built inline in `plugin.ts` that calls `ql.find` directly and is handed to `bridgeResources` — so gating the bridge never reached it. One object declaring `enable.apiEnabled: false` was refused by the `get_record` tool and still readable at `objectstack://objects/{objectName}/records/{recordId}`: same transport, same key, same declaration, two answers. That reader now applies the same gate, with the same action word (`get`), from the same helper, which takes its decision from the spec's single source of truth (`resolveEffectiveApiMethods` / `isApiOperationAllowed`) rather than re-deriving it. `enforceApiExposure` is exported from `stdio-data-bridge.ts` for that one consumer; `index.ts` publishes neither it nor the bridge builder. This is a SURFACE-AREA declaration leak, not an authorization bypass: the read passed CRUD/FLS/RLS under the key's `ExecutionContext` before and after. What was leaking is the author's exposure declaration. Tests drive BOTH read paths down a real `StdioServerTransport` from ONE plugin boot against ONE declaration, so the parity claim is a comparison rather than two independent literals, plus an ablation that moves only the declaration and requires both paths to serve — a parity that held because both paths refused for an unrelated reason would be the failure mode here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8266
An object that declares
enable.apiEnabled: false— or narrowsenable.apiMethodsso a single-record read falls outside the whitelist — was refused by theget_recordtool and still readable through the ADR-0101 record resource (objectstack://objects/{objectName}/records/{recordId}). Same transport, same key, same declaration, two answers.Grading: this is a surface-area declaration leak, NOT an authorization bypass
packages/runtime/src/api-exposure.tsrecords this gate as a surface-area control rather than the authorization boundary. The resource read passed the ObjectQL security middleware (CRUD / FLS / RLS) under the API key'sExecutionContextbefore this change and after it — that is unchanged and was never at issue. What was leaking is the author's exposure declaration. Please do not read this as a security fix in the release notes.Why the resource was missed when the tool was gated
The six object-CRUD verbs all flow through
createStdioDataBridge, gated since #8083 (PR #8265). The record resource does not use that bridge at all: its reader is a separate closure built inline inpackages/mcp/src/plugin.tsthat callsql.finddirectly and is handed tobridgeResources. Gating the bridge therefore never reached it — a seam that skipped the decision, not a seam that got the decision wrong.The change (route A, as scoped on the card)
stdio-data-bridge.ts—enforceApiExposurebecomes exported. It was not already exported; the card's suggested shape assumed it was, which is the one correction worth recording here.index.tspublishes neither it norcreateStdioDataBridge, so this is intra-package only and not a public API change.plugin.ts— thegetRecordclosure calls that helper before itsfind, under the action wordGATED_ACTIONS.get, matching what the HTTP path sends for a single-record read. The decision is not re-implemented: it comes from the spec's single source of truth (resolveEffectiveApiMethods/isApiOperationAllowed), exactly as The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049apiEnabled/apiMethodsexposure gate that HTTP applies #8083 did.Route B (rebuilding the resource reader from the bridge) was explicitly out of scope and was not needed — route A lands cleanly, so there is no fork to report.
Tests: the parity claim, and its ablation
plugin.record-resource-exposure.test.tsboots the real plugin once per case against one fake metadata service holding one declaration, and drives both reads down a realStdioServerTransport:resources/readon the record URI andtools/call get_record. The verdicts are asserted against each other rather than against two hand-written literals — asserting the resource alone would stay green on a build where the tool had silently stopped gating too.The failure mode the card names — a parity that passes because both paths refuse for some unrelated reason — is closed by an ablation that changes only the declaration and requires both paths to serve the row, reach the engine, and still carry the key's identity.
Measured before the fix, with the gate absent and the identical fixture:
The tool half refused in that same run, which is the divergence itself, reproduced. After the fix both paths refuse with the identical sentence and the engine is never asked for the row.
The ADR-0112 envelope (
code+status) is asserted at the seam that carries it, because both surfaces forward only.messageto the wire (the resource'scatchinbridgeResources, and the tool'serrorResult(messageOf(err))). That is pre-existing on both and is not this card's to change.Verification
pnpm --filter @objectstack/mcp test— 170 passed (15 files), whole package suitepnpm --filter @objectstack/mcp typecheck— cleanscripts/pm/dispatch-gates.mjs:check:nul-bytes,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:type-check-coverage,check-changeset-no-major— all pass.check-objectui-pin-freshfails, and does so identically with this PR's changeset removed — a stale.objectui-shain the repo, independent of this diff.Note for #7279, which is not in flight and is untouched here
While gating the reader I measured that the two read paths hand the engine their
ExecutionContextin different argument positions: the bridge sendsfind(obj, query, { context }), while this inline reader sendsfind(obj, { where, limit, context }). Nothing here depends on that and I changed neither, but it is the kind of hand-assembled divergence that card is about, so it is recorded rather than left for it to rediscover.Generated by Claude Code