mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 04:01:05 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
553
src/cli/exec-policy-cli.test.ts
Normal file
553
src/cli/exec-policy-cli.test.ts
Normal 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
442
src/cli/exec-policy-cli.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user