Skip to content

Commit b48eeb1

Browse files
fix(mcp,runtime)!: the MCP data tools refuse to filter, sort, group or aggregate on an internal field, with the data door's own 400 INVALID_FIELD (#28772)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches filtering, sorting, grouping or aggregating on an `internal: true` field over MCP; `content/docs/api/error-catalog.mdx` already states that a read naming such a field in an evaluate position is refused `INVALID_FIELD`, which this diff makes true on the MCP data tools too. ## 维护者速读 - **改了什么**:MCP 的三个数据入口(stdio 的 `query_records` / `aggregate_records`,HTTP `/mcp` 的 `aggregate_records`)在交给引擎之前,改为调用 REST 数据入口同一个「`internal: true` 字段不可求值」闸门(协议服务上的 `assertNoInternalFieldEvaluated`,改为公开方法,定义仍只有一份);对这类字段做过滤、排序、分组或聚合,统一回 `400 INVALID_FIELD`,文案与 REST 逐字相同。 - **为什么改**:QA 第三轮发现的安全加固(细节按披露裁决不公开) - **风险与回滚**:收窄(BREAKING):只影响通过 MCP 对声明了 `internal: true` 的字段做过滤/排序/分组/聚合的调用;普通字段、行读取(该字段仍不出现在任何行里)、字段权限的 403 均不变;未注册协议服务的宿主照旧。回滚 = revert 本 PR,无数据迁移。 - **先看**:① `packages/mcp/src/stdio-data-bridge.ts` 的 `refuseInternalFieldEvaluation` 及其在 `query` / `aggregate` 中的两处调用;② `packages/runtime/src/action-execution.ts` 中 `callData` 的 `aggregate` 分支新增段;③ `packages/metadata-protocol/src/protocol.ts` 中 `assertNoInternalFieldEvaluated` 去掉 `private` 及其文档段。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/mcp-internal-field-evaluate-positions.dogfood.test.ts`,预期 32/32 通过(28 个拒绝用例与 REST 的 code/status/message 逐字一致,4 个普通字段对照用例照常返回)。 - **需要你做的**:无,席位落地。 ## What changes The data door refuses to EVALUATE a field declared `internal: true` — a filter, a sort, a group key, an aggregation operand or an aggregation's own filter on it — for every caller, before the engine is asked (`assertNoInternalFieldEvaluated`, `@objectstack/metadata-protocol`). Three MCP mouths hand a caller's query to the engine without passing through `findData`, where the door asks that gate, so they evaluated those positions. Each now asks the door's ONE gate of the `protocol` service it already resolves, and answers what the door answers, word for word. - **`@objectstack/metadata-protocol`** — `assertNoInternalFieldEvaluated` loses `private`; its docblock names the MCP callers. No other change: one definition, no copy beside it (route-ownership rule 1). - **`@objectstack/mcp`, stdio transport** — `refuseInternalFieldEvaluation` asks the protocol's face; `query` asks it with `where` and `orderBy`, `aggregate` with `where`, `groupBy` and `aggregations`, after the stored-metadata refusals (the door's order) and before the engine. The bridge header's "known divergences" gains this limb. `StdioProtocolObjectReads` declares the optional face. - **`@objectstack/runtime`, HTTP bridge** — `callData`'s `aggregate` arm (the `/mcp` `aggregate_records`) asks the same face before `engine.aggregate`. The `query` arm already reaches `findData`, unchanged. - **Every principal**, administrators and system context included, as at the door. The engine's privileged lookups that match on such a field (key, token, share-link verification) call the engine directly and never reach these mouths. - **Unchanged**: ordinary fields; the row strip (the flagged field is still absent from every row); the security middleware's `403 PERMISSION_DENIED` for a field the caller may not read; the HTTP bridge's `query_records` (it already met the gate). A host that registers no protocol service has no data door to agree with and is served as before — see Acceptance notes. ## Tests - `packages/metadata-protocol/src/protocol.data-door-internal-field-positions.test.ts` — new block: for each canonical query the MCP mouths can send (filter `$eq` / `$startsWith`, sort, aggregate `where`, group key, operand, aggregation filter, relation condition), the public face throws exactly what `findData` throws (code, status, object, field, param, message); ordinary fields pass it. - `packages/runtime/src/action-execution-calldata-aggregate-internal-field.test.ts` — `callData('aggregate')` over the REAL `ObjectStackProtocolImplementation` as the `protocol` service: each position × member / admin / system answers `findData`'s own refusal and the engine never aggregates; controls: ordinary fields reach the engine unchanged, and a host with no protocol is served as before. - `packages/mcp/src/stdio-data-bridge.internal-field-evaluation.test.ts` — the bridge asks the face with the call's own arguments before the engine, the refusal reaches the caller unchanged (and renders as the tool's `code` / `status` / message envelope), the engine is never asked; no protocol, or one without the face: served as before. - `packages/qa/dogfood/test/mcp-internal-field-evaluate-positions.dogfood.test.ts` — the real stack: showcase with its security and `MCPServerPlugin`, a runtime object with an `internal: true` field authored through `PUT /meta/object`, one stdio `MCPServerPlugin` per principal started with `autoStart` and its `OS_MCP_STDIO_API_KEY` minted through `POST /keys`. Seven positions × {stdio, HTTP `/mcp`} × {admin, member}: `isError`, `INVALID_FIELD`, `400`, and the message equal to what `POST /data/:object/query` answers for the same body in the same run. Controls per transport and principal: an ordinary field filters (narrowing one row of two), sorts, aggregates and groups; the flagged column is absent from every served row. ## Acceptance notes - **No-protocol hosts.** A host that registers no `protocol` service (`registerProtocol: false` and no `MetadataProtocolPlugin`) has no data door, so the gate has no verdict to share there and the MCP tools serve as before; the engine still refuses a group key or an aggregation operand on such a field (`rejectCredentialAggregation`). Both mouths say so in their comments; this PR does not make that composition refuse. Raised as an open question in the report. - **Group key / operand.** These were already refused on both transports by the engine, in the engine's own words; they now answer the door's sentence first, as REST does. The dogfood pin compares messages, so that change is measured, not assumed. - **Relation conditions.** A nested-relation condition is judged on its target by the same face (the target's own `internal` fields); the stdio bridge's own relation-target exposure gate still answers first, as before. ## Verification Final head `79470662f` — the branch with `origin/main` (`6cbdef1e4`) merged in; the merge touches none of this diff's files. Readings are from this worktree (`objectstack-ai/objectstack`), every exit code captured before any pipe, heavy runs under `scripts/pm/os-verify-lock.sh`. <details><summary>Targeted pins — on 7947066, all exit 0</summary> - `@objectstack/metadata-protocol` `src/protocol.data-door-internal-field-positions.test.ts`: Tests 50 passed (50). - `@objectstack/runtime` `src/action-execution-calldata-aggregate-internal-field.test.ts`: Tests 17 passed (17). - `@objectstack/mcp` `src/stdio-data-bridge.internal-field-evaluation.test.ts`: Tests 5 passed (5). - `@objectstack/dogfood` `test/mcp-internal-field-evaluate-positions.dogfood.test.ts`: Tests 32 passed (32). </details> <details><summary>Touched packages — full suites and typecheck, all exit 0</summary> - `@objectstack/metadata-protocol` on 7947066 (the merge brought changes into this package, so it was re-run there): Test Files 292 passed, 4 skipped (296); Tests 21963 passed, 26 skipped (21989); `typecheck` exit 0. - `@objectstack/mcp` on `8e5daeeca` (the merge changed nothing under `packages/mcp`): Test Files 49 passed (49); Tests 617 passed (617); `typecheck` (with its test layer) exit 0. - `@objectstack/runtime` on `8e5daeeca` (the merge changed nothing under `packages/runtime`), local project: Test Files 505 passed (505); Tests 8203 passed, 19 skipped (8222); `typecheck` (with its test layer) exit 0. The `repo` project is CI's (`Test Core`). - `@objectstack/dogfood` `typecheck` exit 0 (8e5daee). ESLint over the six changed TypeScript files on 7947066: exit 0. </details> <details><summary>Dogfood battery — on 7947066</summary> `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1`, closure built first, under the verify lock: lock VERDICT command-exit 0; Test Files 372 passed, 3 skipped (375); Tests 3026 passed, 28 skipped (3054); no failure. </details> <details><summary>One-shot ablation — on 7947066, cited here and not kept as a test</summary> - Mutation, through `scripts/ablation-replace.mjs` (anchor must hit): the stdio bridge's gate call and `callData`'s gate call each replaced by a `console.debug` marker — anchor 1 to 0, marker 0 to 1, blob changed, for both files; on-disk counts re-read inside the wrap. - `@objectstack/mcp` and `@objectstack/runtime` rebuilt; `scripts/ablation-dist-preflight.mjs` found each marker in the consumed `dist/` (2 built files each). - Dogfood pin: Tests 22 failed, 10 passed (32). Red: all 14 stdio rows and the 8 HTTP `aggregate_records` rows — `where` and the aggregation's own filter were SERVED; group key and operand answered in the engine's words, not the door's. Green: the 6 HTTP `query_records` rows (they reach `findData`) and the 4 controls. - Restore: each file's blob equals its HEAD blob and `git diff HEAD` is empty (both checked by the tool and again by the script); both packages rebuilt; `--absent` preflight: markers absent from every built file, working tree clean. </details> <details><summary>Gates — derived by dispatch-gates at 7947066</summary> - `node scripts/pm/dispatch-gates.mjs --commands`: 66 commands. Run with exit codes recorded; `--ran` reconciliation: 66 derived, 65 run, 1 NOT-MEASURED, 0 UNRUN. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: exit 3 PREREQUISITE NOT MET — it reads every package's `dist/` and this worktree built the dogfood closure only. CI's run is the reading. - `check:type-check-coverage` and `check:type-check-debt` ran inside the same hold (their closure build nested in it): exit 0, `--re-measure` 0 raw tsc errors. - `check:dts-closure` first answered exit 1 (`@objectstack/metadata-core` declarations missing) while that `--re-measure` was rebuilding the closure in the same hold; re-run on the settled `dist/`: exit 0, 241 of 241 declared declaration files present. - `check:slot-lookup` was red on the first sweep (an `any`-typed stub plugin context in the new dogfood file); fixed in `8e5daeeca`, green since. - Two gate scripts (`check-durability-degradation-log-level.mjs`, `pm/check-half-states.mjs`) changed on `origin/main` after this merge; the local readings are of this tree's copies. - CI check-runs: not yet available when this pull was opened. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc28644 commit b48eeb1

