diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 5b65372e8..d34a07980 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, PolicySchema } from "../../projectSchemas/policy"; import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, @@ -178,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`, @@ -245,6 +256,36 @@ export class FsProjectManager implements ProjectManager { case "gateway": projectSpec.agentCoreGateways.push(input.resourceConfig); break; + 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 "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, @@ -312,7 +353,39 @@ 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 owner = input.engineName + ? candidates.find((engine) => engine.name === input.engineName) + : candidates[0]; + removed = owner !== undefined; + const engines = existingProjectSpec.policyEngines.map((engine) => + engine === owner + ? { ...engine, policies: engine.policies.filter((policy) => policy.name !== input.name) } + : engine, + ); + 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) { @@ -454,6 +527,9 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "gateway": case "gateway-target": return "agentCoreGateways"; + case "policy-engine": + case "policy": + return "policyEngines"; } } diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index e373764d6..ec7e6c578 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/gateway/index.ts b/src/handlers/project/add/gateway/index.ts index 1af7f8f92..41febd2c6 100644 --- a/src/handlers/project/add/gateway/index.ts +++ b/src/handlers/project/add/gateway/index.ts @@ -9,6 +9,16 @@ 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", @@ -55,7 +65,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`, diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index f65600d59..5aa6b7483 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -10,6 +10,8 @@ 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 { createAddPolicyHandler } from "./policy"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { @@ -25,5 +27,7 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddGatewayHandler(config)); 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-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts new file mode 100644 index 000000000..a095b9f9c --- /dev/null +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const { addGateway, 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", + ], + [ + "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); + }); + + 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 addGateway("tools"); + await addGateway("search"); + + await run([ + "add", + "policy-engine", + "--name", + "Guardrails", + "--attach-to-gateways", + "tools", + "search", + ...modeArgs, + ]); + + const spec = await projectSpec(projectRoot); + expect(spec.agentCoreGateways).toHaveLength(2); + for (const gateway of spec.agentCoreGateways) { + expect(gateway.policyEngineConfiguration).toEqual({ + policyEngineName: "Guardrails", + mode, + }); + } + }); + + 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 new file mode 100644 index 000000000..e7d3e8eda --- /dev/null +++ b/src/handlers/project/add/policy-engine/index.ts @@ -0,0 +1,76 @@ +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"; + +/** + 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", + 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()), + 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 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, + description: flags.description, + encryptionKeyArn: flags["encryption-key-arn"], + tags: parseTags(flags.tags), + }; + + 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/add/policy/index.test.ts b/src/handlers/project/add/policy/index.test.ts new file mode 100644 index 000000000..3d13bdb7a --- /dev/null +++ b/src/handlers/project/add/policy/index.test.ts @@ -0,0 +1,147 @@ +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", () => { + // 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"); + }); +}); + +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", + ], + [ + "missing --statement", + ["add", "policy", "--engine", "Guardrails", "--name", "P"], + "required option '--statement", + ], + [ + "unknown engine", + ["add", "policy", "--engine", "Missing", "--name", "P", "--statement", FORBID_ALL], + "policy engine 'Missing' does not exist", + ], + ])("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..7d245a0a8 --- /dev/null +++ b/src/handlers/project/add/policy/index.ts @@ -0,0 +1,94 @@ +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"; +} + +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({ + 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( + "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"); + } + if (!flags.statement) { + throw new InputValidationError("required option '--statement ' not specified"); + } + const project = ctx.require(ProjectKey); + + 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"]] + : inferAuthorizationPhase(statement); + + const policy: z.input = { + name: flags.name, + description: flags.description, + statement, + sourceFile, + validationMode: flags["validation-mode"] && VALIDATION_MODES[flags["validation-mode"]], + enforcementMode: flags["enforcement-mode"] && ENFORCEMENT_MODES[flags["enforcement-mode"]], + 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/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index c6d4a4779..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[] = []; @@ -171,8 +172,66 @@ 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);", + ]); + } + + 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 projectSpec(projectRoot)).policyEngines[0].policies).toEqual([]); + }); + + test("rejects an ambiguous policy name without --engine", async () => { + 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 spec = await projectSpec(projectRoot); + spec.policyEngines[1].policies = spec.policyEngines[0].policies; + await writeProjectSpec(projectRoot, spec); + + 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 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 2f3e39c31..8d5973d92 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) => { @@ -30,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) { @@ -40,12 +60,13 @@ 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`, - ); - } await config.projectManager.removeResource(project, { resourceType: resource, name, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index ad60bac73..e8a51caa4 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, PolicySchema } from "../../projectSchemas/policy"; export const RUNTIME_TEMPLATE_SHORTCUTS = { "hello-world-python": { @@ -179,19 +180,34 @@ export type AddResourceInput = resourceType: "gateway-target"; gatewayName: string; resourceConfig: AgentCoreGatewayTarget; + } + | { + 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; }; /** diff --git a/src/projectSchemas/gateway.ts b/src/projectSchemas/gateway.ts index bb6b60435..97bbc2f84 100644 --- a/src/projectSchemas/gateway.ts +++ b/src/projectSchemas/gateway.ts @@ -433,6 +433,7 @@ export type GatewayPolicyEngineConfiguration = z.infer< >; export const GatewayProtocolTypeSchema = z.enum(["MCP", "None"]); export type GatewayProtocolType = z.infer; + export const AgentCoreGatewaySchema = z .object({ name: GatewayNameSchema, 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) {