Skip to content

Commit fe0cd12

Browse files
committed
feat: add local exec-policy CLI
1 parent 5050017 commit fe0cd12

4 files changed

Lines changed: 545 additions & 0 deletions

File tree

src/cli/exec-policy-cli.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Command } from "commander";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { OpenClawConfig } from "../config/config.js";
4+
import type { ExecApprovalsFile } from "../infra/exec-approvals.js";
5+
import { registerExecPolicyCli } from "./exec-policy-cli.js";
6+
7+
const mocks = vi.hoisted(() => {
8+
const runtimeErrors: string[] = [];
9+
const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" ");
10+
let configState: OpenClawConfig = {
11+
tools: {
12+
exec: {
13+
host: "auto",
14+
security: "allowlist",
15+
ask: "on-miss",
16+
},
17+
},
18+
};
19+
let approvalsState: ExecApprovalsFile = {
20+
version: 1,
21+
defaults: {
22+
security: "allowlist",
23+
ask: "on-miss",
24+
askFallback: "deny",
25+
},
26+
agents: {},
27+
};
28+
const defaultRuntime = {
29+
log: vi.fn(),
30+
error: vi.fn((...args: unknown[]) => {
31+
runtimeErrors.push(stringifyArgs(args));
32+
}),
33+
writeJson: vi.fn((value: unknown, space = 2) => {
34+
defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined));
35+
}),
36+
exit: vi.fn((code: number) => {
37+
throw new Error(`__exit__:${code}`);
38+
}),
39+
};
40+
return {
41+
getConfig: () => configState,
42+
setConfig: (next: OpenClawConfig) => {
43+
configState = next;
44+
},
45+
getApprovals: () => approvalsState,
46+
setApprovals: (next: ExecApprovalsFile) => {
47+
approvalsState = next;
48+
},
49+
defaultRuntime,
50+
runtimeErrors,
51+
mutateConfigFile: vi.fn(async ({ mutate }: { mutate: (draft: OpenClawConfig) => void }) => {
52+
const draft = structuredClone(configState);
53+
mutate(draft);
54+
configState = draft;
55+
return {
56+
path: "/tmp/openclaw.json",
57+
previousHash: "hash-1",
58+
snapshot: { path: "/tmp/openclaw.json" },
59+
nextConfig: draft,
60+
result: undefined,
61+
};
62+
}),
63+
readConfigFileSnapshot: vi.fn(async () => ({
64+
path: "/tmp/openclaw.json",
65+
config: configState,
66+
})),
67+
readExecApprovalsSnapshot: vi.fn(() => ({
68+
path: "/tmp/exec-approvals.json",
69+
exists: true,
70+
raw: "{}",
71+
hash: "approvals-hash",
72+
file: approvalsState,
73+
})),
74+
saveExecApprovals: vi.fn((file: ExecApprovalsFile) => {
75+
approvalsState = file;
76+
}),
77+
};
78+
});
79+
80+
vi.mock("../runtime.js", () => ({
81+
defaultRuntime: mocks.defaultRuntime,
82+
}));
83+
84+
vi.mock("../config/config.js", async () => {
85+
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
86+
return {
87+
...actual,
88+
mutateConfigFile: mocks.mutateConfigFile,
89+
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
90+
};
91+
});
92+
93+
vi.mock("../infra/exec-approvals.js", async () => {
94+
const actual = await vi.importActual<typeof import("../infra/exec-approvals.js")>(
95+
"../infra/exec-approvals.js",
96+
);
97+
return {
98+
...actual,
99+
readExecApprovalsSnapshot: mocks.readExecApprovalsSnapshot,
100+
saveExecApprovals: mocks.saveExecApprovals,
101+
};
102+
});
103+
104+
describe("exec-policy CLI", () => {
105+
const createProgram = () => {
106+
const program = new Command();
107+
program.exitOverride();
108+
registerExecPolicyCli(program);
109+
return program;
110+
};
111+
112+
const runExecPolicyCommand = async (args: string[]) => {
113+
const program = createProgram();
114+
await program.parseAsync(args, { from: "user" });
115+
};
116+
117+
beforeEach(() => {
118+
mocks.setConfig({
119+
tools: {
120+
exec: {
121+
host: "auto",
122+
security: "allowlist",
123+
ask: "on-miss",
124+
},
125+
},
126+
});
127+
mocks.setApprovals({
128+
version: 1,
129+
defaults: {
130+
security: "allowlist",
131+
ask: "on-miss",
132+
askFallback: "deny",
133+
},
134+
agents: {},
135+
});
136+
mocks.runtimeErrors.length = 0;
137+
mocks.defaultRuntime.log.mockClear();
138+
mocks.defaultRuntime.error.mockClear();
139+
mocks.defaultRuntime.writeJson.mockClear();
140+
mocks.defaultRuntime.exit.mockClear();
141+
mocks.mutateConfigFile.mockClear();
142+
mocks.readConfigFileSnapshot.mockClear();
143+
mocks.readExecApprovalsSnapshot.mockClear();
144+
mocks.saveExecApprovals.mockClear();
145+
});
146+
147+
it("shows the local merged exec policy as json", async () => {
148+
await runExecPolicyCommand(["exec-policy", "show", "--json"]);
149+
150+
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
151+
expect.objectContaining({
152+
configPath: "/tmp/openclaw.json",
153+
approvalsPath: "/tmp/exec-approvals.json",
154+
effectivePolicy: expect.objectContaining({
155+
scopes: [
156+
expect.objectContaining({
157+
scopeLabel: "tools.exec",
158+
security: expect.objectContaining({
159+
requested: "allowlist",
160+
host: "allowlist",
161+
effective: "allowlist",
162+
}),
163+
ask: expect.objectContaining({
164+
requested: "on-miss",
165+
host: "on-miss",
166+
effective: "on-miss",
167+
}),
168+
}),
169+
],
170+
}),
171+
}),
172+
0,
173+
);
174+
});
175+
176+
it("applies the yolo preset to both config and approvals", async () => {
177+
await runExecPolicyCommand(["exec-policy", "preset", "yolo", "--json"]);
178+
179+
expect(mocks.getConfig().tools?.exec).toEqual({
180+
host: "gateway",
181+
security: "full",
182+
ask: "off",
183+
});
184+
expect(mocks.getApprovals().defaults).toEqual({
185+
security: "full",
186+
ask: "off",
187+
askFallback: "full",
188+
});
189+
expect(mocks.saveExecApprovals).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it("sets explicit values without requiring a preset", async () => {
193+
await runExecPolicyCommand([
194+
"exec-policy",
195+
"set",
196+
"--host",
197+
"node",
198+
"--security",
199+
"full",
200+
"--ask",
201+
"off",
202+
"--ask-fallback",
203+
"allowlist",
204+
"--json",
205+
]);
206+
207+
expect(mocks.getConfig().tools?.exec).toEqual({
208+
host: "node",
209+
security: "full",
210+
ask: "off",
211+
});
212+
expect(mocks.getApprovals().defaults).toEqual({
213+
security: "full",
214+
ask: "off",
215+
askFallback: "allowlist",
216+
});
217+
});
218+
});

0 commit comments

Comments
 (0)