@@ -30,6 +30,8 @@ export function readJson<T>(filePath: string, fallback: T): T {
3030 return fallback ;
3131 }
3232
33+ restrictExistingPermissions ( filePath ) ;
34+
3335 try {
3436 return JSON . parse ( raw ) as T ;
3537 } catch ( err ) {
@@ -43,9 +45,48 @@ export function readJson<T>(filePath: string, fallback: T): T {
4345 }
4446}
4547
48+ // These files hold provider API keys (secrets.json) and full conversation
49+ // history. Written with the process umask they land as 0644 — or 0664 under
50+ // the umask 002 several distributions ship — leaving their contents readable
51+ // to anything that reaches them: another account on a shared machine, a
52+ // backup or sync tool, an archive unpacked somewhere else. The userData
53+ // directory is usually restrictive enough to cover that on a single-user
54+ // desktop, but a stored credential shouldn't depend on its parent directory's
55+ // mode.
56+ const PRIVATE_FILE_MODE = 0o600 ;
57+
58+ // Files written before this existed keep the mode they were created with, and
59+ // writeJson alone would never reach them: a key set once and never changed is
60+ // only ever read. So the mode is also tightened on first read, once per path
61+ // per run to keep it off the hot path.
62+ const permissionsChecked = new Set < string > ( ) ;
63+
64+ function restrictExistingPermissions ( filePath : string ) : void {
65+ if ( process . platform === "win32" || permissionsChecked . has ( filePath ) ) return ;
66+ permissionsChecked . add ( filePath ) ;
67+ try {
68+ if ( ( fs . statSync ( filePath ) . mode & 0o777 ) !== PRIVATE_FILE_MODE ) {
69+ fs . chmodSync ( filePath , PRIVATE_FILE_MODE ) ;
70+ }
71+ } catch ( err ) {
72+ // Best effort — unusual ownership or an exotic filesystem must not
73+ // stop the app from reading its own data.
74+ logger . error ( `Failed to restrict permissions on ${ filePath } : ${ ( err as Error ) . message } ` ) ;
75+ }
76+ }
77+
4678export function writeJson ( filePath : string , data : unknown ) : void {
4779 fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } ) ;
4880 const tmpPath = `${ filePath } .tmp-${ process . pid } ` ;
49- fs . writeFileSync ( tmpPath , JSON . stringify ( data , null , 2 ) ) ;
81+ // The mode goes on the temp file, because the rename below replaces the
82+ // destination inode and takes the temp file's mode with it. Chmod'ing the
83+ // destination afterwards would leave a window where the contents are
84+ // readable, and would be undone by the next write.
85+ //
86+ // Removed first so writeFileSync always creates the file and so always
87+ // applies `mode` — it ignores the option for a path that already exists,
88+ // and an interrupted earlier write can leave one behind under this pid.
89+ fs . rmSync ( tmpPath , { force : true } ) ;
90+ fs . writeFileSync ( tmpPath , JSON . stringify ( data , null , 2 ) , { mode : PRIVATE_FILE_MODE } ) ;
5091 fs . renameSync ( tmpPath , filePath ) ;
5192}
0 commit comments