From c57d773f0b4bcaab9aaa2c285cc73e25f970749d Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Wed, 27 May 2026 17:11:23 -0400 Subject: [PATCH 1/6] feat(interventions): add cedar intervention handler --- AGENTS.md | 6 +- package-lock.json | 12 + strands-ts/package.json | 9 + .../cedar/__tests__/cedar.test.ts | 581 ++++++++++++++++++ .../cedar/__tests__/fixtures/entities.json | 4 + .../__tests__/fixtures/env-restricted.cedar | 8 + .../cedar/__tests__/fixtures/permit-all.cedar | 2 + .../fixtures/permit-search-deny-delete.cedar | 12 + .../__tests__/fixtures/rate-limited.cedar | 8 + .../__tests__/fixtures/resource-scoped.cedar | 15 + .../cedar/__tests__/fixtures/role-based.cedar | 25 + .../cedar/__tests__/fixtures/test.cedar | 6 + .../__tests__/fixtures/time-window.cedar | 8 + .../src/vended-interventions/cedar/cedar.ts | 291 +++++++++ .../src/vended-interventions/cedar/index.ts | 7 + strands-ts/test/integ/cedar.test.node.ts | 98 +++ 16 files changed, 1091 insertions(+), 1 deletion(-) create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/env-restricted.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-all.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-search-deny-delete.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/rate-limited.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/test.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar create mode 100644 strands-ts/src/vended-interventions/cedar/cedar.ts create mode 100644 strands-ts/src/vended-interventions/cedar/index.ts create mode 100644 strands-ts/test/integ/cedar.test.node.ts diff --git a/AGENTS.md b/AGENTS.md index 1c16778b78..fa225361ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,10 @@ sdk-typescript/ │ │ │ └── shell-quote.ts # Shell-safe string escaping │ │ │ │ │ ├── vended-interventions/ # Optional vended intervention handlers +│ │ │ ├── cedar/ # Cedar authorization handler +│ │ │ │ ├── __tests__/ +│ │ │ │ ├── cedar.ts +│ │ │ │ └── index.ts │ │ │ ├── hitl/ # Human-in-the-loop approval handler │ │ │ │ ├── __tests__/ │ │ │ │ ├── hitl.ts @@ -370,7 +374,7 @@ sdk-typescript/ - **`strands-ts/src/tools/`**: Tool definitions, types, and structured output validation with Zod schemas - **`strands-ts/src/types/`**: Core type definitions used across the SDK - **`strands-ts/src/utils/`**: Shared utility functions -- **`strands-ts/src/vended-interventions/`**: Optional vended intervention handlers (hitl, steering — not part of core SDK, independently importable) +- **`strands-ts/src/vended-interventions/`**: Optional vended intervention handlers (cedar, hitl, steering — not part of core SDK, independently importable) - **`strands-ts/src/vended-plugins/`**: Optional vended plugins (context-offloader, skills — not part of core SDK, independently importable) - **`strands-ts/src/vended-tools/`**: Optional vended tools (bash, file-editor, http-request, notebook) - **`strands-ts/generated/`**: Auto-generated WIT interface type declarations diff --git a/package-lock.json b/package-lock.json index 6ce38e70bf..16c7075a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1539,6 +1539,13 @@ "wizer-darwin-arm64": "wizer" } }, + "node_modules/@cedar-policy/cedar-wasm": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@cedar-policy/cedar-wasm/-/cedar-wasm-4.11.0.tgz", + "integrity": "sha512-jIpMGPXKu6spe9PMXdSUqFMK5nJmAyTxRKRA2eegxQhGRRjDrbPuQB2C0+ARGs2OjqhoexgGODf8Y/rPACQI7A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@chaynabors/componentize-js": { "version": "0.19.3", "dev": true, @@ -8645,6 +8652,7 @@ "@aws-sdk/client-sts": "^3.996.0", "@aws-sdk/credential-providers": "^3.943.0", "@aws/bedrock-token-generator": "^1.1.0", + "@cedar-policy/cedar-wasm": "^4.11.0", "@eslint/js": "^9.39.4", "@google/genai": "^1.40.0", "@opentelemetry/api": "^1.9.0", @@ -8681,6 +8689,7 @@ "@anthropic-ai/sdk": "^0.92.0", "@aws-sdk/client-s3": "^3.943.0", "@aws/bedrock-token-generator": "^1.1.0", + "@cedar-policy/cedar-wasm": "^4.0.0", "@google/genai": "^1.40.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", @@ -8711,6 +8720,9 @@ "@aws/bedrock-token-generator": { "optional": true }, + "@cedar-policy/cedar-wasm": { + "optional": true + }, "@google/genai": { "optional": true }, diff --git a/strands-ts/package.json b/strands-ts/package.json index 4060223f96..25a162e3d0 100644 --- a/strands-ts/package.json +++ b/strands-ts/package.json @@ -95,6 +95,10 @@ "./vended-plugins": { "types": "./dist/src/vended-plugins/index.d.ts", "default": "./dist/src/vended-plugins/index.js" + }, + "./vended-interventions/cedar": { + "types": "./dist/src/vended-interventions/cedar/index.d.ts", + "default": "./dist/src/vended-interventions/cedar/index.js" } }, "scripts": { @@ -145,6 +149,7 @@ "@aws-sdk/client-sts": "^3.996.0", "@aws-sdk/credential-providers": "^3.943.0", "@aws/bedrock-token-generator": "^1.1.0", + "@cedar-policy/cedar-wasm": "^4.11.0", "@eslint/js": "^9.39.4", "@google/genai": "^1.40.0", "@opentelemetry/api": "^1.9.0", @@ -195,6 +200,7 @@ "@anthropic-ai/sdk": "^0.92.0", "@aws-sdk/client-s3": "^3.943.0", "@aws/bedrock-token-generator": "^1.1.0", + "@cedar-policy/cedar-wasm": "^4.0.0", "@google/genai": "^1.40.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", @@ -254,6 +260,9 @@ }, "@opentelemetry/exporter-metrics-otlp-http": { "optional": true + }, + "@cedar-policy/cedar-wasm": { + "optional": true } }, "overrides": { diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts new file mode 100644 index 0000000000..5dc68f7444 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts @@ -0,0 +1,581 @@ +import { describe, expect, it, vi } from 'vitest' +import { CedarAuthorization } from '../cedar.js' +import { Agent } from '../../../agent/agent.js' +import { MockMessageModel } from '../../../__fixtures__/mock-message-model.js' +import { createMockTool } from '../../../__fixtures__/tool-helpers.js' +import { resolve } from 'node:path' + +const FIXTURES = resolve(import.meta.dirname!, 'fixtures') + +describe('CedarAuthorization', () => { + describe('real Cedar evaluation', () => { + + const entities = [ + { uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }, + { uid: { type: 'User', id: 'alice' }, attrs: { role: 'admin' }, parents: [] }, + { uid: { type: 'User', id: 'bob' }, attrs: { role: 'analyst' }, parents: [] }, + { uid: { type: 'User', id: 'eve' }, attrs: { role: 'viewer' }, parents: [] }, + ] + + it('allows permitted tool calls', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: { query: 'test' } }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'results' + }) + + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/test.cedar`, + entities, + principalResolver: (state) => { + if (!state.user_id) return undefined + return { type: 'User', id: String(state.user_id) } + }, + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + const result = await agent.invoke('Search', { invocationState: { user_id: 'alice' } }) + + expect(result.stopReason).toBe('endTurn') + expect(toolExecuted).toBe(true) + }) + + it('denies tools not in any permit policy (default-deny)', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete_record', toolUseId: 'tool-1', input: { id: '1' } }) + .addTurn({ type: 'textBlock', text: 'Ok' }) + + let toolExecuted = false + const tool = createMockTool('delete_record', () => { + toolExecuted = true + return 'deleted' + }) + + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/test.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Delete it', { invocationState: {} }) + + expect(toolExecuted).toBe(false) + }) + + it('enforces role-based access (admin can delete, analyst cannot)', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete_record', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('delete_record', () => { + toolExecuted = true + return 'deleted' + }) + + // Admin can delete + const cedarAdmin = new CedarAuthorization({ + policies: `${FIXTURES}/role-based.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agentAdmin = new Agent({ model, tools: [tool], interventions: [cedarAdmin], printer: false }) + await agentAdmin.invoke('Delete', { invocationState: {} }) + expect(toolExecuted).toBe(true) + + // Analyst cannot delete + toolExecuted = false + const model2 = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete_record', toolUseId: 'tool-2', input: {} }) + .addTurn({ type: 'textBlock', text: 'Denied' }) + + const cedarAnalyst = new CedarAuthorization({ + policies: `${FIXTURES}/role-based.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'bob' }), + }) + + const agentAnalyst = new Agent({ model: model2, tools: [tool], interventions: [cedarAnalyst], printer: false }) + await agentAnalyst.invoke('Delete', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + + it('enforces role-based access (analyst can search, viewer cannot)', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'found' + }) + + // Analyst can search + const cedarAnalyst = new CedarAuthorization({ + policies: `${FIXTURES}/role-based.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'bob' }), + }) + + const agent1 = new Agent({ model, tools: [tool], interventions: [cedarAnalyst], printer: false }) + await agent1.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(true) + + // Viewer cannot search + toolExecuted = false + const model2 = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-2', input: {} }) + .addTurn({ type: 'textBlock', text: 'Denied' }) + + const cedarViewer = new CedarAuthorization({ + policies: `${FIXTURES}/role-based.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'eve' }), + }) + + const agent2 = new Agent({ model: model2, tools: [tool], interventions: [cedarViewer], printer: false }) + await agent2.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + + it('enforces rate limits via call_count in session context', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-2', input: {} }) + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-3', input: {} }) + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-4', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let callCount = 0 + const tool = createMockTool('send_email', () => { + callCount++ + return 'sent' + }) + + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/rate-limited.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Send 4 emails', { invocationState: {} }) + + // Policy allows call_count < 3, so calls 1 and 2 succeed, 3+ denied + expect(callCount).toBe(2) + }) + + it('enforces environment restrictions', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'results' + }) + + // Non-production: allowed + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/env-restricted.cedar`, + entities, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Search', { invocationState: { environment: 'development' } }) + expect(toolExecuted).toBe(true) + + // Production: denied + toolExecuted = false + const model2 = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-2', input: {} }) + .addTurn({ type: 'textBlock', text: 'Denied' }) + + const agent2 = new Agent({ model: model2, tools: [tool], interventions: [cedar], printer: false }) + await agent2.invoke('Search', { invocationState: { environment: 'production' } }) + expect(toolExecuted).toBe(false) + }) + + it('denies when principal is missing (fail-closed)', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Ok' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'results' + }) + + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/test.cedar`, + entities, + principalResolver: (state) => { + if (!state.user_id) return undefined + return { type: 'User', id: String(state.user_id) } + }, + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + + it('denies on malformed policy (evaluation failure)', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Ok' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'results' + }) + + const cedar = new CedarAuthorization({ + policies: 'this is not valid cedar syntax at all!!!', + entities, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + }) + + describe('resource resolution', () => { + it('defaults resource to McpServer', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + // Policy permits on McpServer resource + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"search", resource is McpServer);', + entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Go', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('uses custom resourceId', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + // Policy permits only on specific McpServer + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource == McpServer::"my-agent");', + entities: [{ uid: { type: 'McpServer', id: 'my-agent' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + resourceId: 'my-agent', + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Go', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('uses record-based resource resolver', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete', toolUseId: 'tool-1', input: { record_id: '42' } }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('delete', () => { + toolExecuted = true + return 'deleted' + }) + + // Policy permits deleting Record::"42" specifically + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"delete", resource == Record::"42");', + entities: [{ uid: { type: 'Record', id: '42' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + resourceResolver: { delete: { key: 'record_id', type: 'Record' } }, + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Delete', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('denies when resource resolver maps to unauthorized resource', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete', toolUseId: 'tool-1', input: { record_id: '99' } }) + .addTurn({ type: 'textBlock', text: 'Denied' }) + + let toolExecuted = false + const tool = createMockTool('delete', () => { + toolExecuted = true + return 'deleted' + }) + + // Policy only permits Record::"42" + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"delete", resource == Record::"42");', + entities: [ + { uid: { type: 'Record', id: '42' }, attrs: {}, parents: [] }, + { uid: { type: 'Record', id: '99' }, attrs: {}, parents: [] }, + ], + principalResolver: () => ({ type: 'User', id: 'alice' }), + resourceResolver: { delete: { key: 'record_id', type: 'Record' } }, + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Delete 99', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + }) + + describe('context enricher', () => { + it('adds custom fields usable in policies', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + // Policy checks custom context field + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource) when { context.session.department == "engineering" };', + entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + contextEnricher: () => ({ department: 'engineering' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Go', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('denies when enricher value does not match policy', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Denied' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource) when { context.session.department == "engineering" };', + entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + contextEnricher: () => ({ department: 'marketing' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Go', { invocationState: {} }) + expect(toolExecuted).toBe(false) + }) + }) + + describe('onError behavior', () => { + it('throws by default when handler errors', async () => { + vi.mock('@cedar-policy/cedar-wasm/nodejs', async (importOriginal) => { + const orig = await importOriginal() + return { ...orig } + }) + + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + const tool = createMockTool('tool', () => 'ok') + + // principalResolver throws + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource);', + principalResolver: () => { + throw new Error('resolver crash') + }, + onError: 'throw', + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await expect(agent.invoke('Go', { invocationState: {} })).rejects.toThrow('resolver crash') + }) + + it('denies when onError is "deny" and handler throws', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('tool', () => { + toolExecuted = true + return 'ok' + }) + + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource);', + principalResolver: () => { + throw new Error('resolver crash') + }, + onError: 'deny', + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + const result = await agent.invoke('Go', { invocationState: {} }) + expect(result.stopReason).toBe('endTurn') + expect(toolExecuted).toBe(false) + }) + + it('proceeds when onError is "proceed" and handler throws', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('tool', () => { + toolExecuted = true + return 'ok' + }) + + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource);', + principalResolver: () => { + throw new Error('resolver crash') + }, + onError: 'proceed', + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + const result = await agent.invoke('Go', { invocationState: {} }) + expect(result.stopReason).toBe('endTurn') + expect(toolExecuted).toBe(true) + }) + }) + + describe('file-based config', () => { + it('reads .cedar file from disk', async () => { + const fixturesDir = import.meta.url.replace('file://', '').replace('/cedar.test.ts', '/fixtures') + + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + const cedar = new CedarAuthorization({ + policies: `${fixturesDir}/test.cedar`, + entities: [ + { uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }, + { uid: { type: 'User', id: 'alice' }, attrs: { role: 'analyst' }, parents: [] }, + ], + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('reads .json entity file from disk', async () => { + const fixturesDir = import.meta.url.replace('file://', '').replace('/cedar.test.ts', '/fixtures') + + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('search', () => { + toolExecuted = true + return 'ok' + }) + + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"search", resource);', + entities: `${fixturesDir}/entities.json`, + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Search', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) + + it('treats non-existent .cedar path as inline policy text', () => { + const cedar = new CedarAuthorization({ + policies: '/nonexistent/path.cedar', + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + expect(cedar.name).toBe('cedar-authorization') + }) + }) + + describe('session management', () => { + it('resetSession clears call counts', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-1', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + const tool = createMockTool('send_email', () => 'sent') + + // Rate limit: < 2 calls allowed + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action, resource) when { context.session.call_count < 2 };', + entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + + // First call succeeds + const agent1 = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent1.invoke('Send', { invocationState: { session_id: 'sess1' } }) + + // Reset the session + cedar.resetSession('sess1') + + // Next call succeeds again (counter reset) + let toolExecuted = false + const model2 = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'send_email', toolUseId: 'tool-2', input: {} }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + const tool2 = createMockTool('send_email', () => { + toolExecuted = true + return 'sent' + }) + + const agent2 = new Agent({ model: model2, tools: [tool2], interventions: [cedar], printer: false }) + await agent2.invoke('Send again', { invocationState: { session_id: 'sess1' } }) + expect(toolExecuted).toBe(true) + }) + }) +}) diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json new file mode 100644 index 0000000000..d3e5b801a0 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json @@ -0,0 +1,4 @@ +[ + { "uid": { "type": "McpServer", "id": "test-server" }, "attrs": {}, "parents": [] }, + { "uid": { "type": "User", "id": "alice" }, "attrs": { "role": "admin" }, "parents": [] } +] diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/env-restricted.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/env-restricted.cedar new file mode 100644 index 0000000000..27c2d98b12 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/env-restricted.cedar @@ -0,0 +1,8 @@ +// Only allow tools outside production +permit( + principal, + action, + resource +) when { + context.session.environment != "production" +}; diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-all.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-all.cedar new file mode 100644 index 0000000000..dfccd67b48 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-all.cedar @@ -0,0 +1,2 @@ +// Permit all actions for any principal +permit(principal, action, resource); diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-search-deny-delete.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-search-deny-delete.cedar new file mode 100644 index 0000000000..856a799653 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/permit-search-deny-delete.cedar @@ -0,0 +1,12 @@ +// Allow search, explicitly deny delete +permit( + principal, + action == Action::"search", + resource +); + +forbid( + principal, + action == Action::"delete_record", + resource +); diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/rate-limited.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/rate-limited.cedar new file mode 100644 index 0000000000..a60025dbb8 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/rate-limited.cedar @@ -0,0 +1,8 @@ +// Allow sending emails, but cap at 3 per session +permit( + principal, + action == Action::"send_email", + resource +) when { + context.session.call_count < 3 +}; diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar new file mode 100644 index 0000000000..a9e6dd1abc --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar @@ -0,0 +1,15 @@ +// Allow deleting only records you own +permit( + principal, + action == Action::"delete", + resource is Record +) when { + resource.owner == principal +}; + +// Allow reading any record +permit( + principal, + action == Action::"read", + resource is Record +); diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar new file mode 100644 index 0000000000..01b9ba9e0f --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar @@ -0,0 +1,25 @@ +// Admins can use any tool +permit( + principal is User, + action, + resource is McpServer +) when { + principal.role == "admin" +}; + +// Analysts can only search and query +permit( + principal is User, + action == Action::"search", + resource is McpServer +) when { + principal.role == "analyst" +}; + +permit( + principal is User, + action == Action::"query_database", + resource is McpServer +) when { + principal.role == "analyst" +}; diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/test.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/test.cedar new file mode 100644 index 0000000000..8e3eb5c97d --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/test.cedar @@ -0,0 +1,6 @@ +// Simple policy: allow search for any principal +permit( + principal, + action == Action::"search", + resource +); diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar new file mode 100644 index 0000000000..0df2d3cb06 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar @@ -0,0 +1,8 @@ +// Allow all tools during business hours (9-17 UTC) +permit( + principal, + action, + resource +) when { + context.session.hour_utc >= 9 && context.session.hour_utc < 17 +}; diff --git a/strands-ts/src/vended-interventions/cedar/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts new file mode 100644 index 0000000000..48875fe1a8 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -0,0 +1,291 @@ +import { InterventionHandler } from '../../interventions/handler.js' +import { proceed, deny } from '../../interventions/actions.js' +import type { InterventionAction } from '../../interventions/actions.js' +import type { BeforeToolCallEvent } from '../../hooks/events.js' +import type { OnError } from '../../interventions/handler.js' +import type { JSONValue } from '../../types/json.js' +import { isAuthorized } from '@cedar-policy/cedar-wasm/nodejs' +import { readFileSync, existsSync } from 'node:fs' + +/** + * A {@link https://docs.cedarpolicy.com/syntax-entity.html | Cedar entity} identifier + * consisting of a type and id. + * + * @example + * ```typescript + * const principal: CedarEntityUid = { type: 'User', id: 'alice@acme.com' } + * const resource: CedarEntityUid = { type: 'McpServer', id: 'my-agent' } + * ``` + */ +export interface CedarEntityUid { + type: string + id: string +} + +/** + * A {@link https://docs.cedarpolicy.com/syntax-entity.html | Cedar entity} with + * attributes and parent relationships. + * + * @example + * ```typescript + * const entity: CedarEntity = { + * uid: { type: 'User', id: 'alice' }, + * attrs: { role: 'admin', department: 'engineering' }, + * parents: [{ type: 'Role', id: 'admin' }], + * } + * ``` + */ +export interface CedarEntity { + uid: CedarEntityUid + attrs: Record + parents: CedarEntityUid[] +} + +/** + * Maps tool names to Cedar resources. Either a static record mapping tool names + * to entity type/key pairs, or a function for dynamic resolution. + * + * @example + * ```typescript + * // Static: tool "delete_record" resolves resource from input.record_id + * const resolver: ResourceResolver = { + * delete_record: { key: 'record_id', type: 'Record' }, + * } + * + * // Dynamic: custom logic per tool + * const resolver: ResourceResolver = (toolName, input) => { + * return { type: 'Record', id: String(input.id) } + * } + * ``` + */ +export type ResourceResolver = + | Record + | ((toolName: string, toolInput: Record) => CedarEntityUid) + +/** + * Configuration for the {@link CedarAuthorization} intervention handler. + * + * @see {@link https://docs.cedarpolicy.com/syntax-policy.html | Cedar policy syntax} + */ +export interface CedarAuthorizationConfig { + /** + * Cedar policy text, or a path to a `.cedar` file on disk. + * + * @example + * ```typescript + * // Inline policy + * { policies: 'permit(principal, action == Action::"search", resource);' } + * + * // File path + * { policies: './policies/agent.cedar' } + * ``` + */ + policies: string + + /** + * Entity data as an array, or a path to a `.json` file on disk. + * Entities define the principal/resource hierarchy for Cedar evaluation. + */ + entities?: CedarEntity[] | string + + /** + * Resolves the Cedar principal from the agent's invocationState. + * Return `undefined` to deny the request (fail-closed). + * + * @example + * ```typescript + * principalResolver: (state) => { + * if (!state.user_id) return undefined + * return { type: 'User', id: String(state.user_id) } + * } + * ``` + */ + principalResolver: (invocationState: Record) => CedarEntityUid | undefined + + /** + * Maps tool calls to Cedar resources. When omitted, all tools resolve + * to `McpServer::""`. + */ + resourceResolver?: ResourceResolver | undefined + + /** + * ID of the agent/server resource entity. Defaults to `'strands-agent'`. + */ + resourceId?: string | undefined + + /** + * Adds extra fields to the `context.session` object passed to Cedar. + * Called on every tool invocation. + */ + contextEnricher?: + | ((context: { toolName: string; toolInput: Record }) => Record) + | undefined + + /** + * What to do when the handler throws during evaluation. + * - `'throw'` (default) — rethrow the error + * - `'deny'` — treat errors as denials (fail-closed) + * - `'proceed'` — ignore errors and allow the tool call + */ + onError?: OnError | undefined +} + +/** + * Cedar authorization intervention handler. + * + * Evaluates {@link https://cedarpolicy.com | Cedar} policies before each tool call + * using {@link https://www.npmjs.com/package/@cedar-policy/cedar-wasm | @cedar-policy/cedar-wasm}. + * + * Uses the {@link https://github.com/cedar-policy/cedar-for-agents | cedar-for-agents} + * schema generator conventions: + * - One Cedar action per tool (e.g. `Action::"search"`) + * - Resource defaults to `McpServer::""` + * - Context is nested: `{ input: , session: { hour_utc, call_count, ... } }` + * + * @see {@link https://docs.cedarpolicy.com/syntax-policy.html | Cedar policy syntax} + * @see {@link https://docs.cedarpolicy.com/syntax-entity.html | Cedar entity model} + * + * @example + * ```typescript + * import { CedarAuthorization } from '@strands-agents/sdk/vended-interventions/cedar' + * + * const cedar = new CedarAuthorization({ + * policies: './policies/agent.cedar', + * entities: './policies/entities.json', + * principalResolver: (state) => { + * if (!state.user_id) return undefined + * return { type: 'User', id: String(state.user_id) } + * }, + * }) + * + * const agent = new Agent({ + * tools: [searchTool, deleteTool], + * interventions: [cedar], + * }) + * ``` + */ +export class CedarAuthorization extends InterventionHandler { + readonly name = 'cedar-authorization' + override readonly onError: OnError + + private readonly _policies: string + private readonly _entities: CedarEntity[] + private readonly _principalResolver: (invocationState: Record) => CedarEntityUid | undefined + private readonly _resourceResolver: ResourceResolver | undefined + private readonly _resourceId: string + private readonly _contextEnricher: CedarAuthorizationConfig['contextEnricher'] + private readonly _callCounts = new Map>() + private readonly _maxSessions = 1000 + + constructor(config: CedarAuthorizationConfig) { + super() + this._policies = loadPolicies(config.policies) + this._entities = loadEntities(config.entities) + this._principalResolver = config.principalResolver + this._resourceResolver = config.resourceResolver + this._resourceId = config.resourceId ?? 'strands-agent' + this._contextEnricher = config.contextEnricher + this.onError = config.onError ?? 'throw' + } + + override beforeToolCall(event: BeforeToolCallEvent): InterventionAction { + + const invocationState = event.invocationState as Record + const principal = this._principalResolver(invocationState) + if (!principal) { + return deny('No principal identity found in invocation state') + } + + const sessionId = (invocationState.session_id as string | undefined) ?? '_default' + const callCount = this._incrementCallCount(sessionId, event.toolUse.name) + const toolInput = (event.toolUse.input ?? {}) as Record + const resource = this._resolveResource(event.toolUse.name, toolInput) + const env = invocationState.environment as string | undefined + + const result = isAuthorized({ + principal, + action: { type: 'Action', id: event.toolUse.name }, + resource, + context: { + input: toolInput, + session: { + hour_utc: new Date().getUTCHours(), + call_count: callCount, + ...(env !== undefined && { environment: env }), + ...(this._contextEnricher ? this._contextEnricher({ toolName: event.toolUse.name, toolInput }) : {}), + }, + }, + policies: { staticPolicies: this._policies }, + entities: this._entities.map((e) => ({ + uid: e.uid, + attrs: e.attrs as Record, + parents: e.parents, + })), + }) + + if (result.type === 'failure') { + return deny(`Cedar evaluation failed: ${result.errors.map((e) => e.message).join(', ')}`) + } + + if (result.response.decision === 'deny') { + const reasons = result.response.diagnostics.reason + return deny(`Access denied by Cedar policy${reasons.length ? `: ${reasons.join(', ')}` : ''}`) + } + + return proceed() + } + + /** + * Clears the rate-limit call counters for a given session. + * Call this when a session ends to free memory. + */ + resetSession(sessionId: string): void { + this._callCounts.delete(sessionId) + } + + private _resolveResource(toolName: string, toolInput: Record): CedarEntityUid { + if (!this._resourceResolver) { + return { type: 'McpServer', id: this._resourceId } + } + if (typeof this._resourceResolver === 'function') { + return this._resourceResolver(toolName, toolInput) + } + const mapping = this._resourceResolver[toolName] + if (!mapping) { + return { type: 'McpServer', id: this._resourceId } + } + const id = toolInput[mapping.key] + return { type: mapping.type, id: String(id ?? toolName) } + } + + private _incrementCallCount(sessionId: string, toolName: string): number { + let session = this._callCounts.get(sessionId) + if (!session) { + if (this._callCounts.size >= this._maxSessions) { + const oldest = this._callCounts.keys().next().value! + this._callCounts.delete(oldest) + } + session = new Map() + this._callCounts.set(sessionId, session) + } + const next = (session.get(toolName) ?? 0) + 1 + session.set(toolName, next) + return next + } + +} + +function loadPolicies(policies: string): string { + if (policies.endsWith('.cedar') && existsSync(policies)) { + return readFileSync(policies, 'utf-8') + } + return policies +} + +function loadEntities(entities: CedarEntity[] | string | undefined): CedarEntity[] { + if (!entities) return [] + if (typeof entities === 'string') { + return JSON.parse(readFileSync(entities, 'utf-8')) as CedarEntity[] + } + return entities +} diff --git a/strands-ts/src/vended-interventions/cedar/index.ts b/strands-ts/src/vended-interventions/cedar/index.ts new file mode 100644 index 0000000000..74a0551d9c --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/index.ts @@ -0,0 +1,7 @@ +export { CedarAuthorization } from './cedar.js' +export type { + CedarAuthorizationConfig, + CedarEntityUid, + CedarEntity, + ResourceResolver, +} from './cedar.js' diff --git a/strands-ts/test/integ/cedar.test.node.ts b/strands-ts/test/integ/cedar.test.node.ts new file mode 100644 index 0000000000..2902c3252b --- /dev/null +++ b/strands-ts/test/integ/cedar.test.node.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { Agent, tool } from '@strands-agents/sdk' +import { CedarAuthorization } from '$/sdk/vended-interventions/cedar/index.js' +import { z } from 'zod' +import { resolve } from 'node:path' +import { bedrock } from './__fixtures__/model-providers.js' + +const FIXTURES = resolve(import.meta.dirname!, '../../../src/vended-interventions/cedar/__tests__/fixtures') + +const searchTool = tool({ + name: 'search', + description: 'Search for information. Always use this tool when asked to search.', + inputSchema: z.object({ query: z.string().describe('Search query') }), + callback: (input) => `Results for: ${input.query}`, +}) + +const deleteTool = tool({ + name: 'delete_record', + description: 'Delete a database record by ID. Always use this tool when asked to delete.', + inputSchema: z.object({ record_id: z.string().describe('Record ID to delete') }), + callback: (input) => `Deleted record ${input.record_id}`, +}) + +describe('CedarAuthorization integration', () => { + describe.skipIf(bedrock.skip)('with Bedrock', () => { + it('allows tool execution when policy permits', async () => { + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/test.cedar`, + entities: `${FIXTURES}/entities.json`, + principalResolver: (state) => { + if (!state.user_id) return undefined + return { type: 'User', id: String(state.user_id) } + }, + }) + + const agent = new Agent({ + model: bedrock.createModel(), + tools: [searchTool], + interventions: [cedar], + printer: false, + }) + + const result = await agent.invoke('Search for "cedar policy"', { + invocationState: { user_id: 'alice' }, + }) + + expect(result.stopReason).toBe('endTurn') + }) + + it('denies tool execution when policy forbids', async () => { + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/permit-search-deny-delete.cedar`, + entities: `${FIXTURES}/entities.json`, + principalResolver: (state) => { + if (!state.user_id) return undefined + return { type: 'User', id: String(state.user_id) } + }, + }) + + const agent = new Agent({ + model: bedrock.createModel(), + tools: [searchTool, deleteTool], + interventions: [cedar], + printer: false, + }) + + const result = await agent.invoke('Delete record 42', { + invocationState: { user_id: 'alice' }, + }) + + expect(result.stopReason).toBe('endTurn') + }) + + it('denies all tools when no principal identity (fail-closed)', async () => { + const cedar = new CedarAuthorization({ + policies: `${FIXTURES}/permit-all.cedar`, + entities: `${FIXTURES}/entities.json`, + principalResolver: (state) => { + if (!state.user_id) return undefined + return { type: 'User', id: String(state.user_id) } + }, + }) + + const agent = new Agent({ + model: bedrock.createModel(), + tools: [searchTool], + interventions: [cedar], + printer: false, + }) + + const result = await agent.invoke('Search for something', { + invocationState: {}, + }) + + expect(result.stopReason).toBe('endTurn') + }) + }) +}) From c73026ddc2e9292f74575a17edbb884a163f8c42 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 28 May 2026 16:41:40 -0400 Subject: [PATCH 2/6] fix(cedar): use package types for entity serialization --- strands-ts/src/vended-interventions/cedar/cedar.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/strands-ts/src/vended-interventions/cedar/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts index 48875fe1a8..817eb3ee94 100644 --- a/strands-ts/src/vended-interventions/cedar/cedar.ts +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -4,7 +4,7 @@ import type { InterventionAction } from '../../interventions/actions.js' import type { BeforeToolCallEvent } from '../../hooks/events.js' import type { OnError } from '../../interventions/handler.js' import type { JSONValue } from '../../types/json.js' -import { isAuthorized } from '@cedar-policy/cedar-wasm/nodejs' +import { isAuthorized, type Entities } from '@cedar-policy/cedar-wasm/nodejs' import { readFileSync, existsSync } from 'node:fs' /** @@ -216,11 +216,7 @@ export class CedarAuthorization extends InterventionHandler { }, }, policies: { staticPolicies: this._policies }, - entities: this._entities.map((e) => ({ - uid: e.uid, - attrs: e.attrs as Record, - parents: e.parents, - })), + entities: this._entities as unknown as Entities, }) if (result.type === 'failure') { From a2ba4f24b8dd75f8725c6baeddde506b4d8f3aa4 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 28 May 2026 16:49:27 -0400 Subject: [PATCH 3/6] refactor(cedar): remove McpServer default, use unconstrained resource --- .../cedar/__tests__/cedar.test.ts | 42 ++++--------------- .../cedar/__tests__/fixtures/entities.json | 2 +- .../cedar/__tests__/fixtures/role-based.cedar | 6 +-- .../src/vended-interventions/cedar/cedar.ts | 18 +++----- 4 files changed, 19 insertions(+), 49 deletions(-) diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts index 5dc68f7444..9c14143f01 100644 --- a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts +++ b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts @@ -11,7 +11,7 @@ describe('CedarAuthorization', () => { describe('real Cedar evaluation', () => { const entities = [ - { uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }, + { uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }, { uid: { type: 'User', id: 'alice' }, attrs: { role: 'admin' }, parents: [] }, { uid: { type: 'User', id: 'bob' }, attrs: { role: 'analyst' }, parents: [] }, { uid: { type: 'User', id: 'eve' }, attrs: { role: 'viewer' }, parents: [] }, @@ -254,7 +254,7 @@ describe('CedarAuthorization', () => { }) describe('resource resolution', () => { - it('defaults resource to McpServer', async () => { + it('defaults resource to Resource::"agent"', async () => { const model = new MockMessageModel() .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) .addTurn({ type: 'textBlock', text: 'Done' }) @@ -265,35 +265,11 @@ describe('CedarAuthorization', () => { return 'ok' }) - // Policy permits on McpServer resource + // Policy permits any resource — works with the default const cedar = new CedarAuthorization({ - policies: 'permit(principal, action == Action::"search", resource is McpServer);', - entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], - principalResolver: () => ({ type: 'User', id: 'alice' }), - }) - - const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) - await agent.invoke('Go', { invocationState: {} }) - expect(toolExecuted).toBe(true) - }) - - it('uses custom resourceId', async () => { - const model = new MockMessageModel() - .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) - .addTurn({ type: 'textBlock', text: 'Done' }) - - let toolExecuted = false - const tool = createMockTool('search', () => { - toolExecuted = true - return 'ok' - }) - - // Policy permits only on specific McpServer - const cedar = new CedarAuthorization({ - policies: 'permit(principal, action, resource == McpServer::"my-agent");', - entities: [{ uid: { type: 'McpServer', id: 'my-agent' }, attrs: {}, parents: [] }], + policies: 'permit(principal, action == Action::"search", resource);', + entities: [{ uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }], principalResolver: () => ({ type: 'User', id: 'alice' }), - resourceId: 'my-agent', }) const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) @@ -368,7 +344,7 @@ describe('CedarAuthorization', () => { // Policy checks custom context field const cedar = new CedarAuthorization({ policies: 'permit(principal, action, resource) when { context.session.department == "engineering" };', - entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + entities: [{ uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }], principalResolver: () => ({ type: 'User', id: 'alice' }), contextEnricher: () => ({ department: 'engineering' }), }) @@ -391,7 +367,7 @@ describe('CedarAuthorization', () => { const cedar = new CedarAuthorization({ policies: 'permit(principal, action, resource) when { context.session.department == "engineering" };', - entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + entities: [{ uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }], principalResolver: () => ({ type: 'User', id: 'alice' }), contextEnricher: () => ({ department: 'marketing' }), }) @@ -496,7 +472,7 @@ describe('CedarAuthorization', () => { const cedar = new CedarAuthorization({ policies: `${fixturesDir}/test.cedar`, entities: [ - { uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }, + { uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }, { uid: { type: 'User', id: 'alice' }, attrs: { role: 'analyst' }, parents: [] }, ], principalResolver: () => ({ type: 'User', id: 'alice' }), @@ -551,7 +527,7 @@ describe('CedarAuthorization', () => { // Rate limit: < 2 calls allowed const cedar = new CedarAuthorization({ policies: 'permit(principal, action, resource) when { context.session.call_count < 2 };', - entities: [{ uid: { type: 'McpServer', id: 'strands-agent' }, attrs: {}, parents: [] }], + entities: [{ uid: { type: 'Resource', id: 'agent' }, attrs: {}, parents: [] }], principalResolver: () => ({ type: 'User', id: 'alice' }), }) diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json index d3e5b801a0..78fa980a3d 100644 --- a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json @@ -1,4 +1,4 @@ [ - { "uid": { "type": "McpServer", "id": "test-server" }, "attrs": {}, "parents": [] }, + { "uid": { "type": "Resource", "id": "agent" }, "attrs": {}, "parents": [] }, { "uid": { "type": "User", "id": "alice" }, "attrs": { "role": "admin" }, "parents": [] } ] diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar index 01b9ba9e0f..a9774bc336 100644 --- a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar @@ -2,7 +2,7 @@ permit( principal is User, action, - resource is McpServer + resource ) when { principal.role == "admin" }; @@ -11,7 +11,7 @@ permit( permit( principal is User, action == Action::"search", - resource is McpServer + resource ) when { principal.role == "analyst" }; @@ -19,7 +19,7 @@ permit( permit( principal is User, action == Action::"query_database", - resource is McpServer + resource ) when { principal.role == "analyst" }; diff --git a/strands-ts/src/vended-interventions/cedar/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts index 817eb3ee94..d911883cd1 100644 --- a/strands-ts/src/vended-interventions/cedar/cedar.ts +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -103,16 +103,12 @@ export interface CedarAuthorizationConfig { principalResolver: (invocationState: Record) => CedarEntityUid | undefined /** - * Maps tool calls to Cedar resources. When omitted, all tools resolve - * to `McpServer::""`. + * Maps tool calls to Cedar resources. When omitted, the resource is + * unconstrained (`Resource::"agent"`). Use this to map tools to + * domain-specific entities (e.g. `Record::"42"`). */ resourceResolver?: ResourceResolver | undefined - /** - * ID of the agent/server resource entity. Defaults to `'strands-agent'`. - */ - resourceId?: string | undefined - /** * Adds extra fields to the `context.session` object passed to Cedar. * Called on every tool invocation. @@ -139,7 +135,7 @@ export interface CedarAuthorizationConfig { * Uses the {@link https://github.com/cedar-policy/cedar-for-agents | cedar-for-agents} * schema generator conventions: * - One Cedar action per tool (e.g. `Action::"search"`) - * - Resource defaults to `McpServer::""` + * - Resource is unconstrained by default (use `resourceResolver` for domain objects) * - Context is nested: `{ input: , session: { hour_utc, call_count, ... } }` * * @see {@link https://docs.cedarpolicy.com/syntax-policy.html | Cedar policy syntax} @@ -172,7 +168,6 @@ export class CedarAuthorization extends InterventionHandler { private readonly _entities: CedarEntity[] private readonly _principalResolver: (invocationState: Record) => CedarEntityUid | undefined private readonly _resourceResolver: ResourceResolver | undefined - private readonly _resourceId: string private readonly _contextEnricher: CedarAuthorizationConfig['contextEnricher'] private readonly _callCounts = new Map>() private readonly _maxSessions = 1000 @@ -183,7 +178,6 @@ export class CedarAuthorization extends InterventionHandler { this._entities = loadEntities(config.entities) this._principalResolver = config.principalResolver this._resourceResolver = config.resourceResolver - this._resourceId = config.resourceId ?? 'strands-agent' this._contextEnricher = config.contextEnricher this.onError = config.onError ?? 'throw' } @@ -241,14 +235,14 @@ export class CedarAuthorization extends InterventionHandler { private _resolveResource(toolName: string, toolInput: Record): CedarEntityUid { if (!this._resourceResolver) { - return { type: 'McpServer', id: this._resourceId } + return { type: 'Resource', id: 'agent' } } if (typeof this._resourceResolver === 'function') { return this._resourceResolver(toolName, toolInput) } const mapping = this._resourceResolver[toolName] if (!mapping) { - return { type: 'McpServer', id: this._resourceId } + return { type: 'Resource', id: 'agent' } } const id = toolInput[mapping.key] return { type: mapping.type, id: String(id ?? toolName) } From a9301357d7a369190a07c8994c404bc5b3941bb3 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 28 May 2026 17:02:12 -0400 Subject: [PATCH 4/6] fix(cedar): throw on missing policy file, fix docs, remove unused fixtures --- .../cedar/__tests__/cedar.test.ts | 14 ++++++++------ .../__tests__/fixtures/resource-scoped.cedar | 15 --------------- .../cedar/__tests__/fixtures/time-window.cedar | 8 -------- .../src/vended-interventions/cedar/cedar.ts | 9 ++++++--- 4 files changed, 14 insertions(+), 32 deletions(-) delete mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar delete mode 100644 strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts index 9c14143f01..9024eb613f 100644 --- a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts +++ b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts @@ -507,12 +507,14 @@ describe('CedarAuthorization', () => { expect(toolExecuted).toBe(true) }) - it('treats non-existent .cedar path as inline policy text', () => { - const cedar = new CedarAuthorization({ - policies: '/nonexistent/path.cedar', - principalResolver: () => ({ type: 'User', id: 'alice' }), - }) - expect(cedar.name).toBe('cedar-authorization') + it('throws when .cedar file does not exist', () => { + expect( + () => + new CedarAuthorization({ + policies: '/nonexistent/path.cedar', + principalResolver: () => ({ type: 'User', id: 'alice' }), + }) + ).toThrow('Cedar policy file not found: /nonexistent/path.cedar') }) }) diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar deleted file mode 100644 index a9e6dd1abc..0000000000 --- a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/resource-scoped.cedar +++ /dev/null @@ -1,15 +0,0 @@ -// Allow deleting only records you own -permit( - principal, - action == Action::"delete", - resource is Record -) when { - resource.owner == principal -}; - -// Allow reading any record -permit( - principal, - action == Action::"read", - resource is Record -); diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar deleted file mode 100644 index 0df2d3cb06..0000000000 --- a/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/time-window.cedar +++ /dev/null @@ -1,8 +0,0 @@ -// Allow all tools during business hours (9-17 UTC) -permit( - principal, - action, - resource -) when { - context.session.hour_utc >= 9 && context.session.hour_utc < 17 -}; diff --git a/strands-ts/src/vended-interventions/cedar/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts index d911883cd1..ad52ad2af0 100644 --- a/strands-ts/src/vended-interventions/cedar/cedar.ts +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -14,7 +14,7 @@ import { readFileSync, existsSync } from 'node:fs' * @example * ```typescript * const principal: CedarEntityUid = { type: 'User', id: 'alice@acme.com' } - * const resource: CedarEntityUid = { type: 'McpServer', id: 'my-agent' } + * const resource: CedarEntityUid = { type: 'Record', id: '42' } * ``` */ export interface CedarEntityUid { @@ -183,7 +183,6 @@ export class CedarAuthorization extends InterventionHandler { } override beforeToolCall(event: BeforeToolCallEvent): InterventionAction { - const invocationState = event.invocationState as Record const principal = this._principalResolver(invocationState) if (!principal) { @@ -210,6 +209,7 @@ export class CedarAuthorization extends InterventionHandler { }, }, policies: { staticPolicies: this._policies }, + // JSONValue and CedarValueJson are structurally equivalent but TypeScript can't prove it entities: this._entities as unknown as Entities, }) @@ -266,7 +266,10 @@ export class CedarAuthorization extends InterventionHandler { } function loadPolicies(policies: string): string { - if (policies.endsWith('.cedar') && existsSync(policies)) { + if (policies.endsWith('.cedar')) { + if (!existsSync(policies)) { + throw new Error(`Cedar policy file not found: ${policies}`) + } return readFileSync(policies, 'utf-8') } return policies From 5389b231c793a314faa0166887ab6beb249fd5aa Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Thu, 28 May 2026 17:03:13 -0400 Subject: [PATCH 5/6] refactor(cedar): use CedarValueJson from cedar-wasm instead of JSONValue --- .../src/vended-interventions/cedar/cedar.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/strands-ts/src/vended-interventions/cedar/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts index ad52ad2af0..0a98651787 100644 --- a/strands-ts/src/vended-interventions/cedar/cedar.ts +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -3,8 +3,7 @@ import { proceed, deny } from '../../interventions/actions.js' import type { InterventionAction } from '../../interventions/actions.js' import type { BeforeToolCallEvent } from '../../hooks/events.js' import type { OnError } from '../../interventions/handler.js' -import type { JSONValue } from '../../types/json.js' -import { isAuthorized, type Entities } from '@cedar-policy/cedar-wasm/nodejs' +import { isAuthorized, type Entities, type CedarValueJson } from '@cedar-policy/cedar-wasm/nodejs' import { readFileSync, existsSync } from 'node:fs' /** @@ -37,7 +36,7 @@ export interface CedarEntityUid { */ export interface CedarEntity { uid: CedarEntityUid - attrs: Record + attrs: Record parents: CedarEntityUid[] } @@ -60,7 +59,7 @@ export interface CedarEntity { */ export type ResourceResolver = | Record - | ((toolName: string, toolInput: Record) => CedarEntityUid) + | ((toolName: string, toolInput: Record) => CedarEntityUid) /** * Configuration for the {@link CedarAuthorization} intervention handler. @@ -100,7 +99,7 @@ export interface CedarAuthorizationConfig { * } * ``` */ - principalResolver: (invocationState: Record) => CedarEntityUid | undefined + principalResolver: (invocationState: Record) => CedarEntityUid | undefined /** * Maps tool calls to Cedar resources. When omitted, the resource is @@ -114,7 +113,7 @@ export interface CedarAuthorizationConfig { * Called on every tool invocation. */ contextEnricher?: - | ((context: { toolName: string; toolInput: Record }) => Record) + | ((context: { toolName: string; toolInput: Record }) => Record) | undefined /** @@ -166,7 +165,7 @@ export class CedarAuthorization extends InterventionHandler { private readonly _policies: string private readonly _entities: CedarEntity[] - private readonly _principalResolver: (invocationState: Record) => CedarEntityUid | undefined + private readonly _principalResolver: (invocationState: Record) => CedarEntityUid | undefined private readonly _resourceResolver: ResourceResolver | undefined private readonly _contextEnricher: CedarAuthorizationConfig['contextEnricher'] private readonly _callCounts = new Map>() @@ -183,7 +182,7 @@ export class CedarAuthorization extends InterventionHandler { } override beforeToolCall(event: BeforeToolCallEvent): InterventionAction { - const invocationState = event.invocationState as Record + const invocationState = event.invocationState as Record const principal = this._principalResolver(invocationState) if (!principal) { return deny('No principal identity found in invocation state') @@ -191,7 +190,7 @@ export class CedarAuthorization extends InterventionHandler { const sessionId = (invocationState.session_id as string | undefined) ?? '_default' const callCount = this._incrementCallCount(sessionId, event.toolUse.name) - const toolInput = (event.toolUse.input ?? {}) as Record + const toolInput = (event.toolUse.input ?? {}) as Record const resource = this._resolveResource(event.toolUse.name, toolInput) const env = invocationState.environment as string | undefined @@ -209,8 +208,7 @@ export class CedarAuthorization extends InterventionHandler { }, }, policies: { staticPolicies: this._policies }, - // JSONValue and CedarValueJson are structurally equivalent but TypeScript can't prove it - entities: this._entities as unknown as Entities, + entities: this._entities as Entities, }) if (result.type === 'failure') { @@ -233,7 +231,7 @@ export class CedarAuthorization extends InterventionHandler { this._callCounts.delete(sessionId) } - private _resolveResource(toolName: string, toolInput: Record): CedarEntityUid { + private _resolveResource(toolName: string, toolInput: Record): CedarEntityUid { if (!this._resourceResolver) { return { type: 'Resource', id: 'agent' } } From 0011bf926521addd58f36e62ef450dee89f29996 Mon Sep 17 00:00:00 2001 From: Liz <91279165+lizradway@users.noreply.github.com> Date: Fri, 29 May 2026 10:57:33 -0400 Subject: [PATCH 6/6] fix(cedar): rename test to .node.ts, remove stray vi.mock, add function resolver test --- .../{cedar.test.ts => cedar.test.node.ts} | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) rename strands-ts/src/vended-interventions/cedar/__tests__/{cedar.test.ts => cedar.test.node.ts} (95%) diff --git a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.node.ts similarity index 95% rename from strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts rename to strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.node.ts index 9024eb613f..a92617e347 100644 --- a/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.ts +++ b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.node.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { CedarAuthorization } from '../cedar.js' import { Agent } from '../../../agent/agent.js' import { MockMessageModel } from '../../../__fixtures__/mock-message-model.js' @@ -327,6 +327,29 @@ describe('CedarAuthorization', () => { await agent.invoke('Delete 99', { invocationState: {} }) expect(toolExecuted).toBe(false) }) + + it('uses function-based resource resolver', async () => { + const model = new MockMessageModel() + .addTurn({ type: 'toolUseBlock', name: 'delete', toolUseId: 'tool-1', input: { record_id: '42' } }) + .addTurn({ type: 'textBlock', text: 'Done' }) + + let toolExecuted = false + const tool = createMockTool('delete', () => { + toolExecuted = true + return 'deleted' + }) + + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"delete", resource == Record::"42");', + entities: [{ uid: { type: 'Record', id: '42' }, attrs: {}, parents: [] }], + principalResolver: () => ({ type: 'User', id: 'alice' }), + resourceResolver: (_toolName, input) => ({ type: 'Record', id: String(input.record_id) }), + }) + + const agent = new Agent({ model, tools: [tool], interventions: [cedar], printer: false }) + await agent.invoke('Delete', { invocationState: {} }) + expect(toolExecuted).toBe(true) + }) }) describe('context enricher', () => { @@ -380,11 +403,6 @@ describe('CedarAuthorization', () => { describe('onError behavior', () => { it('throws by default when handler errors', async () => { - vi.mock('@cedar-policy/cedar-wasm/nodejs', async (importOriginal) => { - const orig = await importOriginal() - return { ...orig } - }) - const model = new MockMessageModel() .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} }) .addTurn({ type: 'textBlock', text: 'Done' }) @@ -457,7 +475,7 @@ describe('CedarAuthorization', () => { describe('file-based config', () => { it('reads .cedar file from disk', async () => { - const fixturesDir = import.meta.url.replace('file://', '').replace('/cedar.test.ts', '/fixtures') + const fixturesDir = FIXTURES const model = new MockMessageModel() .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} }) @@ -484,7 +502,7 @@ describe('CedarAuthorization', () => { }) it('reads .json entity file from disk', async () => { - const fixturesDir = import.meta.url.replace('file://', '').replace('/cedar.test.ts', '/fixtures') + const fixturesDir = FIXTURES const model = new MockMessageModel() .addTurn({ type: 'toolUseBlock', name: 'search', toolUseId: 'tool-1', input: {} })