Skip to content
Closed
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
9 changes: 7 additions & 2 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,6 +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.
- 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 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`. 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 @@ -621,7 +626,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
21 changes: 21 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,26 @@ export default [
reportUnusedDisableDirectives: false,
},
},
{
// bubblewrap takes no patterns, so every path that reaches the Linux
// backend is a name. normalizePathForSandbox with one argument decides
// pattern or name from the characters of the path, which would read a
// name with `[`, `*` or `?` in it as a pattern again.
files: [
'src/sandbox/linux-sandbox-utils.ts',
'src/sandbox/read-deny-glob.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.name='normalizePathForSandbox'][arguments.length=1]",
message:
'Every path here is a name: pass { literal: true }, so that glob characters in it are not read as a pattern.',
},
],
},
},
prettierRecommended,
]
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
SandboxRuntimeConfig,
NetworkConfig,
FilesystemConfig,
FilesystemPathEntry,
CredentialsConfig,
CredentialFileConfig,
CredentialEnvVarConfig,
Expand Down
28 changes: 16 additions & 12 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { endianness, tmpdir } from 'node:os'
import path, { join } from 'node:path'
import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js'
import { buildJavaToolOptions } from './java-proxy-agent.js'
import { readNamesOf, writeNamesOf } from './path-entries.js'
import {
generateProxyEnvVars,
buildPosixGitSafeDirEnv,
Expand Down Expand Up @@ -1771,7 +1772,7 @@ async function generateFilesystemArgs(
let readAllowPathsMemo: string[] | undefined
const readAllowPaths = (): string[] =>
(readAllowPathsMemo ??= (readConfig?.allowWithinDeny || []).map(p =>
normalizePathForSandbox(p),
normalizePathForSandbox(p, { literal: true }),
))
// What the read section mounts for one denyRead entry: a tmpfs (directory)
// or a /dev/null mask (anything else) on the entry itself; nothing when it
Expand Down Expand Up @@ -1867,7 +1868,7 @@ async function generateFilesystemArgs(
if (!readConfig) return []
const entries: string[] = []
for (const p of readConfig.denyOnly || []) {
if (normalizePathForSandbox(p) !== '/') {
if (normalizePathForSandbox(p, { literal: true }) !== '/') {
entries.push(p)
continue
}
Expand Down Expand Up @@ -1921,7 +1922,7 @@ async function generateFilesystemArgs(
let readDenyPlanMemo: ReadDenyPlanEntry[] | undefined
const readDenyPlan = (): ReadDenyPlanEntry[] =>
(readDenyPlanMemo ??= readDenyEntries()
.map(p => normalizePathForSandbox(p))
.map(p => normalizePathForSandbox(p, { literal: true }))
.sort((a, b) => canonicalDepth(a) - canonicalDepth(b))
.map(normalizedPath => {
const mount = readDenyMountOf(normalizedPath)
Expand Down Expand Up @@ -1949,10 +1950,9 @@ async function generateFilesystemArgs(

// Allow writes to specific paths
for (const pathPattern of writeConfig.allowOnly || []) {
// normalizePathForSandbox already strips a trailing slash from every
// spelling it does not take for a glob; this strip covers the ones it
// exempts — a literal directory named with glob characters, spelled
// '<dir>/[id]/'. Allow paths are recorded slash-free because every
// Every path here is a name, and normalizePathForSandbox strips a
// name's trailing slash; the strip below stays as a guard. Allow paths
// are recorded slash-free because every
// downstream comparison — the deny loop's within-allowlist gate,
// findSymlinkInPath's mask scoping, the emission filter's re-expose
// check, the denyRead re-bind and its allowRead skip, and the stub-skip
Expand All @@ -1962,7 +1962,7 @@ async function generateFilesystemArgs(
// instead of per-predicate. ('/' itself is kept; the empty spelling an
// empty $HOME expands '~' to is NOT the root, and falls out at the
// existence check below.)
const normalized = normalizePathForSandbox(pathPattern)
const normalized = normalizePathForSandbox(pathPattern, { literal: true })
const normalizedPath =
normalized === '' ? '' : normalized.replace(/\/+$/, '') || '/'

Expand Down Expand Up @@ -2154,7 +2154,7 @@ async function generateFilesystemArgs(
// directory (the re-check below); an emitted one missing from the record
// only costs a spurious abort.
for (const pathPattern of denyPaths) {
const rawPath = normalizePathForSandbox(pathPattern)
const rawPath = normalizePathForSandbox(pathPattern, { literal: true })
if (rawPath.startsWith('/dev/')) {
continue
}
Expand Down Expand Up @@ -2303,7 +2303,7 @@ async function generateFilesystemArgs(
return covered
}
for (const pathPattern of denyPaths) {
const rawPath = normalizePathForSandbox(pathPattern)
const rawPath = normalizePathForSandbox(pathPattern, { literal: true })

// Skip /dev/* paths since --dev /dev already handles them
if (rawPath.startsWith('/dev/')) {
Expand Down Expand Up @@ -2963,8 +2963,6 @@ export async function wrapCommandWithSandboxLinux(
proxyAuthToken,
caCertPath,
javaAgentJarPath,
readConfig,
writeConfig,
unsetEnvVars,
setEnvVars,
maskedFileBinds,
Expand All @@ -2982,6 +2980,12 @@ export async function wrapCommandWithSandboxLinux(
observeSocketPath,
abortSignal,
} = params
// bubblewrap takes no patterns, so every path here is a name. The literal
// lists are folded into the lists they belong to, once, so that nothing
// below can read a list and miss them; a pattern in `allowOnly` that is no
// name is dropped by the fold (see writeNamesOf).
const readConfig = readNamesOf(params.readConfig)
const writeConfig = writeNamesOf(params.writeConfig)

// Determine if we have restrictions to apply
// Read: denyOnly pattern - empty array means no restrictions
Expand Down
Loading
Loading