
























@@ -79,6 +79,14 @@ function readApprovalsFile(homeDir: string): ExecApprovalsFile {
7979return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile;
8080}
818182+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+8290describe("exec approvals store helpers", () => {
8391it("expands home-prefixed default file and socket paths", () => {
8492const dir = createHomeDir();
@@ -173,6 +181,189 @@ describe("exec approvals store helpers", () => {
173181expect(fs.statSync(approvalsPath).ino).not.toBe(fs.statSync(linkedPath).ino);
174182});
175183184+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+176367it("refuses to write approvals through a symlink destination", () => {
177368const dir = createHomeDir();
178369const approvalsPath = approvalsFilePath(dir);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。