feat: add local exec-policy CLI (#64050)

* feat: add local exec-policy CLI

* fix: harden exec-policy CLI output

* fix: harden exec approvals writes

* fix: tighten local exec-policy sync

* docs: document exec-policy CLI

* fix: harden exec-policy rollback and approvals path checks

* fix: reject exec-policy sync when host remains node

* fix: validate approvals path before mkdir

* fix: guard exec-policy rollback against newer approvals writes

* fix: restore exec approvals via hardened rollback path

* fix: guard exec-policy config writes with base hash

* docs: add exec-policy changelog entry

* fix: clarify exec-policy show for node host

* fix: strip stale exec-policy decisions
This commit is contained in:
Tak Hoffman
2026-04-10 01:16:03 -05:00
committed by GitHub
parent 2d126fc623
commit 4bf94aa0d6
14 changed files with 1256 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
- CLI/exec policy: add a local `openclaw exec-policy` command with `show`, `preset`, and `set` subcommands for synchronizing requested `tools.exec.*` config with the local exec approvals file, plus follow-up hardening for node-host rejection, rollback safety, and sync conflict detection.
- Models/providers: add per-provider `models.providers.*.request.allowPrivateNetwork` for trusted self-hosted OpenAI-compatible endpoints, keep the opt-in scoped to model request surfaces, and refresh cached WebSocket managers when request transport overrides change. (#63671) Thanks @qas.
### Fixes

View File

@@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw approvals` (exec approvals for gateway or node hosts)"
summary: "CLI reference for `openclaw approvals` and `openclaw exec-policy`"
read_when:
- You want to edit exec approvals from the CLI
- You need to manage allowlists on gateway or node hosts
@@ -18,6 +18,45 @@ Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals)
- Nodes: [Nodes](/nodes)
## `openclaw exec-policy`
`openclaw exec-policy` is the local convenience command for keeping the requested
`tools.exec.*` config and the local host approvals file aligned in one step.
Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
Examples:
```bash
openclaw exec-policy show
openclaw exec-policy show --json
openclaw exec-policy preset yolo
openclaw exec-policy preset cautious --json
openclaw exec-policy set --host gateway --security full --ask off --ask-fallback full
```
Output modes:
- no `--json`: prints the human-readable table view
- `--json`: prints machine-readable structured output
Current scope:
- `exec-policy` is **local-only**
- it updates the local config file and the local approvals file together
- it does **not** push policy to the gateway host or a node host
- `--host node` is rejected in this command because node exec approvals are fetched from the node at runtime and must be managed through node-targeted approvals commands instead
- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals file
If you need to edit remote host approvals directly, keep using `openclaw approvals set --gateway`
or `openclaw approvals set --node <id|name|ip>`.
## Common commands
```bash
@@ -100,6 +139,16 @@ Why `tools.exec.host=gateway` in this example:
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
Local shortcut:
```bash
openclaw exec-policy preset yolo
```
That local shortcut updates both the requested local `tools.exec.*` config and the
local approvals defaults together. It is equivalent in intent to the manual two-step
setup above, but only for the local machine.
## Allowlist helpers
```bash

View File

@@ -20,6 +20,11 @@ session or config defaults request `ask: "on-miss"`.
Use `openclaw approvals get`, `openclaw approvals get --gateway`, or
`openclaw approvals get --node <id|name|ip>` to inspect the requested policy,
host policy sources, and the effective result.
For the local machine, `openclaw exec-policy show` exposes the same merged view and
`openclaw exec-policy set|preset` can synchronize the local requested policy with the
local host approvals file in one step. When a local scope requests `host=node`,
`openclaw exec-policy show` reports that scope as node-managed at runtime instead of
pretending the local approvals file is the effective source of truth.
If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny).
@@ -143,6 +148,21 @@ openclaw approvals set --stdin <<'EOF'
EOF
```
Local shortcut for the same gateway-host policy on the current machine:
```bash
openclaw exec-policy preset yolo
```
That local shortcut updates both:
- local `tools.exec.host/security/ask`
- local `~/.openclaw/exec-approvals.json` defaults
It is intentionally local-only. If you need to change gateway-host or node-host approvals
remotely, continue using `openclaw approvals set --gateway` or
`openclaw approvals set --node <id|name|ip>`.
For a node host, apply the same approvals file on that node instead:
```bash
@@ -158,6 +178,12 @@ openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
EOF
```
Important local-only limitation:
- `openclaw exec-policy` does not synchronize node approvals
- `openclaw exec-policy set --host node` is rejected
- node exec approvals are fetched from the node at runtime, so node-targeted updates must use `openclaw approvals --node ...`
Session-only shortcut:
- `/exec security=full ask=off` changes only the current session.

View File

@@ -214,9 +214,7 @@ describe("msteams attachment helpers", () => {
messageId: "msg-1",
});
expect(urls).toHaveLength(1);
expect(urls[0]).toContain(
"/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1",
);
expect(urls[0]).toContain("/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1");
});
it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => {

View File

@@ -25,13 +25,13 @@ import {
import { isRecord } from "../attachments/shared.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import { resolveGraphChatId } from "../graph-upload.js";
import {
fetchChannelMessage,
fetchThreadReplies,
formatThreadContext,
resolveTeamGroupId,
} from "../graph-thread.js";
import { resolveGraphChatId } from "../graph-upload.js";
import {
extractMSTeamsConversationMessageId,
extractMSTeamsQuoteInfo,

View File

@@ -485,7 +485,10 @@ async function sweepSubagentRuns() {
// Session-mode runs have no archiveAtMs — apply absolute TTL after cleanup completes.
// Use cleanupCompletedAt (not endedAt) to avoid interrupting deferred cleanup flows.
if (!entry.archiveAtMs) {
if (typeof entry.cleanupCompletedAt === "number" && now - entry.cleanupCompletedAt > SESSION_RUN_TTL_MS) {
if (
typeof entry.cleanupCompletedAt === "number" &&
now - entry.cleanupCompletedAt > SESSION_RUN_TTL_MS
) {
clearPendingLifecycleError(runId);
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,

View File

@@ -0,0 +1,553 @@
import crypto from "node:crypto";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import { stripAnsi } from "../terminal/ansi.js";
import { registerExecPolicyCli } from "./exec-policy-cli.js";
function hashApprovalsFile(file: ExecApprovalsFile): string {
return crypto
.createHash("sha256")
.update(`${JSON.stringify(file, null, 2)}\n`)
.digest("hex");
}
const mocks = vi.hoisted(() => {
const runtimeErrors: string[] = [];
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
let configState: OpenClawConfig = {
tools: {
exec: {
host: "auto",
security: "allowlist",
ask: "on-miss",
},
},
};
let approvalsState: ExecApprovalsFile = {
version: 1,
defaults: {
security: "allowlist",
ask: "on-miss",
askFallback: "deny",
},
agents: {},
};
const defaultRuntime = {
log: vi.fn(),
error: vi.fn((...args: unknown[]) => {
runtimeErrors.push(stringifyArgs(args));
}),
writeJson: vi.fn((value: unknown, space = 2) => {
defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
}),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return {
getConfig: () => configState,
setConfig: (next: OpenClawConfig) => {
configState = next;
},
getApprovals: () => approvalsState,
setApprovals: (next: ExecApprovalsFile) => {
approvalsState = next;
},
defaultRuntime,
runtimeErrors,
mutateConfigFile: vi.fn(async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => {
const draft = structuredClone(configState);
mutate(draft);
configState = draft;
return {
path: "/tmp/openclaw.json",
previousHash: "hash-1",
snapshot: { path: "/tmp/openclaw.json" },
nextConfig: draft,
result: undefined,
};
}),
replaceConfigFile: vi.fn(
async ({ nextConfig }: { nextConfig: OpenClawConfig; baseHash?: string }) => {
configState = structuredClone(nextConfig);
return {
path: "/tmp/openclaw.json",
previousHash: "hash-1",
snapshot: { path: "/tmp/openclaw.json" },
nextConfig,
};
},
),
readConfigFileSnapshot: vi.fn(async () => ({
path: "/tmp/openclaw.json",
hash: "config-hash-1",
config: configState,
})),
readExecApprovalsSnapshot: vi.fn(() => ({
path: "/tmp/exec-approvals.json",
exists: true,
raw: "{}",
hash: "approvals-hash",
file: approvalsState,
})),
restoreExecApprovalsSnapshot: vi.fn(),
saveExecApprovals: vi.fn((file: ExecApprovalsFile) => {
approvalsState = file;
}),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
replaceConfigFile: mocks.replaceConfigFile,
};
});
vi.mock("../infra/exec-approvals.js", async () => {
const actual = await vi.importActual<typeof import("../infra/exec-approvals.js")>(
"../infra/exec-approvals.js",
);
return {
...actual,
readExecApprovalsSnapshot: mocks.readExecApprovalsSnapshot,
restoreExecApprovalsSnapshot: mocks.restoreExecApprovalsSnapshot,
saveExecApprovals: mocks.saveExecApprovals,
};
});
describe("exec-policy CLI", () => {
const createProgram = () => {
const program = new Command();
program.exitOverride();
registerExecPolicyCli(program);
return program;
};
const runExecPolicyCommand = async (args: string[]) => {
const program = createProgram();
await program.parseAsync(args, { from: "user" });
};
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
mocks.setConfig({
tools: {
exec: {
host: "auto",
security: "allowlist",
ask: "on-miss",
},
},
});
mocks.setApprovals({
version: 1,
defaults: {
security: "allowlist",
ask: "on-miss",
askFallback: "deny",
},
agents: {},
});
mocks.runtimeErrors.length = 0;
mocks.defaultRuntime.log.mockClear();
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.exit.mockClear();
mocks.mutateConfigFile.mockReset();
mocks.mutateConfigFile.mockImplementation(
async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => {
const draft = structuredClone(mocks.getConfig());
mutate(draft);
mocks.setConfig(draft);
return {
path: "/tmp/openclaw.json",
previousHash: "hash-1",
snapshot: { path: "/tmp/openclaw.json" },
nextConfig: draft,
result: undefined,
};
},
);
mocks.replaceConfigFile.mockReset();
mocks.replaceConfigFile.mockImplementation(
async ({ nextConfig }: { nextConfig: OpenClawConfig; baseHash?: string }) => {
mocks.setConfig(structuredClone(nextConfig));
return {
path: "/tmp/openclaw.json",
previousHash: "hash-1",
snapshot: { path: "/tmp/openclaw.json" },
nextConfig,
};
},
);
mocks.readConfigFileSnapshot.mockReset();
mocks.readConfigFileSnapshot.mockImplementation(async () => ({
path: "/tmp/openclaw.json",
hash: "config-hash-1",
config: mocks.getConfig(),
}));
mocks.readExecApprovalsSnapshot.mockReset();
mocks.readExecApprovalsSnapshot.mockImplementation(() => ({
path: "/tmp/exec-approvals.json",
exists: true,
raw: "{}",
hash: "approvals-hash",
file: mocks.getApprovals(),
}));
mocks.restoreExecApprovalsSnapshot.mockReset();
mocks.restoreExecApprovalsSnapshot.mockImplementation((_snapshot: ExecApprovalsSnapshot) => {});
mocks.saveExecApprovals.mockReset();
mocks.saveExecApprovals.mockImplementation((file: ExecApprovalsFile) => {
mocks.setApprovals(file);
});
});
it("shows the local merged exec policy as json", async () => {
await runExecPolicyCommand(["exec-policy", "show", "--json"]);
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
configPath: "/tmp/openclaw.json",
approvalsPath: "/tmp/exec-approvals.json",
effectivePolicy: expect.objectContaining({
scopes: [
expect.objectContaining({
scopeLabel: "tools.exec",
security: expect.objectContaining({
requested: "allowlist",
host: "allowlist",
effective: "allowlist",
}),
ask: expect.objectContaining({
requested: "on-miss",
host: "on-miss",
effective: "on-miss",
}),
}),
],
}),
}),
0,
);
});
it("marks host=node scopes as node-managed in show output", async () => {
mocks.setConfig({
tools: {
exec: {
host: "node",
security: "allowlist",
ask: "on-miss",
},
},
});
await runExecPolicyCommand(["exec-policy", "show", "--json"]);
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
effectivePolicy: expect.objectContaining({
note: expect.stringContaining("host=node"),
scopes: [
expect.objectContaining({
scopeLabel: "tools.exec",
runtimeApprovalsSource: "node-runtime",
security: expect.objectContaining({
requested: "allowlist",
host: "unknown",
effective: "unknown",
hostSource: "node runtime approvals",
}),
ask: expect.objectContaining({
requested: "on-miss",
host: "unknown",
effective: "unknown",
hostSource: "node runtime approvals",
}),
askFallback: expect.objectContaining({
effective: "unknown",
source: "node runtime approvals",
}),
}),
],
}),
}),
0,
);
const [{ effectivePolicy }] = mocks.defaultRuntime.writeJson.mock.calls.at(-1) as [Record<
string,
unknown
>, number];
expect((effectivePolicy as { scopes: Record<string, unknown>[] }).scopes[0]).not.toHaveProperty(
"allowedDecisions",
);
});
it("applies the yolo preset to both config and approvals", async () => {
await runExecPolicyCommand(["exec-policy", "preset", "yolo", "--json"]);
expect(mocks.getConfig().tools?.exec).toEqual({
host: "gateway",
security: "full",
ask: "off",
});
expect(mocks.getApprovals().defaults).toEqual({
security: "full",
ask: "off",
askFallback: "full",
});
expect(mocks.replaceConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
baseHash: "config-hash-1",
}),
);
expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1);
expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1);
});
it("sets explicit values without requiring a preset", async () => {
await runExecPolicyCommand([
"exec-policy",
"set",
"--host",
"gateway",
"--security",
"full",
"--ask",
"off",
"--ask-fallback",
"allowlist",
"--json",
]);
expect(mocks.getConfig().tools?.exec).toEqual({
host: "gateway",
security: "full",
ask: "off",
});
expect(mocks.getApprovals().defaults).toEqual({
security: "full",
ask: "off",
askFallback: "allowlist",
});
});
it("sanitizes terminal control content before rendering the text table", async () => {
mocks.setConfig({
tools: {
exec: {
host: "auto",
security: "allowlist\u001B[31m" as unknown as "allowlist",
ask: "on-miss",
},
},
});
mocks.readConfigFileSnapshot.mockImplementationOnce(async () => ({
path: "/tmp/openclaw.json\u001B[2J\nforged",
config: mocks.getConfig(),
}));
mocks.readExecApprovalsSnapshot.mockImplementationOnce(() => ({
path: "/tmp/exec-approvals.json\u0007\nforged",
exists: true,
raw: "{}",
hash: "approvals-hash",
file: {
version: 1,
defaults: {
security: "full",
ask: "off",
askFallback: "full",
},
agents: {
"scope\u200Bname": {
security: "allowlist",
ask: "on-miss",
askFallback: "deny",
},
},
},
}));
await runExecPolicyCommand(["exec-policy", "show"]);
const output = stripAnsi(
mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n"),
);
expect(output).toContain("/tmp/openclaw.json");
expect(output).toContain("/tmp/exec-approvals.json");
expect(output).toContain("scope\\u{200B}name");
expect(output).toContain("host=auto");
expect(output).toContain("tools.exec.");
expect(output).toContain("host)");
expect(output).toContain("\\nforged");
expect(output).not.toContain("/tmp/openclaw.json\nforged");
expect(output).not.toContain("\u001B[2J");
expect(output).not.toContain("\u0007");
});
it("reports invalid input once and exits once", async () => {
await expect(
runExecPolicyCommand(["exec-policy", "set", "--security", "nope"]),
).rejects.toThrow("__exit__:1");
expect(mocks.defaultRuntime.error).toHaveBeenCalledTimes(1);
expect(mocks.runtimeErrors).toEqual(["Invalid exec security: nope"]);
expect(mocks.defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
it("rejects host=node for the local-only sync path", async () => {
await expect(runExecPolicyCommand(["exec-policy", "set", "--host", "node"])).rejects.toThrow(
"__exit__:1",
);
expect(mocks.runtimeErrors).toEqual([
"Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.",
]);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
expect(mocks.saveExecApprovals).not.toHaveBeenCalled();
});
it("rejects sync when the resulting requested host remains node", async () => {
mocks.setConfig({
tools: {
exec: {
host: "node",
security: "allowlist",
ask: "on-miss",
},
},
});
await expect(
runExecPolicyCommand(["exec-policy", "set", "--security", "full"]),
).rejects.toThrow("__exit__:1");
expect(mocks.runtimeErrors).toEqual([
"Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.",
]);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
expect(mocks.saveExecApprovals).not.toHaveBeenCalled();
});
it("rolls back approvals if the config write fails after approvals save", async () => {
const originalApprovals = structuredClone(mocks.getApprovals());
const originalRaw = JSON.stringify(originalApprovals, null, 2);
const originalSnapshot = {
path: "/tmp/exec-approvals.json",
exists: true,
raw: originalRaw,
hash: "approvals-hash",
file: originalApprovals,
} as ExecApprovalsSnapshot as ReturnType<typeof mocks.readExecApprovalsSnapshot>;
mocks.readExecApprovalsSnapshot
.mockImplementationOnce(() => originalSnapshot)
.mockImplementationOnce(
() =>
({
path: "/tmp/exec-approvals.json",
exists: true,
raw: JSON.stringify(mocks.getApprovals(), null, 2),
hash: hashApprovalsFile(mocks.getApprovals()),
file: structuredClone(mocks.getApprovals()),
}) as ExecApprovalsSnapshot as ReturnType<typeof mocks.readExecApprovalsSnapshot>,
);
mocks.replaceConfigFile.mockImplementationOnce(async () => {
throw new Error("config write failed");
});
await expect(
runExecPolicyCommand(["exec-policy", "set", "--security", "full"]),
).rejects.toThrow("__exit__:1");
expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1);
expect(mocks.restoreExecApprovalsSnapshot).toHaveBeenCalledWith(originalSnapshot);
expect(mocks.runtimeErrors).toEqual(["config write failed"]);
});
it("removes a newly-written approvals file when config replacement fails and the original file was missing", async () => {
const missingSnapshot = {
path: "/tmp/missing-exec-approvals.json",
exists: false,
raw: null,
hash: "approvals-hash",
file: { version: 1, agents: {} },
} as ExecApprovalsSnapshot as ReturnType<typeof mocks.readExecApprovalsSnapshot>;
mocks.readExecApprovalsSnapshot
.mockImplementationOnce(() => missingSnapshot)
.mockImplementationOnce(
() =>
({
path: "/tmp/missing-exec-approvals.json",
exists: true,
raw: JSON.stringify(mocks.getApprovals(), null, 2),
hash: hashApprovalsFile(mocks.getApprovals()),
file: structuredClone(mocks.getApprovals()),
}) as ExecApprovalsSnapshot as ReturnType<typeof mocks.readExecApprovalsSnapshot>,
);
mocks.replaceConfigFile.mockImplementationOnce(async () => {
throw new Error("config write failed");
});
await expect(
runExecPolicyCommand(["exec-policy", "set", "--security", "full"]),
).rejects.toThrow("__exit__:1");
expect(mocks.restoreExecApprovalsSnapshot).toHaveBeenCalledWith(missingSnapshot);
});
it("does not clobber a newer approvals write during rollback", async () => {
const originalApprovals = structuredClone(mocks.getApprovals());
const originalRaw = JSON.stringify(originalApprovals, null, 2);
const originalSnapshot: ExecApprovalsSnapshot = {
path: "/tmp/exec-approvals.json",
exists: true,
raw: originalRaw,
hash: "original-hash",
file: originalApprovals,
};
const concurrentFile: ExecApprovalsFile = {
version: 1,
defaults: {
security: "deny",
ask: "off",
askFallback: "deny",
},
agents: {},
};
const concurrentSnapshot = {
path: "/tmp/exec-approvals.json",
exists: true,
raw: JSON.stringify(concurrentFile, null, 2),
hash: "concurrent-write-hash",
file: concurrentFile,
} as ExecApprovalsSnapshot as ReturnType<typeof mocks.readExecApprovalsSnapshot>;
let snapshotReadCount = 0;
mocks.readExecApprovalsSnapshot.mockImplementation(() => {
snapshotReadCount += 1;
return snapshotReadCount === 1 ? originalSnapshot : concurrentSnapshot;
});
mocks.replaceConfigFile.mockImplementationOnce(async () => {
throw new Error("config write failed");
});
await expect(
runExecPolicyCommand(["exec-policy", "set", "--security", "full"]),
).rejects.toThrow("__exit__:1");
expect(mocks.restoreExecApprovalsSnapshot).not.toHaveBeenCalled();
expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1);
expect(mocks.runtimeErrors).toEqual(["config write failed"]);
});
});

