diff --git a/README.md b/README.md index 84f21fcb5..2f2bee3c7 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,11 @@ agentcore # interactive TUI │ ├── connector │ │ ├── get # get a connector-backed Target │ │ └── list # list connector-backed Targets -│ └── rule -│ ├── get # get a Rule under a Gateway -│ └── list # list Rules under a Gateway +│ ├── rule +│ │ ├── get # get a Rule under a Gateway +│ │ └── list # list Rules under a Gateway +│ └── policy +│ └── generate # generate Cedar for a Gateway from a natural-language prompt ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators │ ├── llm-as-a-judge # LLM-as-a-Judge evaluators @@ -262,6 +264,11 @@ agentcore gateway connector get --gateway-id --id agentcore gateway connector list --gateway-id --max-results 20 agentcore gateway rule get --gateway-id --rule-id agentcore gateway rule list --gateway-id --max-results 20 +agentcore gateway policy generate --gateway-id --prompt "forbid IAM callers from every tool" +agentcore gateway policy generate --gateway-id --prompt file://policy.txt --json +# Pipe the generated Cedar into a project (run inside the project) +agentcore gateway policy generate --gateway-id --prompt "..." \ + | agentcore project add policy --engine Guardrails --name Generated --statement - # Manage API key credential providers agentcore identity api-key-credential-provider create --name my-provider --api-key diff --git a/src/core/index.tsx b/src/core/index.tsx index 7207457fb..ef821e37e 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,6 +7,7 @@ import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; +import { PolicyClient } from "./policy"; import { ObservabilityClient } from "./observability"; import { RuntimeClient } from "./runtime"; import { FsReadWriteJson } from "../io"; @@ -74,6 +75,7 @@ export class CoreClient implements AwsClients { readonly gateway: GatewayClient; readonly eval: EvalClient; readonly observability: ObservabilityClient; + readonly policy: PolicyClient; readonly projectManager: ProjectManager; readonly describeBedrockAgent: DescribeBedrockAgent; @@ -89,6 +91,7 @@ export class CoreClient implements AwsClients { this.fetch = fetch; this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); + this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); // EvalClient shares the injected fetch: dataset content is served from a // presigned S3 URL, outside the SDK seam the other operations use. The logger // is used for batch-evaluation result-log diagnostics. diff --git a/src/core/policy.tsx b/src/core/policy.tsx new file mode 100644 index 000000000..6ac4b722a --- /dev/null +++ b/src/core/policy.tsx @@ -0,0 +1,123 @@ +import { + GetGatewayCommand, + ListPolicyGenerationAssetsCommand, + StartPolicyGenerationCommand, + waitForPolicyGenerationCompleted, + type GetPolicyGenerationCommandOutput, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { WaiterState } from "@smithy/core/client"; +import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError, NetworkingError } from "../errors"; +import type { + CorePolicyClient, + GeneratedPolicy, + GeneratePolicyInput, + PolicyGenerationResult, +} from "../handlers/gateway/policy/types"; +import type { Logger } from "../logging"; +import type { ProgressEvent } from "../tui/progress"; +import type { AwsClients, CoreOptions } from "./types"; +import { toClientConfig } from "./utils"; + +export type PolicyGenerationWait = { + maxWaitTime: number; + minDelay: number; + maxDelay: number; +}; + +const DEFAULT_WAIT: PolicyGenerationWait = { maxWaitTime: 60, minDelay: 2, maxDelay: 5 }; + +function resourceIdFromArn(value: string): string { + return value.startsWith("arn:") ? value.slice(value.lastIndexOf("/") + 1) : value; +} + +export class PolicyClient implements CorePolicyClient { + constructor( + private readonly clients: AwsClients, + private readonly logger: Logger, + private readonly wait: PolicyGenerationWait = DEFAULT_WAIT, + ) {} + + async *generatePolicy( + input: GeneratePolicyInput, + options: CoreOptions, + ): AsyncGenerator { + const control = this.clients.control(toClientConfig(options)); + const gatewayId = resourceIdFromArn(input.gatewayId); + + yield { type: "step", message: `Resolving gateway ${gatewayId}` }; + const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId })); + const gatewayArn = gateway.gatewayArn!; + const engine = input.policyEngineId ?? gateway.policyEngineConfiguration?.arn; + if (!engine) { + throw new InputValidationError( + `gateway '${gatewayId}' has no Policy Engine attached; pass --policy-engine-id`, + ); + } + const policyEngineId = resourceIdFromArn(engine); + + yield { type: "step", message: `Starting policy generation ${input.name}` }; + const started = await control.send( + new StartPolicyGenerationCommand({ + policyEngineId, + resource: { arn: gatewayArn }, + content: { rawText: input.prompt }, + name: input.name, + }), + ); + const policyGenerationId = started.policyGenerationId!; + const meta = { policyGenerationId, policyEngineId }; + + yield { type: "step", message: "Waiting for generation to complete" }; + const waited = await waitForPolicyGenerationCompleted( + { client: control, ...this.wait }, + { policyEngineId, policyGenerationId }, + ); + this.logger.debug(`policy generation ${policyGenerationId} waiter state: ${waited.state}`); + if (waited.state === WaiterState.TIMEOUT) { + throw new NetworkingError( + `policy generation '${policyGenerationId}' did not finish within ${this.wait.maxWaitTime}s; ` + + "it may still complete on the service", + { meta }, + ); + } + if (waited.state !== WaiterState.SUCCESS) { + const reasons = (waited.reason as GetPolicyGenerationCommandOutput | undefined) + ?.statusReasons; + throw new AgentCoreCLIError( + `policy generation '${policyGenerationId}' failed: ${reasons?.join("; ") ?? waited.state}`, + { source: ERROR_SOURCE.SERVICE, meta }, + ); + } + + yield { type: "step", message: "Reading generated policies" }; + const policies: GeneratedPolicy[] = []; + let nextToken: string | undefined; + do { + const page = await control.send( + new ListPolicyGenerationAssetsCommand({ policyEngineId, policyGenerationId, nextToken }), + ); + for (const asset of page.policyGenerationAssets ?? []) { + policies.push({ + statement: asset.definition?.cedar?.statement ?? asset.definition?.policy?.statement, + findings: (asset.findings ?? []).map((finding) => ({ + type: finding.type ?? "UNKNOWN", + description: finding.description ?? "", + })), + }); + } + nextToken = page.nextToken; + } while (nextToken); + + if (!policies.some((policy) => policy.statement)) { + const findings = policies + .flatMap((policy) => policy.findings) + .map((finding) => `[${finding.type}] ${finding.description}`) + .join("; "); + throw new AgentCoreCLIError( + `the prompt could not be translated into a Cedar policy${findings ? `: ${findings}` : ""}`, + { source: ERROR_SOURCE.SERVICE, meta }, + ); + } + return { policyGenerationId, policyEngineId, gatewayArn, policies }; + } +} diff --git a/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.5513677d251db507.json b/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.5513677d251db507.json new file mode 100644 index 000000000..441b7cc4a --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.5513677d251db507.json @@ -0,0 +1,19 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-bare-xl0dy3pq5h", + "gatewayId": "policygene2e-bare-xl0dy3pq5h", + "createdAt": { + "$date": "2026-09-02T19:50:44.028Z" + }, + "updatedAt": { + "$date": "2026-09-02T19:50:44.841Z" + }, + "status": "READY", + "name": "PolicyGenE2E-bare", + "authorizerType": "NONE", + "gatewayUrl": "https://policygene2e-bare-xl0dy3pq5h.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Gateway for PolicyGenE2E-bare", + "roleArn": "arn:aws:iam::887863153624:role/AgentCore-PolicyGenE2E-de-McpGatewayBareRole58BAE2C-jSYHioxsHFHU", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:workload-identity-directory/default/workload-identity/policygene2e-bare-xl0dy3pq5h" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.aaf1bce123157e06.json b/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.aaf1bce123157e06.json new file mode 100644 index 000000000..8a5387089 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/GetGatewayCommand.aaf1bce123157e06.json @@ -0,0 +1,23 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z", + "gatewayId": "policygene2e-tools-zhijfh6m5z", + "createdAt": { + "$date": "2026-09-02T19:30:56.123Z" + }, + "updatedAt": { + "$date": "2026-09-02T19:30:56.809Z" + }, + "status": "READY", + "name": "PolicyGenE2E-tools", + "authorizerType": "NONE", + "gatewayUrl": "https://policygene2e-tools-zhijfh6m5z.gateway.bedrock-agentcore.us-west-2.amazonaws.com", + "description": "Gateway for PolicyGenE2E-tools", + "roleArn": "arn:aws:iam::887863153624:role/AgentCore-PolicyGenE2E-de-McpGatewayToolsRole1D55B5-WhvuoXGDoNYC", + "policyEngineConfiguration": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t", + "mode": "LOG_ONLY" + }, + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:workload-identity-directory/default/workload-identity/policygene2e-tools-zhijfh6m5z" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.7ae2ce2725ceedf1.json b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.7ae2ce2725ceedf1.json new file mode 100644 index 000000000..450017f64 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.7ae2ce2725ceedf1.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_forbid_1788379436724-6vfaj00xfb", + "name": "golden_forbid_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_forbid_1788379436724-6vfaj00xfb", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:03:57.296Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:04:08.119Z" + }, + "status": "GENERATED", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.81eab1512df1bb7e.json b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.81eab1512df1bb7e.json new file mode 100644 index 000000000..df6619cd6 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.81eab1512df1bb7e.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_untranslatable_1788379436724-zwadwgy5xq", + "name": "golden_untranslatable_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_untranslatable_1788379436724-zwadwgy5xq", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:04:26.137Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:04:34.371Z" + }, + "status": "GENERATED", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.c3cc22b88924256e.json b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.c3cc22b88924256e.json new file mode 100644 index 000000000..ad311b295 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/GetPolicyGenerationCommand.c3cc22b88924256e.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_permit_1788379436724-ps3sdu9w0d", + "name": "golden_permit_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_permit_1788379436724-ps3sdu9w0d", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:04:08.865Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:04:19.851Z" + }, + "status": "GENERATED", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.7ae2ce2725ceedf1.json b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.7ae2ce2725ceedf1.json new file mode 100644 index 000000000..f6d2ca92b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.7ae2ce2725ceedf1.json @@ -0,0 +1,19 @@ +{ + "policyGenerationAssets": [ + { + "policyGenerationAssetId": "golden_forbid_1788379436724-9nhse8m4wg", + "rawTextFragment": "forbid IAM principals from calling any tool on this gateway", + "findings": [ + { + "type": "DENY_ALL", + "description": "Overly Restrictive: The generated policy denies all actions for all principals. Confirm that full restriction is intended before applying this policy." + } + ], + "definition": { + "policy": { + "statement": "forbid (principal is AgentCore::IamEntity, action, resource == AgentCore::Gateway::\"arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z\");" + } + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.81eab1512df1bb7e.json b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.81eab1512df1bb7e.json new file mode 100644 index 000000000..da2dc1017 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.81eab1512df1bb7e.json @@ -0,0 +1,24 @@ +{ + "policyGenerationAssets": [ + { + "policyGenerationAssetId": "golden_untranslatable_1788379436724-s0kq76o_vl", + "rawTextFragment": "Forbid calling any tool whose name contains delete.", + "findings": [ + { + "type": "INVALID", + "description": "Non-translatable: cannot be expressed in Dogwood" + } + ] + }, + { + "policyGenerationAssetId": "golden_untranslatable_1788379436724-v9e46aqhnp", + "rawTextFragment": "Permit everyone to list tools.", + "findings": [ + { + "type": "INVALID", + "description": "Non-translatable: cannot be expressed in Dogwood" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.c3cc22b88924256e.json b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.c3cc22b88924256e.json new file mode 100644 index 000000000..3b1a745cd --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/ListPolicyGenerationAssetsCommand.c3cc22b88924256e.json @@ -0,0 +1,19 @@ +{ + "policyGenerationAssets": [ + { + "policyGenerationAssetId": "golden_permit_1788379436724-1jag4xr0sz", + "rawTextFragment": "permit IAM principals to call any tool on this gateway", + "findings": [ + { + "type": "ALLOW_ALL", + "description": "Overly Permissive: The generated policy permits all actions for all principals. Confirm that unrestricted access is intended before applying this policy" + } + ], + "definition": { + "policy": { + "statement": "permit (principal is AgentCore::IamEntity, action, resource == AgentCore::Gateway::\"arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z\");" + } + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.3add87bfdfde5e9e.json b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.3add87bfdfde5e9e.json new file mode 100644 index 000000000..81d32ea2b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.3add87bfdfde5e9e.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_untranslatable_1788379436724-zwadwgy5xq", + "name": "golden_untranslatable_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_untranslatable_1788379436724-zwadwgy5xq", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:04:26.137Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:04:26.137Z" + }, + "status": "GENERATING", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.c16bc5832065a273.json b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.c16bc5832065a273.json new file mode 100644 index 000000000..5c5385db6 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.c16bc5832065a273.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ValidationException", + "message": "1 validation error detected. Value at '/policyEngineId' failed to satisfy constraint: Member must satisfy regular expression pattern: ^[A-Za-z][A-Za-z0-9_]*-[a-z0-9_]{10}$" + } +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cbe8967379451f9d.json b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cbe8967379451f9d.json new file mode 100644 index 000000000..60e3372d7 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cbe8967379451f9d.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_permit_1788379436724-ps3sdu9w0d", + "name": "golden_permit_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_permit_1788379436724-ps3sdu9w0d", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:04:08.865Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:04:08.865Z" + }, + "status": "GENERATING", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cf9789a397df7820.json b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cf9789a397df7820.json new file mode 100644 index 000000000..e30f2591d --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/StartPolicyGenerationCommand.cf9789a397df7820.json @@ -0,0 +1,17 @@ +{ + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "policyGenerationId": "golden_forbid_1788379436724-6vfaj00xfb", + "name": "golden_forbid_1788379436724", + "policyGenerationArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t/policy-generation/golden_forbid_1788379436724-6vfaj00xfb", + "resource": { + "arn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z" + }, + "createdAt": { + "$date": "2026-09-02T20:03:57.296Z" + }, + "updatedAt": { + "$date": "2026-09-02T20:03:57.296Z" + }, + "status": "GENERATING", + "statusReasons": [] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/generate-json.golden.json b/src/handlers/gateway/__fixtures__/policy/generate-json.golden.json new file mode 100644 index 000000000..c60a2a3d8 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/generate-json.golden.json @@ -0,0 +1,16 @@ +{ + "policyGenerationId": "golden_permit_1788379436724-ps3sdu9w0d", + "policyEngineId": "PolicyGenE2E_Guardrails-gn5jf72o3t", + "gatewayArn": "arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z", + "policies": [ + { + "statement": "permit (principal is AgentCore::IamEntity, action, resource == AgentCore::Gateway::\"arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z\");", + "findings": [ + { + "type": "ALLOW_ALL", + "description": "Overly Permissive: The generated policy permits all actions for all principals. Confirm that unrestricted access is intended before applying this policy" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/generate.golden.cedar b/src/handlers/gateway/__fixtures__/policy/generate.golden.cedar new file mode 100644 index 000000000..cafca29b8 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/generate.golden.cedar @@ -0,0 +1 @@ +forbid (principal is AgentCore::IamEntity, action, resource == AgentCore::Gateway::"arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/policygene2e-tools-zhijfh6m5z"); \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/generate.golden.stderr b/src/handlers/gateway/__fixtures__/policy/generate.golden.stderr new file mode 100644 index 000000000..d8d8090e1 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/generate.golden.stderr @@ -0,0 +1,4 @@ +Resolving gateway policygene2e-tools-zhijfh6m5z +Starting policy generation golden_forbid_1788379436724 +Waiting for generation to complete +Reading generated policies \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/policy/generation-names.json b/src/handlers/gateway/__fixtures__/policy/generation-names.json new file mode 100644 index 000000000..14e0dc2e9 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/policy/generation-names.json @@ -0,0 +1,6 @@ +{ + "forbid": "golden_forbid_1788379436724", + "permit": "golden_permit_1788379436724", + "missingEngine": "golden_missing_engine_1788379436724", + "untranslatable": "golden_untranslatable_1788379436724" +} \ No newline at end of file diff --git a/src/handlers/gateway/gateway.policy.test.tsx b/src/handlers/gateway/gateway.policy.test.tsx new file mode 100644 index 000000000..2a6fe9a60 --- /dev/null +++ b/src/handlers/gateway/gateway.policy.test.tsx @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestGlobalConfigAccessor, + testIO, + uniquePerRecording, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__", "policy"); +const GATEWAY_ID = "policygene2e-tools-zhijfh6m5z"; +const GATEWAY_ARN = `arn:aws:bedrock-agentcore:us-west-2:887863153624:gateway/${GATEWAY_ID}`; +const ENGINE_ARN = + "arn:aws:bedrock-agentcore:us-west-2:887863153624:policy-engine/PolicyGenE2E_Guardrails-gn5jf72o3t"; +const BARE_GATEWAY_ID = "policygene2e-bare-xl0dy3pq5h"; +const RECORD_TIMEOUT = 600_000; + +// Generation names are unique per engine on the service, so each recording needs +// fresh ones while replays reuse the recorded set. +const NAMES = uniquePerRecording(FIXTURES, "generation-names", () => { + const stamp = Date.now(); + return { + forbid: `golden_forbid_${stamp}`, + permit: `golden_permit_${stamp}`, + missingEngine: `golden_missing_engine_${stamp}`, + untranslatable: `golden_untranslatable_${stamp}`, + }; +}); + +// The fixture graph is the deployed `PolicyGenE2E` project: Gateway `tools` with +// Policy Engine `Guardrails` attached, and Gateway `bare` with no engine. Record with: +// RECORD=1 bun test src/handlers/gateway/gateway.policy.test.tsx +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route([ + "node", + "agentcore", + "gateway", + "policy", + "generate", + ...args, + "--region", + REGION, + ]); + return { stdout: io.stdout(), stderr: io.stderr() }; +} + +describe("gateway policy generate fixture-backed flows", () => { + test( + "prints the Cedar on stdout and the findings on stderr with the attached engine", + async () => { + const { stdout, stderr } = await run([ + "--gateway-id", + GATEWAY_ID, + "--prompt", + "forbid IAM principals from calling any tool on this gateway", + "--name", + NAMES.forbid, + ]); + matchGolden(FIXTURES, "generate.golden.cedar", stdout); + matchGolden(FIXTURES, "generate.golden.stderr", stderr); + expect(stdout).toContain(`resource == AgentCore::Gateway::"${GATEWAY_ARN}"`); + }, + RECORD_TIMEOUT, + ); + + test( + "prints the result object with --json for an ARN and an explicit engine ARN", + async () => { + const { stdout } = await run([ + "--gateway-id", + GATEWAY_ARN, + "--policy-engine-id", + ENGINE_ARN, + "--prompt", + "permit IAM principals to call any tool on this gateway", + "--name", + NAMES.permit, + "--json", + ]); + matchGolden(FIXTURES, "generate-json.golden.json", stdout); + expect(JSON.parse(stdout)).toMatchObject({ + policyEngineId: "PolicyGenE2E_Guardrails-gn5jf72o3t", + gatewayArn: GATEWAY_ARN, + }); + }, + RECORD_TIMEOUT, + ); + + test.each([ + [ + "the gateway has no engine attached", + ["--gateway-id", BARE_GATEWAY_ID, "--prompt", "forbid everything"], + /has no Policy Engine attached; pass --policy-engine-id/, + ], + [ + "the explicit engine does not exist", + [ + "--gateway-id", + GATEWAY_ID, + "--policy-engine-id", + "pe-does-not-exist", + "--prompt", + "forbid everything", + "--name", + NAMES.missingEngine, + ], + /policyEngineId/, + ], + [ + "the prompt cannot be translated", + [ + "--gateway-id", + GATEWAY_ID, + "--prompt", + "permit everyone to list tools but forbid calling any tool whose name contains delete", + "--name", + NAMES.untranslatable, + ], + /could not be translated into a Cedar policy: \[INVALID\]/, + ], + ])( + "fails when %s", + async (_label, args, message) => { + await expect(run(args)).rejects.toThrow(message); + }, + RECORD_TIMEOUT, + ); +}); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index bf767c77b..f61218b05 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -1,4 +1,12 @@ import { describe, expect, test } from "bun:test"; +import { + GetGatewayCommand, + GetPolicyGenerationCommand, + StartPolicyGenerationCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { PolicyClient } from "../../core/policy"; +import type { AwsClients } from "../../core/types"; +import { NetworkingError } from "../../errors"; import { createSilentLogger, TestCoreClient, @@ -7,16 +15,17 @@ import { } from "../../testing"; import { compile, isTuiCommandSupported, ValueContext } from "../../router"; import { createRootHandler } from "../index"; +import type { Core } from "../types"; const REGION = "us-west-2"; const GATEWAY_ID = "gateway-1"; const TARGET_ID = "target-1"; const RULE_ID = "rule-1"; -async function run( +async function run( args: string[], - core = new TestCoreClient(), -): Promise<{ core: TestCoreClient; stdout: string }> { + core: C = new TestCoreClient() as unknown as C, +): Promise<{ core: C; stdout: string }> { const io = testIO(); const root = createRootHandler(core, { io: io.io, @@ -57,6 +66,7 @@ describe("gateway command hierarchy", () => { const target = gateway?.children().find((child) => child.name() === "target"); const connector = gateway?.children().find((child) => child.name() === "connector"); const rule = gateway?.children().find((child) => child.name() === "rule"); + const policy = gateway?.children().find((child) => child.name() === "policy"); expect(gateway?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(gateway?.children().map((child) => child.name())).toEqual([ @@ -69,6 +79,7 @@ describe("gateway command hierarchy", () => { "target", "connector", "rule", + "policy", ]); expect(target?.children().map((child) => child.name())).toEqual([ "create", @@ -91,6 +102,7 @@ describe("gateway command hierarchy", () => { "list", "delete", ]); + expect(policy?.children().map((child) => child.name())).toEqual(["generate"]); }); test.each([ @@ -133,6 +145,12 @@ describe("gateway validation", () => { ["Rule get parent", ["gateway", "rule", "get", "--rule-id", RULE_ID], /--gateway-id/], ["Rule get child", ["gateway", "rule", "get", "--gateway-id", GATEWAY_ID], /--rule-id/], ["Rule list", ["gateway", "rule", "list", "--max-results", "1"], /--gateway-id/], + ["Policy generate gateway", ["gateway", "policy", "generate", "--prompt", "x"], /--gateway-id/], + [ + "Policy generate prompt", + ["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID], + /--prompt/, + ], ] as const)( "rejects a missing selector for %s before calling Core", async (_name, args, error) => { @@ -140,6 +158,7 @@ describe("gateway validation", () => { await expect(run([...args], core)).rejects.toThrow(error); expect(core.gateway.calls).toEqual([]); + expect(core.policy.calls).toEqual([]); }, ); @@ -152,3 +171,50 @@ describe("gateway validation", () => { expect(core.gateway.calls).toEqual([]); }); }); + +/** + The waiter outcomes below cannot be recorded against the live service, so the + control plane is faked at .send() while the real PolicyClient and waiter run. +**/ +describe("gateway policy generate against a faked control plane", () => { + function coreWith(status: string, statusReasons?: string[]): Core { + const control = { + send: async (command: unknown) => { + if (command instanceof GetGatewayCommand) { + return { + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/gw-1", + policyEngineConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:policy-engine/pe-1", + }, + }; + } + if (command instanceof StartPolicyGenerationCommand) return { policyGenerationId: "gen-1" }; + if (command instanceof GetPolicyGenerationCommand) return { status, statusReasons }; + throw new Error(`unexpected command ${(command as object).constructor.name}`); + }, + }; + const clients = { control: () => control } as unknown as AwsClients; + return { + ...new TestCoreClient(), + policy: new PolicyClient(clients, createSilentLogger(), { + maxWaitTime: 2, + minDelay: 1, + maxDelay: 1, + }), + }; + } + + const args = ["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID, "--prompt", "x"]; + + test("fails with the service reasons when the generation fails", async () => { + await expect( + run(args, coreWith("GENERATE_FAILED", ["bad prompt", "try again"])), + ).rejects.toThrow("policy generation 'gen-1' failed: bad prompt; try again"); + }); + + test("times out when the generation keeps running", async () => { + const attempt = run(args, coreWith("GENERATING")); + await expect(attempt).rejects.toBeInstanceOf(NetworkingError); + await expect(attempt).rejects.toThrow("did not finish within 2s"); + }, 10_000); +}); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index c953c3d2c..62b3374b5 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -9,6 +9,7 @@ import { createDeleteGatewayHandler } from "./delete"; import { createGetGatewayHandler } from "./get"; import { createInvokeGatewayHandler } from "./invoke"; import { createListGatewaysHandler } from "./list"; +import { createGatewayPolicyHandler } from "./policy"; import { createGatewayRuleHandler } from "./rule"; import { createGatewayTargetHandler } from "./target"; import { createUpdateGatewayHandler } from "./update"; @@ -26,5 +27,6 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { .handler(createInvokeGatewayHandler(core, io)) .handler(createGatewayTargetHandler(core, io)) .handler(createGatewayConnectorHandler(core, io)) - .handler(createGatewayRuleHandler(core, io)); + .handler(createGatewayRuleHandler(core, io)) + .handler(createGatewayPolicyHandler(core, io)); } diff --git a/src/handlers/gateway/policy/generate.tsx b/src/handlers/gateway/policy/generate.tsx new file mode 100644 index 000000000..3557c71fa --- /dev/null +++ b/src/handlers/gateway/policy/generate.tsx @@ -0,0 +1,81 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import { type AppIO, SourceResolver } from "../../../io"; +import { createHandler, flag } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; +import { JsonKey } from "../../keys"; +import type { Core } from "../../types"; +import { coreOptsFromCtx, renderJsonError } from "../../utils"; +import type { PolicyGenerationResult } from "./types"; + +export const createGeneratePolicyHandler = (core: Core, io: AppIO) => + createHandler({ + name: "generate", + description: "generate a Cedar policy for a Gateway from a natural-language prompt", + flags: [ + flag( + "gateway-id", + "the ID or ARN of the Gateway the policy applies to", + z.string().optional(), + ), + flag( + "policy-engine-id", + "the ID or ARN of the Policy Engine (defaults to the Gateway's attached engine)", + z.string().optional(), + ), + flag( + "prompt", + "what the policy should allow or deny (inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "name", + "name of the generation request (defaults to cli_generation_)", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (flags.prompt === undefined) { + throw new InputValidationError("required option '--prompt ' not specified"); + } + const prompt = (await new SourceResolver({ stdin: io.stdin }).resolveText( + "prompt", + flags.prompt, + ))!; + const jsonOutput = ctx.require(JsonKey); + + const generation = core.policy.generatePolicy( + { + gatewayId: flags["gateway-id"], + policyEngineId: flags["policy-engine-id"], + prompt, + name: flags.name ?? `cli_generation_${Date.now()}`, + }, + coreOptsFromCtx(ctx), + ); + + let result: PolicyGenerationResult; + try { + result = await runWithProgress(generation, { + io, + interactive: jsonOutput ? false : undefined, + }); + } catch (error) { + if (jsonOutput) renderJsonError(ctx, error); + throw error; + } + + if (jsonOutput) { + ctx.require(JsonRendererKey).renderJson(result); + return; + } + const statements = result.policies.flatMap((policy) => + policy.statement ? [policy.statement.trimEnd()] : [], + ); + io.stdout.write(`${statements.join("\n\n")}\n`); + }, + }); diff --git a/src/handlers/gateway/policy/index.tsx b/src/handlers/gateway/policy/index.tsx new file mode 100644 index 000000000..50efe78ec --- /dev/null +++ b/src/handlers/gateway/policy/index.tsx @@ -0,0 +1,10 @@ +import type { AppIO } from "../../../io"; +import { Router } from "../../../router"; +import type { Core } from "../../types"; +import { createGeneratePolicyHandler } from "./generate"; + +export function createGatewayPolicyHandler(core: Core, io: AppIO): Router { + return new Router("policy", "generate Cedar policies for an AgentCore Gateway").handler( + createGeneratePolicyHandler(core, io), + ); +} diff --git a/src/handlers/gateway/policy/types.tsx b/src/handlers/gateway/policy/types.tsx new file mode 100644 index 000000000..dabd678fc --- /dev/null +++ b/src/handlers/gateway/policy/types.tsx @@ -0,0 +1,31 @@ +import type { CoreOptions } from "../../../core/types"; +import type { ProgressEvent } from "../../../tui/progress"; + +export type GeneratePolicyInput = { + /** Gateway ID or ARN. */ + gatewayId: string; + /** Policy Engine ID or ARN. Omitted means the gateway's attached engine. */ + policyEngineId?: string; + prompt: string; + name: string; +}; + +export type GeneratedPolicy = { + /** Absent when the service could not translate this fragment. */ + statement?: string; + findings: { type: string; description: string }[]; +}; + +export type PolicyGenerationResult = { + policyGenerationId: string; + policyEngineId: string; + gatewayArn: string; + policies: GeneratedPolicy[]; +}; + +export interface CorePolicyClient { + generatePolicy( + input: GeneratePolicyInput, + options: CoreOptions, + ): AsyncGenerator; +} diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 570ccb1b0..ff5717ab6 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -1,5 +1,6 @@ import type { CoreEvalClient } from "./eval/types.tsx"; import type { CoreGatewayClient } from "./gateway/types.tsx"; +import type { CorePolicyClient } from "./gateway/policy/types.tsx"; import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreMemoryClient } from "./memory/types.tsx"; @@ -17,6 +18,7 @@ export interface Core { gateway: CoreGatewayClient; eval: CoreEvalClient; observability: CoreObservabilityClient; + policy: CorePolicyClient; projectManager: ProjectManager; /** Describes a Bedrock Agent + alias for `--type import`. */ describeBedrockAgent: DescribeBedrockAgent; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 7cbe6b005..57ff599c4 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -179,6 +179,12 @@ import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreFetch, CoreOptions, CreateCloudFormationClient } from "../core/types"; import type { Project, ProjectManager } from "../handlers/project/types"; +import type { + CorePolicyClient, + GeneratePolicyInput, + PolicyGenerationResult, +} from "../handlers/gateway/policy/types"; +import type { ProgressEvent } from "../tui/progress"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; @@ -2377,6 +2383,25 @@ export class TestObservabilityClient implements CoreObservabilityClient { } } +export class TestPolicyClient implements CorePolicyClient { + readonly calls: GeneratePolicyInput[] = []; + + async *generatePolicy( + input: GeneratePolicyInput, + ): AsyncGenerator { + this.calls.push(input); + yield { type: "step", message: "Generating policy" }; + return { + policyGenerationId: "gen-1", + policyEngineId: "pe-1", + gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/gw-1", + policies: [ + { statement: "forbid (principal, action, resource is AgentCore::Gateway);", findings: [] }, + ], + }; + } +} + // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); @@ -2386,6 +2411,7 @@ export class TestCoreClient implements Core { readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); readonly observability = new TestObservabilityClient(); + readonly policy = new TestPolicyClient(); fetch: CoreFetch = (async () => { throw new Error("TestCoreClient.fetch is not configured; set it in the test that needs it"); }) as unknown as CoreFetch;