Skip to content

Commit 261aff5

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 D10 — delegate action capabilities to MCP agents (#2845)
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 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` (else none — and the MCP tool surface already hides the action tools). That scope is the user's explicit consent to let the agent act for them, so the action capability gate (actionPermissionError) is delegated accordingly. Never widens the agent's DATA reach: what an action reads/writes still flows through the object CRUD/FLS/RLS ceiling ∩ user intersection (a data:read agent invoking a writing action is still blocked at the write; even a data:write agent can't touch better-auth-managed tables; cap-gated OBJECT access stays denied since that gate uses the resolved ceiling sets, which carry no caps). Residual = a cap-gated action with a purely external effect (email/webhook), exactly what actions:execute consents to. Tests: producer unit (agent + actions:execute → user caps; without → none) + MCP run_action bridge integration (agent with delegated cap runs the gated action; without, denied). Full runtime suite 495 green; tsc clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c548bc commit 261aff5

4 files changed

Lines changed: 105 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
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.
6+
7+
`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.
8+
9+
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.

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2440,6 +2440,30 @@ describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', ()
24402440
expect(executeAction).toHaveBeenCalledWith('todo_task', 'issueLicense', expect.anything());
24412441
});
24422442

2443+
// [ADR-0090 D10 #2] An MCP agent acting on behalf of a user carries the user's
2444+
// action capabilities (delegated by the `actions:execute` scope — the producer
2445+
// populates `systemPermissions` accordingly). The action gate is identity-
2446+
// agnostic, so a gated action the user can run is invokable by the agent; an
2447+
// agent whose scope did not delegate the capability is denied.
2448+
it('run_action allows a gated action for an AGENT that inherited the delegating user\'s capability', async () => {
2449+
const { bridge, executeAction } = makeBridge({
2450+
userId: 'u1', principalKind: 'agent', onBehalfOf: { userId: 'u1' },
2451+
systemPermissions: ['manage_platform_settings'],
2452+
});
2453+
const res = await bridge.runAction('issue_license', {});
2454+
expect(res.ok).toBe(true);
2455+
expect(executeAction).toHaveBeenCalledWith('todo_task', 'issueLicense', expect.anything());
2456+
});
2457+
2458+
it('run_action denies a gated action for an AGENT that did NOT inherit the capability (no actions:execute)', async () => {
2459+
const { bridge, executeAction } = makeBridge({
2460+
userId: 'u1', principalKind: 'agent', onBehalfOf: { userId: 'u1' },
2461+
systemPermissions: [],
2462+
});
2463+
await expect(bridge.runAction('issue_license', {})).rejects.toThrow(/requires capability/i);
2464+
expect(executeAction).not.toHaveBeenCalled();
2465+
});
2466+
24432467
it('run_action blocks system-object actions fail-closed (even for a system context)', async () => {
24442468
const { bridge, executeAction } = makeBridge({ isSystem: true });
24452469
await expect(bridge.runAction('rotate', { objectName: 'sys_api_key' })).rejects.toThrow(/system object/i);

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,5 +402,54 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc
402402
expect(ctx.principalKind).toBe('human');
403403
expect(ctx.onBehalfOf).toBeUndefined();
404404
});
405+
406+
// ── [ADR-0090 D10 #2] action-capability delegation ───────────────────────
407+
// A ql that resolves user `u1` to a set carrying system capabilities, so we
408+
// can see whether the agent inherits the user's ACTION authority.
409+
const capQl = () => {
410+
const tables: Record<string, any[]> = {
411+
sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }],
412+
sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }],
413+
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_users","manage_platform_settings"]', object_permissions: '{}' }],
414+
sys_position: [], sys_position_permission_set: [], sys_user_position: [],
415+
};
416+
return {
417+
async find(object: string, opts: any) {
418+
return (tables[object] ?? []).filter((row) =>
419+
Object.entries(opts?.where ?? {}).every(([k, v]) =>
420+
v !== null && typeof v === 'object'
421+
? (Array.isArray((v as any).$in) ? (v as any).$in.includes(row[k]) : true)
422+
: (v ?? null) === (row[k] ?? null),
423+
),
424+
);
425+
},
426+
};
427+
};
428+
const agentWithQl = (scopes: string[], ql: any) => ({
429+
acceptOAuthAccessToken: true,
430+
getService: async (name: string) =>
431+
name === 'auth' ? { verifyMcpAccessToken: async () => ({ userId: 'u1', scopes, clientId: 'c1' }) } : undefined,
432+
getQl: async () => ql,
433+
request: { headers: { authorization: 'Bearer a.b.c' } },
434+
});
435+
436+
it("an agent WITH actions:execute inherits the delegating user's action capabilities", async () => {
437+
const ctx = await resolveExecutionContext(agentWithQl(['data:read', 'actions:execute'], capQl()));
438+
expect(ctx.principalKind).toBe('agent');
439+
// The action gate (actionPermissionError) reads these → agent may invoke
440+
// actions the user can. Delegated because the user consented actions:execute.
441+
expect(ctx.systemPermissions).toContain('manage_users');
442+
expect(ctx.systemPermissions).toContain('manage_platform_settings');
443+
// But the DATA ceiling is still the scope-derived (read-only) set — the
444+
// agent did NOT inherit the user's admin OBJECT grants.
445+
expect(ctx.permissions).toEqual(['mcp_agent_data_read']);
446+
});
447+
448+
it('an agent WITHOUT actions:execute holds NO action capabilities (cannot ride the user\'s caps)', async () => {
449+
const ctx = await resolveExecutionContext(agentWithQl(['data:write'], capQl()));
450+
expect(ctx.principalKind).toBe('agent');
451+
expect(ctx.systemPermissions).toEqual([]);
452+
expect(ctx.permissions).toEqual(['mcp_agent_data_write']);
453+
});
405454
});
406455

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121

2222
import type { ExecutionContext } from '@objectstack/spec/kernel';
23-
import { scopesToAgentPermissionSets } from '@objectstack/spec/ai';
23+
import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai';
2424

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

0 commit comments

Comments
 (0)