Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ agentcore # interactive TUI
│ │ ├── get # get a Rule under a Gateway
│ │ └── list # list Rules under a Gateway
│ └── policy
│ └── generate # generate Cedar for a Gateway from a natural-language prompt
│ └── generate # generate Cedar for a Gateway from a prompt (TUI when run bare)
├── eval # evaluate and optimize AgentCore agents
│ └── evaluator # manage AgentCore evaluators
│ ├── llm-as-a-judge # LLM-as-a-Judge evaluators
Expand Down
17 changes: 17 additions & 0 deletions src/components/ErrorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Box, Text, useInput } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;

export function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) {
useInput((_input, key) => {
if (key.escape || key.return) onBack();
});

return (
<Box flexDirection="column">
<Text color={theme.colors.error}>✗ {message}</Text>
<Text color={theme.colors.muted}>{" esc returns to the form"}</Text>
</Box>
);
}
14 changes: 1 addition & 13 deletions src/components/HarnessWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { CreateHarnessInput } from "../handlers/harness/types";
import type { ScreenProps } from "../handlers/types";
import { coreOptsFromCtx } from "../handlers/utils";
import { Layout } from "./Layout";
import { ErrorPanel } from "./ErrorPanel";
import { FormTextInput } from "./FormTextInput";
import { FormRadioGroup, type FormRadioOption } from "./FormRadioGroup";
import { FormCheckboxMultiSelect, type FormCheckboxOption } from "./FormCheckboxMultiSelect";
Expand Down Expand Up @@ -1188,16 +1189,3 @@ function SuccessPanel({
</Box>
);
}

function ErrorPanel({ message, onBack }: { message: string; onBack: () => void }) {
useInput((_input, key) => {
if (key.escape || key.return) onBack();
});

return (
<Box flexDirection="column">
<Text color={theme.colors.error}>✗ {message}</Text>
<Text color={theme.colors.muted}>{" esc returns to the form"}</Text>
</Box>
);
}
13 changes: 13 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { GatewayPolicyGenerateScreen } from "../handlers/gateway/policy/screen.tsx";
import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx";
import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx";
import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx";
Expand Down Expand Up @@ -464,6 +465,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/rule/get/:gatewayId/:ruleId"
element={<GatewayRuleGetScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/policy"
element={<Navigate to="/agentcore/gateway/policy/generate" replace />}
/>
<Route
path="agentcore/gateway/policy/generate"
element={<GatewayPolicyGenerateScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/policy/generate/:gatewayId"
element={<GatewayPolicyGenerateScreen ctx={ctx} core={core} />}
/>
<Route path="agentcore/eval" element={<EvalScreen ctx={ctx} core={core} />} />
<Route
path="agentcore/eval/evaluator"
Expand Down
20 changes: 17 additions & 3 deletions src/core/policy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
type GetPolicyGenerationCommandOutput,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { WaiterState } from "@smithy/core/client";
import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError, NetworkingError } from "../errors";
import {
AgentCoreCLIError,
ERROR_SOURCE,
InputValidationError,
NetworkingError,
UserCancellationError,
} from "../errors";
import type {
CorePolicyClient,
GeneratedPolicy,
Expand Down Expand Up @@ -40,12 +46,15 @@ export class PolicyClient implements CorePolicyClient {
async *generatePolicy(
input: GeneratePolicyInput,
options: CoreOptions,
signal?: AbortSignal,
): AsyncGenerator<ProgressEvent, PolicyGenerationResult> {
const control = this.clients.control(toClientConfig(options));
const gatewayId = resourceIdFromArn(input.gatewayId);

yield { type: "step", message: `Resolving gateway ${gatewayId}` };
const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId }));
const gateway = await control.send(new GetGatewayCommand({ gatewayIdentifier: gatewayId }), {
abortSignal: signal,
});
const gatewayArn = gateway.gatewayArn!;
const engine = input.policyEngineId ?? gateway.policyEngineConfiguration?.arn;
if (!engine) {
Expand All @@ -63,16 +72,20 @@ export class PolicyClient implements CorePolicyClient {
content: { rawText: input.prompt },
name: input.name,
}),
{ abortSignal: signal },
);
const policyGenerationId = started.policyGenerationId!;
const meta = { policyGenerationId, policyEngineId };

