From 94bd9e58ed91e842534019495702434909f4af57 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:52:38 +0000 Subject: [PATCH 1/2] Read a path entry as the path it spells, where that exists A folder may have `*`, `?`, `[` or `]` in its name (`[WIP] project`), and every entry inside one was read as a pattern alone and lost: a Linux write entry was dropped, a read entry expanded to nothing, macOS compiled a regex that misses the folder, Windows expanded a pattern beneath it to nothing. An entry with those characters is now also read as the path it spells where the part that holds them exists on disk, and as its tail pattern beneath an existing directory. The pattern reading stays. A deny takes every reading that may hold; an allow takes one only for a path that is there and is no symbolic link. `path-entries.ts` decides, for the manager and the wrapper's fold on Linux, the profile builder on macOS and `expandWindowsFsPaths` on Windows. --- README.md | 12 +- src/sandbox/macos-sandbox-utils.ts | 40 +- src/sandbox/path-entries.ts | 286 +++- src/sandbox/sandbox-config.ts | 11 +- src/sandbox/sandbox-manager.ts | 70 +- src/sandbox/sandbox-utils.ts | 28 +- src/sandbox/windows-sandbox-utils.ts | 21 +- test/config-validation.test.ts | 21 + test/sandbox/glob-expand.test.ts | 149 ++- test/sandbox/literal-path-entries.test.ts | 1441 ++++++++++++++++++++- 10 files changed, 1999 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 57493ced5..af6478d8a 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,7 @@ Examples: bubblewrap binds concrete paths, so glob support is narrower than on macOS: -- `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped. +- `allowWrite` / `denyWrite` take literal paths. A trailing `/**` is dropped (`src/**` means `src`); any other glob pattern there is skipped, unless the entry is also the name of a path that exists (see "All platforms" below), and then that path is what is applied. - `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. @@ -455,9 +455,11 @@ Examples: - Paths can be absolute (e.g., `/home/user/.ssh`) or relative to the current working directory (e.g., `./src`) - `~` expands to the user's home directory - A deny glob must not end in a separator: the separator becomes part of the compiled pattern, so the pattern can match no path. `denyRead`, `denyWrite` and a `mode: "deny"` credential file reject such an entry at config validation — write `/data/*`, or add a `**` segment to match at any depth. -- To name a path and nothing else, write `{ "path": "/work/*.env", "literal": true }` in place of the string. Such an entry is never a pattern, whether or not the path exists, so it can deny a file that is not there yet in a folder that is not there yet. `~` and relative paths are resolved as for a string, symbolic links are followed as for any path without such characters, and a `/**` at the end is part of the name. `denyRead`, `allowRead`, `allowWrite` and `denyWrite` take this form; `credentials.files` does not. An object without `"literal": true`, or with any other key, is rejected. -- Not covered: on Linux, a `denyRead` for a path that does not exist when the command is wrapped hides nothing in that command, marked or not. On Windows, `*` and `?` cannot be part of a name, so a marked path that holds one is skipped. -- For embedders: the four lists of `SandboxRuntimeConfig` are of type `FilesystemPathEntry[]`, which is `(string | { path: string; literal: true })[]`, and `getConfig()` gives entries back in the form they were given. Code that reads an entry as a string stops compiling; `typeof entry === 'string' ? entry : entry.path` is its path. Three things change without a compile error. A list joined into text prints `[object Object]` for a marked entry. `getFsReadConfig()` and `getFsWriteConfig()` carry marked entries in `literalDenyOnly`, `literalAllowWithinDeny`, `literalAllowOnly` and `literalDenyWithinAllow`, each of which may be absent when it has no entries, so hand those objects on whole instead of rebuilding them from `denyOnly` and `allowOnly`; `writeRootsOf(getFsWriteConfig())` is every path the command may write. And an empty `denyOnly` no longer means that nothing is read-denied. `getDefaultWritePaths()` and `expandWindowsFsPaths()` take the lists as configured. `wrapCommandWithSandboxLinux()` takes every path it is handed for a name, as bubblewrap does, and skips a string in `allowOnly` that has `*`, `?`, `[` or `]` in it unless something is there under that very spelling. +- A directory may have `*`, `?`, `[` or `]` in its name (`[WIP] project`, `notes (draft?)`). An entry with those characters is read as a pattern, as described above, and **also** as the path it spells when the part of it that holds those characters exists on disk. With a folder `/work/[WIP] project` there, `/work/[WIP] project/keep` is both the pattern and the path, and `/work/[WIP] project/**/.env` is both the pattern and the pattern `**/.env` beneath that folder. A deny covers every reading and an allow allows every reading. What exists is looked at when the command is wrapped (on Windows, at `initialize()`), so a folder created later counts from the next command on (on Windows, from the next `initialize()`). For a deny, a symbolic link of that name counts as existing, and so does a path that cannot be looked at, whatever the entry: beneath a directory that cannot be searched when the command is wrapped, an ordinary pattern such as `/locked/*.pem` is also taken for the path it spells, and denied like any path there that cannot be looked at. For an allow the path has to be there as itself: if any component, from the first one with such a character on, is a symbolic link, the entry is a pattern only. +- A `*` or `?` in a folder's name also matches itself as a pattern, and so does a `[` or `]` without its partner, so inside `build*` or `notes (draft?)` the pattern reading of an entry finds the path too. That shows in a deny ending in `/**`: it is applied as the pattern it is, under which on Linux every entry beneath the directory keeps its own mask (see above) and an `allowRead` beneath it opens only what is created later. Write such a deny without the `/**`. +- To name a path and nothing else, write `{ "path": "/work/*.env", "literal": true }` in place of the string. Such an entry is never a pattern, whether or not the path exists, so it can deny a file that is not there yet in a folder that is not there yet. `~` and relative paths are resolved as for a string, symbolic links are followed as for any path without such characters (a marked allow has no check for them), and a `/**` at the end is part of the name. `denyRead`, `allowRead`, `allowWrite` and `denyWrite` take this form; `credentials.files` does not. An object without `"literal": true`, or with any other key, is rejected. +- Not covered: on Linux, `allowWrite` and `denyWrite` still take no patterns, so a pattern beneath such a folder is skipped there, and `getLinuxGlobPatternWarnings()` still names every write entry with those characters that is not marked, whether or not it is applied as a path. On Linux, a `denyRead` for a path that does not exist when the command is wrapped hides nothing in that command, marked or not. On Linux, the violation monitor takes its lists at `initialize()`: a folder created later counts for the commands wrapped after it, and the monitor does not learn of it. On macOS, an allow that is also the name of a path allows everything beneath that path, as any name does, where its pattern allows only what it matches: under `allowWrite: ["/work/*"]` a command can create a folder named `*` in `/work`, or rename one to that, and from the next command on write everything in it (under `allowRead`, read everything in it, whatever a `denyRead` pattern covers there). On Windows, `*` and `?` cannot be part of a name, so a marked path that holds one is skipped, and a UNC pattern is read as a pattern only, because finding out what exists would mean asking the share. +- For embedders: the four lists of `SandboxRuntimeConfig` are of type `FilesystemPathEntry[]`, which is `(string | { path: string; literal: true })[]`, and `getConfig()` gives entries back in the form they were given. Code that reads an entry as a string stops compiling; `typeof entry === 'string' ? entry : entry.path` is its path. Three things change without a compile error. A list joined into text prints `[object Object]` for a marked entry. `getFsReadConfig()` and `getFsWriteConfig()` carry marked entries in `literalDenyOnly`, `literalAllowWithinDeny`, `literalAllowOnly` and `literalDenyWithinAllow`, each of which may be absent when it has no entries, so hand those objects on whole instead of rebuilding them from `denyOnly` and `allowOnly`; `writeRootsOf(getFsWriteConfig())` is every path the command may write. And an empty `denyOnly` no longer means that nothing is read-denied. `getDefaultWritePaths()` and `expandWindowsFsPaths()` take the lists as configured. `wrapCommandWithSandboxLinux()` takes every path it is handed for a name, as bubblewrap does, and skips a string in `allowOnly` that has such characters and is not the name of a path by the rule above. #### Other Configuration @@ -628,7 +630,7 @@ Running under a distinct user SID structurally closes the surrogate-spawn class - `filesystem.allowRead` → an inheriting `READ|EXECUTE` ALLOW ACE - `filesystem.denyRead` / `filesystem.denyWrite` → an inheriting DENY ACE on the target, plus an inheriting `FILE_DELETE_CHILD` DENY on its parent — together with the withheld `FILE_DELETE_CHILD` on the working-tree grant, this stops the sandboxed process from renaming or deleting a denied path via its parent directory -`reset()` removes every ACE this session added (refcounted across this user's concurrent hosts via the per-user session DB; a crash-recovery pass on the next `initialize()` cleans up after an unclean exit). Directory targets are supported (the ACEs inherit to the whole subtree). Glob patterns are expanded to concrete paths at `initialize()` time — a matching path that appears later is not covered. +`reset()` removes every ACE this session added (refcounted across this user's concurrent hosts via the per-user session DB; a crash-recovery pass on the next `initialize()` cleans up after an unclean exit). Directory targets are supported (the ACEs inherit to the whole subtree). Glob patterns are expanded to concrete paths at `initialize()` time — a matching path that appears later is not covered. In an entry without `*` or `?`, `[` and `]` are characters of the name. In a pattern they are read as a character class, as on the other platforms, and the pattern is also expanded beneath a folder that has them in its name. ### TLS termination on Windows diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index c632771d9..34c477fca 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -19,6 +19,7 @@ import { getDangerousDirectories, } from './sandbox-utils.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' +import { literalReadings, type PathListKind } from './path-entries.js' import type { FsReadRestrictionConfig, @@ -162,8 +163,8 @@ function pathFilter(entry: PathEntry): string { /** * Compiles `entry`'s pattern with one of the shared string compilers in - * `sandbox-utils.ts`. An entry the library anchored at a directory - * (`anchor`) keeps that directory as a literal — the cwd is a name on disk + * `sandbox-utils.ts`. An entry anchored at a directory + * (`anchor`) keeps that directory as a literal — it is a name on disk * and may itself contain `[`, `*` or `?` — so only the tail below it goes * through the compiler, and the anchor is spliced back in escaped. Every * compiler there returns '^…$'. @@ -171,7 +172,7 @@ function pathFilter(entry: PathEntry): string { * This is the whole difference between the entry-taking wrappers here and * the string-taking compilers they call: those see a spelling in which * every character is glob syntax, which is right for what a caller wrote - * and wrong for what the library computed. + * as a pattern and wrong for a name on disk. */ function anchorRegex( entry: GlobPathEntry, @@ -251,8 +252,9 @@ export type PathEntry = glob: true path: string /** - * Set when the library anchored this glob at a directory of its own - * (the cwd, for the mandatory `**\/` patterns): the prefix of + * Set when this glob is anchored at a directory that is a name on + * disk (the cwd, for the mandatory `**\/` patterns, or an + * existing one that holds glob characters): the prefix of * `path` that is a literal path rather than part of the pattern. */ anchor?: string @@ -268,11 +270,12 @@ function anchoredGlobEntry(anchor: string, pattern: string): PathEntry { /** * A spelling that came from the caller's config: `*`, `?` and `[…]` in - * what the caller wrote are the glob syntax it asked for. The decision is + * what the caller wrote are read as glob syntax here. The decision is * made on that raw spelling, because resolving a relative or `~` path can * splice in a cwd or home directory whose own name contains those * characters — read back, they would turn the caller's `secrets` into a * pattern that never matches the directory it was resolved to. + * {@link alsoReadAs} gives the readings this one leaves out. */ function toPathEntry(pathPattern: string): PathEntry { return containsGlobChars(pathPattern) @@ -298,13 +301,26 @@ function toLiteralPathEntry(literalPath: string): PathEntry { } } -/** One of the caller's lists as entries: spellings, then marked paths. */ +/** + * The readings a caller's spellings have beside {@link toPathEntry}'s; see + * {@link literalReadings}. + */ +function alsoReadAs( + spellings: readonly string[] | undefined, + kind: PathListKind, +): PathEntry[] { + return (spellings ?? []).flatMap(spelling => literalReadings(spelling, kind)) +} + +/** One of the caller's lists as entries, in every reading each has. */ function callerEntries( + kind: PathListKind, spellings: readonly string[] | undefined, literalPaths: readonly string[] | undefined, ): PathEntry[] { return [ ...(spellings ?? []).map(toPathEntry), + ...alsoReadAs(spellings, kind), ...(literalPaths ?? []).map(toLiteralPathEntry), ] } @@ -343,13 +359,14 @@ function resolveReadConfig( ): ResolvedReadConfig { return { denies: [ - ...callerEntries(config.denyOnly, config.literalDenyOnly), + ...callerEntries('deny', config.denyOnly, config.literalDenyOnly), ...libraryDenies, ], // Non-glob spellings arrive slash-free from normalizePathForSandbox — // the nested-deny re-emit matches by `path + '/'` prefix, which a // preserved trailing slash would defeat ('//'). allows: callerEntries( + 'allow', config.allowWithinDeny, config.literalAllowWithinDeny, ), @@ -881,13 +898,13 @@ function generateReadRules( } /** - * What a write config allows, as entries: read once per profile, for the - * read and the write section alike, so neither can miss the marked paths. + * What a write config allows, as entries. Decided once per profile and + * handed to the read and the write section, so the two see the same answer. */ function writeRootEntries( config: FsWriteRestrictionConfig | undefined, ): PathEntry[] { - return callerEntries(config?.allowOnly, config?.literalAllowOnly) + return callerEntries('allow', config?.allowOnly, config?.literalAllowOnly) } /** @@ -920,6 +937,7 @@ function generateWriteRules( ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), ...macGetMandatoryDenyEntries(allowGitConfig), ] + denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) const { groups, rest: ungrouped } = groupLiteralDenyPaths(denyEntries) const groupFilters = groups.map(literalDenyGroupFilters) diff --git a/src/sandbox/path-entries.ts b/src/sandbox/path-entries.ts index cee9f199f..7325beb79 100644 --- a/src/sandbox/path-entries.ts +++ b/src/sandbox/path-entries.ts @@ -1,18 +1,34 @@ /** - * The entries of `denyRead`, `allowRead`, `allowWrite` and `denyWrite` as - * configured: a spelling, which is a pattern when it holds `*`, `?`, `[` or - * `]`, or a path marked `{ path, literal: true }`, which is the name and - * nothing else, whatever it holds. + * How an entry of `denyRead`, `allowRead`, `allowWrite` or `denyWrite` is + * read: as the pattern its characters spell, as the path they name, or both. * - * Marked paths travel beside the spellings, in the `literal…` lists of the - * restriction configs, so that no backend reads their characters again. + * An entry with `*`, `?`, `[` or `]` is a pattern. A directory may hold those + * in its own name (`[WIP] project`), so such an entry is ALSO read as what it + * names wherever the part that holds the characters exists on disk. An entry + * marked `{ path, literal: true }` is the name and nothing else. + * + * The decision is made by the functions here only, for each stage that + * needs it (the manager and the wrapper's fold on Linux, the profile builder + * on macOS, `expandWindowsFsPaths` on Windows). One wrap may ask about an + * entry more than once, and the disk may change in between. */ import * as fs from 'node:fs' +import * as path from 'node:path' +import { getPlatform } from '../utils/platform.js' import { + collapseInteriorSpellings, containsGlobChars, + containsGlobCharsForPlatform, expandTilde, + expandWindowsEnvRefs, + isAbsenceErrno, + isUncPath, markedLiteralPath, + normalizePathForSandbox, + removeTrailingGlobSuffix, + stripExtendedPathPrefix, + toForwardSlashes, } from './sandbox-utils.js' import type { FilesystemPathEntry } from './sandbox-config.js' import type { @@ -20,6 +36,23 @@ import type { FsWriteRestrictionConfig, } from './sandbox-schemas.js' +/** + * Which way a list cuts. A deny takes every reading that may hold, since one + * more can only deny more; an allow takes one only for a path that is there + * exactly as spelled. + */ +export type PathListKind = 'allow' | 'deny' + +/** One reading of an entry, shaped as the macOS builder's `PathEntry`. */ +export type PathReading = + /** The entry is the name of this path. */ + | { glob: false; path: string } + /** + * The entry is a pattern beneath `anchor`, a directory taken as the name it + * is: `path` is `anchor` plus the tail, and only the tail is pattern. + */ + | { glob: true; path: string; anchor: string } + /** * The spellings and the marked paths of a list, apart. Throws on an entry * that is neither; see {@link markedLiteralPath}. @@ -60,18 +93,241 @@ export function samePathEntries( } /** - * Whether something is at `spelling`, as the kernel resolves it. `realpath` - * cannot be asked: it folds a `..` by the text first. + * The spelling made absolute as {@link normalizePathForSandbox} does for a + * pattern: no symlink resolved, a trailing separator kept. Undefined for a + * Windows UNC path, which is never probed (see {@link isUncPath}). */ -function isThereAsSpelled(spelling: string): boolean { +function spelledAbsolute(spelling: string): string | undefined { + const onWindows = getPlatform() === 'windows' + let spelled = spelling + if (onWindows) { + spelled = stripExtendedPathPrefix(expandWindowsEnvRefs(spelled)) + if (isUncPath(spelled)) return undefined + } + const expanded = expandTilde(spelled) + const absolute = + expanded === spelled && !path.isAbsolute(spelled) + ? path.resolve(process.cwd(), spelled) + : expanded + return onWindows + ? toForwardSlashes(absolute) + : collapseInteriorSpellings(absolute) +} + +/** + * Whether something may be at `p`, for a deny. A symbolic link counts, + * dangling or not, and so does a path that cannot be looked at: otherwise a + * command running as the same user could switch the deny off by making a + * parent unsearchable, and undo that inside the next sandbox. + */ +function mayBeThere(p: string): boolean { try { - fs.lstatSync(expandTilde(spelling)) + fs.lstatSync(p) return true + } catch (err) { + return !isAbsenceErrno(err) + } +} + +/** + * Whether `p` is there and is not a symbolic link, for an allow. Whoever can + * write the directory that holds `p` can plant a link of that name, and the + * allow would open what it points at. What cannot be looked at is not there. + */ +function isThereUnlinked(p: string): boolean { + try { + return !fs.lstatSync(p).isSymbolicLink() } catch { return false } } +/** Whether a pattern can be walked beneath `p`, under the rule of `kind`. */ +function isDirectoryFor(kind: PathListKind, p: string): boolean { + try { + return kind === 'allow' + ? fs.lstatSync(p).isDirectory() + : fs.statSync(p).isDirectory() + } catch (err) { + return kind === 'deny' && !isAbsenceErrno(err) + } +} + +/** + * The readings a caller's spelling has BESIDE the one its characters give + * it. Empty for a spelling that is no pattern, and for a pattern none of + * whose glob characters are part of a name on disk: that costs one `lstat`. + * + * A trailing `/**` is set aside first. Of the components that hold glob + * characters, the leading ones that exist on disk as spelled are names: + * + * - When all of them exist, the entry is also the NAME it spells. What + * follows the last of them need not exist. + * - When a pattern follows an existing one, the entry is also that pattern + * BENEATH the directory. There is one such reading per existing component, + * the longest first, so creating or removing a directory adds or removes a + * reading and never turns one into another. + * + * Whether a spelling is a pattern is decided on what the caller wrote + * (`isPattern`); which components hold glob characters, by + * {@link containsGlobChars} on every platform, since those are what the + * pattern compiler and the walk take for syntax. + * + * A component exists for a deny as {@link mayBeThere} has it. For an allow + * no component from the first with glob characters on may be a symbolic + * link or beyond looking at: a name to its end, a pattern up to its first + * pattern component, from where the walk of an allow goes through no link. + */ +export function literalReadings( + spelling: string, + kind: PathListKind, + opts: { isPattern?: (spelling: string) => boolean } = {}, +): PathReading[] { + const isPattern = opts.isPattern ?? containsGlobCharsForPlatform + let stripped = removeTrailingGlobSuffix(spelling) + if (!isPattern(stripped)) { + // Where `[` and `]` are no pattern syntax (Windows), `/**` is a + // pattern by its `/**` alone, and the walk still reads the brackets as a + // class: the `/**` stays on, as a pattern beneath the directory. + if (stripped === spelling || !containsGlobChars(stripped)) return [] + stripped = spelling + } + const spelled = spelledAbsolute(stripped) + if (spelled === undefined) return [] + + const parts = spelled.split('/') + const globAt = parts.flatMap((part, i) => + containsGlobChars(part) ? [i] : [], + ) + const first = globAt[0] + const last = globAt[globAt.length - 1] + if (first === undefined || last === undefined) return [] + const prefix = (i: number): string => parts.slice(0, i + 1).join('/') || '/' + + const isThere = kind === 'deny' ? mayBeThere : isThereUnlinked + let reach = first - 1 + while (reach < last && isThere(prefix(reach + 1))) reach++ + if (reach < first) return [] + + const readings: PathReading[] = [] + if ( + reach === last && + (kind === 'deny' || restIsUnlinked(last, parts.length - 1, prefix)) + ) { + readings.push({ + glob: false, + path: normalizePathForSandbox(stripped, { literal: true }), + }) + } + + // The tail keeps a trailing separator, which makes a pattern match nothing + // (`/x/*/`): stripped, an allow spelled so would match every child. The + // `/**` set aside above goes back on, for the walk's directory form. + const setAside = stripped === spelling ? '' : '/**' + for (let n = globAt.length - 2; n >= 0; n--) { + const at = globAt[n]! + if (at > reach) continue + // As far as the directory's own name goes on: up to the next component + // with glob characters, or to where the disk ends. + const next = globAt[n + 1]! + let end = Math.min(next - 1, reach) + while (end >= at && !isDirectoryFor(kind, prefix(end))) end-- + if (end < at) continue + // The walk resolves what the tail spells before its first pattern + // component: a link there would have an allow list what it points at. + if (kind === 'allow' && !restIsUnlinked(end, next - 1, prefix)) continue + const anchor = normalizePathForSandbox(prefix(end), { literal: true }) + readings.push({ + glob: true, + anchor, + path: anchor + spelled.slice(prefix(end).length) + setAside, + }) + } + return readings +} + +/** + * Whether no component after `from`, up to `to`, is a symbolic link or + * beyond looking at. What is not there yet is neither. + */ +function restIsUnlinked( + from: number, + to: number, + prefix: (i: number) => string, +): boolean { + for (let i = from + 1; i <= to; i++) { + try { + if (fs.lstatSync(prefix(i)).isSymbolicLink()) return false + } catch (err) { + return isAbsenceErrno(err) + } + } + return true +} + +/** Whether a spelling that reads as a pattern is also the name of a path. */ +export function hasNameReading(spelling: string, kind: PathListKind): boolean { + return literalReadings(spelling, kind).some(reading => !reading.glob) +} + +/** + * What a Linux read entry resolves to: what `expandGlob` returns for the + * pattern, then what the entry's other readings add. `name` is the entry as + * a name, in the caller's spelling. + */ +export function withOtherReadings( + spelling: string, + name: string, + kind: PathListKind, + expandGlob: (pattern: string, anchor?: string) => string[], +): string[] { + const expansion = expandGlob(spelling) + const seen = new Set(expansion) + const added: string[] = [] + for (const reading of literalReadings(spelling, kind)) { + const found = reading.glob + ? expandGlob(reading.path, reading.anchor) + : [name] + for (const p of found) { + if (seen.has(p)) continue + seen.add(p) + added.push(p) + } + } + return [...expansion, ...added] +} + +/** + * The read rules `getDefaultWritePaths()` is given: the entries, the + * credential denies, and the name each spelling also is, as a marked path. + */ +export function readRulesOf( + denyRead: readonly FilesystemPathEntry[], + allowRead: readonly FilesystemPathEntry[] | undefined, + credentialDenies: readonly string[], +): { + denyRead: FilesystemPathEntry[] + allowRead: FilesystemPathEntry[] | undefined +} { + const withNames = ( + entries: readonly FilesystemPathEntry[], + kind: PathListKind, + ): FilesystemPathEntry[] => [ + ...entries, + ...spelledOf(entries).flatMap(spelling => + literalReadings(spelling, kind).flatMap( + (reading): FilesystemPathEntry[] => + reading.glob ? [] : [{ path: reading.path, literal: true }], + ), + ), + ] + return { + denyRead: withNames([...denyRead, ...credentialDenies], 'deny'), + allowRead: + allowRead === undefined ? undefined : withNames(allowRead, 'allow'), + } +} + function nonEmptyLists>( lists: T, ): Partial { @@ -162,10 +418,10 @@ export function readNamesOf( /** * The same for a write config; see {@link readNamesOf}. A string of - * `allowOnly` that holds glob characters is kept only when something is - * there under that very spelling. Any other is a pattern and no name: - * resolving a name folds a `..` by the text, so `/home/*\/../.ssh` would - * make `/home/.ssh` writable where nothing is named `*`. + * `allowOnly` that holds glob characters is kept only when it is also the + * name of a path (see {@link literalReadings}). Any other is a pattern and + * no name: resolving a name folds a `..` by the text, so `/home/*\/../.ssh` + * would make `/home/.ssh` writable where nothing is named `*`. */ export function writeNamesOf( config: FsWriteRestrictionConfig | undefined, @@ -176,7 +432,7 @@ export function writeNamesOf( ...rest, allowOnly: writeRootsOf({ allowOnly: (config.allowOnly || []).filter( - p => !containsGlobChars(p) || isThereAsSpelled(p), + p => !containsGlobChars(p) || hasNameReading(p, 'allow'), ), literalAllowOnly, }), diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 7d240ffaa..930b78bed 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1099,7 +1099,9 @@ export const SeccompConfigSchema = z.object({ /** * An inert deny is fail-open, so a deny glob whose trailing separator leaves * it matching nothing is rejected; the same glob as an allow fails closed, - * so the allow lists keep the plain path schema. + * so the allow lists keep the plain path schema. Such an entry may also be + * the name of a path (`/w/[WIP]/keep/`), but only the disk can say so and + * validation does not ask it: the message gives the spelling read both ways. */ function addInertSlashedDenyGlobIssue( value: string, @@ -1121,9 +1123,10 @@ function addInertSlashedDenyGlobIssue( code: z.ZodIssueCode.custom, path, message: - `Deny glob "${value}" ends in a separator, so the pattern can match ` + - `no path. Write "${value.replace(trailingSeparator, '')}", or add a ` + - `"**" segment to match at any depth.`, + `Deny glob "${value}" ends in a separator, so as a pattern it can ` + + `match no path. Write "${value.replace(trailingSeparator, '')}", ` + + `which is also read as the path of that name where it exists, or add ` + + `a "**" segment to match at any depth.`, }) } diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index a883589f1..386dd161d 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1,10 +1,14 @@ import { + hasNameReading, literalReadLists, literalWriteLists, reExposedBy, + readRulesOf, samePathEntries, spelledOf, + withOtherReadings, writeRootsOf, + type PathListKind, } from './path-entries.js' import { createHttpProxyServer } from './http-proxy.js' import { createSocksProxyServer } from './socks-proxy.js' @@ -715,7 +719,9 @@ async function initialize( // filters, nor the ripgrep scan for nested dangerous paths; and it still // reports writes bwrap permits through `--dev`, `--proc` and the tmpfs // over each read-denied directory. Started once, so both lists are fixed - // at the cwd and configuration of this call. + // at the cwd and configuration of this call, and at the disk as it is + // now: an entry with glob characters that becomes the name of a path + // later is applied by the next wrap and stays unknown here. const monitoredWrites = getFsWriteConfig() linuxMonitor = startLinuxSandboxViolationMonitor( sandboxViolationStore.addViolation.bind(sandboxViolationStore), @@ -1235,7 +1241,8 @@ function getCredentialDenyReadPaths( * expansion and no credential masking, because getFsWriteConfig() is a * getter callers reach from permission checks and render paths. A mask entry * that degrades to a deny is a file and cannot cover a directory, so - * leaving those out changes nothing. + * leaving those out changes nothing. An entry with glob characters that is + * also the name of a path counts as that name too. */ function defaultWritePathsUnder({ denyRead, @@ -1246,10 +1253,9 @@ function defaultWritePathsUnder({ allowRead: readonly FilesystemPathEntry[] | undefined credentials: CredentialsConfig | undefined }): string[] { - return getDefaultWritePaths({ - denyRead: [...denyRead, ...getCredentialDenyReadPaths(credentials)], - allowRead, - }) + return getDefaultWritePaths( + readRulesOf(denyRead, allowRead, getCredentialDenyReadPaths(credentials)), + ) } /** @@ -1270,8 +1276,9 @@ function unionDenyReadPaths( * Strip a trailing `/**` from each read-path entry and, on Linux, replace * any remaining glob with what `expandGlob` returns for it (bubblewrap takes * concrete paths only). Other platforms match globs natively. - * Entries the caller marked literal are not in the result: they travel in - * the `literal…` lists. + * On Linux the entry's other readings are added (see `withOtherReadings`); + * elsewhere the backend decides them. Entries the caller marked literal are + * not in the result: they travel in the `literal…` lists. * * `literalPaths` are the ones the library resolved itself (a masked * credential file that degraded to deny) — each names one file on disk, so @@ -1279,8 +1286,9 @@ function unionDenyReadPaths( * pattern, a name holding `[` matches nothing and the deny is lost. */ function resolveReadPathEntries( + kind: PathListKind, paths: readonly FilesystemPathEntry[], - expandGlob: (pattern: string) => string[], + expandGlob: (pattern: string, anchor?: string) => string[], literalPaths: readonly string[] = [], ): string[] { const literal = new Set(literalPaths) @@ -1289,7 +1297,7 @@ function resolveReadPathEntries( return getPlatform() === 'linux' && !literal.has(p) && containsGlobChars(stripped) - ? expandGlob(p) + ? withOtherReadings(p, stripped, kind, expandGlob) : [stripped] }) } @@ -1298,13 +1306,21 @@ function resolveReadPathEntries( * Strip a trailing `/**` and drop what is still a glob on Linux: bwrap needs * real paths. macOS subpath matching is recursive, so the strip is harmless * there and the filter never fires. + * A glob that is also the name of a path that exists is kept, as that name. * Marked entries are not in the result, as in {@link resolveReadPathEntries}. */ -function stripWriteGlobs(paths: readonly FilesystemPathEntry[]): string[] { +function stripWriteGlobs( + kind: PathListKind, + paths: readonly FilesystemPathEntry[], +): string[] { return spelledOf(paths) .map(p => removeTrailingGlobSuffix(p)) .filter(p => { - if (getPlatform() === 'linux' && containsGlobChars(p)) { + if ( + getPlatform() === 'linux' && + containsGlobChars(p) && + !hasNameReading(p, kind) + ) { logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`) return false } @@ -1312,8 +1328,8 @@ function stripWriteGlobs(paths: readonly FilesystemPathEntry[]): string[] { }) } -function expandAllowReadGlob(pattern: string): string[] { - const expanded = expandGlobPattern(pattern) +function expandAllowReadGlob(pattern: string, anchor?: string): string[] { + const expanded = expandGlobPattern(pattern, { anchor }) logForDebugging( `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, ) @@ -1342,6 +1358,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { // allowRead (re-allow within denied regions) is resolved first: the // denyRead glob expansion collapses against it. const allowPaths = resolveReadPathEntries( + 'allow', config.filesystem.allowRead ?? [], expandAllowReadGlob, ) @@ -1352,12 +1369,15 @@ function getFsReadConfig(): FsReadRestrictionConfig { ) const unlistableDenyDirs = new Set() const denyPaths = resolveReadPathEntries( + 'deny', unionDenyReadPaths( spelledOf(config.filesystem.denyRead), credentialRestrictions, ), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + (pattern, anchor) => + expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs, { + anchor, + }), credentialRestrictions.degradeToDenyPaths, ) @@ -1381,8 +1401,8 @@ function getFsWriteConfig(): FsWriteRestrictionConfig { return { allowOnly: ['/'], denyWithinAllow: [] } } - const allowPaths = stripWriteGlobs(config.filesystem.allowWrite) - const denyPaths = stripWriteGlobs(config.filesystem.denyWrite) + const allowPaths = stripWriteGlobs('allow', config.filesystem.allowWrite) + const denyPaths = stripWriteGlobs('deny', config.filesystem.denyWrite) const allowOnly = [ ...defaultWritePathsUnder({ @@ -1697,6 +1717,7 @@ async function wrapWithSandbox( let readConfig: FsReadRestrictionConfig | undefined if (!fsDisabled) { const userAllowWrite = stripWriteGlobs( + 'allow', customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite ?? [], @@ -1715,6 +1736,7 @@ async function wrapWithSandbox( ...userAllowWrite, ], denyWithinAllow: stripWriteGlobs( + 'deny', customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite ?? [], @@ -1731,6 +1753,7 @@ async function wrapWithSandbox( // collapsed against the paths that re-expose contents under a denied // directory (allowRead + allowWrite), so both must be final here. const expandedAllowRead = resolveReadPathEntries( + 'allow', customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], expandAllowReadGlob, ) @@ -1751,14 +1774,17 @@ async function wrapWithSandbox( ) const unlistableDenyDirs = new Set() const expandedDenyRead = resolveReadPathEntries( + 'deny', unionDenyReadPaths( spelledOf( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, ), credentialRestrictions, ), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + (pattern, anchor) => + expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs, { + anchor, + }), credentialRestrictions.degradeToDenyPaths, ) readConfig = { @@ -2400,7 +2426,9 @@ function annotateStderrWithSandboxFailures( * sandboxing is disabled. * * Patterns ending with /** are excluded since they work as subpaths. - * So are entries marked literal. + * So are entries marked literal. An entry that is also the name of a path + * that exists is still returned: what is returned must not depend on what + * is on the disk, which a sandboxed command can change. */ function getLinuxGlobPatternWarnings(): string[] { // Only warn on Linux/WSL (bubblewrap doesn't support globs) diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 713ad9863..ad6b709be 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -368,7 +368,7 @@ export function expandWindowsEnvRefs(p: string): string { * Runs of `/` collapsed to one and `/./` components dropped, leaving the rest * of the spelling (a trailing `/` or `/.`, a `..`) to the caller. POSIX only. */ -function collapseInteriorSpellings(pathPattern: string): string { +export function collapseInteriorSpellings(pathPattern: string): string { return pathPattern.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/)/g, '') } @@ -407,14 +407,14 @@ function warnIfParentRefUnfolded(normalizedPath: string): string { * * `opts.literal` marks a path that names one file or directory rather * than matching several: one the library computed itself, one the caller - * marked literal, or a caller spelling that carried no glob character — + * marked literal, a caller spelling that carried no glob character — * resolving such a spelling can splice in a cwd or home directory whose own - * name does. The glob branches are skipped for it, so a component like - * `a[b` is resolved and later compiled as the name it is. A spelling the - * caller wrote with `*`, `?` or `[…]` in it and did not mark keeps the - * character sniffing: there the brackets are the glob syntax it asked for. - * The interior collapse below is not one of the glob branches: `//` and - * `/./` are dead spellings either way. + * name does — or the name a spelling with glob characters also is (see + * path-entries.ts). The glob branches are skipped for it, so a component + * like `a[b` is resolved and later compiled as the name it is. Without it, + * a spelling with `*`, `?` or `[…]` in it keeps the character sniffing, its + * pattern reading. The interior collapse below is not one of the glob + * branches: `//` and `/./` are dead spellings either way. */ export function normalizePathForSandbox( pathPattern: string, @@ -597,7 +597,9 @@ const HOME_CONVENIENCE_WRITE_DIRS: readonly string[] = [ * match is a symlink to one of these directories is not seen. A glob * `allowRead` entry is not counted as re-opening anything. * - * An entry marked `{ path, literal: true }` is a name whatever it holds. + * An entry marked `{ path, literal: true }` is a name whatever it holds. A + * spelling is read by its characters alone: the name it also is when that + * exists on disk is for the caller to add as a marked entry. */ export function getDefaultWritePaths(readRules?: { denyRead: readonly FilesystemPathEntry[] @@ -1154,10 +1156,10 @@ export function globToRegex(globPattern: string): string { * already does (a deny masks the whole subtree). Only ever widens a deny. * * Takes a whole pattern, so every character in it is glob syntax: right for - * a spelling the caller wrote, which is what {@link readRuleCovers} passes. - * A pattern the library anchored at a directory of its own goes through the - * macOS `denyGlobEntryRegex`, which splices that directory back in escaped - * and calls this for the tail. + * the pattern reading of a spelling the caller wrote, which is what + * {@link readRuleCovers} passes. A pattern anchored at a directory that is a + * name on disk goes through the macOS `denyGlobEntryRegex`, which splices + * that directory back in escaped and calls this for the tail. */ export function denyGlobRegex(normalizedGlob: string): string { // globToRegex() always returns '^…$'. diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index d15e2711f..fe9d63c3c 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -25,6 +25,7 @@ export { isUncPath, } from './sandbox-utils.js' import { certThumbprint, generateCa, validateCaPair } from './mitm-ca.js' +import { literalReadings } from './path-entries.js' import type { SandboxDependencyCheck } from './linux-sandbox-utils.js' import type { FilesystemPathEntry, SrtWinConfig } from './sandbox-config.js' @@ -1690,6 +1691,12 @@ export function uninstallWindowsSandbox( * materializing a placeholder chain on an SMB share. A UNC **glob** * still walks the share (user-trusted). * + * `[` and `]` are characters of a name here, but the walk that expands a + * glob reads them as a character class. So a glob beneath a directory with + * brackets in its name is also expanded beneath that directory taken as the + * name it is (see path-entries.ts). A UNC glob is not: finding the directory + * would probe the share. + * * An entry marked `{ path, literal: true }` is a literal whatever it holds. * One that holds `*` or `?` is skipped: no Win32 name has them, and * `srt-win` refuses the characters outright. @@ -1721,7 +1728,19 @@ export function expandWindowsFsPaths( continue } const candidates = isGlob - ? expandGlobPattern(norm, { caseInsensitive: true }) + ? [ + ...expandGlobPattern(norm, { caseInsensitive: true }), + ...literalReadings(raw, opts?.mode === 'deny' ? 'deny' : 'allow', { + isPattern: containsGlobCharsWin, + }).flatMap(reading => + reading.glob + ? expandGlobPattern(reading.path, { + caseInsensitive: true, + anchor: reading.anchor, + }) + : [], + ), + ] : [norm] for (const c of candidates) { const st = fs.statSync(c, { throwIfNoEntry: false }) diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 3a9eb1532..07927748a 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -433,6 +433,27 @@ describe('Config Validation', () => { } }) + test.if(!isWindows)( + 'points a deny inside a folder named like a pattern at the spelling read both ways', + () => { + // Whether the folder exists is for the disk to say, which validation + // does not ask, so the separator is refused here as in any pattern. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + filesystem: { + ...base.filesystem, + denyWrite: ['/work/[WIP] project/keep/'], + }, + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toContain( + 'Write "/work/[WIP] project/keep", which is also read as the path of that name', + ) + } + }, + ) + test('accepts the slash-free glob spellings it points at', () => { const result = SandboxRuntimeConfigSchema.safeParse({ ...base, diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 7d9cf3298..c17be4ae1 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -15,6 +15,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' +import { literalReadings } from '../../src/sandbox/path-entries.js' import type { FilesystemPathEntry } from '../../src/sandbox/sandbox-config.js' import { expandGlobPattern, @@ -946,18 +947,28 @@ describe('expandWindowsFsPaths literal branch', () => { // ============================================================================ /** - * `[` and `]` are characters of a name on Windows: a path that holds them - * and no `*` or `?` is a literal, marked or not. Pinned here on every host; - * the spellings only Windows has are in the suite below. + * `[` and `]` are characters of a name on Windows, but the walk that expands + * a pattern reads them as a character class: beneath such a directory it + * finds nothing on its own, and the deny or grant is lost. Pinned here on + * every host; the spellings only Windows has are in the suite below. */ describe('expandWindowsFsPaths beneath a directory named [WIP] project', () => { let root: string let project: string + let envFiles: string[] beforeAll(() => { root = realPath(mkdtempSync(join(tmpdir(), 'win-literal-'))) project = join(root, '[WIP] project') mkdirSync(join(project, 'keep'), { recursive: true }) + mkdirSync(join(project, 'sub', 'deep'), { recursive: true }) + envFiles = [ + join(project, '.env'), + join(project, 'sub', '.env'), + join(project, 'sub', 'deep', '.env'), + ] + for (const file of envFiles) writeFileSync(file, '') + writeFileSync(join(project, 'sub', 'readme'), '') }) afterAll(() => { @@ -976,6 +987,51 @@ describe('expandWindowsFsPaths beneath a directory named [WIP] project', () => { expect(expandWindowsFsPaths([notYet], { mode: 'grant' })).toEqual([]) }) + it.each(['deny', 'grant'] as const)( + 'expands a pattern beneath it (%s)', + mode => { + expect( + expandWindowsFsPaths([`${project}/**/.env`], { mode }).sort(), + ).toEqual([...envFiles].sort()) + expect( + expandWindowsFsPaths([`${project}/sub/*`], { mode }).sort(), + ).toEqual( + [ + join(project, 'sub', '.env'), + join(project, 'sub', 'deep'), + join(project, 'sub', 'readme'), + ].sort(), + ) + }, + ) + + it.each(['deny', 'grant'] as const)( + 'expands a /** beneath it, the one wildcard the entry has (%s)', + mode => { + // Without `*` or `?` the rest is no pattern to Windows, and the walk + // still reads its brackets as a class. + expect( + expandWindowsFsPaths([`${project}/sub/**`], { mode }).sort(), + ).toEqual( + [ + join(project, 'sub', '.env'), + join(project, 'sub', 'deep'), + join(project, 'sub', 'deep', '.env'), + join(project, 'sub', 'readme'), + ].sort(), + ) + expect(expandWindowsFsPaths([`${project}/**`], { mode })).toHaveLength(7) + }, + ) + + it('lists a path once that more than one reading finds', () => { + const out = expandWindowsFsPaths( + [`${project}/**/.env`, `${project}/*/.env`, join(project, 'sub', '.env')], + { mode: 'deny' }, + ) + expect(out.sort()).toEqual([...envFiles].sort()) + }) + it('takes a marked entry for a literal, whatever it holds', () => { const kept = join(project, 'keep') const marked: FilesystemPathEntry = { path: kept, literal: true } @@ -1009,6 +1065,38 @@ describe('expandWindowsFsPaths beneath a directory named [WIP] project', () => { ).toThrow(TypeError) } }) + + it.if(!isWindows)( + 'does not expand a grant beneath a link that has the name the pattern spells', + () => { + const work = join(root, 'work') + mkdirSync(join(root, 'vault', 'pub'), { recursive: true }) + mkdirSync(work, { recursive: true }) + writeFileSync(join(root, 'vault', 'pub', 'key'), '') + symlinkSync(join(root, 'vault'), join(work, '[ab]')) + const pattern = `${work}/[ab]/pub/*` + expect(expandWindowsFsPaths([pattern], { mode: 'grant' })).toEqual([]) + expect(expandWindowsFsPaths([pattern], { mode: 'deny' })).toEqual([ + join(work, '[ab]', 'pub', 'key'), + ]) + }, + ) + + it.if(!isWindows)( + 'does not expand a grant beneath a link that lies between such a directory and the first wildcard', + () => { + const work = join(root, 'work-with-link-inside') + const vault = join(root, 'vault-behind-link') + mkdirSync(join(vault, 'deep', 'pub'), { recursive: true }) + mkdirSync(join(work, '[ab]'), { recursive: true }) + symlinkSync(vault, join(work, '[ab]', 'mid')) + const pattern = `${work}/[ab]/mid/*/pub` + expect(expandWindowsFsPaths([pattern], { mode: 'grant' })).toEqual([]) + expect(expandWindowsFsPaths([pattern], { mode: 'deny' })).toEqual([ + join(work, '[ab]', 'mid', 'deep', 'pub'), + ]) + }, + ) }) describe.if(isWindows)( @@ -1020,13 +1108,50 @@ describe.if(isWindows)( beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'win-literal-')) project = join(root, '[WIP] project') - mkdirSync(join(project, 'sub'), { recursive: true }) + mkdirSync(join(project, 'sub', 'deep'), { recursive: true }) + for (const dir of ['', 'sub', join('sub', 'deep')]) { + writeFileSync(join(project, dir, '.env'), '') + } + writeFileSync(join(project, 'sub', 'readme'), '') }) afterAll(() => { rmSync(root, { recursive: true, force: true }) }) + /** Every result names an existing `.env`, and there are three of them. */ + function expectTheEnvFiles(out: string[]): void { + expect(out.map(p => basename(p))).toEqual(['.env', '.env', '.env']) + expect(new Set(out.map(p => p.toLowerCase())).size).toBe(3) + for (const p of out) expect(existsSync(p)).toBe(true) + } + + it('expands a pattern spelled with a drive letter and backslashes', () => { + expect(project).toMatch(/^[A-Za-z]:\\/) + expectTheEnvFiles( + expandWindowsFsPaths([`${project}\\**\\.env`], { mode: 'deny' }), + ) + }) + + it('expands one spelled with forward slashes and a lower-case drive letter', () => { + const spelled = + project[0]!.toLowerCase() + project.slice(1).replace(/\\/g, '/') + expectTheEnvFiles( + expandWindowsFsPaths([`${spelled}/**/.env`], { mode: 'deny' }), + ) + expectTheEnvFiles( + expandWindowsFsPaths([`${spelled}/**/.env`], { mode: 'grant' }), + ) + }) + + it('expands one spelled with mixed separators and the extended-length prefix', () => { + expectTheEnvFiles( + expandWindowsFsPaths([`\\\\?\\${project}/sub/..\\**/.env`], { + mode: 'deny', + }), + ) + }) + it('takes a marked path spelled with backslashes, and skips one with a wildcard', () => { const out = expandWindowsFsPaths( [ @@ -1037,6 +1162,22 @@ describe.if(isWindows)( ) expect(out.map(p => basename(p))).toEqual(['sub']) }) + + it('gives a UNC pattern no reading that would probe the share', () => { + // Deciding on a reading looks at the disk, and looking at a UNC path + // is a request to the host it names. + for (const unc of [ + '\\\\srt-no-such-host\\share\\[WIP] project\\**\\.env', + '\\\\?\\UNC\\srt-no-such-host\\share\\[WIP] project\\*.env', + '//srt-no-such-host/share/[WIP] project/*.env', + ]) { + for (const kind of ['deny', 'allow'] as const) { + expect( + literalReadings(unc, kind, { isPattern: containsGlobCharsWin }), + ).toEqual([]) + } + } + }) }, ) diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index cce9c5364..ec97b716e 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -10,12 +10,15 @@ import { } from 'bun:test' import { spawnSync } from 'node:child_process' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { connect } from 'node:net' @@ -30,6 +33,8 @@ import { } from '../../src/sandbox/linux-violation-monitor.js' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' import { + literalReadings, + type PathReading, readNamesOf, samePathEntries, splitPathEntries, @@ -37,7 +42,14 @@ import { writeRootsOf, } from '../../src/sandbox/path-entries.js' import type { FilesystemPathEntry } from '../../src/sandbox/sandbox-config.js' -import { getDefaultWritePaths } from '../../src/sandbox/sandbox-utils.js' +import { + containsGlobCharsWin, + denyGlobRegex, + expandGlobPattern, + getDefaultWritePaths, + globToRegex, + normalizePathForSandbox, +} from '../../src/sandbox/sandbox-utils.js' import { loadConfig, loadConfigFromString, @@ -46,9 +58,9 @@ import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { isLinux, isWindows } from '../helpers/platform.js' /** - * Entries marked `{ path, literal: true }`, and what the Linux wrapper does - * with a name that holds `*`, `?`, `[` or `]`: see - * src/sandbox/path-entries.ts. + * Entries inside a directory with `*`, `?`, `[` or `]` in its name, which + * match nothing when read as a pattern alone, and entries marked + * `{ path, literal: true }`: see src/sandbox/path-entries.ts. * * The Linux suites run real commands under bubblewrap. The macOS suites read * the generated profile on any POSIX host; none runs it under sandbox-exec. @@ -69,7 +81,18 @@ function q(p: string): string { return `'${p.replace(/'/g, `'\\''`)}'` } +function escapedForRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** The filter a spelling compiles to when read as a pattern. */ +function patternFilter(spelling: string, kind: 'allow' | 'deny'): string { + const compile = kind === 'deny' ? denyGlobRegex : globToRegex + return `(regex ${JSON.stringify(compile(normalizePathForSandbox(spelling)))})` +} + const subpath = (p: string): string => `(subpath ${JSON.stringify(p)})` +const literalFilter = (p: string): string => `(literal ${JSON.stringify(p)})` function emittedRegexes(profile: string): RegExp[] { return [...profile.matchAll(/\(regex ("(?:[^"\\]|\\.)*")\)/g)].map( @@ -77,6 +100,12 @@ function emittedRegexes(profile: string): RegExp[] { ) } +function emittedSubpaths(profile: string): string[] { + return [...profile.matchAll(/\(subpath ("(?:[^"\\]|\\.)*")\)/g)].map( + match => JSON.parse(match[1]!) as string, + ) +} + /** The rule of `profile` that starts with `head`, up to its closing line. */ function ruleOf(profile: string, head: string): string { const start = profile.indexOf(head) @@ -85,6 +114,18 @@ function ruleOf(profile: string, head: string): string { return profile.slice(start, end) } +function macProfile( + readConfig: { denyOnly: string[]; allowWithinDeny?: string[] } | undefined, + writeConfig: { allowOnly: string[]; denyWithinAllow: string[] } | undefined, +): string { + return wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig, + writeConfig, + }) +} + const noNetwork = { allowedDomains: [], deniedDomains: [] } type FilesystemLists = { @@ -146,6 +187,388 @@ async function sandboxed( return { stdout: result.stdout, stderr: result.stderr } } +// ============================================================================ +// Deciding the reading +// ============================================================================ + +describe.if(!isWindows)('literalReadings', () => { + let root: string + let project: string + const savedCwd = process.cwd() + + beforeAll(() => { + root = freshRoot() + project = join(root, PROJECT) + mkdirSync(join(project, 'keep'), { recursive: true }) + mkdirSync(join(project, 'src'), { recursive: true }) + mkdirSync(join(project, '[ab]'), { recursive: true }) + writeFileSync(join(project, 'file'), '') + mkdirSync(join(root, 'plain', '[ab]'), { recursive: true }) + mkdirSync(join(root, 'plain', 'a'), { recursive: true }) + mkdirSync(join(root, 'plain', 'b'), { recursive: true }) + mkdirSync(join(root, 'star'), { recursive: true }) + writeFileSync(join(root, 'star', '*.env'), '') + writeFileSync(join(root, 'star', 'a.env'), '') + for (const name of [ + 'open[bracket', + 'close]bracket', + 'build*', + 'notes (draft?)', + 'curly{a,b}', + ]) { + mkdirSync(join(root, name, 'out'), { recursive: true }) + } + mkdirSync(join(root, 'target', 'keep'), { recursive: true }) + symlinkSync(join(root, 'target'), join(root, 'li[n]k')) + symlinkSync(join(root, 'nowhere'), join(root, 'dang[l]ing')) + mkdirSync(join(project, 'real'), { recursive: true }) + symlinkSync(join(root, 'target'), join(project, 'linked')) + }) + + afterAll(() => { + process.chdir(savedCwd) + rmSync(root, { recursive: true, force: true }) + }) + + afterEach(() => { + process.chdir(savedCwd) + }) + + const name = (path: string): PathReading => ({ glob: false, path }) + const beneath = (anchor: string, tail: string): PathReading => ({ + glob: true, + anchor, + path: anchor + tail, + }) + const bothKinds = (spelling: string) => ({ + deny: literalReadings(spelling, 'deny'), + allow: literalReadings(spelling, 'allow'), + }) + const forBoth = (readings: PathReading[]) => ({ + deny: readings, + allow: readings, + }) + + it('reads a path inside the folder as the name it spells', () => { + expect(bothKinds(project)).toEqual(forBoth([name(project)])) + expect(bothKinds(join(project, 'keep'))).toEqual( + forBoth([name(join(project, 'keep'))]), + ) + }) + + it('does not ask for what follows the glob characters to exist', () => { + const notYet = join(project, 'keep', 'not-there-yet') + expect(bothKinds(notYet)).toEqual(forBoth([name(notYet)])) + }) + + it('reads a pattern beneath the folder as that pattern beneath its name', () => { + expect(bothKinds(`${project}/**/.env`)).toEqual( + forBoth([beneath(project, '/**/.env')]), + ) + expect(bothKinds(`${project}/*.pem`)).toEqual( + forBoth([beneath(project, '/*.pem')]), + ) + }) + + it('takes the longest run of directories that exists as the anchor', () => { + expect(bothKinds(`${project}/src/**/.env`)).toEqual( + forBoth([beneath(join(project, 'src'), '/**/.env')]), + ) + expect(bothKinds(`${project}/absent/**/.env`)).toEqual( + forBoth([beneath(project, '/absent/**/.env')]), + ) + }) + + it('reads a wildcard as a name only when a file has that very name', () => { + expect(bothKinds(join(root, 'star', '*.env'))).toEqual( + forBoth([name(join(root, 'star', '*.env'))]), + ) + expect(bothKinds(join(root, 'plain', '*.env'))).toEqual(forBoth([])) + }) + + it('sets a trailing /** aside for the decision and gives it back to a pattern', () => { + expect(bothKinds(`${project}/**`)).toEqual(forBoth([name(project)])) + expect(bothKinds(`${project}/keep/**`)).toEqual( + forBoth([name(join(project, 'keep'))]), + ) + expect(bothKinds(`${project}/**/build/**`)).toEqual( + forBoth([beneath(project, '/**/build/**')]), + ) + }) + + it('keeps the /** on where brackets alone make no pattern', () => { + // As Windows reads an entry: only `*` and `?` make one a pattern, and + // the walk reads brackets as a class all the same. + const asOnWindows = { isPattern: containsGlobCharsWin } + for (const kind of ['deny', 'allow'] as const) { + expect(literalReadings(`${project}/**`, kind, asOnWindows)).toEqual([ + beneath(project, '/**'), + ]) + expect(literalReadings(`${project}/keep/**`, kind, asOnWindows)).toEqual([ + beneath(join(project, 'keep'), '/**'), + ]) + expect(literalReadings(`${project}/keep`, kind, asOnWindows)).toEqual([]) + expect(literalReadings(`${root}/plain/**`, kind, asOnWindows)).toEqual([]) + } + }) + + it('strips a trailing separator from a name and keeps it in a pattern', () => { + expect(bothKinds(`${project}/keep/`)).toEqual( + forBoth([name(join(project, 'keep'))]), + ) + expect(bothKinds(`${project}/*/`)).toEqual( + forBoth([beneath(project, '/*/')]), + ) + }) + + it('folds a parent reference in a name', () => { + expect(bothKinds(`${project}/keep/../src`)).toEqual( + forBoth([name(join(project, 'src'))]), + ) + }) + + it('leaves a relative name inside a bracketed cwd alone', () => { + process.chdir(project) + // No glob character in what the caller wrote: it is a name already. + expect(bothKinds('./keep')).toEqual(forBoth([])) + expect(bothKinds('keep')).toEqual(forBoth([])) + }) + + it('reads a relative pattern inside a bracketed cwd beneath the cwd', () => { + process.chdir(project) + expect(bothKinds('**/.env')).toEqual( + forBoth([beneath(project, '/**/.env')]), + ) + expect(bothKinds('./*.env')).toEqual(forBoth([beneath(project, '/*.env')])) + expect(bothKinds('src/**/.env')).toEqual( + forBoth([beneath(join(project, 'src'), '/**/.env')]), + ) + }) + + it('reads a ~ spelling beneath a home directory with brackets in its name', () => { + const home = join(root, PROJECT) + const script = ` + const { literalReadings } = await import(${JSON.stringify( + join(import.meta.dir, '../../src/sandbox/path-entries.ts'), + )}) + console.log(JSON.stringify({ + name: literalReadings('~/keep', 'deny'), + pattern: literalReadings('~/**/.env', 'deny'), + allow: literalReadings('~/src/*.ts', 'allow'), + }))` + const result = spawnSync(process.execPath, ['-e', script], { + env: { ...process.env, HOME: home }, + encoding: 'utf8', + timeout: WRAP_TIMEOUT_MS, + }) + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '')).toEqual({ + name: [], + pattern: [beneath(home, '/**/.env')], + allow: [beneath(join(home, 'src'), '/*.ts')], + }) + }) + + it.each(['open[bracket', 'close]bracket', 'build*', 'notes (draft?)'])( + 'reads a path inside a directory named %p as a name', + dir => { + const inside = join(root, dir, 'out') + expect(bothKinds(inside)).toEqual(forBoth([name(inside)])) + }, + ) + + it('adds nothing for braces, which are no glob characters', () => { + expect(bothKinds(join(root, 'curly{a,b}', 'out'))).toEqual(forBoth([])) + }) + + it('adds nothing for an entry without glob characters', () => { + expect(bothKinds(join(root, 'plain', 'a'))).toEqual(forBoth([])) + expect(bothKinds(join(root, 'absent'))).toEqual(forBoth([])) + }) + + it('adds nothing for a pattern none of which exists as a name', () => { + expect(bothKinds(join(root, 'plain', '**', '.env'))).toEqual(forBoth([])) + expect(bothKinds(join(root, '[nope]', 'x'))).toEqual(forBoth([])) + expect(bothKinds(join(root, 'plain', '[xy]', 'key'))).toEqual(forBoth([])) + }) + + it('reads [ab] as the class and as the directory of that name', () => { + // The class is the pattern reading every entry has; this adds the name. + const inClass = join(root, 'plain', '[ab]', 'secret') + expect(bothKinds(inClass)).toEqual(forBoth([name(inClass)])) + }) + + it('gives one reading for each name the entry may hold, the longest first', () => { + expect(bothKinds(`${project}/[ab]/secret`)).toEqual( + forBoth([ + name(join(project, '[ab]', 'secret')), + beneath(project, '/[ab]/secret'), + ]), + ) + expect(bothKinds(`${project}/[ab]/**/.env`)).toEqual( + forBoth([ + beneath(join(project, '[ab]'), '/**/.env'), + beneath(project, '/[ab]/**/.env'), + ]), + ) + }) + + it('never turns one reading into another when a directory appears', () => { + const entry = `${project}/[cd]/**/.env` + const before = literalReadings(entry, 'deny') + expect(before).toEqual([beneath(project, '/[cd]/**/.env')]) + mkdirSync(join(project, '[cd]')) + try { + const after = literalReadings(entry, 'deny') + expect(after).toEqual([ + beneath(join(project, '[cd]'), '/**/.env'), + ...before, + ]) + } finally { + rmSync(join(project, '[cd]'), { recursive: true, force: true }) + } + }) + + it('anchors a pattern at the nearest directory when a file is in the way', () => { + // The walk then finds the file where it looked for a directory. + expect(literalReadings(`${project}/file/**/x`, 'deny')).toEqual([ + beneath(project, '/file/**/x'), + ]) + }) + + describe('a prefix that is a symbolic link', () => { + it('counts for a deny, which can only deny more', () => { + const inside = join(root, 'li[n]k', 'keep') + expect(literalReadings(inside, 'deny')).toEqual([name(inside)]) + expect(literalReadings(`${root}/li[n]k/**/x`, 'deny')).toEqual([ + beneath(join(root, 'li[n]k'), '/**/x'), + ]) + }) + + it('does not count for an allow, which would open what it points at', () => { + expect(literalReadings(join(root, 'li[n]k', 'keep'), 'allow')).toEqual([]) + expect(literalReadings(join(root, 'li[n]k'), 'allow')).toEqual([]) + expect(literalReadings(`${root}/li[n]k/**/x`, 'allow')).toEqual([]) + }) + + it('counts for a deny when it dangles, as a name and not as a directory', () => { + const dangling = join(root, 'dang[l]ing') + expect(literalReadings(dangling, 'deny')).toEqual([name(dangling)]) + expect(literalReadings(`${dangling}/**/x`, 'deny')).toEqual([]) + expect(literalReadings(dangling, 'allow')).toEqual([]) + expect(literalReadings(`${dangling}/**/x`, 'allow')).toEqual([]) + }) + + it('ends the name of an allow wherever it lies after the glob characters', () => { + const through = join(project, 'linked', 'keep') + expect(literalReadings(through, 'deny')).toEqual([name(through)]) + expect(literalReadings(through, 'allow')).toEqual([]) + expect(literalReadings(join(project, 'real', 'keep'), 'allow')).toEqual([ + name(join(project, 'real', 'keep')), + ]) + }) + + it('ends the pattern of an allow when it lies before the first pattern component', () => { + // The walk would start at `linked`, resolved, and list what it points at. + for (const tail of ['/linked/*/x', '/linked/*', '/real/../linked/**/x']) { + expect(literalReadings(project + tail, 'allow')).toEqual([]) + } + expect(literalReadings(`${project}/linked/*/x`, 'deny')).toEqual([ + beneath(join(project, 'linked'), '/*/x'), + ]) + // Not there yet is no link, and neither is a directory. + expect(literalReadings(`${project}/absent/sub/*`, 'allow')).toEqual([ + beneath(project, '/absent/sub/*'), + ]) + expect(literalReadings(`${project}/real/*/x`, 'allow')).toEqual([ + beneath(join(project, 'real'), '/*/x'), + ]) + // From the first pattern component on, the walk meets the link itself + // and does not list it. + expect(literalReadings(`${project}/*/linked/x`, 'allow')).toEqual([ + beneath(project, '/*/linked/x'), + ]) + }) + }) + + it.skipIf(process.getuid?.() === 0)( + 'takes a path it cannot look at as present for a deny and absent for an allow', + () => { + const locked = join(root, 'locked') + const inside = join(locked, '[x]', 'secret') + mkdirSync(join(locked, '[x]'), { recursive: true }) + chmodSync(locked, 0o000) + try { + expect(literalReadings(inside, 'deny')).toEqual([name(inside)]) + expect(literalReadings(inside, 'allow')).toEqual([]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + describe('a pattern beneath the folder', () => { + let ordinary: string + + beforeAll(() => { + ordinary = join(root, 'ordinary') + for (const base of [project, ordinary]) { + mkdirSync(join(base, 'deep', 'er'), { recursive: true }) + for (const file of ['a]x', 'deep/a]x', 'deep/er/b]x', 'deep/.env']) { + writeFileSync(join(base, file), '') + } + } + }) + + const found = (base: string, tail: string, kind: 'allow' | 'deny') => + literalReadings(base + tail, kind) + .flatMap(reading => + reading.glob + ? expandGlobPattern(reading.path, { anchor: reading.anchor }) + : [], + ) + .map(match => match.slice(base.length)) + .sort() + + it.each(['/**/.env', '/deep/*', '/**/er/*', '/*/*]x'])( + 'finds for %p what the same pattern finds beneath an ordinary folder', + tail => { + const expected = expandGlobPattern(ordinary + tail) + .map(match => match.slice(ordinary.length)) + .sort() + expect(expected.length).toBeGreaterThan(0) + expect(found(project, tail, 'deny')).toEqual(expected) + expect(found(project, tail, 'allow')).toEqual(expected) + }, + ) + + it.each([ + ['/**/[a*]x', ['/a]x', '/deep/a]x']], + ['/[a*]x', ['/a]x']], + ['/deep/[a*]x', ['/deep/a]x']], + ])( + 'matches %p, which cannot be split, by its spelling from the anchor on', + (tail, expected) => { + // A wildcard inside a bracket expression: the walk matches such a + // pattern against whole paths, which beneath an anchor start at it. + expect( + expandGlobPattern(ordinary + tail) + .map(match => match.slice(ordinary.length)) + .sort(), + ).toEqual(expected) + expect(found(project, tail, 'deny')).toEqual(expected) + expect(found(project, tail, 'allow')).toEqual(expected) + }, + ) + + it('keeps a trailing separator, with which a pattern matches nothing', () => { + expect(expandGlobPattern(`${ordinary}/*/`)).toEqual([]) + expect(found(project, '/*/', 'allow')).toEqual([]) + expect(found(project, '/*/', 'deny')).toEqual([]) + }) + }) +}) + describe('path entries as configured', () => { it('tells spellings from marked paths', () => { expect( @@ -379,8 +802,18 @@ describe.if(isLinux)( root = freshRoot() project = join(root, PROJECT) mkdirSync(join(project, 'keep'), { recursive: true }) + mkdirSync(join(project, 'real'), { recursive: true }) + mkdirSync(join(project, 'secrets', 'public'), { recursive: true }) + mkdirSync(join(project, 'sub', 'deep'), { recursive: true }) writeFileSync(join(project, 'keep', 'file'), 'original\n') + writeFileSync(join(project, 'real', 'file'), 'original\n') writeFileSync(join(project, 'secret.txt'), 'SECRET-FILE\n') + writeFileSync(join(project, 'secrets', 'key'), 'SECRET-DIR\n') + writeFileSync(join(project, 'secrets', 'public', 'ok'), 'PUBLIC\n') + writeFileSync(join(project, '.env'), 'ENV-TOP\n') + writeFileSync(join(project, 'sub', '.env'), 'ENV-SUB\n') + writeFileSync(join(project, 'sub', 'deep', '.env'), 'ENV-DEEP\n') + writeFileSync(join(project, 'sub', 'readme'), 'README\n') // Outside every allowed path, so the scan for dangerous files in the // working directory adds no binds of its own. process.chdir(root) @@ -393,6 +826,198 @@ describe.if(isLinux)( rmSync(root, { recursive: true, force: true }) }) + it.skipIf(!CAN_RUN)( + 'makes the project writable when it is allowed', + async () => { + const { stdout } = await sandboxed( + { allowWrite: [project] }, + `echo hi > ${q(join(project, 'new'))} && echo WROTE`, + { cwd: root }, + ) + expect(stdout).toContain('WROTE') + expect(readFileSync(join(project, 'new'), 'utf8')).toBe('hi\n') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'enforces a denyWrite inside it', + async () => { + const kept = join(project, 'keep', 'file') + const { stdout } = await sandboxed( + { allowWrite: [root], denyWrite: [join(project, 'keep')] }, + `echo tampered > ${q(kept)} && echo WROTE; ` + + `echo fine > ${q(join(project, 'sub', 'other'))} && echo SIBLING`, + { cwd: root }, + ) + expect(stdout).not.toContain('WROTE') + expect(stdout).toContain('SIBLING') + expect(readFileSync(kept, 'utf8')).toBe('original\n') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'hides a denyRead file and a denyRead directory inside it', + async () => { + const { stdout } = await sandboxed( + { denyRead: [join(project, 'secret.txt'), join(project, 'secrets')] }, + `cat ${q(join(project, 'secret.txt'))} ${q(join(project, 'secrets', 'key'))}; ` + + `cat ${q(join(project, 'sub', 'readme'))}`, + { cwd: root }, + ) + expect(stdout).not.toContain('SECRET-FILE') + expect(stdout).not.toContain('SECRET-DIR') + expect(stdout).toContain('README') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'opens an allowRead carve-out inside a denied directory', + async () => { + const { stdout } = await sandboxed( + { + denyRead: [join(project, 'secrets')], + allowRead: [join(project, 'secrets', 'public')], + }, + `cat ${q(join(project, 'secrets', 'key'))} ${q(join(project, 'secrets', 'public', 'ok'))}`, + { cwd: root }, + ) + expect(stdout).not.toContain('SECRET-DIR') + expect(stdout).toContain('PUBLIC') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'opens what an allowRead pattern beneath it matches', + async () => { + const { stdout } = await sandboxed( + { + denyRead: [join(project, 'secrets')], + allowRead: [`${project}/secrets/pub*`], + }, + `cat ${q(join(project, 'secrets', 'key'))} ${q(join(project, 'secrets', 'public', 'ok'))}`, + { cwd: root }, + ) + expect(stdout).not.toContain('SECRET-DIR') + expect(stdout).toContain('PUBLIC') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN).each(['build*', 'notes (draft?)'])( + 'opens a carve-out inside a folder named %p, whose name a pattern matches as itself', + async name => { + // The deny is spelled without a `/**`: with one it is the pattern as + // well, under which every entry beneath keeps a mask of its own. + const folder = join(root, name) + mkdirSync(join(folder, 'secrets', 'public'), { recursive: true }) + writeFileSync(join(folder, 'secrets', 'key'), 'SECRET-DIR\n') + writeFileSync(join(folder, 'secrets', 'public', 'ok'), 'PUBLIC\n') + const { stdout } = await sandboxed( + { + denyRead: [join(folder, 'secrets')], + allowRead: [join(folder, 'secrets', 'public')], + }, + `cat ${q(join(folder, 'secrets', 'key'))} ${q(join(folder, 'secrets', 'public', 'ok'))}`, + { cwd: root }, + ) + expect(stdout).not.toContain('SECRET-DIR') + expect(stdout).toContain('PUBLIC') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'masks .env at every depth for a recursive deny beneath it', + async () => { + const { stdout } = await sandboxed( + { denyRead: [`${project}/**/.env`] }, + `cat ${q(join(project, '.env'))} ${q(join(project, 'sub', '.env'))} ${q(join(project, 'sub', 'deep', '.env'))}; ` + + `cat ${q(join(project, 'sub', 'readme'))}`, + { cwd: root }, + ) + expect(stdout).not.toMatch(/ENV-(TOP|SUB|DEEP)/) + expect(stdout).toContain('README') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'masks the same for a relative recursive deny when it is the cwd', + async () => { + process.chdir(project) + const { stdout } = await sandboxed( + { denyRead: ['**/.env'] }, + `cat .env sub/.env sub/deep/.env; cat sub/readme`, + { cwd: project }, + ) + expect(stdout).not.toMatch(/ENV-(TOP|SUB|DEEP)/) + expect(stdout).toContain('README') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'applies a deny beneath an allow that is spelled through a parent reference', + async () => { + // The allow is recorded where it resolves to: left as spelled, the + // deny beneath it would be judged outside every allowed path. + const denied = join(project, 'real', 'file') + const { stdout } = await sandboxed( + { + allowWrite: [`${project}/keep/../real`], + denyWrite: [denied], + }, + `echo tampered > ${q(denied)} && echo WROTE; ` + + `echo fine > ${q(join(project, 'real', 'other'))} && echo SIBLING`, + { cwd: root }, + ) + expect(stdout).not.toContain('WROTE') + expect(stdout).toContain('SIBLING') + expect(readFileSync(denied, 'utf8')).toBe('original\n') + }, + WRAP_TIMEOUT_MS, + ) + + it('keeps the entries in the write config and resolves the read ones', async () => { + await initialize({ + denyRead: [join(project, 'secret.txt'), `${project}/**/.env`], + allowRead: [join(project, 'secrets', 'public')], + allowWrite: [project], + denyWrite: [join(project, 'keep'), `${project}/**/*.lock`], + }) + const write = SandboxManager.getFsWriteConfig() + expect(write.allowOnly).toContain(project) + // Write lists take no pattern on Linux, beneath this folder or any other. + expect(write.denyWithinAllow).toEqual([join(project, 'keep')]) + const read = SandboxManager.getFsReadConfig() + expect(read.denyOnly).toEqual([ + join(project, 'secret.txt'), + join(project, '.env'), + join(project, 'sub', '.env'), + join(project, 'sub', 'deep', '.env'), + ]) + expect(read.allowWithinDeny).toEqual([join(project, 'secrets', 'public')]) + }) + + it('still reports every write entry whose pattern is not applied', async () => { + // Whether an entry is also a name depends on the disk, which the + // command can change; what is reported does not. + await initialize({ + allowWrite: [project, { path: join(root, 'marked*'), literal: true }], + denyWrite: [join(project, 'keep'), `${project}/**/*.lock`], + denyRead: [`${project}/**/.env`, { path: '/*/x', literal: true }], + }) + expect(SandboxManager.getLinuxGlobPatternWarnings()).toEqual([ + project, + join(project, 'keep'), + `${project}/**/*.lock`, + ]) + }) + it.skipIf(!CAN_RUN)( 'takes the strings a direct caller hands the wrapper as names', async () => { @@ -456,9 +1081,784 @@ describe.if(isLinux)( }, WRAP_TIMEOUT_MS, ) + + it.skipIf(!CAN_RUN)( + 'makes a path writable that is spelled through a folder that is not there, as beneath any folder', + async () => { + // The project's own name holds the glob characters and exists, so the + // string is a name, and a name is resolved, parent reference and all. + const { stdout } = await sandboxed( + { allowWrite: [`${project}/absent/../real`] }, + `echo hi > ${q(join(project, 'real', 'new'))} && echo WROTE; ` + + `echo hi > ${q(join(project, 'keep', 'new'))} && echo ESCAPED`, + { cwd: root }, + ) + expect(stdout).toContain('WROTE') + expect(stdout).not.toContain('ESCAPED') + }, + WRAP_TIMEOUT_MS, + ) }, ) +/** + * What exists is looked at on every wrap, and an earlier command may have + * put it there: nothing it creates may switch a deny off, and a link with + * the name an entry spells is not the path an allow takes. + */ +describe.if(isLinux)( + 'Linux: what a command can change before the next wrap', + () => { + const CAN_RUN = bwrapCanNamespace() + const savedCwd = process.cwd() + let root: string + let work: string + let vault: string + + beforeEach(() => { + root = freshRoot() + work = join(root, 'work') + vault = join(root, 'vault') + mkdirSync(join(work, 'proj', 'pub'), { recursive: true }) + mkdirSync(join(vault, 'pub'), { recursive: true }) + mkdirSync(join(vault, 'deep', 'pub'), { recursive: true }) + writeFileSync(join(work, 'proj', 'pub', 'ok'), 'PROJECT-PUBLIC\n') + writeFileSync(join(vault, 'pub', 'key'), 'VAULT-SECRET\n') + writeFileSync(join(vault, 'deep', 'pub', 'key'), 'VAULT-DEEP-SECRET\n') + process.chdir(root) + }) + + afterEach(async () => { + process.chdir(savedCwd) + SandboxManager.cleanupAfterCommand() + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + }) + + const readVault = (): string => + `cat ${q(join(vault, 'pub', 'key'))} ${q(join(vault, 'deep', 'pub', 'key'))} 2>/dev/null; echo END` + + it.skipIf(!CAN_RUN).each([ + ['work/*/pub', '*'], + ['work/*/**/pub', '*'], + ['work/[ab]/pub', '[ab]'], + ['work/?/pub', '?'], + ])( + 'does not open a denied directory through a link named like %p spells it', + async (entry, linkName) => { + const policy = { + denyRead: [vault], + allowRead: [join(root, entry)], + allowWrite: [work], + } + // The command may write `work`, so it can plant the link itself. + const planted = await sandboxed( + policy, + `ln -s ${q(vault)} ${q(join(work, linkName))} && echo PLANTED`, + { cwd: root }, + ) + expect(planted.stdout).toContain('PLANTED') + await initialize(policy) + const allowed = SandboxManager.getFsReadConfig().allowWithinDeny ?? [] + expect(allowed.filter(p => p.includes(linkName))).toEqual([]) + const { stdout } = await sandboxed(policy, readVault(), { cwd: root }) + expect(stdout).not.toContain('SECRET') + expect(stdout).toContain('END') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'does not open one through a link further along the path either', + async () => { + const policy = { + denyRead: [vault], + allowRead: [join(work, '[ab]', 'mid', 'pub')], + allowWrite: [work], + } + const planted = await sandboxed( + policy, + `mkdir ${q(join(work, '[ab]'))} && ln -s ${q(vault)} ${q(join(work, '[ab]', 'mid'))} && echo PLANTED`, + { cwd: root }, + ) + expect(planted.stdout).toContain('PLANTED') + const { stdout } = await sandboxed(policy, readVault(), { cwd: root }) + expect(stdout).not.toContain('SECRET') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN).each([ + ['[ab]/mid/*/pub', '[ab]', 'mid'], + ['[ab]/mid/**/key', '[ab]', 'mid'], + ['*/dist/*', '*', 'dist'], + ])( + 'does not open a denied directory through a link that a pattern spelled %p starts beneath', + async (entry, dirName, linkName) => { + // The command made the directory the entry spells, and the link + // inside it is what the pattern would be walked from. + const policy = { + denyRead: [vault], + allowRead: [join(work, entry)], + allowWrite: [work], + } + const planted = await sandboxed( + policy, + `mkdir ${q(join(work, dirName))} && ln -s ${q(vault)} ${q(join(work, dirName, linkName))} && echo PLANTED`, + { cwd: root }, + ) + expect(planted.stdout).toContain('PLANTED') + await initialize(policy) + expect(SandboxManager.getFsReadConfig().allowWithinDeny).toEqual([]) + const { stdout } = await sandboxed(policy, readVault(), { cwd: root }) + expect(stdout).not.toContain('SECRET') + expect(stdout).toContain('END') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'hides a file for a read deny spelled through a link with glob characters in its name', + async () => { + // For a deny a link of that name counts: the entry names the file, + // by whichever of its names the command asks for it. + const target = join(root, 'target') + mkdirSync(target) + writeFileSync(join(target, 'token'), 'THROUGH-LINK\n') + symlinkSync(target, join(root, 'li[n]k')) + const entry = join(root, 'li[n]k', 'token') + const policy = { denyRead: [entry] } + await initialize(policy) + expect(SandboxManager.getFsReadConfig().denyOnly).toEqual([entry]) + const { stdout } = await sandboxed( + policy, + `cat ${q(entry)} ${q(join(target, 'token'))} 2>/dev/null; echo END`, + { cwd: root }, + ) + expect(stdout).not.toContain('THROUGH-LINK') + expect(stdout).toContain('END') + }, + WRAP_TIMEOUT_MS, + ) + + it('adds no name for a write allow spelled through a link with glob characters in its name', async () => { + symlinkSync(vault, join(work, '[ab]')) + await initialize({ + allowWrite: [join(work, '[ab]', 'pub'), join(work, '[ab]')], + }) + expect( + SandboxManager.getFsWriteConfig().allowOnly.filter(p => + p.startsWith(root), + ), + ).toEqual([]) + }) + + it.skipIf(!CAN_RUN)( + 'keeps a class deny in force when a directory with the name of the class appears', + async () => { + const bracketed = join(root, PROJECT) + mkdirSync(join(bracketed, 'a'), { recursive: true }) + writeFileSync(join(bracketed, 'a', '.env'), 'ENV-A\n') + const policy = { + denyRead: [`${bracketed}/[ab]/**/.env`], + allowWrite: [bracketed], + } + const read = `cat ${q(join(bracketed, 'a', '.env'))} ${q(join(bracketed, '[ab]', '.env'))} 2>/dev/null; echo END` + expect( + (await sandboxed(policy, read, { cwd: root })).stdout, + ).not.toContain('ENV-') + const made = await sandboxed( + policy, + `mkdir ${q(join(bracketed, '[ab]'))} && echo ENV-LITERAL > ${q(join(bracketed, '[ab]', '.env'))} && echo MADE`, + { cwd: root }, + ) + expect(made.stdout).toContain('MADE') + expect( + (await sandboxed(policy, read, { cwd: root })).stdout, + ).not.toContain('ENV-') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN || process.getuid?.() === 0)( + 'keeps a write deny in force when the folder cannot be looked at', + async () => { + const locked = join(root, 'locked') + const kept = join(locked, PROJECT, 'keep') + mkdirSync(kept, { recursive: true }) + writeFileSync(join(kept, 'file'), 'original\n') + chmodSync(locked, 0o000) + try { + // The command runs as the same user, so it can undo the chmod. + const { stdout } = await sandboxed( + { allowWrite: [root], denyWrite: [kept] }, + `chmod 755 ${q(locked)}; echo tampered > ${q(join(kept, 'file'))} && echo WROTE; echo END`, + { cwd: root }, + ) + expect(stdout).not.toContain('WROTE') + expect(stdout).toContain('END') + } finally { + chmodSync(locked, 0o755) + } + expect(readFileSync(join(kept, 'file'), 'utf8')).toBe('original\n') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN || process.getuid?.() === 0)( + 'takes any write deny with glob characters for the path it spells when the folder cannot be looked at', + async () => { + // Whether anything in the folder has the name cannot be told, so a + // pattern is also applied like a path that is not there: the name is + // kept from being created, and what it would match is not denied. + const locked = join(root, 'locked') + mkdirSync(locked) + writeFileSync(join(locked, 'a.pem'), 'original\n') + const wrapsWith = async ( + name: string, + ): Promise<{ denied: string[]; stdout: string }> => { + const policy = { + allowWrite: [root], + denyWrite: [join(locked, name)], + } + chmodSync(locked, 0o000) + try { + await initialize(policy) + const denied = SandboxManager.getFsWriteConfig().denyWithinAllow + const { stdout } = await sandboxed( + policy, + `chmod 755 ${q(locked)}; echo x > ${q(join(locked, name))} && echo CREATED; ` + + `echo t >> ${q(join(locked, 'a.pem'))} && echo WROTE; echo END`, + { cwd: root }, + ) + return { denied, stdout } + } finally { + chmodSync(locked, 0o755) + } + } + const leftBy = async (name: string): Promise => { + const policy = { + allowWrite: [root], + denyWrite: [join(locked, name)], + } + chmodSync(locked, 0o000) + try { + await sandboxed(policy, 'echo RAN', { cwd: root }) + } finally { + chmodSync(locked, 0o755) + } + const left = readdirSync(locked).filter(entry => entry !== 'a.pem') + for (const entry of left) { + rmSync(join(locked, entry), { recursive: true, force: true }) + } + return left + } + + for (const name of ['absent', '*.pem']) { + const { denied, stdout } = await wrapsWith(name) + expect(denied).toEqual([join(locked, name)]) + expect(stdout).not.toContain('CREATED') + expect(stdout).toContain('WROTE') + expect(stdout).toContain('END') + expect(existsSync(join(locked, name))).toBe(false) + } + // The folder cannot be cleaned by the host while it cannot be looked + // at, for a name and for a pattern alike. + const leftByName = await leftBy('absent') + expect(await leftBy('*.pem')).toEqual( + leftByName.map(entry => (entry === 'absent' ? '*.pem' : entry)), + ) + }, + WRAP_TIMEOUT_MS, + ) + }, +) + +// ============================================================================ +// macOS: the generated profile +// ============================================================================ + +describe.if(!isWindows)( + 'macOS profile: entries inside a folder named [WIP] project', + () => { + let root: string + let project: string + + beforeAll(() => { + root = freshRoot() + project = join(root, PROJECT) + mkdirSync(join(project, 'keep'), { recursive: true }) + mkdirSync(join(project, 'secrets', 'public'), { recursive: true }) + mkdirSync(join(project, 'sub'), { recursive: true }) + writeFileSync(join(project, 'secret.txt'), '') + writeFileSync(join(project, '.env'), '') + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('allows writes to the project by subpath', () => { + const profile = macProfile(undefined, { + allowOnly: [project], + denyWithinAllow: [], + }) + const allow = ruleOf(profile, '(allow file-write*') + expect(allow).toContain(subpath(project)) + // The pattern reading is still there, as for any entry. + expect(allow).toContain(patternFilter(project, 'allow')) + }) + + it('denies a write inside it by subpath, and pins the folder', () => { + const kept = join(project, 'keep') + const profile = macProfile(undefined, { + allowOnly: [root], + denyWithinAllow: [kept], + }) + expect(ruleOf(profile, '(deny file-write*')).toContain(subpath(kept)) + const pinned = ruleOf( + profile, + '(deny file-write-unlink file-write-create', + ) + expect(pinned).toContain(subpath(kept)) + expect(pinned).toContain(literalFilter(project)) + expect(profile).toContain(patternFilter(kept, 'deny')) + }) + + it('denies a read of a file and of a directory inside it by subpath', () => { + const file = join(project, 'secret.txt') + const dir = join(project, 'secrets') + const profile = macProfile({ denyOnly: [file, dir] }, undefined) + const deny = ruleOf(profile, '(deny file-read*') + expect(deny).toContain(subpath(file)) + expect(deny).toContain(subpath(dir)) + expect(deny).toContain(patternFilter(file, 'deny')) + }) + + it('re-allows a carve-out inside a denied directory by subpath', () => { + const dir = join(project, 'secrets') + const open = join(dir, 'public') + const profile = macProfile( + { denyOnly: [dir], allowWithinDeny: [open] }, + undefined, + ) + expect(ruleOf(profile, '(allow file-read*\n')).toContain(subpath(open)) + // A deny nested in an allow lands again after it; here the allow is the + // nested one, so the deny is not repeated by name. + const denies = profile.split('(deny file-read*\n') + expect(denies[1]).toContain(subpath(dir)) + }) + + it('denies a recursive pattern beneath it with the folder escaped', () => { + const profile = macProfile( + { denyOnly: [`${project}/**/.env`] }, + undefined, + ) + const anchored = `^${escapedForRegex(project)}/(.*/)?\\.env(/.*)?$` + expect(profile).toContain(`(regex ${JSON.stringify(anchored)})`) + expect(profile).toContain(patternFilter(`${project}/**/.env`, 'deny')) + const regexes = emittedRegexes(profile) + for (const denied of [ + '.env', + 'sub/.env', + 'sub/deep/.env', + '.env/inside', + ]) { + expect(regexes.some(re => re.test(join(project, denied)))).toBe(true) + } + expect(regexes.some(re => re.test(join(project, 'sub', 'readme')))).toBe( + false, + ) + expect( + ruleOf(profile, '(deny file-write-unlink file-write-create'), + ).toContain(literalFilter(project)) + }) + + it('takes the project as a write root for the rules that keep denied paths in place', () => { + const profile = macProfile( + { denyOnly: [join(project, 'secrets')] }, + { allowOnly: [project], denyWithinAllow: [] }, + ) + expect( + ruleOf(profile, '(allow file-write-unlink file-write-create'), + ).toContain(subpath(project)) + const tail = profile.slice( + profile.indexOf('keep read-denied paths inside write roots in place'), + ) + expect(tail).toContain(subpath(join(project, 'secrets'))) + }) + + it('adds no reading for a link with the name an allow spells', () => { + const work = join(root, 'work') + mkdirSync(join(root, 'vault', 'pub'), { recursive: true }) + mkdirSync(work, { recursive: true }) + symlinkSync(join(root, 'vault'), join(work, '[ab]')) + const entry = join(work, '[ab]', 'pub') + const asAllow = macProfile( + { denyOnly: [join(root, 'vault')], allowWithinDeny: [entry] }, + { allowOnly: [entry], denyWithinAllow: [] }, + ) + expect(emittedSubpaths(asAllow)).not.toContain(entry) + expect(emittedSubpaths(asAllow)).not.toContain(join(root, 'vault', 'pub')) + const asDeny = macProfile({ denyOnly: [entry] }, undefined) + expect(ruleOf(asDeny, '(deny file-read*')).toContain(subpath(entry)) + const asWriteDeny = macProfile(undefined, { + allowOnly: [root], + denyWithinAllow: [entry], + }) + expect(ruleOf(asWriteDeny, '(deny file-write*')).toContain(subpath(entry)) + }) + + it('compiles an entry as before when none of it is a name on disk', () => { + const profile = macProfile( + { denyOnly: ['/srv/[ab]/secrets', '/srv/**/.env'] }, + { allowOnly: ['/srv/build*'], denyWithinAllow: ['/srv/*.lock'] }, + ) + expect( + emittedSubpaths(profile).filter(p => p.startsWith('/srv')), + ).toEqual([]) + }) + }, +) + +// ============================================================================ +// Both readings are both +// ============================================================================ + +describe.if(!isWindows)('an entry that is a pattern and a name', () => { + const CAN_RUN = isLinux && bwrapCanNamespace() + const savedCwd = process.cwd() + let root: string + let entry: string + const dirs = ['[ab]', 'a', 'b', 'c'] + + beforeAll(() => { + root = freshRoot() + entry = join(root, '[ab]', 'secret') + for (const dir of dirs) { + mkdirSync(join(root, dir), { recursive: true }) + writeFileSync(join(root, dir, 'secret'), `SECRET-${dir}\n`) + } + process.chdir(root) + }) + + afterAll(async () => { + process.chdir(savedCwd) + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + }) + + it.if(isLinux)( + 'resolves to the class matches and to the name on Linux', + async () => { + await initialize({ denyRead: [entry] }) + expect(SandboxManager.getFsReadConfig().denyOnly.sort()).toEqual( + ['[ab]', 'a', 'b'].map(dir => join(root, dir, 'secret')).sort(), + ) + }, + ) + + it.if(isLinux)( + 'lists a path once that the pattern and the name both give', + async () => { + const starred = join(root, 'c', '*') + writeFileSync(starred, '') + try { + await initialize({ denyRead: [starred], allowRead: [starred] }) + const read = SandboxManager.getFsReadConfig() + const both = [join(root, 'c', '*'), join(root, 'c', 'secret')].sort() + expect([...read.denyOnly].sort()).toEqual(both) + expect([...(read.allowWithinDeny ?? [])].sort()).toEqual(both) + } finally { + rmSync(starred) + } + }, + ) + + it.skipIf(!CAN_RUN)( + 'denies all three under bubblewrap', + async () => { + const { stdout } = await sandboxed( + { denyRead: [entry] }, + dirs.map(dir => `cat ${q(join(root, dir, 'secret'))}`).join('; '), + { cwd: root }, + ) + expect(stdout).not.toMatch(/SECRET-(\[ab\]|a|b)\n/) + expect(stdout).toContain('SECRET-c') + }, + WRAP_TIMEOUT_MS, + ) + + it('covers all three in the macOS profile', () => { + const profile = macProfile({ denyOnly: [entry] }, undefined) + const deny = ruleOf(profile, '(deny file-read*') + const regexes = emittedRegexes(deny) + const subpaths = emittedSubpaths(deny) + const covered = (p: string): boolean => + regexes.some(re => re.test(p)) || + subpaths.some(s => p === s || p.startsWith(s + '/')) + for (const dir of ['[ab]', 'a', 'b']) { + expect(covered(join(root, dir, 'secret'))).toBe(true) + } + expect(covered(join(root, 'c', 'secret'))).toBe(false) + }) +}) + +// ============================================================================ +// Entries with no such name on disk: nothing changes +// ============================================================================ + +/** + * Ordinary entries and patterns, under a root that does not exist: no glob + * character of theirs can be part of a name on disk, so each has its + * pattern reading alone, and what it resolves to is pinned byte for byte. + */ +describe.if(!isWindows)( + 'entries none of which is a name with glob characters', + () => { + const ROOT = '/srt-literal-path-entries' + const lists = { + denyRead: [ + `${ROOT}/secrets`, + `${ROOT}/home/.ssh/**`, + `${ROOT}/proj/**/.env`, + `${ROOT}/proj/*.pem`, + `${ROOT}/proj/[ab]/key`, + `${ROOT}/proj/file?.txt`, + `${ROOT}/proj/**/build/**`, + ], + allowRead: [ + `${ROOT}/secrets/public`, + `${ROOT}/proj/**/public`, + `${ROOT}/home/.ssh/known_hosts`, + ], + allowWrite: [ + `${ROOT}/proj`, + `${ROOT}/out/**`, + `${ROOT}/build/*`, + `${ROOT}/cache/[0-9]`, + ], + denyWrite: [ + `${ROOT}/proj/.git`, + `${ROOT}/proj/**/node_modules`, + `${ROOT}/proj/locked/**`, + `${ROOT}/proj/?.lock`, + ], + } + const savedCwd = process.cwd() + + beforeAll(() => { + // The built-in write denies are spelled from the working directory and + // its ancestors. From the root they are the same on every host. + process.chdir('/') + }) + + afterAll(async () => { + process.chdir(savedCwd) + await SandboxManager.reset() + }) + + it.if(isLinux)( + 'gives the read and write config it gave before', + async () => { + await initialize(lists) + const read = SandboxManager.getFsReadConfig() + expect(read.denyOnly).toEqual([ + '/srt-literal-path-entries/secrets', + '/srt-literal-path-entries/home/.ssh', + ]) + expect(read.allowWithinDeny).toEqual([ + '/srt-literal-path-entries/secrets/public', + '/srt-literal-path-entries/home/.ssh/known_hosts', + ]) + expect(read.unlistableDenyDirs).toEqual([]) + expect(Object.keys(read).sort()).toEqual([ + 'allowWithinDeny', + 'denyOnly', + 'unlistableDenyDirs', + ]) + + const write = SandboxManager.getFsWriteConfig() + expect(write.allowOnly).toEqual([ + ...getDefaultWritePaths(), + '/srt-literal-path-entries/proj', + '/srt-literal-path-entries/out', + ]) + expect(write.denyWithinAllow).toEqual([ + '/srt-literal-path-entries/proj/.git', + '/srt-literal-path-entries/proj/locked', + ]) + expect('literalAllowOnly' in write).toBe(false) + // Absent or empty: the two mean the same for this list. + expect(write.literalDenyWithinAllow ?? []).toEqual([]) + + expect(SandboxManager.getLinuxGlobPatternWarnings()).toEqual([ + '/srt-literal-path-entries/build/*', + '/srt-literal-path-entries/cache/[0-9]', + '/srt-literal-path-entries/proj/**/node_modules', + '/srt-literal-path-entries/proj/?.lock', + ]) + }, + ) + + it('gives the macOS file rules it gave before', () => { + const wrapped = macProfile( + { denyOnly: lists.denyRead, allowWithinDeny: lists.allowRead }, + { allowOnly: lists.allowWrite, denyWithinAllow: lists.denyWrite }, + ) + expect(fileRulesOf(wrapped)).toBe(EXPECTED_MACOS_FILE_RULES) + }) + }, +) + +/** The file rules of a wrapped command's profile, log tag replaced. */ +function fileRulesOf(wrapped: string): string { + const from = wrapped.indexOf('; File read') + const to = wrapped.indexOf("' ", from) + return wrapped.slice(from, to).replace(/CMD64_.*?_SBX/g, '') +} + +const EXPECTED_MACOS_FILE_RULES = String.raw`; File read +(allow file-read*) +(deny file-read* + (subpath "/srt-literal-path-entries/secrets") + (regex "^/srt-literal-path-entries/home/\\.ssh/.*(/.*)?$") + (regex "^/srt-literal-path-entries/proj/(.*/)?\\.env(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[^/]*\\.pem(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[ab]/key(/.*)?$") + (regex "^/srt-literal-path-entries/proj/file[^/]\\.txt(/.*)?$") + (regex "^/srt-literal-path-entries/proj/(.*/)?build/.*(/.*)?$") + (with message "")) +(allow file-read* + (subpath "/srt-literal-path-entries/secrets/public") + (regex "^/srt-literal-path-entries/proj/(.*/)?public$") + (subpath "/srt-literal-path-entries/home/.ssh/known_hosts") + (with message "")) +(deny file-read* + (require-all (regex "^/srt-literal-path-entries/home/\\.ssh/.*(/.*)?$") (require-not (subpath "/srt-literal-path-entries/home/.ssh/known_hosts"))) + (regex "^/srt-literal-path-entries/proj/(.*/)?\\.env(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[^/]*\\.pem(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[ab]/key(/.*)?$") + (regex "^/srt-literal-path-entries/proj/file[^/]\\.txt(/.*)?$") + (regex "^/srt-literal-path-entries/proj/(.*/)?build/.*(/.*)?$") + (with message "")) +(allow file-read-metadata + (vnode-type DIRECTORY)) +(deny file-write-unlink file-write-create + (subpath "/srt-literal-path-entries/secrets") + (literal "/srt-literal-path-entries") + (regex "^/srt-literal-path-entries/home/\\.ssh/.*(/.*)?$") + (literal "/srt-literal-path-entries/home/.ssh") + (literal "/srt-literal-path-entries/home") + (regex "^/srt-literal-path-entries/proj/(.*/)?\\.env(/.*)?$") + (literal "/srt-literal-path-entries/proj") + (regex "^/srt-literal-path-entries/proj/[^/]*\\.pem(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[ab]/key(/.*)?$") + (regex "^/srt-literal-path-entries/proj/file[^/]\\.txt(/.*)?$") + (regex "^/srt-literal-path-entries/proj/(.*/)?build/.*(/.*)?$") + (with message "")) +(allow file-write-unlink file-write-create + (subpath "/srt-literal-path-entries/proj") + (regex "^/srt-literal-path-entries/out/.*$") + (regex "^/srt-literal-path-entries/build/[^/]*$") + (regex "^/srt-literal-path-entries/cache/[0-9]$") + (with message "")) + +; File write +(allow file-write* + (subpath "/srt-literal-path-entries/proj") + (regex "^/srt-literal-path-entries/out/.*$") + (regex "^/srt-literal-path-entries/build/[^/]*$") + (regex "^/srt-literal-path-entries/cache/[0-9]$") + (with message "")) +(deny file-write* + (subpath "/srt-literal-path-entries/proj/.git") + (regex "^/srt-literal-path-entries/proj/(.*/)?node_modules(/.*)?$") + (regex "^/srt-literal-path-entries/proj/locked/.*(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[^/]\\.lock(/.*)?$") + (subpath "/.gitconfig") + (regex "^/(.*/)?\\.gitconfig(/.*)?$") + (subpath "/.gitmodules") + (regex "^/(.*/)?\\.gitmodules(/.*)?$") + (subpath "/.bashrc") + (regex "^/(.*/)?\\.bashrc(/.*)?$") + (subpath "/.bash_profile") + (regex "^/(.*/)?\\.bash_profile(/.*)?$") + (subpath "/.zshrc") + (regex "^/(.*/)?\\.zshrc(/.*)?$") + (subpath "/.zprofile") + (regex "^/(.*/)?\\.zprofile(/.*)?$") + (subpath "/.profile") + (regex "^/(.*/)?\\.profile(/.*)?$") + (subpath "/.ripgreprc") + (regex "^/(.*/)?\\.ripgreprc(/.*)?$") + (subpath "/.mcp.json") + (regex "^/(.*/)?\\.mcp\\.json(/.*)?$") + (subpath "/.vscode") + (regex "^/(.*/)?\\.vscode/.*(/.*)?$") + (subpath "/.idea") + (regex "^/(.*/)?\\.idea/.*(/.*)?$") + (subpath "/.claude/commands") + (regex "^/(.*/)?\\.claude/commands/.*(/.*)?$") + (subpath "/.claude/agents") + (regex "^/(.*/)?\\.claude/agents/.*(/.*)?$") + (subpath "/.git/hooks") + (regex "^/(.*/)?\\.git/hooks/.*(/.*)?$") + (subpath "/.git/config") + (regex "^/(.*/)?\\.git/config(/.*)?$") + (with message "")) +(deny file-write-unlink file-write-create + (subpath "/srt-literal-path-entries/proj/.git") + (literal "/srt-literal-path-entries/proj") + (literal "/srt-literal-path-entries") + (regex "^/srt-literal-path-entries/proj/(.*/)?node_modules(/.*)?$") + (regex "^/srt-literal-path-entries/proj/locked/.*(/.*)?$") + (literal "/srt-literal-path-entries/proj/locked") + (regex "^/srt-literal-path-entries/proj/[^/]\\.lock(/.*)?$") + (subpath "/.gitconfig") + (regex "^/(.*/)?\\.gitconfig(/.*)?$") + (subpath "/.gitmodules") + (regex "^/(.*/)?\\.gitmodules(/.*)?$") + (subpath "/.bashrc") + (regex "^/(.*/)?\\.bashrc(/.*)?$") + (subpath "/.bash_profile") + (regex "^/(.*/)?\\.bash_profile(/.*)?$") + (subpath "/.zshrc") + (regex "^/(.*/)?\\.zshrc(/.*)?$") + (subpath "/.zprofile") + (regex "^/(.*/)?\\.zprofile(/.*)?$") + (subpath "/.profile") + (regex "^/(.*/)?\\.profile(/.*)?$") + (subpath "/.ripgreprc") + (regex "^/(.*/)?\\.ripgreprc(/.*)?$") + (subpath "/.mcp.json") + (regex "^/(.*/)?\\.mcp\\.json(/.*)?$") + (subpath "/.vscode") + (regex "^/(.*/)?\\.vscode/.*(/.*)?$") + (subpath "/.idea") + (regex "^/(.*/)?\\.idea/.*(/.*)?$") + (subpath "/.claude/commands") + (literal "/.claude") + (regex "^/(.*/)?\\.claude/commands/.*(/.*)?$") + (subpath "/.claude/agents") + (regex "^/(.*/)?\\.claude/agents/.*(/.*)?$") + (subpath "/.git/hooks") + (literal "/.git") + (regex "^/(.*/)?\\.git/hooks/.*(/.*)?$") + (subpath "/.git/config") + (regex "^/(.*/)?\\.git/config(/.*)?$") + (with message "")) + +; File read: keep read-denied paths inside write roots in place +(deny file-write-unlink + (require-all (regex "^/srt-literal-path-entries/home/\\.ssh/.*(/.*)?$") (require-not (subpath "/srt-literal-path-entries/home/.ssh/known_hosts"))) + (regex "^/srt-literal-path-entries/proj/(.*/)?\\.env(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[^/]*\\.pem(/.*)?$") + (regex "^/srt-literal-path-entries/proj/[ab]/key(/.*)?$") + (regex "^/srt-literal-path-entries/proj/file[^/]\\.txt(/.*)?$") + (regex "^/srt-literal-path-entries/proj/(.*/)?build/.*(/.*)?$") + (with message ""))` + // ============================================================================ // The marker // ============================================================================ @@ -924,6 +2324,9 @@ describe.if(!isWindows)( const { getDefaultWritePaths } = await import(${JSON.stringify( join(import.meta.dir, '../../src/sandbox/sandbox-utils.ts'), )}) + const { SandboxManager } = await import(${JSON.stringify( + join(import.meta.dir, '../../src/sandbox/sandbox-manager.ts'), + )}) const kept = paths => paths.filter(p => p.endsWith('/debug') || p.endsWith('/_logs')).map(p => p.split('/').slice(-2).join('/')) ${body}` @@ -941,8 +2344,10 @@ describe.if(!isWindows)( beforeAll(() => { root = freshRoot() - mkdirSync(join(root, 'home', '.claude', 'debug'), { recursive: true }) - mkdirSync(join(root, 'home', '.npm', '_logs'), { recursive: true }) + for (const home of ['home', '[home]']) { + mkdirSync(join(root, home, '.claude', 'debug'), { recursive: true }) + mkdirSync(join(root, home, '.npm', '_logs'), { recursive: true }) + } }) afterAll(() => { @@ -968,5 +2373,29 @@ describe.if(!isWindows)( asPattern: ['.npm/_logs', '.claude/debug'], }) }) + + it('counts a deny spelled inside a home directory with brackets in its name', () => { + const home = join(root, '[home]') + expect( + under( + home, + `const config = filesystem => ({ network: { allowedDomains: [], deniedDomains: [] }, filesystem: { allowWrite: [], denyWrite: [], ...filesystem } }) + await SandboxManager.initialize(config({ denyRead: [${JSON.stringify(join(home, '.claude'))}] }), undefined, false) + const denied = kept(SandboxManager.getFsWriteConfig().allowOnly) + await SandboxManager.reset() + await SandboxManager.initialize(config({ + denyRead: [${JSON.stringify(join(home, '.claude'))}], + allowRead: [${JSON.stringify(join(home, '.claude', 'debug'))}], + }), undefined, false) + const reopened = kept(SandboxManager.getFsWriteConfig().allowOnly) + await SandboxManager.reset() + console.log(JSON.stringify({ denied, reopened })) + process.exit(0)`, + ), + ).toEqual({ + denied: ['.npm/_logs'], + reopened: ['.npm/_logs', '.claude/debug'], + }) + }) }, ) From 1d1e5f6f4033be350dd40229f4315f2354dfbbfc Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 20:02:26 +0000 Subject: [PATCH 2/2] test: on macOS an anchor takes the form a profile goes by /tmp is a link to /private/tmp there, and a profile's rules go by real paths. Nothing pinned that the anchor of a pattern beneath a bracketed folder is canonicalised like any other path. --- test/sandbox/literal-path-entries.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index ec97b716e..27abb907a 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -55,7 +55,7 @@ import { loadConfigFromString, } from '../../src/utils/config-loader.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' -import { isLinux, isWindows } from '../helpers/platform.js' +import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' /** * Entries inside a directory with `*`, `?`, `[` or `]` in its name, which @@ -429,6 +429,19 @@ describe.if(!isWindows)('literalReadings', () => { } }) + it.if(isMacOS)('names the anchor in the form a profile goes by', () => { + // /tmp is a link to /private/tmp, and a profile's rules go by real paths. + const held = mkdtempSync('/tmp/literal-anchor-') + try { + mkdirSync(join(held, '[deep] p')) + expect(literalReadings(`${held}/[deep] p/**/.env`, 'deny')).toEqual([ + beneath(`/private${held}/[deep] p`, '/**/.env'), + ]) + } finally { + rmSync(held, { recursive: true, force: true }) + } + }) + it('anchors a pattern at the nearest directory when a file is in the way', () => { // The walk then finds the file where it looked for a directory. expect(literalReadings(`${project}/file/**/x`, 'deny')).toEqual([