diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 0199f1834..6f3e6d768 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -91,6 +91,8 @@ never enabled by default. Send `SIGHUP` to the REA MCP process after a trusted local policy change; ceilings, administrator grants, and project grants reload without restarting. Revocation affects future operations; an already-running operation retains the decision made at its preflight boundary. +`rea policy revoke ` displays the exact grant and requires interactive +confirmation; automation must pass `--yes` (or `-y`) explicitly. Denials use the shared `permission_required` schema with requested scope, missing scope, administrator ceiling, elicitation support, and exact restart status. diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index d28e2abe0..eebcb34a5 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -1,4 +1,5 @@ import { Cli, z } from "incur"; +import { createInterface } from "node:readline/promises"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { @@ -30,6 +31,24 @@ const capabilitySchema = z.enum([ "reference_read", ]); +type PolicyRevocationApproval = + | { readonly approved: true } + | { readonly approved: false; readonly reason: "cancelled" | "required" }; + +/** Require an explicit decision before a project permission grant is removed. */ +export const approvePolicyRevocation = async (options: { + readonly approved: boolean; + readonly interactive: boolean; + readonly grantId: string; + readonly confirm: (grantId: string) => Promise; +}): Promise => { + if (options.approved) return { approved: true }; + if (!options.interactive) return { approved: false, reason: "required" }; + return (await options.confirm(options.grantId)) + ? { approved: true } + : { approved: false, reason: "cancelled" }; +}; + /** Register inspectable, dry-run, and revocation policy UX for the CLI. */ export const registerPolicyCommands = ( cli: ReturnType, @@ -49,9 +68,13 @@ export const registerPolicyCommands = ( network: z.enum(["none", "loopback", "external"]).default("none"), mount: z.boolean().default(false), write: z.boolean().default(false), + yes: z + .boolean() + .default(false) + .describe("Confirm project permission revocation without prompting"), }), - alias: { environmentNames: "environment-names" }, - run: ({ args, options }) => + alias: { environmentNames: "environment-names", yes: "y" }, + run: ({ agent, args, formatExplicit, options }) => logCliCommand(logger, "policy", async () => { const config = parseConfig(process.env); if (!config.ok) return failure(config.error); @@ -76,6 +99,24 @@ export const registerPolicyCommands = ( permissionProjectStore === undefined ) return { error: "Project policy is not configured" }; + const approval = await approvePolicyRevocation({ + approved: options.yes, + interactive: + !agent && !formatExplicit && process.stdin.isTTY === true, + grantId: args.value, + confirm: confirmPolicyRevocation, + }); + if (!approval.approved) + return approval.reason === "required" + ? { + error: + "Policy revoke requires confirmation. Rerun interactively or with --yes.", + grant_id: args.value, + } + : { + error: "Policy revocation was cancelled", + grant_id: args.value, + }; const revoked = await revokeProjectPermissionGrant( permissionProjectStore, permissionProjectRoot, @@ -100,6 +141,21 @@ export const registerPolicyCommands = ( }); }; +const confirmPolicyRevocation = async (grantId: string): Promise => { + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question( + `Revoke project permission grant ${JSON.stringify(grantId)}? [y/N] `, + ); + return ["y", "yes"].includes(answer.trim().toLowerCase()); + } finally { + prompt.close(); + } +}; + const explain = async ( config: AppConfig, capability: PermissionCapability, diff --git a/tests/cliPolicyCommands.test.ts b/tests/cliPolicyCommands.test.ts new file mode 100644 index 000000000..2770066eb --- /dev/null +++ b/tests/cliPolicyCommands.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +import { approvePolicyRevocation } from "../src/cliPolicyCommands.js"; + +describe("policy revocation approval", () => { + it("accepts --yes without invoking the interactive prompt", async () => { + const confirm = vi.fn<() => Promise>(); + + await expect( + approvePolicyRevocation({ + approved: true, + interactive: false, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual({ approved: true }); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("fails closed for non-interactive revocation without --yes", async () => { + const confirm = vi.fn<() => Promise>(); + + await expect( + approvePolicyRevocation({ + approved: false, + interactive: false, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual({ approved: false, reason: "required" }); + expect(confirm).not.toHaveBeenCalled(); + }); + + it.each([ + [true, { approved: true }], + [false, { approved: false, reason: "cancelled" }], + ] as const)( + "uses the interactive decision %s", + async (decision, expected) => { + const confirm = vi.fn(() => Promise.resolve(decision)); + + await expect( + approvePolicyRevocation({ + approved: false, + interactive: true, + grantId: "grant-1", + confirm, + }), + ).resolves.toEqual(expected); + expect(confirm).toHaveBeenCalledWith("grant-1"); + }, + ); +});