From ed7154bc9a83ee1098c4c64b1bee0fa892d9aa03 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 11 Jul 2026 23:10:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20ADR-0090=20D10=20=E2=80=94=20?= =?UTF-8?q?delegate=20action=20capabilities=20to=20MCP=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/d10-agent-action-caps.md | 9 ++++ packages/runtime/src/http-dispatcher.test.ts | 24 +++++++++ .../resolve-execution-context.test.ts | 49 +++++++++++++++++++ .../src/security/resolve-execution-context.ts | 29 ++++++++--- 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 .changeset/d10-agent-action-caps.md diff --git a/.changeset/d10-agent-action-caps.md b/.changeset/d10-agent-action-caps.md new file mode 100644 index 0000000000..11fd2c575c --- /dev/null +++ b/.changeset/d10-agent-action-caps.md @@ -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. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 5b8ae87a02..5189e4f504 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -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); diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index fad92beadc..eeaa363356 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -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 = { + 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']); + }); }); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index e5aa4d4f1e..9abc39a737 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -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, @@ -169,11 +169,28 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise