Skip to content

Commit d1e2291

Browse files
committed
fix(mcp): the stdio data bridge asks each nested-relation condition's 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
1 parent 23419ba commit d1e2291

7 files changed

Lines changed: 306 additions & 46 deletions

File tree

‎packages/core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ export * from './utils/record-not-found.js';
180180
// resolve with it (one name space for both doors).
181181
export * from './utils/object-not-found.js';
182182

183+
// [#22777] The one walk that finds a filter's nested-relation conditions and
184+
// their target objects: the data door (`@objectstack/metadata-protocol`) and
185+
// the MCP stdio data bridge (`@objectstack/mcp`) ask each target its exposure
186+
// before the engine evaluates the condition, and share no other package.
187+
export * from './utils/relation-condition-sites.js';
188+
183189
// Export in-memory fallbacks for core-criticality services
184190
export * from './fallbacks/index.js';
185191

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@
4141
* for `aggregate` over `queryObjects`); and the reads whose target is a FIXED
4242
* platform object rather than an authored one (a file field's `sys_file`
4343
* hydration, the `sys_user` display names the approvals and audit surfaces
44-
* resolve). They are named here, not held.
44+
* resolve). They are named here, not held. Nor does it see a door that reaches a
45+
* classified read through the shared condition walk rather than calling the
46+
* arbiter itself: the MCP stdio data bridge hands a caller's filter to the
47+
* engine's relation lowering (the `relation-filter-lowering.ts` row), and its
48+
* targets are resolved inside `relationConditionSites` (`@objectstack/core`),
49+
* which is the caller classified below. The bridge asks each target the
50+
* decision for `list` (`enforceRelationTargetExposure`, #22777), held by its
51+
* own package's pin rather than by a row here.
4552
*
4653
* The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts`
4754
* in this directory: git's authored-file list, never a directory crawl, and `.ts`
@@ -161,6 +168,13 @@ const CALLERS: Record<string, Classification> = {
161168
wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' },
162169
pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts',
163170
},
171+
'packages/core/src/utils/relation-condition-sites.ts': {
172+
kind: 'not-a-served-read',
173+
reason:
174+
"finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " +
175+
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " +
176+
"stdio data bridge's `enforceRelationTargetExposure`",
177+
},
164178
'packages/lint/src/object-graph.ts': {
165179
kind: 'not-a-served-read',
166180
reason: 'authoring-time static analysis of metadata; reads no record',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { relationConditionSites } from './relation-condition-sites.js';
5+
6+
const FIELDS = {
7+
name: { type: 'text' },
8+
account: { type: 'lookup', reference: 'rcs_account' },
9+
owner: { type: 'user' },
10+
tags: { type: 'lookup', reference: 'rcs_tag', multiple: true },
11+
};
12+
13+
describe('[#22777] relationConditionSites — the one walk both exposure-asking doors share', () => {
14+
it('finds a condition on a related object, at the top level and under every combinator, with its target', () => {
15+
const where = {
16+
account: { name: 'a' },
17+
$or: [{ owner: { email: 'e' } }, { $not: { tags: { label: 't' } } }],
18+
$and: [{ name: 'n' }],
19+
};
20+
const sites = relationConditionSites(FIELDS, where).map((s) => [s.field, s.target, s.condition]);
21+
expect(sites.sort()).toEqual([
22+
['account', 'rcs_account', { name: 'a' }],
23+
['owner', 'sys_user', { email: 'e' }],
24+
['tags', 'rcs_tag', { label: 't' }],
25+
]);
26+
});
27+
28+
it('CONTROL finds no site in an id comparand, an operator bag, a non-reference field or without a field map', () => {
29+
expect(relationConditionSites(FIELDS, { account: 'a1', owner: { $in: ['u1'] }, name: { first: 'x' } })).toEqual([]);
30+
expect(relationConditionSites(undefined, { account: { name: 'a' } })).toEqual([]);
31+
});
32+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data';
4+
5+
/** One nested-relation condition a filter holds, with the object it is evaluated on. */
6+
export interface RelationConditionSite {
7+
/** The reference field of the filtered object — the filter key. */
8+
field: string;
9+
/** The related object the field points at, by the spec's one arbiter (`referenceTargetOf`). */
10+
target: string;
11+
/** The condition on the related object: the plain record beneath the field. */
12+
condition: Record<string, unknown>;
13+
}
14+
15+
/**
16+
* [#22646] The NESTED-RELATION conditions a filter holds — a key naming a
17+
* reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a
18+
* plain record carrying at least one non-`$` key, i.e. a condition on the
19+
* related object rather than an operator bag — with each site's target object.
20+
* Logical combinators (and any other `$` key) are walked as conditions, so a
21+
* site under `$or` / `$not` is found. The engine's own relation lowering
22+
* (`ObjectQL.lowerRelationConditions`) is the authority on whether a site RUNS;
23+
* this only finds what a filter would evaluate on the related object.
24+
*
25+
* [#22777] The ONE walk for every door that asks each site's target the spec's
26+
* exposure decision before handing the filter to the engine: the data door
27+
* (`@objectstack/metadata-protocol`, where it also feeds the internal-field
28+
* refusal) and the MCP stdio data bridge (`@objectstack/mcp`). It lives here
29+
* because the two doors share no other package, and a second walk is how two
30+
* doors come to disagree about which conditions a filter holds.
31+
*
32+
* `fields` is the filtered object's declared field map; with none, there is
33+
* nothing to resolve a target with and no site is found.
34+
*/
35+
export function relationConditionSites(
36+
fields: Record<string, any> | undefined,
37+
root: unknown,
38+
): RelationConditionSite[] {
39+
const sites: RelationConditionSite[] = [];
40+
if (!fields || typeof fields !== 'object') return sites;
41+
const seen = new WeakSet<object>();
42+
const pending: unknown[] = [root];
43+
while (pending.length > 0) {
44+
const node = pending.pop();
45+
if (node === null || typeof node !== 'object' || seen.has(node)) continue;
46+
seen.add(node);
47+
if (Array.isArray(node)) {
48+
for (const member of node) pending.push(member);
49+
continue;
50+
}
51+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
52+
if (key.startsWith('$')) {
53+
pending.push(value);
54+
continue;
55+
}
56+
const def = fields[key];
57+
if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue;
58+
if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue;
59+
const target = referenceTargetOf(def);
60+
if (target) sites.push({ field: key, target, condition: value });
61+
}
62+
}
63+
return sites;
64+
}
65+
66+
/** A JSON object — not an array, not `null`. */
67+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
68+
return typeof value === 'object' && value !== null && !Array.isArray(value);
69+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22777] A nested-relation condition in a stdio caller's filter is evaluated
5+
* on its TARGET object (the engine lowers it by reading the target as the
6+
* caller), so the stdio data bridge asks each condition's target the spec's one
7+
* exposure decision for `list` — the same gate an addressed object gets — and
8+
* REFUSES an unserved target before the engine is asked. The data door asks
9+
* the same of the same sites (`@objectstack/metadata-protocol`). Fixture objects
10+
* only; refusals are asserted by envelope (`code` + `status`), never by a bare
11+
* throw, and every refusal asserts the engine was never reached.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
18+
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';
19+
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
21+
import { createStdioDataBridge, type McpExposureError } from './stdio-data-bridge.js';
22+
import { registerObjectTools } from './mcp-http-tools.js';
23+
24+
const lookup = (reference: string) => ({ type: 'lookup', reference });
25+
/** Each source lookup → its target's `enable` block (`undefined`: none declared). */
26+
const TARGETS: Record<string, Record<string, unknown> | undefined> = {
27+
hidden: { apiEnabled: false },
28+
closed: { apiMethods: ['create'] },
29+
getonly: { apiMethods: ['get'] },
30+
none: { apiMethods: [] },
31+
listonly: { apiMethods: ['list'] },
32+
open: undefined,
33+
};
34+
const DEFS: Record<string, unknown> = {
35+
rcx_source: {
36+
name: 'rcx_source',
37+
fields: { name: { type: 'text' }, ...Object.fromEntries(Object.keys(TARGETS).map((rel) => [rel, lookup(`rcx_${rel}`)])) },
38+
},
39+
...Object.fromEntries(Object.entries(TARGETS).map(([rel, enable]) => [
40+
`rcx_${rel}`, { name: `rcx_${rel}`, fields: { name: { type: 'text' } }, ...(enable ? { enable } : {}) },
41+
])),
42+
};
43+
const REFUSED = [
44+
['hidden', 'OBJECT_API_DISABLED', 404],
45+
['closed', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
46+
['getonly', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
47+
['none', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
48+
] as const;
49+
50+
function makeBridge() {
51+
const engine = { find: vi.fn(async () => [{ id: 's1' }]), aggregate: vi.fn(async () => [{ n: 1 }]) };
52+
const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) };
53+
const bridge = createStdioDataBridge({
54+
engine: engine as unknown as IDataEngine,
55+
metadataService: metadataService as unknown as IMetadataService,
56+
resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext,
57+
});
58+
return { bridge, engine };
59+
}
60+
61+
const VERBS = {
62+
query: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) => b.query('rcx_source', { where }),
63+
aggregate: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) =>
64+
b.aggregate!('rcx_source', { where, aggregations: [{ function: 'count', alias: 'n' }] }),
65+
};
66+
67+
describe('[#22777] the stdio bridge refuses a condition on a target the decision does not serve for list', () => {
68+
for (const [verb, run] of Object.entries(VERBS)) {
69+
for (const [rel, code, status] of REFUSED) {
70+
for (const [shape, where] of [
71+
['direct', { [rel]: { name: 'x' } }],
72+
['under $or', { $or: [{ [rel]: { name: 'x' } }, { name: 'y' }] }],
73+
['under $not', { $not: { [rel]: { name: 'x' } } }],
74+
] as const) {
75+
it(`${verb}: ${rel} (${shape}) answers ${status} and never reaches the engine`, async () => {
76+
const { bridge, engine } = makeBridge();
77+
const err = (await run(bridge, where).then(() => null, (e: unknown) => e)) as McpExposureError | null;
78+
expect(err, 'the call resolved: no refusal was raised').toBeTruthy();
79+
expect(err!.code).toBe(code);
80+
expect(err!.status).toBe(status);
81+
if (status === 405) {
82+
expect(err!.allowedOperations).toEqual(effectiveOperationsArray(resolveEffectiveApiMethods(TARGETS[rel]!)));
83+
}
84+
expect(err!.message).toContain(`'rcx_${rel}'`);
85+
expect(err!.message).toContain(`'where.${rel}'`);
86+
expect(engine.find).not.toHaveBeenCalled();
87+
expect(engine.aggregate).not.toHaveBeenCalled();
88+
});
89+
}
90+
}
91+
92+
it(`${verb}: CONTROL a served target, an id comparand and an operator bag reach the engine unchanged`, async () => {
93+
for (const where of [
94+
{ listonly: { name: 'x' } },
95+
{ $or: [{ open: { name: 'x' } }] },
96+
{ hidden: 'h1' },
97+
{ hidden: { $in: ['h1'] } },
98+
]) {
99+
const { bridge, engine } = makeBridge();
100+
await run(bridge, where);
101+
const call = (verb === 'query' ? engine.find.mock.calls[0]?.[1] : engine.aggregate.mock.calls[0]?.[1]) as
102+
| { where?: unknown }
103+
| undefined;
104+
expect(call?.where, JSON.stringify(where)).toBe(where);
105+
}
106+
});
107+
}
108+
109+
it('query_records answers the refusal naming the target and the position; CONTROL a served target is answered', async () => {
110+
const { bridge, engine } = makeBridge();
111+
const server = new McpServer({ name: 'pin', version: '0' });
112+
registerObjectTools(server, bridge);
113+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
114+
await server.connect(serverSide);
115+
const client = new Client({ name: 'pin', version: '0' });
116+
await client.connect(clientSide);
117+
try {
118+
const call = (where: Record<string, unknown>) =>
119+
client.callTool({ name: 'query_records', arguments: { objectName: 'rcx_source', where } }) as Promise<{
120+
isError?: boolean;
121+
content: Array<{ text: string }>;
122+
}>;
123+
const refused = await call({ hidden: { name: 'x' } });
124+
expect(refused.isError).toBe(true);
125+
expect(refused.content[0]!.text).toContain("Object 'rcx_hidden' is not exposed via the API");
126+
expect(refused.content[0]!.text).toContain("'where.hidden'");
127+
expect(engine.find).not.toHaveBeenCalled();
128+
const served = await call({ listonly: { name: 'x' } });
129+
expect(served.isError).toBeFalsy();
130+
expect(engine.find).toHaveBeenCalledTimes(1);
131+
} finally {
132+
await client.close();
133+
}
134+
});
135+
});

‎packages/mcp/src/stdio-data-bridge.ts‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
* remains of the divergence above is genuinely not security.
8282
*/
8383

84-
import { omitInternalFieldsFromWriteResponse } from '@objectstack/core';
84+
import { omitInternalFieldsFromWriteResponse, relationConditionSites } from '@objectstack/core';
8585
import {
8686
resolveEffectiveApiMethods,
8787
isApiOperationAllowed,
@@ -271,12 +271,18 @@ function exposureError(
271271
* legacy/test doubles. Reading only the nested shape here would let a flat
272272
* definition be gated on HTTP and ungated on stdio — the very divergence
273273
* this function closes, re-opened one shape down.
274+
*
275+
* [#22777] `condition` is set when `object` is not addressed but is the TARGET
276+
* of a nested-relation condition at that filter position
277+
* ({@link enforceRelationTargetExposure}); the refusal then names the position
278+
* and the way out, as the data door's does for the same condition.
274279
*/
275280
export async function enforceApiExposure(
276281
metadataService: IMetadataService,
277282
object: string,
278283
action: string,
279284
context: ExecutionContext,
285+
condition?: string,
280286
): Promise<void> {
281287
if (context?.isSystem) return;
282288

@@ -291,10 +297,14 @@ export async function enforceApiExposure(
291297
const enable = (
292298
def.enable && typeof def.enable === 'object' ? def.enable : def
293299
) as unknown as EnableLike;
300+
const consequence = condition === undefined
301+
? ''
302+
: `, so the condition on it at '${condition}' was not evaluated and the query was not run. `
303+
+ 'Filter on the lookup\'s stored id instead, or remove the condition.';
294304

295305
if (enable.apiEnabled === false) {
296306
throw exposureError(
297-
`Object '${object}' is not exposed via the API`,
307+
`Object '${object}' is not exposed via the API${consequence}`,
298308
OBJECT_API_DISABLED,
299309
404,
300310
);
@@ -307,13 +317,44 @@ export async function enforceApiExposure(
307317
if (isApiOperationAllowed(eff, operation)) return;
308318

309319
throw exposureError(
310-
`API operation '${operation}' is not allowed on object '${object}'`,
320+
`API operation '${operation}' is not allowed on object '${object}'${consequence}`,
311321
OBJECT_API_METHOD_NOT_ALLOWED,
312322
405,
313323
effectiveOperationsArray(eff),
314324
);
315325
}
316326

327+
/**
328+
* [#22777] A NESTED-RELATION condition in the caller's filter is evaluated on
329+
* its TARGET object: the engine lowers it by reading the target as the caller
330+
* (`ObjectQL.lowerRelationConditions`) and keeps the rows whose related record
331+
* matched. So each condition's target is asked {@link enforceApiExposure}'s
332+
* decision for {@link GATED_ACTIONS}`.query` — `list`, because a condition is a
333+
* predicate over the whole target, the read the target's own filtered query
334+
* performs — and an unserved target REFUSES the call: dropping the condition
335+
* would widen the result. The data door asks the same of the same sites
336+
* (`refuseUnservedRelationTarget`), found by the one walk both doors import
337+
* (`relationConditionSites`). One hop, as the engine serves one: a relation
338+
* beneath a condition is refused by the engine before anything is read.
339+
*/
340+
async function enforceRelationTargetExposure(
341+
metadataService: IMetadataService,
342+
object: string,
343+
where: unknown,
344+
context: ExecutionContext,
345+
): Promise<void> {
346+
if (where == null) return;
347+
let def: ObjectDef | null | undefined;
348+
try {
349+
def = (await metadataService.getObject(object)) as ObjectDef | null | undefined;
350+
} catch {
351+
def = undefined; // no field map, no site: the gate above falls open the same way
352+
}
353+
for (const site of relationConditionSites(def?.fields, where)) {
354+
await enforceApiExposure(metadataService, site.target, GATED_ACTIONS.query, context, `where.${site.field}`);
355+
}
356+
}
357+
317358
// ---------------------------------------------------------------------------
318359
// [#21207] The stored-metadata-body family, at this engine-only reader
319360
// ---------------------------------------------------------------------------
@@ -683,6 +724,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
683724
async query(object, opts) {
684725
const context = await resolvePrincipal();
685726
await enforceApiExposure(metadataService, object, GATED_ACTIONS.query, context);
727+
// [#22777] …and each nested-relation condition's target, before the engine reads it.
728+
await enforceRelationTargetExposure(metadataService, object, opts?.where, context);
686729
// [#21207] After the exposure gate, before the engine: a filter or sort
687730
// on a stored metadata body is refused rather than evaluated.
688731
const bodyRefusal = storedMetadataBodyRefusal(object, { where: opts?.where, orderBy: opts?.orderBy });
@@ -790,6 +833,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
790833
// `list` must not leak row statistics through GROUP BY either. The
791834
// derivation lives in the spec helpers, so that holds here for free.
792835
await enforceApiExposure(metadataService, object, GATED_ACTIONS.aggregate, context);
836+
// [#22777] …and each nested-relation condition's target, as `query` asks it.
837+
await enforceRelationTargetExposure(metadataService, object, opts?.where, context);
793838
// [#21207] A grouping, filter or aggregate member on a stored metadata
794839
// body computes over the stored bytes, so it is refused, not run.
795840
const bodyRefusal = storedMetadataBodyRefusal(object, {

0 commit comments

Comments
 (0)