@@ -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+
8290describe ( "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 ( / c o p y f a i l e d a f t e r o p e n i n g d e s t i n a t i o n / ) ;
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 ( / s y m l i n k | E L O O P / ) ;
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 ( / h a r d - l i n k e d e x e c a p p r o v a l s f i l e / ) ;
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