From 4bf94aa0d6699b7cbb9ffdf5f3f8413b598b5715 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:16:03 -0500 Subject: [PATCH] feat: add local exec-policy CLI (#64050) * feat: add local exec-policy CLI * fix: harden exec-policy CLI output * fix: harden exec approvals writes * fix: tighten local exec-policy sync * docs: document exec-policy CLI * fix: harden exec-policy rollback and approvals path checks * fix: reject exec-policy sync when host remains node * fix: validate approvals path before mkdir * fix: guard exec-policy rollback against newer approvals writes * fix: restore exec approvals via hardened rollback path * fix: guard exec-policy config writes with base hash * docs: add exec-policy changelog entry * fix: clarify exec-policy show for node host * fix: strip stale exec-policy decisions --- CHANGELOG.md | 1 + docs/cli/approvals.md | 51 +- docs/tools/exec-approvals.md | 26 + .../msteams/src/attachments.helpers.test.ts | 4 +- .../src/monitor-handler/message-handler.ts | 2 +- src/agents/subagent-registry.ts | 5 +- src/cli/exec-policy-cli.test.ts | 553 ++++++++++++++++++ src/cli/exec-policy-cli.ts | 442 ++++++++++++++ src/cli/program/register.subclis.ts | 5 + src/cli/program/subcli-descriptors.ts | 5 + ...agent.direct-delivery-forum-topics.test.ts | 13 +- src/infra/exec-approvals-effective.ts | 43 ++ src/infra/exec-approvals-store.test.ts | 44 ++ src/infra/exec-approvals.ts | 80 ++- 14 files changed, 1256 insertions(+), 18 deletions(-) create mode 100644 src/cli/exec-policy-cli.test.ts create mode 100644 src/cli/exec-policy-cli.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 884747c779e..5bf08224ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819. - QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd. - Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras. +- CLI/exec policy: add a local `openclaw exec-policy` command with `show`, `preset`, and `set` subcommands for synchronizing requested `tools.exec.*` config with the local exec approvals file, plus follow-up hardening for node-host rejection, rollback safety, and sync conflict detection. - Models/providers: add per-provider `models.providers.*.request.allowPrivateNetwork` for trusted self-hosted OpenAI-compatible endpoints, keep the opt-in scoped to model request surfaces, and refresh cached WebSocket managers when request transport overrides change. (#63671) Thanks @qas. ### Fixes diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 9381bd0ced9..951d7f08887 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw approvals` (exec approvals for gateway or node hosts)" +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 @@ -18,6 +18,45 @@ Related: - Exec approvals: [Exec approvals](/tools/exec-approvals) - Nodes: [Nodes](/nodes) +## `openclaw exec-policy` + +`openclaw exec-policy` is the local convenience command for keeping the requested +`tools.exec.*` config and the local host approvals file aligned in one step. + +Use it when you want to: + +- inspect the local requested policy, host approvals file, and effective merge +- apply a local preset such as YOLO or deny-all +- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json` + +Examples: + +```bash +openclaw exec-policy show +openclaw exec-policy show --json + +openclaw exec-policy preset yolo +openclaw exec-policy preset cautious --json + +openclaw exec-policy set --host gateway --security full --ask off --ask-fallback full +``` + +Output modes: + +- no `--json`: prints the human-readable table view +- `--json`: prints machine-readable structured output + +Current scope: + +- `exec-policy` is **local-only** +- it updates the local config file and the local approvals file together +- it does **not** push policy to the gateway host or a node host +- `--host node` is rejected in this command because node exec approvals are fetched from the node at runtime and must be managed through node-targeted approvals commands instead +- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals file + +If you need to edit remote host approvals directly, keep using `openclaw approvals set --gateway` +or `openclaw approvals set --node `. + ## Common commands ```bash @@ -100,6 +139,16 @@ Why `tools.exec.host=gateway` in this example: This matches the current host-default YOLO behavior. Tighten it if you want approvals. +Local shortcut: + +```bash +openclaw exec-policy preset yolo +``` + +That local shortcut updates both the requested local `tools.exec.*` config and the +local approvals defaults together. It is equivalent in intent to the manual two-step +setup above, but only for the local machine. + ## Allowlist helpers ```bash diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 39c335fe77a..5a0fd378be3 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -20,6 +20,11 @@ session or config defaults request `ask: "on-miss"`. Use `openclaw approvals get`, `openclaw approvals get --gateway`, or `openclaw approvals get --node ` to inspect the requested policy, host policy sources, and the effective result. +For the local machine, `openclaw exec-policy show` exposes the same merged view and +`openclaw exec-policy set|preset` can synchronize the local requested policy with the +local host approvals file in one step. When a local scope requests `host=node`, +`openclaw exec-policy show` reports that scope as node-managed at runtime instead of +pretending the local approvals file is the effective source of truth. If the companion app UI is **not available**, any request that requires a prompt is resolved by the **ask fallback** (default: deny). @@ -143,6 +148,21 @@ openclaw approvals set --stdin <<'EOF' EOF ``` +Local shortcut for the same gateway-host policy on the current machine: + +```bash +openclaw exec-policy preset yolo +``` + +That local shortcut updates both: + +- local `tools.exec.host/security/ask` +- local `~/.openclaw/exec-approvals.json` defaults + +It is intentionally local-only. If you need to change gateway-host or node-host approvals +remotely, continue using `openclaw approvals set --gateway` or +`openclaw approvals set --node `. + For a node host, apply the same approvals file on that node instead: ```bash @@ -158,6 +178,12 @@ openclaw approvals set --node --stdin <<'EOF' EOF ``` +Important local-only limitation: + +- `openclaw exec-policy` does not synchronize node approvals +- `openclaw exec-policy set --host node` is rejected +- node exec approvals are fetched from the node at runtime, so node-targeted updates must use `openclaw approvals --node ...` + Session-only shortcut: - `/exec security=full ask=off` changes only the current session. diff --git a/extensions/msteams/src/attachments.helpers.test.ts b/extensions/msteams/src/attachments.helpers.test.ts index 84b0d3c3fb2..350085f7524 100644 --- a/extensions/msteams/src/attachments.helpers.test.ts +++ b/extensions/msteams/src/attachments.helpers.test.ts @@ -214,9 +214,7 @@ describe("msteams attachment helpers", () => { messageId: "msg-1", }); expect(urls).toHaveLength(1); - expect(urls[0]).toContain( - "/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1", - ); + expect(urls[0]).toContain("/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1"); }); it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 9be66046446..0bd2c67cada 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -25,13 +25,13 @@ import { import { isRecord } from "../attachments/shared.js"; import type { StoredConversationReference } from "../conversation-store.js"; import { formatUnknownError } from "../errors.js"; -import { resolveGraphChatId } from "../graph-upload.js"; import { fetchChannelMessage, fetchThreadReplies, formatThreadContext, resolveTeamGroupId, } from "../graph-thread.js"; +import { resolveGraphChatId } from "../graph-upload.js"; import { extractMSTeamsConversationMessageId, extractMSTeamsQuoteInfo, diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 709c836bcba..dd1c168b302 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -485,7 +485,10 @@ async function sweepSubagentRuns() { // Session-mode runs have no archiveAtMs — apply absolute TTL after cleanup completes. // Use cleanupCompletedAt (not endedAt) to avoid interrupting deferred cleanup flows. if (!entry.archiveAtMs) { - if (typeof entry.cleanupCompletedAt === "number" && now - entry.cleanupCompletedAt > SESSION_RUN_TTL_MS) { + if ( + typeof entry.cleanupCompletedAt === "number" && + now - entry.cleanupCompletedAt > SESSION_RUN_TTL_MS + ) { clearPendingLifecycleError(runId); void notifyContextEngineSubagentEnded({ childSessionKey: entry.childSessionKey, diff --git a/src/cli/exec-policy-cli.test.ts b/src/cli/exec-policy-cli.test.ts new file mode 100644 index 00000000000..215a1c0879d --- /dev/null +++ b/src/cli/exec-policy-cli.test.ts @@ -0,0 +1,553 @@ +import crypto from "node:crypto"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../infra/exec-approvals.js"; +import { stripAnsi } from "../terminal/ansi.js"; +import { registerExecPolicyCli } from "./exec-policy-cli.js"; + +function hashApprovalsFile(file: ExecApprovalsFile): string { + return crypto + .createHash("sha256") + .update(`${JSON.stringify(file, null, 2)}\n`) + .digest("hex"); +} + +const mocks = vi.hoisted(() => { + const runtimeErrors: string[] = []; + const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); + let configState: OpenClawConfig = { + tools: { + exec: { + host: "auto", + security: "allowlist", + ask: "on-miss", + }, + }, + }; + let approvalsState: ExecApprovalsFile = { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + agents: {}, + }; + const defaultRuntime = { + log: vi.fn(), + error: vi.fn((...args: unknown[]) => { + runtimeErrors.push(stringifyArgs(args)); + }), + 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 { + getConfig: () => configState, + setConfig: (next: OpenClawConfig) => { + configState = next; + }, + getApprovals: () => approvalsState, + setApprovals: (next: ExecApprovalsFile) => { + approvalsState = next; + }, + defaultRuntime, + runtimeErrors, + mutateConfigFile: vi.fn(async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => { + const draft = structuredClone(configState); + mutate(draft); + configState = draft; + return { + path: "/tmp/openclaw.json", + previousHash: "hash-1", + snapshot: { path: "/tmp/openclaw.json" }, + nextConfig: draft, + result: undefined, + }; + }), + replaceConfigFile: vi.fn( + async ({ nextConfig }: { nextConfig: OpenClawConfig; baseHash?: string }) => { + configState = structuredClone(nextConfig); + return { + path: "/tmp/openclaw.json", + previousHash: "hash-1", + snapshot: { path: "/tmp/openclaw.json" }, + nextConfig, + }; + }, + ), + readConfigFileSnapshot: vi.fn(async () => ({ + path: "/tmp/openclaw.json", + hash: "config-hash-1", + config: configState, + })), + readExecApprovalsSnapshot: vi.fn(() => ({ + path: "/tmp/exec-approvals.json", + exists: true, + raw: "{}", + hash: "approvals-hash", + file: approvalsState, + })), + restoreExecApprovalsSnapshot: vi.fn(), + saveExecApprovals: vi.fn((file: ExecApprovalsFile) => { + approvalsState = file; + }), + }; +}); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.defaultRuntime, +})); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + replaceConfigFile: mocks.replaceConfigFile, + }; +}); + +vi.mock("../infra/exec-approvals.js", async () => { + const actual = await vi.importActual( + "../infra/exec-approvals.js", + ); + return { + ...actual, + readExecApprovalsSnapshot: mocks.readExecApprovalsSnapshot, + restoreExecApprovalsSnapshot: mocks.restoreExecApprovalsSnapshot, + saveExecApprovals: mocks.saveExecApprovals, + }; +}); + +describe("exec-policy CLI", () => { + const createProgram = () => { + const program = new Command(); + program.exitOverride(); + registerExecPolicyCli(program); + return program; + }; + + const runExecPolicyCommand = async (args: string[]) => { + const program = createProgram(); + await program.parseAsync(args, { from: "user" }); + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + mocks.setConfig({ + tools: { + exec: { + host: "auto", + security: "allowlist", + ask: "on-miss", + }, + }, + }); + mocks.setApprovals({ + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + agents: {}, + }); + mocks.runtimeErrors.length = 0; + mocks.defaultRuntime.log.mockClear(); + mocks.defaultRuntime.error.mockClear(); + mocks.defaultRuntime.writeJson.mockClear(); + mocks.defaultRuntime.exit.mockClear(); + mocks.mutateConfigFile.mockReset(); + mocks.mutateConfigFile.mockImplementation( + async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => { + const draft = structuredClone(mocks.getConfig()); + mutate(draft); + mocks.setConfig(draft); + return { + path: "/tmp/openclaw.json", + previousHash: "hash-1", + snapshot: { path: "/tmp/openclaw.json" }, + nextConfig: draft, + result: undefined, + }; + }, + ); + mocks.replaceConfigFile.mockReset(); + mocks.replaceConfigFile.mockImplementation( + async ({ nextConfig }: { nextConfig: OpenClawConfig; baseHash?: string }) => { + mocks.setConfig(structuredClone(nextConfig)); + return { + path: "/tmp/openclaw.json", + previousHash: "hash-1", + snapshot: { path: "/tmp/openclaw.json" }, + nextConfig, + }; + }, + ); + mocks.readConfigFileSnapshot.mockReset(); + mocks.readConfigFileSnapshot.mockImplementation(async () => ({ + path: "/tmp/openclaw.json", + hash: "config-hash-1", + config: mocks.getConfig(), + })); + mocks.readExecApprovalsSnapshot.mockReset(); + mocks.readExecApprovalsSnapshot.mockImplementation(() => ({ + path: "/tmp/exec-approvals.json", + exists: true, + raw: "{}", + hash: "approvals-hash", + file: mocks.getApprovals(), + })); + mocks.restoreExecApprovalsSnapshot.mockReset(); + mocks.restoreExecApprovalsSnapshot.mockImplementation((_snapshot: ExecApprovalsSnapshot) => {}); + mocks.saveExecApprovals.mockReset(); + mocks.saveExecApprovals.mockImplementation((file: ExecApprovalsFile) => { + mocks.setApprovals(file); + }); + }); + + it("shows the local merged exec policy as json", async () => { + await runExecPolicyCommand(["exec-policy", "show", "--json"]); + + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + configPath: "/tmp/openclaw.json", + approvalsPath: "/tmp/exec-approvals.json", + effectivePolicy: expect.objectContaining({ + scopes: [ + expect.objectContaining({ + scopeLabel: "tools.exec", + security: expect.objectContaining({ + requested: "allowlist", + host: "allowlist", + effective: "allowlist", + }), + ask: expect.objectContaining({ + requested: "on-miss", + host: "on-miss", + effective: "on-miss", + }), + }), + ], + }), + }), + 0, + ); + }); + + it("marks host=node scopes as node-managed in show output", async () => { + mocks.setConfig({ + tools: { + exec: { + host: "node", + security: "allowlist", + ask: "on-miss", + }, + }, + }); + + await runExecPolicyCommand(["exec-policy", "show", "--json"]); + + expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + effectivePolicy: expect.objectContaining({ + note: expect.stringContaining("host=node"), + scopes: [ + expect.objectContaining({ + scopeLabel: "tools.exec", + runtimeApprovalsSource: "node-runtime", + security: expect.objectContaining({ + requested: "allowlist", + host: "unknown", + effective: "unknown", + hostSource: "node runtime approvals", + }), + ask: expect.objectContaining({ + requested: "on-miss", + host: "unknown", + effective: "unknown", + hostSource: "node runtime approvals", + }), + askFallback: expect.objectContaining({ + effective: "unknown", + source: "node runtime approvals", + }), + }), + ], + }), + }), + 0, + ); + const [{ effectivePolicy }] = mocks.defaultRuntime.writeJson.mock.calls.at(-1) as [Record< + string, + unknown + >, number]; + expect((effectivePolicy as { scopes: Record[] }).scopes[0]).not.toHaveProperty( + "allowedDecisions", + ); + }); + + it("applies the yolo preset to both config and approvals", async () => { + await runExecPolicyCommand(["exec-policy", "preset", "yolo", "--json"]); + + expect(mocks.getConfig().tools?.exec).toEqual({ + host: "gateway", + security: "full", + ask: "off", + }); + expect(mocks.getApprovals().defaults).toEqual({ + security: "full", + ask: "off", + askFallback: "full", + }); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + baseHash: "config-hash-1", + }), + ); + expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1); + expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); + }); + + it("sets explicit values without requiring a preset", async () => { + await runExecPolicyCommand([ + "exec-policy", + "set", + "--host", + "gateway", + "--security", + "full", + "--ask", + "off", + "--ask-fallback", + "allowlist", + "--json", + ]); + + expect(mocks.getConfig().tools?.exec).toEqual({ + host: "gateway", + security: "full", + ask: "off", + }); + expect(mocks.getApprovals().defaults).toEqual({ + security: "full", + ask: "off", + askFallback: "allowlist", + }); + }); + + it("sanitizes terminal control content before rendering the text table", async () => { + mocks.setConfig({ + tools: { + exec: { + host: "auto", + security: "allowlist\u001B[31m" as unknown as "allowlist", + ask: "on-miss", + }, + }, + }); + mocks.readConfigFileSnapshot.mockImplementationOnce(async () => ({ + path: "/tmp/openclaw.json\u001B[2J\nforged", + config: mocks.getConfig(), + })); + mocks.readExecApprovalsSnapshot.mockImplementationOnce(() => ({ + path: "/tmp/exec-approvals.json\u0007\nforged", + exists: true, + raw: "{}", + hash: "approvals-hash", + file: { + version: 1, + defaults: { + security: "full", + ask: "off", + askFallback: "full", + }, + agents: { + "scope\u200Bname": { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + }, + }, + })); + + await runExecPolicyCommand(["exec-policy", "show"]); + + const output = stripAnsi( + mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n"), + ); + expect(output).toContain("/tmp/openclaw.json"); + expect(output).toContain("/tmp/exec-approvals.json"); + expect(output).toContain("scope\\u{200B}name"); + expect(output).toContain("host=auto"); + expect(output).toContain("tools.exec."); + expect(output).toContain("host)"); + expect(output).toContain("\\nforged"); + expect(output).not.toContain("/tmp/openclaw.json\nforged"); + expect(output).not.toContain("\u001B[2J"); + expect(output).not.toContain("\u0007"); + }); + + it("reports invalid input once and exits once", async () => { + await expect( + runExecPolicyCommand(["exec-policy", "set", "--security", "nope"]), + ).rejects.toThrow("__exit__:1"); + + expect(mocks.defaultRuntime.error).toHaveBeenCalledTimes(1); + expect(mocks.runtimeErrors).toEqual(["Invalid exec security: nope"]); + expect(mocks.defaultRuntime.exit).toHaveBeenCalledTimes(1); + }); + + it("rejects host=node for the local-only sync path", async () => { + await expect(runExecPolicyCommand(["exec-policy", "set", "--host", "node"])).rejects.toThrow( + "__exit__:1", + ); + + expect(mocks.runtimeErrors).toEqual([ + "Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.", + ]); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.saveExecApprovals).not.toHaveBeenCalled(); + }); + + it("rejects sync when the resulting requested host remains node", async () => { + mocks.setConfig({ + tools: { + exec: { + host: "node", + security: "allowlist", + ask: "on-miss", + }, + }, + }); + + await expect( + runExecPolicyCommand(["exec-policy", "set", "--security", "full"]), + ).rejects.toThrow("__exit__:1"); + + expect(mocks.runtimeErrors).toEqual([ + "Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.", + ]); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.saveExecApprovals).not.toHaveBeenCalled(); + }); + + it("rolls back approvals if the config write fails after approvals save", async () => { + const originalApprovals = structuredClone(mocks.getApprovals()); + const originalRaw = JSON.stringify(originalApprovals, null, 2); + const originalSnapshot = { + path: "/tmp/exec-approvals.json", + exists: true, + raw: originalRaw, + hash: "approvals-hash", + file: originalApprovals, + } as ExecApprovalsSnapshot as ReturnType; + mocks.readExecApprovalsSnapshot + .mockImplementationOnce(() => originalSnapshot) + .mockImplementationOnce( + () => + ({ + path: "/tmp/exec-approvals.json", + exists: true, + raw: JSON.stringify(mocks.getApprovals(), null, 2), + hash: hashApprovalsFile(mocks.getApprovals()), + file: structuredClone(mocks.getApprovals()), + }) as ExecApprovalsSnapshot as ReturnType, + ); + mocks.replaceConfigFile.mockImplementationOnce(async () => { + throw new Error("config write failed"); + }); + + await expect( + runExecPolicyCommand(["exec-policy", "set", "--security", "full"]), + ).rejects.toThrow("__exit__:1"); + + expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1); + expect(mocks.restoreExecApprovalsSnapshot).toHaveBeenCalledWith(originalSnapshot); + expect(mocks.runtimeErrors).toEqual(["config write failed"]); + }); + + it("removes a newly-written approvals file when config replacement fails and the original file was missing", async () => { + const missingSnapshot = { + path: "/tmp/missing-exec-approvals.json", + exists: false, + raw: null, + hash: "approvals-hash", + file: { version: 1, agents: {} }, + } as ExecApprovalsSnapshot as ReturnType; + mocks.readExecApprovalsSnapshot + .mockImplementationOnce(() => missingSnapshot) + .mockImplementationOnce( + () => + ({ + path: "/tmp/missing-exec-approvals.json", + exists: true, + raw: JSON.stringify(mocks.getApprovals(), null, 2), + hash: hashApprovalsFile(mocks.getApprovals()), + file: structuredClone(mocks.getApprovals()), + }) as ExecApprovalsSnapshot as ReturnType, + ); + mocks.replaceConfigFile.mockImplementationOnce(async () => { + throw new Error("config write failed"); + }); + + await expect( + runExecPolicyCommand(["exec-policy", "set", "--security", "full"]), + ).rejects.toThrow("__exit__:1"); + + expect(mocks.restoreExecApprovalsSnapshot).toHaveBeenCalledWith(missingSnapshot); + }); + + it("does not clobber a newer approvals write during rollback", async () => { + const originalApprovals = structuredClone(mocks.getApprovals()); + const originalRaw = JSON.stringify(originalApprovals, null, 2); + const originalSnapshot: ExecApprovalsSnapshot = { + path: "/tmp/exec-approvals.json", + exists: true, + raw: originalRaw, + hash: "original-hash", + file: originalApprovals, + }; + const concurrentFile: ExecApprovalsFile = { + version: 1, + defaults: { + security: "deny", + ask: "off", + askFallback: "deny", + }, + agents: {}, + }; + const concurrentSnapshot = { + path: "/tmp/exec-approvals.json", + exists: true, + raw: JSON.stringify(concurrentFile, null, 2), + hash: "concurrent-write-hash", + file: concurrentFile, + } as ExecApprovalsSnapshot as ReturnType; + let snapshotReadCount = 0; + mocks.readExecApprovalsSnapshot.mockImplementation(() => { + snapshotReadCount += 1; + return snapshotReadCount === 1 ? originalSnapshot : concurrentSnapshot; + }); + mocks.replaceConfigFile.mockImplementationOnce(async () => { + throw new Error("config write failed"); + }); + + await expect( + runExecPolicyCommand(["exec-policy", "set", "--security", "full"]), + ).rejects.toThrow("__exit__:1"); + + expect(mocks.restoreExecApprovalsSnapshot).not.toHaveBeenCalled(); + expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1); + expect(mocks.runtimeErrors).toEqual(["config write failed"]); + }); +}); diff --git a/src/cli/exec-policy-cli.ts b/src/cli/exec-policy-cli.ts new file mode 100644 index 00000000000..e511f1b6cda --- /dev/null +++ b/src/cli/exec-policy-cli.ts @@ -0,0 +1,442 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import type { Command } from "commander"; +import type { OpenClawConfig } from "../config/config.js"; +import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { sanitizeExecApprovalDisplayText } from "../infra/exec-approval-command-display.js"; +import { + collectExecPolicyScopeSnapshots, + type ExecPolicyScopeSnapshot, +} from "../infra/exec-approvals-effective.js"; +import { + normalizeExecAsk, + normalizeExecSecurity, + normalizeExecTarget, + readExecApprovalsSnapshot, + restoreExecApprovalsSnapshot, + saveExecApprovals, + type ExecApprovalsFile, + type ExecAsk, + type ExecSecurity, + type ExecTarget, +} from "../infra/exec-approvals.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; +import { isRich, theme } from "../terminal/theme.js"; + +type ExecPolicyPresetName = "yolo" | "cautious" | "deny-all"; + +type ExecPolicyResolved = { + host?: ExecTarget; + security?: ExecSecurity; + ask?: ExecAsk; + askFallback?: ExecSecurity; +}; + +const EXEC_POLICY_PRESETS: Record> = { + yolo: { + host: "gateway", + security: "full", + ask: "off", + askFallback: "full", + }, + cautious: { + host: "gateway", + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + "deny-all": { + host: "gateway", + security: "deny", + ask: "off", + askFallback: "deny", + }, +}; + +type ExecPolicyShowPayload = { + configPath: string; + approvalsPath: string; + approvalsExists: boolean; + effectivePolicy: { + note: string; + scopes: ExecPolicyShowScope[]; + }; +}; + +type ExecPolicyShowSecurity = ExecSecurity | "unknown"; +type ExecPolicyShowAsk = ExecAsk | "unknown"; + +type ExecPolicyShowScope = Omit< + ExecPolicyScopeSnapshot, + "security" | "ask" | "askFallback" | "allowedDecisions" +> & { + runtimeApprovalsSource: "local-file" | "node-runtime"; + security: { + requested: ExecSecurity; + requestedSource: string; + host: ExecPolicyShowSecurity; + hostSource: string; + effective: ExecPolicyShowSecurity; + note: string; + }; + ask: { + requested: ExecAsk; + requestedSource: string; + host: ExecPolicyShowAsk; + hostSource: string; + effective: ExecPolicyShowAsk; + note: string; + }; + askFallback: { + effective: ExecPolicyShowSecurity; + source: string; + }; +}; + +class ExecPolicyCliError extends Error { + constructor(message: string) { + super(message); + this.name = "ExecPolicyCliError"; + } +} + +function failExecPolicy(message: string): never { + throw new ExecPolicyCliError(message); +} + +function formatExecPolicyError(err: unknown): string { + return sanitizeExecPolicyMessage(err instanceof Error ? err.message : String(err)); +} + +async function runExecPolicyAction(action: () => Promise): Promise { + try { + await action(); + } catch (err) { + defaultRuntime.error(formatExecPolicyError(err)); + defaultRuntime.exit(1); + } +} + +function sanitizeExecPolicyTableCell(value: string): string { + return sanitizeExecApprovalDisplayText(sanitizeTerminalText(value)); +} + +function sanitizeExecPolicyMessage(value: unknown): string { + return sanitizeTerminalText(String(value)); +} + +function hashExecApprovalsFile(file: ExecApprovalsFile): string { + const raw = `${JSON.stringify(file, null, 2)}\n`; + return crypto.createHash("sha256").update(raw).digest("hex"); +} + +function resolveExecPolicyInput(params: { + host?: string; + security?: string; + ask?: string; + askFallback?: string; +}): ExecPolicyResolved { + const resolved: ExecPolicyResolved = {}; + if (params.host !== undefined) { + const host = normalizeExecTarget(params.host); + if (!host) { + failExecPolicy(`Invalid exec host: ${sanitizeExecPolicyMessage(params.host)}`); + } + resolved.host = host; + } + if (params.security !== undefined) { + const security = normalizeExecSecurity(params.security); + if (!security) { + failExecPolicy(`Invalid exec security: ${sanitizeExecPolicyMessage(params.security)}`); + } + resolved.security = security; + } + if (params.ask !== undefined) { + const ask = normalizeExecAsk(params.ask); + if (!ask) { + failExecPolicy(`Invalid exec ask mode: ${sanitizeExecPolicyMessage(params.ask)}`); + } + resolved.ask = ask; + } + if (params.askFallback !== undefined) { + const askFallback = normalizeExecSecurity(params.askFallback); + if (!askFallback) { + failExecPolicy(`Invalid exec askFallback: ${sanitizeExecPolicyMessage(params.askFallback)}`); + } + resolved.askFallback = askFallback; + } + return resolved; +} + +function applyConfigExecPolicy(draft: Record, policy: ExecPolicyResolved): void { + const root = draft as { + tools?: { + exec?: { + host?: ExecTarget; + security?: ExecSecurity; + ask?: ExecAsk; + }; + }; + }; + root.tools ??= {}; + root.tools.exec ??= {}; + if (policy.host !== undefined) { + root.tools.exec.host = policy.host; + } + if (policy.security !== undefined) { + root.tools.exec.security = policy.security; + } + if (policy.ask !== undefined) { + root.tools.exec.ask = policy.ask; + } +} + +function applyApprovalsDefaults( + file: ExecApprovalsFile, + policy: ExecPolicyResolved, +): ExecApprovalsFile { + const next: ExecApprovalsFile = structuredClone(file ?? { version: 1 }); + next.version = 1; + next.defaults ??= {}; + if (policy.security !== undefined) { + next.defaults.security = policy.security; + } + if (policy.ask !== undefined) { + next.defaults.ask = policy.ask; + } + if (policy.askFallback !== undefined) { + next.defaults.askFallback = policy.askFallback; + } + return next; +} + +function buildNextExecPolicyConfig( + config: OpenClawConfig, + policy: ExecPolicyResolved, +): OpenClawConfig { + const draft = structuredClone(config); + applyConfigExecPolicy(draft as Record, policy); + return draft; +} + +async function buildLocalExecPolicyShowPayload(): Promise { + const configSnapshot = await readConfigFileSnapshot(); + const approvalsSnapshot = readExecApprovalsSnapshot(); + const scopes = collectExecPolicyScopeSnapshots({ + cfg: configSnapshot.config ?? {}, + approvals: approvalsSnapshot.file, + hostPath: approvalsSnapshot.path, + }).map(buildExecPolicyShowScope); + const hasNodeRuntimeScope = scopes.some((scope) => scope.runtimeApprovalsSource === "node-runtime"); + return { + configPath: configSnapshot.path, + approvalsPath: approvalsSnapshot.path, + approvalsExists: approvalsSnapshot.exists, + effectivePolicy: { + note: hasNodeRuntimeScope + ? "Scopes requesting host=node are node-managed at runtime. Local approvals are shown only for local/gateway scopes." + : "Effective exec policy is the host approvals file intersected with requested tools.exec policy.", + scopes, + }, + }; +} + +function buildExecPolicyShowScope(snapshot: ExecPolicyScopeSnapshot): ExecPolicyShowScope { + const { allowedDecisions: _allowedDecisions, ...baseScope } = snapshot; + if (snapshot.host.requested !== "node") { + return { + ...baseScope, + runtimeApprovalsSource: "local-file", + }; + } + return { + ...baseScope, + runtimeApprovalsSource: "node-runtime", + security: { + requested: snapshot.security.requested, + requestedSource: snapshot.security.requestedSource, + host: "unknown", + hostSource: "node runtime approvals", + effective: "unknown", + note: "runtime policy resolved by node approvals", + }, + ask: { + requested: snapshot.ask.requested, + requestedSource: snapshot.ask.requestedSource, + host: "unknown", + hostSource: "node runtime approvals", + effective: "unknown", + note: "runtime policy resolved by node approvals", + }, + askFallback: { + effective: "unknown", + source: "node runtime approvals", + }, + }; +} + +function renderExecPolicyShow(payload: ExecPolicyShowPayload): void { + const rich = isRich(); + const heading = (text: string) => (rich ? theme.heading(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + defaultRuntime.log(heading("Exec Policy")); + defaultRuntime.log( + renderTable({ + width: getTerminalTableWidth(), + columns: [ + { key: "Field", header: "Field", minWidth: 14 }, + { key: "Value", header: "Value", minWidth: 24, flex: true }, + ], + rows: [ + { Field: "Config", Value: sanitizeExecPolicyTableCell(payload.configPath) }, + { Field: "Approvals", Value: sanitizeExecPolicyTableCell(payload.approvalsPath) }, + { + Field: "Approvals File", + Value: sanitizeExecPolicyTableCell(payload.approvalsExists ? "present" : "missing"), + }, + ], + }).trimEnd(), + ); + defaultRuntime.log(""); + defaultRuntime.log(heading("Effective Policy")); + defaultRuntime.log( + renderTable({ + width: getTerminalTableWidth(), + columns: [ + { key: "Scope", header: "Scope", minWidth: 12 }, + { key: "Requested", header: "Requested", minWidth: 24, flex: true }, + { key: "Host", header: "Host", minWidth: 24, flex: true }, + { key: "Effective", header: "Effective", minWidth: 16 }, + ], + rows: payload.effectivePolicy.scopes.map((scope) => ({ + Scope: sanitizeExecPolicyTableCell(scope.scopeLabel), + Requested: sanitizeExecPolicyTableCell( + `host=${scope.host.requested} (${scope.host.requestedSource})\n` + + `security=${scope.security.requested} (${scope.security.requestedSource})\n` + + `ask=${scope.ask.requested} (${scope.ask.requestedSource})`, + ), + Host: sanitizeExecPolicyTableCell( + `security=${scope.security.host} (${scope.security.hostSource})\n` + + `ask=${scope.ask.host} (${scope.ask.hostSource})\n` + + `askFallback=${scope.askFallback.effective} (${scope.askFallback.source})`, + ), + Effective: sanitizeExecPolicyTableCell( + `security=${scope.security.effective}\nask=${scope.ask.effective}`, + ), + })), + }).trimEnd(), + ); + defaultRuntime.log(""); + defaultRuntime.log(muted(payload.effectivePolicy.note)); +} + +async function applyLocalExecPolicy(policy: ExecPolicyResolved): Promise { + const configSnapshot = await readConfigFileSnapshot(); + const nextConfig = buildNextExecPolicyConfig(configSnapshot.config ?? {}, policy); + if (nextConfig.tools?.exec?.host === "node") { + failExecPolicy( + "Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.", + ); + } + const approvalsSnapshot = readExecApprovalsSnapshot(); + const nextApprovals = applyApprovalsDefaults(approvalsSnapshot.file, policy); + const writtenApprovalsHash = hashExecApprovalsFile(nextApprovals); + saveExecApprovals(nextApprovals); + try { + await replaceConfigFile({ + baseHash: configSnapshot.hash, + nextConfig, + }); + } catch (err) { + const currentApprovalsSnapshot = readExecApprovalsSnapshot(); + if (currentApprovalsSnapshot.hash !== writtenApprovalsHash) { + throw err; + } + restoreExecApprovalsSnapshot(approvalsSnapshot); + throw err; + } + return await buildLocalExecPolicyShowPayload(); +} + +export function registerExecPolicyCli(program: Command) { + const execPolicy = program + .command("exec-policy") + .description("Show or synchronize requested exec policy with host approvals") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`, + ); + + execPolicy + .command("show") + .description("Show the local config policy, host approvals, and effective merge") + .option("--json", "Output as JSON", false) + .action(async (opts: { json?: boolean }) => { + await runExecPolicyAction(async () => { + const payload = await buildLocalExecPolicyShowPayload(); + if (opts.json) { + defaultRuntime.writeJson(payload, 0); + return; + } + renderExecPolicyShow(payload); + }); + }); + + execPolicy + .command("preset ") + .description('Apply a synchronized preset: "yolo", "cautious", or "deny-all"') + .option("--json", "Output as JSON", false) + .action(async (name: string, opts: { json?: boolean }) => { + await runExecPolicyAction(async () => { + if (!Object.hasOwn(EXEC_POLICY_PRESETS, name)) { + failExecPolicy(`Unknown exec-policy preset: ${sanitizeExecPolicyMessage(name)}`); + } + const preset = EXEC_POLICY_PRESETS[name as ExecPolicyPresetName]; + const payload = await applyLocalExecPolicy(preset); + if (opts.json) { + defaultRuntime.writeJson({ preset: name, ...payload }, 0); + return; + } + defaultRuntime.log(`Applied exec-policy preset: ${sanitizeExecPolicyMessage(name)}`); + defaultRuntime.log(""); + renderExecPolicyShow(payload); + }); + }); + + execPolicy + .command("set") + .description("Synchronize local config and host approvals using explicit values") + .option("--host ", "Exec host target: auto|sandbox|gateway|node") + .option("--security ", "Exec security: deny|allowlist|full") + .option("--ask ", "Exec ask mode: off|on-miss|always") + .option("--ask-fallback ", "Host approvals fallback: deny|allowlist|full") + .option("--json", "Output as JSON", false) + .action( + async (opts: { + host?: string; + security?: string; + ask?: string; + askFallback?: string; + json?: boolean; + }) => { + await runExecPolicyAction(async () => { + const policy = resolveExecPolicyInput(opts); + if (Object.keys(policy).length === 0) { + failExecPolicy("Provide at least one of --host, --security, --ask, or --ask-fallback."); + } + const payload = await applyLocalExecPolicy(policy); + if (opts.json) { + defaultRuntime.writeJson({ applied: policy, ...payload }, 0); + return; + } + defaultRuntime.log("Synchronized local exec policy."); + defaultRuntime.log(""); + renderExecPolicyShow(payload); + }); + }, + ); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 997bd9c7880..4c2aa7f20bb 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -84,6 +84,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ loadModule: () => import("../exec-approvals-cli.js"), exportName: "registerExecApprovalsCli", }, + { + commandNames: ["exec-policy"], + loadModule: () => import("../exec-policy-cli.js"), + exportName: "registerExecPolicyCli", + }, { commandNames: ["nodes"], loadModule: () => import("../nodes-cli.js"), diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 01eabf96b6e..f95ce47c5d5 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -37,6 +37,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([ description: "Manage exec approvals (gateway or node host)", hasSubcommands: true, }, + { + name: "exec-policy", + description: "Show or synchronize requested exec policy with host approvals", + hasSubcommands: true, + }, { name: "nodes", description: "Manage gateway-owned node pairing and node commands", diff --git a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts index 0991dbb6a87..92eeb4f9252 100644 --- a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts +++ b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts @@ -52,11 +52,7 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = createCliDeps(); mockAgentPayloads( - [ - { text: "section 1" }, - { text: "temporary error", isError: true }, - { text: "section 2" }, - ], + [{ text: "section 1" }, { text: "temporary error", isError: true }, { text: "section 2" }], { meta: makeRunMeta("section 1\nsection 2") }, ); @@ -105,10 +101,9 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { await withTempCronHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = createCliDeps(); - mockAgentPayloads( - [{ text: "Working on it..." }, { text: "Final weather summary" }], - { meta: makeRunMeta("Final weather summary") }, - ); + mockAgentPayloads([{ text: "Working on it..." }, { text: "Final weather summary" }], { + meta: makeRunMeta("Final weather summary"), + }); const plainRes = await runTelegramAnnounceTurn({ home, diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index 36544a735b3..2665465e2d4 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -10,6 +10,7 @@ import { type ExecApprovalsFile, type ExecAsk, type ExecSecurity, + type ExecTarget, } from "./exec-approvals.js"; const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full"; @@ -20,10 +21,16 @@ const REQUESTED_DEFAULT_LABEL = { ask: DEFAULT_REQUESTED_ASK, } as const; type ExecPolicyConfig = { + host?: ExecTarget; security?: ExecSecurity; ask?: ExecAsk; }; +export type ExecPolicyHostSummary = { + requested: ExecTarget; + requestedSource: string; +}; + export type ExecPolicyFieldSummary = { requested: TValue; requestedSource: string; @@ -37,6 +44,7 @@ export type ExecPolicyScopeSnapshot = { scopeLabel: string; configPath: string; agentId?: string; + host: ExecPolicyHostSummary; security: ExecPolicyFieldSummary; ask: ExecPolicyFieldSummary; askFallback: { @@ -50,6 +58,30 @@ export type ExecPolicyScopeSummary = Omit({ field: "ask", scopeExecConfig: params.scopeExecConfig, @@ -203,6 +239,13 @@ export function resolveExecPolicyScopeSnapshot(params: { scopeLabel: params.scopeLabel, configPath: params.configPath, ...(params.agentId ? { agentId: params.agentId } : {}), + host: { + requested: requestedHost.value, + requestedSource: + requestedHost.sourcePath === "__default__" + ? "OpenClaw default (auto)" + : `${requestedHost.sourcePath === "scope" ? params.configPath : requestedHost.sourcePath}.host`, + }, security: { requested: requestedSecurity.value, requestedSource: formatRequestedSource({ diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 3e3703a7fdf..3e2881ac6b1 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -25,6 +25,7 @@ let recordAllowlistUse: ExecApprovalsModule["recordAllowlistUse"]; let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"]; let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"]; let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"]; +let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"]; const tempDirs: string[] = []; const originalOpenClawHome = process.env.OPENCLAW_HOME; @@ -43,6 +44,7 @@ beforeAll(async () => { requestExecApprovalViaSocket, resolveExecApprovalsPath, resolveExecApprovalsSocketPath, + saveExecApprovals, } = await import("./exec-approvals.js")); }); @@ -156,6 +158,48 @@ describe("exec approvals store helpers", () => { expect(readApprovalsFile(dir).socket).toEqual(ensured.socket); }); + it("atomically replaces existing approvals files instead of mutating linked inodes", () => { + const dir = createHomeDir(); + const approvalsPath = approvalsFilePath(dir); + const linkedPath = path.join(dir, "linked.json"); + fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); + fs.writeFileSync(linkedPath, '{"sentinel":true}\n', "utf8"); + fs.linkSync(linkedPath, approvalsPath); + + saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); + + expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); + expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n'); + expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino); + }); + + it("refuses to write approvals through a symlink destination", () => { + const dir = createHomeDir(); + const approvalsPath = approvalsFilePath(dir); + const targetPath = path.join(dir, "elsewhere.json"); + fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); + fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8"); + fs.symlinkSync(targetPath, approvalsPath); + + expect(() => + saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), + ).toThrow(/Refusing to write exec approvals via symlink/); + expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n'); + }); + + it("refuses to traverse a symlinked parent component in the approvals path", () => { + const realHome = makeTempDir(); + const linkedHome = `${realHome}-link`; + tempDirs.push(realHome); + fs.symlinkSync(realHome, linkedHome); + process.env.OPENCLAW_HOME = linkedHome; + + expect(() => + saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), + ).toThrow(/Refusing to traverse symlink in exec approvals path/); + expect(fs.existsSync(path.join(realHome, ".openclaw"))).toBe(false); + }); + it("adds trimmed allowlist entries once and persists generated ids", () => { const dir = createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(123_456); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 82841aeacb8..8cfe05884db 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -11,7 +11,7 @@ import { import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js"; import type { ExecCommandSegment } from "./exec-approvals-analysis.js"; import type { ExecAllowlistEntry } from "./exec-approvals.types.js"; -import { expandHomePrefix } from "./home-dir.js"; +import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js"; import { requestJsonlSocket } from "./jsonl-socket.js"; export * from "./exec-approvals-analysis.js"; export * from "./exec-approvals-allowlist.js"; @@ -229,7 +229,53 @@ function mergeLegacyAgent( function ensureDir(filePath: string) { const dir = path.dirname(filePath); + assertNoSymlinkPathComponents(dir, resolveRequiredHomeDir()); fs.mkdirSync(dir, { recursive: true }); + const dirStat = fs.lstatSync(dir); + if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) { + throw new Error(`Refusing to use unsafe exec approvals directory: ${dir}`); + } + return dir; +} + +function assertNoSymlinkPathComponents(targetPath: string, trustedRoot: string): void { + const resolvedTarget = path.resolve(targetPath); + const resolvedRoot = path.resolve(trustedRoot); + if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) { + return; + } + + const relative = path.relative(resolvedRoot, resolvedTarget); + const segments = relative && relative !== "." ? relative.split(path.sep) : []; + let current = resolvedRoot; + for (const segment of [".", ...segments]) { + if (segment !== ".") { + current = path.join(current, segment); + } + try { + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + } +} + +function assertSafeExecApprovalsDestination(filePath: string): void { + try { + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write exec approvals via symlink: ${filePath}`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } } // Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread @@ -434,8 +480,24 @@ export function loadExecApprovals(): ExecApprovalsFile { export function saveExecApprovals(file: ExecApprovalsFile) { const filePath = resolveExecApprovalsPath(); - ensureDir(filePath); - fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + const raw = `${JSON.stringify(file, null, 2)}\n`; + writeExecApprovalsRaw(filePath, raw); +} + +function writeExecApprovalsRaw(filePath: string, raw: string) { + const dir = ensureDir(filePath); + assertSafeExecApprovalsDestination(filePath); + const tempPath = path.join(dir, `.exec-approvals.${process.pid}.${crypto.randomUUID()}.tmp`); + let tempWritten = false; + try { + fs.writeFileSync(tempPath, raw, { mode: 0o600, flag: "wx" }); + tempWritten = true; + fs.renameSync(tempPath, filePath); + } finally { + if (tempWritten && fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } + } try { fs.chmodSync(filePath, 0o600); } catch { @@ -443,6 +505,18 @@ export function saveExecApprovals(file: ExecApprovalsFile) { } } +export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): void { + if (!snapshot.exists) { + fs.rmSync(snapshot.path, { force: true }); + return; + } + if (snapshot.raw !== null) { + writeExecApprovalsRaw(snapshot.path, snapshot.raw); + return; + } + saveExecApprovals(snapshot.file); +} + export function ensureExecApprovals(): ExecApprovalsFile { const loaded = loadExecApprovals(); const next = normalizeExecApprovals(loaded);