Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/oauth-agent-runs-as-the-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/plugin-security": minor
"@objectstack/spec": minor
"@objectstack/mcp": minor
"@objectstack/runtime": minor
---

fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549)

Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.**

**The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server:

| identity path | `crm_account` | `crm_opportunity` | `crm_task` |
|:--|--:|--:|--:|
| API key, `principalKind: human` | 9 | 23 | 45 |
| OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** |

The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data.

**The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared.

**(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have.

⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead.

**(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before.

**(3)** The Setup page's promise is untouched — it is now true rather than rewritten.

Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth.

`DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released:

| direction, after release | consumer cost |
|:--|:--|
| ship optional fields, later tighten them to required | a compile break |
| ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none |

The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself.
12 changes: 6 additions & 6 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **110
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
Expand Down Expand Up @@ -133,7 +133,7 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **17 of the 109 sites**.
The largest single consumer — **17 of the 110 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -279,7 +279,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 110 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand Down Expand Up @@ -353,12 +353,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 23 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 116 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–63 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **110** | ✅ |
| — behaviour-bearing (rows 1–63 above) | 106 | ✅ |
| — carry the flag onward only (rows 64–67 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
57 changes: 56 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ export interface McpDataBridge {
orderBy?: Array<{ field: string; order: 'asc' | 'desc' }>;
},
): Promise<unknown>;
/**
* [ADR-0090 D10 — maintainer ruling 2026-09-08, #16549] Is a read on `object`
* NARROWED by the agent ceiling the caller runs under?
*
* The transport half of the ruling's consequence (2), and it exists because
* the deceived consumer on this surface is the **AI itself**: a delegated
* `query_records` answering `total: 0` with no note is read by the agent as a
* fact about the data, and it then tells a decision-maker "there are no
* opportunities this quarter". A narrowed count must arrive WITH the
* narrowing stated.
*
* OPTIONAL, with the same graceful-degradation contract as
* {@link McpDataBridge.listObjectsDiagnosed} and
* {@link McpDataBridge.aggregate}: a bridge bound to a principal that cannot
* be delegated (the stdio API-key host), or wired to a security service
* predating the probe, omits this member and the tool renders exactly what it
* rendered before.
*
* ⛔ `{ narrowed: false }` and "cannot say" are deliberately the same RENDERED
* outcome — neither may manufacture a warning. The tool states a narrowing
* only where one was established.
*/
diagnoseDelegation?(object: string): Promise<{ narrowed: boolean; statement?: string } | undefined>;
get(object: string, id: string): Promise<unknown>;
create(object: string, data: Record<string, unknown>): Promise<unknown>;
update(object: string, id: string, data: Record<string, unknown>): Promise<unknown>;
Expand Down Expand Up @@ -231,6 +254,26 @@ function errorResult(message: string) {
return { content: [{ type: 'text' as const, text: message }], isError: true as const };
}

/**
* [ADR-0090 D10 — ruling 2026-09-08, consequence 2] Attach the D10 narrowing
* notice to a query result WITHOUT reshaping it.
*
* The bridge's `query` is typed `Promise<unknown>`, and the shape that ships is
* the protocol's `{ object, records, total }` — so the object case adds two
* keys beside the existing ones, mirroring `list_objects`' `partial` / `warning`
* pair so a client branches on the same vocabulary on both tools. Anything that
* is NOT a plain object (a bare array from some future bridge) keeps its own
* shape and carries the notice alongside: inventing a `records` wrapper there
* would break a caller to deliver a warning about breaking callers.
*/
function withDelegationNotice(value: unknown, statement: string): unknown {
const notice = { delegationNarrowed: true as const, warning: statement };
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>), ...notice };
}
return { result: value, ...notice };
}

function jsonText(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
Expand Down Expand Up @@ -494,7 +537,19 @@ export function registerObjectTools(
offset,
orderBy,
});
return textResult(result);
// [ADR-0090 D10 — ruling 2026-09-08, consequence 2] A delegated read
// the agent ceiling NARROWED is served with the narrowing stated. The
// rows are still served — a partial answer is the most useful true
// thing here, exactly as in `list_objects` above; what is withheld is
// the implicit claim that this count describes the object.
//
// ⛔ The probe may never fail the query it annotates: it is caught
// into "no statement", which is byte-for-byte the pre-#16549 render.
const diagnosed = typeof bridge.diagnoseDelegation === 'function'
? await bridge.diagnoseDelegation(objectName).catch(() => undefined)
: undefined;
if (!diagnosed?.narrowed || !diagnosed.statement) return textResult(result);
return textResult(withDelegationNotice(result, diagnosed.statement));
} catch (err) {
return errorResult(messageOf(err));
}
Expand Down
115 changes: 115 additions & 0 deletions packages/mcp/src/query-records-delegation-diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0090 D10 — maintainer ruling 2026-09-08, #16549 consequence 2]
* A delegated `query_records` NARROWED by the agent ceiling says so; an
* un-narrowed one does not.
*
* This is the half of the ruling that is INDEPENDENT of the parity half, and
* the half that matters most, because the deceived consumer on this surface is
* the AI itself. Measured on the card: `query_records` answered `total: 0` with
* no note, and the agent faithfully told a decision-maker "there are no
* opportunities this quarter" — a wrong answer delivered with full confidence,
* from a tool that was working exactly as specified.
*
* The rows are still served. What the notice adds is the one fact the payload
* could not previously carry: this count describes the CEILING, not the object.
* The shape mirrors `list_objects`' `partial` / `warning` pair (#6504) so a
* client branches on the same vocabulary across both tools.
*/

import { describe, it, expect, beforeEach } from 'vitest';

import { MCPServerRuntime } from './mcp-server-runtime.js';
import type { McpDataBridge } from './mcp-http-tools.js';

const NARROWING = 'This result was narrowed by the ADR-0090 D10 intersection: rows are ABSENT from this result.';

function makeBridge(
diagnose?: McpDataBridge['diagnoseDelegation'],
): McpDataBridge & { calls: string[] } {
const calls: string[] = [];
const bridge: any = {
calls,
async listObjects() { return [{ name: 'crm_opportunity', label: 'Opportunity' }]; },
async describeObject(name: string) { return { name }; },
async query(object: string) {
calls.push('query');
return { object, records: [], total: 0 };
},
async get(object: string, id: string) { return { object, id }; },
async create(object: string, data: any) { return { object, data }; },
async update(object: string, id: string) { return { object, id }; },
async remove(object: string, id: string) { return { object, id, success: true }; },
};
if (diagnose) bridge.diagnoseDelegation = diagnose;
return bridge;
}

async function queryRecords(runtime: MCPServerRuntime, bridge: any): Promise<any> {
const body = {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'query_records', arguments: { objectName: 'crm_opportunity' } },
};
const req = new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body),
});
const res = await runtime.handleHttpRequest(req, { bridge, parsedBody: body });
const json: any = await res.json();
expect(json.result?.isError).toBeFalsy();
return JSON.parse(json.result.content[0].text);
}

describe('query_records — ADR-0090 D10 delegated-read diagnostic (#16549)', () => {
let runtime: MCPServerRuntime;

beforeEach(() => {
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
});

it('a NARROWED delegated read carries the D10 statement beside the rows', async () => {
const bridge = makeBridge(async () => ({ narrowed: true, statement: NARROWING }));
const payload = await queryRecords(runtime, bridge);
// The rows are still served — a partial answer is the most useful true
// thing here. What is added is the withheld claim.
expect(payload.object).toBe('crm_opportunity');
expect(payload.records).toEqual([]);
expect(payload.total).toBe(0);
expect(payload.delegationNarrowed).toBe(true);
expect(payload.warning).toBe(NARROWING);
});

it('an UN-NARROWED delegated read carries NO statement — the payload is byte-identical to before', async () => {
const bridge = makeBridge(async () => ({ narrowed: false }));
const payload = await queryRecords(runtime, bridge);
expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 });
expect(payload.delegationNarrowed).toBeUndefined();
expect(payload.warning).toBeUndefined();
});

it('a bridge that cannot answer (no member at all) renders exactly what it rendered before', async () => {
// The stdio API-key host and any deployment whose security service predates
// the probe. ⛔ Absence must never be rendered as a warning — "cannot say"
// and "nothing to say" are deliberately the same rendered outcome.
const bridge = makeBridge(undefined);
const payload = await queryRecords(runtime, bridge);
expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 });
});

it('a THROWING probe never fails the query it annotates', async () => {
const bridge = makeBridge(async () => { throw new Error('security service exploded'); });
const payload = await queryRecords(runtime, bridge);
expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 });
expect(bridge.calls).toContain('query');
});

it('narrowed:true with NO statement adds nothing — a notice with no sentence is not a notice', async () => {
const bridge = makeBridge(async () => ({ narrowed: true }));
const payload = await queryRecords(runtime, bridge);
expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 });
});
});
Loading
Loading