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
12 changes: 12 additions & 0 deletions .changeset/32163-list-actions-membership-reach-mark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/runtime': patch
'@objectstack/mcp': patch
'@object-ui/console': patch
---

fix(runtime): MCP `list_actions` marks an organization action the caller's membership role does not reach `unavailable`

Clause-②: no
Metadata-protocol: none

A plain organization member's agent was listed identity actions such as inviting a user, removing a member, editing the organization or managing its teams, and `run_action` then answered `403 PERMISSION_DENIED` from the organization endpoint each time. The actions stay listed, and for a caller whose membership role in the organization does not reach the endpoint the row now carries `unavailable: { code: 'PERMISSION_DENIED', reason }`, where the reason names the roles that do reach it (for example "needs an organization owner, admin or delegated admin"). The reach is read from the action's `target` against the `MEMBERSHIP_REACH` table in `@objectstack/spec/identity`. Owners, admins and (for invitations) delegated admins see the rows unmarked. Nothing is hidden and neither run door reads the mark, so who can run each action is unchanged. Actions judged on record ownership or on the deployment's tenancy posture (deleting an SSO provider or an OAuth application, creating an organization) are not marked. The `list_actions` tool description, the `McpActionSummary.unavailable` documentation and the served skill (with its Console mirror) now say a listed action can carry `unavailable` for the caller's role, not only for its credential.
8 changes: 5 additions & 3 deletions content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ client, or a local model) connects over the Model Context Protocol, and the
server exposes three business-action tools — `list_actions`, `run_action` and
`resume_run` — 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
only ones the caller's permissions allow; one the caller still cannot run as it
is connected is listed with `unavailable` (see below). No cloud service and no ObjectOS
runtime are required.

<Callout type="warn">
Expand Down Expand Up @@ -49,7 +50,7 @@ bound to the caller's principal (the API key acts as the user):

| Tool | What it does |
|:---|:---|
| `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, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). An `api` action that `run_action` would refuse for the caller's credential (see the `api` row below) is still listed, with `unavailable: { code, reason }`: `code` is `UNAUTHENTICATED`, the code `run_action` answers with, and `reason` is the same sentence, so an agent can tell its user why it cannot run the action. |
| `list_actions` | Enumerates the business actions that are **AI-exposed** (`ai.exposed: true`) **and** the caller's permissions allow — name, target object, description, whether it needs a `recordId`, whether it is destructive, its declared params, and `aiExposed: true` (the author's opt-in, attested by the bridge). An action that `run_action` would refuse for this caller is still listed, with `unavailable: { code, reason }`. `code` is the code `run_action` answers with: `UNAUTHENTICATED` for an `api` action whose endpoint does not accept the caller's credential (see the `api` row below); `ACTION_CONFIRMATION_REQUIRED` for an action declaring `ai.requiresConfirmation: true` listed for an AI service account; `PERMISSION_DENIED` for an `api` action whose target is an organization endpoint (inviting or removing a member, editing the organization or its teams) that the caller's membership grade in this organization (owner, admin, delegated admin) does not reach. `reason` says why and what would let the action run, so an agent can tell its user why it cannot run it. Nothing is hidden for these reasons, and the mark changes no run: the endpoint still judges the call. |
| `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. |
| `resume_run` | Continues a flow run that `run_action` answered with `status: "paused"` and a `screen`, by submitting that screen's field values as `{ runId, values }`. Only the user who started the run can resume it, behind the same gates as `run_action`. See [Completing a paused screen flow](#completing-a-paused-screen-flow). |

