Repository navigation
fix(mcp)!: the stdio data bridge asks each nested-relation condition's target its exposure for list (#22777) - #22820
Conversation
… target its exposure for list The data door's relation-condition walk moves to @objectstack/core (relationConditionSites) unchanged, and both doors import it. The stdio bridge's query and aggregate ask each condition's target the same gate an addressed object gets, for list, and refuse an unserved target. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…relationConditionSites export Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 39 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 19b8bbf86132b0c20fe3054fabbd383775fc6d32 && git checkout 19b8bbf86132b0c20fe3054fabbd383775fc6d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 67b669e6890a20f443c0aa9309d6b6238913718a 2261c253ec75c2412914df2c11a998a9ec9713c6 && git checkout -B drift-repro 67b669e6890a20f443c0aa9309d6b6238913718a && git merge --no-ff 2261c253ec75c2412914df2c11a998a9ec9713c6
node scripts/docs-audit/affected-docs.mjs --json 67b669e6890a20f443c0aa9309d6b6238913718a
|
Contract reviewServed-tier: Inputs, and nothing else: card #22777 (body; the claim 6107453288; the os-dev-report 6108171450), the family precedent (PR #22768's contract reviews 6106147584 and 6107185299, and the seat's ACCEPT 6107195645 on #22737), PR #22820 (body; its 9-file list; the net diff from merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #22777
Clause-②: yes (narrowing)
Census row 8 of #22661: the decision #22737 brought to the data door, carried to the MCP stdio door. Security card: classes, positions and functions only.
What this changes
createStdioDataBridge(packages/mcp/src/stdio-data-bridge.ts):query()andaggregate()call the newenforceRelationTargetExposureright after the addressed object's gate. It finds the nested-relation conditions inwhereand asks each TARGETenforceApiExposure(metadataService, target, GATED_ACTIONS.query, context, position). That is the gate an addressed object already gets, so the operation islistand there is no second rule.404 OBJECT_API_DISABLED, or405 OBJECT_API_METHOD_NOT_ALLOWEDwithallowedOperations.enforceApiExposuregains an optionalconditionargument; when it is set, the message names the target, the position (where.FIELD) and the remedy, in the data door's words. No new code.relationConditionSitesmoves unchanged frompackages/metadata-protocol/src/protocol.ts, where it was module-private, topackages/core/src/utils/relation-condition-sites.ts, and is exported from@objectstack/core.protocol.tsimports it. The data door's behaviour does not change:refuseUnservedRelationTargetis untouched.packages/objectqlandpackages/types.packages/core/src/security/second-object-read-exposure.pin.test.ts): the moved walk is a new caller ofreferenceTargetOf, classifiednot-a-served-read(it finds sites and reads no record; both doors that call it ask each target the decision). The discriminator does not see the bridge, which resolves targets inside that walk and never calls the arbiter itself. So row 8 does not join as adecidedrow, and the discriminator's semantics are unchanged. The header's not-seen paragraph names the bridge and where it is held.Measured on a real stack, before and after
The harness was a scratch probe, not committed. It used a real
bootStackover fixture objects (the #22661 dogfood fixture). An administrator and a member each had an API key minted through the key route. The principal was resolved through the chainplugin.tsuses (resolveAuthzContext, thenassembleExecutionContext). The realcreateStdioDataBridgeandregisterObjectToolsran over an in-memory MCP client/server pair. Not exercised:StdioServerTransportand the process boundary.23419bafb7), for both personas, onquery_recordsandaggregate_records:get-only orcreate-only was evaluated: a match answered rows and a miss answered none. The same held under$and,$orand$not.list-only and undeclared targets answer as before (rows on a match, none on a miss). A condition through an injected owner lookup to a list-served platform object answers as before. A second-level condition is still refused by the engine before anything is read.isSystem: falseandprincipalKind: human.assembleExecutionContextemitsisSystem: falseas a literal, andresolvePrincipalthrows when it resolves nothing. So the bridge's ownisSystembypass, which this reuses, is unreachable from the stdio door, and every reachable caller gets the verdict security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 gives at the data door.query_recordsandaggregate_recordsflatten a thrown error to its message (errorResult(messageOf(err))), exactly as they already do for an addressed-object refusal. The caller seesisErrorwith text naming the target and the position. The code, status andallowedOperationssit on the bridge's error and are pinned there.Pins
packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts(27 cases):['create'],['get'], deny-all[]), each direct, under$orand under$not, onqueryandaggregate. That is 24 cases. Each assertscode,status,allowedOperationson 405, the target and the position in the message, and that neitherengine.findnorengine.aggregatewas called.list-only target, an undeclared target, an id comparand and an operator bag each reach the engine withwhereunchanged, on both verbs.query_recordsleg over an in-memory MCP pair. It is refused naming the target and position; a served target is answered.packages/core/src/utils/relation-condition-sites.test.ts(2 cases): sites at the top level and under each combinator, with targets (including the implicitusertarget), and a control that finds none.Ablations (each from a committed state, restore proven)
enforceRelationTargetExposure, the walk's result was replaced with an empty list, throughscripts/ablation-replace.mjs(anchor 1 → 0, blobcf59e766→822adfc1).stdio-data-bridge.exposure.test.tsstayed 27/27 green.git diff HEADempty.@objectstack/core'sdist. Mutated with--hold; core rebuilt;ablation-dist-preflight.mjsfound the marker present in 2 built files.$orand$notcells), 11 green.protocol.relation-condition-target-exposure.test.ts: 8 red (its$orfilter cases, both contexts), 5 green. This proves the data door now runs the moved walk.git diff HEADempty; core rebuilt; preflight--absentexit 0;git status --porcelainclean.RelationConditionSitetype.tscanswered TS2353 on that key, which proves mcp reads the rebuilt core declarations. The file was removed, andtscexits 0.Verification (final head
2261c253ec,origin/mainc11b75870amerged; closure rebuilt after the merge)local94 files / 2364 passed;repo5 files / 55 passed (the census pin included).second-object-exposure.dogfood.test.ts: 34/34.tscandcheck:test-typecheckOK), metadata-protocol (tsc) and core (tsc, examples andcheck:test-typecheckOK), all exit 0.dispatch-gates --commandson this diff (67) is 67 commands. 66 exit 0.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: the gate needs every package'sdist, and 19 packages outside this closure are unbuilt). That is NOT MEASURED, not a pass. The narrowed probe:require()of the CJS entry of core, mcp and metadata-protocol exits 0, and core's CJS entry exportsrelationConditionSitesas a function.dispatch-gates --ran: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN.eslint --no-inline-config --format jsonover the 7 changed.tsfiles reports 7 files, 0 errors, 0 warnings.--print-configshows 5 or 6 active rules per file and noparserOptions.projectorprojectService, so no untouched file's verdict can move.Deviations
origin/main23419bafb7(it had moved past the dispatch's14f4912390).origin/mainc11b75870awas merged as2261c253ec. Since then,mainhas gained 3 commits that touch none of these 9 paths.protocol.ts; core's index is +6.whereonqueryandaggregate. It does not coveraggregations[i].filter: theaggregate_recordstool's aggregation schema carries no filter member, and the engine refuses a nested-relation condition there.isSystembypass is kept by reuse. It is unreachable from this door (measured; see above), so no caller gets a different answer from the one security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 gives.@objectstack/mcpisminor, BREAKING, with FROM → TO and the migration, and carries the Clause-② line and the ADR-0087 marker.@objectstack/coreisminor(one export) and@objectstack/metadata-protocolispatch(import only, no behaviour change), in one non-breaking changeset.Acceptance notes
callData) prefers the protocol service, which is the data door security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 decided, and falls back to the engine when that service is absent. Whether the fallback is reachable in a real composition was not measured here.query_recordsandaggregate_recordsanswer every bridge refusal as text only.errorResultFromThrownexists in the same file and is used by other tools, so this refusal's code is not machine-readable at the tool. That is pre-existing and the same for an addressed object; no defect class is claimed. Carrier: none.Generated by Claude Code