From db066fe3ff6a61bb6ef6333a76f7c6d7057ffb79 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:31:38 -0400 Subject: [PATCH 01/19] feat: project add policy-engine --- src/core/project/manager.tsx | 6 ++ src/handlers/project/add/index.ts | 2 + .../project/add/policy-engine/index.test.ts | 57 +++++++++++++++++++ .../project/add/policy-engine/index.ts | 40 +++++++++++++ src/handlers/project/types.ts | 5 ++ 5 files changed, 110 insertions(+) create mode 100644 src/handlers/project/add/policy-engine/index.test.ts create mode 100644 src/handlers/project/add/policy-engine/index.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 5b65372e8..61ec102fe 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -31,6 +31,7 @@ import { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import { CredentialSchema } from "../../projectSchemas/credential"; import { MemorySchema } from "../../projectSchemas/memory"; import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; +import { PolicyEngineSchema } from "../../projectSchemas/policy"; import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, @@ -245,6 +246,9 @@ export class FsProjectManager implements ProjectManager { case "gateway": projectSpec.agentCoreGateways.push(input.resourceConfig); break; + case "policy-engine": + projectSpec.policyEngines.push(parseResource(PolicyEngineSchema, input.resourceConfig)); + break; case "gateway-target": { const gatewayIndex = projectSpec.agentCoreGateways.findIndex( (gateway) => gateway.name === input.gatewayName, @@ -454,6 +458,8 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "gateway": case "gateway-target": return "agentCoreGateways"; + case "policy-engine": + return "policyEngines"; } } diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index f65600d59..2de54fd6f 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -10,6 +10,7 @@ import { createAddOnlineInsightHandler } from "./online-insight"; import { createAddGatewayHandler } from "./gateway"; import { createAddGatewayTargetHandler } from "./gateway-target"; import { createAddGatewayConnectorHandler } from "./gateway-connector"; +import { createAddPolicyEngineHandler } from "./policy-engine"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { @@ -25,5 +26,6 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddGatewayHandler(config)); projectAdd.handler(createAddGatewayTargetHandler(config)); projectAdd.handler(createAddGatewayConnectorHandler(config)); + projectAdd.handler(createAddPolicyEngineHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts new file mode 100644 index 000000000..b02301475 --- /dev/null +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const { cleanup, inProject, projectSpec, run } = + createGatewayProjectTestHarness("policy-engine-add"); + +afterEach(cleanup); + +describe("project add policy-engine", () => { + test("adds a bare policy engine", async () => { + const projectRoot = await inProject(); + const io = await run(["add", "policy-engine", "--name", "Guardrails"]); + + expect((await projectSpec(projectRoot)).policyEngines).toEqual([ + { name: "Guardrails", policies: [] }, + ]); + expect(io.stderr()).toContain("added Policy Engine 'Guardrails'"); + }); + + test("maps scalar flags to policy engine fields", async () => { + const projectRoot = await inProject(); + await run([ + "add", + "policy-engine", + "--name", + "Guardrails", + "--description", + "Cedar authorization", + "--encryption-key-arn", + "arn:aws:kms:us-west-2:123456789012:key/abc", + "--tags", + "team=agents", + ]); + + expect((await projectSpec(projectRoot)).policyEngines[0]).toEqual({ + name: "Guardrails", + description: "Cedar authorization", + encryptionKeyArn: "arn:aws:kms:us-west-2:123456789012:key/abc", + tags: { team: "agents" }, + policies: [], + }); + }); + + test.each([ + ["missing --name", ["add", "policy-engine"], "required option '--name"], + [ + "invalid name", + ["add", "policy-engine", "--name", "9starts-with-digit"], + "Must begin with a letter", + ], + ["duplicate name", ["add", "policy-engine", "--name", "Guardrails"], "already exists"], + ])("rejects %s", async (label, args, message) => { + await inProject(); + if (label === "duplicate name") await run(["add", "policy-engine", "--name", "Guardrails"]); + await expect(run(args)).rejects.toThrow(message); + }); +}); diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts new file mode 100644 index 000000000..37bd8a1b6 --- /dev/null +++ b/src/handlers/project/add/policy-engine/index.ts @@ -0,0 +1,40 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { PolicyEngineSchema } from "../../../../projectSchemas/policy"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseTags } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "policy-engine", + description: "adds a Policy Engine to the current project", + flags: [ + flag("name", "the Policy Engine name", z.string().optional()), + flag("description", "Policy Engine description", z.string().optional()), + flag("encryption-key-arn", "KMS encryption key ARN", z.string().optional()), + flag("tags", "tags as repeated key=value or a JSON object", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + const project = ctx.require(ProjectKey); + + const engine: z.input = { + name: flags.name, + description: flags.description, + encryptionKeyArn: flags["encryption-key-arn"], + tags: parseTags(flags.tags), + policies: [], + }; + + for await (const event of config.projectManager.addResource(project, { + resourceType: "policy-engine", + resourceConfig: engine, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write(`added Policy Engine '${flags.name}' to '${project.name}'\n`); + }, + }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index ad60bac73..443ae2175 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -9,6 +9,7 @@ import type { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-co import { AgentNameSchema, BuildTypeSchema, EntrypointSchema } from "../../projectSchemas/runtime"; import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; +import type { PolicyEngineSchema } from "../../projectSchemas/policy"; export const RUNTIME_TEMPLATE_SHORTCUTS = { "hello-world-python": { @@ -179,6 +180,10 @@ export type AddResourceInput = resourceType: "gateway-target"; gatewayName: string; resourceConfig: AgentCoreGatewayTarget; + } + | { + resourceType: "policy-engine"; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; From 1036232d90ae3e186b11021d0004bc94f420bac7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:35:17 -0400 Subject: [PATCH 02/19] refactor: simplify add policy-engine slice --- src/handlers/project/add/policy-engine/index.test.ts | 4 +--- src/handlers/project/add/policy-engine/index.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts index b02301475..404f55dba 100644 --- a/src/handlers/project/add/policy-engine/index.test.ts +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -48,10 +48,8 @@ describe("project add policy-engine", () => { ["add", "policy-engine", "--name", "9starts-with-digit"], "Must begin with a letter", ], - ["duplicate name", ["add", "policy-engine", "--name", "Guardrails"], "already exists"], - ])("rejects %s", async (label, args, message) => { + ])("rejects %s", async (_label, args, message) => { await inProject(); - if (label === "duplicate name") await run(["add", "policy-engine", "--name", "Guardrails"]); await expect(run(args)).rejects.toThrow(message); }); }); diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts index 37bd8a1b6..dd3cb4cb7 100644 --- a/src/handlers/project/add/policy-engine/index.ts +++ b/src/handlers/project/add/policy-engine/index.ts @@ -26,7 +26,6 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) = description: flags.description, encryptionKeyArn: flags["encryption-key-arn"], tags: parseTags(flags.tags), - policies: [], }; for await (const event of config.projectManager.addResource(project, { From 52bfe76816ef2ff0895999ac2059830c3accf2af Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:38:02 -0400 Subject: [PATCH 03/19] feat: attach policy engine to gateways from add policy-engine --- src/core/project/manager.tsx | 17 +++++- .../project/add/policy-engine/index.test.ts | 59 +++++++++++++++++++ .../project/add/policy-engine/index.ts | 24 ++++++++ src/handlers/project/types.ts | 1 + 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 61ec102fe..393e01659 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -246,9 +246,24 @@ export class FsProjectManager implements ProjectManager { case "gateway": projectSpec.agentCoreGateways.push(input.resourceConfig); break; - case "policy-engine": + case "policy-engine": { projectSpec.policyEngines.push(parseResource(PolicyEngineSchema, input.resourceConfig)); + for (const gatewayName of input.attachGateways?.names ?? []) { + const gateway = projectSpec.agentCoreGateways.find( + (candidate) => candidate.name === gatewayName, + ); + if (!gateway) { + throw new InputValidationError( + `gateway '${gatewayName}' does not exist in this project; check agentCoreGateways in agentcore.json`, + ); + } + gateway.policyEngineConfiguration = { + policyEngineName: input.resourceConfig.name, + mode: input.attachGateways!.mode, + }; + } break; + } case "gateway-target": { const gatewayIndex = projectSpec.agentCoreGateways.findIndex( (gateway) => gateway.name === input.gatewayName, diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts index 404f55dba..f736177e2 100644 --- a/src/handlers/project/add/policy-engine/index.test.ts +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -52,4 +52,63 @@ describe("project add policy-engine", () => { await inProject(); await expect(run(args)).rejects.toThrow(message); }); + + test("attaches the engine to named gateways with the default enforce mode", async () => { + const projectRoot = await inProject(); + await run(["add", "gateway", "--name", "tools"]); + await run(["add", "gateway", "--name", "search"]); + + await run([ + "add", + "policy-engine", + "--name", + "Guardrails", + "--attach-to-gateways", + "tools", + "search", + ]); + + const spec = await projectSpec(projectRoot); + expect(spec.agentCoreGateways).toHaveLength(2); + for (const gateway of spec.agentCoreGateways) { + expect(gateway.policyEngineConfiguration).toEqual({ + policyEngineName: "Guardrails", + mode: "ENFORCE", + }); + } + }); + + test("attaches in log-only mode when requested", async () => { + const projectRoot = await inProject(); + await run(["add", "gateway", "--name", "tools"]); + await run([ + "add", + "policy-engine", + "--name", + "Guardrails", + "--attach-to-gateways", + "tools", + "--attach-mode", + "log-only", + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].policyEngineConfiguration).toEqual( + { policyEngineName: "Guardrails", mode: "LOG_ONLY" }, + ); + }); + + test("rejects unknown gateway names without writing the engine", async () => { + const projectRoot = await inProject(); + await expect( + run(["add", "policy-engine", "--name", "Guardrails", "--attach-to-gateways", "missing"]), + ).rejects.toThrow("gateway 'missing' does not exist"); + expect((await projectSpec(projectRoot)).policyEngines ?? []).toEqual([]); + }); + + test("rejects --attach-mode without --attach-to-gateways", async () => { + await inProject(); + await expect( + run(["add", "policy-engine", "--name", "Guardrails", "--attach-mode", "enforce"]), + ).rejects.toThrow("--attach-mode requires --attach-to-gateways"); + }); }); diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts index dd3cb4cb7..8d5bb46bb 100644 --- a/src/handlers/project/add/policy-engine/index.ts +++ b/src/handlers/project/add/policy-engine/index.ts @@ -14,11 +14,24 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) = flag("description", "Policy Engine description", z.string().optional()), flag("encryption-key-arn", "KMS encryption key ARN", z.string().optional()), flag("tags", "tags as repeated key=value or a JSON object", z.array(z.string()).optional()), + flag( + "attach-to-gateways", + "names of project Gateways to attach this engine to", + z.array(z.string()).optional(), + ), + flag( + "attach-mode", + "attached Gateway enforcement mode: log-only or enforce (default enforce)", + z.enum(["log-only", "enforce"]).optional(), + ), ], handle: async (ctx, flags) => { if (!flags.name) { throw new InputValidationError("required option '--name ' not specified"); } + if (flags["attach-mode"] !== undefined && flags["attach-to-gateways"] === undefined) { + throw new InputValidationError("--attach-mode requires --attach-to-gateways"); + } const project = ctx.require(ProjectKey); const engine: z.input = { @@ -31,9 +44,20 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) = for await (const event of config.projectManager.addResource(project, { resourceType: "policy-engine", resourceConfig: engine, + attachGateways: flags["attach-to-gateways"] + ? { + names: flags["attach-to-gateways"], + mode: flags["attach-mode"] === "log-only" ? "LOG_ONLY" : "ENFORCE", + } + : undefined, })) { config.io.stderr.write(`${event.message}\n`); } config.io.stderr.write(`added Policy Engine '${flags.name}' to '${project.name}'\n`); + if (flags["attach-to-gateways"]) { + config.io.stderr.write( + `attached '${flags.name}' to ${flags["attach-to-gateways"].length} gateway(s)\n`, + ); + } }, }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 443ae2175..14594272d 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -184,6 +184,7 @@ export type AddResourceInput = | { resourceType: "policy-engine"; resourceConfig: z.input; + attachGateways?: { names: string[]; mode: "ENFORCE" | "LOG_ONLY" }; }; export type ProjectResource = AddResourceInput["resourceType"]; From 20d1c5a6d48988bb570a4d1b65c5a4ad09691a7d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:41:51 -0400 Subject: [PATCH 04/19] refactor: simplify policy-engine attach tests --- .../project/add/policy-engine/index.test.ts | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts index f736177e2..e781113ac 100644 --- a/src/handlers/project/add/policy-engine/index.test.ts +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createGatewayProjectTestHarness } from "../gateway-test-support"; -const { cleanup, inProject, projectSpec, run } = +const { addGateway, cleanup, inProject, projectSpec, run } = createGatewayProjectTestHarness("policy-engine-add"); afterEach(cleanup); @@ -53,10 +53,13 @@ describe("project add policy-engine", () => { await expect(run(args)).rejects.toThrow(message); }); - test("attaches the engine to named gateways with the default enforce mode", async () => { + test.each([ + ["defaults to enforce", [], "ENFORCE"], + ["honors --attach-mode log-only", ["--attach-mode", "log-only"], "LOG_ONLY"], + ])("attaches the engine to named gateways: %s", async (_label, modeArgs, mode) => { const projectRoot = await inProject(); - await run(["add", "gateway", "--name", "tools"]); - await run(["add", "gateway", "--name", "search"]); + await addGateway("tools"); + await addGateway("search"); await run([ "add", @@ -66,6 +69,7 @@ describe("project add policy-engine", () => { "--attach-to-gateways", "tools", "search", + ...modeArgs, ]); const spec = await projectSpec(projectRoot); @@ -73,30 +77,11 @@ describe("project add policy-engine", () => { for (const gateway of spec.agentCoreGateways) { expect(gateway.policyEngineConfiguration).toEqual({ policyEngineName: "Guardrails", - mode: "ENFORCE", + mode, }); } }); - test("attaches in log-only mode when requested", async () => { - const projectRoot = await inProject(); - await run(["add", "gateway", "--name", "tools"]); - await run([ - "add", - "policy-engine", - "--name", - "Guardrails", - "--attach-to-gateways", - "tools", - "--attach-mode", - "log-only", - ]); - - expect((await projectSpec(projectRoot)).agentCoreGateways[0].policyEngineConfiguration).toEqual( - { policyEngineName: "Guardrails", mode: "LOG_ONLY" }, - ); - }); - test("rejects unknown gateway names without writing the engine", async () => { const projectRoot = await inProject(); await expect( From a99721c192972e060276d02f50866eb30483c08d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:45:15 -0400 Subject: [PATCH 05/19] feat: project add policy with source-aware statement --- src/core/project/manager.tsx | 25 ++- src/handlers/project/add/index.ts | 2 + src/handlers/project/add/policy/index.test.ts | 164 ++++++++++++++++++ src/handlers/project/add/policy/index.ts | 114 ++++++++++++ src/handlers/project/types.ts | 14 +- 5 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/handlers/project/add/policy/index.test.ts create mode 100644 src/handlers/project/add/policy/index.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 393e01659..44d5018e2 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -31,7 +31,7 @@ import { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import { CredentialSchema } from "../../projectSchemas/credential"; import { MemorySchema } from "../../projectSchemas/memory"; import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; -import { PolicyEngineSchema } from "../../projectSchemas/policy"; +import { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, @@ -179,6 +179,16 @@ export class FsProjectManager implements ProjectManager { `an unassigned gateway target with name '${input.resourceConfig.name}' already exists`, ); } + } else if (input.resourceType === "policy") { + // Policy names are account-unique on the service, so the check spans engines. + const engine = projectSpec.policyEngines.find((candidate) => + candidate.policies.some((policy) => policy.name === input.resourceConfig.name), + ); + if (engine) { + throw new InputValidationError( + `a policy with name '${input.resourceConfig.name}' already exists in policy engine '${engine.name}'`, + ); + } } else if (existingResources.find((resource) => resource.name === input.resourceConfig.name)) { throw new InputValidationError( `a ${input.resourceType} with name '${input.resourceConfig.name}' already exists`, @@ -264,6 +274,18 @@ export class FsProjectManager implements ProjectManager { } break; } + case "policy": { + const engine = projectSpec.policyEngines.find( + (candidate) => candidate.name === input.engineName, + ); + if (!engine) { + throw new InputValidationError( + `policy engine '${input.engineName}' does not exist in this project; check policyEngines in agentcore.json`, + ); + } + engine.policies.push(parseResource(PolicySchema, input.resourceConfig)); + break; + } case "gateway-target": { const gatewayIndex = projectSpec.agentCoreGateways.findIndex( (gateway) => gateway.name === input.gatewayName, @@ -474,6 +496,7 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "gateway-target": return "agentCoreGateways"; case "policy-engine": + case "policy": return "policyEngines"; } } diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 2de54fd6f..5aa6b7483 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -11,6 +11,7 @@ import { createAddGatewayHandler } from "./gateway"; import { createAddGatewayTargetHandler } from "./gateway-target"; import { createAddGatewayConnectorHandler } from "./gateway-connector"; import { createAddPolicyEngineHandler } from "./policy-engine"; +import { createAddPolicyHandler } from "./policy"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { @@ -27,5 +28,6 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddGatewayTargetHandler(config)); projectAdd.handler(createAddGatewayConnectorHandler(config)); projectAdd.handler(createAddPolicyEngineHandler(config)); + projectAdd.handler(createAddPolicyHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts new file mode 100644 index 000000000..86db0dc28 --- /dev/null +++ b/src/handlers/project/add/policy/index.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { inferAuthorizationPhase } from "./index"; + +const { cleanup, inProject, projectSpec, run } = createGatewayProjectTestHarness("policy-add"); + +afterEach(cleanup); + +const FORBID_ALL = "forbid (principal, action, resource);"; +const SUPPRESS = + "suppressOutput (principal, action, resource is AgentCore::Gateway)\n" + + 'when guardrails { BedrockGuardrails::ContentFilter(["HATE"], [context.output.message])' + + '["HATE"].confidenceScore.greaterThan(decimal("0.2")) };'; + +async function withEngine(): Promise { + const projectRoot = await inProject(); + await run(["add", "policy-engine", "--name", "Guardrails"]); + return projectRoot; +} + +describe("inferAuthorizationPhase", () => { + test.each([ + [FORBID_ALL, "INITIATE"], + [SUPPRESS, "RETURN_OUTPUT"], + ["permit (principal, action, resource) when { context.output.done };", "RETURN_OUTPUT"], + ])("classifies %s", (statement, phase) => { + expect(inferAuthorizationPhase(statement)).toBe(phase); + }); +}); + +describe("project add policy", () => { + test("adds an inline statement policy with defaults", async () => { + const projectRoot = await withEngine(); + const io = await run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "DenyAll", + "--statement", + FORBID_ALL, + ]); + + expect((await projectSpec(projectRoot)).policyEngines[0].policies).toEqual([ + { + name: "DenyAll", + statement: FORBID_ALL, + validationMode: "FAIL_ON_ANY_FINDINGS", + enforcementMode: "ACTIVE", + authorizationPhase: "INITIATE", + }, + ]); + expect(io.stderr()).toContain("added Policy 'DenyAll' to Policy Engine 'Guardrails'"); + }); + + test("reads the statement from stdin and maps mode flags", async () => { + const projectRoot = await withEngine(); + await run( + [ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "Suppress", + "--statement", + "-", + "--validation-mode", + "ignore-all-findings", + "--enforcement-mode", + "log-only", + ], + SUPPRESS, + ); + + expect((await projectSpec(projectRoot)).policyEngines[0].policies[0]).toMatchObject({ + validationMode: "IGNORE_ALL_FINDINGS", + enforcementMode: "LOG_ONLY", + authorizationPhase: "RETURN_OUTPUT", + }); + }); + + test("records sourceFile and lets --authorization-phase override inference", async () => { + const projectRoot = await withEngine(); + const cedarPath = `${projectRoot}/deny.cedar`; + await Bun.write(cedarPath, FORBID_ALL); + + await run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "FromFile", + "--statement", + `file://${cedarPath}`, + "--authorization-phase", + "return-output", + ]); + + expect((await projectSpec(projectRoot)).policyEngines[0].policies[0]).toMatchObject({ + statement: FORBID_ALL, + sourceFile: cedarPath, + authorizationPhase: "RETURN_OUTPUT", + }); + }); + + test.each([ + ["missing --engine", ["add", "policy", "--name", "P", "--statement", FORBID_ALL], "--engine"], + [ + "missing --name", + ["add", "policy", "--engine", "Guardrails", "--statement", FORBID_ALL], + "--name", + ], + [ + "no statement source", + ["add", "policy", "--engine", "Guardrails", "--name", "P"], + "one of '--statement' or '--generate'", + ], + [ + "unknown engine", + ["add", "policy", "--engine", "Missing", "--name", "P", "--statement", FORBID_ALL], + "policy engine 'Missing' does not exist", + ], + [ + "--gateway without --generate", + [ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "P", + "--statement", + FORBID_ALL, + "--gateway", + "tools", + ], + "--gateway is valid only with --generate", + ], + ])("rejects %s", async (_label, args, message) => { + await withEngine(); + await expect(run(args)).rejects.toThrow(message); + }); + + test("rejects a duplicate policy name across engines", async () => { + await withEngine(); + await run(["add", "policy-engine", "--name", "Second"]); + await run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "DenyAll", + "--statement", + FORBID_ALL, + ]); + await expect( + run(["add", "policy", "--engine", "Second", "--name", "DenyAll", "--statement", FORBID_ALL]), + ).rejects.toThrow("already exists in policy engine 'Guardrails'"); + }); +}); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts new file mode 100644 index 000000000..2321cd6ea --- /dev/null +++ b/src/handlers/project/add/policy/index.ts @@ -0,0 +1,114 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import type { PolicySchema } from "../../../../projectSchemas/policy"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; + +/** + A substring heuristic, not a Cedar parser; --authorization-phase overrides it. +**/ +export function inferAuthorizationPhase(statement: string): "INITIATE" | "RETURN_OUTPUT" { + return /\bsuppressOutput\b|context\.output/.test(statement) ? "RETURN_OUTPUT" : "INITIATE"; +} + +export const createAddPolicyHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "policy", + description: "adds a Cedar Policy to a project Policy Engine", + flags: [ + flag("engine", "name of the parent Policy Engine in this project", z.string().optional()), + flag("name", "the Policy name", z.string().optional()), + flag("description", "Policy description", z.string().optional()), + flag( + "statement", + "Cedar policy statement (inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "generate", + "generate the Cedar statement from a natural-language description", + z.string().optional(), + ), + flag("gateway", "deployed Gateway name that scopes --generate", z.string().optional()), + flag( + "validation-mode", + "validation mode: fail-on-any-findings or ignore-all-findings", + z.enum(["fail-on-any-findings", "ignore-all-findings"]).optional(), + ), + flag( + "enforcement-mode", + "enforcement mode: active or log-only", + z.enum(["active", "log-only"]).optional(), + ), + flag( + "authorization-phase", + "authorization phase: initiate or return-output (default inferred from the statement)", + z.enum(["initiate", "return-output"]).optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.engine) { + throw new InputValidationError("required option '--engine ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + const sources = [flags.statement, flags.generate].filter((value) => value !== undefined); + if (sources.length !== 1) { + throw new InputValidationError("specify exactly one of '--statement' or '--generate'"); + } + if (flags.gateway !== undefined && flags.generate === undefined) { + throw new InputValidationError("--gateway is valid only with --generate"); + } + const project = ctx.require(ProjectKey); + if (!project.spec.policyEngines.some((engine) => engine.name === flags.engine)) { + throw new InputValidationError( + `policy engine '${flags.engine}' does not exist in policyEngines[]`, + ); + } + + let statement: string; + let sourceFile: string | undefined; + if (flags.statement !== undefined) { + const source = new SourceResolver({ stdin: config.io.stdin }); + statement = await source.resolveText("statement", flags.statement); + if (flags.statement.startsWith("file://")) { + sourceFile = flags.statement.slice("file://".length); + } + } else { + throw new InputValidationError("--generate is not implemented yet"); + } + + const authorizationPhase = + flags["authorization-phase"] === undefined + ? inferAuthorizationPhase(statement) + : flags["authorization-phase"] === "return-output" + ? "RETURN_OUTPUT" + : "INITIATE"; + + const policy: z.input = { + name: flags.name, + description: flags.description, + statement, + sourceFile, + validationMode: + flags["validation-mode"] === "ignore-all-findings" + ? "IGNORE_ALL_FINDINGS" + : "FAIL_ON_ANY_FINDINGS", + enforcementMode: flags["enforcement-mode"] === "log-only" ? "LOG_ONLY" : "ACTIVE", + authorizationPhase, + }; + + for await (const event of config.projectManager.addResource(project, { + resourceType: "policy", + engineName: flags.engine, + resourceConfig: policy, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write( + `added Policy '${flags.name}' to Policy Engine '${flags.engine}' in '${project.name}'\n`, + ); + }, + }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 14594272d..e8a51caa4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -9,7 +9,7 @@ import type { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-co import { AgentNameSchema, BuildTypeSchema, EntrypointSchema } from "../../projectSchemas/runtime"; import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; -import type { PolicyEngineSchema } from "../../projectSchemas/policy"; +import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; export const RUNTIME_TEMPLATE_SHORTCUTS = { "hello-world-python": { @@ -185,19 +185,29 @@ export type AddResourceInput = resourceType: "policy-engine"; resourceConfig: z.input; attachGateways?: { names: string[]; mode: "ENFORCE" | "LOG_ONLY" }; + } + | { + resourceType: "policy"; + engineName: string; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; export type RemoveResourceInput = | { - resourceType: Exclude; + resourceType: Exclude; name: string; } | { resourceType: "gateway-target"; gatewayName: string; name: string; + } + | { + resourceType: "policy"; + engineName?: string; + name: string; }; /** From bd5686f1c251cbf3c7de4d8083c41b57c6f24c7d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:50:06 -0400 Subject: [PATCH 06/19] refactor: simplify add policy slice --- src/handlers/project/add/policy/index.test.ts | 11 +++++------ src/handlers/project/add/policy/index.ts | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index 86db0dc28..277f1ff18 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -19,12 +19,11 @@ async function withEngine(): Promise { } describe("inferAuthorizationPhase", () => { - test.each([ - [FORBID_ALL, "INITIATE"], - [SUPPRESS, "RETURN_OUTPUT"], - ["permit (principal, action, resource) when { context.output.done };", "RETURN_OUTPUT"], - ])("classifies %s", (statement, phase) => { - expect(inferAuthorizationPhase(statement)).toBe(phase); + // FORBID_ALL to INITIATE and SUPPRESS to RETURN_OUTPUT are asserted end to end below. + test("classifies context.output without suppressOutput as RETURN_OUTPUT", () => { + expect( + inferAuthorizationPhase("permit (principal, action, resource) when { context.output.done };"), + ).toBe("RETURN_OUTPUT"); }); }); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 2321cd6ea..94e652887 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -12,6 +12,8 @@ export function inferAuthorizationPhase(statement: string): "INITIATE" | "RETURN return /\bsuppressOutput\b|context\.output/.test(statement) ? "RETURN_OUTPUT" : "INITIATE"; } +const PHASES = { initiate: "INITIATE", "return-output": "RETURN_OUTPUT" } as const; + export const createAddPolicyHandler = (config: AddProjectResourceConfig) => createHandler({ name: "policy", @@ -80,12 +82,9 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => throw new InputValidationError("--generate is not implemented yet"); } - const authorizationPhase = - flags["authorization-phase"] === undefined - ? inferAuthorizationPhase(statement) - : flags["authorization-phase"] === "return-output" - ? "RETURN_OUTPUT" - : "INITIATE"; + const authorizationPhase = flags["authorization-phase"] + ? PHASES[flags["authorization-phase"]] + : inferAuthorizationPhase(statement); const policy: z.input = { name: flags.name, From 87337757f355fa3381b7f0983b7f76b955236acb Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 17:52:52 -0400 Subject: [PATCH 07/19] feat: project remove policy-engine and policy --- src/core/project/manager.tsx | 32 +++++++++++- src/handlers/project/remove/index.test.ts | 64 +++++++++++++++++++++++ src/handlers/project/remove/index.ts | 22 +++++++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 44d5018e2..c1866c8a4 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -353,7 +353,37 @@ export class FsProjectManager implements ProjectManager { let removed = false; let newSpec: unknown; - if (input.resourceType === "gateway-target") { + if (input.resourceType === "policy") { + const candidates = existingProjectSpec.policyEngines.filter((engine) => + engine.policies.some((policy) => policy.name === input.name), + ); + if (!input.engineName && candidates.length > 1) { + throw new InputValidationError( + `policy '${input.name}' exists in multiple engines: ${candidates + .map((engine) => engine.name) + .join(", ")}; use --engine to choose one`, + ); + } + const engineName = input.engineName ?? candidates[0]?.name; + const engines = existingProjectSpec.policyEngines.map((engine) => + engine.name === engineName + ? { ...engine, policies: engine.policies.filter((policy) => policy.name !== input.name) } + : engine, + ); + removed = candidates.some((engine) => engine.name === engineName); + newSpec = { ...existingProjectSpec, policyEngines: engines }; + } else if (input.resourceType === "policy-engine") { + const engines = existingProjectSpec.policyEngines.filter( + (engine) => engine.name !== input.name, + ); + removed = engines.length !== existingProjectSpec.policyEngines.length; + const gateways = existingProjectSpec.agentCoreGateways.map((gateway) => + gateway.policyEngineConfiguration?.policyEngineName === input.name + ? { ...gateway, policyEngineConfiguration: undefined } + : gateway, + ); + newSpec = { ...existingProjectSpec, policyEngines: engines, agentCoreGateways: gateways }; + } else if (input.resourceType === "gateway-target") { const gateways = [...existingProjectSpec.agentCoreGateways]; const gatewayIndex = gateways.findIndex((gateway) => gateway.name === input.gatewayName); if (gatewayIndex >= 0) { diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index c6d4a4779..2e1e8677f 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -171,8 +171,72 @@ describe("project remove", () => { "--gateway on a non-Target resource", ["remove", "gateway", "--gateway", "tools", "--name", "tools"], ], + [ + "--engine on a non-policy resource", + ["remove", "gateway", "--engine", "Guardrails", "--name", "tools"], + ], ])("%s", async (_label, args) => { await inProject(); await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); }); + + async function addPolicy(engine: string, name: string): Promise { + await run([ + "add", + "policy", + "--engine", + engine, + "--name", + name, + "--statement", + "forbid (principal, action, resource);", + ]); + } + + async function policyEngines(projectRoot: string) { + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + return agentcoreJson.policyEngines; + } + + test.each([ + ["with --engine", ["--engine", "Guardrails"]], + ["resolving the engine from an unambiguous name", []], + ])("removes a policy from its engine %s", async (_label, engineArgs) => { + const projectRoot = await inProject(); + await run(["add", "policy-engine", "--name", "Guardrails"]); + await addPolicy("Guardrails", "DenyAll"); + + await run(["remove", "policy", "--name", "DenyAll", ...engineArgs]); + + expect((await policyEngines(projectRoot))[0].policies).toEqual([]); + }); + + test("rejects an ambiguous policy name without --engine", async () => { + await inProject(); + await run(["add", "policy-engine", "--name", "First"]); + await run(["add", "policy-engine", "--name", "Second"]); + await addPolicy("First", "DenyAll"); + // Duplicate policy names cannot be added through the CLI, so seed the + // second one by editing the spec the way a user would. + const specPath = join(process.cwd(), "agentcore", "agentcore.json"); + const spec = await Bun.file(specPath).json(); + spec.policyEngines[1].policies = [...spec.policyEngines[0].policies]; + await Bun.write(specPath, JSON.stringify(spec, undefined, 2)); + + await expect(run(["remove", "policy", "--name", "DenyAll"])).rejects.toThrow( + "exists in multiple engines: First, Second", + ); + }); + + test("removing an engine strips gateway references", async () => { + const projectRoot = await inProject(); + await run(["add", "gateway", "--name", "tools"]); + await run(["add", "policy-engine", "--name", "Guardrails", "--attach-to-gateways", "tools"]); + + await run(["remove", "policy-engine", "--name", "Guardrails"]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.policyEngines).toEqual([]); + expect(agentcoreJson.agentCoreGateways[0].policyEngineConfiguration).toBeUndefined(); + }); }); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 2f3e39c31..6b0b80462 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -16,12 +16,23 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) flags: [ flag("name", "name of the resource to remove", z.string().min(1).optional()), flag("gateway", "name of the parent Gateway for a Target", z.string().min(1).optional()), + flag("engine", "name of the parent Policy Engine for a Policy", z.string().min(1).optional()), ], arguments: [ argument( "resource", "type of resource to remove", - z.enum(["harness", "runtime", "gateway", "gateway-target", "gateway-connector"]).optional(), + z + .enum([ + "harness", + "runtime", + "gateway", + "gateway-target", + "gateway-connector", + "policy-engine", + "policy", + ]) + .optional(), ), ], handle: async (ctx, flags, args) => { @@ -40,12 +51,21 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) gatewayName: flags.gateway, name, }); + } else if (resource === "policy") { + await config.projectManager.removeResource(project, { + resourceType: "policy", + engineName: flags.engine, + name, + }); } else { if (flags.gateway) { throw new InputValidationError( `--gateway is valid only when removing a gateway-target or gateway-connector`, ); } + if (flags.engine) { + throw new InputValidationError(`--engine is valid only when removing a policy`); + } await config.projectManager.removeResource(project, { resourceType: resource, name, From ea3d1a43227e246a8130f1e5f47b4b4c6f1767f9 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:00:30 -0400 Subject: [PATCH 08/19] refactor: simplify remove slice, validate gateway policy engine references --- src/core/project/manager.tsx | 8 +++++--- src/handlers/project/remove/index.test.ts | 25 ++++++++++++----------- src/handlers/project/remove/index.ts | 17 +++++++-------- src/projectSchemas/project.test.ts | 23 +++++++++++++++++++++ src/projectSchemas/project.ts | 8 ++++++++ 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c1866c8a4..d34a07980 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -364,13 +364,15 @@ export class FsProjectManager implements ProjectManager { .join(", ")}; use --engine to choose one`, ); } - const engineName = input.engineName ?? candidates[0]?.name; + const owner = input.engineName + ? candidates.find((engine) => engine.name === input.engineName) + : candidates[0]; + removed = owner !== undefined; const engines = existingProjectSpec.policyEngines.map((engine) => - engine.name === engineName + engine === owner ? { ...engine, policies: engine.policies.filter((policy) => policy.name !== input.name) } : engine, ); - removed = candidates.some((engine) => engine.name === engineName); newSpec = { ...existingProjectSpec, policyEngines: engines }; } else if (input.resourceType === "policy-engine") { const engines = existingProjectSpec.policyEngines.filter( diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index 2e1e8677f..df519e563 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -193,9 +193,8 @@ describe("project remove", () => { ]); } - async function policyEngines(projectRoot: string) { - const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); - return agentcoreJson.policyEngines; + async function projectSpec(projectRoot: string) { + return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); } test.each([ @@ -208,20 +207,22 @@ describe("project remove", () => { await run(["remove", "policy", "--name", "DenyAll", ...engineArgs]); - expect((await policyEngines(projectRoot))[0].policies).toEqual([]); + expect((await projectSpec(projectRoot)).policyEngines[0].policies).toEqual([]); }); test("rejects an ambiguous policy name without --engine", async () => { - await inProject(); + const projectRoot = await inProject(); await run(["add", "policy-engine", "--name", "First"]); await run(["add", "policy-engine", "--name", "Second"]); await addPolicy("First", "DenyAll"); // Duplicate policy names cannot be added through the CLI, so seed the // second one by editing the spec the way a user would. - const specPath = join(process.cwd(), "agentcore", "agentcore.json"); - const spec = await Bun.file(specPath).json(); - spec.policyEngines[1].policies = [...spec.policyEngines[0].policies]; - await Bun.write(specPath, JSON.stringify(spec, undefined, 2)); + const spec = await projectSpec(projectRoot); + spec.policyEngines[1].policies = spec.policyEngines[0].policies; + await Bun.write( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify(spec, undefined, 2), + ); await expect(run(["remove", "policy", "--name", "DenyAll"])).rejects.toThrow( "exists in multiple engines: First, Second", @@ -235,8 +236,8 @@ describe("project remove", () => { await run(["remove", "policy-engine", "--name", "Guardrails"]); - const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); - expect(agentcoreJson.policyEngines).toEqual([]); - expect(agentcoreJson.agentCoreGateways[0].policyEngineConfiguration).toBeUndefined(); + const spec = await projectSpec(projectRoot); + expect(spec.policyEngines).toEqual([]); + expect(spec.agentCoreGateways[0].policyEngineConfiguration).toBeUndefined(); }); }); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 6b0b80462..8d5973d92 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -41,6 +41,15 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) if (!resource) throw new InputValidationError(`resource argument is required to remove`); if (!name) throw new InputValidationError(`--name is required option`); + if (flags.gateway && resource !== "gateway-target" && resource !== "gateway-connector") { + throw new InputValidationError( + `--gateway is valid only when removing a gateway-target or gateway-connector`, + ); + } + if (flags.engine && resource !== "policy") { + throw new InputValidationError(`--engine is valid only when removing a policy`); + } + const project = ctx.require(ProjectKey); if (resource === "gateway-target" || resource === "gateway-connector") { if (!flags.gateway) { @@ -58,14 +67,6 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) name, }); } else { - if (flags.gateway) { - throw new InputValidationError( - `--gateway is valid only when removing a gateway-target or gateway-connector`, - ); - } - if (flags.engine) { - throw new InputValidationError(`--engine is valid only when removing a policy`); - } await config.projectManager.removeResource(project, { resourceType: resource, name, diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 8a9b89842..e70425e13 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -148,6 +148,29 @@ describe("project custom validation", () => { } }); + it("validates gateway policy engine references", () => { + const gatewayWithEngine = (policyEngineName: string) => ({ + ...minimalProject, + agentCoreGateways: [ + { + name: "gateway", + targets: [], + policyEngineConfiguration: { policyEngineName, mode: "ENFORCE" }, + }, + ], + policyEngines: [{ name: "Guardrails" }], + }); + + expect(ProjectSpecSchema.safeParse(gatewayWithEngine("Guardrails")).success).toBe(true); + const result = ProjectSpecSchema.safeParse(gatewayWithEngine("Missing")); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message.includes("unknown policy engine")), + ).toBe(true); + } + }); + it("distinguishes project knowledge-base names from external IDs", () => { const target = { name: "knowledge", diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index efca1f8aa..8e83c994e 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -114,7 +114,15 @@ export const ProjectSpecSchema = z } } } + const policyEngineNames = new Set(spec.policyEngines.map((engine) => engine.name)); for (const gw of spec.agentCoreGateways ?? []) { + const engineName = gw.policyEngineConfiguration?.policyEngineName; + if (engineName && !policyEngineNames.has(engineName)) { + ctx.addIssue({ + code: "custom", + message: `Gateway "${gw.name}" references unknown policy engine "${engineName}". Check spec.policyEngines.`, + }); + } for (const target of gw.targets) { if (target.targetType === "httpRuntime") { if (target.httpRuntime?.runtime) { From 2776f9d5db976c3a7db15d04c9125596d1056b3b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:09:06 -0400 Subject: [PATCH 09/19] feat: generate Cedar policies from natural language in project add policy --- src/core/index.tsx | 3 + src/core/policy.test.ts | 118 ++++++++++++++ src/core/policy.tsx | 152 ++++++++++++++++++ src/handlers/index.tsx | 4 +- .../project/add/gateway-test-support.ts | 4 +- src/handlers/project/add/policy/index.test.ts | 55 +++++++ src/handlers/project/add/policy/index.ts | 26 ++- src/handlers/project/add/policy/types.ts | 20 +++ src/handlers/project/add/types.ts | 2 + src/handlers/project/index.ts | 2 + src/handlers/types.tsx | 2 + src/testing/TestCoreClient.tsx | 24 +++ 12 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 src/core/policy.test.ts create mode 100644 src/core/policy.tsx create mode 100644 src/handlers/project/add/policy/types.ts diff --git a/src/core/index.tsx b/src/core/index.tsx index d7275b912..b6919a866 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 { RuntimeClient } from "./runtime"; import type { AwsClients, @@ -64,6 +65,7 @@ export class CoreClient implements AwsClients { readonly runtime: RuntimeClient; readonly gateway: GatewayClient; readonly eval: EvalClient; + readonly policy: PolicyClient; readonly projectManager: ProjectManager; @@ -85,6 +87,7 @@ export class CoreClient implements AwsClients { this.logger.child({ module: "eval" }), config.newSessionId, ); + this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts new file mode 100644 index 000000000..5dc8e9c48 --- /dev/null +++ b/src/core/policy.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { + GetGatewayCommand, + GetPolicyGenerationCommand, + ListGatewaysCommand, + ListPolicyEngineSummariesCommand, + ListPolicyGenerationAssetsCommand, + StartPolicyGenerationCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { PolicyClient } from "./policy"; +import { createSilentLogger } from "../testing"; +import type { AwsClients } from "./types"; + +function fakeClients(responses: { + engines?: unknown; + gateways?: unknown; + getGateway?: unknown; + start?: unknown; + get?: unknown; + assets?: unknown; +}): AwsClients { + const control = { + send: async (command: unknown) => { + if (command instanceof ListPolicyEngineSummariesCommand) return responses.engines; + if (command instanceof ListGatewaysCommand) return responses.gateways; + if (command instanceof GetGatewayCommand) return responses.getGateway; + if (command instanceof StartPolicyGenerationCommand) return responses.start; + if (command instanceof GetPolicyGenerationCommand) return responses.get; + if (command instanceof ListPolicyGenerationAssetsCommand) return responses.assets; + throw new Error(`unexpected command: ${command?.constructor?.name}`); + }, + }; + return { control: () => control } as unknown as AwsClients; +} + +const HAPPY = { + engines: { + policyEngines: [{ name: "Proj_Guardrails", policyEngineId: "pe-abc123" }], + }, + gateways: { items: [{ name: "Proj-tools", gatewayId: "gw-1" }] }, + getGateway: { gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:1:gateway/gw-1" }, + start: { policyGenerationId: "gen-1" }, + get: { status: "GENERATED" }, + assets: { + policyGenerationAssets: [ + { + definition: { cedar: { statement: "forbid (principal, action, resource);" } }, + findings: [{ type: "VALID", description: "ok" }], + }, + ], + }, +}; + +async function drain(client: PolicyClient, input: Parameters[0]) { + const generator = client.generatePolicy(input, { region: "us-west-2" }); + const messages: string[] = []; + while (true) { + const next = await generator.next(); + if (next.done) return { messages, result: next.value }; + messages.push(next.value.message); + } +} + +describe("PolicyClient.generatePolicy", () => { + const input = { + projectName: "Proj", + engineName: "Guardrails", + gatewayName: "tools", + description: "block hate speech", + }; + + function client(responses: Parameters[0]) { + return new PolicyClient(fakeClients(responses), createSilentLogger(), { pollDelayMs: 0 }); + } + + test("resolves deployed ids, generates, and returns statement with findings", async () => { + const { messages, result } = await drain(client(HAPPY), input); + + expect(result.statement).toBe("forbid (principal, action, resource);"); + expect(result.findings).toEqual([{ type: "VALID", description: "ok" }]); + expect(messages.some((message) => message.includes("Generating"))).toBe(true); + }); + + test("uses the only deployed project gateway when none is named", async () => { + const { result } = await drain(client(HAPPY), { ...input, gatewayName: undefined }); + expect(result.statement).toBe("forbid (principal, action, resource);"); + }); + + test.each([ + ["engine not deployed", { ...HAPPY, engines: { policyEngines: [] } }, "is not deployed"], + ["gateway not deployed", { ...HAPPY, gateways: { items: [] } }, "not deployed"], + [ + "multiple gateways without --gateway", + { + ...HAPPY, + gateways: { + items: [ + { name: "Proj-tools", gatewayId: "gw-1" }, + { name: "Proj-search", gatewayId: "gw-2" }, + ], + }, + }, + "pass --gateway to choose one", + ], + [ + "generation failed", + { ...HAPPY, get: { status: "GENERATE_FAILED", statusReasons: ["bad input"] } }, + "bad input", + ], + ["no assets", { ...HAPPY, assets: { policyGenerationAssets: [] } }, "no generated policy"], + ])("fails when %s", async (label, responses, message) => { + const generateInput = + label === "multiple gateways without --gateway" + ? { ...input, gatewayName: undefined } + : input; + await expect(drain(client(responses), generateInput)).rejects.toThrow(message); + }); +}); diff --git a/src/core/policy.tsx b/src/core/policy.tsx new file mode 100644 index 000000000..3b6825658 --- /dev/null +++ b/src/core/policy.tsx @@ -0,0 +1,152 @@ +import { + GetGatewayCommand, + GetPolicyGenerationCommand, + ListGatewaysCommand, + ListPolicyEngineSummariesCommand, + ListPolicyGenerationAssetsCommand, + StartPolicyGenerationCommand, + type GatewaySummary, + type PolicyEngineSummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { AgentCoreCLIError, ResourceNotFoundError } from "../errors"; +import type { + CorePolicyClient, + GeneratedPolicy, + GeneratePolicyInput, +} from "../handlers/project/add/policy/types"; +import type { Logger } from "../logging"; +import type { AwsClients, CoreOptions } from "./types"; +import { toClientConfig } from "./utils"; + +const GENERATION_POLL_DELAY_MS = 3_000; +const GENERATION_MAX_POLLS = 40; + +type PolicyClientConfig = { + pollDelayMs?: number; +}; + +export class PolicyClient implements CorePolicyClient { + private readonly pollDelayMs: number; + + constructor( + private readonly clients: AwsClients, + private readonly logger: Logger, + config: PolicyClientConfig = {}, + ) { + this.pollDelayMs = config.pollDelayMs ?? GENERATION_POLL_DELAY_MS; + } + + async *generatePolicy( + input: GeneratePolicyInput, + options: CoreOptions, + ): AsyncGenerator<{ message: string }, GeneratedPolicy> { + const control = this.clients.control(toClientConfig(options)); + + yield { message: `Resolving deployed policy engine '${input.engineName}'` }; + const engineServiceName = `${input.projectName}_${input.engineName}`; + let engine: PolicyEngineSummary | undefined; + let engineToken: string | undefined; + do { + const page = await control.send( + new ListPolicyEngineSummariesCommand({ nextToken: engineToken }), + ); + engine = page.policyEngines?.find((candidate) => candidate.name === engineServiceName); + engineToken = page.nextToken; + } while (!engine && engineToken); + if (!engine?.policyEngineId) { + throw new ResourceNotFoundError( + `policy engine '${input.engineName}' is not deployed; run 'agentcore project deploy' first`, + ); + } + + yield { message: "Resolving deployed gateway" }; + const gatewayServicePrefix = `${input.projectName}-`; + const deployed: GatewaySummary[] = []; + let gatewayToken: string | undefined; + do { + const page = await control.send(new ListGatewaysCommand({ nextToken: gatewayToken })); + for (const candidate of page.items ?? []) { + const matches = input.gatewayName + ? candidate.name === `${gatewayServicePrefix}${input.gatewayName}` + : candidate.name?.startsWith(gatewayServicePrefix); + if (matches) deployed.push(candidate); + } + gatewayToken = page.nextToken; + } while (gatewayToken); + if (deployed.length === 0) { + throw new ResourceNotFoundError( + input.gatewayName + ? `gateway '${input.gatewayName}' is not deployed; run 'agentcore project deploy' first` + : `no deployed gateway found for project '${input.projectName}'; deploy one or pass --gateway`, + ); + } + if (!input.gatewayName && deployed.length > 1) { + throw new AgentCoreCLIError( + `multiple deployed gateways found: ${deployed + .map((candidate) => candidate.name) + .join(", ")}; pass --gateway to choose one`, + ); + } + const gateway = await control.send( + new GetGatewayCommand({ gatewayIdentifier: deployed[0]!.gatewayId }), + ); + if (!gateway.gatewayArn) { + throw new AgentCoreCLIError(`could not resolve the ARN of gateway '${deployed[0]!.name}'`); + } + + yield { message: "Generating a Cedar policy from the description (may take a minute)" }; + const started = await control.send( + new StartPolicyGenerationCommand({ + policyEngineId: engine.policyEngineId, + resource: { arn: gateway.gatewayArn }, + content: { rawText: input.description }, + name: `cli_generation_${Date.now()}`, + }), + ); + if (!started.policyGenerationId) { + throw new AgentCoreCLIError("StartPolicyGeneration returned no generation id"); + } + + let status: string | undefined = "GENERATING"; + let statusReasons: string[] | undefined; + for (let poll = 0; poll < GENERATION_MAX_POLLS && status === "GENERATING"; poll++) { + await new Promise((resolve) => setTimeout(resolve, this.pollDelayMs)); + const current = await control.send( + new GetPolicyGenerationCommand({ + policyGenerationId: started.policyGenerationId, + policyEngineId: engine.policyEngineId, + }), + ); + status = current.status; + statusReasons = current.statusReasons; + this.logger.debug(`policy generation ${started.policyGenerationId} status: ${status}`); + if (status === "GENERATING") yield { message: "Still generating" }; + } + if (status !== "GENERATED") { + throw new AgentCoreCLIError( + `policy generation did not complete: ${statusReasons?.join(", ") ?? status}`, + ); + } + + const assets = await control.send( + new ListPolicyGenerationAssetsCommand({ + policyGenerationId: started.policyGenerationId, + policyEngineId: engine.policyEngineId, + }), + ); + const asset = assets.policyGenerationAssets?.[0]; + const statement = asset?.definition?.cedar?.statement; + if (!statement) { + throw new AgentCoreCLIError( + "generation completed but returned no generated policy statement", + ); + } + return { + statement, + findings: (asset.findings ?? []).map((finding) => ({ + type: finding.type ?? "UNKNOWN", + description: finding.description ?? "", + })), + }; + } +} diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 87def85c6..36712557f 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,7 +50,9 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager, io })); + root.handler( + createProjectHandler({ projectManager: core.projectManager, policy: core.policy, io }), + ); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index e373764d6..67077c60a 100644 --- a/src/handlers/project/add/gateway-test-support.ts +++ b/src/handlers/project/add/gateway-test-support.ts @@ -13,10 +13,10 @@ export function createGatewayProjectTestHarness(directoryPrefix: string) { const originalCwd = process.cwd(); const tempDirectories: string[] = []; - async function run(args: string[], stdin?: string) { + async function run(args: string[], stdin?: string, core = new TestCoreClient()) { const io = testIO(); if (stdin !== undefined) io.io.stdin.end(stdin); - const root = createRootHandler(new TestCoreClient(), { + const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index 277f1ff18..8856d3be2 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { TestCoreClient } from "../../../../testing"; import { createGatewayProjectTestHarness } from "../gateway-test-support"; import { inferAuthorizationPhase } from "./index"; @@ -143,6 +144,60 @@ describe("project add policy", () => { await expect(run(args)).rejects.toThrow(message); }); + test("adds a generated policy and surfaces findings", async () => { + const projectRoot = await withEngine(); + const core = new TestCoreClient(); + core.policy.generateResult = { + statement: SUPPRESS, + findings: [{ type: "VALID", description: "ok" }], + }; + + const io = await run( + [ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "Gen", + "--generate", + "block hate speech", + "--gateway", + "tools", + ], + undefined, + core, + ); + + expect(core.policy.generateCalls[0]).toMatchObject({ + engineName: "Guardrails", + gatewayName: "tools", + description: "block hate speech", + }); + expect(io.stderr()).toContain("Generated Cedar policy:"); + expect(io.stderr()).toContain("VALID"); + expect((await projectSpec(projectRoot)).policyEngines[0].policies[0]).toMatchObject({ + name: "Gen", + statement: SUPPRESS, + authorizationPhase: "RETURN_OUTPUT", + }); + }); + + test("fails without writing when generation fails", async () => { + const projectRoot = await withEngine(); + const core = new TestCoreClient(); + core.policy.generateError = new Error("policy engine 'Guardrails' is not deployed"); + + await expect( + run( + ["add", "policy", "--engine", "Guardrails", "--name", "Gen", "--generate", "x"], + undefined, + core, + ), + ).rejects.toThrow("is not deployed"); + expect((await projectSpec(projectRoot)).policyEngines[0].policies).toEqual([]); + }); + test("rejects a duplicate policy name across engines", async () => { await withEngine(); await run(["add", "policy-engine", "--name", "Second"]); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 94e652887..969f0aace 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -3,7 +3,9 @@ import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; import type { PolicySchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; +import { coreOptsFromCtx } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; +import type { GeneratedPolicy } from "./types"; /** A substring heuristic, not a Cedar parser; --authorization-phase overrides it. @@ -79,7 +81,29 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => sourceFile = flags.statement.slice("file://".length); } } else { - throw new InputValidationError("--generate is not implemented yet"); + const generator = config.policy.generatePolicy( + { + projectName: project.name, + engineName: flags.engine, + gatewayName: flags.gateway, + description: flags.generate!, + }, + coreOptsFromCtx(ctx), + ); + let generated: GeneratedPolicy; + while (true) { + const next = await generator.next(); + if (next.done) { + generated = next.value; + break; + } + config.io.stderr.write(`${next.value.message}\n`); + } + statement = generated.statement; + config.io.stderr.write(`Generated Cedar policy:\n${statement}\n`); + for (const finding of generated.findings) { + config.io.stderr.write(`finding [${finding.type}]: ${finding.description}\n`); + } } const authorizationPhase = flags["authorization-phase"] diff --git a/src/handlers/project/add/policy/types.ts b/src/handlers/project/add/policy/types.ts new file mode 100644 index 000000000..85753bf90 --- /dev/null +++ b/src/handlers/project/add/policy/types.ts @@ -0,0 +1,20 @@ +import type { CoreOptions } from "../../../../core/types"; + +export type GeneratePolicyInput = { + projectName: string; + engineName: string; + gatewayName?: string; + description: string; +}; + +export type GeneratedPolicy = { + statement: string; + findings: { type: string; description: string }[]; +}; + +export interface CorePolicyClient { + generatePolicy( + input: GeneratePolicyInput, + options: CoreOptions, + ): AsyncGenerator<{ message: string }, GeneratedPolicy>; +} diff --git a/src/handlers/project/add/types.ts b/src/handlers/project/add/types.ts index 26943b932..0a78352d5 100644 --- a/src/handlers/project/add/types.ts +++ b/src/handlers/project/add/types.ts @@ -1,7 +1,9 @@ import type { AppIO } from "../../../io"; import type { ProjectManager } from "../types"; +import type { CorePolicyClient } from "./policy/types"; export type AddProjectResourceConfig = { projectManager: ProjectManager; + policy: CorePolicyClient; io: AppIO; }; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 2eb42eeaa..3bb94958c 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -13,9 +13,11 @@ import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; +import type { CorePolicyClient } from "./add/policy/types"; type ProjectHandlerConfig = { projectManager: ProjectManager; + policy: CorePolicyClient; io: AppIO; }; diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index f129805a8..c1eec39dc 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -6,6 +6,7 @@ import type { CoreMemoryClient } from "./memory/types.tsx"; import type { CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; import type { ProjectManager } from "./project/types.ts"; +import type { CorePolicyClient } from "./project/add/policy/types"; export interface Core { harness: CoreHarnessClient; @@ -14,6 +15,7 @@ export interface Core { runtime: CoreRuntimeClient; gateway: CoreGatewayClient; eval: CoreEvalClient; + policy: CorePolicyClient; projectManager: ProjectManager; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index e58ef5209..a2575ce1e 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -161,6 +161,11 @@ import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; +import type { + CorePolicyClient, + GeneratedPolicy, + GeneratePolicyInput, +} from "../handlers/project/add/policy/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; @@ -2221,6 +2226,24 @@ export class TestEvalClient implements CoreEvalClient { } } +export class TestPolicyClient implements CorePolicyClient { + generateResult: GeneratedPolicy = { + statement: "forbid (principal, action, resource);", + findings: [], + }; + generateError: Error | undefined; + generateCalls: GeneratePolicyInput[] = []; + + async *generatePolicy( + input: GeneratePolicyInput, + ): AsyncGenerator<{ message: string }, GeneratedPolicy> { + this.generateCalls.push(input); + if (this.generateError) throw this.generateError; + yield { message: "Generating a Cedar policy from the description (may take a minute)" }; + return this.generateResult; + } +} + // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); @@ -2229,6 +2252,7 @@ export class TestCoreClient implements Core { readonly runtime = new TestRuntimeClient(); readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); + readonly policy = new TestPolicyClient(); readonly projectManager: ProjectManager; // Commands the project manager would have run (npm install, git init, ...), From 6169a02c1df5a37b5d2cb9d238672a3eb38448d8 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:20:21 -0400 Subject: [PATCH 10/19] refactor: handler-owned gateway resolution and shared resource-name rules for --generate --- src/core/policy.test.ts | 31 ++---------- src/core/policy.tsx | 49 +++++-------------- src/handlers/project/add/policy/index.test.ts | 24 +++++++++ src/handlers/project/add/policy/index.ts | 40 +++++++++++---- src/handlers/project/add/policy/types.ts | 7 ++- src/projectSchemas/gateway.ts | 11 +++++ src/projectSchemas/policy.ts | 7 +++ src/testing/TestCoreClient.tsx | 2 +- 8 files changed, 96 insertions(+), 75 deletions(-) diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts index 5dc8e9c48..25f668d96 100644 --- a/src/core/policy.test.ts +++ b/src/core/policy.test.ts @@ -63,14 +63,15 @@ async function drain(client: PolicyClient, input: Parameters { const input = { - projectName: "Proj", engineName: "Guardrails", gatewayName: "tools", + engineServiceName: "Proj_Guardrails", + gatewayServiceName: "Proj-tools", description: "block hate speech", }; function client(responses: Parameters[0]) { - return new PolicyClient(fakeClients(responses), createSilentLogger(), { pollDelayMs: 0 }); + return new PolicyClient(fakeClients(responses), createSilentLogger(), 0); } test("resolves deployed ids, generates, and returns statement with findings", async () => { @@ -81,38 +82,16 @@ describe("PolicyClient.generatePolicy", () => { expect(messages.some((message) => message.includes("Generating"))).toBe(true); }); - test("uses the only deployed project gateway when none is named", async () => { - const { result } = await drain(client(HAPPY), { ...input, gatewayName: undefined }); - expect(result.statement).toBe("forbid (principal, action, resource);"); - }); - test.each([ ["engine not deployed", { ...HAPPY, engines: { policyEngines: [] } }, "is not deployed"], ["gateway not deployed", { ...HAPPY, gateways: { items: [] } }, "not deployed"], - [ - "multiple gateways without --gateway", - { - ...HAPPY, - gateways: { - items: [ - { name: "Proj-tools", gatewayId: "gw-1" }, - { name: "Proj-search", gatewayId: "gw-2" }, - ], - }, - }, - "pass --gateway to choose one", - ], [ "generation failed", { ...HAPPY, get: { status: "GENERATE_FAILED", statusReasons: ["bad input"] } }, "bad input", ], ["no assets", { ...HAPPY, assets: { policyGenerationAssets: [] } }, "no generated policy"], - ])("fails when %s", async (label, responses, message) => { - const generateInput = - label === "multiple gateways without --gateway" - ? { ...input, gatewayName: undefined } - : input; - await expect(drain(client(responses), generateInput)).rejects.toThrow(message); + ])("fails when %s", async (_label, responses, message) => { + await expect(drain(client(responses), input)).rejects.toThrow(message); }); }); diff --git a/src/core/policy.tsx b/src/core/policy.tsx index 3b6825658..5122ea236 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -1,3 +1,4 @@ +import { setTimeout as sleep } from "node:timers/promises"; import { GetGatewayCommand, GetPolicyGenerationCommand, @@ -21,20 +22,12 @@ import { toClientConfig } from "./utils"; const GENERATION_POLL_DELAY_MS = 3_000; const GENERATION_MAX_POLLS = 40; -type PolicyClientConfig = { - pollDelayMs?: number; -}; - export class PolicyClient implements CorePolicyClient { - private readonly pollDelayMs: number; - constructor( private readonly clients: AwsClients, private readonly logger: Logger, - config: PolicyClientConfig = {}, - ) { - this.pollDelayMs = config.pollDelayMs ?? GENERATION_POLL_DELAY_MS; - } + private readonly pollDelayMs = GENERATION_POLL_DELAY_MS, + ) {} async *generatePolicy( input: GeneratePolicyInput, @@ -43,14 +36,13 @@ export class PolicyClient implements CorePolicyClient { const control = this.clients.control(toClientConfig(options)); yield { message: `Resolving deployed policy engine '${input.engineName}'` }; - const engineServiceName = `${input.projectName}_${input.engineName}`; let engine: PolicyEngineSummary | undefined; let engineToken: string | undefined; do { const page = await control.send( new ListPolicyEngineSummariesCommand({ nextToken: engineToken }), ); - engine = page.policyEngines?.find((candidate) => candidate.name === engineServiceName); + engine = page.policyEngines?.find((candidate) => candidate.name === input.engineServiceName); engineToken = page.nextToken; } while (!engine && engineToken); if (!engine?.policyEngineId) { @@ -59,39 +51,24 @@ export class PolicyClient implements CorePolicyClient { ); } - yield { message: "Resolving deployed gateway" }; - const gatewayServicePrefix = `${input.projectName}-`; - const deployed: GatewaySummary[] = []; + yield { message: `Resolving deployed gateway '${input.gatewayName}'` }; + let deployed: GatewaySummary | undefined; let gatewayToken: string | undefined; do { const page = await control.send(new ListGatewaysCommand({ nextToken: gatewayToken })); - for (const candidate of page.items ?? []) { - const matches = input.gatewayName - ? candidate.name === `${gatewayServicePrefix}${input.gatewayName}` - : candidate.name?.startsWith(gatewayServicePrefix); - if (matches) deployed.push(candidate); - } + deployed = page.items?.find((candidate) => candidate.name === input.gatewayServiceName); gatewayToken = page.nextToken; - } while (gatewayToken); - if (deployed.length === 0) { + } while (!deployed && gatewayToken); + if (!deployed) { throw new ResourceNotFoundError( - input.gatewayName - ? `gateway '${input.gatewayName}' is not deployed; run 'agentcore project deploy' first` - : `no deployed gateway found for project '${input.projectName}'; deploy one or pass --gateway`, - ); - } - if (!input.gatewayName && deployed.length > 1) { - throw new AgentCoreCLIError( - `multiple deployed gateways found: ${deployed - .map((candidate) => candidate.name) - .join(", ")}; pass --gateway to choose one`, + `gateway '${input.gatewayName}' is not deployed; run 'agentcore project deploy' first`, ); } const gateway = await control.send( - new GetGatewayCommand({ gatewayIdentifier: deployed[0]!.gatewayId }), + new GetGatewayCommand({ gatewayIdentifier: deployed.gatewayId }), ); if (!gateway.gatewayArn) { - throw new AgentCoreCLIError(`could not resolve the ARN of gateway '${deployed[0]!.name}'`); + throw new AgentCoreCLIError(`could not resolve the ARN of gateway '${input.gatewayName}'`); } yield { message: "Generating a Cedar policy from the description (may take a minute)" }; @@ -110,7 +87,7 @@ export class PolicyClient implements CorePolicyClient { let status: string | undefined = "GENERATING"; let statusReasons: string[] | undefined; for (let poll = 0; poll < GENERATION_MAX_POLLS && status === "GENERATING"; poll++) { - await new Promise((resolve) => setTimeout(resolve, this.pollDelayMs)); + await sleep(this.pollDelayMs); const current = await control.send( new GetPolicyGenerationCommand({ policyGenerationId: started.policyGenerationId, diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index 8856d3be2..b3845cea8 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -146,6 +146,7 @@ describe("project add policy", () => { test("adds a generated policy and surfaces findings", async () => { const projectRoot = await withEngine(); + await run(["add", "gateway", "--name", "tools"]); const core = new TestCoreClient(); core.policy.generateResult = { statement: SUPPRESS, @@ -172,6 +173,8 @@ describe("project add policy", () => { expect(core.policy.generateCalls[0]).toMatchObject({ engineName: "Guardrails", gatewayName: "tools", + engineServiceName: "TestProject_Guardrails", + gatewayServiceName: "TestProject-tools", description: "block hate speech", }); expect(io.stderr()).toContain("Generated Cedar policy:"); @@ -183,8 +186,29 @@ describe("project add policy", () => { }); }); + test.each([ + ["an unknown --gateway", ["--gateway", "missing"], "does not exist in agentCoreGateways[]"], + ["no gateways in the project", [], "add one to this project"], + ])("rejects --generate with %s", async (_label, gatewayArgs, message) => { + await withEngine(); + await expect( + run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "Gen", + "--generate", + "x", + ...gatewayArgs, + ]), + ).rejects.toThrow(message); + }); + test("fails without writing when generation fails", async () => { const projectRoot = await withEngine(); + await run(["add", "gateway", "--name", "tools"]); const core = new TestCoreClient(); core.policy.generateError = new Error("policy engine 'Guardrails' is not deployed"); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 969f0aace..a13a8dd00 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -1,7 +1,8 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; -import type { PolicySchema } from "../../../../projectSchemas/policy"; +import { gatewayResourceName } from "../../../../projectSchemas/gateway"; +import { policyEngineResourceName, type PolicySchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { coreOptsFromCtx } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; @@ -81,24 +82,43 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => sourceFile = flags.statement.slice("file://".length); } } else { + const gateways = project.spec.agentCoreGateways; + const gateway = flags.gateway + ? gateways.find((candidate) => candidate.name === flags.gateway) + : gateways.length === 1 + ? gateways[0] + : undefined; + if (flags.gateway && !gateway) { + throw new InputValidationError( + `gateway '${flags.gateway}' does not exist in agentCoreGateways[]`, + ); + } + if (!gateway) { + throw new InputValidationError( + gateways.length === 0 + ? "--generate needs a deployed gateway; add one to this project and deploy it first" + : `this project declares multiple gateways: ${gateways + .map((candidate) => candidate.name) + .join(", ")}; pass --gateway to choose one`, + ); + } + const generator = config.policy.generatePolicy( { - projectName: project.name, engineName: flags.engine, - gatewayName: flags.gateway, + gatewayName: gateway.name, + engineServiceName: policyEngineResourceName(project.name, flags.engine), + gatewayServiceName: gatewayResourceName(project.name, gateway), description: flags.generate!, }, coreOptsFromCtx(ctx), ); - let generated: GeneratedPolicy; - while (true) { - const next = await generator.next(); - if (next.done) { - generated = next.value; - break; - } + let next = await generator.next(); + while (!next.done) { config.io.stderr.write(`${next.value.message}\n`); + next = await generator.next(); } + const generated: GeneratedPolicy = next.value; statement = generated.statement; config.io.stderr.write(`Generated Cedar policy:\n${statement}\n`); for (const finding of generated.findings) { diff --git a/src/handlers/project/add/policy/types.ts b/src/handlers/project/add/policy/types.ts index 85753bf90..7b5620fe4 100644 --- a/src/handlers/project/add/policy/types.ts +++ b/src/handlers/project/add/policy/types.ts @@ -1,9 +1,12 @@ import type { CoreOptions } from "../../../../core/types"; export type GeneratePolicyInput = { - projectName: string; + /** Project-spec names, used in progress and error messages. */ engineName: string; - gatewayName?: string; + gatewayName: string; + /** Exact deployed service names the control-plane lookups match against. */ + engineServiceName: string; + gatewayServiceName: string; description: string; }; diff --git a/src/projectSchemas/gateway.ts b/src/projectSchemas/gateway.ts index bb6b60435..89ea2fc91 100644 --- a/src/projectSchemas/gateway.ts +++ b/src/projectSchemas/gateway.ts @@ -433,6 +433,17 @@ export type GatewayPolicyEngineConfiguration = z.infer< >; export const GatewayProtocolTypeSchema = z.enum(["MCP", "None"]); export type GatewayProtocolType = z.infer; + +/** + The deployed service name of a gateway; mirrors the L3 Gateway construct's rule. +**/ +export function gatewayResourceName( + projectName: string, + gateway: { name: string; resourceName?: string }, +): string { + return gateway.resourceName ?? `${projectName}-${gateway.name}`; +} + export const AgentCoreGatewaySchema = z .object({ name: GatewayNameSchema, diff --git a/src/projectSchemas/policy.ts b/src/projectSchemas/policy.ts index 6f789a9d8..4e6c3eb56 100644 --- a/src/projectSchemas/policy.ts +++ b/src/projectSchemas/policy.ts @@ -49,3 +49,10 @@ export const PolicyEngineSchema = z.object({ ), }); export type PolicyEngine = z.infer; + +/** + The deployed service name of a policy engine; mirrors the L3 AgentCorePolicyEngine construct's rule. +**/ +export function policyEngineResourceName(projectName: string, engineName: string): string { + return `${projectName}_${engineName}`; +} diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index a2575ce1e..d1d16c1fd 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -2239,7 +2239,7 @@ export class TestPolicyClient implements CorePolicyClient { ): AsyncGenerator<{ message: string }, GeneratedPolicy> { this.generateCalls.push(input); if (this.generateError) throw this.generateError; - yield { message: "Generating a Cedar policy from the description (may take a minute)" }; + yield { message: "generating" }; return this.generateResult; } } From f491e643b086ac754b1faff60471318a067e921d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:29:23 -0400 Subject: [PATCH 11/19] refactor: derive gateway resource name through the shared rule --- src/handlers/project/add/gateway/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts index 1af7f8f92..4252d6596 100644 --- a/src/handlers/project/add/gateway/index.ts +++ b/src/handlers/project/add/gateway/index.ts @@ -2,7 +2,7 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; import { GatewayAuthorizerConfigSchema } from "../../../../projectSchemas/auth"; -import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; +import { gatewayResourceName, type AgentCoreGateway } from "../../../../projectSchemas/gateway"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseJsonFlagWithSchema, parseTags } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; @@ -55,7 +55,7 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) => throw new InputValidationError("required option '--name ' not specified"); } const project = ctx.require(ProjectKey); - const resourceName = `${project.name}-${flags.name}`; + const resourceName = gatewayResourceName(project.name, { name: flags.name }); if (resourceName.length > 48) { throw new InputValidationError( `Gateway resource name '${resourceName}' exceeds the service limit of 48 characters`, From 1a7d4a7c22d35beeb64802842736a4b625afa9c8 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:34:19 -0400 Subject: [PATCH 12/19] fix: surface generation findings when no Cedar statement is produced --- src/core/policy.test.ts | 15 +++++++++++++++ src/core/policy.tsx | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts index 25f668d96..017e047be 100644 --- a/src/core/policy.test.ts +++ b/src/core/policy.test.ts @@ -91,6 +91,21 @@ describe("PolicyClient.generatePolicy", () => { "bad input", ], ["no assets", { ...HAPPY, assets: { policyGenerationAssets: [] } }, "no generated policy"], + [ + "the description is not translatable", + { + ...HAPPY, + assets: { + policyGenerationAssets: [ + { + rawTextFragment: "do the thing", + findings: [{ type: "INVALID", description: "Non-translatable" }], + }, + ], + }, + }, + "could not be translated into a Cedar policy: [INVALID] Non-translatable", + ], ])("fails when %s", async (_label, responses, message) => { await expect(drain(client(responses), input)).rejects.toThrow(message); }); diff --git a/src/core/policy.tsx b/src/core/policy.tsx index 5122ea236..d54548876 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -114,8 +114,13 @@ export class PolicyClient implements CorePolicyClient { const asset = assets.policyGenerationAssets?.[0]; const statement = asset?.definition?.cedar?.statement; if (!statement) { + const findings = (asset?.findings ?? []) + .map((finding) => `[${finding.type}] ${finding.description}`) + .join("; "); throw new AgentCoreCLIError( - "generation completed but returned no generated policy statement", + findings + ? `the description could not be translated into a Cedar policy: ${findings}` + : "generation completed but returned no generated policy statement", ); } return { From f984a86ae4d876af00b15ec27ebec8d65d3764fb Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:35:48 -0400 Subject: [PATCH 13/19] fix: accept Dogwood policy definition members from generation assets --- src/core/policy.test.ts | 16 ++++++++++++++++ src/core/policy.tsx | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts index 017e047be..1d49e2cc4 100644 --- a/src/core/policy.test.ts +++ b/src/core/policy.test.ts @@ -82,6 +82,22 @@ describe("PolicyClient.generatePolicy", () => { expect(messages.some((message) => message.includes("Generating"))).toBe(true); }); + test("reads a Dogwood policy definition member", async () => { + const { result } = await drain( + client({ + ...HAPPY, + assets: { + policyGenerationAssets: [ + { definition: { policy: { statement: "forbid (principal, action, resource);" } } }, + ], + }, + }), + input, + ); + expect(result.statement).toBe("forbid (principal, action, resource);"); + expect(result.findings).toEqual([]); + }); + test.each([ ["engine not deployed", { ...HAPPY, engines: { policyEngines: [] } }, "is not deployed"], ["gateway not deployed", { ...HAPPY, gateways: { items: [] } }, "not deployed"], diff --git a/src/core/policy.tsx b/src/core/policy.tsx index d54548876..dbb4dc17f 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -112,7 +112,8 @@ export class PolicyClient implements CorePolicyClient { }), ); const asset = assets.policyGenerationAssets?.[0]; - const statement = asset?.definition?.cedar?.statement; + // The service returns either plain Cedar or its Dogwood superset member. + const statement = asset?.definition?.cedar?.statement ?? asset?.definition?.policy?.statement; if (!statement) { const findings = (asset?.findings ?? []) .map((finding) => `[${finding.type}] ${finding.description}`) From b635758f2603757cd7e93564d9013732b3ff9128 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 18:58:05 -0400 Subject: [PATCH 14/19] refactor: final simplify pass across the policy branch --- src/core/policy.tsx | 2 +- .../project/add/gateway-test-support.ts | 22 +++++++-------- src/handlers/project/add/policy/index.ts | 28 ++++++++++--------- src/handlers/project/remove/index.test.ts | 10 ++----- 4 files changed, 29 insertions(+), 33 deletions(-) diff --git a/src/core/policy.tsx b/src/core/policy.tsx index dbb4dc17f..2905fc4bd 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -114,7 +114,7 @@ export class PolicyClient implements CorePolicyClient { const asset = assets.policyGenerationAssets?.[0]; // The service returns either plain Cedar or its Dogwood superset member. const statement = asset?.definition?.cedar?.statement ?? asset?.definition?.policy?.statement; - if (!statement) { + if (!asset || !statement) { const findings = (asset?.findings ?? []) .map((finding) => `[${finding.type}] ${finding.description}`) .join("; "); diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index 67077c60a..4a6fdd10a 100644 --- a/src/handlers/project/add/gateway-test-support.ts +++ b/src/handlers/project/add/gateway-test-support.ts @@ -9,6 +9,17 @@ import { testIO, } from "../../../testing"; +export async function projectSpec(projectRoot: string) { + return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); +} + +export async function writeProjectSpec(projectRoot: string, spec: unknown): Promise { + await Bun.write( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify(spec, undefined, 2), + ); +} + export function createGatewayProjectTestHarness(directoryPrefix: string) { const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -35,17 +46,6 @@ export function createGatewayProjectTestHarness(directoryPrefix: string) { return projectRoot; } - async function projectSpec(projectRoot: string) { - return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); - } - - async function writeProjectSpec(projectRoot: string, spec: unknown): Promise { - await Bun.write( - join(projectRoot, "agentcore", "agentcore.json"), - JSON.stringify(spec, undefined, 2), - ); - } - async function addGateway(name = "tools"): Promise { await run(["add", "gateway", "--name", name]); } diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index a13a8dd00..23053e473 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -6,7 +6,6 @@ import { policyEngineResourceName, type PolicySchema } from "../../../../project import { createHandler, flag, ProjectKey } from "../../../../router"; import { coreOptsFromCtx } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; -import type { GeneratedPolicy } from "./types"; /** A substring heuristic, not a Cedar parser; --authorization-phase overrides it. @@ -16,6 +15,11 @@ export function inferAuthorizationPhase(statement: string): "INITIATE" | "RETURN } const PHASES = { initiate: "INITIATE", "return-output": "RETURN_OUTPUT" } as const; +const VALIDATION_MODES = { + "fail-on-any-findings": "FAIL_ON_ANY_FINDINGS", + "ignore-all-findings": "IGNORE_ALL_FINDINGS", +} as const; +const ENFORCEMENT_MODES = { active: "ACTIVE", "log-only": "LOG_ONLY" } as const; export const createAddPolicyHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -67,21 +71,22 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => throw new InputValidationError("--gateway is valid only with --generate"); } const project = ctx.require(ProjectKey); - if (!project.spec.policyEngines.some((engine) => engine.name === flags.engine)) { - throw new InputValidationError( - `policy engine '${flags.engine}' does not exist in policyEngines[]`, - ); - } let statement: string; let sourceFile: string | undefined; if (flags.statement !== undefined) { const source = new SourceResolver({ stdin: config.io.stdin }); - statement = await source.resolveText("statement", flags.statement); + statement = (await source.resolveText("statement", flags.statement))!; if (flags.statement.startsWith("file://")) { sourceFile = flags.statement.slice("file://".length); } } else { + // Fail before the minute-long generation call; the manager re-checks on write. + if (!project.spec.policyEngines.some((engine) => engine.name === flags.engine)) { + throw new InputValidationError( + `policy engine '${flags.engine}' does not exist in policyEngines[]`, + ); + } const gateways = project.spec.agentCoreGateways; const gateway = flags.gateway ? gateways.find((candidate) => candidate.name === flags.gateway) @@ -118,7 +123,7 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => config.io.stderr.write(`${next.value.message}\n`); next = await generator.next(); } - const generated: GeneratedPolicy = next.value; + const generated = next.value; statement = generated.statement; config.io.stderr.write(`Generated Cedar policy:\n${statement}\n`); for (const finding of generated.findings) { @@ -135,11 +140,8 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => description: flags.description, statement, sourceFile, - validationMode: - flags["validation-mode"] === "ignore-all-findings" - ? "IGNORE_ALL_FINDINGS" - : "FAIL_ON_ANY_FINDINGS", - enforcementMode: flags["enforcement-mode"] === "log-only" ? "LOG_ONLY" : "ACTIVE", + validationMode: flags["validation-mode"] && VALIDATION_MODES[flags["validation-mode"]], + enforcementMode: flags["enforcement-mode"] && ENFORCEMENT_MODES[flags["enforcement-mode"]], authorizationPhase, }; diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index df519e563..a6423ee05 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -10,6 +10,7 @@ import { testIO, } from "../../../testing"; import { InputValidationError } from "../../../errors"; +import { projectSpec, writeProjectSpec } from "../add/gateway-test-support"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -193,10 +194,6 @@ describe("project remove", () => { ]); } - async function projectSpec(projectRoot: string) { - return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); - } - test.each([ ["with --engine", ["--engine", "Guardrails"]], ["resolving the engine from an unambiguous name", []], @@ -219,10 +216,7 @@ describe("project remove", () => { // second one by editing the spec the way a user would. const spec = await projectSpec(projectRoot); spec.policyEngines[1].policies = spec.policyEngines[0].policies; - await Bun.write( - join(projectRoot, "agentcore", "agentcore.json"), - JSON.stringify(spec, undefined, 2), - ); + await writeProjectSpec(projectRoot, spec); await expect(run(["remove", "policy", "--name", "DenyAll"])).rejects.toThrow( "exists in multiple engines: First, Second", From c0028c7c356803fa20b08f9e20a5962aa8e7c76e Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 20:00:45 -0400 Subject: [PATCH 15/19] fix: address harness review findings on the policy commands --- src/core/policy.test.ts | 5 ++++ src/core/policy.tsx | 4 ++- .../project/add/policy-engine/index.test.ts | 5 ++++ .../project/add/policy-engine/index.ts | 11 +++++++- src/handlers/project/add/policy/index.test.ts | 25 +++++++++++++++++++ src/handlers/project/add/policy/index.ts | 8 ++++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts index 1d49e2cc4..2e8d69a9c 100644 --- a/src/core/policy.test.ts +++ b/src/core/policy.test.ts @@ -107,6 +107,11 @@ describe("PolicyClient.generatePolicy", () => { "bad input", ], ["no assets", { ...HAPPY, assets: { policyGenerationAssets: [] } }, "no generated policy"], + [ + "polling exhausts while still generating", + { ...HAPPY, get: { status: "GENERATING" } }, + "may still complete", + ], [ "the description is not translatable", { diff --git a/src/core/policy.tsx b/src/core/policy.tsx index 2905fc4bd..5f40f9e70 100644 --- a/src/core/policy.tsx +++ b/src/core/policy.tsx @@ -101,7 +101,9 @@ export class PolicyClient implements CorePolicyClient { } if (status !== "GENERATED") { throw new AgentCoreCLIError( - `policy generation did not complete: ${statusReasons?.join(", ") ?? status}`, + status === "GENERATING" + ? "policy generation did not finish within the CLI's wait window; it may still complete, retry the command in a few minutes" + : `policy generation did not complete: ${statusReasons?.join(", ") ?? status}`, ); } diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts index e781113ac..a095b9f9c 100644 --- a/src/handlers/project/add/policy-engine/index.test.ts +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -48,6 +48,11 @@ describe("project add policy-engine", () => { ["add", "policy-engine", "--name", "9starts-with-digit"], "Must begin with a letter", ], + [ + "a deployed name over the service limit", + ["add", "policy-engine", "--name", `E${"x".repeat(36)}`], + "exceeds the service limit of 48 characters", + ], ])("rejects %s", async (_label, args, message) => { await inProject(); await expect(run(args)).rejects.toThrow(message); diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts index 8d5bb46bb..ac13550a3 100644 --- a/src/handlers/project/add/policy-engine/index.ts +++ b/src/handlers/project/add/policy-engine/index.ts @@ -1,6 +1,9 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; -import type { PolicyEngineSchema } from "../../../../projectSchemas/policy"; +import { + policyEngineResourceName, + type PolicyEngineSchema, +} from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseTags } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; @@ -33,6 +36,12 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) = throw new InputValidationError("--attach-mode requires --attach-to-gateways"); } const project = ctx.require(ProjectKey); + const resourceName = policyEngineResourceName(project.name, flags.name); + if (resourceName.length > 48) { + throw new InputValidationError( + `Policy Engine resource name '${resourceName}' exceeds the service limit of 48 characters`, + ); + } const engine: z.input = { name: flags.name, diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index b3845cea8..9323ff373 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -206,6 +206,31 @@ describe("project add policy", () => { ).rejects.toThrow(message); }); + test("rejects a duplicate policy name before generating", async () => { + await withEngine(); + await run(["add", "gateway", "--name", "tools"]); + await run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "DenyAll", + "--statement", + FORBID_ALL, + ]); + const core = new TestCoreClient(); + + await expect( + run( + ["add", "policy", "--engine", "Guardrails", "--name", "DenyAll", "--generate", "x"], + undefined, + core, + ), + ).rejects.toThrow("already exists in policy engine 'Guardrails'"); + expect(core.policy.generateCalls).toEqual([]); + }); + test("fails without writing when generation fails", async () => { const projectRoot = await withEngine(); await run(["add", "gateway", "--name", "tools"]); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 23053e473..3f64e04c0 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -87,6 +87,14 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => `policy engine '${flags.engine}' does not exist in policyEngines[]`, ); } + const owner = project.spec.policyEngines.find((engine) => + engine.policies.some((policy) => policy.name === flags.name), + ); + if (owner) { + throw new InputValidationError( + `a policy with name '${flags.name}' already exists in policy engine '${owner.name}'`, + ); + } const gateways = project.spec.agentCoreGateways; const gateway = flags.gateway ? gateways.find((candidate) => candidate.name === flags.gateway) From 624b3cbe3a0cadf78302e5fc6ae0a0d20b0919c9 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 26 Aug 2026 20:01:52 -0400 Subject: [PATCH 16/19] test: cover the multiple-gateway generate rejection --- src/handlers/project/add/policy/index.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index 9323ff373..c99d8e467 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -186,17 +186,27 @@ describe("project add policy", () => { }); }); + test("rejects --generate when multiple gateways exist and none is named", async () => { + await withEngine(); + await run(["add", "gateway", "--name", "tools"]); + await run(["add", "gateway", "--name", "search"]); + await expect( + run(["add", "policy", "--engine", "Guardrails", "--name", "Gen", "--generate", "x"]), + ).rejects.toThrow("multiple gateways: tools, search; pass --gateway"); + }); + test.each([ - ["an unknown --gateway", ["--gateway", "missing"], "does not exist in agentCoreGateways[]"], - ["no gateways in the project", [], "add one to this project"], - ])("rejects --generate with %s", async (_label, gatewayArgs, message) => { + ["--engine", "Missing", [], "does not exist in policyEngines[]"], + ["--gateway", "Guardrails", ["--gateway", "missing"], "does not exist in agentCoreGateways[]"], + ["no gateways in the project via", "Guardrails", [], "add one to this project"], + ])("rejects --generate with an unknown %s", async (_label, engine, gatewayArgs, message) => { await withEngine(); await expect( run([ "add", "policy", "--engine", - "Guardrails", + engine, "--name", "Gen", "--generate", From 529b9facf70745f6a27b3c9dcdd89fc1e460b40d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 27 Aug 2026 14:25:57 -0400 Subject: [PATCH 17/19] refactor: extract --generate and PolicyClient to a follow-up PR per review --- src/core/index.tsx | 3 - src/core/policy.test.ts | 133 ----------------- src/core/policy.tsx | 137 ------------------ src/handlers/index.tsx | 4 +- .../project/add/gateway-test-support.ts | 4 +- src/handlers/project/add/policy/index.test.ts | 134 +---------------- src/handlers/project/add/policy/index.ts | 89 +----------- src/handlers/project/add/policy/types.ts | 23 --- src/handlers/project/add/types.ts | 2 - src/handlers/project/index.ts | 2 - src/handlers/types.tsx | 2 - src/testing/TestCoreClient.tsx | 24 --- 12 files changed, 13 insertions(+), 544 deletions(-) delete mode 100644 src/core/policy.test.ts delete mode 100644 src/core/policy.tsx delete mode 100644 src/handlers/project/add/policy/types.ts diff --git a/src/core/index.tsx b/src/core/index.tsx index b6919a866..d7275b912 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -7,7 +7,6 @@ import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; import { MemoryClient } from "./memory"; -import { PolicyClient } from "./policy"; import { RuntimeClient } from "./runtime"; import type { AwsClients, @@ -65,7 +64,6 @@ export class CoreClient implements AwsClients { readonly runtime: RuntimeClient; readonly gateway: GatewayClient; readonly eval: EvalClient; - readonly policy: PolicyClient; readonly projectManager: ProjectManager; @@ -87,7 +85,6 @@ export class CoreClient implements AwsClients { this.logger.child({ module: "eval" }), config.newSessionId, ); - this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); this.projectManager = new FsProjectManager({ logger: this.logger.child({ module: "projectManager" }), diff --git a/src/core/policy.test.ts b/src/core/policy.test.ts deleted file mode 100644 index 2e8d69a9c..000000000 --- a/src/core/policy.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - GetGatewayCommand, - GetPolicyGenerationCommand, - ListGatewaysCommand, - ListPolicyEngineSummariesCommand, - ListPolicyGenerationAssetsCommand, - StartPolicyGenerationCommand, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { PolicyClient } from "./policy"; -import { createSilentLogger } from "../testing"; -import type { AwsClients } from "./types"; - -function fakeClients(responses: { - engines?: unknown; - gateways?: unknown; - getGateway?: unknown; - start?: unknown; - get?: unknown; - assets?: unknown; -}): AwsClients { - const control = { - send: async (command: unknown) => { - if (command instanceof ListPolicyEngineSummariesCommand) return responses.engines; - if (command instanceof ListGatewaysCommand) return responses.gateways; - if (command instanceof GetGatewayCommand) return responses.getGateway; - if (command instanceof StartPolicyGenerationCommand) return responses.start; - if (command instanceof GetPolicyGenerationCommand) return responses.get; - if (command instanceof ListPolicyGenerationAssetsCommand) return responses.assets; - throw new Error(`unexpected command: ${command?.constructor?.name}`); - }, - }; - return { control: () => control } as unknown as AwsClients; -} - -const HAPPY = { - engines: { - policyEngines: [{ name: "Proj_Guardrails", policyEngineId: "pe-abc123" }], - }, - gateways: { items: [{ name: "Proj-tools", gatewayId: "gw-1" }] }, - getGateway: { gatewayArn: "arn:aws:bedrock-agentcore:us-west-2:1:gateway/gw-1" }, - start: { policyGenerationId: "gen-1" }, - get: { status: "GENERATED" }, - assets: { - policyGenerationAssets: [ - { - definition: { cedar: { statement: "forbid (principal, action, resource);" } }, - findings: [{ type: "VALID", description: "ok" }], - }, - ], - }, -}; - -async function drain(client: PolicyClient, input: Parameters[0]) { - const generator = client.generatePolicy(input, { region: "us-west-2" }); - const messages: string[] = []; - while (true) { - const next = await generator.next(); - if (next.done) return { messages, result: next.value }; - messages.push(next.value.message); - } -} - -describe("PolicyClient.generatePolicy", () => { - const input = { - engineName: "Guardrails", - gatewayName: "tools", - engineServiceName: "Proj_Guardrails", - gatewayServiceName: "Proj-tools", - description: "block hate speech", - }; - - function client(responses: Parameters[0]) { - return new PolicyClient(fakeClients(responses), createSilentLogger(), 0); - } - - test("resolves deployed ids, generates, and returns statement with findings", async () => { - const { messages, result } = await drain(client(HAPPY), input); - - expect(result.statement).toBe("forbid (principal, action, resource);"); - expect(result.findings).toEqual([{ type: "VALID", description: "ok" }]); - expect(messages.some((message) => message.includes("Generating"))).toBe(true); - }); - - test("reads a Dogwood policy definition member", async () => { - const { result } = await drain( - client({ - ...HAPPY, - assets: { - policyGenerationAssets: [ - { definition: { policy: { statement: "forbid (principal, action, resource);" } } }, - ], - }, - }), - input, - ); - expect(result.statement).toBe("forbid (principal, action, resource);"); - expect(result.findings).toEqual([]); - }); - - test.each([ - ["engine not deployed", { ...HAPPY, engines: { policyEngines: [] } }, "is not deployed"], - ["gateway not deployed", { ...HAPPY, gateways: { items: [] } }, "not deployed"], - [ - "generation failed", - { ...HAPPY, get: { status: "GENERATE_FAILED", statusReasons: ["bad input"] } }, - "bad input", - ], - ["no assets", { ...HAPPY, assets: { policyGenerationAssets: [] } }, "no generated policy"], - [ - "polling exhausts while still generating", - { ...HAPPY, get: { status: "GENERATING" } }, - "may still complete", - ], - [ - "the description is not translatable", - { - ...HAPPY, - assets: { - policyGenerationAssets: [ - { - rawTextFragment: "do the thing", - findings: [{ type: "INVALID", description: "Non-translatable" }], - }, - ], - }, - }, - "could not be translated into a Cedar policy: [INVALID] Non-translatable", - ], - ])("fails when %s", async (_label, responses, message) => { - await expect(drain(client(responses), input)).rejects.toThrow(message); - }); -}); diff --git a/src/core/policy.tsx b/src/core/policy.tsx deleted file mode 100644 index 5f40f9e70..000000000 --- a/src/core/policy.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { setTimeout as sleep } from "node:timers/promises"; -import { - GetGatewayCommand, - GetPolicyGenerationCommand, - ListGatewaysCommand, - ListPolicyEngineSummariesCommand, - ListPolicyGenerationAssetsCommand, - StartPolicyGenerationCommand, - type GatewaySummary, - type PolicyEngineSummary, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { AgentCoreCLIError, ResourceNotFoundError } from "../errors"; -import type { - CorePolicyClient, - GeneratedPolicy, - GeneratePolicyInput, -} from "../handlers/project/add/policy/types"; -import type { Logger } from "../logging"; -import type { AwsClients, CoreOptions } from "./types"; -import { toClientConfig } from "./utils"; - -const GENERATION_POLL_DELAY_MS = 3_000; -const GENERATION_MAX_POLLS = 40; - -export class PolicyClient implements CorePolicyClient { - constructor( - private readonly clients: AwsClients, - private readonly logger: Logger, - private readonly pollDelayMs = GENERATION_POLL_DELAY_MS, - ) {} - - async *generatePolicy( - input: GeneratePolicyInput, - options: CoreOptions, - ): AsyncGenerator<{ message: string }, GeneratedPolicy> { - const control = this.clients.control(toClientConfig(options)); - - yield { message: `Resolving deployed policy engine '${input.engineName}'` }; - let engine: PolicyEngineSummary | undefined; - let engineToken: string | undefined; - do { - const page = await control.send( - new ListPolicyEngineSummariesCommand({ nextToken: engineToken }), - ); - engine = page.policyEngines?.find((candidate) => candidate.name === input.engineServiceName); - engineToken = page.nextToken; - } while (!engine && engineToken); - if (!engine?.policyEngineId) { - throw new ResourceNotFoundError( - `policy engine '${input.engineName}' is not deployed; run 'agentcore project deploy' first`, - ); - } - - yield { message: `Resolving deployed gateway '${input.gatewayName}'` }; - let deployed: GatewaySummary | undefined; - let gatewayToken: string | undefined; - do { - const page = await control.send(new ListGatewaysCommand({ nextToken: gatewayToken })); - deployed = page.items?.find((candidate) => candidate.name === input.gatewayServiceName); - gatewayToken = page.nextToken; - } while (!deployed && gatewayToken); - if (!deployed) { - throw new ResourceNotFoundError( - `gateway '${input.gatewayName}' is not deployed; run 'agentcore project deploy' first`, - ); - } - const gateway = await control.send( - new GetGatewayCommand({ gatewayIdentifier: deployed.gatewayId }), - ); - if (!gateway.gatewayArn) { - throw new AgentCoreCLIError(`could not resolve the ARN of gateway '${input.gatewayName}'`); - } - - yield { message: "Generating a Cedar policy from the description (may take a minute)" }; - const started = await control.send( - new StartPolicyGenerationCommand({ - policyEngineId: engine.policyEngineId, - resource: { arn: gateway.gatewayArn }, - content: { rawText: input.description }, - name: `cli_generation_${Date.now()}`, - }), - ); - if (!started.policyGenerationId) { - throw new AgentCoreCLIError("StartPolicyGeneration returned no generation id"); - } - - let status: string | undefined = "GENERATING"; - let statusReasons: string[] | undefined; - for (let poll = 0; poll < GENERATION_MAX_POLLS && status === "GENERATING"; poll++) { - await sleep(this.pollDelayMs); - const current = await control.send( - new GetPolicyGenerationCommand({ - policyGenerationId: started.policyGenerationId, - policyEngineId: engine.policyEngineId, - }), - ); - status = current.status; - statusReasons = current.statusReasons; - this.logger.debug(`policy generation ${started.policyGenerationId} status: ${status}`); - if (status === "GENERATING") yield { message: "Still generating" }; - } - if (status !== "GENERATED") { - throw new AgentCoreCLIError( - status === "GENERATING" - ? "policy generation did not finish within the CLI's wait window; it may still complete, retry the command in a few minutes" - : `policy generation did not complete: ${statusReasons?.join(", ") ?? status}`, - ); - } - - const assets = await control.send( - new ListPolicyGenerationAssetsCommand({ - policyGenerationId: started.policyGenerationId, - policyEngineId: engine.policyEngineId, - }), - ); - const asset = assets.policyGenerationAssets?.[0]; - // The service returns either plain Cedar or its Dogwood superset member. - const statement = asset?.definition?.cedar?.statement ?? asset?.definition?.policy?.statement; - if (!asset || !statement) { - const findings = (asset?.findings ?? []) - .map((finding) => `[${finding.type}] ${finding.description}`) - .join("; "); - throw new AgentCoreCLIError( - findings - ? `the description could not be translated into a Cedar policy: ${findings}` - : "generation completed but returned no generated policy statement", - ); - } - return { - statement, - findings: (asset.findings ?? []).map((finding) => ({ - type: finding.type ?? "UNKNOWN", - description: finding.description ?? "", - })), - }; - } -} diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 36712557f..87def85c6 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,9 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler( - createProjectHandler({ projectManager: core.projectManager, policy: core.policy, io }), - ); + root.handler(createProjectHandler({ projectManager: core.projectManager, io })); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index 4a6fdd10a..ec7e6c578 100644 --- a/src/handlers/project/add/gateway-test-support.ts +++ b/src/handlers/project/add/gateway-test-support.ts @@ -24,10 +24,10 @@ export function createGatewayProjectTestHarness(directoryPrefix: string) { const originalCwd = process.cwd(); const tempDirectories: string[] = []; - async function run(args: string[], stdin?: string, core = new TestCoreClient()) { + async function run(args: string[], stdin?: string) { const io = testIO(); if (stdin !== undefined) io.io.stdin.end(stdin); - const root = createRootHandler(core, { + const root = createRootHandler(new TestCoreClient(), { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), diff --git a/src/handlers/project/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts index c99d8e467..3d13bdb7a 100644 --- a/src/handlers/project/add/policy/index.test.ts +++ b/src/handlers/project/add/policy/index.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { TestCoreClient } from "../../../../testing"; import { createGatewayProjectTestHarness } from "../gateway-test-support"; import { inferAuthorizationPhase } from "./index"; @@ -114,149 +113,20 @@ describe("project add policy", () => { "--name", ], [ - "no statement source", + "missing --statement", ["add", "policy", "--engine", "Guardrails", "--name", "P"], - "one of '--statement' or '--generate'", + "required option '--statement", ], [ "unknown engine", ["add", "policy", "--engine", "Missing", "--name", "P", "--statement", FORBID_ALL], "policy engine 'Missing' does not exist", ], - [ - "--gateway without --generate", - [ - "add", - "policy", - "--engine", - "Guardrails", - "--name", - "P", - "--statement", - FORBID_ALL, - "--gateway", - "tools", - ], - "--gateway is valid only with --generate", - ], ])("rejects %s", async (_label, args, message) => { await withEngine(); await expect(run(args)).rejects.toThrow(message); }); - test("adds a generated policy and surfaces findings", async () => { - const projectRoot = await withEngine(); - await run(["add", "gateway", "--name", "tools"]); - const core = new TestCoreClient(); - core.policy.generateResult = { - statement: SUPPRESS, - findings: [{ type: "VALID", description: "ok" }], - }; - - const io = await run( - [ - "add", - "policy", - "--engine", - "Guardrails", - "--name", - "Gen", - "--generate", - "block hate speech", - "--gateway", - "tools", - ], - undefined, - core, - ); - - expect(core.policy.generateCalls[0]).toMatchObject({ - engineName: "Guardrails", - gatewayName: "tools", - engineServiceName: "TestProject_Guardrails", - gatewayServiceName: "TestProject-tools", - description: "block hate speech", - }); - expect(io.stderr()).toContain("Generated Cedar policy:"); - expect(io.stderr()).toContain("VALID"); - expect((await projectSpec(projectRoot)).policyEngines[0].policies[0]).toMatchObject({ - name: "Gen", - statement: SUPPRESS, - authorizationPhase: "RETURN_OUTPUT", - }); - }); - - test("rejects --generate when multiple gateways exist and none is named", async () => { - await withEngine(); - await run(["add", "gateway", "--name", "tools"]); - await run(["add", "gateway", "--name", "search"]); - await expect( - run(["add", "policy", "--engine", "Guardrails", "--name", "Gen", "--generate", "x"]), - ).rejects.toThrow("multiple gateways: tools, search; pass --gateway"); - }); - - test.each([ - ["--engine", "Missing", [], "does not exist in policyEngines[]"], - ["--gateway", "Guardrails", ["--gateway", "missing"], "does not exist in agentCoreGateways[]"], - ["no gateways in the project via", "Guardrails", [], "add one to this project"], - ])("rejects --generate with an unknown %s", async (_label, engine, gatewayArgs, message) => { - await withEngine(); - await expect( - run([ - "add", - "policy", - "--engine", - engine, - "--name", - "Gen", - "--generate", - "x", - ...gatewayArgs, - ]), - ).rejects.toThrow(message); - }); - - test("rejects a duplicate policy name before generating", async () => { - await withEngine(); - await run(["add", "gateway", "--name", "tools"]); - await run([ - "add", - "policy", - "--engine", - "Guardrails", - "--name", - "DenyAll", - "--statement", - FORBID_ALL, - ]); - const core = new TestCoreClient(); - - await expect( - run( - ["add", "policy", "--engine", "Guardrails", "--name", "DenyAll", "--generate", "x"], - undefined, - core, - ), - ).rejects.toThrow("already exists in policy engine 'Guardrails'"); - expect(core.policy.generateCalls).toEqual([]); - }); - - test("fails without writing when generation fails", async () => { - const projectRoot = await withEngine(); - await run(["add", "gateway", "--name", "tools"]); - const core = new TestCoreClient(); - core.policy.generateError = new Error("policy engine 'Guardrails' is not deployed"); - - await expect( - run( - ["add", "policy", "--engine", "Guardrails", "--name", "Gen", "--generate", "x"], - undefined, - core, - ), - ).rejects.toThrow("is not deployed"); - expect((await projectSpec(projectRoot)).policyEngines[0].policies).toEqual([]); - }); - test("rejects a duplicate policy name across engines", async () => { await withEngine(); await run(["add", "policy-engine", "--name", "Second"]); diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 3f64e04c0..7d245a0a8 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -1,10 +1,8 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; -import { gatewayResourceName } from "../../../../projectSchemas/gateway"; -import { policyEngineResourceName, type PolicySchema } from "../../../../projectSchemas/policy"; +import type { PolicySchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; -import { coreOptsFromCtx } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; /** @@ -34,12 +32,6 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => "Cedar policy statement (inline, file://, or - for stdin)", z.string().optional(), ), - flag( - "generate", - "generate the Cedar statement from a natural-language description", - z.string().optional(), - ), - flag("gateway", "deployed Gateway name that scopes --generate", z.string().optional()), flag( "validation-mode", "validation mode: fail-on-any-findings or ignore-all-findings", @@ -63,81 +55,16 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => if (!flags.name) { throw new InputValidationError("required option '--name ' not specified"); } - const sources = [flags.statement, flags.generate].filter((value) => value !== undefined); - if (sources.length !== 1) { - throw new InputValidationError("specify exactly one of '--statement' or '--generate'"); - } - if (flags.gateway !== undefined && flags.generate === undefined) { - throw new InputValidationError("--gateway is valid only with --generate"); + if (!flags.statement) { + throw new InputValidationError("required option '--statement ' not specified"); } const project = ctx.require(ProjectKey); - let statement: string; - let sourceFile: string | undefined; - if (flags.statement !== undefined) { - const source = new SourceResolver({ stdin: config.io.stdin }); - statement = (await source.resolveText("statement", flags.statement))!; - if (flags.statement.startsWith("file://")) { - sourceFile = flags.statement.slice("file://".length); - } - } else { - // Fail before the minute-long generation call; the manager re-checks on write. - if (!project.spec.policyEngines.some((engine) => engine.name === flags.engine)) { - throw new InputValidationError( - `policy engine '${flags.engine}' does not exist in policyEngines[]`, - ); - } - const owner = project.spec.policyEngines.find((engine) => - engine.policies.some((policy) => policy.name === flags.name), - ); - if (owner) { - throw new InputValidationError( - `a policy with name '${flags.name}' already exists in policy engine '${owner.name}'`, - ); - } - const gateways = project.spec.agentCoreGateways; - const gateway = flags.gateway - ? gateways.find((candidate) => candidate.name === flags.gateway) - : gateways.length === 1 - ? gateways[0] - : undefined; - if (flags.gateway && !gateway) { - throw new InputValidationError( - `gateway '${flags.gateway}' does not exist in agentCoreGateways[]`, - ); - } - if (!gateway) { - throw new InputValidationError( - gateways.length === 0 - ? "--generate needs a deployed gateway; add one to this project and deploy it first" - : `this project declares multiple gateways: ${gateways - .map((candidate) => candidate.name) - .join(", ")}; pass --gateway to choose one`, - ); - } - - const generator = config.policy.generatePolicy( - { - engineName: flags.engine, - gatewayName: gateway.name, - engineServiceName: policyEngineResourceName(project.name, flags.engine), - gatewayServiceName: gatewayResourceName(project.name, gateway), - description: flags.generate!, - }, - coreOptsFromCtx(ctx), - ); - let next = await generator.next(); - while (!next.done) { - config.io.stderr.write(`${next.value.message}\n`); - next = await generator.next(); - } - const generated = next.value; - statement = generated.statement; - config.io.stderr.write(`Generated Cedar policy:\n${statement}\n`); - for (const finding of generated.findings) { - config.io.stderr.write(`finding [${finding.type}]: ${finding.description}\n`); - } - } + const source = new SourceResolver({ stdin: config.io.stdin }); + const statement = (await source.resolveText("statement", flags.statement))!; + const sourceFile = flags.statement.startsWith("file://") + ? flags.statement.slice("file://".length) + : undefined; const authorizationPhase = flags["authorization-phase"] ? PHASES[flags["authorization-phase"]] diff --git a/src/handlers/project/add/policy/types.ts b/src/handlers/project/add/policy/types.ts deleted file mode 100644 index 7b5620fe4..000000000 --- a/src/handlers/project/add/policy/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CoreOptions } from "../../../../core/types"; - -export type GeneratePolicyInput = { - /** Project-spec names, used in progress and error messages. */ - engineName: string; - gatewayName: string; - /** Exact deployed service names the control-plane lookups match against. */ - engineServiceName: string; - gatewayServiceName: string; - description: string; -}; - -export type GeneratedPolicy = { - statement: string; - findings: { type: string; description: string }[]; -}; - -export interface CorePolicyClient { - generatePolicy( - input: GeneratePolicyInput, - options: CoreOptions, - ): AsyncGenerator<{ message: string }, GeneratedPolicy>; -} diff --git a/src/handlers/project/add/types.ts b/src/handlers/project/add/types.ts index 0a78352d5..26943b932 100644 --- a/src/handlers/project/add/types.ts +++ b/src/handlers/project/add/types.ts @@ -1,9 +1,7 @@ import type { AppIO } from "../../../io"; import type { ProjectManager } from "../types"; -import type { CorePolicyClient } from "./policy/types"; export type AddProjectResourceConfig = { projectManager: ProjectManager; - policy: CorePolicyClient; io: AppIO; }; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 3bb94958c..2eb42eeaa 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -13,11 +13,9 @@ import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; -import type { CorePolicyClient } from "./add/policy/types"; type ProjectHandlerConfig = { projectManager: ProjectManager; - policy: CorePolicyClient; io: AppIO; }; diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index c1eec39dc..f129805a8 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -6,7 +6,6 @@ import type { CoreMemoryClient } from "./memory/types.tsx"; import type { CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; import type { ProjectManager } from "./project/types.ts"; -import type { CorePolicyClient } from "./project/add/policy/types"; export interface Core { harness: CoreHarnessClient; @@ -15,7 +14,6 @@ export interface Core { runtime: CoreRuntimeClient; gateway: CoreGatewayClient; eval: CoreEvalClient; - policy: CorePolicyClient; projectManager: ProjectManager; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index d1d16c1fd..e58ef5209 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -161,11 +161,6 @@ import { isTerminalStatus } from "../core/batchEvaluationResults"; import { abortable } from "../core/abortable"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; -import type { - CorePolicyClient, - GeneratedPolicy, - GeneratePolicyInput, -} from "../handlers/project/add/policy/types"; import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; @@ -2226,24 +2221,6 @@ export class TestEvalClient implements CoreEvalClient { } } -export class TestPolicyClient implements CorePolicyClient { - generateResult: GeneratedPolicy = { - statement: "forbid (principal, action, resource);", - findings: [], - }; - generateError: Error | undefined; - generateCalls: GeneratePolicyInput[] = []; - - async *generatePolicy( - input: GeneratePolicyInput, - ): AsyncGenerator<{ message: string }, GeneratedPolicy> { - this.generateCalls.push(input); - if (this.generateError) throw this.generateError; - yield { message: "generating" }; - return this.generateResult; - } -} - // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); @@ -2252,7 +2229,6 @@ export class TestCoreClient implements Core { readonly runtime = new TestRuntimeClient(); readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); - readonly policy = new TestPolicyClient(); readonly projectManager: ProjectManager; // Commands the project manager would have run (npm install, git init, ...), From f65863040063b7e689bc3da6cad2f63b34dee5ac Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 27 Aug 2026 14:40:45 -0400 Subject: [PATCH 18/19] refactor: colocate policyEngineResourceName with its handler per review --- src/handlers/project/add/policy-engine/index.ts | 12 ++++++++---- src/projectSchemas/policy.ts | 7 ------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts index ac13550a3..e7d3e8eda 100644 --- a/src/handlers/project/add/policy-engine/index.ts +++ b/src/handlers/project/add/policy-engine/index.ts @@ -1,13 +1,17 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; -import { - policyEngineResourceName, - type PolicyEngineSchema, -} from "../../../../projectSchemas/policy"; +import type { PolicyEngineSchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseTags } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; +/** + The deployed service name of a policy engine; mirrors the L3 AgentCorePolicyEngine construct's rule. +**/ +export function policyEngineResourceName(projectName: string, engineName: string): string { + return `${projectName}_${engineName}`; +} + export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) => createHandler({ name: "policy-engine", diff --git a/src/projectSchemas/policy.ts b/src/projectSchemas/policy.ts index 4e6c3eb56..6f789a9d8 100644 --- a/src/projectSchemas/policy.ts +++ b/src/projectSchemas/policy.ts @@ -49,10 +49,3 @@ export const PolicyEngineSchema = z.object({ ), }); export type PolicyEngine = z.infer; - -/** - The deployed service name of a policy engine; mirrors the L3 AgentCorePolicyEngine construct's rule. -**/ -export function policyEngineResourceName(projectName: string, engineName: string): string { - return `${projectName}_${engineName}`; -} From 9065f01bc1b5ed175a6656aea10b34c2f01732ef Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 27 Aug 2026 15:08:13 -0400 Subject: [PATCH 19/19] refactor: colocate gatewayResourceName with its handler per review --- src/handlers/project/add/gateway/index.ts | 12 +++++++++++- src/projectSchemas/gateway.ts | 10 ---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts index 4252d6596..41febd2c6 100644 --- a/src/handlers/project/add/gateway/index.ts +++ b/src/handlers/project/add/gateway/index.ts @@ -2,13 +2,23 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; import { GatewayAuthorizerConfigSchema } from "../../../../projectSchemas/auth"; -import { gatewayResourceName, type AgentCoreGateway } from "../../../../projectSchemas/gateway"; +import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseJsonFlagWithSchema, parseTags } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; const GatewayAuthorizerConfigurationInputSchema = GatewayAuthorizerConfigSchema.strict(); +/** + The deployed service name of a gateway; mirrors the L3 Gateway construct's rule. +**/ +export function gatewayResourceName( + projectName: string, + gateway: { name: string; resourceName?: string }, +): string { + return gateway.resourceName ?? `${projectName}-${gateway.name}`; +} + export const createAddGatewayHandler = (config: AddProjectResourceConfig) => createHandler({ name: "gateway", diff --git a/src/projectSchemas/gateway.ts b/src/projectSchemas/gateway.ts index 89ea2fc91..97bbc2f84 100644 --- a/src/projectSchemas/gateway.ts +++ b/src/projectSchemas/gateway.ts @@ -434,16 +434,6 @@ export type GatewayPolicyEngineConfiguration = z.infer< export const GatewayProtocolTypeSchema = z.enum(["MCP", "None"]); export type GatewayProtocolType = z.infer; -/** - The deployed service name of a gateway; mirrors the L3 Gateway construct's rule. -**/ -export function gatewayResourceName( - projectName: string, - gateway: { name: string; resourceName?: string }, -): string { - return gateway.resourceName ?? `${projectName}-${gateway.name}`; -} - export const AgentCoreGatewaySchema = z .object({ name: GatewayNameSchema,