diff --git a/.changeset/27681-mcp-system-object-action-exposure.md b/.changeset/27681-mcp-system-object-action-exposure.md new file mode 100644 index 00000000000..c4848683f1d --- /dev/null +++ b/.changeset/27681-mcp-system-object-action-exposure.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +'@objectstack/mcp': patch +--- + +fix(mcp): an action on a `sys_*` object whose author exposed it to AI is listed and run over MCP, behind every gate the action already has + +Clause-②: yes (widening: MCP `list_actions` / `run_action` / `resume_run` now admit a `sys_*` action that declares `ai.exposed: true`; `McpActionSummary` gains the optional `aiExposed`) +Metadata-protocol: none +Ruling-ref: #27681 ruling `6513869839` letter B + +MCP refused every action on a `sys_*` object by its object name, ahead of the action's own AI opt-in, so no declaration could open one, while `POST /api/v1/actions/:object/:action` served the same action to the same caller. Now the name refusal yields to the action's own `ai: { exposed: true, description }` (ADR-0011), and to nothing else: + +- **Where.** `run_action`, `list_actions` and `resume_run`, in the runtime bridge and in the `@objectstack/mcp` tool layer. An agent that lists sees exactly the system-object actions it may run. +- **What still runs behind the flag, unchanged.** The ADR-0066 D4 capability gate, the ADR-0126 §8 activation switch, the declared param contract, the `ai.requiresConfirmation` confirmation (428 `ACTION_CONFIRMATION_REQUIRED` without `confirm: true`), the subject load under the caller's row-level security, and for a `type: 'api'` action the target endpoint's own authorization. +- **No deployment switch.** `allowSystemObjects` keeps its meaning (the tool layer's filter) and opens nothing in the runtime: a `sys_*` action without the declaration stays refused there. +- **Nothing changes on day one.** No platform `sys_*` action declares an `ai` block, so every one of them stays hidden and refused until its declaration lands. +- **`McpActionSummary.aiExposed`** (optional) is how a bridge attests the author's opt-in to the tool layer, which never sees the declaration. A bridge that does not set it keeps every `sys_*` action hidden and refused, as before. The runtime bridge sets it on every action it lists. diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 1a03cd2ecfd..c625069e3f9 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -49,7 +49,7 @@ bound to the caller's principal (the API key acts as the user): | Tool | What it does | |:---|:---| -| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, and its declared params. | +| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). | | `run_action` | Invokes an action by name with `{ recordId, params }`. Invocation is gated (author opt-in + capabilities); the action body then runs the app's registered logic as trusted code. | | `resume_run` | Continues a flow run that `run_action` answered with `status: "paused"` and a `screen`, by submitting that screen's field values as `{ runId, values }`. Only the user who started the run can resume it, behind the same gates as `run_action`. See [Completing a paused screen flow](#completing-a-paused-screen-flow). | @@ -101,8 +101,13 @@ data-layer backstop, is the boundary (#2849). The bridge walks every registered object's `actions[]` and offers only actions that are **AI-exposed** (`ai.exposed: true`), have a headless dispatch path, -**and** that the caller is permitted to run. System objects (`sys_*`) are held -back fail-closed. +**and** that the caller is permitted to run. An action on a system object +(`sys_*`) is offered only on its own `ai.exposed: true` — the object's name is +not a second opt-in to clear, and no deployment switch opens one that is not +flagged — and then runs behind every gate below, as on any other object (#27681). +The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are the one +exception: their actions stay refused even when flagged; read and change those +through the metadata API. | `action.type` | Dispatch path | Available where | |:---|:---|:---| diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index c3c0b0862f2..6f465cf02aa 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -252,6 +252,15 @@ can reach is the **default exposure policy (v1)**: whose `target` is on another origin — the browser calls that origin directly, so it is not exposed. See [Actions as Tools](/docs/ai/actions-as-tools) and [Actions](/docs/ui/actions). +- **An action on a `sys_*` object follows the same opt-in**, decided action by + action: it is listed and runs only when the action itself declares + `ai.exposed: true`, and then every gate behind the flag still runs (its + `requiredPermissions`, the installation's action switch, its param contract, + `ai.requiresConfirmation`, and the target's own authorization). The object + being `sys_*` opens nothing, and no deployment switch opens an unflagged one. + The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) stay + refused even when flagged. Each action `list_actions` returns carries + `aiExposed: true`, the server's attestation of that opt-in. This is the default policy, not a ceiling. A finer, **metadata-declared** exposure @@ -397,7 +406,7 @@ skill and a guided `/objectstack:connect` command. | stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback | | `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling | | `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope | -| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets a `sys_*` object (held back fail-closed for every type, api included — #27681 tracks that gap), or the caller fails its `requiredPermissions` | +| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable here (`script` with a body/handler, `flow` with an automation service, and `api` with a same-origin `target` are — `url`, `modal`, `form`, and an `api` action whose `target` is on another origin never appear), it targets `sys_metadata` / `sys_metadata_history` (refused even when flagged — use the metadata API), or the caller fails its `requiredPermissions` | | Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console | ## Related diff --git a/packages/mcp/src/mcp-action-system-object-exposure.test.ts b/packages/mcp/src/mcp-action-system-object-exposure.test.ts new file mode 100644 index 00000000000..81a83101d01 --- /dev/null +++ b/packages/mcp/src/mcp-action-system-object-exposure.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27681] The action tools' `sys_*` filter yields PER ACTION to the author's + * AI opt-in, which the bridge attests as `aiExposed` (ADR-0011). + * + * Maintainer ruling 6513869839 (letter B): a `sys_*` action is reachable over + * MCP only when the action itself declares `ai: { exposed: true, description }`. + * This package never sees a declaration, so before the ruling its fail-closed + * filter (`allowSystemObjects`, default false) hid and refused EVERY action on + * a `sys_*` object by name. It now lets one through exactly when the bridge's + * listing attests the opt-in — at `list_actions` AND at `run_action`, so an + * agent that lists sees exactly what it may run — and stays closed for a bridge + * that attests nothing. `allowSystemObjects` keeps its meaning and gains no + * reach (ruling parameter 1). + * + * Driven through `MCPServerRuntime.handleHttpRequest` over JSON-RPC, the path + * an external client hits. The bridge is a double; the runtime half (the + * bridge that reads the declaration, and the gates behind the flag) is pinned + * in `packages/runtime/src/mcp-system-object-action-exposure.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge, McpActionBridge, McpActionSummary } from './mcp-http-tools.js'; + +const SYS = 'sys_user'; + +/** The bridge attests the author's opt-in on this one. */ +const EXPOSED: McpActionSummary = { name: 'ban_user', objectName: SYS, type: 'api', requiresRecord: true, aiExposed: true }; +/** A `sys_*` entry the bridge listed WITHOUT attesting the opt-in. */ +const UNATTESTED: McpActionSummary = { name: 'unban_user', objectName: SYS, type: 'api', requiresRecord: true }; +/** Control: an ordinary object's action, untouched by this change. */ +const APP: McpActionSummary = { name: 'complete_task', objectName: 'todo_task', type: 'script', requiresRecord: true }; + +function makeBridge(listing: McpActionSummary[]): McpDataBridge & McpActionBridge & { runs: string[] } { + const runs: string[] = []; + return { + runs, + async listObjects() { return []; }, + async describeObject() { return null; }, + async query() { return { records: [] }; }, + async get() { return null; }, + async create() { return {}; }, + async update() { return {}; }, + async remove() { return {}; }, + async listActions() { return listing; }, + async runAction(name: string) { + runs.push(name); + return { ok: true, action: name }; + }, + }; +} + +const toolsCall = (id: number, name: string, args: Record) => ({ + jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args }, +}); + +async function call(bridge: ReturnType, body: unknown, allowSystemObjects?: boolean) { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + 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, + ...(allowSystemObjects !== undefined ? { toolOptions: { allowSystemObjects } } : {}), + }); + return (await res.json()) as any; +} + +const listedNames = async (bridge: ReturnType, allowSystemObjects?: boolean) => { + const json = await call(bridge, toolsCall(1, 'list_actions', {}), allowSystemObjects); + expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy(); + return (JSON.parse(json.result.content[0].text).actions as McpActionSummary[]).map((a) => a.name); +}; + +describe('[#27681] list_actions: a sys_* action is listed exactly when the bridge attests its opt-in', () => { + it('lists the attested sys_* action and hides the unattested one', async () => { + const names = await listedNames(makeBridge([EXPOSED, UNATTESTED, APP])); + expect(names).toEqual(['ban_user', 'complete_task']); + }); + + it('a bridge that attests nothing keeps every sys_* action hidden, as before', async () => { + const { aiExposed: _dropped, ...unattested } = EXPOSED; + expect(await listedNames(makeBridge([unattested, APP]))).toEqual(['complete_task']); + }); + + it('`allowSystemObjects: true` keeps its meaning: the tool layer lists every entry the bridge serves', async () => { + expect(await listedNames(makeBridge([EXPOSED, UNATTESTED, APP]), true)).toEqual(['ban_user', 'unban_user', 'complete_task']); + }); +}); + +describe('[#27681] run_action: a sys_* action runs only where list_actions lists it', () => { + it('REFUSES the unattested sys_* action before the bridge runs anything', async () => { + const bridge = makeBridge([EXPOSED, UNATTESTED]); + const json = await call(bridge, toolsCall(2, 'run_action', { actionName: 'unban_user', objectName: SYS, recordId: 'u1' })); + expect(json.result.isError).toBe(true); + expect(String(json.result.content[0].text)).toMatch(/system object "sys_user" is not exposed via MCP/); + expect(bridge.runs).toEqual([]); + }); + + it('REFUSES a sys_* action the bridge does not list at all (not exposed, or not permitted for this caller)', async () => { + const bridge = makeBridge([APP]); + const json = await call(bridge, toolsCall(3, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' })); + expect(json.result.isError).toBe(true); + expect(bridge.runs).toEqual([]); + }); + + it('a same-named attested action on ANOTHER object does not admit this one', async () => { + const bridge = makeBridge([{ ...EXPOSED, objectName: 'sys_member' }]); + const json = await call(bridge, toolsCall(4, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' })); + expect(json.result.isError).toBe(true); + expect(bridge.runs).toEqual([]); + }); + + it('ADMITS the attested sys_* action and hands it to the bridge, whose gates decide the rest', async () => { + const bridge = makeBridge([EXPOSED, UNATTESTED]); + const json = await call(bridge, toolsCall(5, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' })); + expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy(); + expect(bridge.runs).toEqual(['ban_user']); + }); + + it('CONTROL — an ordinary object\'s action is not screened against the listing', async () => { + const bridge = makeBridge([]); + const json = await call(bridge, toolsCall(6, 'run_action', { actionName: 'complete_task', objectName: 'todo_task', recordId: 't1' })); + expect(json.result.isError).toBeFalsy(); + expect(bridge.runs).toEqual(['complete_task']); + }); +}); diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 0015f6b5475..412f7d9b6db 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -26,7 +26,11 @@ * * SECURITY (zero-tolerance): * - System objects (`sys_*`) are NOT exposed by default — fail-closed guard on - * every tool that takes an object name, independent of the bridge. + * every tool that takes an object name, independent of the bridge. The + * action tools yield PER ACTION: a `sys_*` action whose author declared + * `ai.exposed: true` (the bridge attests it as `aiExposed`) is listed and + * run like any other exposed action (#27681, ADR-0011) — no other key and + * no deployment switch opens one. * - The bridge is bound to the caller's principal; tools cannot widen it. * - Errors are returned as tool errors (text), never thrown across the wire, * and never include secrets. @@ -223,6 +227,15 @@ export interface McpActionSummary { requiresRecord?: boolean; /** True when the action is destructive / an author flagged it for confirmation. */ requiresConfirmation?: boolean; + /** + * True when the action's author opted it into the AI surface + * (`ai: { exposed: true, description }`, ADR-0011), as the bridge read it off + * the declaration. With `allowSystemObjects` off, an action on a `sys_*` + * object is listed and run ONLY when this is `true` (#27681): the tool layer + * never sees the declaration, so a bridge that does not attest it keeps + * every system-object action hidden and refused, exactly as before. + */ + aiExposed?: boolean; /** Declared input parameters. */ params?: McpActionParamSummary[]; } @@ -277,7 +290,13 @@ export interface McpActionBridge { } export interface RegisterActionToolsOptions { - /** Expose actions on `sys_*` system objects too. Default false (fail-closed). */ + /** + * Expose actions on `sys_*` system objects too. Default false (fail-closed): + * a `sys_*` action is then listed and run only when the bridge attests its + * author's AI opt-in ({@link McpActionSummary.aiExposed}). This option opens + * the TOOL layer's filter only; the runtime bridge still requires the + * per-action opt-in for every `sys_*` action (#27681). + */ allowSystemObjects?: boolean; /** * OAuth 2.1 scopes granted to the caller's access token (#2698) — same @@ -294,6 +313,24 @@ function isSystemObject(name: string): boolean { return /^sys_/i.test(name); } +/** + * [#27681] Whether the action tools may list a summary with `allowSystemObjects` + * off: any action not on a `sys_*` object, and a `sys_*` one only when the + * bridge attests its author's `ai.exposed` opt-in. ONE predicate for + * `list_actions` and `run_action`, so the two cannot disagree about a system + * object's action. + */ +function isListableAction(action: McpActionSummary): boolean { + return !action.objectName || !isSystemObject(action.objectName) || action.aiExposed === true; +} + +function systemObjectActionRefusal(actionName: string, objectName: string): string { + return ( + `Action "${actionName}" on system object "${objectName}" is not exposed via MCP: a system object's action is ` + + 'reachable only when its author exposed it to AI (ai.exposed) and list_actions lists it for you' + ); +} + function textResult(value: unknown) { return { content: [{ type: 'text' as const, text: jsonText(value) }] }; } @@ -1225,9 +1262,7 @@ export function registerActionTools( async () => { try { const actions = await bridge.listActions(); - const visible = allowSystem - ? actions - : actions.filter((a) => !a.objectName || !isSystemObject(a.objectName)); + const visible = allowSystem ? actions : actions.filter(isListableAction); return textResult({ actions: visible, totalCount: visible.length }); } catch (err) { return errorResultWithCode(err); @@ -1322,10 +1357,19 @@ export function registerActionTools( if (!actionName || typeof actionName !== 'string') { return errorResult('actionName is required'); } - if (objectName && !allowSystem && isSystemObject(objectName)) { - return errorResult(`Object "${objectName}" is a system object and its actions are not exposed via MCP`); - } try { + // [#27681] A `sys_*` action runs only where `list_actions` would list + // it: the bridge's own listing is what attests the author's + // `ai.exposed` (see `isListableAction`). A name with no `objectName` + // is resolved by the bridge, whose runtime applies the same per-action + // rule to the object it resolves. + if (objectName && !allowSystem && isSystemObject(objectName)) { + const listed = await bridge.listActions(); + const admitted = listed.some( + (a) => a.name === actionName && a.objectName === objectName && isListableAction(a), + ); + if (!admitted) return errorResult(systemObjectActionRefusal(actionName, objectName)); + } const result = await bridge.runAction(actionName, { objectName, recordId, diff --git a/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts new file mode 100644 index 00000000000..49f502ad7c6 --- /dev/null +++ b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27681] An action on a `sys_*` object is reachable over MCP only when the +// action itself declares `ai: { exposed: true, description }` (ADR-0011) — +// pinned on a `sys_user` identity action, through the live MCP transport of a +// booted showcase with better-auth's admin plugin on and the real +// `/admin/ban-user` target. +// +// ## The ruling this file encodes +// +// Maintainer ruling 6513869839 (letter B). Before it, the runtime refused every +// action on a `sys_*` object by name, ahead of the exposure check, and the MCP +// tool layer hid and refused them again — while `POST /actions/sys_user/…` +// served the same action to the same administrator. Now the name refusal +// yields to the action's own opt-in at both MCP sites (`list_actions`, +// `run_action`), and every gate behind it runs unchanged. +// +// ## The declaration here is TEST-ONLY +// +// No platform `sys_*` action declares an `ai` block (ruling parameter 2, as +// amended: the declarations follow their own decision card). `ban_user` is +// given one for THIS boot only — set on the imported `SysUser` before the boot +// registers it, removed in `afterAll` — with `requiresConfirmation: true`, the +// shape the ruling requires for a destructive identity act. Its sibling +// `unban_user` keeps no `ai` block and is the unflagged control on the SAME +// object. +// +// ## What each case pins +// +// - `list_actions` lists `ban_user` (attested `aiExposed`) and not +// `unban_user`: the agent that lists sees exactly what it may run; +// - the unflagged `unban_user` is refused by name over MCP, with and +// without `objectName`; +// - HITL: an unconfirmed `ban_user` is refused 428 and the user stays +// unbanned; +// - the ADR-0126 §8 switch: off ⇒ 409 ACTION_DISABLED over MCP even when +// confirmed, user unbanned; +// - the target still authorizes: a plain member's confirmed call is refused +// and the user stays unbanned; +// - and the confirmed administrator call bans the user. +// +// `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without +// better-auth's admin plugin (see `api-action-door-forward.dogfood.test.ts`). +// The file is ISOLATED (an env toggle + a mutated platform declaration). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +const TARGET_EMAIL = 'mcp.sys.exposure.target@example.com'; +const MEMBER_EMAIL = 'mcp.sys.exposure.member@example.com'; + +/** The test-only opt-in, the shape the ruling requires on a destructive identity act. */ +const TEST_ONLY_AI = { + exposed: true, + description: 'Ban a user so they cannot sign in (dogfood test-only declaration).', + requiresConfirmation: true, +}; + +const banUserDeclaration = (): any => { + const found = ((SysUser as any).actions ?? []).find((a: any) => a?.name === 'ban_user'); + if (!found) throw new Error("sys_user no longer declares 'ban_user' — the fixture moved"); + return found; +}; + +describe('[#27681] a sys_user action the author exposed to AI is listed and run over MCP, behind every gate', () => { + let stack: VerifyStack; + let ql: any; + let admin: string; + let member: string; + let targetUserId: string; + let priorScim: string | undefined; + let priorAi: unknown; + + /** One `tools/call` over the live MCP transport; the tool's text content, parsed. */ + const tool = async (token: string, name: string, args: Record) => { + const res = await stack.api('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }), + }); + expect(res.status).toBe(200); + const rpc: any = await res.json(); + expect(rpc.error, JSON.stringify(rpc.error)).toBeUndefined(); + const text: string = String(rpc.result?.content?.[0]?.text ?? ''); + let payload: any; + try { payload = JSON.parse(text); } catch { payload = { message: text }; } + return { isError: rpc.result?.isError === true, payload, text }; + }; + const messageOf = (r: { payload: any; text: string }) => String(r.payload?.error?.message ?? r.payload?.message ?? r.text); + const isBanned = async () => { + const [row] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + return row?.banned === true || row?.banned === 1; + }; + const flip = async (enabled: boolean) => { + const res = await stack.apiAs(admin, 'POST', '/actions/_activation/sys_user/ban_user', { enabled }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + beforeAll(async () => { + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + const ban = banUserDeclaration(); + priorAi = ban.ai; + expect(priorAi, 'the platform now declares an ai block on ban_user — this fixture would mask it').toBeUndefined(); + ban.ai = TEST_ONLY_AI; + stack = await bootShowcase({ extraPlugins: [new MCPServerPlugin()] }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + await stack.signUp(TARGET_EMAIL, 'Target-Pass-123'); + member = await stack.signUp(MEMBER_EMAIL, 'Member-Pass-123'); + const [target] = rowsOf(await ql.find('sys_user', { where: { email: TARGET_EMAIL }, limit: 1 }, { context: SYS })); + targetUserId = String(target.id); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + const ban = banUserDeclaration(); + if (priorAi === undefined) delete ban.ai; + else ban.ai = priorAi; + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + it('list_actions lists the flagged ban_user (attested, confirmation-gated) and not the unflagged unban_user', async () => { + const listed = await tool(admin, 'list_actions', {}); + expect(listed.isError, listed.text).toBe(false); + const onUser = (listed.payload.actions as any[]).filter((a) => a.objectName === 'sys_user'); + expect(onUser.map((a) => a.name)).toEqual(['ban_user']); + expect(onUser[0]).toMatchObject({ aiExposed: true, requiresConfirmation: true, requiresRecord: true }); + }, 120_000); + + it('the unflagged unban_user stays refused by name, with and without objectName', async () => { + const scoped = await tool(admin, 'run_action', { actionName: 'unban_user', objectName: 'sys_user', recordId: targetUserId }); + expect(scoped.isError).toBe(true); + expect(messageOf(scoped)).toContain('system object "sys_user" is not exposed via MCP'); + + const byName = await tool(admin, 'run_action', { actionName: 'unban_user', recordId: targetUserId }); + expect(byName.isError).toBe(true); + expect(messageOf(byName)).toContain("is on a system object ('sys_user') and is not exposed via MCP"); + }, 120_000); + + it('HITL: an unconfirmed ban_user is refused 428 ACTION_CONFIRMATION_REQUIRED and the user is NOT banned', async () => { + const refused = await tool(admin, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: targetUserId, params: { banReason: 'dogfood' }, + }); + expect(refused.isError).toBe(true); + expect(refused.payload?.error).toMatchObject({ code: 'ACTION_CONFIRMATION_REQUIRED', status: 428 }); + expect(await isBanned(), 'an unconfirmed agent call banned the user').toBe(false); + }, 120_000); + + it('the packaged-action switch holds over MCP: ban_user OFF answers 409 ACTION_DISABLED even confirmed', async () => { + await flip(false); + try { + const refused = await tool(admin, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: targetUserId, confirm: true, + }); + expect(refused.isError).toBe(true); + expect(refused.payload?.error).toMatchObject({ code: 'ACTION_DISABLED', status: 409 }); + expect(await isBanned()).toBe(false); + } finally { + await flip(true); + } + }, 120_000); + + it('the target still authorizes: a plain member\'s confirmed call is refused and the user is NOT banned', async () => { + const refused = await tool(member, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: targetUserId, confirm: true, + }); + expect(refused.isError).toBe(true); + expect(await isBanned()).toBe(false); + }, 120_000); + + it('the confirmed administrator call runs through every gate and bans the user', async () => { + const ran = await tool(admin, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: targetUserId, params: { banReason: 'dogfood' }, confirm: true, + }); + expect(ran.isError, ran.text).toBe(false); + expect(ran.payload).toMatchObject({ ok: true, action: 'ban_user', objectName: 'sys_user', recordId: targetUserId }); + expect(await isBanned()).toBe(true); + }, 120_000); +}); diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts index 4ae827b0974..db73edc77d3 100644 --- a/packages/runtime/src/action-execution-destructive.test.ts +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -22,7 +22,8 @@ * That bridge gates every candidate action through THREE checks before * `summarizeAction` ever runs: * - * 1. fail-closed on `sys_*` objects (`isSystemObjectName`) + * 1. fail-closed on `sys_*` objects unless the action declares + * `ai.exposed: true` (`systemObjectActionExposureError`, #27681) * 2. `isHeadlessInvokableAction` — `type: 'script'` (with `target` or * `body`), `type: 'flow'` (with `target` + an automation service), or — * since #27506 — a `type: 'api'` action with a SAME-ORIGIN `target`, @@ -32,11 +33,13 @@ * * All 14 of #7309's identity actions lived on `sys_*` objects (13 still do), are * `type: 'api'` with same-origin targets, and none declares `ai.exposed`. So - * today's real declarations are excluded on TWO independent grounds before - * `actionLooksDestructive` ever runs on them (the #7828 triage comment's two; - * gate 2 was a third until #27506 admitted same-origin api actions, which is - * pinned below so the count of grounds is a measurement, not a memory) — this - * fix has zero observable effect on any request a caller can make today. + * today's real declarations are excluded before `actionLooksDestructive` ever + * runs on them (gate 2 was another ground until #27506 admitted same-origin api + * actions, which is pinned below so the count of grounds is a measurement, not + * a memory). Since #27681 gates 1 and 3 read ONE fact — the action's own + * `ai.exposed` — so the exclusion is one ground asserted at both gates, not two + * independent ones — this fix has zero observable effect on any request a + * caller can make today. * * That is a fact about *today's platform objects*, not about the heuristic — * a future `ai.exposed`, non-`sys_*`, `script`/`flow` action carrying only @@ -54,6 +57,7 @@ import { actionLooksDestructive, summarizeAction, isSystemObjectName, + systemObjectActionExposureError, actionAiExposureError, isHeadlessInvokableAction, type ActionExecutionDeps, @@ -311,8 +315,11 @@ describe('today, all 13 #7309 identity actions the platform still declares are e expect(allThirteen).toHaveLength(13); }); - it.each(allThirteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { + it.each(allThirteen)('%s is excluded by systemObjectActionExposureError (gate 1: sys_* fail-closed unless ai.exposed)', (_label, objName, obj, name) => { expect(isSystemObjectName(objName)).toBe(true); + // Since #27681 the name alone no longer decides: the action's own + // `ai.exposed` does, and none of the 13 declares it. + expect(systemObjectActionExposureError(deps, actionByName(obj, name), objName)).toMatch(/system object/); }); it.each(allThirteen)('%s is ADMITTED by isHeadlessInvokableAction since #27506 (gate 2: a same-origin api action is server-dispatched)', (_label, _objName, obj, name) => { diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ddb33cf5a40..397aef49c73 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -20,6 +20,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr // declaration its type names, so the refusal and the type cannot drift apart. import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { IHttpServer, IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it @@ -101,6 +102,50 @@ export function isSystemObjectName(name: string): boolean { return /^sys_/i.test(name); } +/** + * [#27681 / ADR-0011] The `sys_*` gate of the MCP action surface, PER ACTION. + * Returns a human-readable refusal for an action on a system object that its + * author did not opt into the AI surface, or `null` when the action may pass + * (a non-system object, or a system-object action declaring `ai.exposed: true`). + * + * Maintainer ruling 6513869839 (letter B): a `sys_*` action is reachable over + * MCP only when the action itself declares `ai: { exposed: true, description }`. + * The guard that stood here refused EVERY action on a `sys_*` object by name, + * ahead of the exposure check, so no declaration could open one, while the REST + * `/actions` door served the same action to the same principal. The refusal now + * yields to the author's opt-in and to nothing else: + * + * - no deployment switch reaches it (the MCP package's `allowSystemObjects` + * stays on the tool layer and gains no reach here — ruling parameter 1); + * - every later gate still runs behind it, unchanged: the ADR-0066 D4 + * capability gate, the ADR-0126 §8 activation consult, the param contract, + * the `ai.requiresConfirmation` gate and the subject load under RLS. + * + * ONE predicate for the three MCP sites that ask it — `run_action` + * ({@link invokeBusinessAction}), the `list_actions` bridge and the + * `resume_run` admission (`domains/mcp.ts`) — so an agent that lists sees + * exactly the system-object actions it may run. + */ +export function systemObjectActionExposureError(_deps: ActionExecutionDeps, action: any, objectName: string): string | null { + if (!isSystemObjectName(objectName)) return null; + // [#21594] The stored-metadata family is NOT opened by the flag: the MCP + // door never reaches the subject load for one of its tables (a row carries + // a stored metadata body, credential material included). That boundary + // predates the ruling, which opened identity actions and named no family + // table; it held here only because every `sys_*` action was refused. + if (isStoredMetadataBodyObject(objectName)) { + return ( + `Action '${action?.name ?? 'unknown'}' is on a system object ('${objectName}') that stores metadata bodies ` + + `and is never exposed via MCP — read and change those through the metadata API` + ); + } + if (action?.ai?.exposed === true) return null; + return ( + `Action '${action?.name ?? 'unknown'}' is on a system object ('${objectName}') and is not exposed via MCP — ` + + `a system object's action is reachable here only when its author declares \`ai: { exposed: true, description: … }\` on it` + ); +} + /** * Escape hatch: accept param bags that violate the declared contract, the way * the pre-17 dispatcher did (ADR-0104 D2, 2026-07-30 addendum). @@ -2175,6 +2220,12 @@ export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any ...(declarativeUpdate ? { operation: DECLARATIVE_UPDATE_OPERATION } : {}), requiresRecord: Boolean(requiresRecord), requiresConfirmation: actionLooksDestructive(deps, action), + // [#27681] The author's AI opt-in, attested by the bridge that read the + // declaration. The MCP tool layer reads it to let a `sys_*` action + // through its own fail-closed filter (`allowSystemObjects` off): it + // never sees the declaration, so this is the only way the per-action + // flag reaches it. Emitted only when declared, like `operation`. + ...(action?.ai?.exposed === true ? { aiExposed: true } : {}), ...(params.length > 0 ? { params } : {}), }; } @@ -3208,10 +3259,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, } const { action, objectName, obj } = resolved; - // Fail-closed on system-object actions (mirrors the object-tool guard). - if (isSystemObjectName(objectName)) { - throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`); - } + // Fail-closed on system-object actions, PER ACTION (#27681): a `sys_*` + // action passes only on its author's `ai.exposed` opt-in, and every gate + // below still runs for it. + const systemObjectError = systemObjectActionExposureError(deps, action, objectName); + if (systemObjectError) throw new Error(systemObjectError); const hasAutomation = Boolean(await resolveAutomationService(deps, requestContext, envId)); if (!isHeadlessInvokableAction(deps, action, hasAutomation)) { throw new Error( diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 8c4d15657b6..565d2d427ea 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -15,7 +15,6 @@ import type { ISecurityService } from '@objectstack/spec/contracts'; import type { AIActionConfirmation } from '@objectstack/spec/contracts'; import { buildApiError } from '../error-envelope.js'; import * as actionExec from '../action-execution.js'; -import { isSystemObjectName } from '../action-execution.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; import { readDispatchedBody } from '../deferred-request-body.js'; @@ -827,7 +826,11 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon deps, meta, await readServedObjects(), await readServedActions(), ); for (const { action, objectName, obj } of declarations) { - if (!objectName || isSystemObjectName(objectName)) continue; // fail-closed on sys_* + if (!objectName) continue; + // [#27681] Fail-closed on `sys_*` PER ACTION: the same predicate + // `run_action` asks, so an agent that lists sees exactly the + // system-object actions it may run (their author's `ai.exposed`). + if (actionExec.systemObjectActionExposureError(deps, action, objectName)) continue; if (!actionExec.isHeadlessInvokableAction(deps, action, hasAutomation)) continue; // The script arm — every action `invokeBusinessAction` sends to // the handler registry, i.e. neither the declarative update, nor @@ -1123,9 +1126,8 @@ function resumeAdmissionRefusal( activationEngine: any, ): Error | undefined { const { action, objectName } = candidate; - if (isSystemObjectName(objectName)) { - return resumeDoorError(deps, `Action '${action?.name}' is on a system object and is not exposed via MCP`, 403); - } + const systemObject = actionExec.systemObjectActionExposureError(deps, action, objectName); + if (systemObject) return resumeDoorError(deps, systemObject, 403); const exposure = actionExec.actionAiExposureError(deps, action, objectName); if (exposure) return resumeDoorError(deps, exposure, 403); const permission = actionExec.actionPermissionError(deps, action, ec, objectName); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 596f4b8bfdf..2a827ff05c7 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -4656,12 +4656,14 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () type: 'script', body: { language: 'js', source: 'return 1;' }, }; + // [#27681] Unflagged: since ruling 6513869839 a `sys_*` action is refused by + // its object only when it does not declare `ai.exposed` — the flagged case + // is pinned in `mcp-system-object-action-exposure.test.ts`. const standaloneOnSysObject = { name: 'rotate_all', objectName: 'sys_api_key', type: 'script', body: { language: 'js', source: 'return 1;' }, - ai: { exposed: true }, }; // Same key as the embedded `complete_task` declaration — the embedded one wins. const standaloneShadowing = { @@ -4715,7 +4717,7 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () expect(actions.find((a: any) => a.name === 'nightly_cleanup')).toMatchObject({ objectName: 'global' }); const names = actions.map((a: any) => a.name); expect(names).not.toContain('raw_reindex'); // ai.exposed absent → hidden (#2849) - expect(names).not.toContain('rotate_all'); // sys_* owner → hidden fail-closed + expect(names).not.toContain('rotate_all'); // unflagged sys_* owner → hidden fail-closed }); it('list_actions dedupes a standalone row that shadows an object-embedded declaration (embedded wins)', async () => { @@ -4749,7 +4751,7 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () expect(executeAction).not.toHaveBeenCalled(); }); - it('run_action blocks a standalone row owned by a system object', async () => { + it('run_action blocks an unflagged standalone row owned by a system object', async () => { const { bridge, executeAction } = makeStandaloneBridge({ userId: 'u1', systemPermissions: [] }, [standaloneOnSysObject]); await expect(bridge.runAction('rotate_all', {})).rejects.toThrow(/system object/i); expect(executeAction).not.toHaveBeenCalled(); diff --git a/packages/runtime/src/mcp-resume-run.test.ts b/packages/runtime/src/mcp-resume-run.test.ts index 53d6c99ab6c..627c35dd679 100644 --- a/packages/runtime/src/mcp-resume-run.test.ts +++ b/packages/runtime/src/mcp-resume-run.test.ts @@ -209,7 +209,11 @@ type Engine = ReturnType; * merged `action` listing alone fail, the object listing still served. */ function makeHarness( - opts: { actions?: any[]; standalone?: any[]; engine?: Engine; merged?: 'listing' | 'throws' | 'action-throws' } = {}, + opts: { + actions?: any[]; standalone?: any[]; engine?: Engine; merged?: 'listing' | 'throws' | 'action-throws'; + /** [#27681] The object the actions are declared on (a `sys_*` name for the system-object cases). */ + objectName?: string; + } = {}, ) { const engine = opts.engine ?? makeEngine(); const leads: Array<{ id: string; owner: string; name: string }> = [ @@ -218,7 +222,7 @@ function makeHarness( ]; let disabled = new Set(); const object = { - name: OBJECT, + name: opts.objectName ?? OBJECT, label: 'Lead', fields: {}, actions: opts.actions ?? [SCHEDULE, WIZARD, GATED, CONFIRMED, CONSOLE_ONLY], @@ -448,6 +452,27 @@ describe('MCP resumeRun — (b) a caller who may not resume is refused before th }); }); +describe('[#27681] MCP resumeRun — on a sys_* object, the same per-action gate as run_action', () => { + const SYS_OBJECT = 'sys_probe_lead'; + + it('a run of a flagged (`ai.exposed`) flow action resumes and completes', async () => { + const h = makeHarness({ objectName: SYS_OBJECT, actions: [SCHEDULE, CONSOLE_ONLY] }); + const runId = await startPaused(h); + const done = await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'Call back' } }); + expect(done).toMatchObject({ ok: true, action: SCHEDULE.name, objectName: SYS_OBJECT }); + expect(h.engine.resume).toHaveBeenCalledTimes(1); + }); + + it('a run of an UNFLAGGED one is refused with the system-object refusal (403), and the engine is not asked', async () => { + const h = makeHarness({ objectName: SYS_OBJECT, actions: [SCHEDULE, CONSOLE_ONLY] }); + const started: any = await h.engine.execute('merge_lead', { userId: 'u1', object: SYS_OBJECT, record: { id: 'lead_1' } }); + const refused = await refusalOf(h.bridgeFor('u1').resumeRun(started.runId, { values: { subject: 'x' } })); + expect(refused).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(refused.message).toMatch(/is on a system object \('sys_probe_lead'\) and is not exposed via MCP/); + expect(h.engine.resume).not.toHaveBeenCalled(); + }); +}); + describe('MCP resumeRun — (c) an unknown runId', () => { it('is refused 404, with exactly the envelope another user\'s run gets', async () => { const h = makeHarness(); diff --git a/packages/runtime/src/mcp-system-object-action-exposure.test.ts b/packages/runtime/src/mcp-system-object-action-exposure.test.ts new file mode 100644 index 00000000000..44d26602bbe --- /dev/null +++ b/packages/runtime/src/mcp-system-object-action-exposure.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27681] An action on a `sys_*` object is reachable over MCP only when the + * action itself declares `ai: { exposed: true, description }` (ADR-0011). + * + * Maintainer ruling 6513869839 (letter B). Before it, the runtime refused + * EVERY action on a `sys_*` object by name, ahead of the exposure check, at + * the three MCP sites — `run_action` (`invokeBusinessAction`), the + * `list_actions` bridge and the `resume_run` admission — so no declaration + * could open one, while the REST `/actions` door served the same action to the + * same principal. The name refusal now yields to the author's opt-in and to + * nothing else, and every gate behind it still runs. + * + * What each block pins, all through the bridge `domains/mcp.ts` builds (the + * one production `McpActionBridge`): + * + * - UNFLAGGED `sys_*` action: not listed, refused by `run_action` with the + * system-object refusal, nothing dispatched — the fail-closed default holds; + * - FLAGGED (test-only declaration) `sys_*` action: listed with `aiExposed`, + * and run through EVERY gate behind the flag — the ADR-0066 D4 capability + * gate, the ADR-0126 §8 activation consult, the `ai.requiresConfirmation` + * gate — before it dispatches; + * - the listing and the run door agree, action by action. + * + * No platform `sys_*` action declares an `ai` block (ruling parameter 2, + * amended: the declarations follow their own decision card). The objects here + * are test doubles, never the platform identity objects. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts'; +import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { + systemObjectActionExposureError, + ACTION_CONFIRMATION_REQUIRED_CODE, + ACTION_CONFIRMATION_REQUIRED_STATUS, + type ActionExecutionDeps, +} from './action-execution.js'; + +const SYS_OBJECT = 'sys_probe_account'; +const APP_OBJECT = 'probe_app_task'; + +/** Flagged: the author opted it into the AI surface. Test-only declaration. */ +const FLAGGED = { + name: 'probe_suspend', label: 'Suspend', type: 'script', target: 'probe_suspend', + ai: { exposed: true, description: 'Suspend a probe account.' }, +}; +/** Flagged AND author-gated for confirmation (the destructive shape). */ +const FLAGGED_GATED = { + name: 'probe_purge', label: 'Purge', type: 'script', target: 'probe_purge', mode: 'delete', + ai: { exposed: true, description: 'Purge a probe account.', requiresConfirmation: true }, +}; +/** Flagged, capability-gated: the ADR-0066 D4 gate must still run behind the flag. */ +const FLAGGED_CAPPED = { + name: 'probe_reset', label: 'Reset', type: 'script', target: 'probe_reset', + requiredPermissions: ['probe.reset'], + ai: { exposed: true, description: 'Reset a probe account.' }, +}; +/** UNFLAGGED: no `ai` block — the platform identity actions' shape today. */ +const UNFLAGGED = { name: 'probe_lock', label: 'Lock', type: 'script', target: 'probe_lock' }; +/** Control: an exposed action on an ordinary object, unaffected by this change. */ +const APP_ACTION = { + name: 'probe_complete', label: 'Complete', type: 'script', target: 'probe_complete', + ai: { exposed: true, description: 'Complete a task.' }, +}; + +const SYS_ACTIONS = [FLAGGED, FLAGGED_GATED, FLAGGED_CAPPED, UNFLAGGED]; + +function harness(opts: { disabled?: readonly string[]; permissions?: readonly string[] } = {}) { + const objects = [ + { name: SYS_OBJECT, label: 'Probe Account', fields: {}, actions: SYS_ACTIONS }, + { name: APP_OBJECT, label: 'Probe Task', fields: {}, actions: [APP_ACTION] }, + ]; + const byName = (n: string) => objects.find((o) => o.name === n); + const handlers = new Set([ + ...SYS_ACTIONS.map((a) => `${SYS_OBJECT}:${a.target}`), + `${APP_OBJECT}:${APP_ACTION.target}`, + ]); + const executeAction = vi.fn(async (obj: string, key: string) => { + if (!handlers.has(`${obj}:${key}`)) throw new Error(`Action '${key}' on object '${obj}' not found`); + return { ran: `${obj}:${key}` }; + }); + const disabled = new Set(opts.disabled ?? []); + const ql: any = { + executeAction, + listRegisteredActions: () => [...handlers].map((k) => ({ + objectName: k.slice(0, k.indexOf(':')), actionName: k.slice(k.indexOf(':') + 1), + })), + isActionEnabled: (name: string) => !disabled.has(name), + describeDisabledAction: (name: string) => `Action '${name}' is switched off for this installation.`, + getSchema: byName, + registry: { getObject: byName, getItem: () => undefined }, + find: vi.fn(async () => [{ id: 'acct_1' }]), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => objects), + getObject: vi.fn(async (n: string) => byName(n)), + }; + const kernel: any = { + context: { + getService: (n: string) => (n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null), + }, + }; + const ctx: any = { + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_agent', permissions: [], systemPermissions: [...(opts.permissions ?? [])] }, + }; + const bridge = (new HttpDispatcher(kernel) as any).buildMcpBridge(ctx); + return { bridge, executeAction }; +} + +/** A refusal is asserted on its envelope, never on the throw alone. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('expected a refusal, the call ran'); +} + +describe('[#27681] the per-action predicate', () => { + const deps = { resolveService: (async () => undefined) as any, getObjectQL: async () => null } as ActionExecutionDeps; + + it('refuses an unflagged action on a sys_* object, and only there', () => { + expect(systemObjectActionExposureError(deps, UNFLAGGED, SYS_OBJECT)).toMatch(/system object/); + expect(systemObjectActionExposureError(deps, UNFLAGGED, APP_OBJECT)).toBeNull(); + }); + + it('does NOT yield on a stored-metadata table: the family stays refused even when flagged (#21594)', () => { + for (const table of STORED_METADATA_BODY_OBJECTS) { + expect(systemObjectActionExposureError(deps, { ...FLAGGED, name: 'probe_on_family' }, table)).toMatch(/stores metadata bodies/); + } + }); + + it('yields to `ai.exposed: true` and to nothing else — a truthy non-boolean is not the opt-in', () => { + expect(systemObjectActionExposureError(deps, FLAGGED, SYS_OBJECT)).toBeNull(); + const stringly = { ...UNFLAGGED, ai: { exposed: 'true', description: 'x' } }; + expect(systemObjectActionExposureError(deps, stringly, SYS_OBJECT)).toMatch(/system object/); + }); +}); + +describe('[#27681] list_actions: a sys_* action is listed exactly when its author exposed it', () => { + it('lists the flagged sys_* actions (attested `aiExposed`) and hides the unflagged one', async () => { + const { bridge } = harness({ permissions: ['probe.reset'] }); + const listed: any[] = await bridge.listActions(); + const sys = listed.filter((a) => a.objectName === SYS_OBJECT).map((a) => a.name).sort(); + expect(sys).toEqual(['probe_purge', 'probe_reset', 'probe_suspend']); + for (const a of listed) expect(a.aiExposed, `${a.name} listed without the attestation`).toBe(true); + // Control: the ordinary object's exposed action is listed as before. + expect(listed.map((a) => a.name)).toContain('probe_complete'); + }); + + it('a flagged sys_* action the caller lacks the capability for is NOT listed (D4 still filters behind the flag)', async () => { + const { bridge } = harness(); + const names = (await bridge.listActions()).map((a: any) => a.name); + expect(names).toContain('probe_suspend'); + expect(names).not.toContain('probe_reset'); + }); +}); + +describe('[#27681] run_action: the unflagged sys_* action stays refused', () => { + it('refuses with the system-object refusal and dispatches nothing', async () => { + const { bridge, executeAction } = harness(); + const err = await refusalOf(bridge.runAction('probe_lock', { objectName: SYS_OBJECT, recordId: 'acct_1' })); + expect(String(err?.message)).toMatch(/is on a system object \('sys_probe_account'\) and is not exposed via MCP/); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('refuses it when resolved by name alone, too (no objectName to pre-screen on)', async () => { + const { bridge, executeAction } = harness(); + const err = await refusalOf(bridge.runAction('probe_lock', { recordId: 'acct_1' })); + expect(String(err?.message)).toMatch(/system object/); + expect(executeAction).not.toHaveBeenCalled(); + }); +}); + +describe('[#27681] run_action: the flagged sys_* action runs through every gate behind the flag', () => { + it('runs when every gate admits it', async () => { + const { bridge, executeAction } = harness(); + await expect(bridge.runAction('probe_suspend', { objectName: SYS_OBJECT, recordId: 'acct_1' })) + .resolves.toMatchObject({ ok: true, action: 'probe_suspend', objectName: SYS_OBJECT }); + expect(executeAction).toHaveBeenCalledTimes(1); + }); + + it('the ADR-0066 D4 capability gate still refuses a caller without the capability', async () => { + const { bridge, executeAction } = harness(); + const err = await refusalOf(bridge.runAction('probe_reset', { objectName: SYS_OBJECT, recordId: 'acct_1' })); + expect(String(err?.message)).toMatch(/requires capability \[probe\.reset\]/); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('the ADR-0126 §8 activation consult still refuses a switched-off action (409 ACTION_DISABLED)', async () => { + const { bridge, executeAction } = harness({ disabled: ['probe_suspend'] }); + const err = await refusalOf(bridge.runAction('probe_suspend', { objectName: SYS_OBJECT, recordId: 'acct_1' })); + expect(err).toMatchObject({ code: 'ACTION_DISABLED', status: 409 }); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('the HITL confirmation gate refuses an unconfirmed destructive call (428), and runs the confirmed one', async () => { + const { bridge, executeAction } = harness(); + const err = await refusalOf(bridge.runAction('probe_purge', { objectName: SYS_OBJECT, recordId: 'acct_1' })); + expect(err).toMatchObject({ + code: ACTION_CONFIRMATION_REQUIRED_CODE, + status: ACTION_CONFIRMATION_REQUIRED_STATUS, + details: { actionName: 'probe_purge', objectName: SYS_OBJECT, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER }, + }); + expect(executeAction).not.toHaveBeenCalled(); + + await expect(bridge.runAction('probe_purge', { + objectName: SYS_OBJECT, recordId: 'acct_1', [AI_ACTION_CONFIRMATION_MEMBER]: true, + })).resolves.toMatchObject({ ok: true, action: 'probe_purge' }); + expect(executeAction).toHaveBeenCalledTimes(1); + }); +});