Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e5155fb
test(macos): assert what the aliasing probes show instead of logging it
ronleizrowice-ant Sep 18, 2026
17cb628
test(glob): cover bracket sets in globToRegex, and check it against a…
ronleizrowice-ant Sep 18, 2026
f7b4bd8
test(windows): put patterns in the swept corpus so the glob branch is…
ronleizrowice-ant Sep 18, 2026
4bfe3c2
test(sigv4): sign each vector a second time with the header set reversed
ronleizrowice-ant Sep 18, 2026
4db3603
test(linux): fail the seccomp suite when a sandbox dependency is missing
ronleizrowice-ant Sep 18, 2026
6e566a2
test: pin the package's public export list
ronleizrowice-ant Sep 18, 2026
03b1a5b
test: drive the mandatory-deny cases off the exported constants
ronleizrowice-ant Sep 18, 2026
621f497
Merge remote-tracking branch 'origin/main' into fix/glob-to-regex-neg…
ronleizrowice-ant Sep 19, 2026
3aeca04
fix(glob): read a pattern once, so a path spelled like a placeholder …
ronleizrowice-ant Sep 19, 2026
2c417ce
fix(glob): a leading ! or ^ negates a bracket set
ronleizrowice-ant Sep 19, 2026
e392ac5
docs: name the negated bracket set, and drop a fence the walk no long…
ronleizrowice-ant Sep 19, 2026
f6b1ce5
Merge remote-tracking branch 'origin/main' into fix/glob-to-regex-neg…
ronleizrowice-ant Sep 20, 2026
6384a68
globToRegex: write a `-` that is a set member first, with no backslash
ronleizrowice-ant Sep 21, 2026
1d8146b
Shorten the comments this change adds
ronleizrowice-ant Sep 28, 2026
b7f0918
globToRegex: write set members bare, and test the sets under sandbox-…
ronleizrowice-ant Sep 28, 2026
8be91e7
Merge main into the negated bracket sets
claude Oct 20, 2026
b0f47a3
test: a negated set with a `-` under sandbox-exec, and a `,` for the …
claude Oct 20, 2026
c479240
glob: write a set the way a macOS profile was measured to read it
claude Oct 20, 2026
19da72c
glob: leave what is beyond ASCII in a set where it was written
claude Oct 20, 2026
8a4a93f
Merge main into fix/glob-to-regex-negation-and-placeholders
claude Nov 6, 2026
9713af9
Merge main into fix/glob-to-regex-negation-and-placeholders
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax:
- `**` - Matches any characters including `/` (e.g., `src/**/*.ts` matches all `.ts` files in `src/`)
- `?` - Matches any single character except `/` (e.g., `file?.txt` matches `file1.txt`)
- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`)
- `[!abc]` and `[^abc]` - Matches any character outside the set, and never `/` (e.g., `file[!a-c].txt` matches `filed.txt` but not `fileb.txt`)

A deny rule also covers everything beneath what its pattern matches, and in such a rule a sandbox profile's own regex engine reads a negated set whose members sit next to `/` in the character order (`[!0-9]`, `[!.]`) as any character but `/`, so that deny covers the characters it lists as well: wider than written, never narrower.

Examples:

Expand All @@ -439,7 +442,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS:
- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the entries that exist when the command is wrapped, so a file that appears later is not covered. The pattern needs a literal directory to start from (a relative pattern starts at the current directory): one with a wildcard in its first path component, such as `/**/*.pem` or `/opt*/keys/**`, is skipped on Linux. Only directories the pattern can match beneath are listed (`certs/*.pem` lists `certs` alone).
- 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. 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`), or with a second `[` that nothing closes. A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself.
- 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. Only a pattern that does not read as written is matched against real paths alone, with every directory under its starting directory listed: one with a wildcard inside a bracket expression (`[a*]`). A link that itself matches it still denies what it leads to. 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, and a link back up the tree denies everything it reaches, as a literal deny of the link would. A link that resolves to nothing is skipped. `allowRead` globs are not expanded through symlinks: they match the link itself.
- 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
160 changes: 132 additions & 28 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,9 @@ export function encodedCommandFromProxyUser(
return suffix
}

/** A character that is syntax to a regex and, unlike `*?[]`, not to a glob. */
const REGEX_METACHARACTER = /[.^$+{}()|\\]/

