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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -630,7 +632,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

Expand Down
40 changes: 29 additions & 11 deletions src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,16 +163,16 @@ 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 '^…$'.
*
* 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,
Expand Down Expand Up @@ -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 `**\/<name>` patterns): the prefix of
* Set when this glob is anchored at a directory that is a name on
* disk (the cwd, for the mandatory `**\/<name>` 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
Expand All @@ -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)
Expand All @@ -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),
]
}
Expand Down Expand Up @@ -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 ('<dir>//').
allows: callerEntries(
'allow',
config.allowWithinDeny,
config.literalAllowWithinDeny,
),
Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading