Skip to content

Commit bc1ab82

Browse files
authored
Merge pull request #195 from anthropic-experimental/atp/cc-1468-denywrite-unmasks-denyread-regression
fix: denyWrite unmasks denyRead when paths overlap (#190 regression)
2 parents 18f2668 + 7858097 commit bc1ab82

2 files changed

Lines changed: 135 additions & 11 deletions

File tree

src/sandbox/linux-sandbox-utils.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,9 @@ async function generateFilesystemArgs(
868868
const readAllowPaths = (readConfig?.allowWithinDeny || []).map(p =>
869869
normalizePathForSandbox(p),
870870
)
871+
// Files masked by --ro-bind /dev/null below. Used to filter denyWriteArgs so
872+
// that --ro-bind <host> <host> doesn't undo the mask.
873+
const maskedFiles = new Set<string>()
871874

872875
// --tmpfs / would wipe all prior mounts (ro-bind /, write binds, deny binds).
873876
// Expand a root deny into its direct children so the existing per-dir tmpfs
@@ -892,8 +895,14 @@ async function generateFilesystemArgs(
892895
readDenyPaths.push('/etc/ssh/ssh_config.d')
893896
}
894897

895-
for (const pathPattern of readDenyPaths) {
896-
const normalizedPath = normalizePathForSandbox(pathPattern)
898+
// Normalize then sort shallow-first so tmpfs over ancestor dirs lands before
899+
// /dev/null masks on descendant files. Otherwise a file-deny listed before
900+
// a dir-deny in denyRead gets wiped when the ancestor tmpfs is applied.
901+
const normalizedDenyPaths = readDenyPaths
902+
.map(p => normalizePathForSandbox(p))
903+
.sort((a, b) => a.split('/').length - b.split('/').length)
904+
905+
for (const normalizedPath of normalizedDenyPaths) {
897906
if (!fs.existsSync(normalizedPath)) {
898907
logForDebugging(
899908
`[Sandbox Linux] Skipping non-existent read deny path: ${normalizedPath}`,
@@ -948,27 +957,32 @@ async function generateFilesystemArgs(
948957
}
949958
}
950959
} else {
951-
// For files, check if this specific file is re-allowed
952-
const isReAllowed = readAllowPaths.some(
953-
allowPath =>
954-
normalizedPath === allowPath ||
955-
normalizedPath.startsWith(allowPath + '/'),
956-
)
957-
if (isReAllowed) {
960+
// For files, only an exact allowRead match overrides the deny. A
961+
// directory allowRead does not un-deny a file specifically listed in
962+
// denyRead — otherwise denyRead: ['.env'] + allowRead: ['.'] silently
963+
// drops the .env deny.
964+
if (readAllowPaths.includes(normalizedPath)) {
958965
logForDebugging(
959966
`[Sandbox Linux] Skipping read deny for re-allowed path: ${normalizedPath}`,
960967
)
961968
continue
962969
}
963970
// For files, bind /dev/null instead of tmpfs
964971
args.push('--ro-bind', '/dev/null', normalizedPath)
972+
maskedFiles.add(normalizedPath)
965973
}
966974
}
967975

968976
// Emitting denyWrite last means these ro-binds layer on top of any write
969977
// paths the denyRead loop just re-bound. Before this ordering, tmpfs over
970-
// an ancestor of cwd would wipe the .git/hooks protection.
971-
args.push(...denyWriteArgs)
978+
// an ancestor of cwd would wipe the .git/hooks protection. But skip any
979+
// dest already masked by denyRead — --ro-bind <host> <host> for denyWrite
980+
// would undo --ro-bind /dev/null <host> from denyRead, which landed first.
981+
for (let i = 0; i < denyWriteArgs.length; i += 3) {
982+
const dest = denyWriteArgs[i + 2]!
983+
if (maskedFiles.has(dest)) continue
984+
args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest)
985+
}
972986

973987
return args
974988
}

test/sandbox/wrap-with-sandbox.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,4 +755,114 @@ describe('allowWrite glob suffix handling', () => {
755755
rmSync(parentDir, { recursive: true, force: true })
756756
}
757757
})
758+
759+
// Regression: #190 reordered denyWrite after denyRead so .git/hooks ro-binds
760+
// survive a tmpfs over an ancestor. But denyWrite's --ro-bind <host> <host>
761+
// now lands after denyRead's --ro-bind /dev/null <host>, undoing the mask
762+
// when the same file is in both lists.
763+
it('does not let denyWrite unmask a denyRead /dev/null bind (Linux)', async () => {
764+
if (getPlatform() !== 'linux') {
765+
return
766+
}
767+
768+
const parentDir = join(tmpdir(), `srt-test-unmask-${Date.now()}`)
769+
const secret = join(parentDir, 'secret.txt')
770+
mkdirSync(parentDir, { recursive: true })
771+
writeFileSync(secret, '')
772+
773+
try {
774+
await SandboxManager.reset()
775+
await SandboxManager.initialize({
776+
network: { allowedDomains: [], deniedDomains: [] },
777+
filesystem: {
778+
denyRead: [secret],
779+
allowWrite: [parentDir],
780+
denyWrite: [secret],
781+
},
782+
})
783+
784+
const result = await SandboxManager.wrapWithSandbox(command)
785+
786+
// The /dev/null mask is what we want; the host-file bind is what we don't.
787+
expect(result).toContain(`--ro-bind /dev/null ${secret}`)
788+
expect(result).not.toContain(`--ro-bind ${secret} ${secret}`)
789+
} finally {
790+
await SandboxManager.reset()
791+
rmSync(parentDir, { recursive: true, force: true })
792+
}
793+
})
794+
795+
// A file listed in denyRead should stay denied even if allowRead covers its
796+
// parent directory. Before this change, startsWith(allowPath + '/') matched
797+
// and the file-deny was silently skipped.
798+
it('file-level denyRead survives a parent-directory allowRead (Linux)', async () => {
799+
if (getPlatform() !== 'linux') {
800+
return
801+
}
802+
803+
const parentDir = join(tmpdir(), `srt-test-file-deny-${Date.now()}`)
804+
const secret = join(parentDir, '.env')
805+
mkdirSync(parentDir, { recursive: true })
806+
writeFileSync(secret, '')
807+
808+
try {
809+
await SandboxManager.reset()
810+
await SandboxManager.initialize({
811+
network: { allowedDomains: [], deniedDomains: [] },
812+
filesystem: {
813+
denyRead: [secret],
814+
allowRead: [parentDir],
815+
allowWrite: [parentDir],
816+
denyWrite: [],
817+
},
818+
})
819+
820+
const result = await SandboxManager.wrapWithSandbox(command)
821+
822+
expect(result).toContain(`--ro-bind /dev/null ${secret}`)
823+
} finally {
824+
await SandboxManager.reset()
825+
rmSync(parentDir, { recursive: true, force: true })
826+
}
827+
})
828+
829+
// denyRead entries are sorted shallow-first before mounting, so a file-deny
830+
// listed before its ancestor dir-deny still lands on top of the ancestor's
831+
// tmpfs + re-allow binds.
832+
it('file-deny survives ancestor dir-deny listed after it in denyRead (Linux)', async () => {
833+
if (getPlatform() !== 'linux') {
834+
return
835+
}
836+
837+
const parentDir = join(tmpdir(), `srt-test-order-${Date.now()}`)
838+
const projectDir = join(parentDir, 'project')
839+
const envFile = join(projectDir, '.env')
840+
mkdirSync(projectDir, { recursive: true })
841+
writeFileSync(envFile, '')
842+
843+
try {
844+
await SandboxManager.reset()
845+
await SandboxManager.initialize({
846+
network: { allowedDomains: [], deniedDomains: [] },
847+
filesystem: {
848+
// File deliberately listed before the dir that contains it
849+
denyRead: [envFile, parentDir],
850+
allowRead: [projectDir],
851+
allowWrite: [projectDir],
852+
denyWrite: [],
853+
},
854+
})
855+
856+
const result = await SandboxManager.wrapWithSandbox(command)
857+
858+
// The /dev/null mask must come after the tmpfs + ro-bind in arg order.
859+
const tmpfsAt = result.indexOf(`--tmpfs ${parentDir}`)
860+
const maskAt = result.indexOf(`--ro-bind /dev/null ${envFile}`)
861+
expect(tmpfsAt).toBeGreaterThan(-1)
862+
expect(maskAt).toBeGreaterThan(tmpfsAt)
863+
} finally {
864+
await SandboxManager.reset()
865+
rmSync(parentDir, { recursive: true, force: true })
866+
}
867+
})
758868
})

0 commit comments

Comments
 (0)