fix(cli): confirm project grant revocation

This commit is contained in:
morluto
2026-07-14 22:25:47 +00:00
3 changed files with 215 additions and 2 deletions
+2
View File
@@ -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 <grant-id>` 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.
+58 -2
View File
@@ -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<boolean>;
}): Promise<PolicyRevocationApproval> => {
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<typeof Cli.create>,
@@ -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<boolean> => {
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,
+155
View File
@@ -0,0 +1,155 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readProjectPermissionStore,
writeProjectPermissionStore,
} from "../src/application/ProjectPermissionStore.js";
import { approvePolicyRevocation } from "../src/cliPolicyCommands.js";
const execute = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("policy revocation approval", () => {
it("accepts --yes without invoking the interactive prompt", async () => {
const confirm = vi.fn<() => Promise<boolean>>();
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<boolean>>();
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");
},
);
it("keeps the packaged project store unchanged until --yes is supplied", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-cli-"));
roots.push(project);
const store = join(project, ".rea", "permissions.json");
const grant = {
grant_id: "project:revoke-me",
capability: "evidence_read" as const,
roots: [project],
executables: [],
environment_names: [],
network: "none" as const,
mount: false,
lifetime: "project" as const,
operation_identity: null,
expires_at: null,
};
expect(
await writeProjectPermissionStore(store, project, [grant]),
).toMatchObject({
ok: true,
value: { grants: [grant] },
});
const before = await readFile(store);
const environment = {
...process.env,
REA_PERMISSION_PROJECT_ROOT: project,
REA_PERMISSION_PROJECT_STORE: store,
};
const denied = await runCli(
["policy", "revoke", grant.grant_id, "--json"],
environment,
);
expect(denied.status).toBe(1);
expect(denied.output).toEqual({
error:
"Policy revoke requires confirmation. Rerun interactively or with --yes.",
grant_id: grant.grant_id,
});
expect(await readFile(store)).toEqual(before);
const approved = await runCli(
["policy", "revoke", grant.grant_id, "--yes", "--json"],
environment,
);
expect(approved).toEqual({
status: 0,
output: {
revoked: grant.grant_id,
reload: "Send SIGHUP to the REA MCP process",
},
});
expect(await readProjectPermissionStore(store, project)).toMatchObject({
ok: true,
value: { grants: [] },
});
}, 40_000);
});
const runCli = async (
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
): Promise<{ readonly status: number; readonly output: unknown }> => {
try {
const { stdout } = await execute(
process.execPath,
["scripts/rea.mjs", ...arguments_],
{ cwd: process.cwd(), env: environment },
);
return { status: 0, output: JSON.parse(stdout) };
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
typeof cause.code === "number" &&
"stdout" in cause &&
typeof cause.stdout === "string"
)
return { status: cause.code, output: JSON.parse(cause.stdout) };
throw cause;
}
};