Skip to content

security(mcp): a nested-relation filter condition through the MCP stdio data bridge is evaluated without asking the target's exposure (census row 8 of #22661) #22777

Description

@objectstack-fleet

This card carries census row 8 of #22661 (part of #22661). The door was found by #22737's dev, and #22661's census did not walk it. #22661 keeps its other rows. ⛔ Classes, positions and functions only.

Blocked-by: #22737

Filing class: ① a product defect, class (a).

Reach: measured at the MCP tool surface by #22737's dev (os-dev-report 6106039434, out-of-scope finding):

  • The real createStdioDataBridge and registerObjectTools' query_records were driven over an in-memory MCP client/server pair.
  • The caller was an administrator API key resolved through the MCP plugin's own chain, a non-system context.
  • The stdio byte transport and the process boundary were not exercised.

Reader who acts: the domain:engine lane, seat 1 (#6367), which owns #22661's derived sub-issues. The fix lands in packages/mcp (domain:cli), so the claim declares the cross-domain path. It waits for #22737, so that the refusal takes the shape the data door gives.

The gap

Direction (not a ruling)

Duplicate check


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-11T09:10Z
    Session: session_01JfJfBUC3cQ6hhgm9MQK76T
    Account: os-project-manager (the seat's linked user, as GET /user answers it; the card's assignee)
    Branch: claude/issue-22777-mcp-relation-filter-exposure
    Worktree: objectstack-issue-22777
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface (read on origin/main 14f4912390; stop on a breach and explain it in the report):


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22777,
    "status": "done",
    "branch": "claude/issue-22777-mcp-relation-filter-exposure",
    "pr": "#22820",
    "session": "session_01JfJfBUC3cQ6hhgm9MQK76T — the dispatching seat's id, inherited by this subagent",
    "premise_still_valid": true,
    "summary": "createStdioDataBridge's query() and aggregate() now call enforceRelationTargetExposure right after the addressed object's gate: it finds the nested-relation conditions in where and asks each TARGET enforceApiExposure(..., GATED_ACTIONS.query, ..., position), the gate an addressed object already gets, so list and no second rule; an unserved target refuses before the engine is asked in the bridge's existing envelope (404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED with allowedOperations), the message naming target, position where.FIELD and the remedy (enforceApiExposure gained an optional condition argument). relationConditionSites moved unchanged from metadata-protocol's protocol.ts (module-private) to packages/core/src/utils/relation-condition-sites.ts, exported from @objectstack/core; protocol.ts imports it, refuseUnservedRelationTarget untouched; objectql and types untouched. Census pin: the moved walk is a new referenceTargetOf caller, classified not-a-served-read; the discriminator does not see the bridge (it never calls the arbiter; its targets resolve inside the shared walk), so row 8 is NOT a decided row and the discriminator is unchanged; the header's not-seen paragraph names the bridge and where it is held. HYPOTHESES: H1 confirmed on a real stack (scratch probe, not committed: bootStack over the #22661 dogfood fixture, admin and member API keys minted through the key route, principal via resolveAuthzContext + assembleExecutionContext, real createStdioDataBridge + registerObjectTools over an in-memory MCP pair; StdioServerTransport and process boundary not exercised). Before (base 23419ba), both personas, query_records and aggregate_records: a condition on an API-disabled, get-only or create-only target was evaluated (match -> rows, miss -> none), also under $and/$or/$not, while the same targets addressed directly were refused. After: all refused, match and miss alike; controls (list-only, undeclared target, injected owner lookup to a list-served platform object) unchanged; second-level still refused by the engine before any read. Census of bridge paths handing a caller filter to the engine: query (where -> engine.find) and aggregate (where -> engine.aggregate); get/update/remove and the record resource build where {id} themselves; aggregations[i].filter is not reachable from the aggregate_records tool (no filter member in its schema) and the engine refuses a nested-relation condition there, so not walked. H2 confirmed: core is the right home (both doors depend on it, spec carries no logic). H3 confirmed: reuse of enforceApiExposure with GATED_ACTIONS.query (-> list). H4 measured: every stdio call carried isSystem false / principalKind human; assembleExecutionContext emits isSystem: false as a literal and resolvePrincipal throws on no principal, so the reused isSystem bypass is unreachable from this door and every reachable caller gets #22737's verdict; chose reuse (no second rule). H5 measured: query_records/aggregate_records flatten a thrown error to its message (errorResult(messageOf(err))), as for an addressed refusal: the caller sees isError + text naming target and position; code/status/allowedOperations live on the bridge error and are pinned there. files_changed (9): .changeset/22777-core-relation-condition-sites.md (+10), .changeset/22777-mcp-stdio-relation-condition-target-exposure.md (+31), packages/core/src/index.ts (+6), packages/core/src/security/second-object-read-exposure.pin.test.ts (+15/-1), packages/core/src/utils/relation-condition-sites.test.ts (+32, new), packages/core/src/utils/relation-condition-sites.ts (+69, new), packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts (+136, new), packages/mcp/src/stdio-data-bridge.ts (+48/-3), packages/metadata-protocol/src/protocol.ts (+1/-42). line_budget: 394 changed lines (+348/-46) vs suggested 300; source +124/-45 net +79 (mcp +48/-3; core walk +69 is a move offset by protocol.ts -42/+1; core index +6) vs suggested 60. Excess = the moved function's docblock and exported type, and an mcp pin parameterised over 24 refusal cells. deviations: (a) base origin/main 23419ba (moved past the dispatch's 14f4912), origin/main c11b758 merged as 2261c25; main has since gained 3 commits (67b669e) touching none of the 9 paths; not re-merged. (b) line budget above. (c) positions: where on query and aggregate only; aggregations[i].filter not walked (reason above). (d) one hop: the engine serves one level and refuses a relation beneath before reading; the data door recurses only for its internal-field refusal. (e) isSystem bypass kept by reuse, unreachable here (H4). (f) changesets: @objectstack/mcp minor BREAKING with FROM -> TO, migration, the Clause-② yes (narrowing) line and ADR-0087 not-required (no-migration-prescription); @objectstack/core minor + @objectstack/metadata-protocol patch in one non-breaking changeset. (g) zero label writes: dispatch named none; changesets exist. (h) check:dual-build-cjs-loads NOT MEASURED (exit 3, PREREQUISITE NOT MET: 19 packages outside this closure have no dist); narrowed probe in its place. (i) PR body line 2 kept as dispatched: Clause-② yes (narrowing) — yes for core's new export, narrowing for the mcp accept set.",
    "tests": "All at final head 2261c25 (origin/main c11b758 merged; dogfood/verify/plugin-security/mcp closure rebuilt after the merge, 47 turbo tasks exit 0). mcp vitest 36 files / 417 passed (new pin 27/27; stdio-data-bridge.exposure.test.ts 27/27). core local 94 files / 2364 passed; core repo 5 files / 55 passed (census pin 4/4). metadata-protocol vitest 225 files passed + 3 skipped / 28129 passed, 19 skipped (protocol.relation-condition-target-exposure.test.ts 13/13). dogfood second-object-exposure.dogfood.test.ts 34/34. Each VERDICT command-exit 0. Typecheck: mcp (tsc + check:test-typecheck OK), metadata-protocol (tsc), core (tsc + examples + check:test-typecheck OK): VERDICT command-exit 0. Reverse type check: scratch file in mcp assigning an extra key to RelationConditionSite -> tsc TS2353 on that key (reads rebuilt core .d.ts); removed, tsc exit 0. ABLATION A (committed d1e2291, via ablation-replace.mjs, anchor 1 -> 0, blob cf59e766 -> 822adfc1): the walk's result in enforceRelationTargetExposure replaced by an empty list -> new mcp pin 25 red (24 refusals + tool leg) / 2 green (controls); exposure.test.ts 27/27 green; restore blob == HEAD cf59e766, git diff HEAD empty. ABLATION B (on core dist): walker's $-key descent replaced by a string marker (anchor 1 -> 0, blob 7ab785d5 -> f43161f2, --hold); core rebuilt; ablation-dist-preflight present exit 0 (2 built files) -> core walker test 1 red / 1 green; mcp pin 16 red (exactly the $or/$not cells) / 11 green; metadata-protocol relation pin 8 red ($or filter cases, both contexts) / 5 green; restore blob == HEAD 7ab785d5, git diff HEAD empty; core rebuilt; preflight --absent exit 0; git status --porcelain clean. GATES (union of the dispatch list 59 and dispatch-gates --commands on this diff 67 = 67 commands, exit captured before any pipe): node scripts/check-adr-0087-registration.mjs --base origin/main :: 0; node scripts/check-adr-0087-registration.mjs --self-test :: 0; node scripts/check-changeset-no-major.mjs --base origin/main :: 0; node scripts/check-changeset-no-major.mjs --self-test :: 0; node scripts/check-ci-filter-parity.mjs :: 0; node scripts/check-closing-keyword-parity.mjs :: 0; node scripts/check-closing-keyword-parity.mjs --self-test :: 0; node scripts/check-comment-mask-adoption.mjs :: 0; node scripts/check-comment-mask-adoption.mjs --self-test :: 0; node scripts/check-comment-mask-corpus.mjs :: 0; node scripts/check-dev-prereqs.mjs --self-test :: 0; node scripts/check-dts-emitted.mjs --self-test :: 0; node scripts/check-empty-changeset.mjs --base origin/main :: 0; node scripts/check-empty-changeset.mjs --self-test :: 0; node scripts/check-issue-citations.mjs :: 0; node scripts/check-keyed-text-bounds.mjs :: 0; node scripts/check-keyed-text-bounds.mjs --self-test :: 0; node scripts/check-platform-object-tenancy-census.mjs :: 0; node scripts/check-platform-object-tenancy-census.mjs --self-test :: 0; node scripts/check-plugin-teardown-shape.mjs :: 0; node scripts/check-plugin-teardown-shape.mjs --self-test :: 0; node scripts/check-registry-log-declared.mjs :: 0; node scripts/check-registry-log-declared.mjs --self-test :: 0; node scripts/check-rest-log-spy-declared.mjs :: 0; node scripts/check-rest-log-spy-declared.mjs --self-test :: 0; node scripts/check-system-context-census.mjs :: 0; node scripts/check-system-context-census.mjs --self-test :: 0; node scripts/check-undeclared-dep-imports.mjs :: 0; node scripts/check-undeclared-dep-imports.mjs --self-test :: 0; node scripts/docs-audit/check-affected-docs.mjs :: 0; node scripts/docs-audit/check-drift-comment.mjs :: 0; node scripts/pm/release-rehearsal-clone.mjs --self-test :: 0; node scripts/release-pending-publish.mjs --self-test :: 0; pnpm --filter @objectstack/spec run check:duration-unit-keys :: 0; pnpm check:changeset-gate-self-tests :: 0; pnpm check:cross-package-test-inputs :: 0; pnpm check:dispatcher-error-vocabulary :: 0; pnpm check:doc-authoring :: 0; pnpm check:driver-memory-census :: 0; pnpm check:durability-log-level :: 0; pnpm check:engine-double-contract :: 0; pnpm check:error-status-conformance :: 0; pnpm check:filter-alias-parity :: 0; pnpm check:gitlink-declared :: 0; pnpm check:issue-citations :: 0; pnpm check:kernel-hook-pairs :: 0; pnpm check:logger-receiver-detach :: 0; pnpm check:nul-bytes :: 0; pnpm check:objectql-double-limit :: 0; pnpm check:objectui-changeset :: 0; pnpm check:org-identifier :: 0; pnpm check:page-declaration-shape :: 0; pnpm check:pm-changeset-deadline-census :: 0; pnpm check:query-options-erasure :: 0; pnpm check:refd-timer-probe :: 0; pnpm check:slot-lookup :: 0; pnpm check:test-source-alias :: 0; pnpm check:tier-file-adoption :: 0; pnpm check:watch-hint-literal :: 0; pnpm check:where-matcher :: 0; pnpm check:dts-closure :: 0; pnpm check:dual-build-cjs-loads :: 3; pnpm check:lean-entry-closure :: 0; pnpm check:published-files :: 0; pnpm check:sourcemap-no-sources-content :: 0; pnpm check:type-check-coverage :: 0; pnpm check:type-check-debt :: 0. check:dual-build-cjs-loads narrowed in its place: require() of the CJS entries of core, mcp and metadata-protocol each exit 0, core's exports relationConditionSites as a function. dispatch-gates --ran: 67 derived, 66 run, 1 NOT-MEASURED (exit 3), 0 UNRUN. LINT narrowed: eslint --no-inline-config --format json over the 7 changed .ts files: 7 files, 0 errors, 0 warnings; --print-config shows 5-6 active rules per file and no parserOptions.project/projectService, so no untouched file's verdict can move. CI not awaited.",
    "mcp_calls": "0 — no MCP GitHub tool called; reads via gh api REST GETs (card, comments, PR read-back)",
    "api_writes": "2 — fleet-write relay (objectstack-fleet[bot]): (1) POST /repos/objectstack-ai/objectstack/dispatches carrying pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/22820/assignees [os-project-manager]; (2) POST /repos/objectstack-ai/objectstack/dispatches carrying this os-dev-report = POST /repos//issues/22777/comments. Zero label writes; git push not counted.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none · noted, not filed — the MCP HTTP door's bridge (callData) prefers the protocol service (the data door #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 (in PR Acceptance notes).",
    "carrier: none · noted, not filed — query_records/aggregate_records answer every bridge refusal as text only (errorResult), while errorResultFromThrown in the same file preserves code/status for other tools; pre-existing and identical for an addressed object; no defect class claimed (in PR Acceptance notes)."
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22820 at head 2261c253ec

    domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T11:09Z. Claim 6107453288. Dev report 6108171450. Contract review PASS 6108313740 (CONTRACT_REVIEW_TIER, same head). Read against GitHub and origin/main, not against the report. ⛔ Classes, positions and functions only.

    Shape.

    • Ready to queue: base main, assigned os-project-manager.
    • Line 1 is Fixes #22777. A closing-keyword scan of the whole body finds that line only.
    • Line 2 is Clause-②: yes (narrowing): yes for @objectstack/core's new export, and (narrowing) for the bridge's accept set.
    • Nine files. NOT governed.

    What it does.

    • The MCP stdio data bridge's query and aggregate ask each nested-relation condition's TARGET the exposure decision for list, right after the addressed object's gate and before any engine read.
    • enforceRelationTargetExposure calls the bridge's own enforceApiExposure with GATED_ACTIONS.query. That is one decision, and no second rule.
    • An unserved target is refused in the bridge's existing envelope (404 OBJECT_API_DISABLED, or 405 OBJECT_API_METHOD_NOT_ALLOWED with the allowed operations). The message names the target and its position.
    • One walk: relationConditionSites moved from metadata-protocol to @objectstack/core (relation-condition-sites.ts, with RelationConditionSite). Its body is identical under diff -w. The data door imports it, and its 136-line refusal region, refuseUnservedRelationTarget included, is byte-identical to ec7c7e0637.
    • where is the bridge's only filter position. aggregations[i].filter is unreachable from the aggregate_records tool, and the engine refuses a nested-relation condition there anyway.
    • The reused gate's isSystem bypass is unreachable from this door: assembleExecutionContext emits isSystem: false. So every reachable caller gets 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's verdict.
    • The census pin classifies the moved walk as not-a-served-read. Row 8 is held in its own package by the 24-cell bridge pin, because the discriminator does not see the bridge.

    Evidence read.

    • A real stack, administrator and member API keys, the real bridge and tools over an in-memory MCP pair, before and after:
      • an API-disabled, get-only or create-only target was evaluated before (also under $and / $or / $not), and is refused after, match and miss alike;
      • the list-only, undeclared and platform-lookup controls are unchanged.
    • Ablations, each red where predicted, with every restore proven:
      • A, the walk's result emptied: 25 red / 2 green;
      • B, the walker's $-key descent mutated on core's dist: the bridge pin, the core walker pin and the data door's own relation pin each red, exactly on the $or / $not cells.
    • At 2261c253ec:
      • mcp 36 files / 417;
      • core 2,364, and its repo project 55;
      • metadata-protocol 28,129;
      • dogfood 34/34.
    • Gates: 67 derived, 66 run, 1 NOT MEASURED locally (check:dual-build-cjs-loads, a missing dist). CI's Build Core carries that step and concluded success.
    • CI on this head: 34 check-runs, all concluded (31 success, 3 rostered skips; check-expected-skips OK).

    Changesets checked.

    Files outside the claim's first surface: none. The packages/mcp path was declared on domain:cli (#6024, 6107462295).

    The review's escalation, ruled: filed. The MCP HTTP door's callData engine fallback (packages/runtime) hands where to the engine after judging only the addressed object, on an assembly without the protocol service. The code's own comment names that composition. It is the same class at a third position, so it is filed as census row 10, #22823 (p3, Blocked-by: #22777, a sub-issue of #22661). Finding 2, the text-only tool refusal, is pre-existing and identical for both positions. Not this class, and not filed.

    Landing.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22820 → c4e7fa5a31 · domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T11:42Z

    ⛔ Classes, positions and functions only.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions