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
13 changes: 13 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'realtime-delivery-authz', summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)', state: 'experimental',
covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'],
note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.' },

// ── ADR-0096 — MCP execution-surface identity admission (#3167). The MCP
// server exposes ObjectStack tool execution over two transports with DIFFERENT
// identity postures; both are pinned here so a refactor can't silently change
// either. (Corrects #3167's premise that the HTTP admission was missing — it
// is wired; the real gap is the opt-in stdio transport.)
{ id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced',
enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data',
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). NOT marked high-risk here only because its end-to-end dogfood proof (anonymous → 401; principal → RLS/FLS-scoped tool results) lands with the dev-default-wiring follow-up (#3167 PR-B), not because the surface is latent — it is a live route. Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it.' },
{ id: 'mcp-stdio-authority', summary: 'MCP stdio transport runs UNSCOPED — the long-lived server bridges the raw metadata service + data engine with no per-request principal (opt-in: autoStart / OS_MCP_SERVER_ENABLED=true)', state: 'experimental',
covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'],
note: 'Surface posture: process-authority, opt-in. Unlike the HTTP path (mcp-http-identity), MCPServerPlugin.start() bridges resources/tools onto the long-lived this.mcpServer from the RAW metadata service + data engine (bridgeResources(metadataService, dataEngine)) — there is no ExecutionContext, so a stdio-attached client reads metadata + records with full, unscoped authority (no RLS/FLS/tenant). This is safe ONLY as a single-operator LOCAL tool: the operator who can attach stdio already owns the process and its dev database, so the transport grants nothing they lack. It is deliberately NOT default (the shouldStart gate in plugin.ts keeps stdio opt-in, stricter than the default-on HTTP surface). ADMISSION REQUIREMENT before stdio is ever promoted to default-on OR served in a multi-user / hosted context: thread a principal (a configured service identity, or a per-session ExecutionContext) into the long-lived bridge, mirroring the HTTP path\'s buildMcpBridge. Changing the raw-service bridging makes the unscoped-stdio key STALE → forces re-classification in CI.' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
{ id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders', state: 'enforced',
Expand Down
49 changes: 49 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,32 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
re: /['"`][^'"`]*\/realtime[^'"`]*['"`]/g,
key: () => 'realtime:rest-server.ts:route(TRANSPORT-WIRED)',
},

// ── ADR-0096 / #3167 — MCP execution-surface identity pins ─────────────
// (1) The HTTP `/mcp` handler must stay classified (a new sibling MCP data
// handler → UNCLASSIFIED). (2) Its caller-identity threading: handleMcp must
// build the tool bridge FROM the request context (carrying the caller EC).
// Drop the threading (or build a system/unscoped bridge for HTTP) → the
// context-threaded key vanishes → the mcp-http-identity row goes STALE → red CI.
{
file: 'packages/runtime/src/http-dispatcher.ts',
re: /async\s+handleMcp\s*\(/g,
key: () => 'mcp:http-dispatcher.ts:handleMcp',
},
{
file: 'packages/runtime/src/http-dispatcher.ts',
re: /this\.buildMcpBridge\(context\)/g,
key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)',
},
// (3) The stdio transport's UNSCOPED data bridge: the long-lived server is fed
// the raw metadata service + data engine with no principal. Wrapping these in
// a principal-bound bridge (the admission fix) changes this line → the
// unscoped-stdio key goes STALE → forces re-classifying mcp-stdio-authority.
{
file: 'packages/mcp/src/plugin.ts',
re: /bridgeResources\(metadataService, dataEngine\)/g,
key: () => 'mcp:plugin.ts:bridgeResources(unscoped-stdio)',
},
];

/** Statically enumerate the anonymous-deny HTTP entry points from source. */
Expand Down Expand Up @@ -211,4 +237,27 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
);
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true);
});

// ── ADR-0096 / #3167 — the MCP identity pins bite too ──────────────────
it('(f) dropping the MCP HTTP context-thread → STALE covers failure (#3167)', () => {
const threaded = 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)';
// Baseline sanity: the HTTP `/mcp` handler threads the caller EC today.
expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true);
const problems = checkLedger(
AUTHZ_CONFORMANCE,
opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== threaded))),
);
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true);
});

it('(g) the stdio unscoped-bridge posture is pinned; changing it goes STALE (#3167)', () => {
const stdio = 'mcp:plugin.ts:bridgeResources(unscoped-stdio)';
// Baseline sanity: the long-lived server bridges the raw services today.
expect(discoverAnonymousDenySurfaces().has(stdio)).toBe(true);
const problems = checkLedger(
AUTHZ_CONFORMANCE,
opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== stdio))),
);
expect(problems.some((p) => /STALE covers/.test(p) && p.includes(stdio))).toBe(true);
});
});