Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/security-2849-action-ai-exposure-gate.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 75 additions & 41 deletions content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
**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.
</Callout>

<Callout type="info">
**ObjectOS** layers an in-product chat *runtime*
on top of these same actions: it generates one `action_<name>` 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_<name>` 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.
</Callout>

## The open path: Actions over MCP
Expand All @@ -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

Expand All @@ -69,32 +87,35 @@ export const triageCaseAction = {
```

<Callout type="info">
**ObjectOS** — the `ai.exposed` flag is a governance gate for the
in-product chat *runtime*: the ObjectOS bridge registers
an `action_<name>` 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).
</Callout>

## 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
Expand All @@ -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.)

<Callout type="info">
**ObjectOS** — the in-product runtime wires actions through its own bridge
Expand All @@ -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
Expand All @@ -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.

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

## 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.

<Callout type="info">
**ObjectOS** — the in-product chat routes
Expand Down
15 changes: 10 additions & 5 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions examples/app-todo/test/mcp-actions.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
30 changes: 21 additions & 9 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading