225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
import { Cli, z } from "incur";
|
|
import { createInterface } from "node:readline/promises";
|
|
|
|
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
|
import {
|
|
readProjectPermissionStore,
|
|
revokeProjectPermissionGrant,
|
|
} from "./application/ProjectPermissionStore.js";
|
|
import { logCliCommand } from "./cliLogging.js";
|
|
import { parseConfig, type AppConfig } from "./config.js";
|
|
import {
|
|
PermissionRequiredError,
|
|
projectAnalysisError,
|
|
} from "./domain/errors.js";
|
|
import type { PermissionCapability } from "./domain/permissionPolicy.js";
|
|
import type { Logger } from "./logger.js";
|
|
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
|
|
|
const capabilitySchema = z.enum([
|
|
"process_capture",
|
|
"browser_observe",
|
|
"electron_observe",
|
|
"evidence_read",
|
|
"evidence_write",
|
|
"investigation_input",
|
|
"investigation_workspace_read",
|
|
"investigation_workspace_write",
|
|
"snapshot_read",
|
|
"snapshot_write",
|
|
"artifact_extract",
|
|
"native_mount",
|
|
"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>,
|
|
logger: Logger,
|
|
): void => {
|
|
cli.command(CLI_COMMANDS.policy, {
|
|
description: "Inspect, explain, or revoke unified local permission grants",
|
|
args: z.object({
|
|
action: z.enum(["status", "list", "explain", "revoke"]),
|
|
value: z.string().optional(),
|
|
}),
|
|
options: z.object({
|
|
root: z.string().optional(),
|
|
executable: z.string().optional(),
|
|
environmentNames: z.array(z.string()).default([]),
|
|
origins: z.array(z.string()).default([]),
|
|
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", yes: "y" },
|
|
run: ({ agent, args, formatExplicit, options }) =>
|
|
logCliCommand(logger, "policy", async () => {
|
|
const config = parseConfig(process.env);
|
|
if (!config.ok) return failure(config.error);
|
|
if (args.action === "status")
|
|
return {
|
|
reload: "SIGHUP",
|
|
restart_required: false,
|
|
administrator_ceilings: config.value.permissionCeilings,
|
|
project_store: config.value.permissionProjectStore ?? null,
|
|
};
|
|
if (args.action === "list")
|
|
return readProjectGrants(config.value).then((result) =>
|
|
result.ok ? { grants: result.grants } : result.error,
|
|
);
|
|
if (args.action === "revoke") {
|
|
if (args.value === undefined)
|
|
return { error: "Grant ID is required for policy revoke" };
|
|
const { permissionProjectRoot, permissionProjectStore } =
|
|
config.value;
|
|
if (
|
|
permissionProjectRoot === undefined ||
|
|
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,
|
|
args.value,
|
|
);
|
|
return revoked.ok && revoked.value
|
|
? {
|
|
revoked: args.value,
|
|
reload: "Send SIGHUP to the REA MCP process",
|
|
}
|
|
: revoked.ok
|
|
? { error: "Grant was not found", grant_id: args.value }
|
|
: { error: revoked.error.message };
|
|
}
|
|
const capability = capabilitySchema.safeParse(args.value);
|
|
if (!capability.success)
|
|
return {
|
|
error: "A registered capability is required for policy explain",
|
|
};
|
|
return explain(config.value, capability.data, options);
|
|
}),
|
|
});
|
|
};
|
|
|
|
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,
|
|
options: {
|
|
readonly root?: string | undefined;
|
|
readonly executable?: string | undefined;
|
|
readonly environmentNames: readonly string[];
|
|
readonly origins: readonly string[];
|
|
readonly network: "none" | "loopback" | "external";
|
|
readonly mount: boolean;
|
|
readonly write: boolean;
|
|
},
|
|
) => {
|
|
const authority = await loadConfiguredPermissionAuthority(config);
|
|
if (!authority.ok) return failure(authority.error);
|
|
const evaluated = await authority.value.explain(
|
|
{
|
|
capability,
|
|
roots: options.root === undefined ? [] : [options.root],
|
|
executables: options.executable === undefined ? [] : [options.executable],
|
|
environment_names: options.environmentNames,
|
|
origins: options.origins,
|
|
network: options.network,
|
|
mount: options.mount,
|
|
operation_identity: `policy_explain:${capability}`,
|
|
},
|
|
options.write ? "write" : "read",
|
|
);
|
|
return evaluated.ok
|
|
? { allowed: true, grant_id: evaluated.value.grant_id }
|
|
: evaluated.error instanceof PermissionRequiredError
|
|
? { allowed: false, error: projectAnalysisError(evaluated.error) }
|
|
: { allowed: false, error: evaluated.error.message };
|
|
};
|
|
|
|
const readProjectGrants = async (config: {
|
|
readonly permissionProjectRoot: string | undefined;
|
|
readonly permissionProjectStore: string | undefined;
|
|
}) => {
|
|
if (
|
|
config.permissionProjectRoot === undefined ||
|
|
config.permissionProjectStore === undefined
|
|
)
|
|
return {
|
|
ok: false as const,
|
|
error: { error: "Project policy is not configured" },
|
|
};
|
|
const store = await readProjectPermissionStore(
|
|
config.permissionProjectStore,
|
|
config.permissionProjectRoot,
|
|
);
|
|
return store.ok
|
|
? {
|
|
ok: true as const,
|
|
grants: store.value?.grants ?? [],
|
|
path: config.permissionProjectStore,
|
|
root: config.permissionProjectRoot,
|
|
}
|
|
: { ok: false as const, error: { error: store.error.message } };
|
|
};
|
|
|
|
const failure = (error: Parameters<typeof projectAnalysisError>[0]) => ({
|
|
error: "Policy command failed",
|
|
...projectAnalysisError(error),
|
|
});
|