mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 19:21:45 +00:00
feat: add exec approvals tooling and service status
This commit is contained in:
243
src/cli/exec-approvals-cli.ts
Normal file
243
src/cli/exec-approvals-cli.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import fs from "node:fs/promises";
|
||||
import JSON5 from "json5";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { callGatewayFromCli } from "./gateway-rpc.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;
|
||||
file?: string;
|
||||
stdin?: boolean;
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
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 : "default";
|
||||
}
|
||||
|
||||
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 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 (defaults to gateway)")
|
||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(getCmd);
|
||||
|
||||
const setCmd = approvals
|
||||
.command("set")
|
||||
.description("Replace exec approvals with a JSON file")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--file <path>", "Path to JSON file to upload")
|
||||
.option("--stdin", "Read JSON from stdin", false)
|
||||
.action(async (opts: ExecApprovalsCliOpts) => {
|
||||
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 nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
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 = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(setCmd);
|
||||
|
||||
const allowlist = approvals
|
||||
.command("allowlist")
|
||||
.description("Edit the per-agent allowlist");
|
||||
|
||||
const allowlistAdd = allowlist
|
||||
.command("add <pattern>")
|
||||
.description("Add a glob pattern to an allowlist")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
defaultRuntime.error("Pattern required.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
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 = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(allowlistAdd);
|
||||
|
||||
const allowlistRemove = allowlist
|
||||
.command("remove <pattern>")
|
||||
.description("Remove a glob pattern from an allowlist")
|
||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
defaultRuntime.error("Pattern required.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const nodeId = await resolveTargetNodeId(opts);
|
||||
const snapshot = await loadSnapshot(opts, nodeId);
|
||||
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 = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||
defaultRuntime.log(payload);
|
||||
});
|
||||
nodesCallOpts(allowlistRemove);
|
||||
}
|
||||
Reference in New Issue
Block a user