Skip to content

Commit 4da709b

Browse files
jesseturner21claudeHweinstock
authored
feat: add policy engine and policy support (#579)
* feat: add policy engine and policy support with full deploy pipeline Add Cedar authorization policy support to AgentCore CLI: - Schema: PolicyEngine and Policy schemas with Zod validation - TUI: Full add/remove wizards for policy engines and policies - Source methods: Cedar file, inline statement, or AI generation - Gateway selection for generation flow - Expandable text input for generation prompts - CLI: Non-interactive add/remove commands with all flags - agentcore add policy-engine --name <name> - agentcore add policy --name <name> --engine <engine> --source/--statement/--generate - agentcore remove policy-engine/policy --name <name> - Deploy: CDK construct integration, CloudFormation output parsing, deployed state tracking with composite engine/policy keys - Status: Policy engines and policies shown in status command and ResourceGraph TUI with correct deployment state diffing - Generation: StartPolicyGeneration + waiter integration with deployed engine ID and gateway ARN resolution - Validation: Schema validation for names, statements, validation modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: use composite key for policy removal to handle cross-engine name collisions Policies are nested under engines, so the same policy name can exist in multiple engines. Switch getRemovable/remove/previewRemove to use an "engineName/policyName" composite key so the generic TUI remove flow can uniquely identify policies with a single string. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: sync package-lock.json for npm@10 compatibility Regenerate lock file with npm@10 to resolve missing yaml@2.8.2 dependency entry that caused `npm ci` failures on Node 20.x and 22.x. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve lint and formatting issues for CI - Merge duplicate imports in policy-generation.ts - Use dot notation instead of bracket notation in outputs test - Replace Array<T> with T[] in outputs.ts and useDeployFlow.ts - Add void operator for floating promises in AddPolicyFlow - Wrap async handlers with void for no-misused-promises - Escape quotes in JSX text in AddPolicyScreen - Fix prettier formatting across all changed files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make --statement, --source, --generate mutually exclusive in add policy Previously, passing multiple source flags (e.g. --statement + --source) was silently accepted with an implicit precedence order. Now the command returns a clear error if more than one is provided. Also fix pre-existing type errors in dev config tests by adding the required policyEngines field to test fixtures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add policy engine and policy support to TUI remove flow Add interactive TUI support for removing policy engines and policies, including menu entries, selection screens, confirmation, and success states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: write both CLIENT_ID and CLIENT_SECRET env vars for managed OAuth credentials The createManagedOAuthCredential method was only writing the client secret with an incorrect env var name. Now correctly writes both _CLIENT_ID and _CLIENT_SECRET suffixed env vars, matching the pattern used by CredentialPrimitive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add PolicyEngineConfiguration support for gateways * feat: add policy engine selection to gateway TUI wizard * fix: shorten disabled policy generate description to prevent truncation The "Generate a Cedar policy" option's disabled description was too long ("Requires deployed engine — run `deploy` first") and got cut off in narrow terminals. Shortened to "Deploy engine first". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent infinite loop when pressing Escape on policy generation error When the policy generation API returned an error, pressing Escape on the review step would loop back to the loading step and re-trigger the API call, creating an infinite loop. The root cause was the double goBack() pattern (one immediate, one via setTimeout) suffering from stale closures — both calls saw the same step, so the second never reached the description step, while the first landed on loading and re-fired the useEffect. The fix uses a skipGeneration ref: when navigating back from review, the ref is set to true and a single goBack() moves to the loading step. The useEffect detects the ref, resets it, and calls goBack() again (now with the correct step in scope) to reach the description step — without ever starting generation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove legacy McpGateway output pattern from gateway parser The CDK constructs renamed McpGateway to Gateway in PR #65. No deployed stacks use the old prefix since this is pre-GA. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add integ tests for --statement/--source/--generate mutual exclusivity Cover all pairwise combinations and the triple-flag case to ensure the CLI rejects conflicting policy source flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unnecessary sourceFile existence check from validate The sourceFile field is metadata tracking where a policy statement originated. The statement itself is persisted in agentcore.json, so the original .cedar file is not needed after add. Failing validation when the source file is cleaned up is incorrect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: respect --json flag in policy remove command The remove action always output JSON regardless of whether --json was passed. Now matches the add command behavior: plain text by default, JSON only when --json is specified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: co-locate hasPolicyEngines with other has* checks in preflight Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review comments for policy support - Validate --validation-mode CLI flag with ValidationModeSchema.parse() instead of unsafe cast (PolicyPrimitive) - Add ambiguity check in remove/previewRemove when policy exists in multiple engines without --engine specified (PolicyPrimitive) - Gate JSON output behind --json flag in policy engine remove (PolicyEnginePrimitive) - Add uniqueBy validation on policies array to prevent duplicate names (policy schema) - Narrow validationModeItems type to remove unnecessary cast (AddPolicyScreen) - Disable reviewNav when generation error is shown (AddPolicyScreen) - Add expandable to inline Cedar statement TextInput (AddPolicyScreen) - Check waiter result state before proceeding (policy-generation) - Revert SelectList wrap from truncate back to wrap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Harrison Weinstock <hkobew@amazon.com>
1 parent 0023284 commit 4da709b

58 files changed

Lines changed: 4165 additions & 1832 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integ-tests/add-remove-policy.test.ts

Lines changed: 469 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 247 additions & 1786 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ test('AgentCoreStack synthesizes with empty spec', () => {
374374
credentials: [],
375375
evaluators: [],
376376
onlineEvalConfigs: [],
377+
policyEngines: [],
377378
},
378379
});
379380
const template = Template.fromStack(stack);