442
src/cli/exec-policy-cli.ts Normal file
View File

@@ -0,0 +1,442 @@
import crypto from "node:crypto";
import fs from "node:fs";
import type { Command } from "commander";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
import { sanitizeExecApprovalDisplayText } from "../infra/exec-approval-command-display.js";
import {
collectExecPolicyScopeSnapshots,
type ExecPolicyScopeSnapshot,
} from "../infra/exec-approvals-effective.js";
import {
normalizeExecAsk,
normalizeExecSecurity,
normalizeExecTarget,
readExecApprovalsSnapshot,
restoreExecApprovalsSnapshot,
saveExecApprovals,
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
type ExecTarget,
} from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
type ExecPolicyPresetName = "yolo" | "cautious" | "deny-all";
type ExecPolicyResolved = {
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
};
const EXEC_POLICY_PRESETS: Record<ExecPolicyPresetName, Required<ExecPolicyResolved>> = {
yolo: {
host: "gateway",
security: "full",
ask: "off",
askFallback: "full",
},
cautious: {
host: "gateway",
security: "allowlist",
ask: "on-miss",
askFallback: "deny",
},
"deny-all": {
host: "gateway",
security: "deny",
ask: "off",
askFallback: "deny",
},
};
type ExecPolicyShowPayload = {
configPath: string;
approvalsPath: string;
approvalsExists: boolean;
effectivePolicy: {
note: string;
scopes: ExecPolicyShowScope[];
};
};
type ExecPolicyShowSecurity = ExecSecurity | "unknown";
type ExecPolicyShowAsk = ExecAsk | "unknown";
type ExecPolicyShowScope = Omit<
ExecPolicyScopeSnapshot,
"security" | "ask" | "askFallback" | "allowedDecisions"
> & {
runtimeApprovalsSource: "local-file" | "node-runtime";
security: {
requested: ExecSecurity;
requestedSource: string;
host: ExecPolicyShowSecurity;
hostSource: string;
effective: ExecPolicyShowSecurity;
note: string;
};
ask: {
requested: ExecAsk;
requestedSource: string;
host: ExecPolicyShowAsk;
hostSource: string;
effective: ExecPolicyShowAsk;
note: string;
};
askFallback: {
effective: ExecPolicyShowSecurity;
source: string;
};
};
class ExecPolicyCliError extends Error {
constructor(message: string) {
super(message);
this.name = "ExecPolicyCliError";
}
}
function failExecPolicy(message: string): never {
throw new ExecPolicyCliError(message);
}
function formatExecPolicyError(err: unknown): string {
return sanitizeExecPolicyMessage(err instanceof Error ? err.message : String(err));
}
async function runExecPolicyAction(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (err) {
defaultRuntime.error(formatExecPolicyError(err));
defaultRuntime.exit(1);
}
}
function sanitizeExecPolicyTableCell(value: string): string {
return sanitizeExecApprovalDisplayText(sanitizeTerminalText(value));
}
function sanitizeExecPolicyMessage(value: unknown): string {
return sanitizeTerminalText(String(value));
}
function hashExecApprovalsFile(file: ExecApprovalsFile): string {
const raw = `${JSON.stringify(file, null, 2)}\n`;
return crypto.createHash("sha256").update(raw).digest("hex");
}
function resolveExecPolicyInput(params: {
host?: string;
security?: string;
ask?: string;
askFallback?: string;
}): ExecPolicyResolved {
const resolved: ExecPolicyResolved = {};
if (params.host !== undefined) {
const host = normalizeExecTarget(params.host);
if (!host) {
failExecPolicy(`Invalid exec host: ${sanitizeExecPolicyMessage(params.host)}`);
}
resolved.host = host;
}
if (params.security !== undefined) {
const security = normalizeExecSecurity(params.security);
if (!security) {
failExecPolicy(`Invalid exec security: ${sanitizeExecPolicyMessage(params.security)}`);
}
resolved.security = security;
}
if (params.ask !== undefined) {
const ask = normalizeExecAsk(params.ask);
if (!ask) {
failExecPolicy(`Invalid exec ask mode: ${sanitizeExecPolicyMessage(params.ask)}`);
}
resolved.ask = ask;
}
if (params.askFallback !== undefined) {
const askFallback = normalizeExecSecurity(params.askFallback);
if (!askFallback) {
failExecPolicy(`Invalid exec askFallback: ${sanitizeExecPolicyMessage(params.askFallback)}`);
}
resolved.askFallback = askFallback;
}
return resolved;
}
function applyConfigExecPolicy(draft: Record<string, unknown>, policy: ExecPolicyResolved): void {
const root = draft as {
tools?: {
exec?: {
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
};
};
};
root.tools ??= {};
root.tools.exec ??= {};
if (policy.host !== undefined) {
root.tools.exec.host = policy.host;
}
if (policy.security !== undefined) {
root.tools.exec.security = policy.security;
}
if (policy.ask !== undefined) {
root.tools.exec.ask = policy.ask;
}
}
function applyApprovalsDefaults(
file: ExecApprovalsFile,
policy: ExecPolicyResolved,
): ExecApprovalsFile {
const next: ExecApprovalsFile = structuredClone(file ?? { version: 1 });
next.version = 1;
next.defaults ??= {};
if (policy.security !== undefined) {
next.defaults.security = policy.security;
}
if (policy.ask !== undefined) {
next.defaults.ask = policy.ask;
}
if (policy.askFallback !== undefined) {
next.defaults.askFallback = policy.askFallback;
}
return next;
}
function buildNextExecPolicyConfig(
config: OpenClawConfig,
policy: ExecPolicyResolved,
): OpenClawConfig {
const draft = structuredClone(config);
applyConfigExecPolicy(draft as Record<string, unknown>, policy);
return draft;
}
async function buildLocalExecPolicyShowPayload(): Promise<ExecPolicyShowPayload> {
const configSnapshot = await readConfigFileSnapshot();
const approvalsSnapshot = readExecApprovalsSnapshot();
const scopes = collectExecPolicyScopeSnapshots({
cfg: configSnapshot.config ?? {},
approvals: approvalsSnapshot.file,
hostPath: approvalsSnapshot.path,
}).map(buildExecPolicyShowScope);
const hasNodeRuntimeScope = scopes.some((scope) => scope.runtimeApprovalsSource === "node-runtime");
return {
configPath: configSnapshot.path,
approvalsPath: approvalsSnapshot.path,
approvalsExists: approvalsSnapshot.exists,
effectivePolicy: {
note: hasNodeRuntimeScope
? "Scopes requesting host=node are node-managed at runtime. Local approvals are shown only for local/gateway scopes."
: "Effective exec policy is the host approvals file intersected with requested tools.exec policy.",
scopes,
},
};
}
function buildExecPolicyShowScope(snapshot: ExecPolicyScopeSnapshot): ExecPolicyShowScope {
const { allowedDecisions: _allowedDecisions, ...baseScope } = snapshot;
if (snapshot.host.requested !== "node") {
return {
...baseScope,
runtimeApprovalsSource: "local-file",
};
}
return {
...baseScope,
runtimeApprovalsSource: "node-runtime",
security: {
requested: snapshot.security.requested,
requestedSource: snapshot.security.requestedSource,
host: "unknown",
hostSource: "node runtime approvals",
effective: "unknown",
note: "runtime policy resolved by node approvals",
},
ask: {
requested: snapshot.ask.requested,
requestedSource: snapshot.ask.requestedSource,
host: "unknown",
hostSource: "node runtime approvals",
effective: "unknown",
note: "runtime policy resolved by node approvals",
},
askFallback: {
effective: "unknown",
source: "node runtime approvals",
},
};
}
function renderExecPolicyShow(payload: ExecPolicyShowPayload): void {
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
defaultRuntime.log(heading("Exec Policy"));
defaultRuntime.log(
renderTable({
width: getTerminalTableWidth(),
columns: [
{ key: "Field", header: "Field", minWidth: 14 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: [
{ Field: "Config", Value: sanitizeExecPolicyTableCell(payload.configPath) },
{ Field: "Approvals", Value: sanitizeExecPolicyTableCell(payload.approvalsPath) },
{
Field: "Approvals File",
Value: sanitizeExecPolicyTableCell(payload.approvalsExists ? "present" : "missing"),
},
],
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(heading("Effective Policy"));
defaultRuntime.log(
renderTable({
width: getTerminalTableWidth(),
columns: [
{ key: "Scope", header: "Scope", minWidth: 12 },
{ key: "Requested", header: "Requested", minWidth: 24, flex: true },
{ key: "Host", header: "Host", minWidth: 24, flex: true },
{ key: "Effective", header: "Effective", minWidth: 16 },
],
rows: payload.effectivePolicy.scopes.map((scope) => ({
Scope: sanitizeExecPolicyTableCell(scope.scopeLabel),
Requested: sanitizeExecPolicyTableCell(
`host=${scope.host.requested} (${scope.host.requestedSource})\n` +
`security=${scope.security.requested} (${scope.security.requestedSource})\n` +
`ask=${scope.ask.requested} (${scope.ask.requestedSource})`,
),
Host: sanitizeExecPolicyTableCell(
`security=${scope.security.host} (${scope.security.hostSource})\n` +
`ask=${scope.ask.host} (${scope.ask.hostSource})\n` +
`askFallback=${scope.askFallback.effective} (${scope.askFallback.source})`,
),
Effective: sanitizeExecPolicyTableCell(
`security=${scope.security.effective}\nask=${scope.ask.effective}`,
),
})),
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(muted(payload.effectivePolicy.note));
}
async function applyLocalExecPolicy(policy: ExecPolicyResolved): Promise<ExecPolicyShowPayload> {
const configSnapshot = await readConfigFileSnapshot();
const nextConfig = buildNextExecPolicyConfig(configSnapshot.config ?? {}, policy);
if (nextConfig.tools?.exec?.host === "node") {
failExecPolicy(
"Local exec-policy cannot synchronize host=node. Node approvals are fetched from the node at runtime.",
);
}
const approvalsSnapshot = readExecApprovalsSnapshot();
const nextApprovals = applyApprovalsDefaults(approvalsSnapshot.file, policy);
const writtenApprovalsHash = hashExecApprovalsFile(nextApprovals);
saveExecApprovals(nextApprovals);
try {
await replaceConfigFile({
baseHash: configSnapshot.hash,
nextConfig,
});
} catch (err) {
const currentApprovalsSnapshot = readExecApprovalsSnapshot();
if (currentApprovalsSnapshot.hash !== writtenApprovalsHash) {
throw err;
}
restoreExecApprovalsSnapshot(approvalsSnapshot);
throw err;
}
return await buildLocalExecPolicyShowPayload();
}
export function registerExecPolicyCli(program: Command) {
const execPolicy = program
.command("exec-policy")
.description("Show or synchronize requested exec policy with host approvals")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.openclaw.ai/cli/approvals")}\n`,
);
execPolicy
.command("show")
.description("Show the local config policy, host approvals, and effective merge")
.option("--json", "Output as JSON", false)
.action(async (opts: { json?: boolean }) => {
await runExecPolicyAction(async () => {
const payload = await buildLocalExecPolicyShowPayload();
if (opts.json) {
defaultRuntime.writeJson(payload, 0);
return;
}
renderExecPolicyShow(payload);
});
});
execPolicy
.command("preset <name>")
.description('Apply a synchronized preset: "yolo", "cautious", or "deny-all"')
.option("--json", "Output as JSON", false)
.action(async (name: string, opts: { json?: boolean }) => {
await runExecPolicyAction(async () => {
if (!Object.hasOwn(EXEC_POLICY_PRESETS, name)) {
failExecPolicy(`Unknown exec-policy preset: ${sanitizeExecPolicyMessage(name)}`);
}
const preset = EXEC_POLICY_PRESETS[name as ExecPolicyPresetName];
const payload = await applyLocalExecPolicy(preset);
if (opts.json) {
defaultRuntime.writeJson({ preset: name, ...payload }, 0);
return;
}
defaultRuntime.log(`Applied exec-policy preset: ${sanitizeExecPolicyMessage(name)}`);
defaultRuntime.log("");
renderExecPolicyShow(payload);
});
});
execPolicy
.command("set")
.description("Synchronize local config and host approvals using explicit values")
.option("--host <host>", "Exec host target: auto|sandbox|gateway|node")
.option("--security <mode>", "Exec security: deny|allowlist|full")
.option("--ask <mode>", "Exec ask mode: off|on-miss|always")
.option("--ask-fallback <mode>", "Host approvals fallback: deny|allowlist|full")
.option("--json", "Output as JSON", false)
.action(
async (opts: {
host?: string;
security?: string;
ask?: string;
askFallback?: string;
json?: boolean;
}) => {
await runExecPolicyAction(async () => {
const policy = resolveExecPolicyInput(opts);
if (Object.keys(policy).length === 0) {
failExecPolicy("Provide at least one of --host, --security, --ask, or --ask-fallback.");
}
const payload = await applyLocalExecPolicy(policy);
if (opts.json) {
defaultRuntime.writeJson({ applied: policy, ...payload }, 0);
return;
}
defaultRuntime.log("Synchronized local exec policy.");
defaultRuntime.log("");
renderExecPolicyShow(payload);
});
},
);
}

View File

@@ -84,6 +84,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
loadModule: () => import("../exec-approvals-cli.js"),
exportName: "registerExecApprovalsCli",
},
{
commandNames: ["exec-policy"],
loadModule: () => import("../exec-policy-cli.js"),
exportName: "registerExecPolicyCli",
},
{
commandNames: ["nodes"],
loadModule: () => import("../nodes-cli.js"),

View File

@@ -37,6 +37,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
description: "Manage exec approvals (gateway or node host)",
hasSubcommands: true,
},
{
name: "exec-policy",
description: "Show or synchronize requested exec policy with host approvals",
hasSubcommands: true,
},
{
name: "nodes",
description: "Manage gateway-owned node pairing and node commands",

View File

@@ -52,11 +52,7 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = createCliDeps();
mockAgentPayloads(
[
{ text: "section 1" },
{ text: "temporary error", isError: true },
{ text: "section 2" },
],
[{ text: "section 1" }, { text: "temporary error", isError: true }, { text: "section 2" }],
{ meta: makeRunMeta("section 1\nsection 2") },
);
@@ -105,10 +101,9 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = createCliDeps();
mockAgentPayloads(
[{ text: "Working on it..." }, { text: "Final weather summary" }],
{ meta: makeRunMeta("Final weather summary") },
);
mockAgentPayloads([{ text: "Working on it..." }, { text: "Final weather summary" }], {
meta: makeRunMeta("Final weather summary"),
});
const plainRes = await runTelegramAnnounceTurn({
home,

View File

@@ -10,6 +10,7 @@ import {
type ExecApprovalsFile,
type ExecAsk,
type ExecSecurity,
type ExecTarget,
} from "./exec-approvals.js";
const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full";
@@ -20,10 +21,16 @@ const REQUESTED_DEFAULT_LABEL = {
ask: DEFAULT_REQUESTED_ASK,
} as const;
type ExecPolicyConfig = {
host?: ExecTarget;
security?: ExecSecurity;
ask?: ExecAsk;
};
export type ExecPolicyHostSummary = {
requested: ExecTarget;
requestedSource: string;
};
export type ExecPolicyFieldSummary<TValue extends ExecSecurity | ExecAsk> = {
requested: TValue;
requestedSource: string;
@@ -37,6 +44,7 @@ export type ExecPolicyScopeSnapshot = {
scopeLabel: string;
configPath: string;
agentId?: string;
host: ExecPolicyHostSummary;
security: ExecPolicyFieldSummary<ExecSecurity>;
ask: ExecPolicyFieldSummary<ExecAsk>;
askFallback: {
@@ -50,6 +58,30 @@ export type ExecPolicyScopeSummary = Omit<ExecPolicyScopeSnapshot, "allowedDecis
type ExecPolicyRequestedField = "security" | "ask";
function resolveRequestedHost(params: {
scopeExecConfig?: ExecPolicyConfig;
globalExecConfig?: ExecPolicyConfig;
}): { value: ExecTarget; sourcePath: string } {
const scopeValue = params.scopeExecConfig?.host;
if (scopeValue !== undefined) {
return {
value: scopeValue,
sourcePath: "scope",
};
}
const globalValue = params.globalExecConfig?.host;
if (globalValue !== undefined) {
return {
value: globalValue,
sourcePath: "tools.exec",
};
}
return {
value: "auto",
sourcePath: "__default__",
};
}
function formatRequestedSource(params: {
sourcePath: string;
field: "security" | "ask";
@@ -182,6 +214,10 @@ export function resolveExecPolicyScopeSnapshot(params: {
scopeExecConfig: params.scopeExecConfig,
globalExecConfig: params.globalExecConfig,
});
const requestedHost = resolveRequestedHost({
scopeExecConfig: params.scopeExecConfig,
globalExecConfig: params.globalExecConfig,
});
const requestedAsk = resolveRequestedField<ExecAsk>({
field: "ask",
scopeExecConfig: params.scopeExecConfig,
@@ -203,6 +239,13 @@ export function resolveExecPolicyScopeSnapshot(params: {
scopeLabel: params.scopeLabel,
configPath: params.configPath,
...(params.agentId ? { agentId: params.agentId } : {}),
host: {
requested: requestedHost.value,
requestedSource:
requestedHost.sourcePath === "__default__"
? "OpenClaw default (auto)"
: `${requestedHost.sourcePath === "scope" ? params.configPath : requestedHost.sourcePath}.host`,
},
security: {
requested: requestedSecurity.value,
requestedSource: formatRequestedSource({

View File

@@ -25,6 +25,7 @@ let recordAllowlistUse: ExecApprovalsModule["recordAllowlistUse"];
let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"];
let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"];
let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"];
let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"];
const tempDirs: string[] = [];
const originalOpenClawHome = process.env.OPENCLAW_HOME;
@@ -43,6 +44,7 @@ beforeAll(async () => {
requestExecApprovalViaSocket,
resolveExecApprovalsPath,
resolveExecApprovalsSocketPath,
saveExecApprovals,
} = await import("./exec-approvals.js"));
});
@@ -156,6 +158,48 @@ describe("exec approvals store helpers", () => {
expect(readApprovalsFile(dir).socket).toEqual(ensured.socket);
});
it("atomically replaces existing approvals files instead of mutating linked inodes", () => {
const dir = createHomeDir();
const approvalsPath = approvalsFilePath(dir);
const linkedPath = path.join(dir, "linked.json");
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
fs.writeFileSync(linkedPath, '{"sentinel":true}\n', "utf8");
fs.linkSync(linkedPath, approvalsPath);
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"');
expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n');
expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino);
});
it("refuses to write approvals through a symlink destination", () => {
const dir = createHomeDir();
const approvalsPath = approvalsFilePath(dir);
const targetPath = path.join(dir, "elsewhere.json");
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8");
fs.symlinkSync(targetPath, approvalsPath);
expect(() =>
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
).toThrow(/Refusing to write exec approvals via symlink/);
expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n');
});
it("refuses to traverse a symlinked parent component in the approvals path", () => {
const realHome = makeTempDir();
const linkedHome = `${realHome}-link`;
tempDirs.push(realHome);
fs.symlinkSync(realHome, linkedHome);
process.env.OPENCLAW_HOME = linkedHome;
expect(() =>
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
).toThrow(/Refusing to traverse symlink in exec approvals path/);
expect(fs.existsSync(path.join(realHome, ".openclaw"))).toBe(false);
});
it("adds trimmed allowlist entries once and persists generated ids", () => {
const dir = createHomeDir();
vi.spyOn(Date, "now").mockReturnValue(123_456);

View File

@@ -11,7 +11,7 @@ import {
import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js";
import type { ExecCommandSegment } from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import { expandHomePrefix } from "./home-dir.js";
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
import { requestJsonlSocket } from "./jsonl-socket.js";
export * from "./exec-approvals-analysis.js";
export * from "./exec-approvals-allowlist.js";
@@ -229,7 +229,53 @@ function mergeLegacyAgent(
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
assertNoSymlinkPathComponents(dir, resolveRequiredHomeDir());
fs.mkdirSync(dir, { recursive: true });
const dirStat = fs.lstatSync(dir);
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
throw new Error(`Refusing to use unsafe exec approvals directory: ${dir}`);
}
return dir;
}
function assertNoSymlinkPathComponents(targetPath: string, trustedRoot: string): void {
const resolvedTarget = path.resolve(targetPath);
const resolvedRoot = path.resolve(trustedRoot);
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
return;
}
const relative = path.relative(resolvedRoot, resolvedTarget);
const segments = relative && relative !== "." ? relative.split(path.sep) : [];
let current = resolvedRoot;
for (const segment of [".", ...segments]) {
if (segment !== ".") {
current = path.join(current, segment);
}
try {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) {
throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
}
function assertSafeExecApprovalsDestination(filePath: string): void {
try {
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
throw new Error(`Refusing to write exec approvals via symlink: ${filePath}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
// Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread
@@ -434,8 +480,24 @@ export function loadExecApprovals(): ExecApprovalsFile {
export function saveExecApprovals(file: ExecApprovalsFile) {
const filePath = resolveExecApprovalsPath();
ensureDir(filePath);
fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
const raw = `${JSON.stringify(file, null, 2)}\n`;
writeExecApprovalsRaw(filePath, raw);
}
function writeExecApprovalsRaw(filePath: string, raw: string) {
const dir = ensureDir(filePath);
assertSafeExecApprovalsDestination(filePath);
const tempPath = path.join(dir, `.exec-approvals.${process.pid}.${crypto.randomUUID()}.tmp`);
let tempWritten = false;
try {
fs.writeFileSync(tempPath, raw, { mode: 0o600, flag: "wx" });
tempWritten = true;
fs.renameSync(tempPath, filePath);
} finally {
if (tempWritten && fs.existsSync(tempPath)) {
fs.rmSync(tempPath, { force: true });
}
}
try {
fs.chmodSync(filePath, 0o600);
} catch {
@@ -443,6 +505,18 @@ export function saveExecApprovals(file: ExecApprovalsFile) {
}
}
export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): void {
if (!snapshot.exists) {
fs.rmSync(snapshot.path, { force: true });
return;
}
if (snapshot.raw !== null) {
writeExecApprovalsRaw(snapshot.path, snapshot.raw);
return;
}
saveExecApprovals(snapshot.file);
}
export function ensureExecApprovals(): ExecApprovalsFile {
const loaded = loadExecApprovals();
const next = normalizeExecApprovals(loaded);