Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
de9aad7
linux: a read-deny glob stays in its own tree, and its walk has a budget
ronleizrowice-ant Sep 25, 2026
883cf6a
test: the tree a read-deny glob stays in, and the budget of its walk
ronleizrowice-ant Sep 25, 2026
c2bb9c8
README: the tree of a denyRead pattern on Linux, and the budget
ronleizrowice-ant Sep 25, 2026
99a93d1
linux: the read configuration says which links its patterns left unfo…
ronleizrowice-ant Sep 25, 2026
f9ef1a8
Say exactly which links unfollowedDenyLinks holds
ronleizrowice-ant Sep 25, 2026
cbc1200
linux: a read-deny glob never lists outside its tree
ronleizrowice-ant Sep 25, 2026
fc9c36d
test: nothing outside a read-deny glob's tree is listed, matched or not
ronleizrowice-ant Sep 25, 2026
b84e5c5
README: what a denyRead pattern's tree leaves out, in full
ronleizrowice-ant Sep 25, 2026
f3bc5f0
linux: the read-deny walk follows links as before; only the budget stays
ronleizrowice-ant Sep 26, 2026
60da702
test: a match behind a link out of the tree stays denied, within the …
ronleizrowice-ant Sep 26, 2026
48151af
README: the budget of the denyRead expansion; links as before
ronleizrowice-ant Sep 26, 2026
0b19ff9
Shorten the comments this change adds
ronleizrowice-ant Sep 28, 2026
faed62f
linux: a read-deny glob budget that real trees fit in, and an option …
ronleizrowice-ant Sep 28, 2026
5707925
denyReadGlobBudget: refuse a key it does not know
ronleizrowice-ant Sep 28, 2026
ba0a884
Merge main into the read-deny glob budget
claude Oct 20, 2026
b34cef5
linux: what a review of the whole glob budget found
claude Oct 20, 2026
1951705
linux: the glob budget in a third of the lines
claude Oct 20, 2026
2c01587
linux: what a second review of the glob budget found
claude Oct 20, 2026
369c275
Merge main into fix/linux-read-deny-glob-bound
claude Nov 6, 2026
dbd699b
Merge main into fix/linux-read-deny-glob-bound
claude Nov 9, 2026
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ Uses two different patterns:

- `filesystem.denyRead` - Array of paths to deny read access. Empty array = full read access.
- `filesystem.allowRead` - Array of paths to re-allow read access within denied regions (takes precedence over denyRead). **Note:** this is the opposite of write, where `denyWrite` takes precedence over `allowWrite`.
- `filesystem.denyReadGlobBudget` - Optional, Linux only: `{ "maxEntries": 20000000, "timeoutMs": 60000 }` (the defaults). What expanding the globs of one read configuration may spend before the wrap is refused; see "Path Syntax (Linux)".

**Write restrictions** (allow-only pattern) - all writes denied by default:

Expand Down Expand Up @@ -564,6 +565,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS:
- A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable.
- A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. So is an entry that may be a directory for all the expansion can learn: one the file system lists without a type and that cannot be looked at either. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way.
- Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it; a link back up the tree is not descended, and denies what it reaches only when it is itself a match, which denies all of it, as a literal deny of the link would. A link that resolves to nothing is skipped. A link whose target cannot be looked at (a directory above the target is not searchable, say) is not followed: when the pattern would continue through it rather than match it, nothing is denied for it and what lies behind it is not masked. A link of that kind that is itself a match is still denied, under its own spelling. `allowRead` globs are not expanded through symlinks: they match the link itself.
- A read configuration's patterns (`allowRead`, `denyRead`, globs among `credentials.files`) are expanded on one shared budget: 20,000,000 directory entries read from disk and 60 seconds of wall-clock time, which a cold cache or busy host spends sooner. `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) changes it, in the configuration or in one wrap's `customConfig`, which replaces only the limit it names; anything but positive integers under those two keys fails the wrap. A directory several `denyRead` patterns walk is read and counted once, and kept until the last is expanded (100 to 200 bytes an entry). Past the budget, `wrapWithSandbox()`, `wrapWithSandboxArgv()` and `getFsReadConfig()` fail with a `LinuxSandboxProfileError`, `.code` `deny_glob_too_large`: a deny list cut short would leave readable what it should hide. `initialize()` and `updateConfig()` expand nothing and nothing is kept between wraps, so each wrap is refused only after spending the budget again. The message names the remedies; its facts are also on `.cause`, a `GlobWalkBudgetError` (type exported): `pattern` (the one being expanded, not always the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries`, `elapsedMs`, `maxEntries`, `timeoutMs`. Time is checked at every directory and entry, so only one blocking call (a dead network mount) or one slow match can overrun it.
- An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox.
- `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it.

Expand Down Expand Up @@ -1191,7 +1193,7 @@ Filesystem restrictions are enforced at the OS level:
- The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released.
- The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this.
- bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected.
- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points.
- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and `deny_glob_too_large`, which says the read patterns could not be expanded within their budget (see "Path Syntax (Linux)"). A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points.

### Mandatory Deny Paths (Auto-Protected Files)

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
LinuxSandboxProfileError,
type LinuxSandboxProfileErrorCode,
} from './sandbox/linux-sandbox-utils.js'
export type { GlobWalkBudgetError } from './sandbox/sandbox-utils.js'