yield { type: "step", message: "Waiting for generation to complete" };
const waited = await waitForPolicyGenerationCompleted(
{ client: control, ...this.wait },
{ client: control, abortSignal: signal, ...this.wait },
{ policyEngineId, policyGenerationId },
);
this.logger.debug(`policy generation ${policyGenerationId} waiter state: ${waited.state}`);
if (waited.state === WaiterState.ABORTED) {
throw signal?.reason ?? new UserCancellationError();
}
if (waited.state === WaiterState.TIMEOUT) {
throw new NetworkingError(
`policy generation '${policyGenerationId}' did not finish within ${this.wait.maxWaitTime}s; ` +
Expand All @@ -95,6 +108,7 @@ export class PolicyClient implements CorePolicyClient {
do {
const page = await control.send(
new ListPolicyGenerationAssetsCommand({ policyEngineId, policyGenerationId, nextToken }),
{ abortSignal: signal },
);
for (const asset of page.policyGenerationAssets ?? []) {
policies.push({
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule", "policy"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
51 changes: 49 additions & 2 deletions src/handlers/gateway/gateway.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "@aws-sdk/client-bedrock-agentcore-control";
import { PolicyClient } from "../../core/policy";
import type { AwsClients } from "../../core/types";
import { NetworkingError } from "../../errors";
import { NetworkingError, UserCancellationError } from "../../errors";
import {
createSilentLogger,
TestCoreClient,
Expand All @@ -15,6 +15,9 @@ import {
} from "../../testing";
import { compile, isTuiCommandSupported, ValueContext } from "../../router";
import { createRootHandler } from "../index";
import { PathKey } from "../../router";
import { JsonKey } from "../keys";
import { createGeneratePolicyHandler } from "./policy/generate";
import type { Core } from "../types";

const REGION = "us-west-2";
Expand Down Expand Up @@ -148,7 +151,7 @@ describe("gateway validation", () => {
["Policy generate gateway", ["gateway", "policy", "generate", "--prompt", "x"], /--gateway-id/],
[
"Policy generate prompt",
["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID],
["gateway", "policy", "generate", "--gateway-id", GATEWAY_ID, "--json"],
/--prompt/,
],
] as const)(
Expand Down Expand Up @@ -212,9 +215,53 @@ describe("gateway policy generate against a faked control plane", () => {
).rejects.toThrow("policy generation 'gen-1' failed: bad prompt; try again");
});

test("stops waiting when the signal aborts", async () => {
const generation = coreWith("GENERATING").policy.generatePolicy(
{ gatewayId: GATEWAY_ID, prompt: "x", name: "n" },
{ region: REGION },
AbortSignal.abort(new UserCancellationError()),
);
await expect(
(async () => {
for await (const _event of generation) {
// drain
}
})(),
).rejects.toBeInstanceOf(UserCancellationError);
});

test("times out when the generation keeps running", async () => {
const attempt = run(args, coreWith("GENERATING"));
await expect(attempt).rejects.toBeInstanceOf(NetworkingError);
await expect(attempt).rejects.toThrow("did not finish within 2s");
}, 10_000);
});

describe("gateway policy generate deep link", () => {
test.each([
["opens the form when only --gateway-id is given", { "gateway-id": "gw-1" }, 1],
["stays headless when --name is also given", { "gateway-id": "gw-1", name: "n" }, 0],
])("%s", async (_label, flags, expectedRenders) => {
const core = new TestCoreClient();
let renders = 0;
const handler = createGeneratePolicyHandler(
core,
testIO().io,
async (path, _ctx, renderedCore) => {
renders++;
expect(path).toBe("/agentcore/gateway/policy/generate/gw-1");
expect(renderedCore).toBe(core);
},
);
const ctx = ValueContext.EmptyContext()
.withValue(PathKey, "/agentcore/gateway/policy/generate")
.withValue(JsonKey, false);

const attempt = handler.handle(ctx, { prompt: undefined, ...flags }, {});
if (expectedRenders === 0) await expect(attempt).rejects.toThrow(/--prompt/);
else await attempt;

expect(renders).toBe(expectedRenders);
expect(core.policy.calls).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule", "policy")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
125 changes: 125 additions & 0 deletions src/handlers/gateway/policy/generate.screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { GatewaySummary, GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control";
import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing";

afterEach(cleanupScreens);

const GATEWAY_ID = "gw-1";
const ENGINE_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:policy-engine/pe-1";
const FORBID =
"forbid (principal is AgentCore::IamEntity, action, resource is AgentCore::Gateway);";
const PERMIT = "permit (principal, action, resource is AgentCore::Gateway);";
const PLACEHOLDER = "Describe what the policy should allow or deny";

function coreWith(engineArn: string | undefined): TestCoreClient {
const core = new TestCoreClient();
const summary: GatewaySummary = {
gatewayId: GATEWAY_ID,
name: "checkout-gateway",
status: "READY",
createdAt: new Date("2026-08-01T01:02:03.000Z"),
updatedAt: new Date("2026-08-02T03:04:05.000Z"),
authorizerType: "AWS_IAM",
};
core.gateway.setListResponse({ items: [summary] });
core.gateway.setGetResponse({
gatewayId: GATEWAY_ID,
name: "checkout-gateway",
gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-1",
policyEngineConfiguration: engineArn ? { arn: engineArn, mode: "ENFORCE" } : undefined,
} as GetGatewayResponse);
return core;
}

describe("gateway policy generate screen", () => {
test("picks a gateway, then shows its engine and the prompt", async () => {
const screen = renderScreen("/agentcore/gateway/policy/generate", {
core: coreWith(ENGINE_ARN),
});

await waitForText(screen.lastFrame, "checkout-gateway");
await screen.press("return");

await waitForText(screen.lastFrame, ENGINE_ARN);
const frame = screen.lastFrame()!;
expect(frame).toContain(`agentcore → gateway → policy → generate → ${GATEWAY_ID}`);
expect(frame).toContain(PLACEHOLDER);
expect(frame).toContain("[enter] generate");
});

test("explains when the gateway has no engine and offers no prompt", async () => {
const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, {
core: coreWith(undefined),
});

await waitForText(screen.lastFrame, "no Policy Engine attached");
expect(screen.lastFrame()).not.toContain(PLACEHOLDER);
});

test("generates, renders the Cedar and findings, and edits again on e", async () => {
const core = coreWith(ENGINE_ARN);
core.policy.result = {
policyGenerationId: "gen-1",
policyEngineId: "pe-1",
gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-1",
policies: [
{
statement: FORBID,
findings: [{ type: "DENY_ALL", description: "denies every request" }],
},
{ statement: PERMIT, findings: [] },
],
};
const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core });
await waitForText(screen.lastFrame, PLACEHOLDER);

await screen.write("forbid IAM callers");
await screen.press("return");

await waitForText(screen.lastFrame, PERMIT, 3000);
const frame = screen.lastFrame()!;
expect(frame).toContain(FORBID);
expect(frame).toContain("✓ Resolving gateway");
expect(frame).toContain("[DENY_ALL] denies every request");
expect(frame).toContain("[e] edit prompt");
expect(core.policy.calls[0]).toMatchObject({
gatewayId: GATEWAY_ID,
prompt: "forbid IAM callers",
});
expect(core.policy.calls[0]).not.toHaveProperty("policyEngineId");
expect(core.policy.calls[0]!.name).toMatch(/^cli_generation_\d+$/);

await screen.write("e");
await waitForText(screen.lastFrame, "[enter] generate");
expect(screen.lastFrame()).toContain("forbid IAM callers");
});

test("aborts the run and returns to the picker on esc while generating", async () => {
const core = coreWith(ENGINE_ARN);
core.policy.hang = true;
const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core });
await waitForText(screen.lastFrame, PLACEHOLDER);

await screen.write("x");
await screen.press("return");
await waitForText(screen.lastFrame, "[esc] cancel");

await screen.press("escape");
await waitForText(screen.lastFrame, "choose a Gateway to generate a policy for");
expect(core.policy.signals[0]!.aborted).toBe(true);
});

test("shows the error and returns to the form on esc", async () => {
const core = coreWith(ENGINE_ARN);
core.policy.error = new Error("policy generation 'gen-1' failed: bad prompt");
const screen = renderScreen(`/agentcore/gateway/policy/generate/${GATEWAY_ID}`, { core });
await waitForText(screen.lastFrame, PLACEHOLDER);

await screen.write("x");
await screen.press("return");

await waitForText(screen.lastFrame, "✗ policy generation 'gen-1' failed: bad prompt", 3000);
await screen.press("escape");
await waitForText(screen.lastFrame, "[enter] generate");
});
});
Loading
Loading