openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,090 lines (1,089 loc) • 40.2 kB
JavaScript
import { w as parseStrictPositiveInteger } from "./number-coercion-CLj0HTDM.js";
import { t as sanitizeForLog } from "./ansi-DrXAcdMD.js";
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js";
import { a as readBestEffortConfig } from "./io.runtime-B9iJRs3w.js";
import { c as readFileDescriptorBounded } from "./boundary-file-read-uaJcf6X6.js";
import { _ as resolveConfiguredAgentId } from "./agent-scope-config-DcbEhP0R.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { n as APPROVALS_SCOPE, t as ADMIN_SCOPE } from "./operator-scopes-Dw7Gu2cA.js";
import "./config-Cs0XXL3x.js";
import { t as formatDocsLink } from "./links-ClIwBcy4.js";
import { n as isRich, r as theme } from "./theme-vjDs9tao.js";
import { n as callGatewayFromCli } from "./gateway-rpc-C9fDQ0vT.js";
import "./method-scopes-K6J_UQGL.js";
import { n as isWellFormedApprovalId } from "./approval-id-BTRnO3t1.js";
import { c as rethrowExpectedCliError } from "./failure-output-Dc5ce9_F.js";
import { a as mergeExecApprovalsSocketDefaults } from "./exec-approvals-config-D1lCGl0_.js";
import { n as redactExecApprovals, t as normalizeExecApprovals } from "./exec-approvals-BSZ-fPIY.js";
import { d as updateExecApprovals, o as readExecApprovalsSnapshot } from "./exec-approvals-store-DBR0neS0.js";
import { t as readByteStreamWithLimit } from "./read-byte-stream-with-limit-CNew-qG0.js";
import { n as formatTimeAgo } from "./format-relative-DiD6ocfu.js";
import { r as renderTerminalSafeTable, t as getTerminalTableWidth } from "./table-DzN9OC44.js";
import { a as nodesCallOpts, u as resolveCliNodeId } from "./rpc-BOZ2ZIpe.js";
import { t as applyParentDefaultHelpAction } from "./parent-default-help-DQUF3qKA.js";
import { n as collectExecPolicyScopeSnapshots, t as SESSION_EXEC_OVERRIDES_NOTE } from "./exec-approvals-effective-DGNgYRIx.js";
import fs from "node:fs/promises";
import JSON5 from "json5";
//#region src/cli/exec-approvals-cli.ts
const APPROVALS_GET_DEFAULT_TIMEOUT_MS = 6e4;
const EXEC_APPROVALS_STDIN_MAX_BYTES = 1048576;
const APPROVAL_DECISIONS = [
"allow-once",
"allow-always",
"deny"
];
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 = process.stdin, maxBytes = EXEC_APPROVALS_STDIN_MAX_BYTES) {
return (await readByteStreamWithLimit(stream, {
maxBytes,
onOverflow: ({ maxBytes: limit }) => /* @__PURE__ */ new Error(`Exec approvals stdin exceeds ${limit} bytes.`)
})).toString("utf8");
}
async function readApprovalsFile(filePath) {
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) {
if (opts.gateway) return null;
const raw = normalizeOptionalString(opts.node) ?? "";
if (!raw) return null;
return await resolveCliNodeId(opts, raw);
}
async function loadSnapshot(opts, nodeId) {
return await callGatewayFromCli(nodeId ? "exec.approvals.node.get" : "exec.approvals.get", opts, nodeId ? { nodeId } : {});
}
function loadSnapshotLocal() {
const snapshot = readExecApprovalsSnapshot();
return {
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: snapshot.file
};
}
function isFileApprovalsSnapshot(snapshot) {
return "file" in snapshot;
}
function isNativeApprovalsSnapshot(snapshot) {
return "enabled" in snapshot;
}
function parseNativeAction(value, label) {
if (value === "allow" || value === "deny" || value === "prompt") return value;
return exitWithError(`${label} must be allow, deny, or prompt.`);
}
function normalizeNativePolicyInput(value) {
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 === void 0 ? void 0 : 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;
if (entry.shells !== void 0) {
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 !== void 0 && typeof entry.description !== "string") exitWithError(`Host-native exec approval rule ${index + 1} description must be a string.`);
if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") exitWithError(`Host-native exec approval rule ${index + 1} enabled must be a boolean.`);
return {
pattern,
action,
...shells ? { shells } : {},
...entry.description !== void 0 ? { description: entry.description } : {},
...entry.enabled !== void 0 ? { enabled: entry.enabled } : {}
};
});
return {
...defaultAction ? { defaultAction } : {},
rules
};
}
async function saveSnapshotLocal(file, baseHash) {
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) {
if (!opts.gateway && !opts.node) return {
snapshot: loadSnapshotLocal(),
nodeId: null,
source: "local"
};
const nodeId = await resolveTargetNodeId(opts);
return {
snapshot: await loadSnapshot(opts, nodeId),
nodeId,
source: nodeId ? "node" : "gateway"
};
}
function exitWithError(message) {
throw new Error(message);
}
function requireTrimmedNonEmpty(value, message) {
const trimmed = value.trim();
if (!trimmed) exitWithError(message);
return trimmed;
}
async function loadWritableSnapshotTarget(opts) {
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
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 : void 0;
if (!baseHash) exitWithError("Exec approvals hash missing; reload and retry.");
return {
snapshot,
nodeId,
source,
targetLabel,
baseHash,
kind: isNativeApprovalsSnapshot(snapshot) ? "native" : "file"
};
}
async function saveSnapshotTargeted(params) {
let next;
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") {
defaultRuntime.log(theme.muted("Writing local approvals."));
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(isFileApprovalsSnapshot(next) ? redactExecApprovals(next) : next, 0);
return;
}
defaultRuntime.log(theme.muted(`Target: ${params.targetLabel}`));
renderApprovalsSnapshot(next, params.targetLabel);
}
function formatCliError(err) {
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 failApprovalsCommand(err, opts) {
rethrowExpectedCliError(err);
const message = formatCliError(err);
if (opts.json) throw new Error(message);
defaultRuntime.error(message);
defaultRuntime.exit(1);
}
function isApprovalDecision(value) {
return APPROVAL_DECISIONS.includes(value);
}
function shortenPendingApprovalSummary(value) {
if (value.length <= PENDING_APPROVAL_SUMMARY_MAX_LENGTH) return value;
return `${truncateUtf16Safe(value, 93)}...`;
}
function escapeApprovalTextForTerminal(value) {
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;
}
const APPROVAL_ID_TERMINAL_SAFE_RE = /^[A-Za-z0-9._:][A-Za-z0-9._:-]{0,127}$/;
function formatApprovalIdForTerminal(value) {
if (APPROVAL_ID_TERMINAL_SAFE_RE.test(value)) return value;
return `${APPROVAL_ID_TOKEN_PREFIX}${Buffer.from(value, "utf16le").toString("base64url")}`;
}
function decodeDisplayedApprovalId(value) {
if (!value.startsWith(APPROVAL_ID_TOKEN_PREFIX)) return null;
const encoded = value.slice(5);
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, kind) {
if (!isRecord(value) || !isRecord(value.request)) return null;
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;
return {
id,
kind,
agentId,
sessionKey,
createdAtMs,
expiresAtMs,
summary: (kind === "exec" ? command : kind === "plugin" && command ? `${prose ? `${prose} — ` : ""}Command: ${command}` : prose) ?? "(summary unavailable)"
};
}
function readPendingApprovalList(value, kind) {
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) {
const listCall = (method) => 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) {
const parts = [entry.agentId, entry.sessionKey].filter((value) => Boolean(value));
return parts.length > 0 ? escapeApprovalTextForTerminal(parts.join(" / ")) : "-";
}
function describeGrantState(grant, nowMs) {
if (grant.revokedAtMs !== null) return `revoked${grant.revokedBy ? ` by ${escapeApprovalTextForTerminal(grant.revokedBy)}` : ""}`;
if (grant.expiresAtMs !== null && grant.expiresAtMs <= nowMs) return "expired";
if (grant.expiresAtMs !== null) return `expires in ${Math.max(1, Math.ceil((grant.expiresAtMs - nowMs) / 864e5))}d`;
return "until revoked";
}
function renderStandingGrants(grants) {
if (grants.length === 0) {
defaultRuntime.log(theme.muted("No standing grants."));
return;
}
const now = Date.now();
defaultRuntime.log(`${theme.heading("Standing grants")} ${theme.muted(`(${grants.length})`)}`);
defaultRuntime.log(renderTerminalSafeTable({
width: getTerminalTableWidth(),
columns: [
{
key: "ID",
header: "ID",
minWidth: 12,
flex: true
},
{
key: "Automation",
header: "Automation",
minWidth: 12,
flex: true
},
{
key: "Command",
header: "Command",
minWidth: 16,
flex: true
},
{
key: "Uses",
header: "Uses",
minWidth: 4
},
{
key: "State",
header: "State",
minWidth: 12
}
],
rows: grants.map((grant) => ({
ID: grant.grantId,
Automation: escapeApprovalTextForTerminal(grant.cronJobName ?? grant.cronJobId),
Command: escapeApprovalTextForTerminal(grant.command),
Uses: String(grant.useCount),
State: describeGrantState(grant, now)
}))
}));
defaultRuntime.log(theme.muted("Revoke with: openclaw approvals grants revoke <grant-id>"));
}
function renderPendingApprovals(entries) {
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(renderTerminalSafeTable({
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) {
return "decision" in approval && isApprovalDecision(approval.decision) ? approval.decision : null;
}
function formatResolver(approval) {
const resolver = approval.resolver;
if (!resolver) return "unknown resolver";
return resolver.id ? `${resolver.kind}:${escapeApprovalTextForTerminal(resolver.id)}` : resolver.kind;
}
function describeTerminalApprovalFailure(approval) {
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, decisionInput, opts) {
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 === void 0 ? null : normalizeOptionalString(opts.reason);
if (opts.reason !== void 0 && !reason) exitWithError("Reason must not be empty.");
const approvalCallOptions = { scopes: [ADMIN_SCOPE, APPROVALS_SCOPE] };
const lookupOne = async (id, tolerateNotFound = false) => {
try {
return await callGatewayFromCli("approval.get", opts, { id }, approvalCallOptions);
} catch (error) {
if (tolerateNotFound && formatErrorMessage(error).toLowerCase().includes("approval not found")) return null;
throw error;
}
};
const decodedId = decodeDisplayedApprovalId(rawId);
let id = rawId;
let lookup;
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;
if (!allowedDecisions.includes(decision)) exitWithError(`Decision ${decision} is not allowed for ${current.presentation.kind} approvals; allowed decisions: ${allowedDecisions.join(", ")}.`);
}
const expiresInDays = parseStrictPositiveInteger(opts.expiresInDays);
if (opts.expiresInDays !== void 0 && (expiresInDays === void 0 || expiresInDays > 3650)) exitWithError("--expires-in-days must be a whole number of days between 1 and 3650.");
if (expiresInDays !== void 0 && decision !== "allow-always") exitWithError("--expires-in-days only applies to allow-always.");
const result = await callGatewayFromCli("approval.resolve", opts, {
id,
kind: current.presentation.kind,
decision,
...expiresInDays !== void 0 ? { grantExpiresInDays: expiresInDays } : {}
}, approvalCallOptions);
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) {
try {
if (params.source === "local") return {
config: await readBestEffortConfig(),
timedOut: false
};
const snapshot = await callGatewayFromCli("config.get", params.opts, {});
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) {
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) {
const rich = isRich();
const heading = (text) => rich ? theme.heading(text) : text;
const muted = (text) => 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(renderTerminalSafeTable({
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, targetLabel) {
if (isNativeApprovalsSnapshot(snapshot)) {
renderNativeApprovalsSnapshot(snapshot, targetLabel);
return;
}
const rich = isRich();
const heading = (text) => rich ? theme.heading(text) : text;
const muted = (text) => 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 != null);
const agents = file.agents ?? {};
const allowlistRows = [];
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 mcpToolRows = Object.entries(agents).flatMap(([agentId, agent]) => (agent.mcpTools ?? []).map((grant) => ({
Agent: agentId,
Server: grant.server,
Tool: grant.tool,
Added: formatTimeAgo(Math.max(0, now - grant.addedAt))
})));
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)
},
{
Field: "MCP tool grants",
Value: String(mcpToolRows.length)
}
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(renderTerminalSafeTable({
width: tableWidth,
columns: [{
key: "Field",
header: "Field",
minWidth: 8
}, {
key: "Value",
header: "Value",
minWidth: 24,
flex: true
}],
rows: summaryRows
}).trimEnd());
defaultRuntime.log("");
if (allowlistRows.length > 0) {
defaultRuntime.log(heading("Allowlist"));
defaultRuntime.log(renderTerminalSafeTable({
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());
} else defaultRuntime.log(muted("No allowlist entries."));
if (mcpToolRows.length === 0) return;
defaultRuntime.log("");
defaultRuntime.log(heading("MCP tool grants"));
defaultRuntime.log(renderTerminalSafeTable({
width: tableWidth,
columns: [
{
key: "Agent",
header: "Agent",
minWidth: 8
},
{
key: "Server",
header: "Server",
minWidth: 16,
flex: true
},
{
key: "Tool",
header: "Tool",
minWidth: 16,
flex: true
},
{
key: "Added",
header: "Added",
minWidth: 10
}
],
rows: mcpToolRows
}).trimEnd());
}
function renderNativeApprovalsSnapshot(snapshot, targetLabel) {
const rich = isRich();
const heading = (text) => rich ? theme.heading(text) : text;
const muted = (text) => 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(renderTerminalSafeTable({
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(renderTerminalSafeTable({
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, nodeId, file, baseHash) {
return await callGatewayFromCli(nodeId ? "exec.approvals.node.set" : "exec.approvals.set", opts, nodeId ? {
nodeId,
file,
baseHash
} : {
file,
baseHash
});
}
function resolveAgentKey(value) {
return value == null ? "*" : requireTrimmedNonEmpty(value, "--agent must not be blank");
}
function normalizeAllowlistEntry(entry) {
const pattern = normalizeOptionalString(entry?.pattern) ?? "";
return pattern ? pattern : null;
}
function isEmptyAgent(agent) {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
return !agent.security && !agent.ask && !agent.askFallback && agent.autoAllowSkills === void 0 && !agent.mcpTools?.length && allowlist.length === 0;
}
async function loadWritableAllowlistAgent(opts) {
const agentKey = resolveAgentKey(opts.agent);
if (agentKey !== "*") {
const { config } = await loadConfigForApprovalsTarget({
opts,
source: !opts.gateway && !opts.node ? "local" : opts.gateway ? "gateway" : "node"
});
if (!config) exitWithError("Config unavailable; cannot validate --agent.");
resolveConfiguredAgentId(config, agentKey);
}
const target = await loadWritableSnapshotTarget(opts);
const { snapshot, kind } = target;
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 agent = file.agents?.[agentKey] ?? {};
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
return {
...target,
snapshot,
file,
agentKey,
agent,
allowlistEntries
};
}
async function runAllowlistMutation(pattern, opts, mutate) {
try {
const trimmedPattern = requireTrimmedNonEmpty(pattern, "Pattern required.");
const context = await loadWritableAllowlistAgent(opts);
if (!await mutate({
...context,
trimmedPattern
})) {
if (opts.json) defaultRuntime.writeJson(redactExecApprovals(context.snapshot), 0);
return;
}
await saveSnapshotTargeted({
...context,
opts
});
} catch (err) {
failApprovalsCommand(err, opts);
}
}
function registerAllowlistMutationCommand(params) {
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, opts) => {
await runAllowlistMutation(pattern, opts, params.mutate);
});
nodesCallOpts(command);
return command;
}
function registerExecApprovalsCli(program) {
const formatExample = (cmd, desc) => ` ${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) => {
try {
const entries = await loadPendingApprovals(opts);
if (opts.json) {
defaultRuntime.writeJson({ approvals: entries }, 0);
return;
}
renderPendingApprovals(entries);
} catch (err) {
failApprovalsCommand(err, opts);
}
});
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").option("--expires-in-days <days>", "Allow-always on an automation approval: freeze this grant lifetime instead of the configured default").action(async (id, decision, opts) => {
try {
await resolvePendingApproval(id, decision, opts);
} catch (err) {
failApprovalsCommand(err, opts);
}
});
nodesCallOpts(resolveCmd);
const grants = approvals.command("grants").description("Standing grants minted by allow-always on automation approvals");
const grantsListCmd = grants.command("list").description("List standing grants, newest first").option("--limit <n>", "Maximum rows to return (default 200)").action(async (opts) => {
try {
const limit = parseStrictPositiveInteger(opts.limit);
if (opts.limit !== void 0 && limit === void 0) exitWithError("--limit must be a positive integer.");
const result = await callGatewayFromCli("exec.approval.grants.list", opts, limit !== void 0 ? { limit } : {});
if (opts.json) {
defaultRuntime.writeJson(result, 0);
return;
}
renderStandingGrants(result.grants);
} catch (err) {
failApprovalsCommand(err, opts);
}
});
nodesCallOpts(grantsListCmd);
const grantsRevokeCmd = grants.command("revoke <grantId>").description("Revoke a standing grant; the next occurrence prompts again").action(async (grantId, opts) => {
try {
const result = await callGatewayFromCli("exec.approval.grants.revoke", opts, { grantId });
if (opts.json) {
defaultRuntime.writeJson(result, 0);
return;
}
if (result.outcome === "revoked") defaultRuntime.log(`Grant ${grantId} revoked. The next occurrence prompts again.`);
else if (result.outcome === "already-revoked") defaultRuntime.log(`Grant ${grantId} was already revoked.`);
else exitWithError(`Grant ${grantId} not found.`);
} catch (err) {
failApprovalsCommand(err, opts);
}
});
nodesCallOpts(grantsRevokeCmd);
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) => {
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) {
const outputSnapshot = fileSnapshot ? redactExecApprovals(fileSnapshot) : snapshot;
defaultRuntime.writeJson({
...outputSnapshot,
effectivePolicy
}, 0);
return;
}
const muted = (text) => isRich() ? theme.muted(text) : text;
if (source === "local") {
defaultRuntime.log(muted("Showing local approvals."));
defaultRuntime.log("");
}
renderApprovalsSnapshot(snapshot, source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway");
renderEffectivePolicy({ report: effectivePolicy });
} catch (err) {
failApprovalsCommand(err, opts);
}
});
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) => {
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;
try {
input = JSON5.parse(raw);
} catch (err) {
exitWithError(`Failed to parse approvals JSON: ${String(err)}`);
}
if (kind === "native") {
await saveSnapshotTargeted({
opts,
source,
nodeId,
native: normalizeNativePolicyInput(input),
baseHash,
targetLabel
});
return;
}
if (!isRecord(input)) exitWithError("Exec approvals JSON must be an object.");
const file = input;
file.version = 1;
await saveSnapshotTargeted({
opts,
source,
nodeId,
file,
baseHash,
targetLabel
});
} catch (err) {
failApprovalsCommand(err, opts);
}
});
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 : void 0;
} else file.agents = {
...file.agents,
[agentKey]: agent
};
return true;
}
});
applyParentDefaultHelpAction(approvals);
}
const testing = {
formatCliError,
readStdin
};
//#endregion
export { registerExecApprovalsCli, testing };