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