@@ -644,14 +644,18 @@ async function generateFilesystemArgs(
644644 const args : string [ ] = [ ]
645645 // fs already imported
646646
647+ // Collect normalized allowed write paths. Populated in the writeConfig
648+ // block, read again in the denyRead loop to re-bind writes under tmpfs.
649+ const allowedWritePaths : string [ ] = [ ]
650+ // denyWrite binds are buffered and emitted after denyRead processing so that
651+ // a denyRead tmpfs over an ancestor directory doesn't wipe them out.
652+ const denyWriteArgs : string [ ] = [ ]
653+
647654 // Determine initial root mount based on write restrictions
648655 if ( writeConfig ) {
649656 // Write restrictions: Start with read-only root, then allow writes to specific paths
650657 args . push ( '--ro-bind' , '/' , '/' )
651658
652- // Collect normalized allowed write paths for later checking
653- const allowedWritePaths : string [ ] = [ ]
654-
655659 // Allow writes to specific paths
656660 for ( const pathPattern of writeConfig . allowOnly || [ ] ) {
657661 const normalizedPath = normalizePathForSandbox ( pathPattern )
@@ -714,8 +718,15 @@ async function generateFilesystemArgs(
714718 ) ) ,
715719 ]
716720
721+ // Dedup post-normalization: entries like ['~/.foo', '/home/user/.foo']
722+ // converge to the same path here. A duplicate --ro-bind /dev/null <dest>
723+ // hits a char device on the second pass and bwrap's ensure_file() falls
724+ // through to creat() on a read-only mount.
725+ const seenDenyWrite = new Set < string > ( )
717726 for ( const pathPattern of denyPaths ) {
718727 const normalizedPath = normalizePathForSandbox ( pathPattern )
728+ if ( seenDenyWrite . has ( normalizedPath ) ) continue
729+ seenDenyWrite . add ( normalizedPath )
719730
720731 // Skip /dev/* paths since --dev /dev already handles them
721732 if ( normalizedPath . startsWith ( '/dev/' ) ) {
@@ -728,7 +739,7 @@ async function generateFilesystemArgs(
728739 // symlink and creates real .claude/settings.json with malicious hooks.
729740 const symlinkInPath = findSymlinkInPath ( normalizedPath , allowedWritePaths )
730741 if ( symlinkInPath ) {
731- args . push ( '--ro-bind' , '/dev/null' , symlinkInPath )
742+ denyWriteArgs . push ( '--ro-bind' , '/dev/null' , symlinkInPath )
732743 logForDebugging (
733744 `[Sandbox Linux] Mounted /dev/null at symlink ${ symlinkInPath } to prevent symlink replacement attack` ,
734745 )
@@ -780,14 +791,14 @@ async function generateFilesystemArgs(
780791 const emptyDir = fs . mkdtempSync (
781792 path . join ( tmpdir ( ) , 'claude-empty-' ) ,
782793 )
783- args . push ( '--ro-bind' , emptyDir , firstNonExistent )
794+ denyWriteArgs . push ( '--ro-bind' , emptyDir , firstNonExistent )
784795 bwrapMountPoints . add ( firstNonExistent )
785796 registerExitCleanupHandler ( )
786797 logForDebugging (
787798 `[Sandbox Linux] Mounted empty dir at ${ firstNonExistent } to block creation of ${ normalizedPath } ` ,
788799 )
789800 } else {
790- args . push ( '--ro-bind' , '/dev/null' , firstNonExistent )
801+ denyWriteArgs . push ( '--ro-bind' , '/dev/null' , firstNonExistent )
791802 bwrapMountPoints . add ( firstNonExistent )
792803 registerExitCleanupHandler ( )
793804 logForDebugging (
@@ -811,7 +822,7 @@ async function generateFilesystemArgs(
811822 )
812823
813824 if ( isWithinAllowedPath ) {
814- args . push ( '--ro-bind' , normalizedPath , normalizedPath )
825+ denyWriteArgs . push ( '--ro-bind' , normalizedPath , normalizedPath )
815826 } else {
816827 logForDebugging (
817828 `[Sandbox Linux] Skipping deny path not within allowed paths: ${ normalizedPath } ` ,
@@ -822,13 +833,30 @@ async function generateFilesystemArgs(
822833 // No write restrictions: Allow all writes
823834 args . push ( '--bind' , '/' , '/' )
824835 }
836+ // denyWriteArgs is emitted after the denyRead loop below.
825837
826838 // Handle read restrictions by mounting tmpfs over denied paths
827- const readDenyPaths = [ ... ( readConfig ?. denyOnly || [ ] ) ]
839+ const readDenyPaths : string [ ] = [ ]
828840 const readAllowPaths = ( readConfig ?. allowWithinDeny || [ ] ) . map ( p =>
829841 normalizePathForSandbox ( p ) ,
830842 )
831843
844+ // --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds).
845+ // Expand a root deny into its direct children so the existing per-dir tmpfs
846+ // + re-bind logic applies. Skip /proc and /dev: they're remounted by the
847+ // caller after this function returns. Skip /sys: kernel interface, tmpfs
848+ // over it breaks tooling and the host /sys is already read-only via ro-bind.
849+ const rootSkip = new Set ( [ 'proc' , 'dev' , 'sys' ] )
850+ for ( const p of readConfig ?. denyOnly || [ ] ) {
851+ if ( normalizePathForSandbox ( p ) === '/' ) {
852+ for ( const child of fs . readdirSync ( '/' ) ) {
853+ if ( ! rootSkip . has ( child ) ) readDenyPaths . push ( '/' + child )
854+ }
855+ } else {
856+ readDenyPaths . push ( p )
857+ }
858+ }
859+
832860 // Always hide /etc/ssh/ssh_config.d to avoid permission issues with OrbStack
833861 // SSH is very strict about config file permissions and ownership, and they can
834862 // appear wrong inside the sandbox causing "Bad owner or permissions" errors
@@ -845,24 +873,45 @@ async function generateFilesystemArgs(
845873 continue
846874 }
847875
876+ const denySep = normalizedPath === '/' ? '/' : normalizedPath + '/'
848877 const readDenyStat = fs . statSync ( normalizedPath )
849878 if ( readDenyStat . isDirectory ( ) ) {
850879 args . push ( '--tmpfs' , normalizedPath )
851880
881+ // tmpfs wiped any earlier write binds under this path — restore them.
882+ for ( const writePath of allowedWritePaths ) {
883+ if ( writePath . startsWith ( denySep ) || writePath === normalizedPath ) {
884+ args . push ( '--bind' , writePath , writePath )
885+ logForDebugging (
886+ `[Sandbox Linux] Re-bound write path wiped by denyRead tmpfs: ${ writePath } ` ,
887+ )
888+ }
889+ }
890+
852891 // Re-allow specific paths within the denied directory (allowRead overrides denyRead).
853892 // After mounting tmpfs over the denied dir, bind back the allowed subdirectories
854893 // so they are readable again.
855894 for ( const allowPath of readAllowPaths ) {
856- if (
857- allowPath . startsWith ( normalizedPath + '/' ) ||
858- allowPath === normalizedPath
859- ) {
895+ if ( allowPath . startsWith ( denySep ) || allowPath === normalizedPath ) {
860896 if ( ! fs . existsSync ( allowPath ) ) {
861897 logForDebugging (
862898 `[Sandbox Linux] Skipping non-existent read allow path: ${ allowPath } ` ,
863899 )
864900 continue
865901 }
902+ // Skip only if a write path was re-bound just above AND covers
903+ // allowPath. A write path that's an ancestor of the deny dir isn't
904+ // re-bound (it wasn't wiped), so allowPath under it still needs
905+ // its own ro-bind here.
906+ if (
907+ allowedWritePaths . some (
908+ w =>
909+ ( w . startsWith ( denySep ) || w === normalizedPath ) &&
910+ ( allowPath === w || allowPath . startsWith ( w + '/' ) ) ,
911+ )
912+ ) {
913+ continue
914+ }
866915 // Bind the allowed path back over the tmpfs so it's readable
867916 args . push ( '--ro-bind' , allowPath , allowPath )
868917 logForDebugging (
@@ -888,6 +937,11 @@ async function generateFilesystemArgs(
888937 }
889938 }
890939
940+ // Emitting denyWrite last means these ro-binds layer on top of any write
941+ // paths the denyRead loop just re-bound. Before this ordering, tmpfs over
942+ // an ancestor of cwd would wipe the .git/hooks protection.
943+ args . push ( ...denyWriteArgs )
944+
891945 return args
892946}
893947
0 commit comments