diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 927c86ac79..59141e256e 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -138,6 +138,8 @@ import { AddOnlineEvalScreen } from "../handlers/project/add/online-eval/screen. import { AddOnlineInsightScreen } from "../handlers/project/add/online-insight/screen.tsx"; import { AddHarnessScreen } from "../handlers/project/add/harness/screen.tsx"; import { AddConfigBundleScreen } from "../handlers/project/add/config-bundle/screen.tsx"; +import { AddPolicyEngineScreen } from "../handlers/project/add/policy-engine/screen.tsx"; +import { AddPolicyScreen } from "../handlers/project/add/policy/screen.tsx"; import { AddPaymentManagerScreen } from "../handlers/project/add/payment-manager/screen.tsx"; import { AddPaymentConnectorScreen } from "../handlers/project/add/payment-connector/screen.tsx"; import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx"; @@ -935,6 +937,11 @@ function RouteTable({ ctx, core }: ScreenProps) { path="agentcore/add/config-bundle" element={} /> + } + /> + } /> } diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index 9df953028a..c2302fb007 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -30,6 +30,8 @@ const WITH_SCREENS = [ "online-insight", "harness", "config-bundle", + "policy-engine", + "policy", "payment-manager", "payment-connector", ]; diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 2f35df6703..c8df48b828 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -37,6 +37,8 @@ export function createAddProjectResourceHandler( "online-insight", "harness", "config-bundle", + "policy-engine", + "policy", "payment-manager", "payment-connector", ); diff --git a/src/handlers/project/add/policy-engine/index.ts b/src/handlers/project/add/policy-engine/index.ts index eb909cfda5..bf690249cb 100644 --- a/src/handlers/project/add/policy-engine/index.ts +++ b/src/handlers/project/add/policy-engine/index.ts @@ -1,11 +1,66 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; +import type { AwsDeploymentTarget } from "../../../../projectSchemas/aws-targets"; import type { PolicyEngineSchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseTags } from "../../../utils"; +import type { AddResourceInput, Project } from "../../types"; import type { AddProjectResourceConfig } from "../types"; import { addProjectResource, requireDeployedNameFits } from "../shared"; +// The deployed name is __, and the service caps it here. +export const POLICY_ENGINE_DEPLOYED_NAME_MAX = 48; + +export type AttachMode = "enforce" | "log-only"; + +// PolicyEngineInput is the engine as the flags state it: the engine's own +// fields plus, optionally, the project Gateways to attach it to and how. +export type PolicyEngineInput = { + name: string; + description?: string; + encryptionKeyArn?: string; + tags?: z.input["tags"]; + attachToGateways?: string[]; + attachMode?: AttachMode; +}; + +// toAddPolicyEngineInput is the one place a Policy Engine is built from user +// input — the flags, or the wizard's answers — so both paths bound the deployed +// name the same way and attach Gateways under the same rule. +export function toAddPolicyEngineInput( + project: Project, + targets: readonly AwsDeploymentTarget[], + input: PolicyEngineInput, +): AddResourceInput { + if (input.attachMode !== undefined && input.attachToGateways === undefined) { + throw new InputValidationError("--attach-mode requires --attach-to-gateways"); + } + requireDeployedNameFits( + "Policy Engine", + project.name, + input.name, + "_", + POLICY_ENGINE_DEPLOYED_NAME_MAX, + targets, + ); + const engine: z.input = { + name: input.name, + description: input.description, + encryptionKeyArn: input.encryptionKeyArn, + tags: input.tags, + }; + return { + resourceType: "policy-engine", + resourceConfig: engine, + attachGateways: input.attachToGateways + ? { + names: input.attachToGateways, + mode: input.attachMode === "log-only" ? "LOG_ONLY" : "ENFORCE", + } + : undefined, + }; +} + export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) => createHandler({ name: "policy-engine", @@ -27,40 +82,25 @@ export const createAddPolicyEngineHandler = (config: AddProjectResourceConfig) = ), ], handle: async (ctx, flags) => { - if (flags["attach-mode"] !== undefined && flags["attach-to-gateways"] === undefined) { - throw new InputValidationError("--attach-mode requires --attach-to-gateways"); - } const project = ctx.require(ProjectKey); - requireDeployedNameFits( - "Policy Engine", - project.name, - flags.name, - "_", - 48, + const input = toAddPolicyEngineInput( + project, await config.projectManager.listTargets(project), + { + name: flags.name, + description: flags.description, + encryptionKeyArn: flags["encryption-key-arn"], + tags: parseTags(flags.tags), + attachToGateways: flags["attach-to-gateways"], + attachMode: flags["attach-mode"], + }, ); - const engine: z.input = { - name: flags.name, - description: flags.description, - encryptionKeyArn: flags["encryption-key-arn"], - tags: parseTags(flags.tags), - }; - await addProjectResource( ctx, config, 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, - }, + input, `added Policy Engine '${flags.name}' to '${project.name}'`, { notes: flags["attach-to-gateways"] diff --git a/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx b/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx new file mode 100644 index 0000000000..50234d1c17 --- /dev/null +++ b/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + cleanupScreens, + createSilentLogger, + flatFrame, + renderScreen, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, + waitFor, + waitForFlatText, + waitForText, + type RenderScreenResult, +} from "../../../../testing"; +import { createRootHandler } from "../../../index"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const { addGateway, cleanup, inProject, projectSpec, run } = createGatewayProjectTestHarness( + "add-policy-engine-wizard", +); + +afterEach(cleanup); +afterEach(cleanupScreens); + +async function nameStep(screen: RenderScreenResult, name: string): Promise { + await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); + await screen.write(name); + await screen.press("return"); +} + +describe("project add policy-engine wizard", () => { + test("in a project without Gateways it asks only for a name", async () => { + const projectRoot = await inProject(); + const screen = renderScreen("/agentcore/add/policy-engine"); + + await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); + expect(screen.lastFrame()).not.toContain("gateways"); + await screen.write("Guardrails"); + await screen.press("return"); + + // No Gateways to attach to, so the attachment step is not offered at all. + await waitForText(screen.lastFrame, "this Policy Engine will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("policy engine Guardrails"); + expect(review).toContain("gateways (none)"); + expect(review).not.toContain("mode"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "added Policy Engine 'Guardrails' to 'TestProject'"); + // Bare, so following it opens the policy wizard; --engine would make it headless. + expect(screen.lastFrame()).toContain("agentcore add policy"); + expect(screen.lastFrame()).not.toContain("--engine"); + expect(screen.lastFrame()).toContain("[enter] go back"); + // The same bare engine `--name Guardrails` writes. + expect((await projectSpec(projectRoot)).policyEngines).toEqual([ + { name: "Guardrails", policies: [] }, + ]); + + await screen.press("return"); + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }, 15000); + + test("attaches to the Gateways checked, in the mode revealed beneath them", async () => { + const projectRoot = await inProject(); + await addGateway("tools"); + await addGateway("search"); + const screen = renderScreen("/agentcore/add/policy-engine"); + await nameStep(screen, "Guardrails"); + + await waitForText(screen.lastFrame, "attach it to any Gateways now?"); + expect(screen.lastFrame()).toContain("❯ [ ] tools"); + expect(screen.lastFrame()).toContain("[ ] search"); + // Nothing checked yet, so the mode question is not on screen. + expect(screen.lastFrame()).not.toContain("Enforcement on those Gateways"); + + await screen.write(" "); + await waitForText(screen.lastFrame, "[✓] tools"); + // Checking a Gateway reveals the mode beneath the list, with enforce + // preselected; the list keeps the pointer. + await waitForText(screen.lastFrame, "Enforcement on those Gateways"); + expect(screen.lastFrame()).toContain("● enforce (default)"); + expect(screen.lastFrame()).toContain("❯ [✓] tools"); + + await screen.press("down"); + await screen.write(" "); + await waitForText(screen.lastFrame, "❯ [✓] search"); + + // Enter moves focus from the list into the mode rows; down picks log-only. + await screen.press("return"); + await waitForText(screen.lastFrame, "❯ ● enforce (default)"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● log-only"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this Policy Engine will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("gateways tools, search"); + expect(review).toContain("mode log-only"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "added Policy Engine 'Guardrails'"); + expect(screen.lastFrame()).toContain("attached to 2 Gateways in log-only mode"); + const spec = await projectSpec(projectRoot); + for (const gateway of spec.agentCoreGateways) { + expect(gateway.policyEngineConfiguration).toEqual({ + policyEngineName: "Guardrails", + mode: "LOG_ONLY", + }); + } + screen.unmount(); + }, 15000); + + test("enter with nothing checked continues without attaching", async () => { + const projectRoot = await inProject(); + await addGateway("tools"); + const screen = renderScreen("/agentcore/add/policy-engine"); + await nameStep(screen, "Guardrails"); + + await waitForText(screen.lastFrame, "attach it to any Gateways now?"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this Policy Engine will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("gateways (none)"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "added Policy Engine 'Guardrails'"); + expect(screen.lastFrame()).not.toContain("attached to"); + const spec = await projectSpec(projectRoot); + expect(spec.policyEngines).toEqual([{ name: "Guardrails", policies: [] }]); + expect(spec.agentCoreGateways[0].policyEngineConfiguration).toBeUndefined(); + screen.unmount(); + }, 15000); + + test("up from the first mode row returns to the Gateway list", async () => { + await inProject(); + await addGateway("tools"); + const screen = renderScreen("/agentcore/add/policy-engine"); + await nameStep(screen, "Guardrails"); + await waitForText(screen.lastFrame, "attach it to any Gateways now?"); + await screen.write(" "); + await screen.press("return"); + await waitForText(screen.lastFrame, "❯ ● enforce (default)"); + + await screen.press("up"); + + await waitForText(screen.lastFrame, "❯ [✓] tools"); + expect(screen.lastFrame()).not.toContain("❯ ● enforce (default)"); + screen.unmount(); + }); + + test("a name that breaks the schema's pattern is rejected as it is typed", async () => { + await inProject(); + const screen = renderScreen("/agentcore/add/policy-engine"); + + await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); + await screen.write("9starts"); + + await waitForText(screen.lastFrame, "Must begin with a letter"); + screen.unmount(); + }); + + test("validates the deployed name against the longest project target", async () => { + const projectRoot = await inProject(); + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([{ name: "production", account: "111122223333", region: "us-east-1" }]), + ); + // 26 characters: legal on its own, one over once the project and the target + // are prefixed. + const name = `E${"x".repeat(25)}`; + const screen = renderScreen("/agentcore/add/policy-engine"); + + await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); + await screen.write(name); + await screen.press("return"); + + await waitForFlatText(screen.lastFrame, "is 49 characters. The maximum is 48."); + const frame = flatFrame(screen.lastFrame); + expect(frame).toContain("TestProject_production_"); + expect(frame).toContain("what should this Policy Engine be called?"); + screen.unmount(); + }); + + test("a rejected add reports itself and hands the form back", async () => { + const projectRoot = await inProject(); + await run(["add", "policy-engine", "--name", "Guardrails"]); + const screen = renderScreen("/agentcore/add/policy-engine"); + await nameStep(screen, "Guardrails"); + await waitForText(screen.lastFrame, "this Policy Engine will be added to agentcore.json"); + await screen.press("return"); + + await waitForFlatText(screen.lastFrame, "already exists"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "this Policy Engine will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("policy engine Guardrails"); + + expect((await projectSpec(projectRoot)).policyEngines).toHaveLength(1); + screen.unmount(); + }, 15000); + + test("esc on the first step returns to the add menu", async () => { + await inProject(); + const screen = renderScreen("/agentcore/add/policy-engine"); + + await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); +}); + +// These drive the real CLI entrypoint rather than mounting the screen, because +// what they cover is the routing in front of it: a bare `agentcore add +// policy-engine` has to reach the wizard, and everything else has to stay +// headless. +describe("project add policy-engine dispatch", () => { + function buildRoot(io: AppIO) { + return createRootHandler(new TestCoreClient(), { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + } + + const MISSING_NAME = "required option '--name' not specified"; + + async function routeError(io: AppIO, args: string[]): Promise { + return buildRoot(io) + .route(["node", "agentcore", "add", "policy-engine", ...args]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + } + + test("bare add policy-engine in a TTY session opens the wizard", async () => { + await inProject(); + const { streams, stdin } = ttyTestIO(); + + const outcome = buildRoot(streams.io) + .route(["node", "agentcore", "add", "policy-engine"]) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + await waitFor( + () => { + if (!settled) stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(streams.stderr()).not.toContain("required option"); + }, 10000); + + test("bare add policy-engine without a TTY stays headless and reports the missing --name", async () => { + await inProject(); + + const error = await routeError(testIO().io, []); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("any user-supplied flag stays headless even in a TTY", async () => { + await inProject(); + + const error = await routeError(ttyTestIO().streams.io, ["--description", "guardrails"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("--json stays headless even in a TTY", async () => { + await inProject(); + + const error = await routeError(ttyTestIO().streams.io, ["--json"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("flag-driven add policy-engine still runs headless in a TTY session", async () => { + const projectRoot = await inProject(); + const { streams } = ttyTestIO(); + + await buildRoot(streams.io).route([ + "node", + "agentcore", + "add", + "policy-engine", + "--name", + "FlagEngine", + ]); + + expect((await projectSpec(projectRoot)).policyEngines).toEqual([ + { name: "FlagEngine", policies: [] }, + ]); + }, 10000); +}); diff --git a/src/handlers/project/add/policy-engine/screen.tsx b/src/handlers/project/add/policy-engine/screen.tsx new file mode 100644 index 0000000000..1739232772 --- /dev/null +++ b/src/handlers/project/add/policy-engine/screen.tsx @@ -0,0 +1,325 @@ +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Box, useInput } from "ink"; +import { useNavigate } from "react-router"; +import { FormCheckboxMultiSelect } from "../../../../components/FormCheckboxMultiSelect"; +import { FormRadioGroup } from "../../../../components/FormRadioGroup"; +import { + Step, + Summary, + TextField, + Wizard, + useKeyHints, + useWizard, + type Choice, +} from "../../../../components/wizard"; +import type { AwsDeploymentTarget } from "../../../../projectSchemas/aws-targets"; +import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; +import { PolicyEngineNameSchema } from "../../../../projectSchemas/policy"; +import { ProjectKey } from "../../../../router"; +import type { ScreenProps } from "../../../types"; +import type { Project } from "../../types"; +import { LoadingFrame, ProjectGate, projectQueryKey, useProjectTargets } from "../../ProjectGate"; +import { requireDeployedNameFits } from "../shared"; +import { + POLICY_ENGINE_DEPLOYED_NAME_MAX, + toAddPolicyEngineInput, + type AttachMode, + type PolicyEngineInput, +} from "./index"; + +const BREADCRUMB = ["agentcore", "add", "policy-engine"]; +const DESCRIPTION = "add a Policy Engine to the current project"; +const ADD_MENU = "/agentcore/add"; + +const MODE_CHOICES: Choice[] = [ + { + value: "enforce", + label: "enforce (default)", + description: "deny the calls the engine's policies forbid", + }, + { + value: "log-only", + label: "log-only", + description: "record what the policies would decide, without blocking", + }, +]; + +type PolicyEngineFormValues = { + name: string; + // The Gateways to attach the engine to, in the order the project lists them. + attached: string[]; + mode: AttachMode; +}; + +// toPolicyEngineInput is the answers as the flag path would state them: no +// attachment at all when no Gateway was picked, exactly as omitting +// --attach-to-gateways does. --description, --encryption-key-arn and --tags +// stay flag-only. +export function toPolicyEngineInput(values: PolicyEngineFormValues): PolicyEngineInput { + const attaches = values.attached.length > 0; + return { + name: values.name, + attachToGateways: attaches ? values.attached : undefined, + attachMode: attaches ? values.mode : undefined, + }; +} + +function summaryOf(values: PolicyEngineFormValues): Record { + const attaches = values.attached.length > 0; + return { + "policy engine": values.name, + // The row is always shown, so the review says what an empty selection + // means: the engine exists on its own and can be attached later. + gateways: attaches ? values.attached.join(", ") : "(none) · attach from a Gateway later", + ...(attaches ? { mode: values.mode } : {}), + }; +} + +export function AddPolicyEngineScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(ADD_MENU)} + > + {(project) => } + + ); +} + +function AddPolicyEngineLoader({ project, core }: { project: Project; core: ScreenProps["core"] }) { + const navigate = useNavigate(); + const targets = useProjectTargets(core, project); + + if (targets.data !== undefined) { + return ; + } + + return ( + navigate(ADD_MENU)} + /> + ); +} + +function AddPolicyEngineWizard({ + project, + targets, + core, +}: { + project: Project; + targets: readonly AwsDeploymentTarget[]; + core: ScreenProps["core"]; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const gateways = project.spec.agentCoreGateways ?? []; + const [values, setValues] = useState({ + name: "", + attached: [], + mode: "enforce", + }); + const set = (update: Partial) => + setValues((current) => ({ ...current, ...update })); + + // The deployed name is __ and must fit the service + // cap, so the live check reports the real budget rather than the schema's 48. + const nameSchema = useMemo( + () => + PolicyEngineNameSchema.superRefine((name, ctx) => { + try { + requireDeployedNameFits( + "Policy Engine", + project.name, + name, + "_", + POLICY_ENGINE_DEPLOYED_NAME_MAX, + targets, + ); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error), + }); + } + }), + [project.name, targets], + ); + + const attaches = values.attached.length > 0; + + return ( + navigate(ADD_MENU)} + onSubmit={async function* () { + const updated = yield* core.projectManager.addResource( + project, + toAddPolicyEngineInput(project, targets, toPolicyEngineInput(values)), + ); + queryClient.setQueryData(projectQueryKey(), updated); + return updated; + }} + runningLabel={`adding Policy Engine ${values.name}…`} + successLabel={`added Policy Engine '${values.name}' to '${project.name}'`} + successHint={ + attaches + ? `attached to ${values.attached.length} ${values.attached.length === 1 ? "Gateway" : "Gateways"} in ${values.mode} mode` + : undefined + } + // Bare, so it opens the policy wizard, which asks for the engine; naming + // the engine with --engine would select the headless path instead. + successNextSteps={["agentcore add policy", "agentcore deploy"]} + onDone={() => navigate(ADD_MENU)} + doneLabel="go back" + > + + __ must fit ${POLICY_ENGINE_DEPLOYED_NAME_MAX} characters`} + placeholder="Guardrails" + value={values.name} + onChange={(name) => set({ name })} + required + schema={nameSchema} + live + /> + + + {/* Skipped when the project has no Gateways: there is nothing to attach + to, and the engine can be attached from a Gateway later. */} + {gateways.length > 0 && ( + + set(update)} + /> + + )} + + + + + + ); +} + +// GatewayAttachmentField is a compound field: a checklist of the project's +// Gateways and, once any is checked, the enforcement mode for those +// attachments revealed beneath it — the question exists only once a Gateway is +// picked, which is the `--attach-mode requires --attach-to-gateways` rule. The +// checklist has focus first; enter (or down past the last Gateway) moves into +// the mode rows when there are attachments, and enter there continues. Enter +// with nothing checked continues straight away. +function GatewayAttachmentField({ + gateways, + attached, + mode, + onChange, +}: { + gateways: readonly AgentCoreGateway[]; + attached: string[]; + mode: AttachMode; + onChange: (update: Partial>) => void; +}) { + const { advance, back } = useWizard(); + const [focused, setFocused] = useState<"gateways" | "mode">("gateways"); + const [cursor, setCursor] = useState(0); + const modeIndex = Math.max( + 0, + MODE_CHOICES.findIndex((choice) => choice.value === mode), + ); + const attaches = attached.length > 0; + + useKeyHints([ + { key: "↑↓", label: "navigate" }, + { key: "space", label: "toggle" }, + { key: "enter", label: "continue" }, + ]); + + useInput((input, key) => { + if (key.escape) { + back(); + return; + } + + if (focused === "gateways") { + if (key.upArrow) { + setCursor((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow) { + if (cursor < gateways.length - 1) setCursor(cursor + 1); + else if (attaches) setFocused("mode"); + return; + } + if (input === " ") { + const name = gateways[cursor]!.name; + const toggled = attached.includes(name) + ? attached.filter((candidate) => candidate !== name) + : [...attached, name]; + // Kept in the project's order, so the review and the spec agree. + onChange({ + attached: gateways + .filter((gateway) => toggled.includes(gateway.name)) + .map((gateway) => gateway.name), + }); + return; + } + if (key.return) { + if (attaches) setFocused("mode"); + else advance(); + } + return; + } + + if (key.upArrow) { + if (modeIndex === 0) setFocused("gateways"); + else onChange({ mode: MODE_CHOICES[modeIndex - 1]!.value }); + return; + } + if (key.downArrow) { + onChange({ mode: MODE_CHOICES[Math.min(MODE_CHOICES.length - 1, modeIndex + 1)]!.value }); + return; + } + if (key.return) advance(); + }); + + return ( + + ({ + label: gateway.name, + description: `${gateway.targets.length} ${gateway.targets.length === 1 ? "Target" : "Targets"}`, + checked: attached.includes(gateway.name), + }))} + cursorIndex={focused === "gateways" ? cursor : -1} + /> + {attaches && ( + ({ + label: choice.label, + description: choice.description ?? "", + }))} + focusedIndex={focused === "mode" ? modeIndex : undefined} + selectedIndex={modeIndex} + /> + )} + + ); +} diff --git a/src/handlers/project/add/policy/index.ts b/src/handlers/project/add/policy/index.ts index 98543a5fde..ba93732ecc 100644 --- a/src/handlers/project/add/policy/index.ts +++ b/src/handlers/project/add/policy/index.ts @@ -2,6 +2,7 @@ import z from "zod"; import { SourceResolver } from "../../../../io"; import type { PolicySchema } from "../../../../projectSchemas/policy"; import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddResourceInput } from "../../types"; import type { AddProjectResourceConfig } from "../types"; import { addProjectResource } from "../shared"; @@ -19,6 +20,39 @@ const VALIDATION_MODES = { } as const; const ENFORCEMENT_MODES = { active: "ACTIVE", "log-only": "LOG_ONLY" } as const; +export type PolicyEnforcementMode = keyof typeof ENFORCEMENT_MODES; + +// PolicyInput is the policy as the flags state it, with the statement already +// resolved to text and, when it came from a file, the path it came from. +export type PolicyInput = { + engine: string; + name: string; + statement: string; + sourceFile?: string; + description?: string; + validationMode?: keyof typeof VALIDATION_MODES; + enforcementMode?: PolicyEnforcementMode; + authorizationPhase?: keyof typeof PHASES; +}; + +// toAddPolicyInput is the one place a Policy is built from user input — the +// flags, or the wizard's answers — so both infer the authorization phase from +// the statement the same way and map modes to the same values. +export function toAddPolicyInput(input: PolicyInput): AddResourceInput { + const policy: z.input = { + name: input.name, + description: input.description, + statement: input.statement, + sourceFile: input.sourceFile, + validationMode: input.validationMode && VALIDATION_MODES[input.validationMode], + enforcementMode: input.enforcementMode && ENFORCEMENT_MODES[input.enforcementMode], + authorizationPhase: input.authorizationPhase + ? PHASES[input.authorizationPhase] + : inferAuthorizationPhase(input.statement), + }; + return { resourceType: "policy", engineName: input.engine, resourceConfig: policy }; +} + export const createAddPolicyHandler = (config: AddProjectResourceConfig) => createHandler({ name: "policy", @@ -57,29 +91,20 @@ export const createAddPolicyHandler = (config: AddProjectResourceConfig) => ? 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, - }; - await addProjectResource( ctx, config, project, - { - resourceType: "policy", - engineName: flags.engine, - resourceConfig: policy, - }, + toAddPolicyInput({ + engine: flags.engine, + name: flags.name, + statement, + sourceFile, + description: flags.description, + validationMode: flags["validation-mode"], + enforcementMode: flags["enforcement-mode"], + authorizationPhase: flags["authorization-phase"], + }), `added Policy '${flags.name}' to Policy Engine '${flags.engine}' in '${project.name}'`, ); }, diff --git a/src/handlers/project/add/policy/policy.screen.test.tsx b/src/handlers/project/add/policy/policy.screen.test.tsx new file mode 100644 index 0000000000..373910aae3 --- /dev/null +++ b/src/handlers/project/add/policy/policy.screen.test.tsx @@ -0,0 +1,425 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + cleanupScreens, + createSilentLogger, + flatFrame, + renderScreen, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, + waitFor, + waitForFlatText, + waitForText, + type RenderScreenResult, +} from "../../../../testing"; +import { createRootHandler } from "../../../index"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { readableFileSchema } from "./screen"; + +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")) };'; +const FILE_HELP = "a .cedar file, relative to the current directory or absolute"; + +const { cleanup, inProject, projectSpec, run } = + createGatewayProjectTestHarness("add-policy-wizard"); + +afterEach(cleanup); +afterEach(cleanupScreens); + +async function withEngine(): Promise { + const projectRoot = await inProject(); + await run(["add", "policy-engine", "--name", "Guardrails"]); + return projectRoot; +} + +async function policiesOf(projectRoot: string, engine = "Guardrails") { + const spec = await projectSpec(projectRoot); + return spec.policyEngines.find((candidate: { name: string }) => candidate.name === engine) + .policies; +} + +// reachSourceStep confirms the only engine and names the policy, leaving the +// wizard on the statement-source step. +async function reachSourceStep(screen: RenderScreenResult, name: string): Promise { + await waitForText(screen.lastFrame, "which Policy Engine should this Policy belong to?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this Policy be called?"); + await screen.write(name); + await screen.press("return"); + await waitForText(screen.lastFrame, "where is the Cedar statement?"); +} + +describe("policy wizard helpers", () => { + test("readableFileSchema accepts an existing file and refuses anything else", async () => { + const projectRoot = await inProject(); + const path = join(projectRoot, "deny.cedar"); + await writeFile(path, FORBID_ALL); + expect(readableFileSchema.safeParse(path).success).toBe(true); + expect(readableFileSchema.safeParse(join(projectRoot, "missing.cedar")).success).toBe(false); + // A directory is not a statement. + expect(readableFileSchema.safeParse(projectRoot).success).toBe(false); + }); +}); + +describe("project add policy wizard", () => { + test("adds the same inline policy as the flags, with the inferred phase on review", async () => { + const projectRoot = await withEngine(); + const screen = renderScreen("/agentcore/add/policy"); + + await waitForText(screen.lastFrame, "which Policy Engine should this Policy belong to?"); + expect(screen.lastFrame()).toContain("❯ ● Guardrails"); + expect(screen.lastFrame()).toContain("0 policies"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "what should this Policy be called?"); + await screen.write("DenyAll"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "where is the Cedar statement?"); + expect(screen.lastFrame()).toContain("❯ ● type or paste the Cedar statement"); + expect(screen.lastFrame()).toContain("○ load it from a file"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "what is the Cedar statement?"); + expect(screen.lastFrame()).toContain("[ctrl+d] continue"); + await screen.write(FORBID_ALL); + await screen.press("ctrl+d"); + + await waitForText(screen.lastFrame, "should it enforce, or only log?"); + expect(screen.lastFrame()).toContain("❯ ● active (default)"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this Policy will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("policy engine Guardrails"); + expect(review).toContain("policy DenyAll"); + expect(review).toContain(`statement ${FORBID_ALL}`); + expect(review).toContain("enforcement active"); + expect(review).toContain("authorization phase INITIATE · inferred from the statement"); + await screen.press("return"); + + await waitForText( + screen.lastFrame, + "added Policy 'DenyAll' to Policy Engine 'Guardrails' in 'TestProject'", + ); + expect(await policiesOf(projectRoot)).toEqual([ + { + name: "DenyAll", + statement: FORBID_ALL, + validationMode: "FAIL_ON_ANY_FINDINGS", + enforcementMode: "ACTIVE", + authorizationPhase: "INITIATE", + }, + ]); + + await screen.press("return"); + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }, 15000); + + test("loads the statement from a file, recording its path and inferring RETURN_OUTPUT", async () => { + const projectRoot = await withEngine(); + // Relative to the project, which is the working directory: the path is + // recorded as typed, the way `--statement file://suppress.cedar` records it. + const cedarPath = "suppress.cedar"; + await writeFile(join(projectRoot, cedarPath), SUPPRESS); + const screen = renderScreen("/agentcore/add/policy"); + await reachSourceStep(screen, "Suppress"); + + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● load it from a file"); + await screen.press("return"); + + // The path input opens under the row. + await waitForText(screen.lastFrame, FILE_HELP); + expect(screen.lastFrame()).toContain("where is the Cedar statement?"); + await screen.write(cedarPath); + await screen.press("return"); + + // No editor step for a file; straight on to enforcement. + await waitForText(screen.lastFrame, "should it enforce, or only log?"); + expect(screen.lastFrame()).not.toContain("what is the Cedar statement?"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● log-only"); + await screen.press("return"); + + // The file is read when the review mounts, so the phase row fills in a + // moment after the rest of the review. + await waitForFlatText(screen.lastFrame, "authorization phase RETURN_OUTPUT"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain(`statement from ${cedarPath}`); + expect(review).toContain("enforcement log-only"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "added Policy 'Suppress'"); + expect((await policiesOf(projectRoot))[0]).toMatchObject({ + statement: SUPPRESS, + sourceFile: cedarPath, + enforcementMode: "LOG_ONLY", + authorizationPhase: "RETURN_OUTPUT", + }); + screen.unmount(); + }, 15000); + + test("a file that is not there keeps the step", async () => { + const projectRoot = await withEngine(); + const screen = renderScreen("/agentcore/add/policy"); + await reachSourceStep(screen, "Missing"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, FILE_HELP); + + await screen.press("return"); + await waitForText(screen.lastFrame, "Statement file is required"); + + await screen.write(join(projectRoot, "nope.cedar")); + await screen.press("return"); + await waitForText(screen.lastFrame, "no readable file at"); + expect(screen.lastFrame()).toContain("where is the Cedar statement?"); + screen.unmount(); + }); + + test("a statement file that is not valid UTF-8 is refused rather than decoded lossily", async () => { + const projectRoot = await withEngine(); + // Latin-1 bytes: a lossy decode would turn "café" into "caf�" and silently + // change what the policy matches. + await writeFile( + join(projectRoot, "latin1.cedar"), + Buffer.from('permit (principal, action, resource) when { context.tag == "café" };', "latin1"), + ); + const screen = renderScreen("/agentcore/add/policy"); + await reachSourceStep(screen, "Latin"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, FILE_HELP); + await screen.write("latin1.cedar"); + await screen.press("return"); + await waitForText(screen.lastFrame, "should it enforce, or only log?"); + await screen.press("return"); + + // The review says so instead of guessing a phase from mangled text. + await waitForFlatText(screen.lastFrame, "must contain valid UTF-8"); + expect(screen.lastFrame()).toContain("this Policy will be added to agentcore.json"); + await screen.press("return"); + + // And the submit refuses it for the same reason, handing the form back. + await waitFor(() => !(screen.lastFrame() ?? "").includes("this Policy will be added")); + await waitForFlatText(screen.lastFrame, "must contain valid UTF-8"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "this Policy will be added to agentcore.json"); + expect(await policiesOf(projectRoot)).toHaveLength(0); + screen.unmount(); + }, 15000); + + test("an empty statement is refused", async () => { + await withEngine(); + const screen = renderScreen("/agentcore/add/policy"); + await reachSourceStep(screen, "Empty"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what is the Cedar statement?"); + + await screen.press("ctrl+d"); + + await waitForText(screen.lastFrame, "Cedar statement is required"); + expect(screen.lastFrame()).not.toContain("should it enforce"); + screen.unmount(); + }); + + test("a multi-line statement is kept as typed and previewed on one line", async () => { + const projectRoot = await withEngine(); + const screen = renderScreen("/agentcore/add/policy"); + await reachSourceStep(screen, "Suppress"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what is the Cedar statement?"); + + const [first, second] = SUPPRESS.split("\n"); + await screen.write(first!); + await screen.press("return"); + await screen.write(second!); + await screen.press("ctrl+d"); + await waitForText(screen.lastFrame, "should it enforce, or only log?"); + await screen.press("return"); + + await waitForFlatText(screen.lastFrame, "(+1 more line)"); + expect(flatFrame(screen.lastFrame)).toContain("authorization phase RETURN_OUTPUT"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "added Policy 'Suppress'"); + expect((await policiesOf(projectRoot))[0].statement).toBe(SUPPRESS); + screen.unmount(); + }, 15000); + + test("without a Policy Engine the first step says what to add and esc returns to the menu", async () => { + await inProject(); + const screen = renderScreen("/agentcore/add/policy"); + + await waitForText(screen.lastFrame, "no Policy Engines in this project"); + expect(screen.lastFrame()).toContain("agentcore add policy-engine"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); + + test("a rejected add reports itself and hands the form back", async () => { + const projectRoot = await withEngine(); + await run(["add", "policy-engine", "--name", "Second"]); + await run([ + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "DenyAll", + "--statement", + FORBID_ALL, + ]); + const screen = renderScreen("/agentcore/add/policy"); + + // Pick the second engine; the name is taken by the first, and names are + // unique across engines, so addResource refuses it. + await waitForText(screen.lastFrame, "which Policy Engine should this Policy belong to?"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● Second"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this Policy be called?"); + await screen.write("DenyAll"); + await screen.press("return"); + await waitForText(screen.lastFrame, "where is the Cedar statement?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what is the Cedar statement?"); + await screen.write(FORBID_ALL); + await screen.press("ctrl+d"); + await waitForText(screen.lastFrame, "should it enforce, or only log?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "this Policy will be added to agentcore.json"); + await screen.press("return"); + + await waitForFlatText(screen.lastFrame, "already exists in policy engine 'Guardrails'"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "this Policy will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("policy DenyAll"); + + expect(await policiesOf(projectRoot, "Second")).toHaveLength(0); + screen.unmount(); + }, 15000); + + test("esc on the first step returns to the add menu", async () => { + await withEngine(); + const screen = renderScreen("/agentcore/add/policy"); + + await waitForText(screen.lastFrame, "which Policy Engine should this Policy belong to?"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); +}); + +// These drive the real CLI entrypoint rather than mounting the screen, because +// what they cover is the routing in front of it: a bare `agentcore add policy` +// has to reach the wizard, and everything else has to stay headless. +describe("project add policy dispatch", () => { + function buildRoot(io: AppIO) { + return createRootHandler(new TestCoreClient(), { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + } + + const MISSING_ENGINE = "required option '--engine' not specified"; + + async function routeError(io: AppIO, args: string[]): Promise { + return buildRoot(io) + .route(["node", "agentcore", "add", "policy", ...args]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + } + + test("bare add policy in a TTY session opens the wizard", async () => { + await withEngine(); + const { streams, stdin } = ttyTestIO(); + + const outcome = buildRoot(streams.io) + .route(["node", "agentcore", "add", "policy"]) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + await waitFor( + () => { + if (!settled) stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(streams.stderr()).not.toContain("required option"); + }, 10000); + + test("bare add policy without a TTY stays headless and reports the missing --engine", async () => { + await withEngine(); + + const error = await routeError(testIO().io, []); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_ENGINE); + }); + + test("any user-supplied flag stays headless even in a TTY", async () => { + await withEngine(); + + const error = await routeError(ttyTestIO().streams.io, ["--name", "DenyAll"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_ENGINE); + }); + + test("--json stays headless even in a TTY", async () => { + await withEngine(); + + const error = await routeError(ttyTestIO().streams.io, ["--json"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_ENGINE); + }); + + test("flag-driven add policy still runs headless in a TTY session", async () => { + const projectRoot = await withEngine(); + const { streams } = ttyTestIO(); + + await buildRoot(streams.io).route([ + "node", + "agentcore", + "add", + "policy", + "--engine", + "Guardrails", + "--name", + "Flagged", + "--statement", + FORBID_ALL, + ]); + + expect((await policiesOf(projectRoot)).map((policy: { name: string }) => policy.name)).toEqual([ + "Flagged", + ]); + }, 10000); +}); diff --git a/src/handlers/project/add/policy/screen.tsx b/src/handlers/project/add/policy/screen.tsx new file mode 100644 index 0000000000..772e2abf44 --- /dev/null +++ b/src/handlers/project/add/policy/screen.tsx @@ -0,0 +1,312 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import z from "zod"; +import { + ChoiceField, + ResourceChoiceField, + RevealChoiceField, + Step, + Summary, + TextAreaField, + TextField, + Wizard, + type Choice, +} from "../../../../components/wizard"; +import { SourceResolver } from "../../../../io"; +import { PolicyNameSchema, type PolicyEngine } from "../../../../projectSchemas/policy"; +import { ProjectKey } from "../../../../router"; +import type { ScreenProps } from "../../../types"; +import type { Project } from "../../types"; +import { ProjectGate, projectQueryKey } from "../../ProjectGate"; +import { + inferAuthorizationPhase, + toAddPolicyInput, + type PolicyEnforcementMode, + type PolicyInput, +} from "./index"; + +const BREADCRUMB = ["agentcore", "add", "policy"]; +const DESCRIPTION = "add a Cedar Policy to a project Policy Engine"; +const ADD_MENU = "/agentcore/add"; + +// Where the statement comes from. A file is the way `--statement file://…` +// arrives, and the flag path records the path as the policy's sourceFile, so +// the wizard keeps that provenance rather than pasting the file's text. +type StatementSource = "inline" | "file"; + +const SOURCE_CHOICES: Choice[] = [ + { + value: "inline", + label: "type or paste the Cedar statement", + description: "on the next step, over as many lines as it takes", + }, + { + value: "file", + label: "load it from a file", + description: "the path is recorded as the policy's sourceFile", + }, +]; + +const ENFORCEMENT_CHOICES: Choice[] = [ + { + value: "active", + label: "active (default)", + description: "deny the calls this policy forbids", + }, + { + value: "log-only", + label: "log-only", + description: "record what this policy would decide, without blocking", + }, +]; + +const STATEMENT_EXAMPLE = "forbid (principal, action, resource);"; + +// readableFileSchema accepts a path to an existing, readable file — checked +// before the step advances, so a typo is caught here rather than on submit. +export const readableFileSchema: z.ZodType = z.string().superRefine((path, ctx) => { + try { + if (!statSync(path).isFile()) throw new Error("not a file"); + accessSync(path, constants.R_OK); + } catch { + ctx.addIssue({ code: "custom", message: `no readable file at '${path}'` }); + } +}); + +type PolicyFormValues = { + engine: string; + name: string; + source: StatementSource; + statement: string; + statementFile: string; + enforcement: PolicyEnforcementMode; +}; + +// readStatementFile reads a statement the way `--statement file://…` does: +// through SourceResolver, so a file that is not valid UTF-8 is refused rather +// than decoded with replacement characters that would change what the policy +// matches. Nothing here reads stdin, so the resolver gets none. +function readStatementFile(path: string): Promise { + return new SourceResolver({}).resolveText("statement", `file://${path}`); +} + +// StatementLoad is what the review knows about the statement: a pasted one is +// ready at once; a file is read when the review mounts. +type StatementLoad = + | { state: "reading" } + | { state: "ready"; statement: string } + | { state: "failed"; message: string }; + +// toPolicyInput is the answers as the flag path would state them; the phase is +// left for the shared builder to infer, exactly as an omitted +// --authorization-phase is. --description and --validation-mode stay flag-only. +export function toPolicyInput(values: PolicyFormValues, statement: string): PolicyInput { + return { + engine: values.engine, + name: values.name, + statement, + sourceFile: values.source === "file" ? values.statementFile : undefined, + enforcementMode: values.enforcement, + }; +} + +function firstLine(text: string): string { + const lines = text.trim().split("\n"); + const first = lines[0] ?? ""; + const shown = first.length > 60 ? `${first.slice(0, 59)}…` : first; + const rest = lines.length - 1; + return rest > 0 ? `${shown} (+${rest} more ${rest === 1 ? "line" : "lines"})` : shown; +} + +function summaryOf(values: PolicyFormValues, load: StatementLoad): Record { + return { + "policy engine": values.engine, + policy: values.name, + statement: + values.source === "file" ? `from ${values.statementFile}` : firstLine(values.statement), + enforcement: values.enforcement, + // The phase is a substring heuristic over the statement, so the guess is + // shown before it is written; --authorization-phase overrides it. + "authorization phase": + load.state === "reading" + ? `reading ${values.statementFile}…` + : load.state === "failed" + ? `(statement unreadable: ${load.message})` + : `${inferAuthorizationPhase(load.statement)} · inferred from the statement`, + }; +} + +// PolicyReview loads a file-sourced statement when the review step mounts — +// asynchronously, so the TUI never blocks on a read, and only after the source +// step has checked that the path is a readable file. A pasted statement needs +// no loading. +function PolicyReview({ values }: { values: PolicyFormValues }) { + const [load, setLoad] = useState(() => + values.source === "inline" + ? { state: "ready", statement: values.statement } + : { state: "reading" }, + ); + + // A pasted statement is ready from the initializer; only a file has anything + // to load, and it resolves through the promise rather than in the effect body. + useEffect(() => { + if (values.source !== "file") return; + let cancelled = false; + readStatementFile(values.statementFile).then( + (statement) => { + if (!cancelled) setLoad({ state: "ready", statement }); + }, + (error: unknown) => { + if (!cancelled) { + setLoad({ + state: "failed", + message: error instanceof Error ? error.message : String(error), + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, [values.source, values.statementFile]); + + return ; +} + +function engineChoices(engines: readonly PolicyEngine[]): Choice[] { + return engines.map((engine) => ({ + value: engine.name, + label: engine.name, + description: `${engine.policies.length} ${engine.policies.length === 1 ? "policy" : "policies"}`, + })); +} + +export function AddPolicyScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(ADD_MENU)} + > + {(project) => } + + ); +} + +function AddPolicyWizard({ project, core }: { project: Project; core: ScreenProps["core"] }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const engines = project.spec.policyEngines ?? []; + const [values, setValues] = useState({ + engine: engines[0]?.name ?? "", + name: "", + source: "inline", + statement: "", + statementFile: "", + enforcement: "active", + }); + const set = (update: Partial) => + setValues((current) => ({ ...current, ...update })); + + return ( + navigate(ADD_MENU)} + onSubmit={async function* () { + // Read the file at submit, the way `--statement file://…` does, so what + // is written is what the file holds now, decoded strictly. + const statement = + values.source === "file" + ? await readStatementFile(values.statementFile) + : values.statement; + const updated = yield* core.projectManager.addResource( + project, + toAddPolicyInput(toPolicyInput(values, statement)), + ); + queryClient.setQueryData(projectQueryKey(), updated); + return updated; + }} + runningLabel={`adding Policy ${values.name}…`} + successLabel={`added Policy '${values.name}' to Policy Engine '${values.engine}' in '${project.name}'`} + successNextSteps={["agentcore deploy"]} + onDone={() => navigate(ADD_MENU)} + doneLabel="go back" + > + + set({ engine })} + emptyMessage="no Policy Engines in this project" + emptyHint="add one with agentcore add policy-engine" + /> + + + + set({ name })} + required + schema={PolicyNameSchema} + live + /> + + + + set({ source })} + input={{ + // Only a file needs a path, so it is asked under that row; pasting + // continues to the editor on the next step. + opensFor: (source) => source === "file", + label: "Statement file", + name: "statement file", + help: "a .cedar file, relative to the current directory or absolute", + placeholder: "policies/deny-all.cedar", + value: values.statementFile, + onChange: (statementFile) => set({ statementFile }), + required: true, + schema: readableFileSchema, + }} + /> + + + {values.source === "inline" && ( + + set({ statement })} + required + /> + + )} + + + set({ enforcement })} + /> + + + + + + + ); +} diff --git a/src/io/source.ts b/src/io/source.ts index 8dfa73a64b..5454ca8c5d 100644 --- a/src/io/source.ts +++ b/src/io/source.ts @@ -8,7 +8,9 @@ const FILE_PREFIX = "file://"; const STDIN = "-"; export type SourceResolverConfig = { - stdin: NodeJS.ReadStream; + // Omitted by callers that only ever resolve inline values and files — a + // screen, say — in which case '-' is refused rather than read. + stdin?: NodeJS.ReadStream; signal?: AbortSignal; }; @@ -70,6 +72,9 @@ export class SourceResolver { } private async readStdin(name: string): Promise { + if (this.config.stdin === undefined) { + throw new SourceResolutionError(`'--${name}' cannot be read from stdin here`); + } if (this.stdinClaimedBy !== undefined) { throw new SourceResolutionError( `only one option may read from stdin; '--${name}' conflicts with ` +