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/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
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/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
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;