/**
* Convert a glob pattern to a regular expression
*
Expand All @@ -1084,27 +1087,135 @@ export function encodedCommandFromProxyUser(
* - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/)
* - ? matches any single character except / (e.g., file?.txt matches file1.txt)
* - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
* - [!abc] and [^abc] match any character outside the set, never a `/`
*
* The pattern is read once, left to right, and the regex is emitted as it
* goes, so no literal text in the pattern is ever read as a wildcard.
*
* A `[` that opens no set is a literal character: one that nothing closes,
* or whose set would hold no members (`[]`, `[!]`). The string is compiled
* by JavaScript and by the regex engine of a macOS sandbox profile, so `]`
* is never a member of a set, no spelling of it reading the same to both,
* and the other members are written the way both read ({@link setRegex}).
*
* Exported for testing and shared between macOS sandbox profiles and Linux glob expansion.
*/
export function globToRegex(globPattern: string): string {
return (
'^' +
globPattern
// Escape regex special characters (except glob chars * ? [ ])
.replace(/[.^$+{}()|\\]/g, '\\$&')
// Escape unclosed brackets (no matching ])
.replace(/\[([^\]]*?)$/g, '\\[$1')
// Convert glob patterns to regex (order matters - ** before *)
.replace(/\*\*\//g, '__GLOBSTAR_SLASH__') // Placeholder for **/
.replace(/\*\*/g, '__GLOBSTAR__') // Placeholder for **
.replace(/\*/g, '[^/]*') // * matches anything except /
.replace(/\?/g, '[^/]') // ? matches single character except /
// Restore placeholders
.replace(/__GLOBSTAR_SLASH__/g, '(.*/)?') // **/ matches zero or more dirs
.replace(/__GLOBSTAR__/g, '.*') + // ** matches anything including /
'$'
)
let regex = '^'
/** Inside a `[…]`, where `]` closes the set rather than standing for itself. */
let inSet = false
let i = 0
while (i < globPattern.length) {
const char = globPattern[i]!
if (char === '*') {
const run = i
while (globPattern[i] === '*') i++
// The last two stars of a run before a separator go with it, as `**/`;
// the rest of the run is `**` pairs from the left and then a lone `*`.
const withSeparator = i - run >= 2 && globPattern[i] === '/'
let stars = i - run - (withSeparator ? 2 : 0)
for (; stars >= 2; stars -= 2) regex += '.*' // ** matches anything including /
if (stars === 1) regex += '[^/]*' // * matches anything except /
if (withSeparator) {
regex += '(.*/)?' // **/ matches zero or more dirs
i++
}
continue
}
if (char === '?') {
regex += '[^/]' // ? matches a single character except /
i++
continue
}
if (inSet && char === ']') {
regex += ']'
inSet = false
i++
continue
}
if (!inSet && char === '[') {
const set = setOpenedAt(globPattern, i)
if (set === undefined) {
regex += '\\['
i++
continue
}
const body = globPattern.slice(set.members, set.close)
if (!/[*?]/.test(body)) {
regex += setRegex(body, set.negated)
i = set.close + 1
continue
}
// A set that holds a wildcard is not read as one, but a `-` first in
// it stays first, not a range from the `/`.
const lead = set.negated && body[0] === '-' ? '-' : ''
regex += set.negated ? `[^${lead}/` : '['
inSet = true
i = set.members + lead.length
continue
}
regex += REGEX_METACHARACTER.test(char) ? `\\${char}` : char
i++
}
return regex + '$'
}

/**
* The set the `[` at `open` opens: where its members start, the `]` that
* closes it, and whether a leading `!` or `^` negates it. Undefined when
* nothing closes it or it would hold no members.
*/
function setOpenedAt(
pattern: string,
open: number,
): { members: number; close: number; negated: boolean } | undefined {
const lead = pattern[open + 1]
const negated = lead === '!' || lead === '^'
const members = open + (negated ? 2 : 1)
const close = pattern.indexOf(']', members)
return close > members ? { members, close, negated } : undefined
}

/**
* The regex for a set with no wildcard in it, written the way JavaScript
* and the regex engine of a macOS sandbox profile read alike.
*
* That engine takes a backslash inside a set for a member (`[^/\$]` leaves
* out `\` as well as `$`), so a member is written as it is, and a backslash
* doubled, which is one `\` to both. Nothing else needs more: `]` is never a
* member, and no set that is not negated starts with a `^`, `[^` opening a
* negated one in the glob.
*
* That engine also refuses a set that ends in one character and a `-`
* (`[a-]`), and the whole profile with it. Only first among the members do
* both read a `-` alike, so one that stands for itself (first, last,
* straight after a range, or at either end of one) goes there, ahead of the
* `/` of a negated set, and a range that starts or ends at one is the `-`
* and the rest of the range, from `.` or up to `,`.
*/
function setRegex(body: string, negated: boolean): string {
let dash = false
let members = ''
const member = (char: string): string => (char === '\\' ? '\\\\' : char)
for (let i = 0; i < body.length; i++) {
let low = body[i]!
let high = low
if (body[i + 1] === '-' && i + 2 < body.length) {
high = body[i + 2]!
i += 2
}
if (low === '-' || high === '-') {
dash = true
if (low === high) continue
if (low === '-') low = '.'
else high = ','
}
members += low === high ? member(low) : `${member(low)}-${member(high)}`
}
const lead = dash ? '-' : ''
if (negated) return `[^${lead}/${members}]`
// Alone in its set, the `-` is as well written without one.
return members === '' ? '-' : `[${lead}${members}]`
}

/**
Expand Down Expand Up @@ -1214,11 +1325,10 @@ export function expandGlobPattern(
*
* `splits` is false for a pattern this cannot be done for: one with a
* wildcard inside a bracket expression (globToRegex rewrites that wildcard
* like any other, and what is left no longer reads as one character), with a
* second `[` that nothing closes, or that spells one of globToRegex's
* placeholders. Its positions say nothing, so every directory is listed, an
* entry is matched by its whole spelling, and {@link walkGlobPattern} does
* not list such a pattern through symlinks.
* like any other, and what is left no longer reads as one character). Its
* positions say nothing, so every directory is listed, an entry is matched
* by its whole spelling, and {@link walkGlobPattern} does not list such a
* pattern through symlinks.
*/
interface GlobPositions {
splits: boolean
Expand Down Expand Up @@ -1254,11 +1364,8 @@ type GlobPiece =
| { source: string; canBeSeparator?: true }

function globPieces(pattern: string, flags: string): GlobPiece[] | undefined {
// globToRegex rewrites its own placeholders where a pattern spells one.
if (pattern.includes('__GLOBSTAR')) return undefined
const sourceOf = (text: string): string => globToRegex(text).slice(1, -1)
const pieces: GlobPiece[] = []
let unclosed = 0
// A bracket expression first, the way a regular expression reads one:
// everything up to the first `]`. Then a run of `*` with the separator
// after it, since `**/` is one thing to globToRegex.
Expand Down Expand Up @@ -1286,9 +1393,6 @@ function globPieces(pattern: string, flags: string): GlobPiece[] | undefined {
} else if (separator !== undefined) {
pieces.push('/')
} else {
// globToRegex escapes the first `[` that nothing closes and no other:
// a second one reads on into whatever a later wildcard is rewritten to.
if (text === '[' && unclosed++ > 0) return undefined
pieces.push({ source: sourceOf(text) })
}
}
Expand Down
Loading
Loading