diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 2ca14ea3b9b..72d71520c45 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -9,7 +9,7 @@ title: "Approvals" # `openclaw approvals` Manage exec approvals for the **local host**, **gateway host**, or a **node host**. -By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node. +By default, commands target the local approvals state in SQLite. Use `--gateway` to target the gateway, or `--node` to target a specific node. Alias: `openclaw exec-approvals` @@ -21,13 +21,13 @@ Related: ## `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. +`tools.exec.*` config and the local host approvals state aligned in one step. Use it when you want to: -- inspect the local requested policy, host approvals file, and effective merge +- inspect the local requested policy, host approvals state, and effective merge - apply a local preset such as YOLO or deny-all -- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json` +- synchronize local `tools.exec.*` and local exec approvals state Examples: @@ -49,10 +49,10 @@ Output modes: Current scope: - `exec-policy` is **local-only** -- it updates the local config file and the local approvals file together +- it updates the local config file and the local approvals state 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 +- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from local approvals state If you need to edit remote host approvals directly, keep using `openclaw approvals set --gateway` or `openclaw approvals set --node `. @@ -73,9 +73,9 @@ openclaw approvals get --gateway Precedence is intentional: -- the host approvals file is the enforceable source of truth +- the host approvals state is the enforceable source of truth - requested `tools.exec` policy can narrow or broaden intent, but the effective result is still derived from the host rules -- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime +- `--node` combines the node host approvals state with gateway `tools.exec` policy, because both still 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 ## Replace approvals from a file @@ -123,7 +123,7 @@ openclaw approvals set --node --stdin <<'EOF' EOF ``` -This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set: +This changes the **host approvals state** only. To keep the requested OpenClaw policy aligned, also set: ```bash openclaw config set tools.exec.host gateway @@ -169,8 +169,8 @@ openclaw approvals allowlist remove "~/Projects/**/bin/rg" Targeting notes: -- no target flags means the local approvals file on disk -- `--gateway` targets the gateway host approvals file +- no target flags means the local approvals state +- `--gateway` targets the gateway host approvals state - `--node` targets one node host after resolving id, name, IP, or id prefix `allowlist add|remove` also supports: @@ -182,7 +182,7 @@ Targeting notes: - `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix). - `--agent` defaults to `"*"`, which applies to all agents. - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). -- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`. +- Approvals are stored per host in the SQLite state database. Legacy `~/.openclaw/exec-approvals.json` files are imported by `openclaw doctor --fix`. ## Related diff --git a/docs/cli/node.md b/docs/cli/node.md index b93ee69abea..127d27bb49d 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -160,7 +160,7 @@ the SQLite state database. `system.run` is gated by local exec approvals: -- `~/.openclaw/exec-approvals.json` +- host-local SQLite approvals state - [Exec approvals](/tools/exec-approvals) - `openclaw approvals --node ` (edit from the Gateway) diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index f4923f609a8..3b58682bc36 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -91,7 +91,7 @@ exhaustive): | `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` fails closed when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | | `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | | `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no | -| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no | +| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | SQLite exec approvals state | no | | `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no | | `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | | `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no | diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 0f89e892ed6..09c6565a0ed 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -68,7 +68,7 @@ forwards `exec` calls to the **node host** when `host=node` is selected. - **Gateway host**: receives messages, runs the model, routes tool calls. - **Node host**: executes `system.run`/`system.which` on the node machine. -- **Approvals**: enforced on the node host via `~/.openclaw/exec-approvals.json`. +- **Approvals**: enforced on the node host via host-local SQLite approvals state. Approval note: @@ -149,7 +149,7 @@ openclaw approvals allowlist add --node "/usr/bin/uname" openclaw approvals allowlist add --node "/usr/bin/sw_vers" ``` -Approvals live on the node host at `~/.openclaw/exec-approvals.json`. +Approvals live in the node host's SQLite state database. ### Point exec at the node @@ -379,7 +379,7 @@ Notes: - Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals). Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`. -- On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`). +- On headless node host, `system.run` is gated by exec approvals in the local SQLite state database. ## Exec node binding @@ -426,7 +426,7 @@ Notes: - Pairing is still required (the Gateway will show a device pairing prompt). - The node host stores its node id, token, display name, and gateway connection info in the SQLite state database. -- Exec approvals are enforced locally via `~/.openclaw/exec-approvals.json` +- Exec approvals are enforced locally via SQLite approvals state (see [Exec approvals](/tools/exec-approvals)). - On macOS, the headless node host executes `system.run` locally by default. Set `OPENCLAW_NODE_EXEC_HOST=app` to route `system.run` through the companion app exec host; add diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 611fe634567..e85c73b6c46 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -75,10 +75,10 @@ Gateway -> Node Service (WS) ## Exec approvals (system.run) `system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals). -Security + ask + allowlist are stored locally on the Mac in: +Security + ask + allowlist are stored locally on the Mac in SQLite: ``` -~/.openclaw/exec-approvals.json +~/.openclaw/state/openclaw.sqlite ``` Example: diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 7cdf677f9ef..fec8acbb654 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -116,6 +116,9 @@ The branch already has a real shared SQLite base: run artifact, and scoped cache stores for workers. - Workspace bootstrap completion markers now live in shared SQLite KV keyed by resolved workspace path instead of `.openclaw/workspace-state.json`. +- Exec approvals now live in shared SQLite KV (`exec.approvals/current`). + Doctor imports legacy `~/.openclaw/exec-approvals.json`; runtime writes no + longer create or rewrite that file. - `src/commands/doctor-sqlite-state.ts` already imports several legacy JSON state files, including node host config, into SQLite from doctor. - `src/infra/state-migrations.ts` already imports legacy `sessions.json` and diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index c077d338a8e..1cbe1ed9861 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -102,7 +102,7 @@ automatically. ### Safe bins versus allowlist -| Topic | `tools.exec.safeBins` | Allowlist (`exec-approvals.json`) | +| Topic | `tools.exec.safeBins` | Exec approvals allowlist | | ---------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | | Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables | | Match type | Executable name + safe-bin argv policy | Resolved executable path glob, or bare command-name glob for PATH-invoked commands | @@ -115,7 +115,7 @@ Configuration location: - `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). - `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`). - `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. -- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). +- allowlist entries live in host-local SQLite approvals state under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). - `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. - `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded. @@ -348,7 +348,7 @@ Gateway -> Node Service (WS) Security notes: -- Unix socket mode `0600`, token stored in `exec-approvals.json`. +- Unix socket mode `0600`, token stored in SQLite approvals state. - Same-UID peer check. - Challenge/response (nonce + HMAC token + request hash) + short TTL. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index e616bf286bd..2873c79b9c3 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -19,21 +19,21 @@ skips approvals). Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used. Host exec also uses local approvals state on that machine - a -host-local `ask: "always"` in `~/.openclaw/exec-approvals.json` keeps +host-local `ask: "always"` in SQLite approvals state keeps prompting even if session or config defaults request `ask: "on-miss"`. ## Inspecting the effective policy -| Command | What it shows | -| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `openclaw approvals get` / `--gateway` / `--node ` | Requested policy, host policy sources, and the effective result. | -| `openclaw exec-policy show` | Local-machine merged view. | -| `openclaw exec-policy set` / `preset` | Synchronize the local requested policy with the local host approvals file in one step. | +| Command | What it shows | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `openclaw approvals get` / `--gateway` / `--node ` | Requested policy, host policy sources, and the effective result. | +| `openclaw exec-policy show` | Local-machine merged view. | +| `openclaw exec-policy set` / `preset` | Synchronize the local requested policy with local host approvals state in one step. | When a local scope requests `host=node`, `exec-policy show` reports that scope as node-managed at runtime instead of pretending the local -approvals file is the source of truth. +approvals state is the source of truth. If the companion app UI is **not available**, any request that would normally prompt is resolved by the **ask fallback** (default: `deny`). @@ -68,13 +68,14 @@ Exec approvals are enforced locally on the execution host: ## Settings and storage -Approvals live in a local JSON file on the execution host: +Approvals live in the local SQLite state database on the execution host: ```text -~/.openclaw/exec-approvals.json +~/.openclaw/state/openclaw.sqlite ``` -Example schema: +Legacy `~/.openclaw/exec-approvals.json` files are migration inputs for +`openclaw doctor --fix`. The logical record keeps the same JSON shape: ```json { @@ -169,8 +170,7 @@ automatically. If you want host exec to run without approval prompts, you must open **both** policy layers - requested exec policy in OpenClaw config -(`tools.exec.*`) **and** host-local approvals policy in -`~/.openclaw/exec-approvals.json`. +(`tools.exec.*`) **and** host-local approvals policy in SQLite. YOLO is the default host behavior unless you tighten it explicitly: @@ -212,7 +212,7 @@ If you want a more conservative setup, tighten either layer back to openclaw gateway restart ``` - + ```bash openclaw approvals set --stdin <<'EOF' { @@ -237,7 +237,7 @@ openclaw exec-policy preset yolo That local shortcut updates both: - Local `tools.exec.host/security/ask`. -- Local `~/.openclaw/exec-approvals.json` defaults. +- Local approvals defaults. It is intentionally local-only. To change gateway-host or node-host approvals remotely, use `openclaw approvals set --gateway` or @@ -245,7 +245,7 @@ approvals remotely, use `openclaw approvals set --gateway` or ### Node host -For a node host, apply the same approvals file on that node instead: +For a node host, apply the same approvals state on that node instead: ```bash openclaw approvals set --node --stdin <<'EOF' @@ -274,7 +274,7 @@ EOF - `/exec security=full ask=off` changes only the current session. - `/elevated full` is a break-glass shortcut that also skips exec approvals for that session. -If the host approvals file stays stricter than config, the stricter host +If the host approvals state stays stricter than config, the stricter host policy still wins. ## Allowlist (per agent) @@ -377,7 +377,7 @@ shows last-used metadata per pattern so you can keep the list tidy. The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes must advertise `system.execApprovals.get/set` (macOS app or headless node host). If a node does not advertise exec approvals yet, -edit its local `~/.openclaw/exec-approvals.json` directly. +upgrade the node host and use `openclaw approvals set --node ...`. CLI: `openclaw approvals` supports gateway or node editing - see [Approvals CLI](/cli/approvals). diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 0278c4d5c66..37bc9943030 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -67,7 +67,7 @@ Notes: - `auto` is the default routing strategy, not a wildcard. Per-call `host=node` is allowed from `auto`; per-call `host=gateway` is only allowed when no sandbox runtime is active. - With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox. - `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider. -- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`. +- `gateway`/`node` approvals are controlled by host-local SQLite approvals state. - `node` requires a paired node (companion app or headless node host). - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - `exec host=node` is the only shell-execution path for nodes; the legacy `nodes.run` wrapper has been removed. @@ -101,7 +101,7 @@ Notes: - `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise) - `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset) - `tools.exec.ask` (default: `off`) -- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). +- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals state; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). - YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`. - In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer. - `tools.exec.node` (default: unset) diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 452ca807baa..952d391c1ad 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -36,6 +36,7 @@ const legacyStoreMarkers = [ }, { label: "subagent registry JSON", pattern: /\bsubagents[/\\]runs\.json\b/u }, { label: "OpenRouter model cache JSON", pattern: /\bopenrouter-models\.json\b/u }, + { label: "exec approvals JSON", pattern: /\bexec-approvals\.json\b/u }, { label: "ACPX process leases JSON", pattern: /\bprocess-leases\.json\b/u }, { label: "ACPX gateway instance id file", pattern: /\bgateway-instance-id\b/u }, { label: "gateway restart sentinel JSON", pattern: /\brestart-sentinel\.json\b/u }, diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 245562b2304..6bbd1ec9645 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -392,9 +392,9 @@ export function buildHeadlessExecApprovalDeniedMessage(params: { return [ `exec denied: ${runLabel} cannot wait for interactive exec approval.`, `Effective host exec policy: security=${params.security} ask=${params.ask} askFallback=${params.askFallback}`, - "Stricter values from tools.exec and ~/.openclaw/exec-approvals.json both apply.", + "Stricter values from tools.exec and SQLite exec approvals state both apply.", "Fix one of these:", - '- align both files to security="full" and ask="off" for trusted local automation', + '- align config and approvals state to security="full" and ask="off" for trusted local automation', "- keep allowlist mode and add an explicit allowlist entry for this command", "- enable Web UI, terminal UI, or chat exec approvals and rerun interactively", 'Tip: run "openclaw doctor" and "openclaw approvals get --gateway" to inspect the effective policy.', diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 60b71f2aff8..f17e41615b1 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1351,7 +1351,7 @@ export function createExecTool( if (elevatedRequested && elevatedMode === "full") { security = "full"; } - // Keep local exec defaults in sync with exec-approvals.json when tools.exec.* is unset. + // Keep local exec defaults in sync with approvals state when tools.exec.* is unset. const configuredAsk = defaults?.ask ?? approvalDefaults?.ask ?? "off"; const requestedAsk = normalizeExecAsk(params.ask); let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 4bb7b2cc06a..4a3784efb08 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -190,7 +190,7 @@ describe("exec approvals CLI", () => { expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ effectivePolicy: { - note: "Effective exec policy is the host approvals file intersected with requested tools.exec policy.", + note: "Effective exec policy is the host approvals state intersected with requested tools.exec policy.", scopes: [ expect.objectContaining({ scopeLabel: "tools.exec", @@ -301,7 +301,7 @@ describe("exec approvals CLI", () => { expect(defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ effectivePolicy: { - note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.", + note: "Effective exec policy is the node host approvals state intersected with gateway tools.exec policy.", scopes: [ expect.objectContaining({ scopeLabel: "tools.exec", diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 4df0a64666e..ccd4b01caf1 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -221,7 +221,7 @@ function buildEffectivePolicyReport(params: { approvals: params.approvals, hostPath: params.hostPath, }), - note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.", + note: "Effective exec policy is the node host approvals state intersected with gateway tools.exec policy.", }; } if (!cfg) { @@ -236,7 +236,7 @@ function buildEffectivePolicyReport(params: { approvals: params.approvals, hostPath: params.hostPath, }), - note: "Effective exec policy is the host approvals file intersected with requested tools.exec policy.", + note: "Effective exec policy is the host approvals state intersected with requested tools.exec policy.", }; } diff --git a/src/cli/exec-policy-cli.ts b/src/cli/exec-policy-cli.ts index 250b5e417ec..4acc960a195 100644 --- a/src/cli/exec-policy-cli.ts +++ b/src/cli/exec-policy-cli.ts @@ -239,7 +239,7 @@ async function buildLocalExecPolicyShowPayload(): Promise 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.", + : "Effective exec policy is the host approvals state intersected with requested tools.exec policy.", scopes, }, }; diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 64b745fb709..59ddfead9ff 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { saveExecApprovals, type ExecApprovalsFile } from "../infra/exec-approvals.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; const note = vi.hoisted(() => vi.fn()); const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); @@ -26,6 +28,8 @@ describe("noteSecurityWarnings gateway exposure", () => { let prevToken: string | undefined; let prevPassword: string | undefined; let prevHome: string | undefined; + let prevOpenClawHome: string | undefined; + let prevStateDir: string | undefined; beforeEach(() => { note.mockClear(); @@ -35,6 +39,8 @@ describe("noteSecurityWarnings gateway exposure", () => { prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; prevHome = process.env.HOME; + prevOpenClawHome = process.env.OPENCLAW_HOME; + prevStateDir = process.env.OPENCLAW_STATE_DIR; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; }); @@ -55,26 +61,40 @@ describe("noteSecurityWarnings gateway exposure", () => { } else { process.env.HOME = prevHome; } + if (prevOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = prevOpenClawHome; + } + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + closeOpenClawStateDatabaseForTest(); }); const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? ""); - async function withExecApprovalsFile( - file: Record, + async function withExecApprovalsState( + file: ExecApprovalsFile, run: () => Promise, ): Promise { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-security-")); + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-security-state-")); process.env.HOME = home; - await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - await fs.writeFile( - path.join(home, ".openclaw", "exec-approvals.json"), - JSON.stringify(file, null, 2), - ); - await run(); + process.env.OPENCLAW_HOME = home; + process.env.OPENCLAW_STATE_DIR = stateDir; + saveExecApprovals(file); + try { + await run(); + } finally { + closeOpenClawStateDatabaseForTest(); + } } async function expectAgentExecHostPolicyWarning(agentKey: "*" | "runner") { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, defaults: @@ -267,12 +287,12 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings(cfg); const message = lastMessage(); expect(message).toContain("disables approval forwarding only"); - expect(message).toContain("exec-approvals.json"); + expect(message).toContain("SQLite exec approvals state"); expect(message).toContain("openclaw approvals get --gateway"); }); it("warns when tools.exec is broader than host exec defaults", async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, defaults: { @@ -304,7 +324,7 @@ describe("noteSecurityWarnings gateway exposure", () => { }); it("does not invent a deny host policy when exec-approvals defaults.security is unset", async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, agents: {}, @@ -327,7 +347,7 @@ describe("noteSecurityWarnings gateway exposure", () => { }); it("does not invent an on-miss host ask policy when exec-approvals defaults.ask is unset", async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, agents: {}, @@ -353,7 +373,7 @@ describe("noteSecurityWarnings gateway exposure", () => { }); it("warns when an agent inherits broader global tools.exec policy than the matching host agent policy", async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, agents: { @@ -387,7 +407,7 @@ describe("noteSecurityWarnings gateway exposure", () => { }); it("ignores malformed host policy fields when attributing doctor conflicts", async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, defaults: { @@ -420,7 +440,7 @@ describe("noteSecurityWarnings gateway exposure", () => { }); it('does not warn about durable allow-always trust when ask="always" is enforced', async () => { - await withExecApprovalsFile( + await withExecApprovalsState( { version: 1, defaults: { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 9b0cce893cf..1eaf6ba0cc9 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -137,7 +137,7 @@ function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] { ` Config: ${configParts.join(", ")}`, ` Host: ${hostParts.join(", ")}`, ` Effective host exec stays security="${snapshot.security.effective}" ask="${snapshot.ask.effective}" because the stricter side wins.`, - " Headless runs like isolated cron cannot answer approval prompts; align both files or enable Web UI, terminal UI, or chat exec approvals.", + " Headless runs like isolated cron cannot answer approval prompts; align config and host approvals state or enable Web UI, terminal UI, or chat exec approvals.", ` Inspect with: ${formatCliCommand("openclaw approvals get --gateway")}`, ].join("\n"), ); @@ -172,7 +172,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (cfg.approvals?.exec?.enabled === false) { warnings.push( "- Note: approvals.exec.enabled=false disables approval forwarding only.", - " Host exec gating still comes from ~/.openclaw/exec-approvals.json.", + " Host exec gating still comes from SQLite exec approvals state.", ` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`, ); } diff --git a/src/commands/doctor-sqlite-state.test.ts b/src/commands/doctor-sqlite-state.test.ts index c384d85627c..2bcabb2150b 100644 --- a/src/commands/doctor-sqlite-state.test.ts +++ b/src/commands/doctor-sqlite-state.test.ts @@ -41,8 +41,25 @@ describe("maybeRepairLegacyRuntimeStateFiles", () => { it("imports legacy runtime JSON files into SQLite during doctor --fix", async () => { await withTempDir("openclaw-doctor-sqlite-state-", async (stateDir) => { - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_TEST_FAST: "1" }; + const openClawHome = path.join(stateDir, "home"); + const env = { + ...process.env, + OPENCLAW_HOME: openClawHome, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }; await withEnvAsync(env, async () => { + const execApprovalsPath = path.join(openClawHome, ".openclaw", "exec-approvals.json"); + await fs.mkdir(path.dirname(execApprovalsPath), { recursive: true }); + await fs.writeFile( + execApprovalsPath, + `${JSON.stringify({ + version: 1, + defaults: { security: "allowlist", ask: "on-miss" }, + agents: {}, + })}\n`, + "utf8", + ); await fs.mkdir(path.join(stateDir, "devices"), { recursive: true }); await fs.writeFile( path.join(stateDir, "devices", "bootstrap.json"), @@ -367,6 +384,10 @@ describe("maybeRepairLegacyRuntimeStateFiles", () => { displayName: "Legacy Node", gateway: { host: "gateway.local", port: 18443, tls: true }, }); + expect(readOpenClawStateKvJson("exec.approvals", "current", { env })).toContain( + '"security":"allowlist"', + ); + await expect(fs.stat(execApprovalsPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.stat(path.join(stateDir, "node.json"))).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/src/commands/doctor-sqlite-state.ts b/src/commands/doctor-sqlite-state.ts index a57a01fcf5d..d71349e542f 100644 --- a/src/commands/doctor-sqlite-state.ts +++ b/src/commands/doctor-sqlite-state.ts @@ -32,6 +32,10 @@ import { importLegacyDeviceIdentityFileToSqlite, legacyDeviceIdentityFileExists, } from "../infra/device-identity.js"; +import { + importLegacyExecApprovalsFileToSqlite, + legacyExecApprovalsFileExists, +} from "../infra/exec-approvals-migration.js"; import { importLegacyPairingStateFilesToSqlite, legacyPairingStateFilesExist, @@ -94,6 +98,7 @@ type LegacyStateProbe = { deviceAuth: boolean; deviceBootstrap: boolean; devicePairing: boolean; + execApprovals: boolean; nodePairing: boolean; nodeHostConfig: boolean; channelPairing: boolean; @@ -128,6 +133,7 @@ async function probeLegacyRuntimeStateFiles(params: { deviceAuth: legacyDeviceAuthFileExists(env), deviceBootstrap: await legacyDeviceBootstrapFileExists(baseDir), devicePairing: await legacyPairingStateFilesExist({ baseDir, subdir: "devices" }), + execApprovals: legacyExecApprovalsFileExists(env), nodePairing: await legacyPairingStateFilesExist({ baseDir, subdir: "nodes" }), nodeHostConfig: await legacyNodeHostConfigFileExists(env), channelPairing: await legacyChannelPairingFilesExist(env), @@ -171,7 +177,7 @@ export async function maybeRepairLegacyRuntimeStateFiles(params: { } if (!params.prompter.shouldRepair) { note( - "Legacy runtime state files detected. Run `openclaw doctor --fix` to import commitments, device, bootstrap, channel pairing, node pairing, node host config, push, media, plugin, plugin binding approvals, installed plugin index, subagent, task, Task Flow, TUI, Voice Wake, memory-core dreaming checkpoints, auth routing, OpenRouter cache, and update-check state into SQLite.", + "Legacy runtime state files detected. Run `openclaw doctor --fix` to import commitments, device, bootstrap, exec approvals, channel pairing, node pairing, node host config, push, media, plugin, plugin binding approvals, installed plugin index, subagent, task, Task Flow, TUI, Voice Wake, memory-core dreaming checkpoints, auth routing, OpenRouter cache, and update-check state into SQLite.", "SQLite state", ); return; @@ -221,6 +227,14 @@ export async function maybeRepairLegacyRuntimeStateFiles(params: { } }); } + if (probe.execApprovals) { + await runImport("Exec approvals", () => { + const result = importLegacyExecApprovalsFileToSqlite(env); + if (result.imported) { + changes.push("- Imported exec approvals into SQLite."); + } + }); + } if (probe.nodePairing) { await runImport("Node pairing", async () => { const result = await importLegacyPairingStateFilesToSqlite({ baseDir, subdir: "nodes" }); diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts index e5cc211121e..162c6989082 100644 --- a/src/infra/exec-approvals-config.test.ts +++ b/src/infra/exec-approvals-config.test.ts @@ -1,6 +1,5 @@ -import fs from "node:fs"; -import path from "node:path"; import { describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { makeTempDir } from "./exec-approvals-test-helpers.js"; import { isSafeBinUsage, @@ -9,6 +8,7 @@ import { normalizeSafeBins, resolveExecApprovals, resolveExecApprovalsFromFile, + saveExecApprovals, type ExecApprovalsAgent, type ExecAllowlistEntry, type ExecApprovalsFile, @@ -17,26 +17,20 @@ import { describe("exec approvals wildcard agent", () => { it("merges wildcard allowlist entries with agent entries", () => { const dir = makeTempDir(); + const stateDir = makeTempDir(); const prevOpenClawHome = process.env.OPENCLAW_HOME; + const prevStateDir = process.env.OPENCLAW_STATE_DIR; try { process.env.OPENCLAW_HOME = dir; - const approvalsPath = path.join(dir, ".openclaw", "exec-approvals.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync( - approvalsPath, - JSON.stringify( - { - version: 1, - agents: { - "*": { allowlist: [{ pattern: "/bin/hostname" }] }, - main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, - }, - }, - null, - 2, - ), - ); + process.env.OPENCLAW_STATE_DIR = stateDir; + saveExecApprovals({ + version: 1, + agents: { + "*": { allowlist: [{ pattern: "/bin/hostname" }] }, + main: { allowlist: [{ pattern: "/usr/bin/uname" }] }, + }, + }); const resolved = resolveExecApprovals("main"); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([ @@ -49,6 +43,12 @@ describe("exec approvals wildcard agent", () => { } else { process.env.OPENCLAW_HOME = prevOpenClawHome; } + if (prevStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = prevStateDir; + } + closeOpenClawStateDatabaseForTest(); } }); }); diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index c5e082b2b2a..c9f0056f4eb 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -15,7 +15,7 @@ import { const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full"; const DEFAULT_REQUESTED_ASK: ExecAsk = "off"; -const DEFAULT_HOST_PATH = "~/.openclaw/exec-approvals.json"; +const DEFAULT_HOST_PATH = "SQLite exec approvals state"; const REQUESTED_DEFAULT_LABEL = { security: DEFAULT_REQUESTED_SECURITY, ask: DEFAULT_REQUESTED_ASK, diff --git a/src/infra/exec-approvals-migration.ts b/src/infra/exec-approvals-migration.ts new file mode 100644 index 00000000000..d8fc6973316 --- /dev/null +++ b/src/infra/exec-approvals-migration.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + writeOpenClawStateKvJson, + type OpenClawStateJsonValue, +} from "../state/openclaw-state-kv.js"; +import { resolveExecApprovalsPath } from "./exec-approvals.js"; + +const EXEC_APPROVALS_KV_SCOPE = "exec.approvals"; +const EXEC_APPROVALS_KV_KEY = "current"; + +function sqliteOptionsForEnv(env: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions { + return { env }; +} + +function readLegacyExecApprovalsRaw(env: NodeJS.ProcessEnv = process.env): { + raw: string | null; + exists: boolean; + path: string; +} { + const filePath = resolveExecApprovalsPath(env); + if (!fs.existsSync(filePath)) { + return { raw: null, exists: false, path: filePath }; + } + return { raw: fs.readFileSync(filePath, "utf8"), exists: true, path: filePath }; +} + +export function legacyExecApprovalsFileExists(env: NodeJS.ProcessEnv = process.env): boolean { + return readLegacyExecApprovalsRaw(env).exists; +} + +export function importLegacyExecApprovalsFileToSqlite(env: NodeJS.ProcessEnv = process.env): { + imported: boolean; +} { + const legacy = readLegacyExecApprovalsRaw(env); + if (!legacy.exists || legacy.raw === null) { + return { imported: false }; + } + writeOpenClawStateKvJson( + EXEC_APPROVALS_KV_SCOPE, + EXEC_APPROVALS_KV_KEY, + legacy.raw, + sqliteOptionsForEnv(env), + ); + fs.rmSync(legacy.path, { force: true }); + return { imported: true }; +} diff --git a/src/infra/exec-approvals-policy.test.ts b/src/infra/exec-approvals-policy.test.ts index bae43151ca6..5f7390bce1c 100644 --- a/src/infra/exec-approvals-policy.test.ts +++ b/src/infra/exec-approvals-policy.test.ts @@ -48,7 +48,7 @@ function expectMalformedAgentAskUsesDefaults(agentAsk: unknown): void { expect(summary.ask).toMatchObject({ requested: "off", host: "always", - hostSource: "~/.openclaw/exec-approvals.json defaults.ask", + hostSource: "SQLite exec approvals state defaults.ask", effective: "always", note: "more aggressive ask wins", }); @@ -276,19 +276,19 @@ describe("exec approvals policy helpers", () => { requested: "full", host: "allowlist", effective: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json defaults.security", + hostSource: "SQLite exec approvals state defaults.security", note: "stricter host security wins", }); expect(summary.ask).toMatchObject({ requested: "off", host: "always", effective: "always", - hostSource: "~/.openclaw/exec-approvals.json defaults.ask", + hostSource: "SQLite exec approvals state defaults.ask", note: "more aggressive ask wins", }); expect(summary.askFallback).toEqual({ effective: "deny", - source: "~/.openclaw/exec-approvals.json defaults.askFallback", + source: "SQLite exec approvals state defaults.askFallback", }); }); @@ -362,7 +362,7 @@ describe("exec approvals policy helpers", () => { expect(summary.askFallback).toEqual({ effective: "allowlist", - source: "~/.openclaw/exec-approvals.json defaults.askFallback", + source: "SQLite exec approvals state defaults.askFallback", }); }); @@ -406,15 +406,15 @@ describe("exec approvals policy helpers", () => { expect(summary.security).toMatchObject({ host: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json agents.*.security", + hostSource: "SQLite exec approvals state agents.*.security", }); expect(summary.ask).toMatchObject({ host: "always", - hostSource: "~/.openclaw/exec-approvals.json agents.*.ask", + hostSource: "SQLite exec approvals state agents.*.ask", }); expect(summary.askFallback).toEqual({ effective: "deny", - source: "~/.openclaw/exec-approvals.json agents.*.askFallback", + source: "SQLite exec approvals state agents.*.askFallback", }); }); @@ -537,11 +537,11 @@ describe("exec approvals policy helpers", () => { expect(snapshots.map((snapshot) => snapshot.scopeLabel)).toEqual(["tools.exec"]); expect(snapshots[0]?.security).toMatchObject({ host: "allowlist", - hostSource: "~/.openclaw/exec-approvals.json agents.main.security", + hostSource: "SQLite exec approvals state agents.main.security", }); expect(snapshots[0]?.ask).toMatchObject({ host: "always", - hostSource: "~/.openclaw/exec-approvals.json agents.main.ask", + hostSource: "SQLite exec approvals state agents.main.ask", }); }); diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 933325800f5..c1f6ae5c3e9 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; +import { readOpenClawStateKvJson } from "../state/openclaw-state-kv.js"; +import { + importLegacyExecApprovalsFileToSqlite, + legacyExecApprovalsFileExists, +} from "./exec-approvals-migration.js"; import { makeTempDir } from "./exec-approvals-test-helpers.js"; const requestJsonlSocketMock = vi.hoisted(() => vi.fn()); @@ -16,6 +22,7 @@ type ExecApprovalsModule = typeof import("./exec-approvals.js"); let addAllowlistEntry: ExecApprovalsModule["addAllowlistEntry"]; let addDurableCommandApproval: ExecApprovalsModule["addDurableCommandApproval"]; let ensureExecApprovals: ExecApprovalsModule["ensureExecApprovals"]; +let loadExecApprovals: ExecApprovalsModule["loadExecApprovals"]; let mergeExecApprovalsSocketDefaults: ExecApprovalsModule["mergeExecApprovalsSocketDefaults"]; let normalizeExecApprovals: ExecApprovalsModule["normalizeExecApprovals"]; let persistAllowAlwaysPatterns: ExecApprovalsModule["persistAllowAlwaysPatterns"]; @@ -29,12 +36,14 @@ let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"]; const tempDirs: string[] = []; const originalOpenClawHome = process.env.OPENCLAW_HOME; +const originalStateDir = process.env.OPENCLAW_STATE_DIR; beforeAll(async () => { ({ addAllowlistEntry, addDurableCommandApproval, ensureExecApprovals, + loadExecApprovals, mergeExecApprovalsSocketDefaults, normalizeExecApprovals, persistAllowAlwaysPatterns, @@ -54,11 +63,17 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + resetPluginStateStoreForTests(); if (originalOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { process.env.OPENCLAW_HOME = originalOpenClawHome; } + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalStateDir; + } for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -66,8 +81,10 @@ afterEach(() => { function createHomeDir(): string { const dir = makeTempDir(); - tempDirs.push(dir); + const stateDir = makeTempDir(); + tempDirs.push(dir, stateDir); process.env.OPENCLAW_HOME = dir; + process.env.OPENCLAW_STATE_DIR = stateDir; return dir; } @@ -75,20 +92,17 @@ function approvalsFilePath(homeDir: string): string { return path.join(homeDir, ".openclaw", "exec-approvals.json"); } -function readApprovalsFile(homeDir: string): ExecApprovalsFile { - return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile; +function readApprovalsFile(): ExecApprovalsFile { + return loadExecApprovals(); } -function listExecApprovalTempFiles(homeDir: string): string[] { - const dir = path.dirname(approvalsFilePath(homeDir)); - if (!fs.existsSync(dir)) { - return []; - } - return fs.readdirSync(dir).filter((name) => name.endsWith(".tmp")); +function readSqliteRaw(): string | undefined { + const value = readOpenClawStateKvJson("exec.approvals", "current"); + return typeof value === "string" ? value : undefined; } describe("exec approvals store helpers", () => { - it("expands home-prefixed default file and socket paths", () => { + it("expands home-prefixed default file and socket paths for compatibility labels", () => { const dir = createHomeDir(); expect(path.normalize(resolveExecApprovalsPath())).toBe( @@ -115,28 +129,22 @@ describe("exec approvals store helpers", () => { path: "/tmp/a.sock", token: "a", }); - - const merged = mergeExecApprovalsSocketDefaults({ - normalized: normalizeExecApprovals({ version: 1, agents: {} }), - current, - }); - expect(merged.socket).toEqual({ - path: "/tmp/b.sock", - token: "b", - }); + expect( + mergeExecApprovalsSocketDefaults({ + normalized: normalizeExecApprovals({ version: 1, agents: {} }), + current, + }).socket, + ).toEqual({ path: "/tmp/b.sock", token: "b" }); createHomeDir(); expect( mergeExecApprovalsSocketDefaults({ normalized: normalizeExecApprovals({ version: 1, agents: {} }), }).socket, - ).toEqual({ - path: resolveExecApprovalsSocketPath(), - token: "", - }); + ).toEqual({ path: resolveExecApprovalsSocketPath(), token: "" }); }); - it("returns normalized empty snapshots for missing and invalid approvals files", () => { + it("returns normalized snapshots from SQLite and ignores legacy files until import", () => { const dir = createHomeDir(); const missing = readExecApprovalsSnapshot(); @@ -148,268 +156,50 @@ describe("exec approvals store helpers", () => { fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8"); - const invalid = readExecApprovalsSnapshot(); - expect(invalid.exists).toBe(true); - expect(invalid.raw).toBe("{invalid"); - expect(invalid.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); + const ignoredLegacy = readExecApprovalsSnapshot(); + expect(ignoredLegacy.exists).toBe(false); + expect(ignoredLegacy.raw).toBeNull(); + expect(ignoredLegacy.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); + + saveExecApprovals({ version: 1, defaults: { security: "deny" }, agents: {} }); + const sqlite = readExecApprovalsSnapshot(); + expect(sqlite.exists).toBe(true); + expect(sqlite.file.defaults?.security).toBe("deny"); + expect(sqlite.raw).toContain('"security": "deny"'); }); - it("ensures approvals file with default socket path and generated token", () => { + it("ensures approvals in SQLite with default socket path and generated token", () => { const dir = createHomeDir(); const ensured = ensureExecApprovals(); - const raw = fs.readFileSync(approvalsFilePath(dir), "utf8"); + const raw = readSqliteRaw(); expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath()); expect(ensured.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); - expect(raw.endsWith("\n")).toBe(true); - expect(readApprovalsFile(dir).socket).toEqual(ensured.socket); + expect(raw?.endsWith("\n")).toBe(true); + expect(readApprovalsFile().socket).toEqual(ensured.socket); + expect(fs.existsSync(approvalsFilePath(dir))).toBe(false); }); - 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("normalizes successful rename writes to owner-only permissions", () => { - const dir = createHomeDir(); - const actualWriteFileSync = fs.writeFileSync.bind(fs); - vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - const result = actualWriteFileSync(file, data, options as never); - const filePath = String(file); - if ( - typeof file !== "number" && - filePath.includes(".exec-approvals.") && - filePath.endsWith(".tmp") - ) { - fs.chmodSync(file, 0o000); - } - return result; - }); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsFilePath(dir)).mode & 0o777).toBe(0o600); - }); - - it("normalizes the approvals directory to owner-only permissions", () => { - const dir = createHomeDir(); - const approvalsDir = path.dirname(approvalsFilePath(dir)); - fs.mkdirSync(approvalsDir, { recursive: true }); - fs.chmodSync(approvalsDir, 0o777); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsDir).mode & 0o777).toBe(0o700); - }); - - it("falls back to copying when rename cannot overwrite the approvals file", () => { + it("imports legacy approvals files into SQLite and removes the source", () => { const dir = createHomeDir(); const approvalsPath = approvalsFilePath(dir); fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - const actualRenameSync = fs.renameSync.bind(fs); - const rename = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); + fs.writeFileSync( + approvalsPath, + `${JSON.stringify({ version: 1, defaults: { security: "deny" }, agents: {} })}\n`, + "utf8", + ); - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); + expect(legacyExecApprovalsFileExists()).toBe(true); + expect(importLegacyExecApprovalsFileToSqlite()).toEqual({ imported: true }); - expect(rename).toHaveBeenCalled(); - expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toEqual([]); - }); - - it("normalizes fallback temp files before copying", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - const actualWriteFileSync = fs.writeFileSync.bind(fs); - vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - const result = actualWriteFileSync(file, data, options as never); - const filePath = String(file); - if ( - typeof file !== "number" && - filePath.includes(".exec-approvals.") && - filePath.endsWith(".tmp") - ) { - fs.chmodSync(file, 0o000); - } - return result; - }); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"'); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toEqual([]); - }); - - it("restores the previous approvals file when fallback copy fails", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const previousRaw = '{"version":1,"defaults":{"security":"deny"},"agents":{}}\n'; - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, previousRaw, { encoding: "utf8", mode: 0o600 }); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - const actualFtruncateSync = fs.ftruncateSync.bind(fs); - let forcedFallbackFailure = false; - vi.spyOn(fs, "ftruncateSync").mockImplementation((fd, len) => { - if (!forcedFallbackFailure && len === 0) { - forcedFallbackFailure = true; - actualFtruncateSync(fd, len); - const error = Object.assign(new Error("copy failed after opening destination"), { - code: "ENOSPC", - }); - throw error; - } - return actualFtruncateSync(fd, len); - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/copy failed after opening destination/); - expect(fs.readFileSync(approvalsPath, "utf8")).toBe(previousRaw); - expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600); - expect(listExecApprovalTempFiles(dir)).toEqual([]); - }); - - it("does not follow a symlink swapped in before fallback copy", () => { - const dir = createHomeDir(); - const approvalsPath = approvalsFilePath(dir); - const targetPath = path.join(dir, "elsewhere.json"); - fs.mkdirSync(path.dirname(approvalsPath), { recursive: true }); - fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8"); - fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8"); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - const actualStatSync = fs.statSync.bind(fs); - let swappedDestination = false; - vi.spyOn(fs, "statSync").mockImplementation((file, options) => { - const result = actualStatSync(file, options as never); - if (!swappedDestination && String(file) === approvalsPath) { - swappedDestination = true; - fs.rmSync(approvalsPath); - fs.symlinkSync(targetPath, approvalsPath); - } - return result; - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/symlink|ELOOP/); - expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n'); - expect(listExecApprovalTempFiles(dir)).toEqual([]); - }); - - it("does not use the copy fallback for hard-linked approvals files", () => { - 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); - const actualRenameSync = fs.renameSync.bind(fs); - vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { - if (String(to) === approvalsPath) { - const error = Object.assign(new Error("locked target"), { code: "EPERM" }); - throw error; - } - return actualRenameSync(from, to); - }); - - expect(() => - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }), - ).toThrow(/hard-linked exec approvals file/); - expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n'); - expect(listExecApprovalTempFiles(dir)).toEqual([]); - }); - - 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("accepts a symlinked OPENCLAW_HOME as the trusted approvals root", () => { - const realHome = makeTempDir(); - const linkedHome = `${realHome}-link`; - tempDirs.push(realHome, linkedHome); - fs.symlinkSync(realHome, linkedHome, "dir"); - process.env.OPENCLAW_HOME = linkedHome; - - saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }); - - expect( - fs.readFileSync(path.join(realHome, ".openclaw", "exec-approvals.json"), "utf8"), - ).toContain('"security": "full"'); - }); - - it("refuses to traverse symlinked approvals components below a symlinked home", () => { - const realHome = makeTempDir(); - const linkedHome = `${realHome}-link`; - const linkedStateTarget = path.join(realHome, "state-target"); - tempDirs.push(realHome, linkedHome); - fs.mkdirSync(linkedStateTarget, { recursive: true }); - fs.symlinkSync(realHome, linkedHome, "dir"); - fs.symlinkSync(linkedStateTarget, path.join(realHome, ".openclaw"), "dir"); - 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(linkedStateTarget, "exec-approvals.json"))).toBe(false); + expect(loadExecApprovals().defaults?.security).toBe("deny"); + expect(fs.existsSync(approvalsPath)).toBe(false); }); it("adds trimmed allowlist entries once and persists generated ids", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(123_456); const approvals = ensureExecApprovals(); @@ -417,76 +207,58 @@ describe("exec approvals store helpers", () => { addAllowlistEntry(approvals, "worker", "/usr/bin/rg"); addAllowlistEntry(approvals, "worker", " "); - expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.worker?.allowlist).toEqual([ expect.objectContaining({ pattern: "/usr/bin/rg", lastUsedAt: 123_456, }), ]); - expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(readApprovalsFile().agents?.worker?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); }); it("persists durable command approvals without storing plaintext command text", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(321_000); const approvals = ensureExecApprovals(); addDurableCommandApproval(approvals, "worker", 'printenv API_KEY="secret-value"'); - expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.worker?.allowlist).toEqual([ expect.objectContaining({ source: "allow-always", lastUsedAt: 321_000, }), ]); - expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]?.pattern).toMatch( + expect(readApprovalsFile().agents?.worker?.allowlist?.[0]?.pattern).toMatch( /^=command:[0-9a-f]{16}$/i, ); - expect(readApprovalsFile(dir).agents?.worker?.allowlist?.[0]).not.toHaveProperty("commandText"); + expect(readApprovalsFile().agents?.worker?.allowlist?.[0]).not.toHaveProperty("commandText"); }); it("strips legacy plaintext command text during normalization", () => { - expect( - normalizeExecApprovals({ - version: 1, - agents: { - main: { - allowlist: [ - { - pattern: "=command:test", - source: "allow-always", - commandText: "echo secret-token", - }, - ], - }, + const normalized = normalizeExecApprovals({ + version: 1, + agents: { + main: { + allowlist: [ + { + pattern: "=command:test", + source: "allow-always", + commandText: "echo secret-token", + }, + ], }, - }).agents?.main?.allowlist, - ).toEqual([ - expect.objectContaining({ - pattern: "=command:test", - source: "allow-always", - }), + }, + }); + + expect(normalized.agents?.main?.allowlist).toEqual([ + expect.objectContaining({ pattern: "=command:test", source: "allow-always" }), ]); - expect( - normalizeExecApprovals({ - version: 1, - agents: { - main: { - allowlist: [ - { - pattern: "=command:test", - source: "allow-always", - commandText: "echo secret-token", - }, - ], - }, - }, - }).agents?.main?.allowlist?.[0], - ).not.toHaveProperty("commandText"); + expect(normalized.agents?.main?.allowlist?.[0]).not.toHaveProperty("commandText"); }); it("preserves source and argPattern metadata for allow-always entries", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(321_000); const approvals = ensureExecApprovals(); @@ -503,7 +275,7 @@ describe("exec approvals store helpers", () => { source: "allow-always", }); - expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.worker?.allowlist).toEqual([ expect.objectContaining({ pattern: "/usr/bin/python3", argPattern: "^script\\.py\x00$", @@ -520,7 +292,7 @@ describe("exec approvals store helpers", () => { }); it("records allowlist usage on the matching entry and backfills missing ids", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(999_000); const approvals: ExecApprovalsFile = { @@ -531,8 +303,7 @@ describe("exec approvals store helpers", () => { }, }, }; - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync(approvalsFilePath(dir), JSON.stringify(approvals, null, 2), "utf8"); + saveExecApprovals(approvals); recordAllowlistUse( approvals, @@ -542,7 +313,7 @@ describe("exec approvals store helpers", () => { "/opt/homebrew/bin/rg", ); - expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.main?.allowlist).toEqual([ expect.objectContaining({ pattern: "/usr/bin/rg", lastUsedAt: 999_000, @@ -551,11 +322,11 @@ describe("exec approvals store helpers", () => { }), { pattern: "/usr/bin/jq", id: "keep-id" }, ]); - expect(readApprovalsFile(dir).agents?.main?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(readApprovalsFile().agents?.main?.allowlist?.[0]?.id).toMatch(/^[0-9a-f-]{36}$/i); }); it("dedupes allowlist usage by pattern and argPattern", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(777_000); const approvals: ExecApprovalsFile = { @@ -569,8 +340,7 @@ describe("exec approvals store helpers", () => { }, }, }; - fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); - fs.writeFileSync(approvalsFilePath(dir), JSON.stringify(approvals, null, 2), "utf8"); + saveExecApprovals(approvals); recordAllowlistMatchesUse({ approvals, @@ -584,7 +354,7 @@ describe("exec approvals store helpers", () => { resolvedPath: "/usr/bin/python3", }); - expect(readApprovalsFile(dir).agents?.main?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.main?.allowlist).toEqual([ expect.objectContaining({ pattern: "/usr/bin/python3", argPattern: "^a\\.py\x00$", @@ -599,7 +369,7 @@ describe("exec approvals store helpers", () => { }); it("persists allow-always patterns with shared helper", () => { - const dir = createHomeDir(); + createHomeDir(); vi.spyOn(Date, "now").mockReturnValue(654_321); const approvals = ensureExecApprovals(); @@ -633,7 +403,7 @@ describe("exec approvals store helpers", () => { argPattern: "^a\\.py\x00$", }, ]); - expect(readApprovalsFile(dir).agents?.worker?.allowlist).toEqual([ + expect(readApprovalsFile().agents?.worker?.allowlist).toEqual([ expect.objectContaining({ pattern: "/usr/bin/custom-tool.exe", argPattern: "^a\\.py\x00$", diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index ebefb855a05..469420db5f4 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -1,6 +1,4 @@ import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -8,12 +6,18 @@ import { normalizeOptionalString, readStringValue, } from "../shared/string-coerce.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { + deleteOpenClawStateKvJson, + readOpenClawStateKvJson, + writeOpenClawStateKvJson, + type OpenClawStateJsonValue, +} from "../state/openclaw-state-kv.js"; import type { CommandExplanationSummary } from "./command-analysis/explain.js"; import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js"; import type { ExecCommandSegment } from "./exec-approvals-analysis.js"; import type { ExecAllowlistEntry } from "./exec-approvals.types.js"; -import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js"; -import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js"; +import { expandHomePrefix } from "./home-dir.js"; import { requestJsonlSocket } from "./jsonl-socket.js"; export * from "./exec-approvals-analysis.js"; export * from "./exec-approvals-allowlist.js"; @@ -202,6 +206,8 @@ export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "full"; const DEFAULT_AUTO_ALLOW_SKILLS = false; const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock"; const DEFAULT_FILE = "~/.openclaw/exec-approvals.json"; +const EXEC_APPROVALS_KV_SCOPE = "exec.approvals"; +const EXEC_APPROVALS_KV_KEY = "current"; function hashExecApprovalsRaw(raw: string | null): string { return crypto @@ -210,12 +216,12 @@ function hashExecApprovalsRaw(raw: string | null): string { .digest("hex"); } -export function resolveExecApprovalsPath(): string { - return expandHomePrefix(DEFAULT_FILE); +export function resolveExecApprovalsPath(env: NodeJS.ProcessEnv = process.env): string { + return expandHomePrefix(DEFAULT_FILE, { env }); } -export function resolveExecApprovalsSocketPath(): string { - return expandHomePrefix(DEFAULT_SOCKET); +export function resolveExecApprovalsSocketPath(env: NodeJS.ProcessEnv = process.env): string { + return expandHomePrefix(DEFAULT_SOCKET, { env }); } function normalizeAllowlistPattern(value: string | undefined): string | null { @@ -257,241 +263,6 @@ function mergeLegacyAgent( }; } -function ensureDir(filePath: string) { - const dir = path.dirname(filePath); - assertNoExecApprovalsSymlinkParents(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}`); - } - try { - fs.chmodSync(dir, 0o700); - } catch (err) { - if (process.platform !== "win32") { - throw err; - } - } - return dir; -} - -function assertNoExecApprovalsSymlinkParents(targetPath: string, trustedRoot: string): void { - assertNoSymlinkParentsSync({ - rootDir: trustedRoot, - targetPath, - allowOutsideRoot: true, - messagePrefix: "Refusing to traverse symlink in exec approvals path", - }); -} - -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; - } - } -} - -function assertSafeExecApprovalsOverwriteFallback(filePath: string): void { - assertSafeExecApprovalsDestination(filePath); - try { - const stat = fs.statSync(filePath); - if (stat.nlink > 1) { - throw new Error(`Refusing copy fallback for hard-linked exec approvals file: ${filePath}`); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } -} - -type ExecApprovalsFallbackDestination = { - existed: boolean; - fd: number; - snapshot: Buffer | null; -}; - -function sameFilesystemEntry(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -function readExecApprovalsFallbackSnapshotFromFd(fd: number): Buffer { - const chunks: Buffer[] = []; - const buffer = Buffer.alloc(64 * 1024); - let position = 0; - while (true) { - const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, position); - if (bytesRead === 0) { - break; - } - chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); - position += bytesRead; - } - return Buffer.concat(chunks); -} - -function validateExecApprovalsFallbackFd(filePath: string, fd: number): fs.Stats { - const linkStat = fs.lstatSync(filePath); - if (linkStat.isSymbolicLink()) { - throw new Error(`Refusing to write exec approvals via symlink: ${filePath}`); - } - const pathStat = fs.statSync(filePath); - const fdStat = fs.fstatSync(fd); - if (!fdStat.isFile()) { - throw new Error(`Refusing copy fallback for non-file exec approvals path: ${filePath}`); - } - if (fdStat.nlink > 1) { - throw new Error(`Refusing copy fallback for hard-linked exec approvals file: ${filePath}`); - } - if (!sameFilesystemEntry(pathStat, fdStat)) { - throw new Error(`Refusing copy fallback after exec approvals path changed: ${filePath}`); - } - return fdStat; -} - -function openExistingExecApprovalsFallbackDestination( - filePath: string, -): ExecApprovalsFallbackDestination { - const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; - const fd = fs.openSync(filePath, fs.constants.O_RDWR | noFollowFlag, 0o600); - try { - validateExecApprovalsFallbackFd(filePath, fd); - return { - existed: true, - fd, - snapshot: readExecApprovalsFallbackSnapshotFromFd(fd), - }; - } catch (err) { - try { - fs.closeSync(fd); - } catch { - // best-effort after validation failure - } - throw err; - } -} - -function createExecApprovalsFallbackDestination( - filePath: string, -): ExecApprovalsFallbackDestination { - const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; - try { - const fd = fs.openSync( - filePath, - fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollowFlag, - 0o600, - ); - try { - validateExecApprovalsFallbackFd(filePath, fd); - return { existed: false, fd, snapshot: null }; - } catch (err) { - try { - fs.closeSync(fd); - } catch { - // best-effort after validation failure - } - throw err; - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EEXIST") { - return openExistingExecApprovalsFallbackDestination(filePath); - } - throw err; - } -} - -function openExecApprovalsFallbackDestination(filePath: string): ExecApprovalsFallbackDestination { - try { - return openExistingExecApprovalsFallbackDestination(filePath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return createExecApprovalsFallbackDestination(filePath); - } - throw err; - } -} - -function writeExecApprovalsFallbackBuffer(fd: number, contents: Buffer): void { - fs.ftruncateSync(fd, 0); - let written = 0; - while (written < contents.length) { - written += fs.writeSync(fd, contents, written, contents.length - written, written); - } - fs.ftruncateSync(fd, contents.length); - try { - fs.fchmodSync(fd, 0o600); - } catch { - // best-effort on platforms without chmod - } -} - -function restoreExecApprovalsFallbackDestination( - filePath: string, - destination: ExecApprovalsFallbackDestination, -): void { - if (!destination.existed) { - try { - const pathStat = fs.statSync(filePath); - const fdStat = fs.fstatSync(destination.fd); - if (sameFilesystemEntry(pathStat, fdStat)) { - fs.rmSync(filePath, { force: true }); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } - return; - } - writeExecApprovalsFallbackBuffer(destination.fd, destination.snapshot ?? Buffer.alloc(0)); -} - -function copyExecApprovalsFallback(tempPath: string, filePath: string): void { - const contents = fs.readFileSync(tempPath); - const destination = openExecApprovalsFallbackDestination(filePath); - try { - writeExecApprovalsFallbackBuffer(destination.fd, contents); - validateExecApprovalsFallbackFd(filePath, destination.fd); - } catch (copyErr) { - try { - restoreExecApprovalsFallbackDestination(filePath, destination); - } catch (restoreErr) { - throw new Error( - `Failed to restore exec approvals after copy fallback failure for ${filePath}: ${String( - copyErr, - )}`, - { cause: restoreErr }, - ); - } - throw copyErr; - } finally { - fs.closeSync(destination.fd); - } -} - -function renameExecApprovalsWithFallback(tempPath: string, filePath: string): void { - try { - fs.renameSync(tempPath, filePath); - return; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - // Windows can reject rename-overwrite when another process has a transient - // handle on the target approvals file. - if (code !== "EPERM" && code !== "EEXIST") { - throw err; - } - assertSafeExecApprovalsOverwriteFallback(filePath); - copyExecApprovalsFallback(tempPath, filePath); - fs.rmSync(tempPath, { force: true }); - } -} - // Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread // entries to add ids (spreading strings creates {"0":"l","1":"s",...}). function coerceAllowlistEntries(allowlist: unknown): ExecAllowlistEntry[] | undefined { @@ -643,94 +414,77 @@ function generateToken(): string { return crypto.randomBytes(24).toString("base64url"); } -export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { - const filePath = resolveExecApprovalsPath(); - if (!fs.existsSync(filePath)) { - const file = normalizeExecApprovals({ version: 1, agents: {} }); - return { - path: filePath, - exists: false, - raw: null, - file, - hash: hashExecApprovalsRaw(null), - }; - } - const raw = fs.readFileSync(filePath, "utf8"); - let parsed: ExecApprovalsFile | null = null; - try { - parsed = JSON.parse(raw) as ExecApprovalsFile; - } catch { - parsed = null; - } - const file = - parsed?.version === 1 - ? normalizeExecApprovals(parsed) - : normalizeExecApprovals({ version: 1, agents: {} }); - return { - path: filePath, - exists: true, - raw, - file, - hash: hashExecApprovalsRaw(raw), - }; +function sqliteOptionsForEnv(env: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions { + return { env }; } -export function loadExecApprovals(): ExecApprovalsFile { - const filePath = resolveExecApprovalsPath(); +function readExecApprovalsRawFromSqlite(env: NodeJS.ProcessEnv = process.env): string | null { + const value = readOpenClawStateKvJson( + EXEC_APPROVALS_KV_SCOPE, + EXEC_APPROVALS_KV_KEY, + sqliteOptionsForEnv(env), + ); + return typeof value === "string" ? value : null; +} + +function writeExecApprovalsRawToSqlite(raw: string, env: NodeJS.ProcessEnv = process.env): void { + writeOpenClawStateKvJson( + EXEC_APPROVALS_KV_SCOPE, + EXEC_APPROVALS_KV_KEY, + raw, + sqliteOptionsForEnv(env), + ); +} + +function deleteExecApprovalsSqliteState(env: NodeJS.ProcessEnv = process.env): void { + deleteOpenClawStateKvJson( + EXEC_APPROVALS_KV_SCOPE, + EXEC_APPROVALS_KV_KEY, + sqliteOptionsForEnv(env), + ); +} + +function parseExecApprovalsRaw(raw: string | null): ExecApprovalsFile { + if (raw === null) { + return normalizeExecApprovals({ version: 1, agents: {} }); + } try { - if (!fs.existsSync(filePath)) { - return normalizeExecApprovals({ version: 1, agents: {} }); - } - const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as ExecApprovalsFile; - if (parsed?.version !== 1) { - return normalizeExecApprovals({ version: 1, agents: {} }); - } - return normalizeExecApprovals(parsed); + return parsed?.version === 1 + ? normalizeExecApprovals(parsed) + : normalizeExecApprovals({ version: 1, agents: {} }); } catch { return normalizeExecApprovals({ version: 1, agents: {} }); } } -export function saveExecApprovals(file: ExecApprovalsFile) { +export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { const filePath = resolveExecApprovalsPath(); - const raw = `${JSON.stringify(file, null, 2)}\n`; - writeExecApprovalsRaw(filePath, raw); + const sqliteRaw = readExecApprovalsRawFromSqlite(); + return { + path: filePath, + exists: sqliteRaw !== null, + raw: sqliteRaw, + file: parseExecApprovalsRaw(sqliteRaw), + hash: hashExecApprovalsRaw(sqliteRaw), + }; } -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" }); - try { - fs.chmodSync(tempPath, 0o600); - } catch { - // best-effort on platforms without chmod - } - tempWritten = true; - renameExecApprovalsWithFallback(tempPath, filePath); - } finally { - if (tempWritten && fs.existsSync(tempPath)) { - fs.rmSync(tempPath, { force: true }); - } - } - try { - fs.chmodSync(filePath, 0o600); - } catch { - // best-effort on platforms without chmod - } +export function loadExecApprovals(): ExecApprovalsFile { + return parseExecApprovalsRaw(readExecApprovalsRawFromSqlite()); +} + +export function saveExecApprovals(file: ExecApprovalsFile) { + writeExecApprovalsRawToSqlite(`${JSON.stringify(file, null, 2)}\n`); } export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): void { if (!snapshot.exists) { - fs.rmSync(snapshot.path, { force: true }); + deleteExecApprovalsSqliteState(); return; } if (snapshot.raw !== null) { - writeExecApprovalsRaw(snapshot.path, snapshot.raw); + writeExecApprovalsRawToSqlite(snapshot.raw); return; } saveExecApprovals(snapshot.file); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index f213fc04ba0..1c7ac6b8832 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -25,6 +25,8 @@ import { saveExecApprovals, } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { deleteOpenClawStateKvJson } from "../state/openclaw-state-kv.js"; import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; import { handleSystemRunInvoke } from "./invoke-system-run.js"; import type { HandleSystemRunInvokeOptions } from "./invoke-system-run.js"; @@ -42,16 +44,20 @@ type MockedSendNodeEvent = Mock; describe("handleSystemRunInvoke mac app exec host routing", () => { let sharedFixtureRoot = ""; let sharedOpenClawHome = ""; + let sharedStateDir = ""; let sharedRuntimeBinDir = ""; let sharedFixtureId = 0; let previousOpenClawHome: string | undefined; + let previousStateDir: string | undefined; const sharedRuntimeBins = new Set(); beforeAll(() => { sharedFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-fixtures-")); sharedOpenClawHome = path.join(sharedFixtureRoot, "openclaw-home"); + sharedStateDir = path.join(sharedFixtureRoot, "openclaw-state"); sharedRuntimeBinDir = path.join(sharedFixtureRoot, "bin"); fs.mkdirSync(sharedOpenClawHome, { recursive: true }); + fs.mkdirSync(sharedStateDir, { recursive: true }); fs.mkdirSync(sharedRuntimeBinDir, { recursive: true }); }); @@ -69,18 +75,28 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { beforeEach(() => { previousOpenClawHome = process.env.OPENCLAW_HOME; + previousStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_HOME = sharedOpenClawHome; + process.env.OPENCLAW_STATE_DIR = sharedStateDir; fs.rmSync(resolveExecApprovalsPath(), { force: true }); + deleteOpenClawStateKvJson("exec.approvals", "current"); clearRuntimeConfigSnapshot(); }); afterEach(() => { clearRuntimeConfigSnapshot(); + deleteOpenClawStateKvJson("exec.approvals", "current"); + closeOpenClawStateDatabaseForTest(); if (previousOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { process.env.OPENCLAW_HOME = previousOpenClawHome; } + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } }); function createLocalRunResult(stdout = "local-ok") {