refactor: store exec approvals in sqlite

This commit is contained in:
Peter Steinberger
2026-05-08 17:01:26 +01:00
parent d5d647826a
commit ff8a939733
26 changed files with 379 additions and 733 deletions

View File

@@ -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 <id|name|ip>`.
@@ -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 <id|name|ip> --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

View File

@@ -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 <id|name|ip>` (edit from the Gateway)

View File

@@ -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 |

View File

@@ -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 <id|name|ip> "/usr/bin/uname"
openclaw approvals allowlist add --node <id|name|ip> "/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

View File

@@ -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:

View File

@@ -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

View File

@@ -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.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- allowlist entries live in host-local SQLite approvals state under `agents.<id>.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.<bin>` 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.

View File

@@ -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"`.
</Note>
## Inspecting the effective policy
| Command | What it shows |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `openclaw approvals get` / `--gateway` / `--node <id\|name\|ip>` | 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 <id\|name\|ip>` | 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
```
</Step>
<Step title="Match the host approvals file">
<Step title="Match the host approvals state">
```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 <id|name|ip> --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).

View File

@@ -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)

View File

@@ -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 },

View File

@@ -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.',

View File

@@ -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);

View File

@@ -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",

View File

@@ -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.",
};
}

View File

@@ -239,7 +239,7 @@ async function buildLocalExecPolicyShowPayload(): Promise<ExecPolicyShowPayload>
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,
},
};

View File

@@ -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<string, unknown>,
async function withExecApprovalsState(
file: ExecApprovalsFile,
run: () => Promise<void>,
): Promise<void> {
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: {

View File

@@ -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")}`,
);
}

View File

@@ -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",
});

View File

@@ -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" });

View File

@@ -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();
}
});
});

View File

@@ -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,

View File

@@ -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<OpenClawStateJsonValue>(
EXEC_APPROVALS_KV_SCOPE,
EXEC_APPROVALS_KV_KEY,
legacy.raw,
sqliteOptionsForEnv(env),
);
fs.rmSync(legacy.path, { force: true });
return { imported: true };
}

View File

@@ -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",
});
});

View File

@@ -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$",

View File

@@ -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<OpenClawStateJsonValue>(
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);

View File

@@ -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<HandleSystemRunInvokeOptions["sendNodeEvent"]>;
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<string>();
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") {