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 b2f49211b9..58e43bba52 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.node.ts b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.node.ts new file mode 100644 index 0000000000..a92617e347 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/cedar.test.node.ts @@ -0,0 +1,577 @@ +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' +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: '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: [] }, + ] + + 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 Resource::"agent"', 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 any resource — works with the default + const cedar = new CedarAuthorization({ + policies: 'permit(principal, action == Action::"search", resource);', + entities: [{ uid: { type: 'Resource', id: '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 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) + }) + + 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', () => { + 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: 'Resource', id: '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: 'Resource', id: '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 () => { + 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 = 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: 'Resource', id: '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 = 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('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') + }) + }) + + 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: 'Resource', id: '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..78fa980a3d --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/entities.json @@ -0,0 +1,4 @@ +[ + { "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/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/role-based.cedar b/strands-ts/src/vended-interventions/cedar/__tests__/fixtures/role-based.cedar new file mode 100644 index 0000000000..a9774bc336 --- /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 +) when { + principal.role == "admin" +}; + +// Analysts can only search and query +permit( + principal is User, + action == Action::"search", + resource +) when { + principal.role == "analyst" +}; + +permit( + principal is User, + action == Action::"query_database", + resource +) 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/cedar.ts b/strands-ts/src/vended-interventions/cedar/cedar.ts new file mode 100644 index 0000000000..0a98651787 --- /dev/null +++ b/strands-ts/src/vended-interventions/cedar/cedar.ts @@ -0,0 +1,282 @@ +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 { isAuthorized, type Entities, type CedarValueJson } 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: 'Record', id: '42' } + * ``` + */ +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, the resource is + * unconstrained (`Resource::"agent"`). Use this to map tools to + * domain-specific entities (e.g. `Record::"42"`). + */ + resourceResolver?: ResourceResolver | 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 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} + * @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 _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._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 as Entities, + }) + + 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: 'Resource', id: 'agent' } + } + if (typeof this._resourceResolver === 'function') { + return this._resourceResolver(toolName, toolInput) + } + const mapping = this._resourceResolver[toolName] + if (!mapping) { + return { type: 'Resource', id: 'agent' } + } + 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')) { + if (!existsSync(policies)) { + throw new Error(`Cedar policy file not found: ${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') + }) + }) +})