UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,090 lines (1,089 loc) 40.2 kB
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 };