Skip to content

Commit 512fab6

Browse files
committed
Merge remote-tracking branch 'origin/main' into ci-run-all-tests
2 parents 9036c81 + 41a5792 commit 512fab6

5 files changed

Lines changed: 344 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"clean": "rm -rf dist",
1919
"test": "bun test",
2020
"test:unit": "bun test test/config-validation.test.ts test/sandbox/seccomp-filter.test.ts",
21-
"test:integration": "bun test test/sandbox/integration.test.ts",
21+
"test:integration": "bun test test/sandbox/integration.test.ts test/sandbox/allow-read.test.ts test/sandbox/wrap-with-sandbox.test.ts",
2222
"typecheck": "tsc --noEmit",
2323
"lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache",
2424
"lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache",

src/sandbox/linux-sandbox-utils.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/sandbox/macos-sandbox-utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ function generateReadRules(
213213
}
214214

215215
const rules: string[] = []
216+
let deniesRoot = false
216217

217218
// Start by allowing everything
218219
rules.push(`(allow file-read*)`)
@@ -221,6 +222,8 @@ function generateReadRules(
221222
for (const pathPattern of config.denyOnly || []) {
222223
const normalizedPath = normalizePathForSandbox(pathPattern)
223224

225+
if (normalizedPath === '/') deniesRoot = true
226+
224227
if (containsGlobChars(normalizedPath)) {
225228
// Use regex matching for glob patterns
226229
const regexPattern = globToRegex(normalizedPath)
@@ -239,6 +242,13 @@ function generateReadRules(
239242
}
240243
}
241244

245+
// (subpath "/") denies the root inode itself; allowWithinDeny subpaths don't
246+
// cover "/", so dyld aborts before exec. Re-allow the literal root so path
247+
// traversal works. This exposes `ls /` dirent names but no subtree contents.
248+
if (deniesRoot) {
249+
rules.push(`(allow file-read* (literal "/"))`)
250+
}
251+
242252
// Re-allow specific paths within denied regions (allowWithinDeny takes precedence)
243253
for (const pathPattern of config.allowWithinDeny || []) {
244254
const normalizedPath = normalizePathForSandbox(pathPattern)

0 commit comments

Comments
 (0)