diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 87ef6d155388..ca2058b28089 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -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 openclaw approvals get --gateway +openclaw approvals pending +openclaw approvals resolve ``` `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_` 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 allow-once +openclaw approvals resolve allow-always +openclaw approvals resolve 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 ` (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). diff --git a/docs/docs_map.md b/docs/docs_map.md index 6968e9269792..f58fbae52412 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 5f2ba7ce1832..971a11d768bf 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -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 diff --git a/src/cli/exec-approvals-cli.pending-resolve.test.ts b/src/cli/exec-approvals-cli.pending-resolve.test.ts new file mode 100644 index 000000000000..fcdcd080031a --- /dev/null +++ b/src/cli/exec-approvals-cli.pending-resolve.test.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Expected ${label}`); + } + return value as Record; +} + +function firstMockArg(mock: { mock: { calls: ReadonlyArray> } }): 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 { + 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); + }); +}); diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 76490816f66a..e9f13fdb6ee5 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -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 => { + 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 () => { diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 7c28f416a253..6197321d2786 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -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 ` 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 { + // 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 { + // 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 ") + .description("Resolve a pending approval") + .option("--reason ", "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") diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 34684826beed..c745502cc200 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -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, },