Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ function generateReadRules(
}

// Re-allow specific paths within denied regions (allowWithinDeny takes precedence)
const allowedSubpaths: string[] = []
for (const pathPattern of config.allowWithinDeny || []) {
const normalizedPath = normalizePathForSandbox(pathPattern)

Expand All @@ -280,13 +281,30 @@ function generateReadRules(
` (with message "${logTag}"))`,
)
} else {
allowedSubpaths.push(normalizedPath)
rules.push(
`(allow file-read*`,
` (subpath ${escapePath(normalizedPath)})`,
` (with message "${logTag}"))`,
)
}
}
// A literal denyOnly path nested inside a literal allowWithinDeny subpath
// would otherwise be re-allowed (last-match-wins). Re-emit it so the
// more-specific deny lands last. Glob denies aren't re-emitted: nesting
// of regex-vs-subpath isn't decidable here, and the schema's denyReadAlways
// is the explicit lever for that case.
for (const denyPath of config.denyOnly || []) {
if (containsGlobChars(denyPath)) continue
const normalized = normalizePathForSandbox(denyPath)
if (allowedSubpaths.some(a => normalized.startsWith(a + '/'))) {
rules.push(
`(deny file-read*`,
` (subpath ${escapePath(normalized)})`,
` (with message "${logTag}"))`,
)
}
}

// Allow stat/lstat on all directories so that realpath() can traverse
// path components within denied regions. Without this, C realpath() fails
Expand Down
27 changes: 27 additions & 0 deletions test/sandbox/allow-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,33 @@ describe('allowRead without denyRead does not trigger sandboxing', () => {
)
})

// A literal denyRead path nested under a literal allowRead subpath must keep
// its deny: Seatbelt is last-match-wins, so the deny is re-emitted after the
// allow. (Glob paths are out of scope; denyReadAlways is the lever there.)
describe.if(isMacOS)('macOS denyRead nested under allowRead', () => {
it('re-emits the deny after the allow so it stays denied', () => {
const result = wrapCommandWithSandboxMacOS({
command: 'cat /work/secrets/key',
needsNetworkRestriction: false,
readConfig: {
denyOnly: ['/work/secrets'],
allowWithinDeny: ['/work'],
},
writeConfig: undefined,
})
// The profile is embedded in a shell command, so quotes are escaped —
// match on the unquoted skeleton.
const allowAt = result.indexOf('(allow file-read*\n (subpath')
const lastDenySecrets = result.lastIndexOf('/work/secrets')
const firstDenySecrets = result.indexOf('/work/secrets')
expect(allowAt).toBeGreaterThan(-1)
// The deny on /work/secrets appears both before and after the allow:
// original emit, then re-emit after allowWithinDeny.
expect(firstDenySecrets).toBeLessThan(allowAt)
expect(lastDenySecrets).toBeGreaterThan(allowAt)
})
})

describe('rm in allowWrite under denyRead ancestor (issue #171)', () => {
const TEST_BASE_DIR = join(tmpdir(), 'rm-under-denyread-' + Date.now())
const TEST_PROJECT_DIR = join(TEST_BASE_DIR, 'project')
Expand Down
Loading