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
9 changes: 9 additions & 0 deletions .changeset/d10-agent-action-caps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/runtime': minor
---

ADR-0090 D10 (follow-up) — an MCP agent may now invoke the business **actions** its delegating user can run, gated by the `actions:execute` scope. Previously an agent principal carried no system capabilities, so any capability-gated action (`requiredPermissions`) was denied even when the user was entitled to it.

`resolve-execution-context` now keeps the delegating user's `systemPermissions` on the agent context **only when the token carries `actions:execute`** (otherwise none — and the MCP tool surface already hides the action tools). The `actions:execute` scope is the user's explicit consent to let the agent act on their behalf, so the capability gate (`actionPermissionError`) is delegated accordingly.

This never widens the agent's **data** reach: what an action reads or writes still flows through the object CRUD/FLS/RLS ceiling ∩ user intersection. A `data:read` agent that invokes a writing action is still blocked at the write; even a `data:write` agent cannot touch better-auth-managed tables; and capability-gated **object** access stays denied to the agent (that gate is driven by the resolved ceiling sets, which carry no capabilities). The residual is a capability-gated action whose effect is purely external (email, webhook) — exactly what `actions:execute` consents to. Tighter per-action agent scoping is the per-client-grants follow-up.
24 changes: 24 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2440,6 +2440,30 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', ()
expect(executeAction).toHaveBeenCalledWith('todo_task', 'issueLicense', expect.anything());
});

// [ADR-0090 D10 #2] An MCP agent acting on behalf of a user carries the user's
// action capabilities (delegated by the `actions:execute` scope — the producer
// populates `systemPermissions` accordingly). The action gate is identity-
// agnostic, so a gated action the user can run is invokable by the agent; an
// agent whose scope did not delegate the capability is denied.
it('run_action allows a gated action for an AGENT that inherited the delegating user\'s capability', async () => {
const { bridge, executeAction } = makeBridge({
userId: 'u1', principalKind: 'agent', onBehalfOf: { userId: 'u1' },
systemPermissions: ['manage_platform_settings'],
});
const res = await bridge.runAction('issue_license', {});
expect(res.ok).toBe(true);
expect(executeAction).toHaveBeenCalledWith('todo_task', 'issueLicense', expect.anything());
});

it('run_action denies a gated action for an AGENT that did NOT inherit the capability (no actions:execute)', async () => {
const { bridge, executeAction } = makeBridge({
userId: 'u1', principalKind: 'agent', onBehalfOf: { userId: 'u1' },
systemPermissions: [],
});
await expect(bridge.runAction('issue_license', {})).rejects.toThrow(/requires capability/i);
expect(executeAction).not.toHaveBeenCalled();
});

it('run_action blocks system-object actions fail-closed (even for a system context)', async () => {
const { bridge, executeAction } = makeBridge({ isSystem: true });
await expect(bridge.runAction('rotate', { objectName: 'sys_api_key' })).rejects.toThrow(/system object/i);
Expand Down
49 changes: 49 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,5 +402,54 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc
expect(ctx.principalKind).toBe('human');
expect(ctx.onBehalfOf).toBeUndefined();
});

// ── [ADR-0090 D10 #2] action-capability delegation ───────────────────────
// A ql that resolves user `u1` to a set carrying system capabilities, so we
// can see whether the agent inherits the user's ACTION authority.
const capQl = () => {
const tables: Record<string, any[]> = {
sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }],
sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }],
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_users","manage_platform_settings"]', object_permissions: '{}' }],
sys_position: [], sys_position_permission_set: [], sys_user_position: [],
};
return {
async find(object: string, opts: any) {
return (tables[object] ?? []).filter((row) =>
Object.entries(opts?.where ?? {}).every(([k, v]) =>
v !== null && typeof v === 'object'
? (Array.isArray((v as any).$in) ? (v as any).$in.includes(row[k]) : true)
: (v ?? null) === (row[k] ?? null),
),
);
},
};
};
const agentWithQl = (scopes: string[], ql: any) => ({
acceptOAuthAccessToken: true,
getService: async (name: string) =>
name === 'auth' ? { verifyMcpAccessToken: async () => ({ userId: 'u1', scopes, clientId: 'c1' }) } : undefined,
getQl: async () => ql,
request: { headers: { authorization: 'Bearer a.b.c' } },
});

it("an agent WITH actions:execute inherits the delegating user's action capabilities", async () => {
const ctx = await resolveExecutionContext(agentWithQl(['data:read', 'actions:execute'], capQl()));
expect(ctx.principalKind).toBe('agent');
// The action gate (actionPermissionError) reads these → agent may invoke
// actions the user can. Delegated because the user consented actions:execute.
expect(ctx.systemPermissions).toContain('manage_users');
expect(ctx.systemPermissions).toContain('manage_platform_settings');
// But the DATA ceiling is still the scope-derived (read-only) set — the
// agent did NOT inherit the user's admin OBJECT grants.
expect(ctx.permissions).toEqual(['mcp_agent_data_read']);
});

it('an agent WITHOUT actions:execute holds NO action capabilities (cannot ride the user\'s caps)', async () => {
const ctx = await resolveExecutionContext(agentWithQl(['data:write'], capQl()));
expect(ctx.principalKind).toBe('agent');
expect(ctx.systemPermissions).toEqual([]);
expect(ctx.permissions).toEqual(['mcp_agent_data_write']);
});
});

29 changes: 23 additions & 6 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/

import type { ExecutionContext } from '@objectstack/spec/kernel';
import { scopesToAgentPermissionSets } from '@objectstack/spec/ai';
import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai';

import {
resolveAuthzContext,
Expand Down Expand Up @@ -169,11 +169,28 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
ctx.onBehalfOf = { userId: authz.userId, principalKind: 'human' };
ctx.permissions = scopesToAgentPermissionSets(oauthPrincipal.scopes);
ctx.positions = [];
// The agent's capabilities come from its ceiling sets (which carry none),
// not the user's — clear the user-derived aggregate so a cap-gated action
// (checked outside the D10 object intersection) can't ride the user's
// system permissions.
ctx.systemPermissions = [];
// [ADR-0090 D10] System capabilities on the agent principal gate business
// ACTION invocation (`actionPermissionError` reads `ctx.systemPermissions`)
// — a door SEPARATE from the object CRUD/FLS/RLS intersection, which is
// driven by the resolved ceiling SETS (they carry no caps, so cap-gated
// OBJECT access stays denied to the agent regardless of this line).
//
// The `actions:execute` scope IS the user's consent to let this agent
// invoke actions on their behalf, so when it is granted we DELEGATE the
// user's action capabilities to the gate; without it the agent holds none
// (and the MCP tool surface hides the action tools anyway). This never
// widens data reach: whatever an action reads/writes still flows through
// the object ceiling ∩ user intersection — e.g. a `data:read` agent that
// invokes a writing action is still blocked at the write, and even a
// `data:write` agent cannot touch better-auth-managed tables. The residual
// is a cap-gated action whose effect is purely EXTERNAL (email, webhook) —
// exactly what `actions:execute` consents to. Per-action agent scoping
// (tighter than this all-or-nothing scope) is the per-client-grants
// follow-up.
ctx.systemPermissions =
oauthPrincipal.scopes?.includes(MCP_OAUTH_SCOPE_ACTIONS)
? (authz.systemPermissions ?? [])
: [];
} else {
ctx.principalKind = 'human';
}
Expand Down