feat(cli): list and resolve pending approvals headlessly (#111060)

* feat(cli): manage pending approvals

* fix(cli): show terminal-safe approval ids raw, reserve id64 tokens for hostile ids

* fix(cli): preserve opaque approval ids verbatim

* fix(cli): tokenize leading-hyphen approval ids for pasteability

* fix(cli): lossless utf16 id64 tokens for opaque approval ids

* fix(cli): resolve approval ids verbatim, no input trim

* fix(cli): scope-only approval auth, reviewer-safe system-agent summaries, skip ill-formed ids

* fix(cli): validate pending approval ids

* fix(cli): align approvals catalog and docs map
This commit is contained in:
Peter Steinberger
2026-07-18 23:23:49 -07:00
committed by GitHub
parent 5e4a324f22
commit a2ccbdfa96
7 changed files with 995 additions and 34 deletions
+32
View File
@@ -3,6 +3,7 @@ summary: "CLI reference for `openclaw approvals` and `openclaw exec-policy`"
read_when:
- You want to edit exec approvals from the CLI
- You need to manage allowlists on gateway or node hosts
- You need to list or resolve a pending approval without a chat surface
title: "Approvals"
---
@@ -44,6 +45,8 @@ For remote host approvals, use `openclaw approvals set --gateway` or `openclaw a
openclaw approvals get
openclaw approvals get --node <id|name|ip>
openclaw approvals get --gateway
openclaw approvals pending
openclaw approvals resolve <id> <allow-once|allow-always|deny>
```
`get` shows the effective exec policy for the target: the requested `tools.exec` policy, the host approvals-file policy, and the merged effective result. Nodes with a host-native policy, such as the Windows companion, show that policy directly instead of applying OpenClaw approvals-file policy math.
@@ -61,6 +64,33 @@ Precedence:
- `--node` combines the node host approvals file with gateway `tools.exec` policy (both apply at runtime).
- If gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed.
## Pending approvals
List pending exec, plugin, and OpenClaw system-agent approvals from the Gateway:
```bash
openclaw approvals pending
openclaw approvals pending --json
```
Complete enumeration and the matching operator-wide `resolve` flow use `operator.admin` because approval records otherwise retain requester/reviewer filtering. Resolution also requests the dedicated `operator.approvals` scope. The standard CLI operator grant includes both scopes; a restricted third-party client should not request admin merely to emulate this command.
Human output shows the approval kind, agent/session attribution, request age, time until expiry, a shortened command or summary, and a shell-neutral `id64_<base64url>` id token. A `Full request text` block always follows the compact table with every complete token and a losslessly escaped request, so terminal-width shortening cannot hide a suffix or the token needed for resolution. Copy the complete token into `resolve`. Unsafe terminal characters in other fields are shown as visible Unicode escapes. JSON output returns normalized entries under `approvals`, preserving the original raw `id`, `summary`, `createdAtMs`, and `expiresAtMs` for scripts; raw ids remain accepted by `resolve` unless they use the reserved `id64_` display-token prefix.
If a supplied `id64_` value matches both a literal raw id and the decoded display token for another approval, the CLI rejects it as ambiguous instead of risking resolution of the wrong request.
Resolve one approval by its full id:
```bash
openclaw approvals resolve <id> allow-once
openclaw approvals resolve <id> allow-always
openclaw approvals resolve <id> deny --reason "Not expected during maintenance"
```
The CLI reads the unified approval record to select its kind, checks the requested decision against the record's allowed decisions, and then calls the unified resolver. A first successful decision exits `0`. Repeating the recorded decision also exits `0` and reports `already resolved (same decision)`. A conflicting decision, missing approval, expired approval, or decision unavailable for that approval kind prints a clear error and exits non-zero.
`--reason` adds a local note to the CLI confirmation. The current Gateway approval record has no free-text resolution-reason field, so this note is not persisted or sent to other approval surfaces.
## Replace approvals from a file
```bash
@@ -146,6 +176,8 @@ No target flag means the local approvals file on disk.
`allowlist add|remove` also supports `--agent <id>` (defaults to `"*"`, applying to all agents).
`pending` and `resolve` always use the Gateway because pending requests are live Gateway state. They support the shared Gateway connection options `--url`, `--token`, and `--timeout`; `pending` also supports `--json`.
## Notes
- The node host must advertise `system.execApprovals.get/set` (macOS app, headless node host, or Windows companion).
+2
View File
@@ -1283,6 +1283,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H1: openclaw approvals
- H2: openclaw exec-policy
- H2: Common commands
- H2: Pending approvals
- H2: Replace approvals from a file
- H2: "Never prompt" / YOLO example
- H2: Allowlist helpers
@@ -9860,6 +9861,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Safe bins versus allowlist
- H2: Interpreter/runtime commands
- H3: Followup delivery behavior
- H2: Minimal scopes for third-party clients
- H2: Approval forwarding to chat channels
- H3: Plugin approval forwarding
- H3: Same-chat approvals on any channel
+4
View File
@@ -183,6 +183,10 @@ main session are either suppressed or reported through a safe direct route when
- If a caller explicitly requests strict external delivery with no resolvable external channel, the request fails with `INVALID_REQUEST`.
- If `bestEffortDeliver` is enabled and no external channel can be resolved, delivery is downgraded to session-only instead of failing.
## Minimal scopes for third-party clients
Gateway approval resolution is guarded by the dedicated `operator.approvals` scope. This applies to both the owner-specific `exec.approval.resolve` method and the kind-agnostic `approval.resolve` method; `operator.write` does not subsume it. Dashboards and integrations should request only the scopes required by the methods they use. Treat approval-resolution access as remote-execution-grade authority and grant `operator.approvals` deliberately, even when the client only presents a small approval UI.
## Approval forwarding to chat channels
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
@@ -0,0 +1,522 @@
// Pending and resolve CLI tests stay separate from policy-management coverage.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerExecApprovalsCli } from "./exec-approvals-cli.js";
const mocks = vi.hoisted(() => {
const runtimeErrors: string[] = [];
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
const defaultRuntime = {
log: vi.fn(),
error: vi.fn((...args: unknown[]) => {
runtimeErrors.push(stringifyArgs(args));
}),
writeStdout: vi.fn((value: string) => {
defaultRuntime.log(value.endsWith("\n") ? value.slice(0, -1) : value);
}),
writeJson: vi.fn((value: unknown, space = 2) => {
defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
}),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return {
callGatewayFromCli: vi.fn(),
defaultRuntime,
runtimeErrors,
};
});
const { callGatewayFromCli, defaultRuntime, runtimeErrors } = mocks;
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
}
return value as Record<string, unknown>;
}
function firstMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } }): unknown {
const call = mock.mock.calls[0];
if (!call) {
throw new Error("Expected mock to have at least one call");
}
return call[0];
}
function writtenJson(): Record<string, unknown> {
return requireRecord(firstMockArg(vi.mocked(defaultRuntime.writeJson)), "written json");
}
function runtimeOutput(): string {
return defaultRuntime.log.mock.calls.map(([line]) => String(line ?? "")).join("\n");
}
function approvalDisplayId(id: string): string {
return /^[A-Za-z0-9._:][A-Za-z0-9._:-]{0,127}$/.test(id)
? id
: `id64_${Buffer.from(id, "utf16le").toString("base64url")}`;
}
function pendingApprovalSnapshot(params: {
id: string;
kind?: "exec" | "plugin" | "system-agent";
allowedDecisions?: string[];
expiresAtMs?: number;
}) {
const kind = params.kind ?? "exec";
return {
approval: {
id: params.id,
status: "pending",
urlPath: `/approve/${params.id}`,
createdAtMs: Date.now() - 1_000,
expiresAtMs: params.expiresAtMs ?? Date.now() + 60_000,
presentation:
kind === "exec"
? {
kind,
commandText: "echo ready",
allowedDecisions: params.allowedDecisions ?? ["allow-once", "allow-always", "deny"],
}
: {
kind,
title: kind === "plugin" ? "Plugin action" : "OpenClaw change",
description: "Apply the requested change",
...(kind === "plugin" ? { severity: "warning" } : { proposalHash: "a".repeat(64) }),
allowedDecisions: params.allowedDecisions ?? ["allow-once", "deny"],
},
},
};
}
function terminalApprovalSnapshot(params: {
id: string;
decision: "allow-once" | "allow-always" | "deny";
resolverId?: string;
}) {
const allowed = params.decision !== "deny";
return {
id: params.id,
status: allowed ? "allowed" : "denied",
decision: params.decision,
reason: "user",
urlPath: `/approve/${params.id}`,
createdAtMs: Date.now() - 1_000,
expiresAtMs: Date.now() + 60_000,
resolvedAtMs: Date.now(),
presentation: {
kind: "exec",
commandText: "echo ready",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
resolver: { kind: "device", id: params.resolverId ?? "device-1" },
};
}
vi.mock("./gateway-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
mocks.callGatewayFromCli(method, opts, params, extra),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
describe("exec approvals pending and resolve CLI", () => {
const createProgram = () => {
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
return program;
};
const runApprovalsCommand = async (args: string[]) => {
const program = createProgram();
await program.parseAsync(args, { from: "user" });
};
beforeEach(() => {
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
defaultRuntime.log.mockClear();
defaultRuntime.error.mockClear();
defaultRuntime.writeStdout.mockClear();
defaultRuntime.writeJson.mockClear();
defaultRuntime.exit.mockClear();
});
it("renders pending approvals from all three approval kinds", async () => {
const now = Date.now();
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approval.list") {
return [
{
id: "exec-\u202E1",
request: {
command: `printf '${"x".repeat(120)}' \u001B]52;c;--osc-hidden-action\u0007 --full-command-tail`,
agentId: "m\u202Ea",
sessionKey: "agent:main:discord:dm:1",
},
createdAtMs: now - 5_000,
expiresAtMs: now + 60_000,
},
];
}
if (method === "plugin.approval.list") {
return [
{
id: "plugin:1",
request: {
title: "Publish package",
description: "Publish the prepared plugin package",
agentId: "release",
sessionKey: "agent:release:main",
},
createdAtMs: now - 4_000,
expiresAtMs: now + 55_000,
},
{
id: "plugin:blank",
request: { title: " ", description: "\t" },
createdAtMs: now - 3_500,
expiresAtMs: now + 54_000,
},
];
}
if (method === "openclaw.approval.list") {
return [
{
id: "system-agent:1",
request: {
title: "OpenClaw change",
description: "Change the system configuration",
command: "apply-system-change --force",
agentId: "main",
sessionKey: "agent:main:main",
},
createdAtMs: now - 3_000,
// The Gateway list is authoritative even when the CLI clock is ahead.
expiresAtMs: now - 500,
},
];
}
return [];
});
await runApprovalsCommand(["approvals", "pending"]);
expect(callGatewayFromCli.mock.calls.map((call) => call[0])).toEqual([
"exec.approval.list",
"plugin.approval.list",
"openclaw.approval.list",
]);
for (const call of callGatewayFromCli.mock.calls) {
expect(call[3]).toEqual({ scopes: ["operator.admin"] });
}
const output = runtimeOutput();
const execDisplayId = approvalDisplayId("exec-\u202E1");
expect(output).toContain("Pending approvals");
expect(output).toContain(execDisplayId);
expect(output).toContain("m\\u{202E}a");
expect(output).toContain(approvalDisplayId("plugin:1"));
expect(output).toContain(approvalDisplayId("system-agent:1"));
expect(output).toContain(approvalDisplayId("plugin:blank"));
expect(output).toContain("Publish package");
// System-agent approvals show only their reviewer-safe presentation; the
// raw host-local operation must never reach the terminal.
expect(output).not.toContain("apply-system-change");
expect(output).toContain("OpenClaw change: Change the system configuration");
expect(output).toContain("\\u{9}");
expect(output).toContain("Full request text");
expect(output).toContain("--osc-hidden-action");
expect(output).toContain("\\u{1B}]52;c;");
expect(output).toContain("--full-command-tail");
expect(output).toContain("Agent / Session");
expect(output).toContain("Expires In");
expect(runtimeErrors).toHaveLength(0);
});
it("writes normalized pending approvals as JSON", async () => {
const now = Date.now();
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approval.list") {
return [
{
id: "exec-json",
request: {
command: "uname -a\u001B]52;c;hidden-action\u0007",
agentId: "main",
sessionKey: "agent:main:main",
},
createdAtMs: now - 2_000,
expiresAtMs: now + 60_000,
},
];
}
return [];
});
await runApprovalsCommand(["approvals", "pending", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(writtenJson(), 0);
expect(writtenJson()).toEqual({
approvals: [
{
id: "exec-json",
kind: "exec",
agentId: "main",
sessionKey: "agent:main:main",
createdAtMs: now - 2_000,
expiresAtMs: now + 60_000,
summary: "uname -a\u001B]52;c;hidden-action\u0007",
},
],
});
});
it("preserves whitespace-bearing ids verbatim and keeps them distinct", async () => {
const now = Date.now();
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approval.list") {
return [
{
id: " victim ",
request: { command: "echo padded" },
createdAtMs: now - 2_000,
expiresAtMs: now + 60_000,
},
{
id: "victim",
request: { command: "echo exact" },
createdAtMs: now - 1_000,
expiresAtMs: now + 60_000,
},
{
// Ill-formed ids are unresolvable through the unified schema and
// must be skipped rather than listed with a dead token.
id: "bad-\uD800",
request: { command: "echo surrogate" },
createdAtMs: now - 500,
expiresAtMs: now + 60_000,
},
];
}
return [];
});
await runApprovalsCommand(["approvals", "pending", "--json"]);
const ids = (writtenJson() as { approvals: { id: string }[] }).approvals.map(
(entry) => entry.id,
);
expect(ids).toContain(" victim ");
expect(ids).toContain("victim");
expect(ids).not.toContain("bad-\uD800");
// Display forms stay distinct: raw for the safe id, exact id64 token for
// the padded one.
expect(approvalDisplayId("victim")).toBe("victim");
expect(approvalDisplayId(" victim ")).toBe(
`id64_${Buffer.from(" victim ", "utf16le").toString("base64url")}`,
);
});
it("resolves an approval and prints the settled decision and resolver", async () => {
const approvalId = "approval-\u202E1";
const displayId = approvalDisplayId(approvalId);
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "approval.get") {
const requestedId = requireRecord(params, "approval lookup params").id;
if (requestedId === displayId) {
throw new Error("approval not found");
}
return pendingApprovalSnapshot({ id: approvalId });
}
if (method === "approval.resolve") {
return {
applied: true,
approval: terminalApprovalSnapshot({
id: approvalId,
decision: "allow-once",
resolverId: "device-\u202E1",
}),
};
}
return {};
},
);
await runApprovalsCommand(["approvals", "resolve", displayId, "allow-once"]);
expect(callGatewayFromCli.mock.calls[2]?.[0]).toBe("approval.resolve");
expect(callGatewayFromCli.mock.calls[2]?.[2]).toEqual({
id: approvalId,
kind: "exec",
decision: "allow-once",
});
for (const call of callGatewayFromCli.mock.calls) {
expect(call[3]).toEqual({
scopes: ["operator.admin", "operator.approvals"],
});
}
expect(runtimeOutput()).toContain(
`Approval ${displayId} resolved allow-once by device:device-\\u{202E}1`,
);
expect(defaultRuntime.exit).not.toHaveBeenCalled();
});
it("treats an already-resolved same decision as idempotent success", async () => {
const approvalId = "job\\u{41}";
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "approval.get") {
return pendingApprovalSnapshot({ id: approvalId });
}
return {
applied: false,
approval: terminalApprovalSnapshot({
id: approvalId,
decision: "deny",
resolverId: "other-device",
}),
};
});
await runApprovalsCommand(["approvals", "resolve", approvalId, "deny"]);
expect(callGatewayFromCli.mock.calls[0]?.[2]).toEqual({ id: approvalId });
expect(callGatewayFromCli.mock.calls[1]?.[2]).toMatchObject({ id: approvalId });
expect(runtimeOutput()).toContain("already resolved (same decision: deny)");
expect(runtimeOutput()).toContain("device:other-device");
expect(defaultRuntime.exit).not.toHaveBeenCalled();
});
it("resolves with shared credentials and no device identity", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "approval.get") {
return pendingApprovalSnapshot({ id: "approval-no-device" });
}
return {
applied: true,
approval: terminalApprovalSnapshot({
id: "approval-no-device",
decision: "deny",
}),
};
});
await runApprovalsCommand([
"approvals",
"resolve",
"approval-no-device",
"deny",
"--url",
"ws://127.0.0.1:18789",
"--token",
"test-token",
]);
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
for (const call of callGatewayFromCli.mock.calls) {
expect(call[3]).toEqual({ scopes: ["operator.admin", "operator.approvals"] });
}
});
it("rejects an id token that also exists as a raw approval id", async () => {
// Explicit token form: the display helper renders safe ids raw, but the
// resolve path must stay ambiguity-safe for pasted tokens regardless.
const displayId = `id64_${Buffer.from("foo", "utf16le").toString("base64url")}`;
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method !== "approval.get") {
throw new Error("resolve must not be called");
}
const id = String(requireRecord(params, "approval lookup params").id);
return pendingApprovalSnapshot({ id });
},
);
await expect(runApprovalsCommand(["approvals", "resolve", displayId, "deny"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors[0]).toContain("matches both a raw id and a displayed id token");
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
});
it("exits non-zero when an approval already has a different decision", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "approval.get") {
return pendingApprovalSnapshot({ id: "approval-3" });
}
return {
applied: false,
approval: terminalApprovalSnapshot({ id: "approval-3", decision: "deny" }),
};
});
await expect(
runApprovalsCommand(["approvals", "resolve", "approval-3", "allow-once"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors[0]).toContain("already resolved with deny by device:device-1");
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("exits non-zero when an approval is not found", async () => {
callGatewayFromCli.mockRejectedValue(new Error("approval not found"));
await expect(runApprovalsCommand(["approvals", "resolve", "missing", "deny"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors[0]).toBe("approval not found");
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
});
it("lets the gateway decide that an approval expired", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "approval.get") {
return pendingApprovalSnapshot({ id: "expired-1", expiresAtMs: Date.now() - 1 });
}
const pending = pendingApprovalSnapshot({ id: "expired-1" }).approval;
return {
applied: false,
approval: {
...pending,
status: "expired",
reason: "timeout",
resolvedAtMs: pending.expiresAtMs,
},
};
});
await expect(
runApprovalsCommand(["approvals", "resolve", "expired-1", "deny"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors[0]).toBe(`Approval ${approvalDisplayId("expired-1")} expired.`);
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
});
it("rejects decisions unavailable for the approval kind", async () => {
callGatewayFromCli.mockResolvedValueOnce(
pendingApprovalSnapshot({
id: "system-agent:2",
kind: "system-agent",
allowedDecisions: ["allow-once", "deny"],
}),
);
await expect(
runApprovalsCommand(["approvals", "resolve", "system-agent:2", "allow-always"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors[0]).toContain(
"allow-always is not allowed for system-agent approvals; allowed decisions: allow-once, deny",
);
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
});
});
+39 -32
View File
@@ -38,40 +38,47 @@ const mocks = vi.hoisted(() => {
}),
};
return {
callGatewayFromCli: vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
if (method === "config.get") {
return {
config: {
tools: {
exec: {
security: "full",
ask: "off",
callGatewayFromCli: vi.fn(
async (
method: string,
_opts: unknown,
params?: unknown,
_extra?: unknown,
): Promise<unknown> => {
if (method.endsWith(".get")) {
if (method === "config.get") {
return {
config: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
},
},
};
}
const snapshot = {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
return method === "exec.approvals.node.get"
? {
...snapshot,
resolvedDefaults: {
security: "allowlist" as const,
ask: "on-miss" as const,
askFallback: "deny" as const,
autoAllowSkills: false,
},
}
: snapshot;
}
const snapshot = {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
return method === "exec.approvals.node.get"
? {
...snapshot,
resolvedDefaults: {
security: "allowlist" as const,
ask: "on-miss" as const,
askFallback: "deny" as const,
autoAllowSkills: false,
},
}
: snapshot;
}
return { method, params };
}),
return { method, params };
},
),
defaultRuntime,
readBestEffortConfig,
runtimeErrors,
@@ -167,8 +174,8 @@ function resetLocalSnapshot() {
}
vi.mock("./gateway-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
mocks.callGatewayFromCli(method, opts, params),
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
mocks.callGatewayFromCli(method, opts, params, extra),
}));
vi.mock("./nodes-cli/rpc.js", async () => {
+395 -1
View File
@@ -6,11 +6,20 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
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 { formatErrorMessage } from "../infra/errors.js";
import {
collectExecPolicyScopeSnapshots,
@@ -87,8 +96,25 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
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,
@@ -356,6 +382,342 @@ function formatCliError(err: unknown): string {
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;
@@ -748,13 +1110,45 @@ export function registerExecApprovalsCli(program: Command) {
const approvals = program
.command("approvals")
.alias("exec-approvals")
.description("Manage exec approvals (gateway or node host)")
.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")
+1 -1
View File
@@ -46,7 +46,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
},
{
name: "approvals",
description: "Manage exec approvals (gateway or node host)",
description: "Manage approval policy and pending requests",
hasSubcommands: true,
parentDefaultHelp: true,
},