8 files changed

Lines changed: 795 additions & 6 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
fix(mcp,runtime)!: the MCP data tools refuse to filter, sort, group or aggregate on a field declared `internal: true`, with the data door's own 400 INVALID_FIELD, on the stdio transport and on the HTTP bridge's aggregate_records
8+
9+
Clause-②: no (narrowing) — the MCP stdio transport's `query_records` and `aggregate_records`, and the HTTP MCP bridge's `aggregate_records`, refuse a query that evaluates a field declared `internal: true`, which they served before. Nothing is widened.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata an author writes changes meaning: a query position the REST data door already refuses is now refused by the MCP data tools too, with the door's own refusal. The fix for a caller is to query another field, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing at the MCP data tools (`@objectstack/mcp`, `@objectstack/runtime`), on the gate the data door already applies (`@objectstack/metadata-protocol`).
15+
16+
- **Before.** A field declared `internal: true` was omitted from every row on every door, and the REST data door (and the HTTP bridge's `query_records`, which reaches it) refused to evaluate one: `400 INVALID_FIELD`. Three MCP mouths hand a caller's query to the engine without reaching that door, and evaluated it: the stdio transport's `query_records` answered a `where` on such a field (`$eq`, `$startsWith`, any operator) and an `orderBy` by it; the stdio transport's `aggregate_records` and the HTTP bridge's `aggregate_records` answered a `where` or an aggregation's own `filter` on it. A group key or an aggregation operand on it was refused by the engine there, in the engine's words.
17+
- **Now.** Each of the three asks the data door's gate (`assertNoInternalFieldEvaluated`, now public on the protocol service) before the engine, for every principal, administrators included, and answers what the door answers for the same query: `400 INVALID_FIELD`, naming the object, the field and the position, in the door's sentence. On MCP the tool result carries that `code`, `status` and message. No row is read.
18+
- **Unchanged.** A query over ordinary fields is served as before; the flagged field is still omitted from every row; a field the caller may not read is still the security middleware's `403 PERMISSION_DENIED`. The engine's privileged lookups that match on such a field (key, token and share-link verification) do not pass through these doors and are untouched. A host that registers no protocol service has no data door for these tools to agree with and serves them as before.
19+
- **Who sees it.** An MCP client that filters, sorts, groups or aggregates on a field its object declares `internal: true`. The built-in objects that declare one are platform `sys_*` objects, which the MCP data tools refuse by default (`allowSystemObjects`); where a host serves them, the same refusal now applies.
20+
21+
**The remedy.** Filter, sort, group and aggregate on another field. A field that must be queryable is not one to declare `internal: true`.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The stdio data bridge asks the data door's internal-field gate — the
5+
* `protocol` service's `assertNoInternalFieldEvaluated` — before its engine
6+
* reads anything, on `query` (the call's `where` and `orderBy`) and on
7+
* `aggregate` (`where`, `groupBy`, `aggregations`), and a refusal it throws
8+
* reaches the caller as thrown: the tool renders its `code`, `status` and
9+
* sentence, and the engine is never asked.
10+
*
11+
* The gate itself is the protocol's and is pinned there
12+
* (`@objectstack/metadata-protocol`, the public-face cases beside the data
13+
* door's position enumeration), and the whole path on a real stack in
14+
* `@objectstack/dogfood`. This file pins the bridge's half: that it asks, with
15+
* what, before what — and that a host whose protocol has no such face (or that
16+
* registers no protocol) is served exactly as before. Refusals are asserted on
17+
* the envelope, never on a bare throw.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
23+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
24+
import type { ExecutionContext } from '@objectstack/spec/kernel';
25+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
26+
import { createStdioDataBridge, type StdioProtocolObjectReads } from './stdio-data-bridge.js';
27+
import { registerObjectTools } from './mcp-http-tools.js';
28+
29+
const OBJECT = 'ifx_vault';
30+
const FLAGGED = 'pin';
31+
const DEF = { name: OBJECT, fields: { name: { type: 'text' }, [FLAGGED]: { type: 'text' } } };
32+
33+
/** The door's refusal as the protocol throws it (fields only; the sentence is the protocol's to pin). */
34+
function doorRefusal(param: string) {
35+
return Object.assign(new Error(`Cannot evaluate '${OBJECT}' by '${FLAGGED}' (${param}): the query was not run.`), {
36+
code: 'INVALID_FIELD', status: 400, httpStatus: 400, object: OBJECT, field: FLAGGED, fields: [FLAGGED], param,
37+
});
38+
}
39+
40+
/** A stand-in for the protocol's gate: refuses any query whose JSON names the flagged field. */
41+
function gate() {
42+
return vi.fn((_object: string, query: Record<string, unknown>) => {
43+
if (JSON.stringify(query).includes(`"${FLAGGED}"`)) throw doorRefusal('filter');
44+
});
45+
}
46+
47+
function makeBridge(protocol?: StdioProtocolObjectReads) {
48+
const engine = {
49+
find: vi.fn(async () => [{ id: 'r1', name: 'alpha' }]),
50+
aggregate: vi.fn(async () => [{ n: 1 }]),
51+
};
52+
const metadataService = { getObject: vi.fn(async (name: string) => (name === OBJECT ? DEF : undefined)) };
53+
const bridge = createStdioDataBridge({
54+
engine: engine as unknown as IDataEngine,
55+
metadataService: metadataService as unknown as IMetadataService,
56+
...(protocol ? { protocol: () => protocol } : {}),
57+
resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext,
58+
});
59+
return { bridge, engine };
60+
}
61+
62+
const refusalOf = async (run: () => Promise<unknown>) => {
63+
const err: any = await run().then(() => undefined, (e: unknown) => e);
64+
expect(err, 'the call resolved: the gate\'s refusal did not reach the caller').toBeDefined();
65+
return { code: err.code, status: err.status, field: err.field, object: err.object };
66+
};
67+
const REFUSED = { code: 'INVALID_FIELD', status: 400, field: FLAGGED, object: OBJECT };
68+
69+
describe('the stdio bridge asks the protocol\'s internal-field gate before the engine', () => {
70+
it('query: the gate is asked with the call\'s where and orderBy, and its refusal stops the read', async () => {
71+
const assertNoInternalFieldEvaluated = gate();
72+
const { bridge, engine } = makeBridge({ assertNoInternalFieldEvaluated });
73+
const where = { [FLAGGED]: { $startsWith: 'x' } };
74+
const orderBy = [{ field: 'name', order: 'asc' as const }];
75+
expect(await refusalOf(() => bridge.query(OBJECT, { where, orderBy, limit: 5 }))).toEqual(REFUSED);
76+
expect(assertNoInternalFieldEvaluated).toHaveBeenCalledWith(OBJECT, { where, orderBy });
77+
expect(engine.find).not.toHaveBeenCalled();
78+
79+
const sorted = [{ field: FLAGGED, order: 'desc' as const }];
80+
expect(await refusalOf(() => bridge.query(OBJECT, { orderBy: sorted }))).toEqual(REFUSED);
81+
expect(engine.find).not.toHaveBeenCalled();
82+
});
83+
84+
it('aggregate: the gate is asked with where, groupBy and aggregations, and its refusal stops the aggregate', async () => {
85+
const assertNoInternalFieldEvaluated = gate();
86+
const { bridge, engine } = makeBridge({ assertNoInternalFieldEvaluated });
87+
const bodies = [
88+
{ where: { [FLAGGED]: 'x' }, aggregations: [{ function: 'count' as const, alias: 'n' }] },
89+
{ groupBy: [FLAGGED], aggregations: [{ function: 'count' as const, alias: 'n' }] },
90+
{ aggregations: [{ function: 'max' as const, field: FLAGGED, alias: 'm' }] },
91+
{ aggregations: [{ function: 'count' as const, alias: 'n', filter: { [FLAGGED]: 'x' } }] },
92+
];
93+
for (const body of bodies) {
94+
expect(await refusalOf(() => bridge.aggregate!(OBJECT, body))).toEqual(REFUSED);
95+
expect(assertNoInternalFieldEvaluated).toHaveBeenLastCalledWith(OBJECT, {
96+
where: (body as { where?: unknown }).where,
97+
groupBy: (body as { groupBy?: unknown }).groupBy,
98+
aggregations: body.aggregations,
99+
});
100+
}
101+
expect(engine.aggregate).not.toHaveBeenCalled();
102+
});
103+
104+
it('CONTROL — a query the gate passes reaches the engine unchanged, on both verbs', async () => {
105+
const assertNoInternalFieldEvaluated = gate();
106+
const { bridge, engine } = makeBridge({ assertNoInternalFieldEvaluated });
107+
const where = { name: 'alpha' };
108+
await bridge.query(OBJECT, { where, orderBy: [{ field: 'name', order: 'asc' }] });
109+
expect(engine.find).toHaveBeenCalledTimes(1);
110+
expect((engine.find.mock.calls[0] as unknown[])[1]).toMatchObject({ where, orderBy: [{ field: 'name', order: 'asc' }] });
111+
await bridge.aggregate!(OBJECT, { where, groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] });
112+
expect(engine.aggregate).toHaveBeenCalledTimes(1);
113+
expect(assertNoInternalFieldEvaluated).toHaveBeenCalledTimes(2);
114+
});
115+
116+
it('CONTROL — no protocol, or one without the face: served as before (no door to agree with)', async () => {
117+
for (const protocol of [undefined, {} as StdioProtocolObjectReads]) {
118+
const { bridge, engine } = makeBridge(protocol);
119+
await bridge.query(OBJECT, { where: { [FLAGGED]: 'x' } });
120+
await bridge.aggregate!(OBJECT, { where: { [FLAGGED]: 'x' }, aggregations: [{ function: 'count', alias: 'n' }] });
121+
expect(engine.find).toHaveBeenCalledTimes(1);
122+
expect(engine.aggregate).toHaveBeenCalledTimes(1);
123+
}
124+
});
125+
126+
it('query_records and aggregate_records render the gate\'s refusal as its envelope', async () => {
127+
const { bridge, engine } = makeBridge({ assertNoInternalFieldEvaluated: gate() });
128+
const server = new McpServer({ name: 'pin', version: '0' });
129+
registerObjectTools(server, bridge);
130+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
131+
await server.connect(serverSide);
132+
const client = new Client({ name: 'pin', version: '0' });
133+
await client.connect(clientSide);
134+
try {
135+
for (const [name, args] of [
136+
['query_records', { objectName: OBJECT, where: { [FLAGGED]: 'x' } }],
137+
['aggregate_records', { objectName: OBJECT, groupBy: [FLAGGED], aggregations: [{ function: 'count', alias: 'n' }] }],
138+
] as const) {
139+
const res = (await client.callTool({ name, arguments: args })) as { isError?: boolean; content: Array<{ text: string }> };
140+
expect(res.isError, name).toBe(true);
141+
const { error } = JSON.parse(res.content[0]!.text) as { error: { code: unknown; status: unknown; message: string } };
142+
expect({ code: error.code, status: error.status }).toEqual({ code: 'INVALID_FIELD', status: 400 });
143+
expect(error.message).toBe(doorRefusal('filter').message);
144+
}
145+
expect(engine.find).not.toHaveBeenCalled();
146+
expect(engine.aggregate).not.toHaveBeenCalled();
147+
} finally {
148+
await client.close();
149+
}
150+
});
151+
});

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,20 @@
8989
* this engine-only reader served it as stored — to an administrator's key, a
9090
* member being refused by the engine as usual. The family's one projection is
9191
* now applied here and the evaluate shapes are refused; see
92-
* {@link storedMetadataBodyRefusal} and {@link serveStoredMetadataRows}. What
93-
* remains of the divergence above is genuinely not security.
92+
* {@link storedMetadataBodyRefusal} and {@link serveStoredMetadataRows}.
93+
*
94+
* ⚠️ A THIRD limb was security as well: the EVALUATE positions of a field
95+
* declared `internal: true`. The engine omits such a field from every row, but
96+
* it still evaluates one named in a filter or a sort, and the protocol's data
97+
* door is where those positions are refused — for every caller, administrators
98+
* included, before the engine is asked. This engine-only bridge evaluated them.
99+
* `query` and `aggregate` now ask that door's ONE gate of the `protocol`
100+
* service before the engine ({@link refuseInternalFieldEvaluation}), so a
101+
* filter, a sort, a group key or an aggregation over such a field answers the
102+
* door's `400 INVALID_FIELD`, word for word, on this transport as on REST and
103+
* on the HTTP bridge. ⛔ Never a copy of that gate here: a second definition is
104+
* the fork this note exists to close. What remains of the divergence above is
105+
* genuinely not security.
94106
*/
95107

96108
import { omitInternalFieldsFromWriteResponse, relationConditionSites } from '@objectstack/core';
@@ -136,8 +148,8 @@ import {
136148
} from './metadata-completeness.js';
137149

138150
/**
139-
* [#25138] The object reads this transport asks of the `protocol` service.
140-
* Every member is optional: the slot is filled by name from a host-owned
151+
* [#25138] The object reads this transport asks of the `protocol` service,
152+
* and the data door's internal-field gate beside them. Every member is optional: the slot is filled by name from a host-owned
141153
* registry, and a host may fill it with a partial object (the same duck-typed
142154
* probe `plugin.ts` applies to `getMetaItems` for the skill read).
143155
* `getMetaItemForExecution` is the server-only by-name face the REST exposure
@@ -151,6 +163,14 @@ export interface StdioProtocolObjectReads {
151163
getMetaItem?(request: { type: string; name: string }): Promise<unknown>;
152164
/** The by-name read without the serving decorations. */
153165
getMetaItemForExecution?(request: { type: string; name: string }): Promise<unknown>;
166+
/**
167+
* The data door's evaluate refusal for a field declared `internal: true`:
168+
* throws its `400 INVALID_FIELD` when `query` filters, sorts, groups or
169+
* aggregates on one, and returns otherwise. Not a read, but asked of the
170+
* same service at the same moment, so it rides this slot
171+
* ({@link refuseInternalFieldEvaluation}).
172+
*/
173+
assertNoInternalFieldEvaluated?(object: string, query: Record<string, unknown>): void;
154174
}
155175

156176
/** [#25138] Where this transport reads an object's definition from. */
@@ -551,6 +571,38 @@ async function enforceRelationTargetExposure(
551571
}
552572
}
553573

