Skip to content

Commit b971eba

Browse files
fix(exec-approvals): guard Windows rename fallback (#77907)
* fix exec approvals Windows rename fallback * fix(exec-approvals): restore approvals directory mode * fix(exec-approvals): normalize fallback temp mode --------- Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
1 parent f4a6394 commit b971eba

3 files changed

Lines changed: 415 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai
104104
- Control UI/Sessions: make the compaction count a compact `N Checkpoint(s)` disclosure and show expanded session-level details with modern checkpoint history cards across responsive table layouts. Thanks @BunsDev.
105105
- Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev.
106106
- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev.
107+
- Exec approvals: fall back to a guarded copy when Windows rejects rename-overwrite for `exec-approvals.json`, while preserving symlink, hard-link, and owner-only permission safeguards. Fixes #77785. (#77907) Thanks @Alex-Alaniz and @MilleniumGenAI.
107108
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.
108109
- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev.
109110
- Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest.

src/infra/exec-approvals-store.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ function readApprovalsFile(homeDir: string): ExecApprovalsFile {
7979
return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile;
8080
}
8181

82+
function listExecApprovalTempFiles(homeDir: string): string[] {
83+
const dir = path.dirname(approvalsFilePath(homeDir));
84+
if (!fs.existsSync(dir)) {
85+
return [];
86+
}
87+
return fs.readdirSync(dir).filter((name) => name.endsWith(".tmp"));
88+
}
89+
8290
describe("exec approvals store helpers", () => {
8391
it("expands home-prefixed default file and socket paths", () => {
8492
const dir = createHomeDir();
@@ -173,6 +181,189 @@ describe("exec approvals store helpers", () => {
173181
expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino);
174182
});
175183

184+
it("normalizes successful rename writes to owner-only permissions", () => {
185+
const dir = createHomeDir();
186+
const actualWriteFileSync = fs.writeFileSync.bind(fs);
187+
vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => {
188+
const result = actualWriteFileSync(file, data, options as never);
189+
const filePath = String(file);
190+
if (
191+
typeof file !== "number" &&
192+
filePath.includes(".exec-approvals.") &&
193+
filePath.endsWith(".tmp")
194+
) {
195+
fs.chmodSync(file, 0o000);
196+
}
197+
return result;
198+
});
199+
200+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
201+
202+
expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"');
203+
expect(fs.statSync(approvalsFilePath(dir)).mode & 0o777).toBe(0o600);
204+
});
205+
206+
it("normalizes the approvals directory to owner-only permissions", () => {
207+
const dir = createHomeDir();
208+
const approvalsDir = path.dirname(approvalsFilePath(dir));
209+
fs.mkdirSync(approvalsDir, { recursive: true });
210+
fs.chmodSync(approvalsDir, 0o777);
211+
212+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
213+
214+
expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toContain('"security": "full"');
215+
expect(fs.statSync(approvalsDir).mode & 0o777).toBe(0o700);
216+
});
217+
218+
it("falls back to copying when rename cannot overwrite the approvals file", () => {
219+
const dir = createHomeDir();
220+
const approvalsPath = approvalsFilePath(dir);
221+
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
222+
fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8");
223+
const actualRenameSync = fs.renameSync.bind(fs);
224+
const rename = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
225+
if (String(to) === approvalsPath) {
226+
const error = Object.assign(new Error("locked target"), { code: "EPERM" });
227+
throw error;
228+
}
229+
return actualRenameSync(from, to);
230+
});
231+
232+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
233+
234+
expect(rename).toHaveBeenCalled();
235+
expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"');
236+
expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600);
237+
expect(listExecApprovalTempFiles(dir)).toEqual([]);
238+
});
239+
240+
it("normalizes fallback temp files before copying", () => {
241+
const dir = createHomeDir();
242+
const approvalsPath = approvalsFilePath(dir);
243+
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
244+
fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8");
245+
const actualWriteFileSync = fs.writeFileSync.bind(fs);
246+
vi.spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => {
247+
const result = actualWriteFileSync(file, data, options as never);
248+
const filePath = String(file);
249+
if (
250+
typeof file !== "number" &&
251+
filePath.includes(".exec-approvals.") &&
252+
filePath.endsWith(".tmp")
253+
) {
254+
fs.chmodSync(file, 0o000);
255+
}
256+
return result;
257+
});
258+
const actualRenameSync = fs.renameSync.bind(fs);
259+
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
260+
if (String(to) === approvalsPath) {
261+
const error = Object.assign(new Error("locked target"), { code: "EPERM" });
262+
throw error;
263+
}
264+
return actualRenameSync(from, to);
265+
});
266+
267+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
268+
269+
expect(fs.readFileSync(approvalsPath, "utf8")).toContain('"security": "full"');
270+
expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600);
271+
expect(listExecApprovalTempFiles(dir)).toEqual([]);
272+
});
273+
274+
it("restores the previous approvals file when fallback copy fails", () => {
275+
const dir = createHomeDir();
276+
const approvalsPath = approvalsFilePath(dir);
277+
const previousRaw = '{"version":1,"defaults":{"security":"deny"},"agents":{}}\n';
278+
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
279+
fs.writeFileSync(approvalsPath, previousRaw, { encoding: "utf8", mode: 0o600 });
280+
const actualRenameSync = fs.renameSync.bind(fs);
281+
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
282+
if (String(to) === approvalsPath) {
283+
const error = Object.assign(new Error("locked target"), { code: "EPERM" });
284+
throw error;
285+
}
286+
return actualRenameSync(from, to);
287+
});
288+
const actualFtruncateSync = fs.ftruncateSync.bind(fs);
289+
let forcedFallbackFailure = false;
290+
vi.spyOn(fs, "ftruncateSync").mockImplementation((fd, len) => {
291+
if (!forcedFallbackFailure && len === 0) {
292+
forcedFallbackFailure = true;
293+
actualFtruncateSync(fd, len);
294+
const error = Object.assign(new Error("copy failed after opening destination"), {
295+
code: "ENOSPC",
296+
});
297+
throw error;
298+
}
299+
return actualFtruncateSync(fd, len);
300+
});
301+
302+
expect(() =>
303+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
304+
).toThrow(/copy failed after opening destination/);
305+
expect(fs.readFileSync(approvalsPath, "utf8")).toBe(previousRaw);
306+
expect(fs.statSync(approvalsPath).mode & 0o777).toBe(0o600);
307+
expect(listExecApprovalTempFiles(dir)).toEqual([]);
308+
});
309+
310+
it("does not follow a symlink swapped in before fallback copy", () => {
311+
const dir = createHomeDir();
312+
const approvalsPath = approvalsFilePath(dir);
313+
const targetPath = path.join(dir, "elsewhere.json");
314+
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
315+
fs.writeFileSync(approvalsPath, '{"version":1,"agents":{}}\n', "utf8");
316+
fs.writeFileSync(targetPath, '{"sentinel":true}\n', "utf8");
317+
const actualRenameSync = fs.renameSync.bind(fs);
318+
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
319+
if (String(to) === approvalsPath) {
320+
const error = Object.assign(new Error("locked target"), { code: "EPERM" });
321+
throw error;
322+
}
323+
return actualRenameSync(from, to);
324+
});
325+
const actualStatSync = fs.statSync.bind(fs);
326+
let swappedDestination = false;
327+
vi.spyOn(fs, "statSync").mockImplementation((file, options) => {
328+
const result = actualStatSync(file, options as never);
329+
if (!swappedDestination && String(file) === approvalsPath) {
330+
swappedDestination = true;
331+
fs.rmSync(approvalsPath);
332+
fs.symlinkSync(targetPath, approvalsPath);
333+
}
334+
return result;
335+
});
336+
337+
expect(() =>
338+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
339+
).toThrow(/symlink|ELOOP/);
340+
expect(fs.readFileSync(targetPath, "utf8")).toBe('{"sentinel":true}\n');
341+
expect(listExecApprovalTempFiles(dir)).toEqual([]);
342+
});
343+
344+
it("does not use the copy fallback for hard-linked approvals files", () => {
345+
const dir = createHomeDir();
346+
const approvalsPath = approvalsFilePath(dir);
347+
const linkedPath = path.join(dir, "linked.json");
348+
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
349+
fs.writeFileSync(linkedPath, '{"sentinel":true}\n', "utf8");
350+
fs.linkSync(linkedPath, approvalsPath);
351+
const actualRenameSync = fs.renameSync.bind(fs);
352+
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
353+
if (String(to) === approvalsPath) {
354+
const error = Object.assign(new Error("locked target"), { code: "EPERM" });
355+
throw error;
356+
}
357+
return actualRenameSync(from, to);
358+
});
359+
360+
expect(() =>
361+
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),
362+
).toThrow(/hard-linked exec approvals file/);
363+
expect(fs.readFileSync(linkedPath, "utf8")).toBe('{"sentinel":true}\n');
364+
expect(listExecApprovalTempFiles(dir)).toEqual([]);
365+
});
366+
176367
it("refuses to write approvals through a symlink destination", () => {
177368
const dir = createHomeDir();
178369
const approvalsPath = approvalsFilePath(dir);

0 commit comments

Comments
 (0)