// Windows install/status API
export {
Expand Down
6 changes: 6 additions & 0 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,12 @@ export type LinuxSandboxProfileErrorCode =
| 'args_file_unavailable'
/** The line does not fit one shell argument even with the mounts in a file. */
| 'command_too_long'
/**
* The read patterns could not be expanded within their budget, so which
* paths to hide is not known. The `GlobWalkBudgetError` on `.cause` names
* the pattern, the directory being listed and what had been spent.
*/
| 'deny_glob_too_large'

/**
* Thrown when a Linux bubblewrap profile cannot be run on this host: what the
Expand Down
15 changes: 10 additions & 5 deletions src/sandbox/read-deny-glob.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { logForDebugging } from '../utils/debug.js'
import {
type GlobWalkListings,
type GlobWalkOptions,
isAtOrUnder,
normalizePathForSandbox,
pathSpellings,
Expand Down Expand Up @@ -59,6 +59,11 @@ function collapseReadDenyLocations({
return kept
}

type ReadDenyGlobOptions = Pick<
GlobWalkOptions,
'anchor' | 'budget' | 'listings'
>

/**
* Expand a read-deny glob into the paths bwrap should mount over, collapsed
* against `reExposedPaths` (the caller's allowRead and allowWrite entries).
Expand All @@ -74,13 +79,14 @@ function collapseReadDenyLocations({
* pattern matches under an allowed path in there was never found, and would
* come back unmasked.
* @param opts.anchor - as in `ExpandGlobOptions.anchor`.
* @param opts.budget - as in `GlobWalkOptions.budget`.
* @param opts.listings - one map for all the patterns of a configuration.
*/
export function expandReadDenyGlobLinux(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
opts: { anchor?: string; listings?: GlobWalkListings } = {},
opts: ReadDenyGlobOptions = {},
): string[] {
return finish(
expandReadDenyGlobLinuxSteps(
Expand All @@ -97,13 +103,12 @@ export function* expandReadDenyGlobLinuxSteps(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
opts: { anchor?: string; listings?: GlobWalkListings } = {},
opts: ReadDenyGlobOptions = {},
): Steps<string[]> {
const walk = yield* walkGlobPatternSteps(globPattern, {
withDirectoryForm: true,
followSymlinkedDirectories: true,
anchor: opts.anchor,
listings: opts.listings,
...opts,
})
// Where a path the walk reported really lives: the denyRead loop mounts an
// entry there, whatever spelling named it.
Expand Down
12 changes: 12 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,18 @@ export const FilesystemConfigSchema = z.object({
'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' +
'Use with denyRead to deny a broad region then allow back specific subdirectories.',
),
denyReadGlobBudget: z
.object({
maxEntries: z.number().int().positive().optional(),
timeoutMs: z.number().int().positive().optional(),
})
.strict()
.optional()
.describe(
'Linux: what expanding the globs of one read configuration may spend, all of them together, ' +
'before the wrap is refused: directory entries read (default 20,000,000) and ' +
'milliseconds (default 60,000).',
),
allowWrite: z
.array(filesystemPathEntrySchema)
.describe('Paths allowed for writing'),
Expand Down
67 changes: 57 additions & 10 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import type {
SandboxRuntimeConfig,
SeccompConfig,
} from './sandbox-config.js'
import { FilesystemConfigSchema } from './sandbox-config.js'
import type {
SandboxAskCallback,
CredentialRestrictionConfig,
Expand All @@ -64,6 +65,7 @@ import {
type SandboxDependencyCheck,
cleanupBwrapMountPoints,
linuxGetCwdMandatoryDenyPaths,
LinuxSandboxProfileError,
} from './linux-sandbox-utils.js'
import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js'
import {
Expand Down Expand Up @@ -127,9 +129,12 @@ import {
globPatternBaseDir,
normalizePathForSandbox,
removeTrailingGlobSuffix,
GlobWalkBudgetError,
walkGlobPatternSteps,
workingDirectory,
type GlobWalkBudget,
type GlobWalkListings,
newGlobWalkBudget,
type Steps,
finish,
finishInTurns,
Expand Down Expand Up @@ -2197,11 +2202,8 @@ function* resolveReadPathEntries(
!literal.has(p) &&
containsGlobChars(stripped)
) {
for (const found of yield* withOtherReadings(
p,
stripped,
kind,
expandGlob,
for (const found of yield* withinBudget(
withOtherReadings(p, stripped, kind, expandGlob),
)) {
resolved.add(found)
}
Expand Down Expand Up @@ -2238,11 +2240,49 @@ function stripWriteGlobs(
})
}

/**
* What the glob walks of one read configuration may spend between them, on
* Linux, its clock starting now: `filesystem.denyReadGlobBudget`, a wrap's own
* limits over the session's one by one. Checked here, because only the CLI
* runs the schema and a limit that is no number would never be reached.
*/
function readGlobBudget(
...limits: Array<SandboxRuntimeConfig['filesystem']['denyReadGlobBudget']>
): GlobWalkBudget {
const [session, own] = limits.map(given =>
FilesystemConfigSchema.shape.denyReadGlobBudget.parse(given, {
path: ['filesystem', 'denyReadGlobBudget'],
}),
)
return newGlobWalkBudget({
maxEntries: own?.maxEntries ?? session?.maxEntries,
timeoutMs: own?.timeoutMs ?? session?.timeoutMs,
})
}

/** `walk`, with a budget that runs out in it told as
* {@link LinuxSandboxProfileError} `deny_glob_too_large`. */
function* withinBudget<T>(walk: Steps<T>): Steps<T> {
try {
return yield* walk
} catch (error) {
if (!(error instanceof GlobWalkBudgetError)) throw error
throw new LinuxSandboxProfileError(
'deny_glob_too_large',
`${error.message}; the allowRead, denyRead and credentials.files patterns of one configuration share the budget: ` +
`narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`,
error,
)
}
}

function* expandAllowReadGlob(
budget: GlobWalkBudget,
pattern: string,
anchor?: string,
): Steps<string[]> {
const expanded = (yield* walkGlobPatternSteps(pattern, { anchor })).matches
const expanded = (yield* walkGlobPatternSteps(pattern, { anchor, budget }))
.matches
logForDebugging(
`[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`,
)
Expand Down Expand Up @@ -2270,11 +2310,12 @@ function getFsReadConfig(): FsReadRestrictionConfig {
)
// allowRead (re-allow within denied regions) is resolved first: the
// denyRead glob expansion collapses against it.
const budget = readGlobBudget(config.filesystem.denyReadGlobBudget)
const allowPaths = finish(
resolveReadPathEntries(
'allow',
config.filesystem.allowRead ?? [],
expandAllowReadGlob,
(pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor),
),
)
const reExposedPaths = reExposedBy(
Expand All @@ -2293,7 +2334,7 @@ function getFsReadConfig(): FsReadRestrictionConfig {
pattern,
reExposedPaths,
unlistableDenyDirs,
{ anchor, listings },
{ anchor, budget, listings },
),
),
)
Expand Down Expand Up @@ -2847,13 +2888,17 @@ async function wrapWithSandboxOnce(
// allowRead is resolved first: on Linux a denyRead glob's expansion is
// collapsed against the paths that re-expose contents under a denied
// directory (allowRead + allowWrite), so both must be final here.
const budget = readGlobBudget(
config?.filesystem.denyReadGlobBudget,
customConfig?.filesystem?.denyReadGlobBudget,
)
const expandedAllowRead = await walked(
resolveReadPathEntries(
'allow',
customConfig?.filesystem?.allowRead ??
config?.filesystem.allowRead ??
[],
expandAllowReadGlob,
(pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor),
),
)
if (expandedAllowRead === REPLACED) return startOver()
Expand Down Expand Up @@ -2885,7 +2930,7 @@ async function wrapWithSandboxOnce(
pattern,
reExposedPaths,
unlistableDenyDirs,
{ anchor, listings },
{ anchor, budget, listings },
),
),
)
Expand Down Expand Up @@ -3916,6 +3961,8 @@ export interface ISandboxManager {
},
options?: CheckDependenciesOptions,
): Promise<SandboxDependencyCheck>
/** On Linux it expands the `denyRead` globs, and throws
* {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. */
getFsReadConfig(): FsReadRestrictionConfig
getFsWriteConfig(): FsWriteRestrictionConfig
getNetworkRestrictionConfig(): NetworkRestrictionConfig
Expand Down
Loading
Loading