fix(filesystem): preserve file permissions during write and edit operations (#4115)

* fix(filesystem): preserve file permissions during write and edit operations

The atomic write pattern (write temp file + rename) replaces the original
inode, causing the new file to have default 0644 permissions regardless of
what the original file had. This breaks executable scripts and other files
with non-default permissions.

Fix: capture stat.mode before writing and restore it with chmod after rename.

Fixes both writeFileContent() and applyFileEdits().

* fix(filesystem): mask chmod mode and keep chmod failure from failing the write

Pass origStats.mode & 0o777 to chmod instead of the full st_mode. Move
the chmod out of the try whose catch unlinks the temp file and rethrows,
since rename has already succeeded by then. Add a test that an EPERM
from chmod does not reject the write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Wo28CHPXyM3DHoLWvKnCK

---------

Co-authored-by: Dustin Rudiger <drudiger@apple.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
drudiger 2026-08-27 19:36:45 -07:00 • committed by GitHub
commit 562feeb281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -168,6 +168,7 @@ export async function writeFileContent(filePath: string, content: string): Promi
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, content, 'utf-8');
@ -178,6 +179,13 @@ export async function writeFileContent(filePath: string, content: string): Promi
} catch {}
throw renameError;
}
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
} else {
throw error;
}
@ -266,6 +274,7 @@ export async function applyFileEdits(
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
@ -276,6 +285,13 @@ export async function applyFileEdits(
} catch {}
throw error;
}
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
}
return formattedDiff;