Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -545,7 +545,10 @@ Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax:
- `*` - Matches any characters except `/` (e.g., `*.ts` matches `foo.ts` but not `foo/bar.ts`)
- `**` - 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`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`)
- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name), but for a `!` or `^` that negates (next entry). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`)
- `[!abc]` and `[^abc]` - Matches any character outside the set, and never `/` (e.g., `file[!a-c].txt` matches `filed.txt` but not `fileb.txt`). Only a `!` or `^` straight after the `[` negates, and it is no member itself. With nothing after it (`[!]`, `[^]`) the brackets spell no set

Two things a macOS sandbox profile's own regex engine does with these. It matches bytes, so `?` and a set, negated or not, match no character outside ASCII. And in a deny rule it reads a negated set that lists a `-` and one of `,` and `.`, not both (`[!-.]`, `[!+--]`), as any character but `/`, so that deny covers the characters it lists as well: wider than written, never narrower.

Examples:

Expand Down
96 changes: 82 additions & 14 deletions src/sandbox/glob-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@ export enum GlobTokenKind {
Globstar = 'globstar',
/** `**` with the `/` after it: any number of directories. */
GlobstarSlash = 'globstarSlash',
/** `[abc]`, `[a-z]`: one character of a set. */
/** `[abc]`, `[a-z]`: one character of a set. `[!abc]`: one outside it. */
Set = 'set',
}

export type GlobToken =
| { kind: GlobTokenKind.Text; text: string }
| {
kind: GlobTokenKind.Set
/** What is written between the brackets. */
/** What is written between the brackets, after a `!` or `^` that negates. */
members: string
/** `[+-9]` does: a `/` lies between the two. */
/** `[!abc]`, `[^abc]`: a character that is no member, and no `/`. */
negated: boolean
/** `[+-9]` does: a `/` lies between the two. A negated set never does. */
matchesSeparator: boolean
}
| { kind: GlobTokenKind.Separator | GlobTokenKind.AnyChar | WildcardKind }
Expand Down Expand Up @@ -66,11 +68,12 @@ const SEPARATOR = '/'.charCodeAt(0)
* is a pattern, and no character escapes another:
*
* - A set runs from a `[` to the first `]` after it, be that the next
* character. All between the two are members, a `*`, a `?`, a `/` or another
* `[` too, and `^` and `!` do not negate.
* character. A `!` or a `^` straight after the `[` negates it. All else
* between the two are members, a `*`, a `?`, a `/` or another `[` too.
* - Brackets that spell no set (see {@link setRanges}) are text, as POSIX
* reads a `[` that opens none, and what is between them keeps its meaning.
* So are a `[` that nothing closes and a `]` that nothing opens.
* reads a `[` that opens none, and what is between them keeps its meaning:
* `[!]` and `[^]` have no members. So are a `[` that nothing closes and a
* `]` that nothing opens.
* - Of a run of `*`, the last two go with a `/` that follows. Two or more of
* the rest are a `**`.
*/
Expand Down Expand Up @@ -151,10 +154,11 @@ export function parseGlob(pattern: string): GlobToken[] {
i++
break
}
const members = pattern.slice(i + 1, close)
const negated = pattern[i + 1] === '!' || pattern[i + 1] === '^'
const members = pattern.slice(i + (negated ? 2 : 1), close)
const ranges = setRanges(members)
if (ranges === undefined) {
noSets.push(`[${members}]`)
noSets.push(pattern.slice(i, close + 1))
bracketsAreTextUntil = close + 1
i++
break
Expand All @@ -163,9 +167,10 @@ export function parseGlob(pattern: string): GlobToken[] {
tokens.push({
kind: GlobTokenKind.Set,
members,
matchesSeparator: ranges.some(
([from, to]) => from <= SEPARATOR && SEPARATOR <= to,
),
negated,
matchesSeparator:
!negated &&
ranges.some(([from, to]) => from <= SEPARATOR && SEPARATOR <= to),
})
textStart = i = close + 1
break
Expand All @@ -189,6 +194,69 @@ export function escapeRegexLiteral(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

const DASH = '-'.charCodeAt(0)
const CARET = '^'.charCodeAt(0)

/**
* The regular expression for a set, written the way JavaScript and the regex
* engine of a macOS sandbox profile read alike. What that engine does with a
* set, as measured on macOS 14, 15 and 26, and what is done about it:
*
* - A backslash is a member (`[^/\$]` leaves out `\` as well as `$`). So a
* member is written as it is, and a backslash doubled: one `\` to both.
* - `[.`, `[:` and `[=` open a POSIX element, and the whole profile is refused
* where nothing closes it (`[[.]`). So the members go in ascending order,
* in which none of the three follows a `[`.
* - A `-` is a member only where it is the first: a set that ends in one
* (`[a-]`) and a range that starts at one (`[--9]`) are refused. So one
* that stands for itself, or at either end of a range, goes first, and such
* a range is the `-` and the rest of it, from `.` or up to `,`.
* - In a rule with the tail of {@link denyGlobRegex}, a negated set in which
* two neighbours are written apart (`[^/0-9]`, `[^/abc]`) is read as `[^/]`.
* So there they are written as one range (`[^/-9]`, `[^/a-c]`). That leaves
* a `-` beside a `,` or a `.`, which the line above keeps apart.
*
* In a set that is not negated a `^` goes last, where neither takes it to
* negate. Beyond ASCII nothing is moved: sorted by code unit, the halves of
* two characters outside the basic plane would part.
*/
function setToRegex(members: string, negated: boolean): string {
const ranges = setRanges(members) ?? []
if (negated) ranges.push([SEPARATOR, SEPARATOR])
const isAscii = ([, to]: (typeof ranges)[number]): boolean => to < 0x80
const merged: typeof ranges = []
for (const [from, to] of ranges.filter(isAscii).sort(([a], [b]) => a - b)) {
const last = merged[merged.length - 1]
if (last !== undefined && from <= last[1] + (negated ? 1 : 0)) {
last[1] = Math.max(last[1], to)
} else {
merged.push([from, to])
}
}
const member = (code: number): string =>
String.fromCharCode(code).replace('\\', '\\\\')
let dash = ''
let caret = ''
let rest = ''
for (let [from, to] of [...merged, ...ranges.filter(r => !isAscii(r))]) {
if (from === DASH || to === DASH) {
dash = '-'
if (from === to) continue
if (from === DASH) from++
else to--
}
if (from === CARET && !negated) {
caret = '^'
if (from === to) continue
from++
}
rest += from === to ? member(from) : `${member(from)}-${member(to)}`
}
const body = dash + rest + caret
// Alone in its set, the `-` is as well written without one.
return body === '-' ? body : `[${negated ? '^' : ''}${body}]`
}

/** The regular expression for what `token` matches. */
export function globTokenToRegex(token: GlobToken): string {
switch (token.kind) {
Expand All @@ -205,8 +273,7 @@ export function globTokenToRegex(token: GlobToken): string {
case GlobTokenKind.GlobstarSlash:
return '(.*/)?'
case GlobTokenKind.Set:
// A `-` stays what it is to the set, and the rest are its members.
return `[${token.members.replace(/[.^$+{}()|\\]/g, '\\$&')}]`
return setToRegex(token.members, token.negated)
}
}

Expand All @@ -221,6 +288,7 @@ export function globTokenToRegex(token: GlobToken): string {
* - ** 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 `/`
*
* Whatever a pattern spells, what is returned compiles, so that no entry keeps
* a sandbox from starting (see {@link parseGlob}). Only its size can be more
Expand Down
27 changes: 18 additions & 9 deletions test/sandbox/glob-expand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,14 @@ describe.if(!isWindows)('walkGlobPattern', () => {
join(root, 'outside', 'cert', 'x.pem'),
join(root, 'outside', 'cert9x.pem'),
])
// A negated set is one character of a name, whatever it leaves out.
expect(found('*/cert[!s/]x.pem')).toEqual([
join(root, 'outside', 'cert9x.pem'),
])
expect(found('*/cert[^0-8]x.pem')).toEqual([
join(root, 'outside', 'cert9x.pem'),
join(root, 'outside', 'certsx.pem'),
])
} finally {
rmSync(root, { recursive: true, force: true })
}
Expand Down Expand Up @@ -2410,19 +2418,20 @@ describe('globToRegex (shared)', () => {
expect(new RegExp(letters).test('/tmp/test/1bc.txt')).toBe(false)
})

it('never negates a bracket set: ^ and ! are members of it', () => {
it('negates a bracket set written with ^ or !', () => {
// Both spellings used for negation elsewhere (regex `^`, gitignore `!`)
// land in the set as ordinary characters, so such a pattern matches
// fewer names than its author meant, not more.
// negate the set rather than joining it as ordinary characters.
const caret = globToRegex('/tmp/test/file[^0-9].txt')
expect(new RegExp(caret).test('/tmp/test/file^.txt')).toBe(true)
expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(true)
expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(false)
expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(true)
expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(false)

const bang = globToRegex('/tmp/test/file[!0-9].txt')
expect(new RegExp(bang).test('/tmp/test/file!.txt')).toBe(true)
expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(true)
expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(false)
expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(true)
expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(false)

// The character that negates the set is not also a member of it.
expect(new RegExp(globToRegex('/tmp/[^^]')).test('/tmp/^')).toBe(false)
expect(new RegExp(globToRegex('/tmp/[!!]')).test('/tmp/!')).toBe(false)
})

it('treats a bracket that opens no set as a literal character', () => {
Expand Down
61 changes: 55 additions & 6 deletions test/sandbox/glob-pattern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ const text = (t: string): GlobToken => ({ kind: Kind.Text, text: t })
const set = (members: string, matchesSeparator = false): GlobToken => ({
kind: Kind.Set,
members,
negated: false,
matchesSeparator,
})
const allBut = (members: string): GlobToken => ({
kind: Kind.Set,
members,
negated: true,
matchesSeparator: false,
})
const SEPARATOR: GlobToken = { kind: Kind.Separator }
const ANY_CHAR: GlobToken = { kind: Kind.AnyChar }
const STAR: GlobToken = { kind: Kind.Star }
Expand Down Expand Up @@ -73,8 +80,8 @@ describe('parseGlob', () => {
['[-a]', [set('-a')]],
['[a-]', [set('a-')]],
['[a-a]', [set('a-a')]],
['[^a]', [set('^a')]],
['[!a]', [set('!a')]],
['[a^]', [set('a^')]],
['[a!]', [set('a!')]],
['[a[b]', [set('a[b')]],
['[a]]', [set('a'), text(']')]],
['[s/]', [set('s/', true)]],
Expand All @@ -87,11 +94,35 @@ describe('parseGlob', () => {
expect(parseGlob(pattern)).toEqual(tokens)
})

it.each<[string, GlobToken[]]>([
['[^a]', [allBut('a')]],
['[!a]', [allBut('a')]],
['x[!a-z]y', [text('x'), allBut('a-z'), text('y')]],
// Only the first of them negates.
['[!!]', [allBut('!')]],
['[^^]', [allBut('^')]],
['[!^]', [allBut('^')]],
['[^!a]', [allBut('!a')]],
['[!-a]', [allBut('-a')]],
['[!*]', [allBut('*')]],
// Never the separator, be it a member or not.
['[!s/]', [allBut('s/')]],
['[!+-9]', [allBut('+-9')]],
['[!a]]', [allBut('a'), text(']')]],
])('reads %p as a negated set', (pattern, tokens) => {
expect(parseGlob(pattern)).toEqual(tokens)
})

it.each<[string, GlobToken[]]>([
['[z-a]', [text('[z-a]')]],
['[user-id]', [text('[user-id]')]],
['[]', [text('[]')]],
['[]a]', [text('[]a]')]],
['[!]', [text('[!]')]],
['[^]', [text('[^]')]],
['[!]a]', [text('[!]a]')]],
['[!z-a]', [text('[!z-a]')]],
['[^user-id]', [text('[^user-id]')]],
['a]b', [text('a]b')]],
['a[b', [text('a[b')]],
['a[b[c', [text('a[b[c')]],
Expand All @@ -106,6 +137,7 @@ describe('parseGlob', () => {

it.each<[string, GlobToken[]]>([
['[z-a*]', [text('[z-a'), STAR, text(']')]],
['[!z-a*]', [text('[!z-a'), STAR, text(']')]],
['[z-a/?]', [text('[z-a'), SEPARATOR, ANY_CHAR, text(']')]],
['a[b[c*', [text('a[b[c'), STAR]],
['[**/', [text('['), GLOBSTAR_SLASH]],
Expand Down Expand Up @@ -173,8 +205,18 @@ function referenceGlobMatch(pattern: string, pathText: string): boolean {
if (head === '?') return pathText[s] !== '/' && rest(p + 1, s + 1)
if (head === '[' && p >= bracketsAreTextUntil) {
const close = pattern.indexOf(']', p + 1)
const has = close === -1 ? undefined : setOf(pattern.slice(p + 1, close))
if (has !== undefined) return has(pathText[s]!) && rest(close + 1, s + 1)
const negated = pattern[p + 1] === '!' || pattern[p + 1] === '^'
const has =
close === -1
? undefined
: setOf(pattern.slice(p + (negated ? 2 : 1), close))
if (has !== undefined) {
const char = pathText[s]!
return (
(negated ? !has(char) && char !== '/' : has(char)) &&
rest(close + 1, s + 1)
)
}
bracketsAreTextUntil = close === -1 ? pattern.length : close + 1
}
return pathText[s] === head && from(p + 1, s + 1, bracketsAreTextUntil)
Expand Down Expand Up @@ -219,17 +261,24 @@ describe('globToRegex', () => {

it('agrees with a reference matcher over generated patterns and paths', () => {
const piece = fc.constantFrom(
...'[[]]--**??//az09+.^!\\',
...'[[]]--**??//az09+,.^!\\',
'**/',
'[a-z]',
'[z-a]',
'[+-9]',
'[*]',
'[]',
'[!a-z]',
'[^09]',
'[!z-a]',
'[!-a]',
'[!]',
'[+--]',
'[--0]',
)
const pattern = fc.array(piece, { maxLength: 8 }).map(p => p.join(''))
// A path like the pattern, so that a match is not rare, or any at all.
const char = fc.constantFrom(...'[]-*?/az09+.^!\\')
const char = fc.constantFrom(...'[]-*?/az09+,.^!\\')
const pathFor = (p: string): fc.Arbitrary<string> =>
fc.oneof(
fc.array(char, { maxLength: 6 }).map(chars => chars.join('')),
Expand Down
11 changes: 10 additions & 1 deletion test/sandbox/glob-walk.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import { isWindows } from '../helpers/platform.js'
/** Names that put the interesting readings next to each other: a bracket set
* that can match a separator (`[+-9]` spans `/`), a `**` written against
* text, a name that is a prefix of another, a wildcard that is a member of a
* set, and brackets that spell no set (`b-a` runs backwards). */
* set, brackets that spell no set (`b-a` runs backwards), and negated sets,
* which never match one, whatever their members. */
const NAME = fc.constantFrom(
'a',
'b',
Expand All @@ -43,6 +44,7 @@ const NAME = fc.constantFrom(
's',
'*',
'[b-a]',
'[!b-a]',
)

const SEGMENT = fc.constantFrom(
Expand All @@ -61,6 +63,11 @@ const SEGMENT = fc.constantFrom(
'[a*]',
'**.pem',
'[b-a]',
'[!ab]',
'[^s/]',
'[!+-9]',
'cert[!x]',
'[!b-a]',
)

const LAST_SEGMENT = fc.constantFrom(
Expand All @@ -71,6 +78,8 @@ const LAST_SEGMENT = fc.constantFrom(
'?.pem',
'[xy].pem',
'[+-9].pem',
'[!x].pem',
'[^+-9].pem',
)

const PATTERN_TAIL = fc
Expand Down
Loading
Loading