src/assets/cdk/test/cdk.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ test('AgentCoreStack synthesizes with empty spec', () => {
1313
credentials: [],
1414
evaluators: [],
1515
onlineEvalConfigs: [],
16+
policyEngines: [],
1617
},
1718
});
1819
const template = Template.fromStack(stack);

src/cli/aws/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export {
1515
} from './agentcore-control';
1616
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
1717
export { enableTransactionSearch, type TransactionSearchEnableResult } from './transaction-search';
18+
export {
19+
startPolicyGeneration,
20+
getPolicyGeneration,
21+
type StartPolicyGenerationOptions,
22+
type StartPolicyGenerationResult,
23+
type GetPolicyGenerationOptions,
24+
type GetPolicyGenerationResult,
25+
} from './policy-generation';
1826
export {
1927
DEFAULT_RUNTIME_USER_ID,
2028
invokeA2ARuntime,

src/cli/aws/policy-generation.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { getCredentialProvider } from './account';
2+
import {
3+
BedrockAgentCoreControlClient,
4+
GetPolicyGenerationCommand,
5+
ListPolicyGenerationAssetsCommand,
6+
StartPolicyGenerationCommand,
7+
waitUntilPolicyGenerationCompleted,
8+
} from '@aws-sdk/client-bedrock-agentcore-control';
9+
import { WaiterState } from '@smithy/util-waiter';
10+
11+
export interface StartPolicyGenerationOptions {
12+
policyEngineId: string;
13+
description: string;
14+
region: string;
15+
resourceArn: string;
16+
}
17+
18+
export interface StartPolicyGenerationResult {
19+
generationId: string;
20+
}
21+
22+
export interface GetPolicyGenerationOptions {
23+
generationId: string;
24+
policyEngineId: string;
25+
region: string;
26+
}
27+
28+
export interface GetPolicyGenerationResult {
29+
status: string;
30+
statement: string;
31+
}
32+
33+
export async function startPolicyGeneration(
34+
options: StartPolicyGenerationOptions
35+
): Promise<StartPolicyGenerationResult> {
36+
const client = new BedrockAgentCoreControlClient({
37+
region: options.region,
38+
credentials: getCredentialProvider(),
39+
});
40+
41+
const command = new StartPolicyGenerationCommand({
42+
policyEngineId: options.policyEngineId,
43+
resource: { arn: options.resourceArn },
44+
content: {
45+
rawText: options.description,
46+
},
47+
name: `cli_generation_${Date.now()}`,
48+
});
49+
50+
const response = await client.send(command);
51+
52+
if (!response.policyGenerationId) {
53+
throw new Error('No generation ID returned from StartPolicyGeneration');
54+
}
55+
56+
return { generationId: response.policyGenerationId };
57+
}
58+
59+
export async function getPolicyGeneration(options: GetPolicyGenerationOptions): Promise<GetPolicyGenerationResult> {
60+
const client = new BedrockAgentCoreControlClient({
61+
region: options.region,
62+
credentials: getCredentialProvider(),
63+
});
64+
65+
// Use the SDK waiter to poll until generation completes
66+
const waiterResult = await waitUntilPolicyGenerationCompleted(
67+
{ client, maxWaitTime: 120, minDelay: 2, maxDelay: 5 },
68+
{ policyGenerationId: options.generationId, policyEngineId: options.policyEngineId }
69+
);
70+
71+
if (waiterResult.state !== WaiterState.SUCCESS) {
72+
throw new Error(
73+
`Policy generation did not complete within the timeout period (state: ${waiterResult.state}). ` +
74+
`Generation ID: ${options.generationId}`
75+
);
76+
}
77+
78+
// Check the final status
79+
const getCommand = new GetPolicyGenerationCommand({
80+
policyGenerationId: options.generationId,
81+
policyEngineId: options.policyEngineId,
82+
});
83+
84+
const statusResponse = await client.send(getCommand);
85+
86+
if (statusResponse.status === 'GENERATE_FAILED') {
87+
const reasons = statusResponse.statusReasons?.join(', ') ?? 'Unknown reason';
88+
throw new Error(`Policy generation failed: ${reasons}`);
89+
}
90+
91+
// Fetch the generated assets
92+
const assetsCommand = new ListPolicyGenerationAssetsCommand({
93+
policyGenerationId: options.generationId,
94+
policyEngineId: options.policyEngineId,
95+
});
96+
97+
const assetsResponse = await client.send(assetsCommand);
98+
const assets = assetsResponse.policyGenerationAssets ?? [];
99+
100+
if (assets.length === 0) {
101+
throw new Error('Policy generation completed but no assets were returned');
102+
}
103+
104+
// Get the Cedar statement from the first asset
105+
const firstAsset = assets[0]!;
106+
const cedarStatement = firstAsset.definition?.cedar?.statement;
107+
108+
if (!cedarStatement) {
109+
throw new Error('Policy generation completed but no Cedar policy statement was found in the assets');
110+
}
111+
112+
return {
113+
status: statusResponse.status ?? 'GENERATED',
114+
statement: cedarStatement,
115+
};
116+
}

src/cli/cloudformation/__tests__/outputs.test.ts

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { buildDeployedState, parseGatewayOutputs, parseMemoryOutputs } from '../outputs';
1+
import {
2+
buildDeployedState,
3+
parseGatewayOutputs,
4+
parseMemoryOutputs,
5+
parsePolicyEngineOutputs,
6+
parsePolicyOutputs,
7+
} from '../outputs';
28
import { describe, expect, it } from 'vitest';
39

410
describe('buildDeployedState', () => {
@@ -285,3 +291,181 @@ describe('parseMemoryOutputs', () => {
285291
expect(result).toEqual({});
286292
});
287293
});
294+
295+
describe('parsePolicyEngineOutputs', () => {
296+
it('extracts policy engine outputs matching pattern', () => {
297+
const outputs = {
298+
ApplicationPolicyEngineMyEngineIdOutputABC123: 'pe-123',
299+
ApplicationPolicyEngineMyEngineArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123456789012:policy-engine/pe-123',
300+
UnrelatedOutput: 'some-value',
301+
};
302+
303+
const result = parsePolicyEngineOutputs(outputs, ['MyEngine']);
304+
305+
expect(result).toEqual({
306+
MyEngine: {
307+
policyEngineId: 'pe-123',
308+
policyEngineArn: 'arn:aws:bedrock:us-east-1:123456789012:policy-engine/pe-123',
309+
},
310+
});
311+
});
312+
313+
it('handles multiple policy engines', () => {
314+
const outputs = {
315+
ApplicationPolicyEngineFirstEngineIdOutput123: 'pe-1',
316+
ApplicationPolicyEngineFirstEngineArnOutput123: 'arn:pe-1',
317+
ApplicationPolicyEngineSecondEngineIdOutput456: 'pe-2',
318+
ApplicationPolicyEngineSecondEngineArnOutput456: 'arn:pe-2',
319+
};
320+
321+
const result = parsePolicyEngineOutputs(outputs, ['FirstEngine', 'SecondEngine']);
322+
323+
expect(Object.keys(result)).toHaveLength(2);
324+
expect(result.FirstEngine?.policyEngineId).toBe('pe-1');
325+
expect(result.SecondEngine?.policyEngineId).toBe('pe-2');
326+
});
327+
328+
it('returns empty record when no policy engine outputs found', () => {
329+
const outputs = {
330+
UnrelatedOutput: 'some-value',
331+
};
332+
333+
const result = parsePolicyEngineOutputs(outputs, ['MyEngine']);
334+
335+
expect(result).toEqual({});
336+
});
337+
338+
it('skips incomplete policy engine outputs (missing ARN)', () => {
339+
const outputs = {
340+
ApplicationPolicyEngineMyEngineIdOutputABC123: 'pe-123',
341+
};
342+
343+
const result = parsePolicyEngineOutputs(outputs, ['MyEngine']);
344+
345+
expect(result).toEqual({});
346+
});
347+
});
348+
349+
describe('parsePolicyOutputs', () => {
350+
it('extracts policy outputs matching pattern', () => {
351+
const outputs = {
352+
ApplicationPolicyMyEngineDenyAllIdOutputABC123: 'pol-123',
353+
ApplicationPolicyMyEngineDenyAllArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123456789012:policy/pol-123',
354+
UnrelatedOutput: 'some-value',
355+
};
356+
357+
const result = parsePolicyOutputs(outputs, [{ engineName: 'MyEngine', policyName: 'DenyAll' }]);
358+
359+
expect(result).toEqual({
360+
'MyEngine/DenyAll': {
361+
policyId: 'pol-123',
362+
policyArn: 'arn:aws:bedrock:us-east-1:123456789012:policy/pol-123',
363+
engineName: 'MyEngine',
364+
},
365+
});
366+
});
367+
368+
it('handles multiple policies across engines', () => {
369+
const outputs = {
370+
ApplicationPolicyEngine1Policy1IdOutput123: 'pol-1',
371+
ApplicationPolicyEngine1Policy1ArnOutput123: 'arn:pol-1',
372+
ApplicationPolicyEngine1Policy2IdOutput456: 'pol-2',
373+
ApplicationPolicyEngine1Policy2ArnOutput456: 'arn:pol-2',
374+
};
375+
376+
const result = parsePolicyOutputs(outputs, [
377+
{ engineName: 'Engine1', policyName: 'Policy1' },
378+
{ engineName: 'Engine1', policyName: 'Policy2' },
379+
]);
380+
381+
expect(Object.keys(result)).toHaveLength(2);
382+
expect(result['Engine1/Policy1']?.policyId).toBe('pol-1');
383+
expect(result['Engine1/Policy2']?.policyId).toBe('pol-2');
384+
});
385+
386+
it('returns empty record when no policy outputs found', () => {
387+
const outputs = {
388+
UnrelatedOutput: 'some-value',
389+
};
390+
391+
const result = parsePolicyOutputs(outputs, [{ engineName: 'MyEngine', policyName: 'DenyAll' }]);
392+
393+
expect(result).toEqual({});
394+
});
395+
});
396+
397+
describe('buildDeployedState with policy data', () => {
398+
it('includes policyEngines in deployed state when provided', () => {
399+
const policyEngines = {
400+
MyEngine: {
401+
policyEngineId: 'pe-123',
402+
policyEngineArn: 'arn:aws:bedrock:us-east-1:123456789012:policy-engine/pe-123',
403+
},
404+
};
405+
406+
const result = buildDeployedState({
407+
targetName: 'default',
408+
stackName: 'TestStack',
409+
agents: {},
410+
gateways: {},
411+
policyEngines,
412+
});
413+
414+
expect(result.targets.default!.resources?.policyEngines).toEqual(policyEngines);
415+
});
416+
417+
it('includes policies in deployed state when provided', () => {
418+
const policies = {
419+
'MyEngine/DenyAll': {
420+
policyId: 'pol-123',
421+
policyArn: 'arn:aws:bedrock:us-east-1:123456789012:policy/pol-123',
422+
engineName: 'MyEngine',
423+
},
424+
};
425+
426+
const result = buildDeployedState({
427+
targetName: 'default',
428+
stackName: 'TestStack',
429+
agents: {},
430+
gateways: {},
431+
policies,
432+
});
433+
434+
expect(result.targets.default!.resources?.policies).toEqual(policies);
435+
});
436+
437+
it('omits policyEngines field when policyEngines is empty object', () => {
438+
const result = buildDeployedState({
439+
targetName: 'default',
440+
stackName: 'TestStack',
441+
agents: {},
442+
gateways: {},
443+
policyEngines: {},
444+
});
445+
446+
expect(result.targets.default!.resources?.policyEngines).toBeUndefined();
447+
});
448+
449+
it('omits policies field when policies is empty object', () => {
450+
const result = buildDeployedState({
451+
targetName: 'default',
452+
stackName: 'TestStack',
453+
agents: {},
454+
gateways: {},
455+
policies: {},
456+
});
457+
458+
expect(result.targets.default!.resources?.policies).toBeUndefined();
459+
});
460+
461+
it('omits policyEngines field when not provided', () => {
462+
const result = buildDeployedState({
463+
targetName: 'default',
464+
stackName: 'TestStack',
465+
agents: {},
466+
gateways: {},
467+
});
468+
469+
expect(result.targets.default!.resources?.policyEngines).toBeUndefined();
470+
});
471+
});

0 commit comments

Comments
 (0)