mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
refactor: store exec approvals in sqlite
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
src/infra/exec-approvals-migration.ts
Normal file
47
src/infra/exec-approvals-migration.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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$",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user