Skip to content

Commit 141f551

Browse files
committed
fix(exec-approvals): coerce bare string allowlist entries (#9903) (thanks @mcaxtr)
1 parent 6ff209e commit 141f551

File tree

7 files changed

+82
-22
lines changed

7 files changed

+82
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
3737
- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
3838
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
3939
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
40+
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
4041
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
4142
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
4243
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.

src/agents/pi-tools.safe-bins.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import type { OpenClawConfig } from "../config/config.js";
66
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
77
import { createOpenClawCodingTools } from "./pi-tools.js";
88

9+
vi.mock("../plugins/tools.js", () => ({
10+
getPluginToolMeta: () => undefined,
11+
resolvePluginTools: () => [],
12+
}));
13+
14+
vi.mock("../infra/shell-env.js", async (importOriginal) => {
15+
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
16+
return { ...mod, getShellPathFromLoginShell: () => null };
17+
});
18+
919
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
1020
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
1121
const approvals: ExecApprovalsResolved = {

src/agents/pi-tools.workspace-paths.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { describe, expect, it } from "vitest";
4+
import { describe, expect, it, vi } from "vitest";
55
import { createOpenClawCodingTools } from "./pi-tools.js";
66

7+
vi.mock("../plugins/tools.js", () => ({
8+
getPluginToolMeta: () => undefined,
9+
resolvePluginTools: () => [],
10+
}));
11+
12+
vi.mock("../infra/shell-env.js", async (importOriginal) => {
13+
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
14+
return { ...mod, getShellPathFromLoginShell: () => null };
15+
});
16+
717
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
818
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
919
try {
@@ -99,7 +109,7 @@ describe("workspace path resolution", () => {
99109

100110
it("defaults exec cwd to workspaceDir when workdir is omitted", async () => {
101111
await withTempDir("openclaw-ws-", async (workspaceDir) => {
102-
const tools = createOpenClawCodingTools({ workspaceDir });
112+
const tools = createOpenClawCodingTools({ workspaceDir, exec: { host: "gateway" } });
103113
const execTool = tools.find((tool) => tool.name === "exec");
104114
expect(execTool).toBeDefined();
105115

@@ -122,7 +132,7 @@ describe("workspace path resolution", () => {
122132
it("lets exec workdir override the workspace default", async () => {
123133
await withTempDir("openclaw-ws-", async (workspaceDir) => {
124134
await withTempDir("openclaw-override-", async (overrideDir) => {
125-
const tools = createOpenClawCodingTools({ workspaceDir });
135+
const tools = createOpenClawCodingTools({ workspaceDir, exec: { host: "gateway" } });
126136
const execTool = tools.find((tool) => tool.name === "exec");
127137
expect(execTool).toBeDefined();
128138

src/cli/gateway-cli.coverage.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,17 @@ async function withEnvOverride<T>(
5353
}
5454
}
5555

56-
vi.mock("../gateway/call.js", () => ({
57-
callGateway: (opts: unknown) => callGateway(opts),
58-
randomIdempotencyKey: () => "rk_test",
59-
}));
56+
vi.mock(
57+
new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href,
58+
async (importOriginal) => {
59+
const mod = await importOriginal();
60+
return {
61+
...mod,
62+
callGateway: (opts: unknown) => callGateway(opts),
63+
randomIdempotencyKey: () => "rk_test",
64+
};
65+
},
66+
);
6067

6168
vi.mock("../gateway/server.js", () => ({
6269
startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts),
@@ -122,7 +129,7 @@ describe("gateway-cli coverage", () => {
122129

123130
expect(callGateway).toHaveBeenCalledTimes(1);
124131
expect(runtimeLogs.join("\n")).toContain('"ok": true');
125-
}, 30_000);
132+
}, 60_000);
126133

127134
it("registers gateway probe and routes to gatewayStatusCommand", async () => {
128135
runtimeLogs.length = 0;
@@ -137,7 +144,7 @@ describe("gateway-cli coverage", () => {
137144
await program.parseAsync(["gateway", "probe", "--json"], { from: "user" });
138145

139146
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
140-
}, 30_000);
147+
}, 60_000);
141148

142149
it("registers gateway discover and prints JSON", async () => {
143150
runtimeLogs.length = 0;

src/cli/program.smoke.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock("../gateway/call.js", () => ({
5050
}),
5151
}));
5252
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
53+
vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} }));
5354

5455
const { buildProgram } = await import("./program.js");
5556

src/infra/exec-approvals.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,4 +680,37 @@ describe("normalizeExecApprovals handles string allowlist entries (#9790)", () =
680680
// Only "ls" should survive; empty/whitespace strings should be dropped
681681
expect(entries.map((e) => e.pattern)).toEqual(["ls"]);
682682
});
683+
684+
it("drops malformed object entries with missing/non-string patterns", () => {
685+
const file = {
686+
version: 1,
687+
agents: {
688+
main: {
689+
allowlist: [{ pattern: "/usr/bin/ls" }, {}, { pattern: 123 }, { pattern: " " }, "echo"],
690+
},
691+
},
692+
} as unknown as ExecApprovalsFile;
693+
694+
const normalized = normalizeExecApprovals(file);
695+
const entries = normalized.agents?.main?.allowlist ?? [];
696+
697+
expect(entries.map((e) => e.pattern)).toEqual(["/usr/bin/ls", "echo"]);
698+
for (const entry of entries) {
699+
expect(entry).not.toHaveProperty("0");
700+
}
701+
});
702+
703+
it("drops non-array allowlist values", () => {
704+
const file = {
705+
version: 1,
706+
agents: {
707+
main: {
708+
allowlist: "ls",
709+
},
710+
},
711+
} as unknown as ExecApprovalsFile;
712+
713+
const normalized = normalizeExecApprovals(file);
714+
expect(normalized.agents?.main?.allowlist).toBeUndefined();
715+
});
683716
});

src/infra/exec-approvals.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,11 @@ function ensureDir(filePath: string) {
132132
fs.mkdirSync(dir, { recursive: true });
133133
}
134134

135-
/**
136-
* Coerce each allowlist item into a proper {@link ExecAllowlistEntry}.
137-
* Older config formats or manual edits may store bare strings (e.g.
138-
* `["ls", "cat"]`). Spreading a string (`{ ..."ls" }`) produces
139-
* `{"0":"l","1":"s"}`, so we must detect and convert strings first.
140-
* Non-object, non-string entries and blank strings are dropped.
141-
*/
142-
function coerceAllowlistEntries(
143-
allowlist: unknown[] | undefined,
144-
): ExecAllowlistEntry[] | undefined {
135+
// Coerce legacy/corrupted allowlists into `ExecAllowlistEntry[]` before we spread
136+
// entries to add ids (spreading strings creates {"0":"l","1":"s",...}).
137+
function coerceAllowlistEntries(allowlist: unknown): ExecAllowlistEntry[] | undefined {
145138
if (!Array.isArray(allowlist) || allowlist.length === 0) {
146-
return allowlist as ExecAllowlistEntry[] | undefined;
139+
return Array.isArray(allowlist) ? (allowlist as ExecAllowlistEntry[]) : undefined;
147140
}
148141
let changed = false;
149142
const result: ExecAllowlistEntry[] = [];
@@ -157,7 +150,12 @@ function coerceAllowlistEntries(
157150
changed = true; // dropped empty string
158151
}
159152
} else if (item && typeof item === "object" && !Array.isArray(item)) {
160-
result.push(item as ExecAllowlistEntry);
153+
const pattern = (item as { pattern?: unknown }).pattern;
154+
if (typeof pattern === "string" && pattern.trim().length > 0) {
155+
result.push(item as ExecAllowlistEntry);
156+
} else {
157+
changed = true; // dropped invalid entry
158+
}
161159
} else {
162160
changed = true; // dropped invalid entry
163161
}
@@ -193,7 +191,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
193191
delete agents.default;
194192
}
195193
for (const [key, agent] of Object.entries(agents)) {
196-
const coerced = coerceAllowlistEntries(agent.allowlist as unknown[]);
194+
const coerced = coerceAllowlistEntries(agent.allowlist);
197195
const allowlist = ensureAllowlistIds(coerced);
198196
if (allowlist !== agent.allowlist) {
199197
agents[key] = { ...agent, allowlist };

0 commit comments

Comments
 (0)