From 47d5d27a65d18eb1cc676f51a387689a1fb90384 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:31:03 +0000 Subject: [PATCH 1/3] fix(security): enforce ai.exposed on the MCP action surface, fail-closed (#2849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business-action bodies execute as trusted code: buildActionEngineFacade carries no ExecutionContext, so a body's reads/writes hit the security plugin's empty-principal skip and bypass RLS/FLS/CRUD/tenant scoping — the caller's permissions and an agent's ADR-0090 D10 data ceiling do not bound what an invoked action does internally. The MCP run_action bridge nevertheless dispatched ANY headless-invokable action, ignoring the spec's ai.exposed governance gate (ADR-0011). - list_actions/run_action now fail-closed on ai.exposed: only actions the author explicitly opted into the AI surface are enumerable and invokable via MCP. - Flow-type actions receive a proper AutomationContext (record/object/ userId/positions/permissions/tenantId/params) instead of the former triggerData envelope the engine never read, so runAs:'user' flows enforce RLS as the invoker instead of running unscoped (ADR-0049/#1888). - Trusted body dispatch is audit-logged on both the MCP and REST action paths, and the facades' trusted (SECURITY-DEFINER-like) semantics are documented at the source. - Corrected the MCP tool descriptions, README, and agents docs that claimed action bodies run under the caller's RLS (the #2845 framing): the agent-facing boundary is ai.exposed + actions:execute + the capability gate, not the data ceiling. Longer-term direction (action-level runAs:'user'|'system' mirroring flows) stays tracked in #2849. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155bjzJZxZaZTKSMSvg6Aum --- .../security-2849-action-ai-exposure-gate.md | 28 +++++ content/docs/ai/agents.mdx | 15 ++- examples/app-todo/test/mcp-actions.e2e.ts | 15 +++ packages/mcp/README.md | 30 +++-- packages/mcp/src/mcp-http-tools.ts | 21 ++-- packages/runtime/src/http-dispatcher.test.ts | 61 +++++++++- packages/runtime/src/http-dispatcher.ts | 104 ++++++++++++++++-- 7 files changed, 238 insertions(+), 36 deletions(-) create mode 100644 .changeset/security-2849-action-ai-exposure-gate.md diff --git a/.changeset/security-2849-action-ai-exposure-gate.md b/.changeset/security-2849-action-ai-exposure-gate.md new file mode 100644 index 0000000000..bfa75dd842 --- /dev/null +++ b/.changeset/security-2849-action-ai-exposure-gate.md @@ -0,0 +1,28 @@ +--- +'@objectstack/runtime': patch +'@objectstack/mcp': patch +--- + +fix(security): enforce the `ai.exposed` opt-in on the MCP action surface (#2849) + +Business-action bodies execute as trusted code: their engine facade carries no +`ExecutionContext`, so a body's internal reads/writes bypass RLS/FLS/CRUD and +tenant scoping — the caller's permissions and an agent's ADR-0090 D10 data +ceiling do NOT bound what an invoked action does. The MCP `run_action` bridge +nevertheless allowed invoking ANY headless action, ignoring the spec's +`ai.exposed` governance gate (ADR-0011) entirely. + +The MCP bridge now fail-closes on `ai.exposed`: `list_actions` only enumerates +— and `run_action` only dispatches — actions the app author explicitly opted +into the AI surface with `ai: { exposed: true, description }`. Flow-type +actions additionally receive the caller's identity (`userId` / `positions` / +`permissions` / `tenantId`) as a proper `AutomationContext` (replacing the +former `triggerData` envelope the engine never read), so a `runAs: 'user'` +flow enforces RLS as the invoker instead of running unscoped (ADR-0049). +Trusted body dispatches are now audit-logged on both the MCP and REST action +paths, and the MCP tool/README/docs wording no longer claims action bodies run +under the caller's RLS. + +Migration: actions that should stay invokable by AI agents through MCP must +declare `ai: { exposed: true, description: '…' }` (≥40-char description). All +other invocation surfaces (UI, REST `/actions/...`) are unchanged. diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index 94c082e2e2..c332864c6b 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -57,13 +57,18 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting. pre-registered with Anthropic or any central service, so **self-hosted and private deployments work out of the box**. You authorize the client in the browser; it then acts as an **agent on your behalf** (`principalKind: 'agent'`), - bounded by the **intersection** of the consent scopes and your own - permissions/RLS — it can never exceed either. The consent scopes - (`data:read`, `data:write`, `actions:execute`) are a real ceiling, not just a + its **data access** bounded by the **intersection** of the consent scopes and + your own permissions/RLS — it can never exceed either. For object CRUD the + consent scopes (`data:read`, `data:write`) are a real ceiling, not just a tool-family filter: they narrow the exposed tools **and** cap what the agent can do at the data layer — a `data:read` token can never write a record even - where you could, and `actions:execute` lets the agent invoke the business - actions you are entitled to run. + where you could. **Business actions are gated differently** (#2849): + `actions:execute` + the action's declared capabilities decide *which* actions + the agent may invoke, and only actions the app author explicitly exposed to + AI (`ai: { exposed: true }`) are invokable at all — but an invoked action's + *body* runs as trusted application code with the app's full data authority, + **not** under the agent's data ceiling. The author's AI opt-in, not the data + layer, is the boundary for what actions can do. - **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and send it as `x-api-key` — for CI, scripts, and agents without a browser. diff --git a/examples/app-todo/test/mcp-actions.e2e.ts b/examples/app-todo/test/mcp-actions.e2e.ts index 092fa40d8c..b978b235fd 100644 --- a/examples/app-todo/test/mcp-actions.e2e.ts +++ b/examples/app-todo/test/mcp-actions.e2e.ts @@ -165,6 +165,21 @@ function mcpRequest(body: unknown): Request { const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[]; check(allowList.some((a) => a.name === 'clone_task'), 'list_actions reveals the gated action to a holder'); + // ── Step 6 — AI-exposure gate (#2849) end-to-end ─────────────────── + console.log('\n🔒 Step 6 — an action without ai.exposed is hidden and uninvokable'); + // Same app, but clone_task no longer opts into the AI surface. + const unexposedObjects = mergedObjects.map((o) => + o.name !== 'todo_task' + ? o + : { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? (({ ai: _ai, ...rest }: any) => rest)(a) : a)) }, + ); + const unexposedBridge = bridgeFor({ userId: 'user_3', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, unexposedObjects); + const unexposedList = JSON.parse((await callMcp(unexposedBridge, toolsCall(9, 'list_actions', {}))).result.content[0].text).actions as any[]; + check(!unexposedList.some((a) => a.name === 'clone_task'), 'list_actions hides an action the author did not expose to AI'); + const unexposedRun = await callMcp(unexposedBridge, toolsCall(10, 'run_action', { actionName: 'clone_task', recordId: taskId })); + check(unexposedRun.result?.isError === true, 'run_action refuses the unexposed action (fail-closed)'); + check(/not exposed to AI/i.test(unexposedRun.result?.content?.[0]?.text ?? ''), 'refusal names the AI-exposure gate'); + console.log('\n────────────────────────────────────────────────────────────────────────────────'); if (failures > 0) { console.error(`❌ MCP action E2E FAILED — ${failures} check(s) failed`); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index acf5f66028..9dbf62d5fb 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -115,13 +115,22 @@ the open framework. ``` `list_actions` enumerates each object's headless-invokable actions (script / -flow), filtered to what the caller is permitted to run — declared -`requiredPermissions` (ADR-0066 D4) are enforced and `sys_*`-object actions are -held back fail-closed. `run_action` resolves the action by name and dispatches -it through the framework's own action mechanism (`engine.executeAction` / -automation flow runner) as the caller, so a BYO-AI MCP client (Claude Code, -Cursor, …) can trigger real business logic — e.g. "complete this task", -"convert this lead" — exactly as the UI would, under the same guardrails. +flow), filtered to what the author exposed and the caller may run: only actions +opted into the AI surface (`ai: { exposed: true }`, ADR-0011 / #2849) are +listed, declared `requiredPermissions` (ADR-0066 D4) are enforced, and +`sys_*`-object actions are held back fail-closed. `run_action` resolves the +action by name and dispatches it through the framework's own action mechanism +(`engine.executeAction` / automation flow runner), so a BYO-AI MCP client +(Claude Code, Cursor, …) can trigger real business logic — e.g. "complete this +task", "convert this lead". + +> **Security model (#2849):** gating happens at *invoke* time (`ai.exposed` + +> capability gate + record-context loads under the caller's RLS). Once invoked, +> a script/body action executes as **trusted application code** — its internal +> reads/writes carry the app's full data authority and are *not* bounded by the +> caller's RLS/FLS. Expose an action to AI only when its body is safe to run on +> behalf of anyone allowed through the gate. Flow actions honour the flow's +> `runAs` declaration (ADR-0049) with the caller's identity forwarded. ### Custom Tools @@ -301,8 +310,11 @@ TLS is required for OAuth (localhost is exempt, per OAuth 2.1). Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable. Coarse scopes (`data:read`, `data:write`, `actions:execute`) narrow the exposed tool -families at consent time; permissions/RLS still bind every call to the -logged-in user. +families at consent time; permissions/RLS bind every *object CRUD* call to the +logged-in user. Business actions are the exception: `actions:execute` gates +*which* actions may be invoked (author AI opt-in + capabilities), but an +invoked action's body runs as trusted app code, not under the caller's RLS +(#2849). **API key — the headless track (CI, scripts, background agents).** Mint a key (`POST /api/v1/keys`, shown once) and send it as a header — no browser diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index cb5e63873a..4f13834d0d 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -381,9 +381,15 @@ export function registerObjectTools( * caller's principal. * * Symmetry with the object tools is deliberate — like `list_objects`/CRUD, the - * action surface exposes the *mechanism* and relies on the bridge's - * permission + RLS enforcement (not a separate AI opt-in flag) for safety, - * with `sys_*`-scoped actions held back fail-closed by default. + * action surface exposes the *mechanism* and delegates enforcement to the + * bridge, with `sys_*`-scoped actions held back fail-closed by default. + * + * SECURITY (#2849): unlike object CRUD — where every call is RLS/FLS-bounded — + * an action's body executes as TRUSTED app code once invoked. The bridge + * therefore gates at invoke time: `ai.exposed` (the author's explicit AI + * opt-in, ADR-0011) + the ADR-0066 D4 capability gate. The earlier design + * ("no separate AI opt-in flag; rely on permission + RLS enforcement") was + * revised, because there is no data-layer backstop inside an action body. */ export function registerActionTools( server: McpServer, @@ -403,8 +409,8 @@ export function registerActionTools( description: 'List the business actions you can invoke in this app (e.g. "complete task", "convert lead"). ' + 'Returns each action\'s name, the object it operates on, a description, whether it needs a record id, ' + - 'whether it is destructive, and its input parameters. Only actions the caller is permitted to run are returned. ' + - 'Use run_action to invoke one.', + 'whether it is destructive, and its input parameters. Only actions the app author has exposed to AI ' + + 'and that the caller is permitted to run are returned. Use run_action to invoke one.', inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, @@ -425,8 +431,9 @@ export function registerActionTools( 'run_action', { description: - 'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic ' + - 'under the caller\'s permissions and row-level security — this can mutate data or trigger flows. ' + + 'Invoke a business action by name (see list_actions). Runs the app\'s registered business logic — ' + + 'this can mutate data or trigger flows. Invocation is gated (author AI opt-in + your capabilities), ' + + 'but the action body itself runs as trusted application code with the app\'s full data authority. ' + 'Supply recordId for actions that operate on a specific record, and params for any declared inputs.', inputSchema: { actionName: z.string().describe('The action name from list_actions, e.g. "complete_task"'), diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index cca5e5ec2c..20999d65ee 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2350,6 +2350,7 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () type: 'script', target: 'issueLicense', requiredPermissions: ['manage_platform_settings'], + ai: { exposed: true, description: 'Issue a license for the current tenant.' }, }; const modalAction = { name: 'defer_task', @@ -2358,11 +2359,20 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () type: 'modal', target: 'defer_modal', }; + // Invokable script the author did NOT expose to AI (`ai.exposed` absent) — + // must be invisible + fail-closed on the MCP surface (#2849). + const unexposedAction = { + name: 'internal_cleanup', + label: 'Internal Cleanup', + objectName: 'todo_task', + type: 'script', + target: 'internalCleanup', + }; const todoObject = { name: 'todo_task', label: 'Task', fields: { subject: { type: 'text', label: 'Subject' }, status: { type: 'select', label: 'Status' } }, - actions: [completeAction, gatedAction, modalAction], + actions: [completeAction, gatedAction, modalAction, unexposedAction], }; // A system object carrying an action — must be hidden + fail-closed. const sysObject = { @@ -2410,15 +2420,35 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () return { bridge, executeAction, store }; }; - it('list_actions returns only invokable, permitted, non-system actions', async () => { + it('list_actions returns only AI-exposed, invokable, permitted, non-system actions', async () => { const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); const names = (await bridge.listActions()).map((a: any) => a.name); - expect(names).toContain('complete_task'); // script + permitted + expect(names).toContain('complete_task'); // script + exposed + permitted expect(names).not.toContain('issue_license'); // gated, caller lacks the capability expect(names).not.toContain('defer_task'); // modal = UI-only, no headless path + expect(names).not.toContain('internal_cleanup'); // ai.exposed absent → hidden (#2849) expect(names).not.toContain('rotate'); // sys_api_key → hidden fail-closed }); + // [#2849 / ADR-0011] The AI-exposure gate is the real agent-facing boundary: + // action bodies run TRUSTED (context-less engine, RLS/FLS-bypassing), so an + // action the author never opted into the AI surface must be uninvokable — + // fail-closed, never reaching the handler. + it('run_action refuses an action the author did not expose to AI (ai.exposed absent)', async () => { + const { bridge, executeAction } = makeBridge({ userId: 'u1', systemPermissions: [] }); + await expect(bridge.runAction('internal_cleanup', { recordId: 't1' })).rejects.toThrow(/not exposed to AI/i); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('run_action refuses an unexposed action even for an AGENT holding every capability', async () => { + const { bridge, executeAction } = makeBridge({ + userId: 'u1', principalKind: 'agent', onBehalfOf: { userId: 'u1' }, + systemPermissions: ['manage_platform_settings'], + }); + await expect(bridge.runAction('internal_cleanup', { recordId: 't1' })).rejects.toThrow(/not exposed to AI/i); + expect(executeAction).not.toHaveBeenCalled(); + }); + it('list_actions surfaces record-context + summary metadata', async () => { const { bridge } = makeBridge({ userId: 'u1', systemPermissions: [] }); const complete = (await bridge.listActions()).find((a: any) => a.name === 'complete_task'); @@ -2508,6 +2538,7 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () type: 'flow', target: 'escalation_flow', locations: ['record_header'], + ai: { exposed: true, description: 'Escalate a ticket to the on-call team.' }, }; const makeFlowBridge = (execCtx: any, automation: any) => { const flowObject = { ...{ name: 'todo_task', label: 'Task', fields: {} }, actions: [flowAction] }; @@ -2547,9 +2578,31 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () const res = await bridge.runAction('escalate_ticket', { recordId: 't1', params: { reason: 'sla' } }); expect(res.ok).toBe(true); expect(ql.executeAction).not.toHaveBeenCalled(); // flow path, not executeAction + // A proper AutomationContext (not the former `triggerData` envelope the + // engine never read): record + object + explicit params (winning on clash). + expect(execute).toHaveBeenCalledWith( + 'escalation_flow', + expect.objectContaining({ + record: expect.objectContaining({ id: 't1' }), + object: 'todo_task', + params: expect.objectContaining({ reason: 'sla' }), + }), + ); + }); + + // [#2849 / ADR-0049] The caller's identity must reach the flow engine so a + // `runAs:'user'` flow enforces RLS as the invoker instead of falling into the + // user-less UNSCOPED (fail-open) path. + it('run_action forwards the caller identity (userId/positions/tenantId) into the flow context', async () => { + const execute = vi.fn(async () => ({ success: true })); + const { bridge } = makeFlowBridge( + { userId: 'u1', positions: ['support_rep'], tenantId: 'org_1', systemPermissions: [] }, + { execute }, + ); + await bridge.runAction('escalate_ticket', { recordId: 't1' }); expect(execute).toHaveBeenCalledWith( 'escalation_flow', - expect.objectContaining({ triggerData: expect.objectContaining({ action: 'escalate_ticket', params: { reason: 'sla' } }) }), + expect.objectContaining({ userId: 'u1', positions: ['support_rep'], tenantId: 'org_1' }), ); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 61ed2137b6..ce4f305912 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -748,9 +748,12 @@ export class HttpDispatcher { // ── Business-action surface (McpActionBridge) ────────────── // Resolution + dispatch flow through the framework's own action - // mechanism (engine.executeAction / automation flow runner) bound to - // THIS request's ExecutionContext — the same permission + RLS path - // the REST `/actions/...` route uses. No `@objectstack/service-ai`. + // mechanism (engine.executeAction / automation flow runner). All + // gating is at INVOKE time — `ai.exposed` (author opt-in, #2849) + + // the ADR-0066 D4 capability gate + record load under the caller's + // RLS. Script/body handlers then run TRUSTED (see + // buildActionEngineFacade); flows honour `runAs` with the caller's + // identity forwarded. No `@objectstack/service-ai`. listActions: async () => { const meta: any = await getMeta(); const objs: any[] = (await meta?.listObjects?.()) ?? []; @@ -765,6 +768,11 @@ export class HttpDispatcher { for (const action of actions) { if (!action || typeof action.name !== 'string') continue; if (!this.isHeadlessInvokableAction(action, hasAutomation)) continue; + // [#2849 / ADR-0011] MCP is an AI surface: only actions the + // author explicitly opted in via `ai.exposed` are listed. + // Fail-closed — bodies run as trusted code (see + // buildActionEngineFacade), so author opt-in is the boundary. + if (this.actionAiExposureError(action)) continue; // Hide actions the caller is not permitted to run. if (this.actionPermissionError(action, ec)) continue; out.push(this.summarizeAction(action, obj, objectName)); @@ -805,6 +813,27 @@ export class HttpDispatcher { ); } + /** + * [#2849 / ADR-0011] AI-exposure gate for the MCP action surface. Returns a + * human-readable error string unless the action's author explicitly opted it + * into the AI surface with `ai.exposed: true`, or `null` when exposed. + * + * This gate is the REAL agent-facing boundary for actions: script/body + * handlers execute as TRUSTED application code (the engine facade carries no + * ExecutionContext — see {@link buildActionEngineFacade}), so once invoked, a + * body's reads/writes are NOT bounded by the caller's RLS/FLS or an agent's + * data ceiling (ADR-0090 D10). The author's explicit opt-in — not a data-layer + * backstop — therefore decides what AI may trigger. Fail-closed by default. + */ + private actionAiExposureError(actionDef: any, objectName?: string): string | null { + if (actionDef?.ai?.exposed === true) return null; + const on = objectName ? ` on '${objectName}'` : ''; + return ( + `Action '${actionDef?.name ?? 'unknown'}'${on} is not exposed to AI — ` + + `the app author must opt it in with \`ai: { exposed: true, description: … }\`` + ); + } + /** * Whether an action has a headless invocation path (so MCP can run it). * Mirrors the supported-type set of the (now cloud-side) action-tools @@ -893,7 +922,18 @@ export class HttpDispatcher { return out; } - /** Slim engine facade matching the ActionContext.engine shape handlers expect. */ + /** + * Slim engine facade matching the ActionContext.engine shape handlers expect. + * + * ⚠️ TRUSTED (SECURITY-DEFINER-like) BY DESIGN (#2849): these calls carry NO + * ExecutionContext, so the data engine's security middleware skips RLS / FLS / + * CRUD / tenant scoping entirely. Action bodies are the app author's own code + * and legitimately perform cross-object writes the invoking user could not + * (convert-lead, cascade-close). The boundary is therefore enforced at INVOKE + * time (`ai.exposed` + ADR-0066 D4 capability gate), and every dispatch is + * audit-logged. Longer-term direction: an action-level `runAs: 'user'|'system'` + * mirroring flows (ADR-0049) — tracked in #2849. + */ private buildActionEngineFacade(ql: any): any { return { async insert(object: string, data: Record): Promise<{ id: string }> { @@ -922,11 +962,19 @@ export class HttpDispatcher { /** * Resolve + invoke a business action by its declarative name for the MCP - * `run_action` tool. Enforces the ADR-0066 D4 capability gate and RLS as the - * caller, loads the subject record under RLS for row-context actions, and - * dispatches through the framework's `engine.executeAction` (script/body) or - * automation flow runner (flow). Throws on denial / not-found / handler - * failure so the tool surfaces a clean tool-error. No service-ai dependency. + * `run_action` tool. Enforces the AI-exposure gate (`ai.exposed`, #2849), the + * ADR-0066 D4 capability gate, loads the subject record under the caller's + * RLS for row-context actions, and dispatches through the framework's + * `engine.executeAction` (script/body) or automation flow runner (flow). + * Throws on denial / not-found / handler failure so the tool surfaces a + * clean tool-error. No service-ai dependency. + * + * SECURITY MODEL (#2849): all gating happens at INVOKE time. A script/body + * handler then runs as trusted code — its engine facade performs + * context-less reads/writes that bypass RLS/FLS (SECURITY-DEFINER-like), so + * the caller's permissions and an agent's ADR-0090 D10 data ceiling do NOT + * bound what the body does internally. Flow actions differ: the flow engine + * receives the caller's identity below and honours `runAs` (ADR-0049). */ private async invokeBusinessAction( name: string, @@ -966,6 +1014,12 @@ export class HttpDispatcher { ); } + // [#2849 / ADR-0011] AI-exposure gate — fail-closed. Bodies run trusted + // (unbounded by the caller's RLS or an agent's data ceiling), so only + // actions the author explicitly exposed to AI may be invoked here. + const exposureError = this.actionAiExposureError(action, objectName); + if (exposureError) throw new Error(exposureError); + // ADR-0066 D4 capability gate — same declaration the REST route enforces. const gateError = this.actionPermissionError(action, ec, objectName); if (gateError) throw new Error(gateError); @@ -993,8 +1047,21 @@ export class HttpDispatcher { if (!automation || typeof automation.execute !== 'function') { throw new Error(`Action '${name}' is a flow but no automation service is available`); } + // Pass a proper AutomationContext (the engine never read the former + // `triggerData` envelope). Forwarding the caller's identity is what + // lets a `runAs:'user'` flow enforce RLS as the invoker instead of + // falling into the user-less UNSCOPED path (#2849, ADR-0049 / #1888; + // mirrors the record-change trigger's context shape). const result: any = await automation.execute(action.target, { - triggerData: { record, params, user, action: action.name }, + record, + ...(objectName !== 'global' ? { object: objectName } : {}), + userId: ec?.userId, + ...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}), + ...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}), + ...(ec?.tenantId ? { tenantId: ec.tenantId } : {}), + // Record fields seed flows' named `isInput` variables (like the + // record-change trigger); explicit action params win on clash. + params: { ...record, ...params }, }); if (result && typeof result === 'object' && 'success' in result && result.success === false) { throw new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`); @@ -1007,6 +1074,13 @@ export class HttpDispatcher { if (!ql || typeof ql.executeAction !== 'function') { throw new Error('Data engine not available for action dispatch'); } + // [#2849] Trusted-mode elevation must be AUDIBLE: the body's engine + // facade bypasses RLS/FLS, so record who triggered which action. + console.info( + `[action-audit] MCP run_action '${action.name}' on '${objectName}' — body executes TRUSTED ` + + `(context-less engine, RLS/FLS-bypassing) for user '${ec?.userId ?? 'anonymous'}'` + + (ec?.principalKind === 'agent' ? ` (AGENT on behalf of '${ec?.onBehalfOf?.userId ?? 'unknown'}')` : ''), + ); const actionContext: any = { record, user, @@ -3497,7 +3571,9 @@ export class HttpDispatcher { } if (record && (record as any).id == null && recordId) (record as any).id = recordId; - // Slim engine facade matching the ActionContext.engine shape used by CRM handlers. + // Slim engine facade matching the ActionContext.engine shape used by CRM + // handlers. ⚠️ TRUSTED — context-less, RLS/FLS-bypassing by design; see + // buildActionEngineFacade for the full security-model rationale (#2849). const engineFacade = { async insert(object: string, data: Record): Promise<{ id: string }> { const res = await ql.insert(object, data); @@ -3544,6 +3620,12 @@ export class HttpDispatcher { params: { ...reqParams, recordId, objectName }, }; + // [#2849] Same trusted-mode elevation as the MCP path — keep it audible. + console.info( + `[action-audit] REST action '${objectName}/${actionName}' — body executes TRUSTED ` + + `(context-less engine, RLS/FLS-bypassing) for user '${userFromAuth.id}'`, + ); + try { // Try object-specific first; on "not found" error, fall back to wildcard. let result: any; From 20d0e934138c153f693d12ef2f0ff3c9768d97ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:32:31 +0000 Subject: [PATCH 2/3] docs(mcp): align the generated SKILL.md action wording with the #2849 security model Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155bjzJZxZaZTKSMSvg6Aum --- packages/mcp/src/skill.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/skill.ts b/packages/mcp/src/skill.ts index 3b0d9ada32..6e0f4f6f46 100644 --- a/packages/mcp/src/skill.ts +++ b/packages/mcp/src/skill.ts @@ -137,14 +137,16 @@ create/update payload. - **update_record({ objectName, recordId, data })** — change fields on a record. - **delete_record({ objectName, recordId })** — delete a record (destructive — confirm with the user first). -- **list_actions()** — list the business actions you are permitted to run, with - each action's declared parameters, whether it operates on a record, and - whether it is flagged destructive. +- **list_actions()** — list the business actions available to you (only those + the app author exposed to AI and you are permitted to run), with each + action's declared parameters, whether it operates on a record, and whether + it is flagged destructive. - **run_action({ actionName, objectName?, recordId?, params? })** — invoke a - business action by name. This executes the app's registered logic (can - mutate data or trigger flows) under your permissions and RLS. Pass - \`recordId\` for record-scoped actions and \`params\` for declared inputs; - \`objectName\` only disambiguates a name shared by multiple objects. + business action by name. Invocation is permission-gated, but the action body + executes the app's registered logic as trusted code (it can mutate data or + trigger flows with the app's own authority). Pass \`recordId\` for + record-scoped actions and \`params\` for declared inputs; \`objectName\` only + disambiguates a name shared by multiple objects. ## Conventions & gotchas From db3830a548bf153d0d6bb5d174bb3cafb357f0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:42:15 +0000 Subject: [PATCH 3/3] docs(ai): correct actions-as-tools framing for the #2849 security model The open MCP path now gates on ai.exposed (not just permissions), and action bodies run trusted rather than under the caller's RLS. Rewrite the Actions-as-Tools doc to match: flip the 'open path does NOT use ai.exposed' callout, distinguish RLS-bounded object CRUD from trusted action bodies, correct the flow dispatch shape (AutomationContext, not triggerData), and restate the permission section as invoke-time gate + trusted body. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155bjzJZxZaZTKSMSvg6Aum --- content/docs/ai/actions-as-tools.mdx | 116 +++++++++++++++++---------- 1 file changed, 75 insertions(+), 41 deletions(-) diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 2b75dc0b0b..68cbe6c13b 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -14,16 +14,32 @@ reached by an LLM as a callable tool. On the **open edition** this happens through [`@objectstack/mcp`](/docs/ai): your own AI (Claude, Cursor, any MCP client, or a local model) connects over the Model Context Protocol, and the server exposes two business-action tools — `list_actions` and `run_action` — -bound to the caller's principal. The agent operates the app the same way the -Console toolbar does, under the same row-level security and permissions. No -cloud service and no ObjectOS runtime are required. +bound to the caller's principal. The agent invokes actions the same way the +Console toolbar does — but only actions the author explicitly exposed to AI, and +only ones the caller is permitted to run. No cloud service and no ObjectOS +runtime are required. + + +**Action bodies run as trusted code (#2849).** Unlike the object CRUD tools — +where every read/write is bounded by the caller's row-level security — a +`script`/`body` action's handler executes with the app's **full data +authority**: its internal `engine.insert/update/delete/find` calls carry no +caller context, so they bypass RLS/FLS (the SECURITY-DEFINER model many actions +rely on for cross-object writes like convert-lead). The boundary is therefore at +**invoke time**, not inside the body: `ai.exposed` (author opt-in) + +`requiredPermissions` (ADR-0066) decide what an agent may trigger. Expose an +action to AI only when its body is safe to run on behalf of anyone the gate lets +through. Flow actions differ — they honour the flow's `runAs` (ADR-0049) with +the caller's identity forwarded. + **ObjectOS** layers an in-product chat *runtime* -on top of these same actions: it generates one `action_` tool per action, -gates them behind an `ai.exposed` opt-in, and adds a server-side approval queue. -Those pieces are called out below. The open path — the same Action reachable as -an MCP tool by your own AI — is the default. +on top of these same actions: it generates one `action_` tool per action +and adds a server-side approval queue. Both the open MCP path and the ObjectOS +runtime gate on the same `ai.exposed` opt-in. Those pieces are called out below. +The open path — the same Action reachable as an MCP tool by your own AI — is the +default. ## The open path: Actions over MCP @@ -34,16 +50,18 @@ bound to the caller's principal (the API key acts as the user): | Tool | What it does | |:---|:---| -| `list_actions` | Enumerates the invokable business actions the caller is permitted to run — name, target object, description, whether it needs a `recordId`, whether it is destructive, and its declared params. | -| `run_action` | Invokes an action by name with `{ recordId, params }`. Runs the app's registered business logic under the caller's permissions and RLS. | +| `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. | +| `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. | `run_action` resolves the action and dispatches it through the framework's own action mechanism — `IDataEngine.executeAction` for `script` / inline-`body` actions, or the automation flow runner for `type:'flow'` — exactly the path the -REST `/actions/...` route uses. Because the bridge is bound to the caller's -`ExecutionContext`, a BYO-AI client (Claude Code, Cursor, …) can trigger real -business logic — "complete this task", "convert this lead" — under the same -guardrails as the UI. +REST `/actions/...` route uses. Invocation is bound to the caller's +`ExecutionContext` for the gate checks and subject-record load, so a BYO-AI +client (Claude Code, Cursor, …) can trigger real business logic — "complete this +task", "convert this lead". Note the body itself runs trusted (see the warning +above); the caller context bounds *whether* the action fires and what record it +loads, not what the handler does internally. ### Describing an action for the LLM @@ -69,32 +87,35 @@ export const triageCaseAction = { ``` -**ObjectOS** — the `ai.exposed` flag is a governance gate for the -in-product chat *runtime*: the ObjectOS bridge registers -an `action_` tool **only** when `ai.exposed === true` (and then -`ai.description` is required, ≥ 40 chars). The open MCP path does **not** use -`ai.exposed` — it filters by permission, returning every action the caller is -allowed to run. Set `ai.description` regardless: both paths read it. +The `ai.exposed` flag is a governance gate for the **whole AI surface**. Both +the open MCP path (`list_actions` / `run_action`) and the ObjectOS in-product +chat runtime register an action as an AI tool **only** when `ai.exposed === true` +(and then `ai.description` is required, ≥ 40 chars). An action left un-exposed is +invisible to agents and fail-closed at invocation, even for a caller who holds +every required capability — because the body runs trusted, author opt-in, not a +data-layer backstop, is the boundary (#2849). ## What gets exposed The bridge walks every registered object's `actions[]` and offers only actions -that have a headless dispatch path **and** that the caller is permitted to run. -System objects (`sys_*`) are held back fail-closed. +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. | `action.type` | Dispatch path | Available where | |:---|:---|:---| | `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS | -| `flow` | automation flow runner — `execute(target, { triggerData })` | open (MCP) + ObjectOS; needs the `automation` service registered | +| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered | | `api` | HTTP call to `action.target` via a configured `apiClient` | **ObjectOS** runtime only | Console-only types (`url`, `modal`, `form`) are always skipped. -Permission filtering is single-sourced with the REST route: an action's declared -`requiredPermissions` (ADR-0066) are enforced as the caller, so -`list_actions` hides — and `run_action` refuses — anything the user could not -invoke through the API. Destructive actions (`confirmText`, `mode: 'delete'`, +Two gates are single-sourced with the REST route and applied at invoke time: the +`ai.exposed` opt-in (#2849) and an action's declared `requiredPermissions` +(ADR-0066), enforced as the caller — so `list_actions` hides, and `run_action` +refuses, anything the author did not expose to AI or the user could not invoke +through the API. Destructive actions (`confirmText`, `mode: 'delete'`, `variant: 'danger'`, or `ai.requiresConfirmation: true`) are reported with `requiresConfirmation: true` so the client can ask the human before calling. To assert that a destructive-looking action is safe for autonomous execution, set @@ -116,7 +137,8 @@ await kernel.bootstrap(); `type:'flow'` actions are picked up automatically when the `automation` service is registered. Point your MCP client at the server; the caller's API key acts as -the user, so every `run_action` runs under that principal's RLS. +the user for the invoke-time gates and the subject-record load. (Flow bodies then +honour `runAs`; `script`/`body` handlers run trusted — see the warning above.) **ObjectOS** — the in-product runtime wires actions through its own bridge @@ -137,7 +159,7 @@ A BYO-AI client invokes `run_action` the same way it calls any MCP tool: "recordId": "case_42", "params": { "priority": "high" } } -// → dispatches case_triage as the caller, under RLS; returns the flow result +// → invoke-gated as the caller; case_triage is a flow, so it honours runAs. Returns the flow result. ``` ## Human-in-the-loop approval @@ -148,7 +170,8 @@ lives at the protocol boundary: `run_action` is annotated `destructiveHint: true and each action's per-call risk is surfaced through `requiresConfirmation` in `list_actions`, so the MCP client (Claude Desktop, Cursor, …) prompts the operator to approve the call before it runs. The human stays in the loop at the -point of invocation, and the action still executes under the caller's RLS. +point of invocation — the meaningful control point, since the body then runs +with the app's own authority. **ObjectOS** — the in-product chat runtime adds a *server-side* @@ -164,27 +187,38 @@ process, and operators expect single-click yes/no. See the queue API. -## Permission-aware execution (RLS for agents) +## Permission model (invoke-time gate + trusted body) + +Two different boundaries apply, and it matters which: -Every action an agent runs executes under the **end-user's** `ExecutionContext`, -so the same row-level-security rules that protect the REST API automatically -scope what the agent can see and do. There is no separate "agent permission" -surface to maintain — if a user cannot read account `acc_42` through ObjectQL, -neither can an LLM acting on their behalf. +- **Object CRUD tools** (`query_records` / `get_record` / `create_record` / …) + execute under the **end-user's** `ExecutionContext`, so row-level security + scopes what the agent can see and do — if a user cannot read account `acc_42` + through ObjectQL, neither can an LLM acting on their behalf. This is automatic + and needs no separate "agent permission" surface. +- **Business actions** are gated at **invoke time**, then run trusted. The + caller context decides *whether* an action fires and which record it loads, but + a `script`/`body` handler's own reads/writes are **not** RLS-bounded (#2849). -On the open MCP path this is automatic: +On the open MCP path the action gate works like this: 1. The server binds each session to the caller's principal — the API key acts as the user, resolving to the same `ExecutionContext` a plain ObjectQL request from that user would get. -2. The action bridge threads that context into every `executeAction` / flow - dispatch and every subject-record load, so RLS engages exactly as it does for - a hand-rolled API endpoint. +2. `list_actions` / `run_action` fail-closed on the author's `ai.exposed` opt-in, + so an action never meant for AI is invisible and uninvokable — regardless of + the caller's capabilities. 3. Declared `requiredPermissions` are enforced against the caller — the same declaration the REST `/actions/...` route checks — so `list_actions` hides and `run_action` refuses anything the user cannot invoke. -4. Action audit logs attribute the dispatch to the real user instead of a generic - "AI Assistant" principal. +4. The subject record (for record-context actions) is loaded under the caller's + RLS, so an action over a record the user cannot see reads as not-found. +5. The action body then executes with the app's full data authority (flows honour + `runAs`), and the dispatch is audit-logged against the real user. + +Because the body is trusted, **`ai.exposed` is the security decision**: opt an +action into AI only when its logic is safe to run on behalf of anyone the +capability gate admits. **ObjectOS** — the in-product chat routes