Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
db066fe
feat: project add policy-engine
tejaskash Aug 26, 2026
1036232
refactor: simplify add policy-engine slice
tejaskash Aug 26, 2026
52bfe76
feat: attach policy engine to gateways from add policy-engine
tejaskash Aug 26, 2026
20d1c5a
refactor: simplify policy-engine attach tests
tejaskash Aug 26, 2026
a99721c
feat: project add policy with source-aware statement
tejaskash Aug 26, 2026
bd5686f
refactor: simplify add policy slice
tejaskash Aug 26, 2026
8733775
feat: project remove policy-engine and policy
tejaskash Aug 26, 2026
ea3d1a4
refactor: simplify remove slice, validate gateway policy engine refer…
tejaskash Aug 26, 2026
2776f9d
feat: generate Cedar policies from natural language in project add po…
tejaskash Aug 26, 2026
6169a02
refactor: handler-owned gateway resolution and shared resource-name r…
tejaskash Aug 26, 2026
f491e64
refactor: derive gateway resource name through the shared rule
tejaskash Aug 26, 2026
1a7d4a7
fix: surface generation findings when no Cedar statement is produced
tejaskash Aug 26, 2026
f984a86
fix: accept Dogwood policy definition members from generation assets
tejaskash Aug 26, 2026
b635758
refactor: final simplify pass across the policy branch
tejaskash Aug 26, 2026
c0028c7
fix: address harness review findings on the policy commands
tejaskash Aug 27, 2026
624b3cb
test: cover the multiple-gateway generate rejection
tejaskash Aug 27, 2026
529b9fa
refactor: extract --generate and PolicyClient to a follow-up PR per r…
tejaskash Aug 27, 2026
f658630
refactor: colocate policyEngineResourceName with its handler per review
tejaskash Aug 27, 2026
9065f01
refactor: colocate gatewayResourceName with its handler per review
tejaskash Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -454,6 +527,9 @@ function toProjectSpecKey(resourceType: ProjectResource) {
case "gateway":
case "gateway-target":
return "agentCoreGateways";
case "policy-engine":
case "policy":
return "policyEngines";
}
}

Expand Down
22 changes: 11 additions & 11 deletions src/handlers/project/add/gateway-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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[] = [];
Expand All @@ -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<void> {
await Bun.write(
join(projectRoot, "agentcore", "agentcore.json"),
JSON.stringify(spec, undefined, 2),
);
}

async function addGateway(name = "tools"): Promise<void> {
await run(["add", "gateway", "--name", name]);
}
Expand Down
12 changes: 11 additions & 1 deletion src/handlers/project/add/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -55,7 +65,7 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) =>
throw new InputValidationError("required option '--name <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`,
Expand Down
4 changes: 4 additions & 0 deletions src/handlers/project/add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
104 changes: 104 additions & 0 deletions src/handlers/project/add/policy-engine/index.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
76 changes: 76 additions & 0 deletions src/handlers/project/add/policy-engine/index.ts
Original file line number Diff line number Diff line change
@@ -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 <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<typeof PolicyEngineSchema> = {
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`,
);
}
},
});
Loading
Loading