574+
/**
575+
* The data door's evaluate refusal for a field declared `internal: true`,
576+
* asked of the `protocol` service before the engine reads anything: the
577+
* protocol's own `assertNoInternalFieldEvaluated`, the gate `findData` asks
578+
* for REST and for the HTTP bridge's `query_records` — ⛔ never a copy of it
579+
* here (route-ownership rule 1). It throws that door's `400 INVALID_FIELD`,
580+
* naming the object, the field and the position in the door's own sentence,
581+
* for a filter (`where`, each aggregation's own `filter`, a nested-relation
582+
* condition judged on its target), a sort, a group key or an aggregation
583+
* operand on such a field — for every principal this transport admits. A
584+
* query over ordinary fields passes untouched.
585+
*
586+
* Asked whatever the caller's context, as the door asks it: the engine's
587+
* privileged lookups that legitimately match on such a field call the engine
588+
* directly and never reach this bridge.
589+
*
590+
* Structural absence answers nothing: on a host that registers no protocol,
591+
* or one whose protocol has no such face, there is no data door whose verdict
592+
* this transport could match, and the call proceeds to the engine as before.
593+
* An object the protocol's registry does not hold is likewise left to the
594+
* engine's existence answer, as the door leaves it.
595+
*/
596+
function refuseInternalFieldEvaluation(
597+
reads: StdioObjectReads,
598+
object: string,
599+
query: Record<string, unknown>,
600+
): void {
601+
const protocol = protocolOf(reads);
602+
if (typeof protocol?.assertNoInternalFieldEvaluated !== 'function') return;
603+
protocol.assertNoInternalFieldEvaluated(object, query);
604+
}
605+
554606
// ---------------------------------------------------------------------------
555607
// [#21207] The stored-metadata-body family, at this engine-only reader
556608
// ---------------------------------------------------------------------------
@@ -968,6 +1020,9 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
9681020
// [#21207] …and the same for a stored content-hash column.
9691021
const hashRefusal = storedMetadataHashRefusal(object, { where: opts?.where, orderBy: opts?.orderBy });
9701022
if (hashRefusal) throw hashRefusal;
1023+
// …and a filter or sort on an `internal: true` field, by the data door's
1024+
// own gate, after the stored-metadata refusals as the door orders them.
1025+
refuseInternalFieldEvaluation(reads, object, { where: opts?.where, orderBy: opts?.orderBy });
9711026
const read = storedMetadataBodyReadFields(object, opts?.fields);
9721027
const query: Record<string, unknown> = {};
9731028
if (opts?.where) query.where = opts.where;
@@ -1091,6 +1146,14 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
10911146
aggregations: opts?.aggregations,
10921147
});
10931148
if (hashRefusal) throw hashRefusal;
1149+
// …and a filter (`where` or an aggregation's own), a group key or an
1150+
// aggregation operand on an `internal: true` field, by the data door's
1151+
// own gate ({@link refuseInternalFieldEvaluation}).
1152+
refuseInternalFieldEvaluation(reads, object, {
1153+
where: opts?.where,
1154+
groupBy: opts?.groupBy,
1155+
aggregations: opts?.aggregations,
1156+
});
10941157
// No casts: `McpDataBridge.aggregate` declares the engine's own
10951158
// `EngineAggregateOptions` slices since #8032, so the honest call
10961159
// compiles — the two `as unknown as` casts this line used to carry

0 commit comments

Comments
 (0)