mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 15:01:35 +00:00
1315 lines
45 KiB
TypeScript
1315 lines
45 KiB
TypeScript
// CLI for reading and mutating exec approval allowlists locally, via gateway, or via node.
|
|
import fs from "node:fs/promises";
|
|
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
|
import type { Command } from "commander";
|
|
import JSON5 from "json5";
|
|
import {
|
|
isWellFormedApprovalId,
|
|
type ApprovalDecision,
|
|
type ApprovalGetResult,
|
|
type ApprovalKind,
|
|
type ApprovalResolveResult,
|
|
type ApprovalSnapshot,
|
|
} from "../../packages/gateway-protocol/src/index.js";
|
|
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
|
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
|
|
import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js";
|
|
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
|
import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js";
|
|
import { ADMIN_SCOPE, APPROVALS_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
|
|
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import {
|
|
collectExecPolicyScopeSnapshots,
|
|
SESSION_EXEC_OVERRIDES_NOTE,
|
|
type ExecPolicyScopeSnapshot,
|
|
} from "../infra/exec-approvals-effective.js";
|
|
import {
|
|
mergeExecApprovalsSocketDefaults,
|
|
normalizeExecApprovals,
|
|
readExecApprovalsSnapshot,
|
|
updateExecApprovals,
|
|
type ExecApprovalsAgent,
|
|
type ExecApprovalsDefaults,
|
|
type ExecApprovalsFile,
|
|
} from "../infra/exec-approvals.js";
|
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
|
import { defaultRuntime } from "../runtime.js";
|
|
import { callGatewayFromCli } from "./gateway-rpc.js";
|
|
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
|
|
import type { NodesRpcOpts } from "./nodes-cli/types.js";
|
|
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
|
|
|
|
type FileExecApprovalsSnapshot = {
|
|
path: string;
|
|
exists: boolean;
|
|
hash: string;
|
|
file: ExecApprovalsFile;
|
|
resolvedDefaults?: Required<ExecApprovalsDefaults>;
|
|
};
|
|
|
|
type NativeExecApprovalAction = "allow" | "deny" | "prompt";
|
|
type NativeExecApprovalRule = {
|
|
pattern: string;
|
|
action: NativeExecApprovalAction;
|
|
shells?: string[];
|
|
description?: string;
|
|
enabled?: boolean;
|
|
};
|
|
type NativeExecApprovalPolicy = {
|
|
defaultAction?: NativeExecApprovalAction;
|
|
rules: NativeExecApprovalRule[];
|
|
};
|
|
type NativeExecApprovalsSnapshot =
|
|
| {
|
|
enabled: true;
|
|
hash: string;
|
|
baseHash?: string;
|
|
defaultAction: NativeExecApprovalAction;
|
|
rules: NativeExecApprovalRule[];
|
|
constraints?: Record<string, boolean>;
|
|
}
|
|
| { enabled: false; message?: string };
|
|
type ExecApprovalsSnapshot = FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot;
|
|
|
|
type ConfigSnapshotLike = {
|
|
config?: OpenClawConfig;
|
|
};
|
|
type ConfigLoadResult = {
|
|
config: OpenClawConfig | null;
|
|
timedOut: boolean;
|
|
};
|
|
type ApprovalsTargetSource = "gateway" | "node" | "local";
|
|
type EffectivePolicyReport = {
|
|
scopes: ExecPolicyScopeSnapshot[];
|
|
note?: string;
|
|
};
|
|
const APPROVALS_GET_DEFAULT_TIMEOUT_MS = 60_000;
|
|
const EXEC_APPROVALS_STDIN_MAX_BYTES = 1024 * 1024;
|
|
|
|
type ExecApprovalsCliOpts = NodesRpcOpts & {
|
|
node?: string;
|
|
gateway?: boolean;
|
|
file?: string;
|
|
stdin?: boolean;
|
|
agent?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
type PendingApprovalCliEntry = {
|
|
id: string;
|
|
kind: ApprovalKind;
|
|
agentId: string | null;
|
|
sessionKey: string | null;
|
|
createdAtMs: number;
|
|
expiresAtMs: number;
|
|
summary: string;
|
|
};
|
|
|
|
const APPROVAL_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
|
|
const PENDING_APPROVAL_SUMMARY_MAX_LENGTH = 96;
|
|
const APPROVAL_ID_TOKEN_PREFIX = "id64_";
|
|
const APPROVAL_TERMINAL_UNSAFE_CHAR =
|
|
/^[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u;
|
|
|
|
async function readStdin(
|
|
stream: NodeJS.ReadableStream = process.stdin,
|
|
maxBytes = EXEC_APPROVALS_STDIN_MAX_BYTES,
|
|
): Promise<string> {
|
|
const bytes = await readByteStreamWithLimit(stream, {
|
|
maxBytes,
|
|
onOverflow: ({ maxBytes: limit }) => new Error(`Exec approvals stdin exceeds ${limit} bytes.`),
|
|
});
|
|
return bytes.toString("utf8");
|
|
}
|
|
|
|
async function readApprovalsFile(filePath: string): Promise<string> {
|
|
// Explicit CLI file inputs have historically followed symlinks and readable
|
|
// special files. Pin that opened target while bounding the bytes consumed.
|
|
const handle = await fs.open(filePath, "r");
|
|
try {
|
|
return (await readFileDescriptorBounded(handle.fd, EXEC_APPROVALS_STDIN_MAX_BYTES)).toString(
|
|
"utf8",
|
|
);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
|
|
if (opts.gateway) {
|
|
return null;
|
|
}
|
|
const raw = normalizeOptionalString(opts.node) ?? "";
|
|
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 isFileApprovalsSnapshot(
|
|
snapshot: ExecApprovalsSnapshot,
|
|
): snapshot is FileExecApprovalsSnapshot {
|
|
return "file" in snapshot;
|
|
}
|
|
|
|
function isNativeApprovalsSnapshot(
|
|
snapshot: ExecApprovalsSnapshot,
|
|
): snapshot is NativeExecApprovalsSnapshot {
|
|
return "enabled" in snapshot;
|
|
}
|
|
|
|
function parseNativeAction(value: unknown, label: string): NativeExecApprovalAction {
|
|
if (value === "allow" || value === "deny" || value === "prompt") {
|
|
return value;
|
|
}
|
|
return exitWithError(`${label} must be allow, deny, or prompt.`);
|
|
}
|
|
|
|
function normalizeNativePolicyInput(value: unknown): NativeExecApprovalPolicy {
|
|
if (!isRecord(value)) {
|
|
exitWithError("Host-native exec approvals JSON must be an object.");
|
|
}
|
|
const unknownKeys = Object.keys(value).filter(
|
|
(key) => key !== "defaultAction" && key !== "rules",
|
|
);
|
|
if (unknownKeys.length > 0) {
|
|
exitWithError(`Unknown host-native exec approvals field: ${unknownKeys[0]}.`);
|
|
}
|
|
const defaultAction =
|
|
value.defaultAction === undefined
|
|
? undefined
|
|
: parseNativeAction(value.defaultAction, "defaultAction");
|
|
if (!Array.isArray(value.rules)) {
|
|
exitWithError("Host-native exec approvals rules must be an array.");
|
|
}
|
|
const rules = value.rules?.map((entry, index) => {
|
|
if (!isRecord(entry)) {
|
|
exitWithError(`Host-native exec approval rule ${index + 1} must be an object.`);
|
|
}
|
|
const unknownRuleKeys = Object.keys(entry).filter(
|
|
(key) =>
|
|
key !== "pattern" &&
|
|
key !== "action" &&
|
|
key !== "shells" &&
|
|
key !== "description" &&
|
|
key !== "enabled",
|
|
);
|
|
if (unknownRuleKeys.length > 0) {
|
|
exitWithError(
|
|
`Unknown host-native exec approval rule ${index + 1} field: ${unknownRuleKeys[0]}.`,
|
|
);
|
|
}
|
|
const pattern = normalizeOptionalString(entry.pattern);
|
|
if (!pattern) {
|
|
exitWithError(`Host-native exec approval rule ${index + 1} requires pattern.`);
|
|
}
|
|
const action = parseNativeAction(
|
|
entry.action,
|
|
`Host-native exec approval rule ${index + 1} action`,
|
|
);
|
|
let shells: string[] | undefined;
|
|
if (entry.shells !== undefined) {
|
|
if (!Array.isArray(entry.shells)) {
|
|
exitWithError(`Host-native exec approval rule ${index + 1} shells must be an array.`);
|
|
}
|
|
shells = entry.shells.map((shell) => {
|
|
const normalized = typeof shell === "string" ? shell.trim() : "";
|
|
if (!normalized) {
|
|
exitWithError(
|
|
`Host-native exec approval rule ${index + 1} shells must be non-empty strings.`,
|
|
);
|
|
}
|
|
return normalized;
|
|
});
|
|
}
|
|
if (entry.description !== undefined && typeof entry.description !== "string") {
|
|
exitWithError(`Host-native exec approval rule ${index + 1} description must be a string.`);
|
|
}
|
|
if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") {
|
|
exitWithError(`Host-native exec approval rule ${index + 1} enabled must be a boolean.`);
|
|
}
|
|
return {
|
|
pattern,
|
|
action,
|
|
...(shells ? { shells } : {}),
|
|
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
...(entry.enabled !== undefined ? { enabled: entry.enabled } : {}),
|
|
};
|
|
});
|
|
return {
|
|
...(defaultAction ? { defaultAction } : {}),
|
|
rules,
|
|
};
|
|
}
|
|
|
|
async function saveSnapshotLocal(
|
|
file: ExecApprovalsFile,
|
|
baseHash: string,
|
|
): Promise<ExecApprovalsSnapshot> {
|
|
const snapshot = await updateExecApprovals({
|
|
baseHash,
|
|
update: (current) =>
|
|
mergeExecApprovalsSocketDefaults({
|
|
normalized: normalizeExecApprovals(file),
|
|
current,
|
|
}),
|
|
});
|
|
if (!snapshot) {
|
|
throw new Error("Exec approvals changed; reload and retry.");
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
async function loadSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
|
|
snapshot: ExecApprovalsSnapshot;
|
|
nodeId: string | null;
|
|
source: ApprovalsTargetSource;
|
|
}> {
|
|
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 exitWithError(message: string): never {
|
|
defaultRuntime.error(message);
|
|
defaultRuntime.exit(1);
|
|
throw new Error(message);
|
|
}
|
|
|
|
function requireTrimmedNonEmpty(value: string, message: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
exitWithError(message);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
async function loadWritableSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
|
|
snapshot: FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot;
|
|
nodeId: string | null;
|
|
source: ApprovalsTargetSource;
|
|
targetLabel: string;
|
|
baseHash: string;
|
|
kind: "file" | "native";
|
|
}> {
|
|
// Writes carry the base hash so gateway/node updates can reject stale snapshots.
|
|
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 (isNativeApprovalsSnapshot(snapshot) && !snapshot.enabled) {
|
|
exitWithError(
|
|
"Host-native exec approvals are disabled on this node and cannot be configured remotely.",
|
|
);
|
|
}
|
|
const baseHash = "hash" in snapshot ? snapshot.hash : undefined;
|
|
if (!baseHash) {
|
|
exitWithError("Exec approvals hash missing; reload and retry.");
|
|
}
|
|
const kind = isNativeApprovalsSnapshot(snapshot) ? "native" : "file";
|
|
return { snapshot, nodeId, source, targetLabel, baseHash, kind };
|
|
}
|
|
|
|
type SaveSnapshotTargetedParams = {
|
|
opts: ExecApprovalsCliOpts;
|
|
source: ApprovalsTargetSource;
|
|
nodeId: string | null;
|
|
baseHash: string;
|
|
targetLabel: string;
|
|
} & ({ file: ExecApprovalsFile } | { native: NativeExecApprovalPolicy });
|
|
|
|
async function saveSnapshotTargeted(params: SaveSnapshotTargetedParams): Promise<void> {
|
|
let next: ExecApprovalsSnapshot;
|
|
if ("native" in params) {
|
|
if (params.source !== "node" || !params.nodeId) {
|
|
exitWithError("Host-native exec approvals can only target a node.");
|
|
}
|
|
await callGatewayFromCli("exec.approvals.node.set", params.opts, {
|
|
nodeId: params.nodeId,
|
|
native: params.native,
|
|
baseHash: params.baseHash,
|
|
});
|
|
next = await loadSnapshot(params.opts, params.nodeId);
|
|
} else if (params.source === "local") {
|
|
next = await saveSnapshotLocal(params.file, params.baseHash);
|
|
} else {
|
|
next = await saveSnapshot(params.opts, params.nodeId, params.file, params.baseHash);
|
|
}
|
|
if (params.opts.json) {
|
|
defaultRuntime.writeJson(next, 0);
|
|
return;
|
|
}
|
|
defaultRuntime.log(theme.muted(`Target: ${params.targetLabel}`));
|
|
renderApprovalsSnapshot(next, params.targetLabel);
|
|
}
|
|
|
|
function formatCliError(err: unknown): string {
|
|
const msg = formatErrorMessage(err);
|
|
const firstLine = msg.includes("\n") ? msg.split("\n")[0] : msg;
|
|
const safe = sanitizeForLog(expectDefined(firstLine, "exec approvals cli first line"));
|
|
return safe.length > 300 ? `${truncateUtf16Safe(safe, 300)}...` : safe;
|
|
}
|
|
|
|
function isApprovalDecision(value: string): value is ApprovalDecision {
|
|
return (APPROVAL_DECISIONS as readonly string[]).includes(value);
|
|
}
|
|
|
|
function shortenPendingApprovalSummary(value: string): string {
|
|
if (value.length <= PENDING_APPROVAL_SUMMARY_MAX_LENGTH) {
|
|
return value;
|
|
}
|
|
return `${truncateUtf16Safe(value, PENDING_APPROVAL_SUMMARY_MAX_LENGTH - 3)}...`;
|
|
}
|
|
|
|
function escapeApprovalTextForTerminal(value: string): string {
|
|
let escaped = "";
|
|
for (const char of value) {
|
|
if (char === "\\") {
|
|
escaped += "\\\\";
|
|
continue;
|
|
}
|
|
if (APPROVAL_TERMINAL_UNSAFE_CHAR.test(char)) {
|
|
escaped += `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`;
|
|
continue;
|
|
}
|
|
escaped += char;
|
|
}
|
|
return escaped;
|
|
}
|
|
|
|
// Gateway-minted ids are UUID-shaped, but explicit ids from an agent host are
|
|
// stored verbatim, so hostile ids (ANSI escapes, controls) are possible. Show
|
|
// the raw id when it is terminal-safe; wrap only unsafe ids in a copyable
|
|
// token that `resolve` decodes.
|
|
// Leading hyphen excluded: a raw `-x`/`--flag` id could not be pasted into
|
|
// `approvals resolve <id>` without Commander eating it as an option.
|
|
const APPROVAL_ID_TERMINAL_SAFE_RE = /^[A-Za-z0-9._:][A-Za-z0-9._:-]{0,127}$/;
|
|
|
|
// Tokens encode UTF-16 code units, not UTF-8: ids are opaque JS strings and
|
|
// UTF-8 replaces lone surrogates with U+FFFD, which would let two distinct
|
|
// ids collide into one token on this remote-execution surface.
|
|
function formatApprovalIdForTerminal(value: string): string {
|
|
if (APPROVAL_ID_TERMINAL_SAFE_RE.test(value)) {
|
|
return value;
|
|
}
|
|
return `${APPROVAL_ID_TOKEN_PREFIX}${Buffer.from(value, "utf16le").toString("base64url")}`;
|
|
}
|
|
|
|
function decodeDisplayedApprovalId(value: string): string | null {
|
|
if (!value.startsWith(APPROVAL_ID_TOKEN_PREFIX)) {
|
|
return null;
|
|
}
|
|
const encoded = value.slice(APPROVAL_ID_TOKEN_PREFIX.length);
|
|
if (!encoded || !/^[a-zA-Z0-9_-]+$/.test(encoded)) {
|
|
return null;
|
|
}
|
|
const decoded = Buffer.from(encoded, "base64url").toString("utf16le");
|
|
return Buffer.from(decoded, "utf16le").toString("base64url") === encoded ? decoded : null;
|
|
}
|
|
|
|
function readPendingApprovalEntry(
|
|
value: unknown,
|
|
kind: ApprovalKind,
|
|
): PendingApprovalCliEntry | null {
|
|
if (!isRecord(value) || !isRecord(value.request)) {
|
|
return null;
|
|
}
|
|
// Approval ids are opaque and stored verbatim by the gateway — never trim
|
|
// them, or two ids differing only in whitespace collapse into one display
|
|
// form and resolving could target the wrong request. Whitespace-bearing ids
|
|
// fail the terminal-safe charset and render as exact-round-trip id64 tokens.
|
|
// Ill-formed (lone-surrogate) ids are skipped outright: the unified
|
|
// approval.get/resolve schema rejects them, so listing one would advertise
|
|
// a token that can never be resolved.
|
|
const id = typeof value.id === "string" && isWellFormedApprovalId(value.id) ? value.id : null;
|
|
const createdAtMs = value.createdAtMs;
|
|
const expiresAtMs = value.expiresAtMs;
|
|
if (
|
|
!id ||
|
|
typeof createdAtMs !== "number" ||
|
|
!Number.isFinite(createdAtMs) ||
|
|
typeof expiresAtMs !== "number" ||
|
|
!Number.isFinite(expiresAtMs)
|
|
) {
|
|
return null;
|
|
}
|
|
const request = value.request;
|
|
const agentId = normalizeOptionalString(request.agentId) ?? null;
|
|
const sessionKey = normalizeOptionalString(request.sessionKey) ?? null;
|
|
const command = typeof request.command === "string" && request.command ? request.command : null;
|
|
const title = typeof request.title === "string" && request.title ? request.title : null;
|
|
const description =
|
|
typeof request.description === "string" && request.description ? request.description : null;
|
|
const prose = title && description ? `${title}: ${description}` : (title ?? description);
|
|
// System-agent approvals stay on their reviewer-safe presentation (title,
|
|
// description); the raw operation is host-local by contract and must not
|
|
// leak into terminals, scripts, or logs.
|
|
const summarySource =
|
|
kind === "exec"
|
|
? command
|
|
: kind === "plugin" && command
|
|
? `${prose ? `${prose} — ` : ""}Command: ${command}`
|
|
: prose;
|
|
return {
|
|
id,
|
|
kind,
|
|
agentId,
|
|
sessionKey,
|
|
createdAtMs,
|
|
expiresAtMs,
|
|
summary: summarySource ?? "(summary unavailable)",
|
|
};
|
|
}
|
|
|
|
function readPendingApprovalList(value: unknown, kind: ApprovalKind): PendingApprovalCliEntry[] {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`Invalid ${kind} approval list response.`);
|
|
}
|
|
return value.flatMap((entry) => {
|
|
const parsed = readPendingApprovalEntry(entry, kind);
|
|
return parsed ? [parsed] : [];
|
|
});
|
|
}
|
|
|
|
async function loadPendingApprovals(
|
|
opts: ExecApprovalsCliOpts,
|
|
): Promise<PendingApprovalCliEntry[]> {
|
|
// The owner-specific list methods retain requester filtering unless the caller is an admin.
|
|
// Request admin explicitly so this operator command cannot silently omit live approvals.
|
|
const listCall = (method: string) =>
|
|
callGatewayFromCli(method, opts, {}, { scopes: [ADMIN_SCOPE] });
|
|
const [exec, plugin, systemAgent] = await Promise.all([
|
|
listCall("exec.approval.list"),
|
|
listCall("plugin.approval.list"),
|
|
listCall("openclaw.approval.list"),
|
|
]);
|
|
return [
|
|
...readPendingApprovalList(exec, "exec"),
|
|
...readPendingApprovalList(plugin, "plugin"),
|
|
...readPendingApprovalList(systemAgent, "system-agent"),
|
|
].toSorted((a, b) => b.createdAtMs - a.createdAtMs);
|
|
}
|
|
|
|
function formatPendingAgentSession(entry: PendingApprovalCliEntry): string {
|
|
const parts = [entry.agentId, entry.sessionKey].filter((value): value is string =>
|
|
Boolean(value),
|
|
);
|
|
return parts.length > 0 ? escapeApprovalTextForTerminal(parts.join(" / ")) : "-";
|
|
}
|
|
|
|
function renderPendingApprovals(entries: PendingApprovalCliEntry[]): void {
|
|
if (entries.length === 0) {
|
|
defaultRuntime.log(theme.muted("No pending approvals."));
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
defaultRuntime.log(`${theme.heading("Pending approvals")} ${theme.muted(`(${entries.length})`)}`);
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: getTerminalTableWidth(),
|
|
columns: [
|
|
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
|
{ key: "Kind", header: "Kind", minWidth: 12 },
|
|
{ key: "AgentSession", header: "Agent / Session", minWidth: 16, flex: true },
|
|
{ key: "Requested", header: "Requested", minWidth: 12 },
|
|
{ key: "Expires", header: "Expires In", minWidth: 10 },
|
|
{ key: "Summary", header: "Command / Summary", minWidth: 20, flex: true },
|
|
],
|
|
rows: entries.map((entry) => {
|
|
const summary = escapeApprovalTextForTerminal(entry.summary);
|
|
return {
|
|
ID: formatApprovalIdForTerminal(entry.id),
|
|
Kind: entry.kind,
|
|
AgentSession: formatPendingAgentSession(entry),
|
|
Requested: formatTimeAgo(Math.max(0, now - entry.createdAtMs)),
|
|
Expires: formatTimeAgo(Math.max(0, entry.expiresAtMs - now), { suffix: false }),
|
|
Summary: shortenPendingApprovalSummary(summary),
|
|
};
|
|
}),
|
|
}).trimEnd(),
|
|
);
|
|
defaultRuntime.log(theme.heading("Full request text"));
|
|
for (const entry of entries) {
|
|
defaultRuntime.log(
|
|
`${formatApprovalIdForTerminal(entry.id)}: ${escapeApprovalTextForTerminal(entry.summary)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function approvalRecordedDecision(approval: ApprovalSnapshot): ApprovalDecision | null {
|
|
return "decision" in approval && isApprovalDecision(approval.decision) ? approval.decision : null;
|
|
}
|
|
|
|
function formatResolver(approval: ApprovalResolveResult["approval"]): string {
|
|
const resolver = approval.resolver;
|
|
if (!resolver) {
|
|
return "unknown resolver";
|
|
}
|
|
return resolver.id
|
|
? `${resolver.kind}:${escapeApprovalTextForTerminal(resolver.id)}`
|
|
: resolver.kind;
|
|
}
|
|
|
|
function describeTerminalApprovalFailure(approval: ApprovalResolveResult["approval"]): string {
|
|
const id = formatApprovalIdForTerminal(approval.id);
|
|
if (approval.status === "expired") {
|
|
return `Approval ${id} expired.`;
|
|
}
|
|
if (approval.status === "cancelled") {
|
|
return `Approval ${id} was cancelled (${approval.reason}).`;
|
|
}
|
|
return `Approval ${id} did not settle to a recorded decision.`;
|
|
}
|
|
|
|
async function resolvePendingApproval(
|
|
idInput: string,
|
|
decisionInput: string,
|
|
opts: ExecApprovalsCliOpts,
|
|
): Promise<void> {
|
|
// Never trim the id: `pending --json` emits ids verbatim, and a
|
|
// whitespace-bearing id fed back through a script must target exactly that
|
|
// approval, not its trimmed sibling.
|
|
if (idInput.length === 0) {
|
|
exitWithError("Approval id required.");
|
|
}
|
|
const rawId = idInput;
|
|
const decision = requireTrimmedNonEmpty(decisionInput, "Decision required.");
|
|
if (!isApprovalDecision(decision)) {
|
|
exitWithError(`Decision must be one of: ${APPROVAL_DECISIONS.join(", ")}.`);
|
|
}
|
|
const reason = opts.reason === undefined ? null : normalizeOptionalString(opts.reason);
|
|
if (opts.reason !== undefined && !reason) {
|
|
exitWithError("Reason must not be empty.");
|
|
}
|
|
|
|
// No explicit device identity: operator.admin authorizes resolution on its
|
|
// own (canReviewOperatorApproval), and forcing a local identity onto a
|
|
// loopback token/password session can trigger pairing for an otherwise
|
|
// authorized credential.
|
|
const approvalCallOptions = {
|
|
scopes: [ADMIN_SCOPE, APPROVALS_SCOPE] as OperatorScope[],
|
|
};
|
|
|
|
const lookupOne = async (id: string, tolerateNotFound = false) => {
|
|
try {
|
|
return (await callGatewayFromCli(
|
|
"approval.get",
|
|
opts,
|
|
{ id },
|
|
approvalCallOptions,
|
|
)) as ApprovalGetResult;
|
|
} catch (error) {
|
|
if (
|
|
tolerateNotFound &&
|
|
formatErrorMessage(error).toLowerCase().includes("approval not found")
|
|
) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const decodedId = decodeDisplayedApprovalId(rawId);
|
|
let id = rawId;
|
|
let lookup: ApprovalGetResult;
|
|
if (decodedId && decodedId !== rawId) {
|
|
const [rawLookup, decodedLookup] = await Promise.all([
|
|
lookupOne(rawId, true),
|
|
lookupOne(decodedId, true),
|
|
]);
|
|
if (rawLookup && decodedLookup) {
|
|
exitWithError(
|
|
"Approval id is ambiguous: it matches both a raw id and a displayed id token. This CLI cannot resolve it safely.",
|
|
);
|
|
}
|
|
if (rawLookup) {
|
|
lookup = rawLookup;
|
|
} else if (decodedLookup) {
|
|
id = decodedId;
|
|
lookup = decodedLookup;
|
|
} else {
|
|
exitWithError("Approval not found.");
|
|
}
|
|
} else {
|
|
lookup = expectDefined(await lookupOne(rawId), "approval lookup result");
|
|
}
|
|
const displayId = formatApprovalIdForTerminal(id);
|
|
const current = lookup.approval;
|
|
if (current.status === "pending") {
|
|
const allowedDecisions = current.presentation.allowedDecisions as readonly ApprovalDecision[];
|
|
if (!allowedDecisions.includes(decision)) {
|
|
exitWithError(
|
|
`Decision ${decision} is not allowed for ${current.presentation.kind} approvals; allowed decisions: ${allowedDecisions.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const result = (await callGatewayFromCli(
|
|
"approval.resolve",
|
|
opts,
|
|
{
|
|
id,
|
|
kind: current.presentation.kind,
|
|
decision,
|
|
},
|
|
approvalCallOptions,
|
|
)) as ApprovalResolveResult;
|
|
const recordedDecision = approvalRecordedDecision(result.approval);
|
|
if (!recordedDecision) {
|
|
exitWithError(describeTerminalApprovalFailure(result.approval));
|
|
}
|
|
if (recordedDecision !== decision) {
|
|
exitWithError(
|
|
`Approval ${displayId} was already resolved with ${recordedDecision} by ${formatResolver(result.approval)}.`,
|
|
);
|
|
}
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson(
|
|
{
|
|
...result,
|
|
alreadyResolved: !result.applied,
|
|
...(reason ? { cliReason: reason } : {}),
|
|
},
|
|
0,
|
|
);
|
|
return;
|
|
}
|
|
const settled = result.applied
|
|
? `resolved ${recordedDecision}`
|
|
: `already resolved (same decision: ${recordedDecision})`;
|
|
const reasonSuffix = reason
|
|
? `; CLI reason: ${shortenPendingApprovalSummary(escapeApprovalTextForTerminal(reason))}`
|
|
: "";
|
|
defaultRuntime.log(
|
|
`Approval ${displayId} ${settled} by ${formatResolver(result.approval)}${reasonSuffix}.`,
|
|
);
|
|
}
|
|
|
|
async function loadConfigForApprovalsTarget(params: {
|
|
opts: ExecApprovalsCliOpts;
|
|
source: ApprovalsTargetSource;
|
|
}): Promise<ConfigLoadResult> {
|
|
try {
|
|
if (params.source === "local") {
|
|
return { config: await readBestEffortConfig(), timedOut: false };
|
|
}
|
|
const snapshot = (await callGatewayFromCli(
|
|
"config.get",
|
|
params.opts,
|
|
{},
|
|
)) as ConfigSnapshotLike;
|
|
return {
|
|
config: snapshot.config && typeof snapshot.config === "object" ? snapshot.config : null,
|
|
timedOut: false,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
config: null,
|
|
timedOut: /^gateway timeout after \d+ms\b/i.test(formatCliError(err)),
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildEffectivePolicyReport(params: {
|
|
configLoad: ConfigLoadResult;
|
|
source: ApprovalsTargetSource;
|
|
approvals?: ExecApprovalsFile;
|
|
resolvedDefaults?: Required<ExecApprovalsDefaults>;
|
|
hostPath: string;
|
|
nativePolicy: boolean;
|
|
}): EffectivePolicyReport {
|
|
const cfg = params.configLoad.config;
|
|
const timeoutNote = params.configLoad.timedOut
|
|
? "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy."
|
|
: null;
|
|
if (!params.approvals) {
|
|
return {
|
|
scopes: [],
|
|
note: params.nativePolicy
|
|
? "This node enforces a host-native exec policy; OpenClaw approvals-file policy math does not apply."
|
|
: "Host approvals policy unavailable.",
|
|
};
|
|
}
|
|
if (params.source === "node") {
|
|
if (!cfg) {
|
|
return {
|
|
scopes: [],
|
|
note:
|
|
timeoutNote ??
|
|
"Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
|
|
};
|
|
}
|
|
if (!params.resolvedDefaults) {
|
|
return {
|
|
scopes: [],
|
|
note: "This node does not expose a complete resolved host policy, so Effective Policy is unavailable.",
|
|
};
|
|
}
|
|
return {
|
|
scopes: collectExecPolicyScopeSnapshots({
|
|
cfg,
|
|
approvals: params.approvals,
|
|
hostPath: params.hostPath,
|
|
hostDefaults: params.resolvedDefaults,
|
|
hostDefaultSource: "node-reported resolved defaults",
|
|
}),
|
|
note:
|
|
"Effective exec policy is the node host approvals policy intersected with gateway tools.exec policy. " +
|
|
SESSION_EXEC_OVERRIDES_NOTE,
|
|
};
|
|
}
|
|
if (!cfg) {
|
|
return {
|
|
scopes: [],
|
|
note: timeoutNote ?? "Config unavailable.",
|
|
};
|
|
}
|
|
return {
|
|
scopes: collectExecPolicyScopeSnapshots({
|
|
cfg,
|
|
approvals: params.approvals,
|
|
hostPath: params.hostPath,
|
|
}),
|
|
note:
|
|
"Effective exec policy is the host approvals policy intersected with requested tools.exec policy. " +
|
|
SESSION_EXEC_OVERRIDES_NOTE,
|
|
};
|
|
}
|
|
|
|
function renderEffectivePolicy(params: { report: EffectivePolicyReport }) {
|
|
const rich = isRich();
|
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
|
if (params.report.scopes.length === 0 && !params.report.note) {
|
|
return;
|
|
}
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Effective Policy"));
|
|
if (params.report.scopes.length === 0) {
|
|
defaultRuntime.log(muted(params.report.note ?? "No effective policy details available."));
|
|
return;
|
|
}
|
|
const rows = params.report.scopes.map((summary) => ({
|
|
Scope: summary.scopeLabel,
|
|
Requested: `security=${summary.security.requested} (${summary.security.requestedSource})\nask=${summary.ask.requested} (${summary.ask.requestedSource})`,
|
|
Host: `security=${summary.security.host} (${summary.security.hostSource})\nask=${summary.ask.host} (${summary.ask.hostSource})\naskFallback=${summary.askFallback.effective} (${summary.askFallback.source})`,
|
|
Effective: `security=${summary.security.effective}\nask=${summary.ask.effective}`,
|
|
Notes: `${summary.security.note}; ${summary.ask.note}`,
|
|
}));
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: getTerminalTableWidth(),
|
|
columns: [
|
|
{ key: "Scope", header: "Scope", minWidth: 12 },
|
|
{ key: "Requested", header: "Requested", minWidth: 24, flex: true },
|
|
{ key: "Host", header: "Host", minWidth: 24, flex: true },
|
|
{ key: "Effective", header: "Effective", minWidth: 16 },
|
|
{ key: "Notes", header: "Notes", minWidth: 20, flex: true },
|
|
],
|
|
rows,
|
|
}).trimEnd(),
|
|
);
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(muted(`Precedence: ${params.report.note}`));
|
|
}
|
|
|
|
function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: string) {
|
|
if (isNativeApprovalsSnapshot(snapshot)) {
|
|
renderNativeApprovalsSnapshot(snapshot, targetLabel);
|
|
return;
|
|
}
|
|
const rich = isRich();
|
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
|
const tableWidth = getTerminalTableWidth();
|
|
|
|
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((part): part is string => part != null);
|
|
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 = normalizeOptionalString(entry?.pattern) ?? "";
|
|
if (!pattern) {
|
|
continue;
|
|
}
|
|
const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null;
|
|
allowlistRows.push({
|
|
Target: targetLabel,
|
|
Agent: agentId,
|
|
Pattern: pattern,
|
|
LastUsed: lastUsedAt ? formatTimeAgo(Math.max(0, now - lastUsedAt)) : muted("unknown"),
|
|
});
|
|
}
|
|
}
|
|
|
|
const summaryRows = [
|
|
{ Field: "Target", Value: targetLabel },
|
|
{ Field: "Path", Value: snapshot.path },
|
|
{
|
|
Field: "State",
|
|
Value: snapshot.exists ? "stored" : "defaults (no stored overrides)",
|
|
},
|
|
{ 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(),
|
|
);
|
|
}
|
|
|
|
function renderNativeApprovalsSnapshot(snapshot: NativeExecApprovalsSnapshot, targetLabel: string) {
|
|
const rich = isRich();
|
|
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
|
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
|
const rules = snapshot.enabled ? snapshot.rules : [];
|
|
const summaryRows = [
|
|
{ Field: "Target", Value: targetLabel },
|
|
{ Field: "Kind", Value: "host-native" },
|
|
{ Field: "Enabled", Value: snapshot.enabled ? "yes" : "no" },
|
|
{ Field: "Hash", Value: snapshot.enabled ? snapshot.hash : "unavailable" },
|
|
{
|
|
Field: "Default",
|
|
Value: snapshot.enabled ? snapshot.defaultAction : (snapshot.message ?? "unavailable"),
|
|
},
|
|
{ Field: "Rules", Value: String(rules.length) },
|
|
];
|
|
defaultRuntime.log(heading("Approvals"));
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: getTerminalTableWidth(),
|
|
columns: [
|
|
{ key: "Field", header: "Field", minWidth: 8 },
|
|
{ key: "Value", header: "Value", minWidth: 24, flex: true },
|
|
],
|
|
rows: summaryRows,
|
|
}).trimEnd(),
|
|
);
|
|
if (rules.length === 0) {
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(muted("No host-native rules."));
|
|
return;
|
|
}
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(heading("Rules"));
|
|
defaultRuntime.log(
|
|
renderTable({
|
|
width: getTerminalTableWidth(),
|
|
columns: [
|
|
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
|
|
{ key: "Action", header: "Action", minWidth: 8 },
|
|
{ key: "Shells", header: "Shells", minWidth: 10, flex: true },
|
|
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
|
],
|
|
rows: rules.map((rule) => ({
|
|
Pattern: rule.pattern,
|
|
Action: rule.action,
|
|
Shells: rule.shells?.join(", ") || "all",
|
|
Enabled: rule.enabled === false ? "no" : "yes",
|
|
})),
|
|
}).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 = normalizeOptionalString(value) ?? "";
|
|
return trimmed ? trimmed : "*";
|
|
}
|
|
|
|
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
|
|
const pattern = normalizeOptionalString(entry?.pattern) ?? "";
|
|
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
|
|
);
|
|
}
|
|
|
|
async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
|
|
nodeId: string | null;
|
|
source: "gateway" | "node" | "local";
|
|
targetLabel: string;
|
|
baseHash: string;
|
|
file: ExecApprovalsFile;
|
|
agentKey: string;
|
|
agent: ExecApprovalsAgent;
|
|
allowlistEntries: NonNullable<ExecApprovalsAgent["allowlist"]>;
|
|
}> {
|
|
const { snapshot, nodeId, source, targetLabel, baseHash, kind } =
|
|
await loadWritableSnapshotTarget(opts);
|
|
if (kind === "native" || !isFileApprovalsSnapshot(snapshot)) {
|
|
exitWithError(
|
|
"Host-native node approvals do not support allowlist mutations; use approvals set --node with host-native JSON.",
|
|
);
|
|
}
|
|
const file = snapshot.file;
|
|
file.version = 1;
|
|
|
|
const agentKey = resolveAgentKey(opts.agent);
|
|
const agent = ensureAgent(file, agentKey);
|
|
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
|
|
|
|
return { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries };
|
|
}
|
|
|
|
type WritableAllowlistAgentContext = Awaited<ReturnType<typeof loadWritableAllowlistAgent>> & {
|
|
trimmedPattern: string;
|
|
};
|
|
type AllowlistMutation = (context: WritableAllowlistAgentContext) => boolean | Promise<boolean>;
|
|
|
|
async function runAllowlistMutation(
|
|
pattern: string,
|
|
opts: ExecApprovalsCliOpts,
|
|
mutate: AllowlistMutation,
|
|
): Promise<void> {
|
|
try {
|
|
const trimmedPattern = requireTrimmedNonEmpty(pattern, "Pattern required.");
|
|
const context = await loadWritableAllowlistAgent(opts);
|
|
const shouldSave = await mutate({ ...context, trimmedPattern });
|
|
if (!shouldSave) {
|
|
return;
|
|
}
|
|
await saveSnapshotTargeted({
|
|
opts,
|
|
source: context.source,
|
|
nodeId: context.nodeId,
|
|
file: context.file,
|
|
baseHash: context.baseHash,
|
|
targetLabel: context.targetLabel,
|
|
});
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
}
|
|
|
|
function registerAllowlistMutationCommand(params: {
|
|
allowlist: Command;
|
|
name: "add" | "remove";
|
|
description: string;
|
|
mutate: AllowlistMutation;
|
|
}): Command {
|
|
const command = params.allowlist
|
|
.command(`${params.name} <pattern>`)
|
|
.description(params.description)
|
|
.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) => {
|
|
await runAllowlistMutation(pattern, opts, params.mutate);
|
|
});
|
|
nodesCallOpts(command);
|
|
return command;
|
|
}
|
|
|
|
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 approval policy and pending requests")
|
|
.addHelpText(
|
|
"after",
|
|
() =>
|
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`,
|
|
);
|
|
|
|
const pendingCmd = approvals
|
|
.command("pending")
|
|
.description("List pending exec, plugin, and system-agent approvals")
|
|
.action(async (opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
const entries = await loadPendingApprovals(opts);
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({ approvals: entries }, 0);
|
|
return;
|
|
}
|
|
renderPendingApprovals(entries);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(pendingCmd);
|
|
|
|
const resolveCmd = approvals
|
|
.command("resolve <id> <decision>")
|
|
.description("Resolve a pending approval")
|
|
.option("--reason <text>", "Add a local note to the CLI confirmation")
|
|
.action(async (id: string, decision: string, opts: ExecApprovalsCliOpts) => {
|
|
try {
|
|
await resolvePendingApproval(id, decision, opts);
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(resolveCmd);
|
|
|
|
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);
|
|
const nativePolicy = isNativeApprovalsSnapshot(snapshot);
|
|
const configLoad = nativePolicy
|
|
? { config: null, timedOut: false }
|
|
: await loadConfigForApprovalsTarget({ opts, source });
|
|
const fileSnapshot = isFileApprovalsSnapshot(snapshot) ? snapshot : null;
|
|
const effectivePolicy = buildEffectivePolicyReport({
|
|
configLoad,
|
|
source,
|
|
approvals: fileSnapshot?.file,
|
|
resolvedDefaults: fileSnapshot?.resolvedDefaults,
|
|
hostPath: fileSnapshot?.path ?? "",
|
|
nativePolicy,
|
|
});
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({ ...snapshot, effectivePolicy }, 0);
|
|
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);
|
|
renderEffectivePolicy({ report: effectivePolicy });
|
|
} catch (err) {
|
|
defaultRuntime.error(formatCliError(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
nodesCallOpts(getCmd, { timeoutMs: APPROVALS_GET_DEFAULT_TIMEOUT_MS });
|
|
|
|
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) {
|
|
exitWithError("Provide --file or --stdin.");
|
|
}
|
|
if (opts.file && opts.stdin) {
|
|
exitWithError("Use either --file or --stdin (not both).");
|
|
}
|
|
const { source, nodeId, targetLabel, baseHash, kind } =
|
|
await loadWritableSnapshotTarget(opts);
|
|
const raw = opts.stdin ? await readStdin() : await readApprovalsFile(String(opts.file));
|
|
let input: unknown;
|
|
try {
|
|
input = JSON5.parse(raw);
|
|
} catch (err) {
|
|
exitWithError(`Failed to parse approvals JSON: ${String(err)}`);
|
|
}
|
|
if (kind === "native") {
|
|
const native = normalizeNativePolicyInput(input);
|
|
await saveSnapshotTargeted({
|
|
opts,
|
|
source,
|
|
nodeId,
|
|
native,
|
|
baseHash,
|
|
targetLabel,
|
|
});
|
|
return;
|
|
}
|
|
if (!isRecord(input)) {
|
|
exitWithError("Exec approvals JSON must be an object.");
|
|
}
|
|
const file = input as ExecApprovalsFile;
|
|
file.version = 1;
|
|
await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, 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(
|
|
'openclaw approvals allowlist add "~/Projects/**/bin/rg"',
|
|
"Allowlist a local binary pattern for the main agent.",
|
|
)}\n${formatExample(
|
|
'openclaw approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
|
|
"Allowlist on a specific node/agent.",
|
|
)}\n${formatExample(
|
|
'openclaw approvals allowlist add --agent "*" "/usr/bin/uname"',
|
|
"Allowlist for all agents (wildcard).",
|
|
)}\n${formatExample(
|
|
'openclaw approvals allowlist remove "~/Projects/**/bin/rg"',
|
|
"Remove an allowlist pattern.",
|
|
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`,
|
|
);
|
|
|
|
registerAllowlistMutationCommand({
|
|
allowlist,
|
|
name: "add",
|
|
description: "Add a glob pattern to an allowlist",
|
|
mutate: ({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => {
|
|
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmedPattern)) {
|
|
defaultRuntime.log("Already allowlisted.");
|
|
return false;
|
|
}
|
|
allowlistEntries.push({ pattern: trimmedPattern, lastUsedAt: Date.now() });
|
|
agent.allowlist = allowlistEntries;
|
|
file.agents = { ...file.agents, [agentKey]: agent };
|
|
return true;
|
|
},
|
|
});
|
|
|
|
registerAllowlistMutationCommand({
|
|
allowlist,
|
|
name: "remove",
|
|
description: "Remove a glob pattern from an allowlist",
|
|
mutate: ({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => {
|
|
const nextEntries = allowlistEntries.filter(
|
|
(entry) => normalizeAllowlistEntry(entry) !== trimmedPattern,
|
|
);
|
|
if (nextEntries.length === allowlistEntries.length) {
|
|
defaultRuntime.log("Pattern not found.");
|
|
return false;
|
|
}
|
|
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 };
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
applyParentDefaultHelpAction(approvals);
|
|
}
|
|
|
|
export const testing = {
|
|
formatCliError,
|
|
readStdin,
|
|
};
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|