Skip to content

Commit db3830a

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155bjzJZxZaZTKSMSvg6Aum
1 parent 20d0e93 commit db3830a

1 file changed

Lines changed: 75 additions & 41 deletions

File tree

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

0 commit comments

Comments
 (0)