Skip to content

Commit fdfe1d9

Browse files
authored
fix(security): gate the MCP action surface on ai.exposed, fail-closed (#2964)
Gate list_actions/run_action on ai.exposed (fail-closed), forward the caller's AutomationContext into flow-action dispatch so runAs:'user' flows enforce RLS, audit trusted body dispatch, and correct the docs/framing to state the agent action boundary as ai.exposed + actions:execute + capability gate — not the data ceiling. Groundwork for the longer-term action-level runAs convergence (#2849). Closes the agent-facing half of #2849.
1 parent f71d19a commit fdfe1d9

9 files changed

Lines changed: 322 additions & 84 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/mcp': patch
4+
---
5+
6+
fix(security): enforce the `ai.exposed` opt-in on the MCP action surface (#2849)
7+
8+
Business-action bodies execute as trusted code: their engine facade carries no
9+
`ExecutionContext`, so a body's internal reads/writes bypass RLS/FLS/CRUD and
10+
tenant scoping — the caller's permissions and an agent's ADR-0090 D10 data
11+
ceiling do NOT bound what an invoked action does. The MCP `run_action` bridge
12+
nevertheless allowed invoking ANY headless action, ignoring the spec's
13+
`ai.exposed` governance gate (ADR-0011) entirely.
14+
15+
The MCP bridge now fail-closes on `ai.exposed`: `list_actions` only enumerates
16+
— and `run_action` only dispatches — actions the app author explicitly opted
17+
into the AI surface with `ai: { exposed: true, description }`. Flow-type
18+
actions additionally receive the caller's identity (`userId` / `positions` /
19+
`permissions` / `tenantId`) as a proper `AutomationContext` (replacing the
20+
former `triggerData` envelope the engine never read), so a `runAs: 'user'`
21+
flow enforces RLS as the invoker instead of running unscoped (ADR-0049).
22+
Trusted body dispatches are now audit-logged on both the MCP and REST action
23+
paths, and the MCP tool/README/docs wording no longer claims action bodies run
24+
under the caller's RLS.
25+
26+
Migration: actions that should stay invokable by AI agents through MCP must
27+
declare `ai: { exposed: true, description: '…' }` (≥40-char description). All
28+
other invocation surfaces (UI, REST `/actions/...`) are unchanged.

content/docs/ai/actions-as-tools.mdx

Lines changed: 75 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,32 @@ reached by an LLM as a callable tool. On the **open edition** this happens
1414
through [`@objectstack/mcp`](/docs/ai): your own AI (Claude, Cursor, any MCP
1515
client, or a local model) connects over the Model Context Protocol, and the
1616
server exposes two business-action tools — `list_actions` and `run_action`
17-
bound to the caller's principal. The agent operates the app the same way the
18-
Console toolbar does, under the same row-level security and permissions. No
19-
cloud service and no ObjectOS runtime are required.
17+
bound to the caller's principal. The agent invokes actions the same way the
18+
Console toolbar does — but only actions the author explicitly exposed to AI, and
19+
only ones the caller is permitted to run. No cloud service and no ObjectOS
20+
runtime are required.
21+
22+
<Callout type="warn">
23+
**Action bodies run as trusted code (#2849).** Unlike the object CRUD tools —
24+
where every read/write is bounded by the caller's row-level security — a
25+
`script`/`body` action's handler executes with the app's **full data
26+
authority**: its internal `engine.insert/update/delete/find` calls carry no
27+
caller context, so they bypass RLS/FLS (the SECURITY-DEFINER model many actions
28+
rely on for cross-object writes like convert-lead). The boundary is therefore at
29+
**invoke time**, not inside the body: `ai.exposed` (author opt-in) +
30+
`requiredPermissions` (ADR-0066) decide what an agent may trigger. Expose an
31+
action to AI only when its body is safe to run on behalf of anyone the gate lets
32+
through. Flow actions differ — they honour the flow's `runAs` (ADR-0049) with
33+
the caller's identity forwarded.
34+
</Callout>
2035

2136
<Callout type="info">
2237
**ObjectOS** layers an in-product chat *runtime*
23-
on top of these same actions: it generates one `action_<name>` tool per action,
24-
gates them behind an `ai.exposed` opt-in, and adds a server-side approval queue.
25-
Those pieces are called out below. The open path — the same Action reachable as
26-
an MCP tool by your own AI — is the default.
38+
on top of these same actions: it generates one `action_<name>` tool per action
39+
and adds a server-side approval queue. Both the open MCP path and the ObjectOS
40+
runtime gate on the same `ai.exposed` opt-in. Those pieces are called out below.
41+
The open path — the same Action reachable as an MCP tool by your own AI — is the
42+
default.
2743
</Callout>
2844

2945
## The open path: Actions over MCP
@@ -34,16 +50,18 @@ bound to the caller's principal (the API key acts as the user):
3450

3551
| Tool | What it does |
3652
|:---|:---|
37-
| `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. |
38-
| `run_action` | Invokes an action by name with `{ recordId, params }`. Runs the app's registered business logic under the caller's permissions and RLS. |
53+
| `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. |
54+
| `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. |
3955

4056
`run_action` resolves the action and dispatches it through the framework's own
4157
action mechanism — `IDataEngine.executeAction` for `script` / inline-`body`
4258
actions, or the automation flow runner for `type:'flow'` — exactly the path the
43-
REST `/actions/...` route uses. Because the bridge is bound to the caller's
44-
`ExecutionContext`, a BYO-AI client (Claude Code, Cursor, …) can trigger real
45-
business logic — "complete this task", "convert this lead" — under the same
46-
guardrails as the UI.
59+
REST `/actions/...` route uses. Invocation is bound to the caller's
60+
`ExecutionContext` for the gate checks and subject-record load, so a BYO-AI
61+
client (Claude Code, Cursor, …) can trigger real business logic — "complete this
62+
task", "convert this lead". Note the body itself runs trusted (see the warning
63+
above); the caller context bounds *whether* the action fires and what record it
64+
loads, not what the handler does internally.
4765

4866
### Describing an action for the LLM
4967

@@ -69,32 +87,35 @@ export const triageCaseAction = {
6987
```
7088

7189
<Callout type="info">
72-
**ObjectOS** — the `ai.exposed` flag is a governance gate for the
73-
in-product chat *runtime*: the ObjectOS bridge registers
74-
an `action_<name>` tool **only** when `ai.exposed === true` (and then
75-
`ai.description` is required, ≥ 40 chars). The open MCP path does **not** use
76-
`ai.exposed` — it filters by permission, returning every action the caller is
77-
allowed to run. Set `ai.description` regardless: both paths read it.
90+
The `ai.exposed` flag is a governance gate for the **whole AI surface**. Both
91+
the open MCP path (`list_actions` / `run_action`) and the ObjectOS in-product
92+
chat runtime register an action as an AI tool **only** when `ai.exposed === true`
93+
(and then `ai.description` is required, ≥ 40 chars). An action left un-exposed is
94+
invisible to agents and fail-closed at invocation, even for a caller who holds
95+
every required capability — because the body runs trusted, author opt-in, not a
96+
data-layer backstop, is the boundary (#2849).
7897
</Callout>
7998

8099
## What gets exposed
81100

82101
The bridge walks every registered object's `actions[]` and offers only actions
83-
that have a headless dispatch path **and** that the caller is permitted to run.
84-
System objects (`sys_*`) are held back fail-closed.
102+
that are **AI-exposed** (`ai.exposed: true`), have a headless dispatch path,
103+
**and** that the caller is permitted to run. System objects (`sys_*`) are held
104+
back fail-closed.
85105

86106
| `action.type` | Dispatch path | Available where |
87107
|:---|:---|:---|
88108
| `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS |
89-
| `flow` | automation flow runner — `execute(target, { triggerData })` | open (MCP) + ObjectOS; needs the `automation` service registered |
109+
| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered |
90110
| `api` | HTTP call to `action.target` via a configured `apiClient` | **ObjectOS** runtime only |
91111

92112
Console-only types (`url`, `modal`, `form`) are always skipped.
93113

94-
Permission filtering is single-sourced with the REST route: an action's declared
95-
`requiredPermissions` (ADR-0066) are enforced as the caller, so
96-
`list_actions` hides — and `run_action` refuses — anything the user could not
97-
invoke through the API. Destructive actions (`confirmText`, `mode: 'delete'`,
114+
Two gates are single-sourced with the REST route and applied at invoke time: the
115+
`ai.exposed` opt-in (#2849) and an action's declared `requiredPermissions`
116+
(ADR-0066), enforced as the caller — so `list_actions` hides, and `run_action`
117+
refuses, anything the author did not expose to AI or the user could not invoke
118+
through the API. Destructive actions (`confirmText`, `mode: 'delete'`,
98119
`variant: 'danger'`, or `ai.requiresConfirmation: true`) are reported with
99120
`requiresConfirmation: true` so the client can ask the human before calling. To
100121
assert that a destructive-looking action is safe for autonomous execution, set
@@ -116,7 +137,8 @@ await kernel.bootstrap();
116137

117138
`type:'flow'` actions are picked up automatically when the `automation` service
118139
is registered. Point your MCP client at the server; the caller's API key acts as
119-
the user, so every `run_action` runs under that principal's RLS.
140+
the user for the invoke-time gates and the subject-record load. (Flow bodies then
141+
honour `runAs`; `script`/`body` handlers run trusted — see the warning above.)
120142

121143
<Callout type="info">
122144
**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:
137159
"recordId": "case_42",
138160
"params": { "priority": "high" }
139161
}
140-
//dispatches case_triage as the caller, under RLS; returns the flow result
162+
//invoke-gated as the caller; case_triage is a flow, so it honours runAs. Returns the flow result.
141163
```
142164

143165
## Human-in-the-loop approval
@@ -148,7 +170,8 @@ lives at the protocol boundary: `run_action` is annotated `destructiveHint: true
148170
and each action's per-call risk is surfaced through `requiresConfirmation` in
149171
`list_actions`, so the MCP client (Claude Desktop, Cursor, …) prompts the
150172
operator to approve the call before it runs. The human stays in the loop at the
151-
point of invocation, and the action still executes under the caller's RLS.
173+
point of invocation — the meaningful control point, since the body then runs
174+
with the app's own authority.
152175

153176
<Callout type="info">
154177
**ObjectOS** — the in-product chat runtime adds a *server-side*
@@ -164,27 +187,38 @@ process, and operators expect single-click yes/no. See the
164187
queue API.
165188
</Callout>
166189

167-
## Permission-aware execution (RLS for agents)
190+
## Permission model (invoke-time gate + trusted body)
191+
192+
Two different boundaries apply, and it matters which:
168193

169-
Every action an agent runs executes under the **end-user's** `ExecutionContext`,
170-
so the same row-level-security rules that protect the REST API automatically
171-
scope what the agent can see and do. There is no separate "agent permission"
172-
surface to maintain — if a user cannot read account `acc_42` through ObjectQL,
173-
neither can an LLM acting on their behalf.
194+
- **Object CRUD tools** (`query_records` / `get_record` / `create_record` / …)
195+
execute under the **end-user's** `ExecutionContext`, so row-level security
196+
scopes what the agent can see and do — if a user cannot read account `acc_42`
197+
through ObjectQL, neither can an LLM acting on their behalf. This is automatic
198+
and needs no separate "agent permission" surface.
199+
- **Business actions** are gated at **invoke time**, then run trusted. The
200+
caller context decides *whether* an action fires and which record it loads, but
201+
a `script`/`body` handler's own reads/writes are **not** RLS-bounded (#2849).
174202

175-
On the open MCP path this is automatic:
203+
On the open MCP path the action gate works like this:
176204

177205
1. The server binds each session to the caller's principal — the API key acts as
178206
the user, resolving to the same `ExecutionContext` a plain ObjectQL request
179207
from that user would get.
180-
2. The action bridge threads that context into every `executeAction` / flow
181-
dispatch and every subject-record load, so RLS engages exactly as it does for
182-
a hand-rolled API endpoint.
208+
2. `list_actions` / `run_action` fail-closed on the author's `ai.exposed` opt-in,
209+
so an action never meant for AI is invisible and uninvokable — regardless of
210+
the caller's capabilities.
183211
3. Declared `requiredPermissions` are enforced against the caller — the same
184212
declaration the REST `/actions/...` route checks — so `list_actions` hides and
185213
`run_action` refuses anything the user cannot invoke.
186-
4. Action audit logs attribute the dispatch to the real user instead of a generic
187-
"AI Assistant" principal.
214+
4. The subject record (for record-context actions) is loaded under the caller's
215+
RLS, so an action over a record the user cannot see reads as not-found.
216+
5. The action body then executes with the app's full data authority (flows honour
217+
`runAs`), and the dispatch is audit-logged against the real user.
218+
219+
Because the body is trusted, **`ai.exposed` is the security decision**: opt an
220+
action into AI only when its logic is safe to run on behalf of anyone the
221+
capability gate admits.
188222

189223
<Callout type="info">
190224
**ObjectOS** — the in-product chat routes

content/docs/ai/agents.mdx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting.
5757
pre-registered with Anthropic or any central service, so **self-hosted and
5858
private deployments work out of the box**. You authorize the client in the
5959
browser; it then acts as an **agent on your behalf** (`principalKind: 'agent'`),
60-
bounded by the **intersection** of the consent scopes and your own
61-
permissions/RLS — it can never exceed either. The consent scopes
62-
(`data:read`, `data:write`, `actions:execute`) are a real ceiling, not just a
60+
its **data access** bounded by the **intersection** of the consent scopes and
61+
your own permissions/RLS — it can never exceed either. For object CRUD the
62+
consent scopes (`data:read`, `data:write`) are a real ceiling, not just a
6363
tool-family filter: they narrow the exposed tools **and** cap what the agent
6464
can do at the data layer — a `data:read` token can never write a record even
65-
where you could, and `actions:execute` lets the agent invoke the business
66-
actions you are entitled to run.
65+
where you could. **Business actions are gated differently** (#2849):
66+
`actions:execute` + the action's declared capabilities decide *which* actions
67+
the agent may invoke, and only actions the app author explicitly exposed to
68+
AI (`ai: { exposed: true }`) are invokable at all — but an invoked action's
69+
*body* runs as trusted application code with the app's full data authority,
70+
**not** under the agent's data ceiling. The author's AI opt-in, not the data
71+
layer, is the boundary for what actions can do.
6772
- **API key (headless).** Mint a key (`POST /api/v1/keys`, shown once) and
6873
send it as `x-api-key` — for CI, scripts, and agents without a browser.
6974

examples/app-todo/test/mcp-actions.e2e.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ function mcpRequest(body: unknown): Request {
165165
const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[];
166166
check(allowList.some((a) => a.name === 'clone_task'), 'list_actions reveals the gated action to a holder');
167167

168+
// ── Step 6 — AI-exposure gate (#2849) end-to-end ───────────────────
169+
console.log('\n🔒 Step 6 — an action without ai.exposed is hidden and uninvokable');
170+
// Same app, but clone_task no longer opts into the AI surface.
171+
const unexposedObjects = mergedObjects.map((o) =>
172+
o.name !== 'todo_task'
173+
? o
174+
: { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? (({ ai: _ai, ...rest }: any) => rest)(a) : a)) },
175+
);
176+
const unexposedBridge = bridgeFor({ userId: 'user_3', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, unexposedObjects);
177+
const unexposedList = JSON.parse((await callMcp(unexposedBridge, toolsCall(9, 'list_actions', {}))).result.content[0].text).actions as any[];
178+
check(!unexposedList.some((a) => a.name === 'clone_task'), 'list_actions hides an action the author did not expose to AI');
179+
const unexposedRun = await callMcp(unexposedBridge, toolsCall(10, 'run_action', { actionName: 'clone_task', recordId: taskId }));
180+
check(unexposedRun.result?.isError === true, 'run_action refuses the unexposed action (fail-closed)');
181+
check(/not exposed to AI/i.test(unexposedRun.result?.content?.[0]?.text ?? ''), 'refusal names the AI-exposure gate');
182+
168183
console.log('\n────────────────────────────────────────────────────────────────────────────────');
169184
if (failures > 0) {
170185
console.error(`❌ MCP action E2E FAILED — ${failures} check(s) failed`);

packages/mcp/README.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,22 @@ the open framework.
115115
```
116116

117117
`list_actions` enumerates each object's headless-invokable actions (script /
118-
flow), filtered to what the caller is permitted to run — declared
119-
`requiredPermissions` (ADR-0066 D4) are enforced and `sys_*`-object actions are
120-
held back fail-closed. `run_action` resolves the action by name and dispatches
121-
it through the framework's own action mechanism (`engine.executeAction` /
122-
automation flow runner) as the caller, so a BYO-AI MCP client (Claude Code,
123-
Cursor, …) can trigger real business logic — e.g. "complete this task",
124-
"convert this lead" — exactly as the UI would, under the same guardrails.
118+
flow), filtered to what the author exposed and the caller may run: only actions
119+
opted into the AI surface (`ai: { exposed: true }`, ADR-0011 / #2849) are
120+
listed, declared `requiredPermissions` (ADR-0066 D4) are enforced, and
121+
`sys_*`-object actions are held back fail-closed. `run_action` resolves the
122+
action by name and dispatches it through the framework's own action mechanism
123+
(`engine.executeAction` / automation flow runner), so a BYO-AI MCP client
124+
(Claude Code, Cursor, …) can trigger real business logic — e.g. "complete this
125+
task", "convert this lead".
126+
127+
> **Security model (#2849):** gating happens at *invoke* time (`ai.exposed` +
128+
> capability gate + record-context loads under the caller's RLS). Once invoked,
129+
> a script/body action executes as **trusted application code** — its internal
130+
> reads/writes carry the app's full data authority and are *not* bounded by the
131+
> caller's RLS/FLS. Expose an action to AI only when its body is safe to run on
132+
> behalf of anyone allowed through the gate. Flow actions honour the flow's
133+
> `runAs` declaration (ADR-0049) with the caller's identity forwarded.
125134
126135
### Custom Tools
127136

@@ -301,8 +310,11 @@ TLS is required for OAuth (localhost is exempt, per OAuth 2.1). Local clients
301310
(Claude Code / Desktop) can reach intranet deployments; claude.ai web
302311
connectors additionally need the endpoint publicly reachable. Coarse scopes
303312
(`data:read`, `data:write`, `actions:execute`) narrow the exposed tool
304-
families at consent time; permissions/RLS still bind every call to the
305-
logged-in user.
313+
families at consent time; permissions/RLS bind every *object CRUD* call to the
314+
logged-in user. Business actions are the exception: `actions:execute` gates
315+
*which* actions may be invoked (author AI opt-in + capabilities), but an
316+
invoked action's body runs as trusted app code, not under the caller's RLS
317+
(#2849).
306318

307319
**API key — the headless track (CI, scripts, background agents).** Mint a key
308320
(`POST /api/v1/keys`, shown once) and send it as a header — no browser

0 commit comments

Comments
 (0)