461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import JSON5 from "json5";
|
|
import type { Command } from "commander";
|
|
|
|
import {
|
|
readExecApprovalsSnapshot,
|
|
saveExecApprovals,
|
|
type ExecApprovalsAgent,
|
|
type ExecApprovalsFile,
|
|
} from "../infra/exec-approvals.js";
|
|
import { defaultRuntime } from "../runtime.js";
|
|
import { formatDocsLink } from "../terminal/links.js";
|
|
import { isRich, theme } from "../terminal/theme.js";
|
|
import { renderTable } from "../terminal/table.js";
|
|
import { callGatewayFromCli } from "./gateway-rpc.js";
|
|
import { describeUnknownError } from "./gateway-cli/shared.js";
|
|
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
|
|
import type { NodesRpcOpts } from "./nodes-cli/types.js";
|
|
|
|
type ExecApprovalsSnapshot = {
|
|
path: string;
|
|
exists: boolean;
|
|
hash: string;
|
|
file: ExecApprovalsFile;
|
|
};
|
|
|
|
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
|
node?: string;
|
|
gateway?: boolean;
|
|
file?: string;
|
|
stdin?: boolean;
|
|
agent?: string;
|
|
};
|
|
|
|
function formatAge(msAgo: number) {
|
|
const s = Math.max(0, Math.floor(msAgo / 1000));
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h`;
|
|
const d = Math.floor(h / 24);
|
|
return `${d}d`;
|
|
}
|
|
|
|
async function readStdin(): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
|
if (opts.gateway) return null;
|
|
const raw = opts.node?.trim() ?? "";
|
|
if (!raw) return null;
|
|
return await resolveNodeId(opts as NodesRpcOpts, raw);
|
|
}
|
|
|
|
async function loadSnapshot(
|
|
opts: ExecApprovalsCliOpts,
|
|
nodeId: string | null,
|
|
): Promise<ExecApprovalsSnapshot> {
|
|
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
|
|
const params = nodeId ? { nodeId } : {};
|
|
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
|
return snapshot;
|
|
}
|
|
|
|
function loadSnapshotLocal(): ExecApprovalsSnapshot {
|
|
const snapshot = readExecApprovalsSnapshot();
|
|
return {
|
|
path: snapshot.path,
|
|
exists: snapshot.exists,
|
|
hash: snapshot.hash,
|
|
file: snapshot.file,
|
|
};
|
|
}
|
|
|
|
function saveSnapshotLocal(file: ExecApprovalsFile): ExecApprovalsSnapshot {
|
|
saveExecApprovals(file);
|
|
return loadSnapshotLocal();
|
|
}
|
|
|
|
async function loadSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
|
|
snapshot: ExecApprovalsSnapshot;
|
|
nodeId: string | null;
|
|
source: "gateway" | "node" | "local";
|
|
}> {
|
|
if (!opts.gateway && !opts.node) {
|
|
return { snapshot: loadSnapshotLocal(), nodeId: null, source: "local" };
|
|
}
|
|
const nodeId = await resolveTargetNodeId(opts);
|
|
const snapshot = await loadSnapshot(opts, nodeId);
|
|
return { snapshot, nodeId, source: nodeId ? "node" : "gateway" };
|
|
}
|
|
|
|
function formatCliError(err: unknown): string {
|
|
const msg = describeUnknownError(err);
|
|
return msg.includes("\n") ? msg.split("\n")[0] : msg;
|
|
}
|
|
|
|
function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: string) {
|
|
const rich = isRich();
|
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
|
|
const file = snapshot.file ?? { version: 1 };
|
|
const defaults = file.defaults ?? {};
|
|
const defaultsParts = [
|
|
defaults.security ? `security=${defaults.security}` : null,
|
|
defaults.ask ? `ask=${defaults.ask}` : null,
|
|
defaults.askFallback ? `askFallback=${defaults.askFallback}` : null,
|
|
typeof defaults.autoAllowSkills === "boolean"
|
|
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
|
|
: null,
|
|
].filter(Boolean) as string[];
|
|
const agents = file.agents ?? {};
|
|
const allowlistRows: Array<{ Target: string; Agent: string; Pattern: string; LastUsed: string }> =
|
|
[];
|
|
const now = Date.now();
|
|
for (const [agentId, agent] of Object.entries(agents)) {
|
|
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
for (const entry of allowlist) {
|
|
const pattern = entry?.pattern?.trim() ?? "";
|
|
if (!pattern) continue;
|
|
const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null;
|
|
allowlistRows.push({
|
|
Target: targetLabel,
|
|
Agent: agentId,
|
|
Pattern: pattern,
|
|
LastUsed: lastUsedAt ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` : muted("unknown"),
|
|
});
|
|
}
|
|
}
|
|
|
|
const summaryRows = [
|
|
{ Field: "Target", Value: targetLabel },
|
|
{ Field: "Path", Value: snapshot.path },
|
|
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
|
|
{ Field: "Hash", Value: snapshot.hash },
|
|
{ Field: "Version", Value: String(file.version ?? 1) },
|
|
{ Field: "Socket", Value: file.socket?.path ?? "default" },
|
|
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
|
|
{ Field: "Agents", Value: String(Object.keys(agents).length) },
|
|
{ Field: "Allowlist", Value: String(allowlistRows.length) },
|
|
];
|
|
|
|
defaultRuntime.log(heading("Approvals"));
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Field", header: "Field", minWidth: 8 },
|
|
{ key: "Value", header: "Value", minWidth: 24, flex: true },
|
|
],
|
|
rows: summaryRows,
|
|
}).trimEnd(),
|
|
);
|
|
|
|
if (allowlistRows.length === 0) {
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(muted("No allowlist entries."));
|
|
return;
|
|
}
|
|
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Allowlist"));
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Target", header: "Target", minWidth: 10 },
|
|
{ key: "Agent", header: "Agent", minWidth: 8 },
|
|
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
|
|
{ key: "LastUsed", header: "Last Used", minWidth: 10 },
|
|
],
|
|
rows: allowlistRows,
|
|
}).trimEnd(),
|
|
);
|
|
}
|
|
|
|
async function saveSnapshot(
|
|
opts: ExecApprovalsCliOpts,
|
|
nodeId: string | null,
|
|
file: ExecApprovalsFile,
|
|
baseHash: string,
|
|
): Promise<ExecApprovalsSnapshot> {
|
|
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
|
|
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
|
|
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
|
|
return snapshot;
|
|
}
|
|
|
|
function resolveAgentKey(value?: string | null): string {
|
|
const trimmed = value?.trim() ?? "";
|
|
return trimmed ? trimmed : "*";
|
|
}
|
|
|
|
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
|
const pattern = entry?.pattern?.trim() ?? "";
|
|
return pattern ? pattern : null;
|
|
}
|
|
|
|
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
|
|
const agents = file.agents ?? {};
|
|
const entry = agents[agentKey] ?? {};
|
|
file.agents = agents;
|
|
return entry;
|
|
}
|
|
|
|
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
|
|
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
return (
|
|
!agent.security &&
|
|
!agent.ask &&
|
|
!agent.askFallback &&
|
|
agent.autoAllowSkills === undefined &&
|
|
allowlist.length === 0
|
|
);
|
|
}
|
|
|
|
export function registerExecApprovalsCli(program: Command) {
|
|
const formatExample = (cmd: string, desc: string) =>
|
|
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
|
|
|
|
const approvals = program
|
|
.command("approvals")
|
|
.alias("exec-approvals")
|
|
.description("Manage exec approvals (gateway or node host)")
|
|
.addHelpText(
|
|
"after",
|
|
() =>
|
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
|
|
);
|
|
|
|
const getCmd = approvals
|
|
.command("get")
|
|
.description("Fetch exec approvals snapshot")
|
|
.option("--node <node>", "Target node id/name/IP")
|
|
.option("--gateway", "Force gateway approvals", false)
|
|
.action(async (opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(snapshot));
|
|
return;
|
|
}
|
|
|
|
const muted = (text: string) => (isRich() ? theme.muted(text) : text);
|
|
if (source === "local") {
|
|
defaultRuntime.log(muted("Showing local approvals."));
|
|
defaultRuntime.log("");
|
|
}
|
|
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
|
|
renderApprovalsSnapshot(snapshot, targetLabel);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(getCmd);
|
|
|
|
const setCmd = approvals
|
|
.command("set")
|
|
.description("Replace exec approvals with a JSON file")
|
|
.option("--node <node>", "Target node id/name/IP")
|
|
.option("--gateway", "Force gateway approvals", false)
|
|
.option("--file <path>", "Path to JSON file to upload")
|
|
.option("--stdin", "Read JSON from stdin", false)
|
|
.action(async (opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
if (!opts.file && !opts.stdin) {
|
|
defaultRuntime.error("Provide --file or --stdin.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
if (opts.file && opts.stdin) {
|
|
defaultRuntime.error("Use either --file or --stdin (not both).");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
|
|
if (source === "local") {
|
|
defaultRuntime.log(theme.muted("Writing local approvals."));
|
|
}
|
|
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
|
|
if (!snapshot.hash) {
|
|
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
|
|
let file: ExecApprovalsFile;
|
|
try {
|
|
file = JSON5.parse(raw) as ExecApprovalsFile;
|
|
} catch (err) {
|
|
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
file.version = 1;
|
|
const next =
|
|
source === "local"
|
|
? saveSnapshotLocal(file)
|
|
: await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(next));
|
|
return;
|
|
}
|
|
defaultRuntime.log(theme.muted(`Target: ${targetLabel}`));
|
|
renderApprovalsSnapshot(next, targetLabel);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(setCmd);
|
|
|
|
const allowlist = approvals
|
|
.command("allowlist")
|
|
.description("Edit the per-agent allowlist")
|
|
.addHelpText(
|
|
"after",
|
|
() =>
|
|
`\n${theme.heading("Examples:")}\n${formatExample(
|
|
'clawdbot approvals allowlist add "~/Projects/**/bin/rg"',
|
|
"Allowlist a local binary pattern for the main agent.",
|
|
)}\n${formatExample(
|
|
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
|
|
"Allowlist on a specific node/agent.",
|
|
)}\n${formatExample(
|
|
'clawdbot approvals allowlist add --agent "*" "/usr/bin/uname"',
|
|
"Allowlist for all agents (wildcard).",
|
|
)}\n${formatExample(
|
|
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
|
|
"Remove an allowlist pattern.",
|
|
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
|
|
);
|
|
|
|
const allowlistAdd = allowlist
|
|
.command("add <pattern>")
|
|
.description("Add a glob pattern to an allowlist")
|
|
.option("--node <node>", "Target node id/name/IP")
|
|
.option("--gateway", "Force gateway approvals", false)
|
|
.option("--agent <id>", 'Agent id (defaults to "*")')
|
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
const trimmed = pattern.trim();
|
|
if (!trimmed) {
|
|
defaultRuntime.error("Pattern required.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
|
|
if (source === "local") {
|
|
defaultRuntime.log(theme.muted("Writing local approvals."));
|
|
}
|
|
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
|
|
if (!snapshot.hash) {
|
|
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const file = snapshot.file ?? { version: 1 };
|
|
file.version = 1;
|
|
const agentKey = resolveAgentKey(opts.agent);
|
|
const agent = ensureAgent(file, agentKey);
|
|
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
|
|
defaultRuntime.log("Already allowlisted.");
|
|
return;
|
|
}
|
|
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
|
agent.allowlist = allowlistEntries;
|
|
file.agents = { ...file.agents, [agentKey]: agent };
|
|
const next =
|
|
source === "local"
|
|
? saveSnapshotLocal(file)
|
|
: await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(next));
|
|
return;
|
|
}
|
|
defaultRuntime.log(theme.muted(`Target: ${targetLabel}`));
|
|
renderApprovalsSnapshot(next, targetLabel);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(allowlistAdd);
|
|
|
|
const allowlistRemove = allowlist
|
|
.command("remove <pattern>")
|
|
.description("Remove a glob pattern from an allowlist")
|
|
.option("--node <node>", "Target node id/name/IP")
|
|
.option("--gateway", "Force gateway approvals", false)
|
|
.option("--agent <id>", 'Agent id (defaults to "*")')
|
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
const trimmed = pattern.trim();
|
|
if (!trimmed) {
|
|
defaultRuntime.error("Pattern required.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
|
|
if (source === "local") {
|
|
defaultRuntime.log(theme.muted("Writing local approvals."));
|
|
}
|
|
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
|
|
if (!snapshot.hash) {
|
|
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
const file = snapshot.file ?? { version: 1 };
|
|
file.version = 1;
|
|
const agentKey = resolveAgentKey(opts.agent);
|
|
const agent = ensureAgent(file, agentKey);
|
|
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
const nextEntries = allowlistEntries.filter(
|
|
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
|
|
);
|
|
if (nextEntries.length === allowlistEntries.length) {
|
|
defaultRuntime.log("Pattern not found.");
|
|
return;
|
|
}
|
|
if (nextEntries.length === 0) {
|
|
delete agent.allowlist;
|
|
} else {
|
|
agent.allowlist = nextEntries;
|
|
}
|
|
if (isEmptyAgent(agent)) {
|
|
const agents = { ...file.agents };
|
|
delete agents[agentKey];
|
|
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
|
|
} else {
|
|
file.agents = { ...file.agents, [agentKey]: agent };
|
|
}
|
|
const next =
|
|
source === "local"
|
|
? saveSnapshotLocal(file)
|
|
: await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(next));
|
|
return;
|
|
}
|
|
defaultRuntime.log(theme.muted(`Target: ${targetLabel}`));
|
|
renderApprovalsSnapshot(next, targetLabel);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(allowlistRemove);
|
|
}
|