Expand Down Expand Up @@ -101,7 +102,8 @@ data-layer backstop, is the boundary (#2849).

The bridge walks every registered object's `actions[]` and offers only actions
that are **AI-exposed** (`ai.exposed: true`), have a headless dispatch path,
**and** that the caller is permitted to run. An action on a system object
**and** that the caller's permissions allow (one `run_action` would still refuse
for this caller is listed with `unavailable`, as above). An action on a system object
(`sys_*`) is offered only on its own `ai.exposed: true` — the object's name is
not a second opt-in to clear, and no deployment switch opens one that is not
flagged — and then runs behind every gate below, as on any other object (#27681).
Expand Down
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ One of the largest consumers of the flag;
| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `resolveInboxRecipient` |
| 61b | **The principal-session mint refuses a system context** | plugin-auth | Get: nothing — `mintPrincipalSession` answers `PERMISSION_DENIED` / `403` before anything is minted. A principal session acts as the requesting user and never as the system, so a render or any other in-process act that needs a session must carry the user's own context | `refuseUpFront` |
| 62 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` |
| 62b | **MCP `list_actions` asks no membership-grade mark of a system caller** — an organization action whose endpoint (`/organization/...`) the caller's membership grade does not reach is listed without `unavailable` | runtime | Get: nothing a run would not give. The mark is a courtesy reading of the caller's own `positions` (ruling record 6812625052, letter E: list and mark, do not hide), and a system context carries no person's grades, so it is left unmarked, as a delegated agent's envelope is. Every other caller whose positions name none of the grades the `MEMBERSHIP_REACH` rows for that endpoint list sees the row marked `PERMISSION_DENIED`. Lose: nothing — neither run door reads the mark, and the organization endpoint still judges the call | `membershipReachRefusal` |

### 6. Reads that only carry the flag onward

Expand Down Expand Up @@ -674,6 +675,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/runtime/src/domains/i18n.ts#handleI18nRequest` | 1 | 52 |
| `packages/runtime/src/domains/mcp.ts#federationService` | 1 | 55b |
| `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | 1 | 53 |
| `packages/runtime/src/domains/mcp.ts#membershipReachRefusal` | 1 | 62b |
| `packages/runtime/src/domains/meta.ts#handleMetadataRequest` | 5 | 50, 52 |
| `packages/runtime/src/domains/packages.ts#handlePackagesRequest` | 1 | 52 |
| `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | 1 | 55b |
Expand Down
32 changes: 18 additions & 14 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,17 +373,21 @@ export interface McpActionSummary {
/** Declared input parameters. */
params?: McpActionParamSummary[];
/**
* Present when `run_action` would refuse this action for THIS caller's
* credential before running it: an `api` action whose target endpoint does
* not accept the credential the caller connected with (an OAuth access token
* is honoured on the MCP endpoint only, so an endpoint outside it cannot read
* it), or an action declaring `ai.requiresConfirmation: true` listed for an
* AI service account, which never confirms an action for itself (ADR-0151 D3
* rule 3). The action is still listed, so an agent can tell its user why it
* Present when `run_action` would refuse this action for THIS caller: an
* `api` action whose target endpoint does not accept the credential the
* caller connected with (an OAuth access token is honoured on the MCP
* endpoint only, so an endpoint outside it cannot read it); an action
* declaring `ai.requiresConfirmation: true` listed for an AI service
* account, which never confirms an action for itself (ADR-0151 D3 rule 3);
* or an `api` action whose target is an organization endpoint the caller's
* membership role in this organization does not reach (inviting, removing a
* member, editing the organization or its teams), which that endpoint
* refuses. The action is still listed, so an agent can tell its user why it
* cannot run it; `code` is the error code `run_action` answers with
* (`UNAUTHENTICATED` for the endpoint, `ACTION_CONFIRMATION_REQUIRED` for the
* account), `reason` the sentence it carries, including what would let the
* action run. Absent when the action can be run.
* (`UNAUTHENTICATED` for the credential, `ACTION_CONFIRMATION_REQUIRED` for
* the account, `PERMISSION_DENIED` for the membership role), `reason` the
* sentence that says why, including what would let the action run. Absent
* when the action can be run.
*/
unavailable?: McpActionUnavailable;
}
Expand Down Expand Up @@ -3124,12 +3128,12 @@ export function registerActionTools(
'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 app author has exposed to AI ' +
'and that the caller is permitted to run are returned. Use run_action to invoke one. One name can be ' +
'and that the caller\'s permissions allow are returned. Use run_action to invoke one. One name can be ' +
'listed on several rows, one per object it is bound to: each row carries its own face, and run_action ' +
'takes that face as actionName (or the row\'s name with its objectName and namespace). An action that ' +
'carries "unavailable" cannot be run with the credential you connected with: run_action would refuse ' +
'it with unavailable.code, and unavailable.reason says why and what would let it run, so tell the ' +
'user instead of calling it.',
'carries "unavailable" cannot be run by you as you are connected (your credential, or your role in ' +
'this organization): run_action would refuse it with unavailable.code, and unavailable.reason says ' +
'why and what would let it run, so tell the user instead of calling it.',
inputSchema: strictToolInput({ surface: 'this list_actions call' }, {}),
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
Expand Down
7 changes: 5 additions & 2 deletions packages/mcp/src/skill-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,12 @@ create/update payload.
stops the call and names the record it stopped at. Over OAuth it needs both
\`data:read\` and \`data:write\`.
- **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
the app author exposed to AI and your permissions allow), with each
action's declared parameters, whether it operates on a record, and whether
it is flagged destructive.
it is flagged destructive. An action that carries \`unavailable\` is listed
but \`run_action\` would refuse it for you (your credential, or your role in
this organization): \`unavailable.reason\` says why and what would let it
run, so tell your human instead of calling it.
- **run_action({ actionName, objectName?, recordId?, params?, confirm? })** —
invoke a business action by name. Invocation is permission-gated, but the
action body executes the app's registered logic as trusted code (it can
Expand Down
77 changes: 76 additions & 1 deletion packages/runtime/src/domains/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import type { ErrorCode, MetadataProtocol } from '@objectstack/spec/api';
import type { ISecurityService } from '@objectstack/spec/contracts';
// [#32442] The ONE reading of `enable.aiReadable` (ruling A, #32865).
import { isAiReadExposed } from '@objectstack/spec/data';
import {
BUILTIN_MEMBERSHIP_ROLES,
MEMBERSHIP_REACH,
mapMembershipRole,
type BuiltinMembershipRole,
type MembershipReachEntry,
} from '@objectstack/spec/identity';
import type { AIActionConfirmation, IExternalDatasourceService } from '@objectstack/spec/contracts';
import {
FEDERATION_READ_CAPABILITY,
Expand Down Expand Up @@ -55,6 +62,67 @@ import { actionRunFieldVisibilityRefusal, actionRunMasker, projectActionForCalle
/** Typed as the registered vocabulary, so a misspelling fails to compile. */
const FIELD_VISIBILITY_UNRESOLVED: ErrorCode = 'FIELD_VISIBILITY_UNRESOLVED';

/**
* The code `run_action` answers when an organization endpoint refuses the
* caller's membership role: the forward relays the endpoint's 403, served as
* the standard code for that status (ADR-0112).
*/
const MEMBERSHIP_REACH_REFUSAL_CODE: ErrorCode = 'PERMISSION_DENIED';

/** An identity-route target on better-auth's organization plugin, captured relative to the auth base path. */
const ORGANIZATION_ENDPOINT_TARGET = /^\/api\/v1\/auth(\/organization\/[^?#]+)/;

/**
* [ruling record 6812625052, letter E: list and mark, do not hide] Why
* `run_action` would be refused for a listed `api` action whose target is an
* organization endpoint the caller's membership role does not reach, or
* `undefined` when it would not, or when this listing cannot tell.
*
* The reach is read from the action's declared `target` against the spec's
* reach table ({@link MEMBERSHIP_REACH}, pinned equal to the vendor doors in
* plugin-auth), never from a local list: the grades that reach the endpoint
* are those of every row naming it (`/organization/update-member-role` is two
* rows, so its reach is their union). A target no row names is no grade's to
* judge (`/organization/create` is judged on the deployment's tenancy posture,
* an SSO provider or OAuth application on record ownership), so it is never
* marked.
*
* The caller's grades are its `positions` in the organization this listing
* reads, by the names {@link mapMembershipRole} projects them to: the reading
* the action's `requiresMembershipReach` sugar is lowered to. Only a principal
* whose envelope carries its own positions is judged: the system context, a
* delegated agent (its envelope carries the agent ceiling, not the delegating
* person's grades) and an envelope with no `positions` are left unmarked.
*
* This is a mark and nothing else. The run doors do not read it: the endpoint
* stays the authority, and a call naming another organization is judged there.
*/
function membershipReachRefusal(action: any, objectName: string | undefined, ec: any): { code: string; message: string } | undefined {
if (action?.type !== 'api' || typeof action?.target !== 'string') return undefined;
const endpoint = ORGANIZATION_ENDPOINT_TARGET.exec(action.target)?.[1]?.replace(/\/+$/, '');
if (!endpoint) return undefined;
const reaching = new Set<BuiltinMembershipRole>();
for (const row of Object.values(MEMBERSHIP_REACH) as MembershipReachEntry[]) {
if (row.endpoint === endpoint) for (const grade of row.grades) reaching.add(grade);
}
if (reaching.size === 0) return undefined;
if (!ec || ec.isSystem === true || ec.onBehalfOf || !Array.isArray(ec.positions)) return undefined;
const grades = BUILTIN_MEMBERSHIP_ROLES.filter((grade) => reaching.has(grade));
if (grades.some((grade) => ec.positions.includes(mapMembershipRole(grade)))) return undefined;

const names = grades.map((grade) => grade.replace(/_/g, ' '));
const roles = names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names[0];
const on = objectName ? ` on '${objectName}'` : '';
const where = ec.tenantId ? 'in this organization' : 'in any organization you belong to';
return {
code: MEMBERSHIP_REACH_REFUSAL_CODE,
message:
`Action '${String(action?.name ?? '')}'${on} needs an organization ${roles}: its endpoint ` +
`(${endpoint}) refuses every other membership role, and your role ${where} is none of those. ` +
`Ask a person who holds one of those roles to run it.`,
};
}

/**
* [ADR-0106 D6 tier 3] The refusal an MCP object-schema read answers when the
* caller's field visibility could not be evaluated, or the projection would
Expand Down Expand Up @@ -1222,7 +1290,14 @@ export function buildMcpBridge(
const confirmation = actionExec.isAiServiceAccountCaller(ec)
? actionExec.actionConfirmationRefusal(deps, action, undefined, objectName, ec)
: undefined;
const refusal = confirmation ?? actionExec.apiForwardCredentialRefusal(context, action);
// [ruling record 6812625052, letter E] …and an organization
// endpoint the caller's membership role does not reach: the
// forward reaches it and it refuses 403. Asked last, because
// `run_action` meets it last: after the admission sequence and
// the forward's credential table. Listed, never hidden: the
// run doors do not read the mark ({@link membershipReachRefusal}).
const refusal = confirmation ?? actionExec.apiForwardCredentialRefusal(context, action)
?? membershipReachRefusal(action, actionExec.isObjectLessActionKey(objectName) ? undefined : objectName, ec);
// [ADR-0106 D1] Summarised against the object as this caller is
// served it, and not listed where `/meta` withholds it.
const served = await servedActionObject(action, objectName, obj, objectsByName);
Expand Down
Loading
Loading