diff --git a/README.md b/README.md index 6116ecb15..a067d8539 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,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 @@ -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 diff --git a/eslint.config.js b/eslint.config.js index b7783d1c9..95e8f4423 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, ] diff --git a/src/index.ts b/src/index.ts index d0a06954e..0884a9bb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { SandboxRuntimeConfig, NetworkConfig, FilesystemConfig, + FilesystemPathEntry, CredentialsConfig, CredentialFileConfig, CredentialEnvVarConfig, diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 4579a9424..b8553d603 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -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, @@ -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 @@ -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 } @@ -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) @@ -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 - // '/[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 @@ -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(/\/+$/, '') || '/' @@ -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 } @@ -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/')) { @@ -2963,8 +2963,6 @@ export async function wrapCommandWithSandboxLinux( proxyAuthToken, caCertPath, javaAgentJarPath, - readConfig, - writeConfig, unsetEnvVars, setEnvVars, maskedFileBinds, @@ -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 diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4c6a3a4df..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) @@ -289,7 +292,7 @@ function toPathEntry(pathPattern: string): PathEntry { * `subpath` filter whatever characters it contains — sniffed as a pattern, * a component like `a[b` would become a character class and the filter * would no longer match the path it was built from (the rule would be - * inert). Never use this for a caller's spelling. + * inert). Never use this for a spelling the caller did not mark literal. */ function toLiteralPathEntry(literalPath: string): PathEntry { return { @@ -298,6 +301,30 @@ function toLiteralPathEntry(literalPath: string): PathEntry { } } +/** + * 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), + ] +} + /** Does a subtree-extended deny glob regex cover `entry`'s region? */ function denyGlobCovers(denyRegex: RegExp, entry: PathEntry): boolean { return denyRegex.test(entrySamplePath(entry)) @@ -322,7 +349,7 @@ interface ResolvedReadConfig { function resolveReadConfig( config: FsReadRestrictionConfig, - writeAllowPaths: readonly string[] | undefined, + writeRoots: readonly PathEntry[], /** * Read denies the library resolved itself — the masked credential files, * which macOS degrades to a deny. Already literal: each names the file @@ -331,12 +358,19 @@ function resolveReadConfig( libraryDenies: readonly PathEntry[], ): ResolvedReadConfig { return { - denies: [...(config.denyOnly || []).map(toPathEntry), ...libraryDenies], + denies: [ + ...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: (config.allowWithinDeny || []).map(toPathEntry), - writeRoots: (writeAllowPaths || []).map(toPathEntry), + allows: callerEntries( + 'allow', + config.allowWithinDeny, + config.literalAllowWithinDeny, + ), + writeRoots: [...writeRoots], } } @@ -863,13 +897,24 @@ function generateReadRules( return rules } +/** + * 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('allow', config?.allowOnly, config?.literalAllowOnly) +} + /** * Generate filesystem write rules for sandbox profile */ function generateWriteRules( config: FsWriteRestrictionConfig | undefined, logTag: string, - allowGitConfig = false, + allowGitConfig: boolean, + writeRoots: readonly PathEntry[], ): string[] { if (!config) { return [`(allow file-write*)`] @@ -879,8 +924,8 @@ function generateWriteRules( // Generate allow rules const allowFilters = new Set() - for (const pathPattern of config.allowOnly || []) { - allowFilters.add(pathFilter(toPathEntry(pathPattern))) + for (const entry of writeRoots) { + allowFilters.add(pathFilter(entry)) } rules.push(...renderRule('allow', ['file-write*'], allowFilters, logTag)) @@ -889,8 +934,10 @@ function generateWriteRules( // the mandatory entries carry their own literal/glob split. const denyEntries = [ ...(config.denyWithinAllow || []).map(toPathEntry), + ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), ...macGetMandatoryDenyEntries(allowGitConfig), ] + denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) const { groups, rest: ungrouped } = groupLiteralDenyPaths(denyEntries) const groupFilters = groups.map(literalDenyGroupFilters) @@ -1231,6 +1278,7 @@ function generateSandboxProfile({ } profile.push('') + const writeRoots = writeRootEntries(writeConfig) // Read rules // Pass write-allowed paths so that move-blocking deny rules in the read section // can be overridden for paths where file deletion should be permitted. @@ -1238,7 +1286,7 @@ function generateSandboxProfile({ readConfig || libraryDenyEntries.length > 0 ? resolveReadConfig( readConfig ?? { denyOnly: [] }, - writeConfig?.allowOnly, + writeRoots, libraryDenyEntries, ) : undefined @@ -1248,7 +1296,9 @@ function generateSandboxProfile({ // Write rules profile.push('; File write') - profile.push(...generateWriteRules(writeConfig, logTag, allowGitConfig)) + profile.push( + ...generateWriteRules(writeConfig, logTag, allowGitConfig, writeRoots), + ) // Read-denied paths inside write roots: the read section's unlink/create // re-allow for write roots (and the write section's file-write* allows) @@ -1347,7 +1397,9 @@ export function wrapCommandWithSandboxMacOS( // Read: denyOnly pattern - empty array means no restrictions // Write: allowOnly pattern - undefined means no restrictions, any config means restrictions const hasReadRestrictions = - (readConfig && readConfig.denyOnly.length > 0) || + (readConfig && + (readConfig.denyOnly.length > 0 || + (readConfig.literalDenyOnly?.length ?? 0) > 0)) || libraryDenyEntries.length > 0 const hasWriteRestrictions = writeConfig !== undefined const hasEnvRestrictions = diff --git a/src/sandbox/path-entries.ts b/src/sandbox/path-entries.ts new file mode 100644 index 000000000..52c335e26 --- /dev/null +++ b/src/sandbox/path-entries.ts @@ -0,0 +1,433 @@ +/** + * How an entry of `denyRead`, `allowRead`, `allowWrite` or `denyWrite` is + * read: as the pattern its characters spell, as the path they name, or both. + * + * 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 here only, once per entry per wrap, by the first + * stage that needs it (the manager on Linux, the profile builder on macOS, + * `expandWindowsFsPaths` on Windows); no later stage reads the characters. + */ + +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 { + FsReadRestrictionConfig, + FsWriteRestrictionConfig, +} from './sandbox-schemas.js' + +export { markedLiteralPath } + +/** + * 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}. + */ +export function splitPathEntries( + entries: readonly FilesystemPathEntry[] | undefined, +): { spelled: string[]; marked: string[] } { + const spelled: string[] = [] + const marked: string[] = [] + for (const entry of entries ?? []) { + if (typeof entry === 'string') spelled.push(entry) + else marked.push(markedLiteralPath(entry)) + } + return { spelled, marked } +} + +/** The spellings among the entries of a list: what may be a pattern. */ +export function spelledOf( + entries: readonly FilesystemPathEntry[] | undefined, +): string[] { + return splitPathEntries(entries).spelled +} + +/** + * A key that tells entries apart by value: a marked entry is a new object + * after every clone of the config, and `/x` is not `{ path: '/x' }`. + */ +export function pathEntryKey(entry: FilesystemPathEntry): string { + return typeof entry === 'string' + ? `s:${entry}` + : `l:${markedLiteralPath(entry)}` +} + +/** + * 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 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(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 + const stripped = removeTrailingGlobSuffix(spelling) + if (!isPattern(stripped)) return [] + 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(parts.length - 1, last, 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(next - 1, end, 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( + to: number, + from: 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 { + return Object.fromEntries( + Object.entries(lists).filter(([, list]) => list.length > 0), + ) as Partial +} + +/** + * The `literal…` lists of a read config: the marked entries of the two read + * lists. A key is present only when its list is not empty. + */ +export function literalReadLists( + denyRead: readonly FilesystemPathEntry[] | undefined, + allowRead: readonly FilesystemPathEntry[] | undefined, +): Pick { + return nonEmptyLists({ + literalDenyOnly: splitPathEntries(denyRead).marked, + literalAllowWithinDeny: splitPathEntries(allowRead).marked, + }) +} + +/** The `literal…` lists of a write config; see {@link literalReadLists}. */ +export function literalWriteLists( + allowWrite: readonly FilesystemPathEntry[] | undefined, + denyWrite: readonly FilesystemPathEntry[] | undefined, +): Pick< + FsWriteRestrictionConfig, + 'literalAllowOnly' | 'literalDenyWithinAllow' +> { + return nonEmptyLists({ + literalAllowOnly: splitPathEntries(allowWrite).marked, + literalDenyWithinAllow: splitPathEntries(denyWrite).marked, + }) +} + +/** + * Every path a write config allows, whichever list it came in on. Whoever + * asks "may the command write here" asks this, not `allowOnly` alone: a + * path the caller marked literal is in `literalAllowOnly` and nowhere else. + */ +export function writeRootsOf( + config: Pick, +): string[] { + return [...(config.allowOnly || []), ...(config.literalAllowOnly ?? [])] +} + +/** + * The paths bound back over a read deny: the read allows and the write + * allows, the marked ones among them. A deny glob's matches are collapsed + * against these, and one left out loses the matches beneath it their mask. + */ +export function reExposedBy( + expandedAllowRead: readonly string[], + allowRead: readonly FilesystemPathEntry[] | undefined, + writeConfig: FsWriteRestrictionConfig, +): string[] { + return [ + ...expandedAllowRead, + ...splitPathEntries(allowRead).marked, + ...writeRootsOf(writeConfig), + ] +} + +/** + * A read config for a backend to which every path is a name (Linux): the + * literal lists folded into the lists they are more entries of, and gone as + * keys, so that whatever reads `denyOnly` has read them all. + */ +export function readNamesOf( + config: FsReadRestrictionConfig | undefined, +): FsReadRestrictionConfig | undefined { + if (!config) return config + const { literalDenyOnly, literalAllowWithinDeny, ...rest } = config + return { + ...rest, + denyOnly: [...(config.denyOnly || []), ...(literalDenyOnly ?? [])], + ...(config.allowWithinDeny || literalAllowWithinDeny + ? { + allowWithinDeny: [ + ...(config.allowWithinDeny ?? []), + ...(literalAllowWithinDeny ?? []), + ], + } + : {}), + } +} + +/** + * The same for a write config; see {@link readNamesOf}. A string of + * `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, +): FsWriteRestrictionConfig | undefined { + if (!config) return config + const { literalAllowOnly, literalDenyWithinAllow, ...rest } = config + return { + ...rest, + allowOnly: writeRootsOf({ + allowOnly: (config.allowOnly || []).filter( + p => !containsGlobChars(p) || hasNameReading(p, 'allow'), + ), + literalAllowOnly, + }), + denyWithinAllow: [ + ...(config.denyWithinAllow || []), + ...(literalDenyWithinAllow ?? []), + ], + } +} diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 342805143..782f12569 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -63,7 +63,7 @@ function collapseReadDenyLocations({ * `**\/build/**` yields one mount per `build/` directory. A match reached * through a symlink is listed where it really lives, and a directory the walk * could not list is denied whole. Sorted, so an ancestor precedes its - * descendants. + * descendants. `anchor` is as in `ExpandGlobOptions.anchor`. * * @param unlistableDirs - receives the returned locations that hide something * the walk could not enumerate, whether by being that directory or by @@ -75,10 +75,12 @@ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, + anchor?: string, ): string[] { const walk = walkGlobPattern(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, + anchor, }) // Where a path the walk reported really lives: the denyRead loop mounts an // entry there, whatever spelling named it. @@ -156,8 +158,11 @@ export function expandReadDenyGlobLinux( addLocation(standIn, candidate) } + // Allow paths, which reach this backend as names whatever they hold. const reExposed = new Set( - reExposedPaths.flatMap(p => pathSpellings(normalizePathForSandbox(p))), + reExposedPaths.flatMap(p => + pathSpellings(normalizePathForSandbox(p, { literal: true })), + ), ) const mounts = collapseReadDenyLocations({ locations, diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 6a2b32767..1b6530d87 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -134,6 +134,28 @@ const addressRangeSchema = z */ const filesystemPathSchema = z.string().min(1, 'Path cannot be empty') +/** + * An entry of `denyRead`, `allowRead`, `allowWrite` or `denyWrite`: a + * spelling, which is a pattern when it reads as one, or a path marked + * `literal: true`, which never is. An object without the mark, or with a + * key this does not know, is refused rather than read either way. + */ +const filesystemPathEntrySchema = z.union( + [ + filesystemPathSchema, + z.object({ path: filesystemPathSchema, literal: z.literal(true) }).strict(), + ], + { + errorMap: (issue, ctx) => + issue.code === z.ZodIssueCode.invalid_union + ? { + message: + 'Expected a path, or { "path": "", "literal": true }', + } + : { message: ctx.defaultError }, + }, +) + /** * Schema for an absolute path to an external binary. * Relative paths are rejected to prevent PATH/CWD-based hijacking — these @@ -315,7 +337,7 @@ const extractPatternSchema = z.string().superRefine((val, ctx) => { export const CredentialFileConfigSchema = z.object({ path: filesystemPathSchema.describe( 'Path to a credential file or directory. Supports the same path forms as ' + - 'filesystem.denyRead (absolute paths and ~ expansion).', + 'a string in filesystem.denyRead (absolute paths and ~ expansion).', ), mode: credentialModeSchema.describe('Access mode for this path'), extract: extractPatternSchema @@ -901,19 +923,21 @@ export const FilesystemConfigSchema = z.object({ 'is trusted with full host filesystem access. Network and credential-env restrictions ' + 'still apply. On Linux, /dev is still replaced by the bwrap minimal devtmpfs.', ), - denyRead: z.array(filesystemPathSchema).describe('Paths denied for reading'), + denyRead: z + .array(filesystemPathEntrySchema) + .describe('Paths denied for reading'), allowRead: z - .array(filesystemPathSchema) + .array(filesystemPathEntrySchema) .optional() .describe( 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), allowWrite: z - .array(filesystemPathSchema) + .array(filesystemPathEntrySchema) .describe('Paths allowed for writing'), denyWrite: z - .array(filesystemPathSchema) + .array(filesystemPathEntrySchema) .describe('Paths denied for writing (takes precedence over allowWrite)'), allowGitConfig: z .boolean() @@ -1195,10 +1219,14 @@ export const SandboxRuntimeConfigSchema = z // under it is not a hole. const fsEnforced = !cfg.filesystem.disabled if (fsEnforced) { + // A marked entry is never a glob, so a separator at its end is the + // end of a name. for (const [idx, p] of cfg.filesystem.denyRead.entries()) { + if (typeof p !== 'string') continue addInertSlashedDenyGlobIssue(p, ['filesystem', 'denyRead', idx], ctx) } for (const [idx, p] of cfg.filesystem.denyWrite.entries()) { + if (typeof p !== 'string') continue addInertSlashedDenyGlobIssue(p, ['filesystem', 'denyWrite', idx], ctx) } } @@ -1459,6 +1487,7 @@ export type MitmProxyConfig = z.infer export type ParentProxyConfig = z.infer export type NetworkConfig = z.infer export type FilesystemConfig = z.infer +export type FilesystemPathEntry = z.infer export type CredentialMode = z.infer export type CredentialFileConfig = z.infer export type CredentialEnvVarConfig = z.infer< diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 91daa448a..b1d23c5a0 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1,3 +1,15 @@ +import { + hasNameReading, + literalReadLists, + literalWriteLists, + pathEntryKey, + reExposedBy, + readRulesOf, + spelledOf, + withOtherReadings, + writeRootsOf, + type PathListKind, +} from './path-entries.js' import { createHttpProxyServer } from './http-proxy.js' import { createSocksProxyServer } from './socks-proxy.js' import type { SocksProxyWrapper } from './socks-proxy.js' @@ -30,6 +42,7 @@ import * as fs from 'fs' import { randomBytes } from 'node:crypto' import type { CredentialsConfig, + FilesystemPathEntry, SandboxRuntimeConfig, SeccompConfig, } from './sandbox-config.js' @@ -699,7 +712,7 @@ async function initialize( if (enableLogMonitor && getPlatform() === 'linux') { // The monitor compares paths the kernel reported, so its lists are // expanded the way the wrapper expands them (getFsWriteConfig folds in - // the default write paths and drops the globs bwrap cannot take; + // the default write paths and drops the patterns, so only names are left; // normalizePathForSandbox resolves `~`, relative spellings and symlinks), // plus the built-in write denies the wrapper always applies. // It does not reproduce the wrapper's existence and boundary-symlink @@ -719,12 +732,15 @@ async function initialize( // out because it is read-denied sits under that deny's writable // tmpfs, so a write there is permitted and is not a violation. allowWritePaths: [ - ...monitoredWrites.allowOnly, + ...writeRootsOf(monitoredWrites), ...(config.filesystem.disabled ? [] : getDefaultWritePaths()), - ].map(p => normalizePathForSandbox(p)), + ].map(p => normalizePathForSandbox(p, { literal: true })), denyWritePaths: [ ...monitoredWrites.denyWithinAllow.map(p => - normalizePathForSandbox(p), + normalizePathForSandbox(p, { literal: true }), + ), + ...(monitoredWrites.literalDenyWithinAllow ?? []).map(p => + normalizePathForSandbox(p, { literal: true }), ), // filesystem.disabled reaches the wrapper as `writeConfig === // undefined`, which skips every bind and the built-in denies with @@ -1223,21 +1239,21 @@ 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, allowRead, credentials, }: { - denyRead: readonly string[] - allowRead: readonly string[] | undefined + denyRead: readonly FilesystemPathEntry[] + allowRead: readonly FilesystemPathEntry[] | undefined credentials: CredentialsConfig | undefined }): string[] { - return getDefaultWritePaths({ - denyRead: [...denyRead, ...getCredentialDenyReadPaths(credentials)], - allowRead, - }) + return getDefaultWritePaths( + readRulesOf(denyRead, allowRead, getCredentialDenyReadPaths(credentials)), + ) } /** @@ -1258,6 +1274,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. + * 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 @@ -1265,17 +1284,18 @@ function unionDenyReadPaths( * pattern, a name holding `[` matches nothing and the deny is lost. */ function resolveReadPathEntries( - paths: readonly string[], - expandGlob: (pattern: string) => string[], + kind: PathListKind, + paths: readonly FilesystemPathEntry[], + expandGlob: (pattern: string, anchor?: string) => string[], literalPaths: readonly string[] = [], ): string[] { const literal = new Set(literalPaths) - return paths.flatMap(p => { + return spelledOf(paths).flatMap(p => { const stripped = removeTrailingGlobSuffix(p) return getPlatform() === 'linux' && !literal.has(p) && containsGlobChars(stripped) - ? expandGlob(p) + ? withOtherReadings(p, stripped, kind, expandGlob) : [stripped] }) } @@ -1284,12 +1304,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 string[]): string[] { - return paths +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 } @@ -1297,8 +1326,8 @@ function stripWriteGlobs(paths: readonly string[]): 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`, ) @@ -1308,9 +1337,10 @@ function expandAllowReadGlob(pattern: string): string[] { /** * The read policy of the initialized config, for inspection and display. * On Linux, denyRead globs are collapsed to covering directory mounts against - * this config's allowRead and {@link getFsWriteConfig}'s allowOnly, so + * this config's allowRead and {@link getFsWriteConfig}'s write roots, so * `denyOnly` is only sound alongside that write config and must not be handed - * to wrapCommandWithSandboxLinux with a different one. + * to wrapCommandWithSandboxLinux with a different one. Marked entries are + * in the `literal…` lists, as spelled: pass the object on whole. */ function getFsReadConfig(): FsReadRestrictionConfig { if (!config || config.filesystem.disabled) { @@ -1326,15 +1356,29 @@ 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, ) - const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly] + const reExposedPaths = reExposedBy( + allowPaths, + config.filesystem.allowRead, + getFsWriteConfig(), + ) const unlistableDenyDirs = new Set() const denyPaths = resolveReadPathEntries( - unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + 'deny', + unionDenyReadPaths( + spelledOf(config.filesystem.denyRead), + credentialRestrictions, + ), + (pattern, anchor) => + expandReadDenyGlobLinux( + pattern, + reExposedPaths, + unlistableDenyDirs, + anchor, + ), credentialRestrictions.degradeToDenyPaths, ) @@ -1342,6 +1386,10 @@ function getFsReadConfig(): FsReadRestrictionConfig { denyOnly: denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + config.filesystem.denyRead, + config.filesystem.allowRead, + ), } } @@ -1354,8 +1402,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({ @@ -1369,6 +1417,10 @@ function getFsWriteConfig(): FsWriteRestrictionConfig { return { allowOnly, denyWithinAllow: denyPaths, + ...literalWriteLists( + config.filesystem.allowWrite, + config.filesystem.denyWrite, + ), } } @@ -1450,10 +1502,13 @@ function rawWindowsFsInputs(c: SandboxRuntimeConfig) { } } -function setEq(a: readonly string[], b: readonly string[]): boolean { +function setEq( + a: readonly FilesystemPathEntry[], + b: readonly FilesystemPathEntry[], +): boolean { if (a.length !== b.length) return false - const s = new Set(a) - return b.every(x => s.has(x)) + const s = new Set(a.map(pathEntryKey)) + return b.every(x => s.has(pathEntryKey(x))) } function sameRawWindowsFsInputs( @@ -1672,6 +1727,7 @@ async function wrapWithSandbox( let readConfig: FsReadRestrictionConfig | undefined if (!fsDisabled) { const userAllowWrite = stripWriteGlobs( + 'allow', customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite ?? [], @@ -1690,10 +1746,15 @@ async function wrapWithSandbox( ...userAllowWrite, ], denyWithinAllow: stripWriteGlobs( + 'deny', customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite ?? [], ), + ...literalWriteLists( + customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite, + customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite, + ), } // Credential deny paths are unioned with the caller's denyRead — never @@ -1702,6 +1763,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, ) @@ -1715,21 +1777,37 @@ async function wrapWithSandbox( if (javaAgentJarPath) { expandedAllowRead.push(javaAgentJarPath) } - const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly] + const reExposedPaths = reExposedBy( + expandedAllowRead, + customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, + writeConfig, + ) const unlistableDenyDirs = new Set() const expandedDenyRead = resolveReadPathEntries( + 'deny', unionDenyReadPaths( - customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], + spelledOf( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + ), credentialRestrictions, ), - pattern => - expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), + (pattern, anchor) => + expandReadDenyGlobLinux( + pattern, + reExposedPaths, + unlistableDenyDirs, + anchor, + ), credentialRestrictions.degradeToDenyPaths, ) readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, + ), } } @@ -2361,6 +2439,9 @@ function annotateStderrWithSandboxFailures( * sandboxing is disabled. * * Patterns ending with /** are excluded since they work as subpaths. + * 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) @@ -2379,6 +2460,7 @@ function getLinuxGlobPatternWarnings(): string[] { ] for (const path of allPaths) { + if (typeof path !== 'string') continue // Strip trailing /** since that's just a subpath (directory and everything under it) const pathWithoutTrailingStar = removeTrailingGlobSuffix(path) @@ -2396,6 +2478,7 @@ function getLinuxGlobPatternWarnings(): string[] { ...config.filesystem.denyRead, ...(config.filesystem.allowRead ?? []), ]) { + if (typeof path !== 'string') continue const baseDir = globPatternBaseDir(normalizePathForSandbox(path)) if ( containsGlobChars(removeTrailingGlobSuffix(path)) && diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index ce4243109..09ac5a30b 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -16,10 +16,17 @@ * one — stays denied. * * This is maximally permissive by default - only explicitly denied paths are blocked. + * + * A `literal…` list holds MORE entries for the list it is named after: the + * paths the caller marked `{ path, literal: true }`, names that no backend + * expands or compiles. The key is absent when there are none. Whatever reads + * `denyOnly` to learn what is denied has to read `literalDenyOnly` too. */ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] + literalDenyOnly?: string[] + literalAllowWithinDeny?: string[] /** * The `denyOnly` entries that stand for a directory a glob expansion could * not list. Nothing is bound back beneath one — neither an @@ -41,10 +48,16 @@ export interface FsReadRestrictionConfig { * * This is maximally restrictive by default - only explicitly allowed paths are writable. * Note: Empty `allowOnly` means NO paths are writable (unlike read's empty denyOnly). + * + * A `literal…` list holds MORE entries for the list it is named after, as in + * {@link FsReadRestrictionConfig}: what the command may write is `allowOnly` + * and `literalAllowOnly` together. */ export interface FsWriteRestrictionConfig { allowOnly: string[] + literalAllowOnly?: string[] denyWithinAllow: string[] + literalDenyWithinAllow?: string[] } /** diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 23859d190..a8009549c 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -136,6 +136,36 @@ export function containsGlobCharsForPlatform(p: string): boolean { : containsGlobChars(p) } +/** + * An entry of a filesystem list as configured: a spelling, which is a + * pattern when it reads as one, or a path marked literal, which never is. + */ +export type PathListEntry = string | { path: string; literal: true } + +/** + * The path of an entry that is not a spelling. Throws unless the entry is + * `{ path: string, literal: true }`: a config built in code skips the + * schema, and reading `{ path }` either way would be a guess about a deny. + */ +export function markedLiteralPath( + entry: Exclude, +): string { + const candidate: { path?: unknown; literal?: unknown } | null = entry + if ( + candidate === null || + typeof candidate !== 'object' || + typeof candidate.path !== 'string' || + candidate.path === '' || + candidate.literal !== true + ) { + throw new TypeError( + 'A filesystem path entry must be a path, or { path, literal: true }; ' + + `got ${JSON.stringify(entry) ?? String(entry)}`, + ) + } + return candidate.path +} + /** * Strip the Win32 `\\?\` extended-path prefix so the residue is * a conventional absolute path (drive-letter or UNC) with no `?` @@ -343,7 +373,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, '') } @@ -381,14 +411,15 @@ function warnIfParentRefUnfolded(normalizedPath: string): string { * Returns the absolute path with symlinks resolved (or normalized glob pattern) * * `opts.literal` marks a path that names one file or directory rather - * than matching several: one the library computed itself, or 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 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. + * than matching several: one the library computed itself, one the caller + * 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 — 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, @@ -570,10 +601,14 @@ const HOME_CONVENIENCE_WRITE_DIRS: readonly string[] = [ * still counts (which only ever drops a convenience path), and a glob whose * 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. 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 string[] - allowRead?: readonly string[] + denyRead: readonly PathListEntry[] + allowRead?: readonly PathListEntry[] }): string[] { const home = homedir() const keptDirs = @@ -592,8 +627,8 @@ export function getDefaultWritePaths(readRules?: { */ function homeDirsNotReadDenied( home: string, - denyRead: readonly string[], - allowRead: readonly string[] = [], + denyRead: readonly PathListEntry[], + allowRead: readonly PathListEntry[] = [], ): readonly string[] { // Rules are compared as normalizePathForSandbox spells them, and on macOS // that resolves /tmp and /var to /private/... for a path that exists. A @@ -604,8 +639,11 @@ function homeDirsNotReadDenied( ] const denies = denyRead.map(entry => readRuleCovers(entry)) const reopened = allowRead - .map(entry => removeTrailingGlobSuffix(entry)) - .filter(entry => !containsGlobCharsForPlatform(entry)) + .flatMap(entry => { + if (typeof entry !== 'string') return [markedLiteralPath(entry)] + const stripped = removeTrailingGlobSuffix(entry) + return containsGlobCharsForPlatform(stripped) ? [] : [stripped] + }) .map(entry => normalizePathForSandbox(entry, { literal: true })) return HOME_CONVENIENCE_WRITE_DIRS.filter(rel => { const spellings = homes.map(h => path.join(h, rel)) @@ -629,9 +667,15 @@ function homeDirsNotReadDenied( * is resolved: `*`, `?` and `[…]` there are the syntax it asked for, while * resolving splices in a cwd or home directory that may carry those * characters in its own name. A spelling without them is normalized as the - * name it is. + * name it is, and so is an entry marked literal, whatever it holds. */ -function readRuleCovers(entry: string): (p: string) => boolean { +function readRuleCovers(entry: PathListEntry): (p: string) => boolean { + if (typeof entry !== 'string') { + const rule = normalizePathForSandbox(markedLiteralPath(entry), { + literal: true, + }) + return p => isAtOrUnder(p, rule) + } const stripped = removeTrailingGlobSuffix(entry) if (containsGlobCharsForPlatform(stripped)) { try { @@ -1117,10 +1161,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 '^…$'. @@ -1135,6 +1179,13 @@ export interface ExpandGlobOptions { * don't need it). */ caseInsensitive?: boolean + /** + * A directory the pattern is walked beneath, taken as the name it is: a + * leading run of `globPath` that exists on disk and may itself hold `[`, + * `*` or `?`. Only what follows it is pattern. The glob dialect has no + * escape, so this is the one way to say where such a name ends. + */ + anchor?: string } /** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */ @@ -1456,7 +1507,8 @@ function globPositions( * component), which {@link walkGlobPattern} refuses to expand. * * @param normalizedPattern - a pattern already through - * {@link normalizePathForSandbox} (and, on Windows, {@link toForwardSlashes}) + * {@link normalizePathForSandbox} (and, on Windows, {@link toForwardSlashes}), + * or the tail of one that is walked beneath an anchor */ export function globPatternBaseDir(normalizedPattern: string): string { const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0] @@ -1500,9 +1552,19 @@ export function walkGlobPattern( realOf: new Map(), } - const normalizedPattern = toForwardSlashes(normalizePathForSandbox(globPath)) - const baseDir = globPatternBaseDir(normalizedPattern) - if (baseDir === '' || baseDir === '/') { + // Beneath an anchor the pattern is the tail alone, so no character of the + // anchor is compiled; the walk starts at the anchor plus the tail's base. + const anchor = + opts.anchor === undefined ? undefined : toForwardSlashes(opts.anchor) + const normalizedPattern = + anchor === undefined + ? toForwardSlashes(normalizePathForSandbox(globPath)) + : toForwardSlashes(globPath).slice(anchor.length) + const patternBaseDir = globPatternBaseDir(normalizedPattern) + const baseDirBelowAnchor = patternBaseDir === '/' ? '' : patternBaseDir + const baseDir = + anchor === undefined ? patternBaseDir : anchor + baseDirBelowAnchor + if (anchor === undefined && (baseDir === '' || baseDir === '/')) { logForDebugging( `[Sandbox] Glob pattern has no literal directory to start from, skipping: ${globPath}`, { level: 'warn' }, @@ -1606,7 +1668,9 @@ export function walkGlobPattern( dir: baseDir, real: baseReal, short: baseDir.length < baseReal.length ? baseDir : baseReal, - positions: baseDir.split('/').reduce(positions.next, positions.start), + positions: (anchor === undefined ? baseDir : baseDirBelowAnchor) + .split('/') + .reduce(positions.next, positions.start), }) for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) { const { dir, real } = frame @@ -1638,7 +1702,14 @@ export function walkGlobPattern( for (const entry of entries) { const fullPath = path.join(dir, entry.name) const realPath = path.join(real, entry.name) - const candidate = toForwardSlashes(fullPath) + // A pattern that does not split is matched by its whole spelling, and + // beneath an anchor that is the spelling from the anchor on. It is never + // listed through a link, so every path it sees starts with the anchor. + const spelled = toForwardSlashes(fullPath) + const candidate = + anchor !== undefined && spelled.startsWith(anchor) + ? spelled.slice(anchor.length) + : spelled const isMatch = positions.matches(fresh, entry.name, candidate) if (isMatch) walk.matches.push(fullPath) if (entry.isDirectory()) { diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index ece8fd95b..962914d86 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -24,8 +24,9 @@ export { isUncPath, } from './sandbox-utils.js' import { certThumbprint, generateCa, validateCaPair } from './mitm-ca.js' +import { literalReadings, markedLiteralPath } from './path-entries.js' import type { SandboxDependencyCheck } from './linux-sandbox-utils.js' -import type { SrtWinConfig } from './sandbox-config.js' +import type { FilesystemPathEntry, SrtWinConfig } from './sandbox-config.js' /** * Windows sandbox backend. @@ -1688,15 +1689,37 @@ export function uninstallWindowsSandbox( * `srt-win` soft-drops a missing UNC deny target rather than * 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. */ export function expandWindowsFsPaths( - patterns: readonly string[], + patterns: readonly FilesystemPathEntry[], opts?: { mode?: 'grant' | 'deny' }, ): string[] { const out = new Set() - for (const raw of patterns) { - const norm = normalizePathForSandbox(raw) - const isGlob = containsGlobCharsWin(norm) + for (const entry of patterns) { + const marked = typeof entry !== 'string' + const raw = marked ? markedLiteralPath(entry) : entry + const norm = normalizePathForSandbox( + raw, + marked ? { literal: true } : undefined, + ) + if (marked && containsGlobCharsWin(norm)) { + logForDebugging( + `[Sandbox Windows] Skipping a literal path that no file can have: ${norm}`, + { level: 'warn' }, + ) + continue + } + const isGlob = !marked && containsGlobCharsWin(norm) // UNC literal: pass raw (no stat) — see {@link isUncPath}. A // UNC glob falls through to expandGlobPattern below. if (isUncPath(norm) && !isGlob) { @@ -1704,7 +1727,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 f2864ac23..3a9eb1532 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -525,6 +525,121 @@ describe('Config Validation', () => { }) }) + describe('path entries marked literal', () => { + type List = 'denyRead' | 'allowRead' | 'allowWrite' | 'denyWrite' + const lists: List[] = ['denyRead', 'allowRead', 'allowWrite', 'denyWrite'] + + function parse(list: List, entry: unknown) { + return SandboxRuntimeConfigSchema.safeParse({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + [list]: [entry], + }, + }) + } + + test.each(lists)('%s takes a path marked literal beside a string', list => { + const marked = { path: '/work/*.env', literal: true as const } + const result = parse(list, marked) + expect(result.success).toBe(true) + expect(result.success && result.data.filesystem[list]).toEqual([marked]) + const plain = parse(list, '/work/keep') + expect(plain.success && plain.data.filesystem[list]).toEqual([ + '/work/keep', + ]) + }) + + test.each(lists)( + '%s takes ~ and relative spellings marked literal', + list => { + for (const path of ['~/notes (draft?)', './build*', '[WIP] project']) { + expect(parse(list, { path, literal: true }).success).toBe(true) + } + }, + ) + + test.each([ + ['no mark', { path: '/work/x' }], + ['a mark that is false', { path: '/work/x', literal: false }], + ['a mark that is a string', { path: '/work/x', literal: 'true' }], + ['a mark that is a number', { path: '/work/x', literal: 1 }], + ['no path', { literal: true }], + ['a path that is not a string', { path: ['/work/x'], literal: true }], + ['a number', 42], + ['null', null], + ['a list', ['/work/x']], + ])('refuses an entry with %s', (_what, entry) => { + for (const list of lists) { + const result = parse(list, entry) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.path.slice(0, 3)).toEqual([ + 'filesystem', + list, + 0, + ]) + } + } + }) + + test('says what an entry may be when it is neither', () => { + const result = parse('denyRead', { path: '/work/x', literal: false }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + 'Expected a path, or { "path": "", "literal": true }', + ) + } + }) + + test('refuses a key it does not know, rather than ignore it', () => { + const result = parse('denyRead', { + path: '/work/x', + literal: true, + recursive: true, + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.code).toBe('unrecognized_keys') + } + }) + + test('refuses an empty path, marked or not, with the same message', () => { + for (const entry of ['', { path: '', literal: true }]) { + const result = parse('allowWrite', entry) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Path cannot be empty') + } + } + }) + + test('takes a separator at the end of a marked deny, which is no glob', () => { + // The same spelling as a string is a pattern that can match no path, + // and is refused; marked, it is a name and the separator ends it. + for (const list of ['denyRead', 'denyWrite'] as const) { + expect(parse(list, '/data/*/').success).toBe(false) + expect(parse(list, { path: '/data/*/', literal: true }).success).toBe( + true, + ) + } + }) + + test('does not take the mark on a credential file', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + credentials: { + files: [{ path: { path: '~/.netrc', literal: true }, mode: 'deny' }], + }, + }) + expect(result.success).toBe(false) + }) + }) + describe('bwrapPath / socatPath', () => { const base = { network: { allowedDomains: [], deniedDomains: [] }, diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index ec0625c9f..9867e31d5 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -14,7 +14,9 @@ import { symlinkSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +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, expandTilde, @@ -835,6 +837,226 @@ describe('expandWindowsFsPaths literal branch', () => { }) }) +// ============================================================================ +// expandWindowsFsPaths — beneath a directory with brackets in its name +// ============================================================================ + +/** + * `[` 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(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('takes a path inside it for the path it is', () => { + const kept = join(project, 'keep') + expect(expandWindowsFsPaths([kept])).toEqual([kept]) + expect(expandWindowsFsPaths([kept], { mode: 'deny' })).toEqual([kept]) + }) + + it('passes a deny for a path inside it that is not there yet, and drops the grant', () => { + const notYet = join(project, 'not-yet') + expect(expandWindowsFsPaths([notYet], { mode: 'deny' })).toEqual([notYet]) + 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('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 } + expect(expandWindowsFsPaths([marked])).toEqual([kept]) + const notYet: FilesystemPathEntry = { + path: join(project, '[later]'), + literal: true, + } + expect(expandWindowsFsPaths([notYet], { mode: 'deny' })).toEqual([ + join(project, '[later]'), + ]) + expect(expandWindowsFsPaths([notYet], { mode: 'grant' })).toEqual([]) + }) + + it('skips a marked entry with a character no Windows name can hold', () => { + // srt-win refuses `*` and `?` outright, and nothing can have the name. + for (const path of [`${project}/*.env`, join(project, 'what?')]) { + const marked: FilesystemPathEntry = { path, literal: true } + expect(expandWindowsFsPaths([marked], { mode: 'deny' })).toEqual([]) + expect(expandWindowsFsPaths([marked], { mode: 'grant' })).toEqual([]) + } + }) + + it('refuses an entry that is neither a path nor a marked path', () => { + for (const entry of [ + { path: project }, + { path: project, literal: false }, + ]) { + expect(() => + expandWindowsFsPaths([entry as unknown as FilesystemPathEntry]), + ).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)( + 'expandWindowsFsPaths beneath a bracketed directory, as Windows spells paths', + () => { + let root: string + let project: string + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'win-literal-')) + project = join(root, '[WIP] project') + 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( + [ + { path: `${project}\\sub`, literal: true }, + { path: `${project}\\*.env`, literal: true }, + ], + { mode: 'deny' }, + ) + 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([]) + } + } + }) + }, +) + // ============================================================================ // isUncPath — broker never stats `\\server\…` with real-user creds // ============================================================================ diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts new file mode 100644 index 000000000..0dbfe441d --- /dev/null +++ b/test/sandbox/literal-path-entries.test.ts @@ -0,0 +1,2226 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + spyOn, +} 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' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import * as linuxViolationMonitorModule from '../../src/sandbox/linux-violation-monitor.js' +import { + type LinuxViolationMonitorOptions, + startLinuxSandboxViolationMonitor, +} from '../../src/sandbox/linux-violation-monitor.js' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { + literalReadings, + type PathReading, + pathEntryKey, + readNamesOf, + splitPathEntries, + writeNamesOf, + writeRootsOf, +} from '../../src/sandbox/path-entries.js' +import type { FilesystemPathEntry } from '../../src/sandbox/sandbox-config.js' +import { + denyGlobRegex, + expandGlobPattern, + getDefaultWritePaths, + globToRegex, + normalizePathForSandbox, +} from '../../src/sandbox/sandbox-utils.js' +import { + loadConfig, + loadConfigFromString, +} from '../../src/utils/config-loader.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * 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. + */ + +/** The folder every suite works in: a name a pattern reads as a class. */ +const PROJECT = '[WIP] project' + +const WRAP_TIMEOUT_MS = 60_000 +const RUN_TIMEOUT_MS = 15_000 + +function freshRoot(): string { + return realpathSync(mkdtempSync(join(tmpdir(), 'literal-paths-'))) +} + +/** A path as one shell word. */ +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( + match => new RegExp(JSON.parse(match[1]!) as string), + ) +} + +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) + if (start < 0) return '' + const end = profile.indexOf('(with message', start) + 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 = { + denyRead?: FilesystemPathEntry[] + allowRead?: FilesystemPathEntry[] + allowWrite?: FilesystemPathEntry[] + denyWrite?: FilesystemPathEntry[] +} + +async function initialize(filesystem: FilesystemLists): Promise { + await SandboxManager.reset() + await SandboxManager.initialize( + { + network: noNetwork, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + ...filesystem, + }, + }, + undefined, + false, + ) +} + +/** + * Runs `payload` under the policy, behind an `echo BOOTED`, so a sandbox + * that failed to start cannot read as a payload that was refused. + */ +async function sandboxed( + filesystem: FilesystemLists, + payload: string, + opts: { cwd: string; perWrap?: FilesystemLists; keepMountPoints?: boolean }, +): Promise<{ stdout: string; stderr: string }> { + await initialize(opts.perWrap ? {} : filesystem) + const wrapped = await SandboxManager.wrapWithSandbox( + `echo BOOTED; ${payload}`, + undefined, + opts.perWrap + ? { + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + ...opts.perWrap, + }, + } + : undefined, + ) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: opts.cwd, + timeout: RUN_TIMEOUT_MS, + }) + if (!opts.keepMountPoints) SandboxManager.cleanupAfterCommand() + expect(result.stdout).toContain('BOOTED') + 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('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( + splitPathEntries([ + '/a/*.env', + { path: '/a/*.env', literal: true }, + '/b', + { path: '~/c', literal: true }, + ]), + ).toEqual({ spelled: ['/a/*.env', '/b'], marked: ['/a/*.env', '~/c'] }) + expect(splitPathEntries(undefined)).toEqual({ spelled: [], marked: [] }) + }) + + it.each([ + ['an object without the mark', { path: '/a' }], + ['a mark that is not true', { path: '/a', literal: false }], + ['a mark that is a string', { path: '/a', literal: 'true' }], + ['a path that is not a string', { path: 1, literal: true }], + ['an empty path', { path: '', literal: true }], + ['null', null], + ['a number', 1], + ['a list', ['/a']], + ])('refuses %s rather than guess', (_what, entry) => { + // initialize() runs no schema: this is a hand-built config's only check. + expect(() => + splitPathEntries([entry as unknown as FilesystemPathEntry]), + ).toThrow(TypeError) + }) + + it('keys a marked path apart from the same spelling, and by value', () => { + expect(pathEntryKey('/a')).not.toBe( + pathEntryKey({ path: '/a', literal: true }), + ) + expect(pathEntryKey({ path: '/a', literal: true })).toBe( + pathEntryKey(structuredClone({ path: '/a', literal: true as const })), + ) + }) + + it('folds the literal lists into the lists they are more entries of', () => { + expect( + readNamesOf({ + denyOnly: ['/d'], + allowWithinDeny: ['/d/a'], + unlistableDenyDirs: ['/u'], + literalDenyOnly: ['/l*'], + literalAllowWithinDeny: ['/d/l*'], + }), + ).toEqual({ + denyOnly: ['/d', '/l*'], + allowWithinDeny: ['/d/a', '/d/l*'], + unlistableDenyDirs: ['/u'], + }) + expect(readNamesOf({ denyOnly: ['/d'] })).toEqual({ denyOnly: ['/d'] }) + expect( + readNamesOf({ denyOnly: [], literalAllowWithinDeny: ['/a'] }), + ).toEqual({ denyOnly: [], allowWithinDeny: ['/a'] }) + expect( + writeNamesOf({ + allowOnly: ['/w'], + denyWithinAllow: ['/w/d'], + literalAllowOnly: ['/l*'], + literalDenyWithinAllow: ['/w/l*'], + }), + ).toEqual({ allowOnly: ['/w', '/l*'], denyWithinAllow: ['/w/d', '/w/l*'] }) + expect(readNamesOf(undefined)).toBeUndefined() + expect(writeNamesOf(undefined)).toBeUndefined() + }) + + it('counts a marked allow among what a write config allows', () => { + expect( + writeRootsOf({ allowOnly: ['/w'], literalAllowOnly: ['/l*'] }), + ).toEqual(['/w', '/l*']) + expect(writeRootsOf({ allowOnly: ['/w'] })).toEqual(['/w']) + }) +}) + +// ============================================================================ +// Linux: real commands under bubblewrap +// ============================================================================ + +describe.if(isLinux)( + 'Linux: entries inside a folder named [WIP] project', + () => { + const CAN_RUN = bwrapCanNamespace() + const savedCwd = process.cwd() + let root: string + let project: string + + beforeEach(() => { + 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) + }) + + afterEach(async () => { + process.chdir(savedCwd) + SandboxManager.cleanupAfterCommand() + await SandboxManager.reset() + 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).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 () => { + const kept = join(project, 'keep', 'file') + const wrapped = await wrapCommandWithSandboxLinux({ + command: + `echo BOOTED; echo tampered > ${q(kept)} && echo WROTE; ` + + `cat ${q(join(project, 'secret.txt'))}; ` + + `echo fine > ${q(join(project, 'new'))} && echo SIBLING`, + needsNetworkRestriction: false, + readConfig: { denyOnly: [join(project, 'secret.txt')] }, + writeConfig: { + allowOnly: [`${project}/`], + denyWithinAllow: [join(project, 'keep')], + }, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: root, + timeout: RUN_TIMEOUT_MS, + }) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).not.toContain('WROTE') + expect(result.stdout).not.toContain('SECRET-FILE') + expect(result.stdout).toContain('SIBLING') + expect(readFileSync(kept, 'utf8')).toBe('original\n') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN).each([ + ['*/../keep', 'keep/file'], + ['[ab]/..', 'secret.txt'], + ])( + 'makes nothing writable for a direct caller that spells %p, which is not there', + async (tail, target) => { + // Nothing is named `*` or `[ab]`. Resolved as a name, the string would + // lose that component to its parent reference and name what is there. + const kept = join(project, target) + const before = readFileSync(kept, 'utf8') + const wrapped = await wrapCommandWithSandboxLinux({ + command: `echo BOOTED; echo tampered > ${q(kept)} && echo WROTE; echo END`, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { + allowOnly: [`${project}/${tail}`], + denyWithinAllow: [], + }, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: root, + timeout: RUN_TIMEOUT_MS, + }) + expect(result.stdout).toContain('BOOTED') + expect(result.stdout).not.toContain('WROTE') + expect(result.stdout).toContain('END') + expect(readFileSync(kept, 'utf8')).toBe(before) + }, + 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 +// ============================================================================ + +describe.if(!isWindows)('an entry marked literal', () => { + const CAN_RUN = isLinux && bwrapCanNamespace() + const savedCwd = process.cwd() + let root: string + let dir: string + let starred: string + + beforeEach(() => { + root = freshRoot() + dir = join(root, 'x') + starred = join(dir, '*.env') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'a.env'), 'ENV-A\n') + process.chdir(root) + }) + + afterEach(async () => { + process.chdir(savedCwd) + SandboxManager.cleanupAfterCommand() + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + }) + + const marked = (path: string): FilesystemPathEntry => ({ + path, + literal: true, + }) + + it('travels in the literal lists, as spelled, and is never expanded', async () => { + await initialize({ + denyRead: [marked(starred), marked('~/x/[y]')], + allowRead: [marked(join(dir, 'a?'))], + allowWrite: [marked(join(root, 'out*')), root], + denyWrite: [marked(join(root, 'keep/**'))], + }) + const read = SandboxManager.getFsReadConfig() + expect(read.denyOnly).toEqual([]) + expect(read.allowWithinDeny).toEqual([]) + expect(read.literalDenyOnly).toEqual([starred, '~/x/[y]']) + expect(read.literalAllowWithinDeny).toEqual([join(dir, 'a?')]) + const write = SandboxManager.getFsWriteConfig() + expect(write.allowOnly).toEqual([...getDefaultWritePaths(), root]) + expect(write.literalAllowOnly).toEqual([join(root, 'out*')]) + expect(write.denyWithinAllow).toEqual([]) + // A `/**` at the end of a marked path is part of the name. + expect(write.literalDenyWithinAllow).toEqual([join(root, 'keep/**')]) + if (isLinux) + expect(SandboxManager.getLinuxGlobPatternWarnings()).toEqual([]) + }) + + it.skipIf(!CAN_RUN)( + 'denies the file of that name and not what the pattern would match', + async () => { + const read = `cat ${q(join(dir, 'a.env'))}; cat ${q(starred)} 2>/dev/null; echo END` + const before = await sandboxed({ denyRead: [marked(starred)] }, read, { + cwd: root, + }) + expect(before.stdout).toContain('ENV-A') + writeFileSync(starred, 'ENV-STAR\n') + const after = await sandboxed({ denyRead: [marked(starred)] }, read, { + cwd: root, + }) + expect(after.stdout).toContain('ENV-A') + expect(after.stdout).not.toContain('ENV-STAR') + }, + WRAP_TIMEOUT_MS, + ) + + it('compiles to a subpath and to no regex in the macOS profile', () => { + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { + denyOnly: [], + literalDenyOnly: [starred], + literalAllowWithinDeny: [join(dir, 'a?')], + }, + writeConfig: { + allowOnly: [], + denyWithinAllow: [], + literalAllowOnly: [join(root, 'out*')], + literalDenyWithinAllow: [join(root, 'keep[1]')], + }, + }) + expect(ruleOf(profile, '(deny file-read*')).toContain(subpath(starred)) + expect(ruleOf(profile, '(allow file-read*\n')).toContain( + subpath(join(dir, 'a?')), + ) + expect(ruleOf(profile, '(allow file-write*')).toContain( + subpath(join(root, 'out*')), + ) + expect(ruleOf(profile, '(deny file-write*')).toContain( + subpath(join(root, 'keep[1]')), + ) + // No regex of the profile matches what a path would match as a pattern. + const regexes = emittedRegexes(profile) + for (const matched of [ + join(dir, 'a.env'), + join(dir, 'ab'), + join(root, 'outX'), + join(root, 'keep1'), + ]) { + expect(regexes.some(re => re.test(matched))).toBe(false) + } + }) + + it('is a read restriction on its own in the macOS profile', () => { + const wrapped = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [], literalDenyOnly: [starred] }, + writeConfig: undefined, + }) + expect(wrapped).toContain(subpath(starred)) + }) + + it.skipIf(!CAN_RUN)( + 'is a read restriction on its own for the Linux wrapper', + async () => { + writeFileSync(starred, 'ENV-STAR\n') + const wrapped = await wrapCommandWithSandboxLinux({ + command: `cat ${q(starred)} ${q(join(dir, 'a.env'))}`, + needsNetworkRestriction: false, + readConfig: { denyOnly: [], literalDenyOnly: [starred] }, + writeConfig: undefined, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: root, + timeout: RUN_TIMEOUT_MS, + }) + expect(result.stdout).toContain('ENV-A') + expect(result.stdout).not.toContain('ENV-STAR') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'makes a marked path writable, and a marked path inside it read-only', + async () => { + const out = join(root, 'out*') + mkdirSync(join(out, 'keep[1]'), { recursive: true }) + writeFileSync(join(out, 'keep[1]', 'file'), 'original\n') + mkdirSync(join(root, 'outside')) + const { stdout } = await sandboxed( + { + allowWrite: [marked(out)], + denyWrite: [marked(join(out, 'keep[1]'))], + }, + `echo hi > ${q(join(out, 'new'))} && echo WROTE; ` + + `echo tampered > ${q(join(out, 'keep[1]', 'file'))} && echo TAMPERED; ` + + `echo hi > ${q(join(root, 'outside', 'new'))} && echo ESCAPED`, + { cwd: root }, + ) + expect(stdout).toContain('WROTE') + expect(stdout).not.toContain('TAMPERED') + expect(stdout).not.toContain('ESCAPED') + expect(readFileSync(join(out, 'keep[1]', 'file'), 'utf8')).toBe( + 'original\n', + ) + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'denies a write to a path that is not there yet, in a folder that is not there yet', + async () => { + // An absent deny path gets a placeholder at its first missing + // component, which is removed from the host once the command is done. + const folder = join(root, '[new]') + const secret = join(folder, 'secret') + const { stdout } = await sandboxed( + { allowWrite: [root], denyWrite: [marked(secret)] }, + `mkdir -p ${q(folder)} 2>/dev/null; echo x > ${q(secret)} && echo CREATED; ` + + `echo fine > ${q(join(root, 'other'))} && echo SIBLING`, + { cwd: root, keepMountPoints: true }, + ) + expect(stdout).not.toContain('CREATED') + expect(stdout).toContain('SIBLING') + expect(existsSync(secret)).toBe(false) + SandboxManager.cleanupAfterCommand() + expect(existsSync(folder)).toBe(false) + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'denies a read of a path that is not there yet from the wrap after it appears', + async () => { + // Nothing is mounted for a read deny whose path is absent, marked or + // not; the path is looked at again for the next command. + const late = join(root, '[late]', 'secret') + const policy = { denyRead: [marked(late)] } + const read = `cat ${q(late)} 2>/dev/null; echo END` + expect((await sandboxed(policy, read, { cwd: root })).stdout).toContain( + 'END', + ) + mkdirSync(join(root, '[late]')) + writeFileSync(late, 'LATE-SECRET\n') + const { stdout } = await sandboxed(policy, read, { cwd: root }) + expect(stdout).not.toContain('LATE-SECRET') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'keeps the masks of a deny pattern beneath a marked allow', + async () => { + // A match of a deny pattern that a directory match above it already + // hides gets no mount of its own, unless a path bound back over that + // directory lies between the two. A marked allow is one such path. + const vault = join(root, 'tree', 'vault.key') + const open = join(vault, 'open[1]') + mkdirSync(open, { recursive: true }) + writeFileSync(join(vault, 'top'), 'TOP\n') + writeFileSync(join(open, 'ok'), 'OPEN\n') + writeFileSync(join(open, 'inner.key'), 'INNER\n') + const { stdout } = await sandboxed( + { + denyRead: [`${root}/tree/**/*.key`], + allowRead: [marked(open)], + }, + `cat ${q(join(vault, 'top'))} ${q(join(open, 'inner.key'))} 2>/dev/null; ` + + `cat ${q(join(open, 'ok'))}`, + { cwd: root }, + ) + expect(stdout).not.toContain('TOP') + expect(stdout).not.toContain('INNER') + expect(stdout).toContain('OPEN') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'keeps them beneath a marked write allow too', + async () => { + const vault = join(root, 'tree', 'vault.key') + const out = join(vault, 'out[1]') + mkdirSync(out, { recursive: true }) + writeFileSync(join(vault, 'top'), 'TOP\n') + writeFileSync(join(out, 'inner.key'), 'INNER\n') + const { stdout } = await sandboxed( + { + denyRead: [`${root}/tree/**/*.key`], + allowWrite: [marked(out)], + }, + `cat ${q(join(vault, 'top'))} ${q(join(out, 'inner.key'))} 2>/dev/null; ` + + `echo hi > ${q(join(out, 'new'))} && echo WROTE`, + { cwd: root }, + ) + expect(stdout).not.toContain('TOP') + expect(stdout).not.toContain('INNER') + expect(stdout).toContain('WROTE') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'reaches the wrap from a per-command config', + async () => { + writeFileSync(starred, 'ENV-STAR\n') + const { stdout } = await sandboxed( + {}, + `cat ${q(join(dir, 'a.env'))}; cat ${q(starred)} 2>/dev/null; echo END`, + { cwd: root, perWrap: { denyRead: [marked(starred)] } }, + ) + expect(stdout).toContain('ENV-A') + expect(stdout).not.toContain('ENV-STAR') + }, + WRAP_TIMEOUT_MS, + ) + + it.skipIf(!CAN_RUN)( + 'reaches the wrap from a per-command config in each of the four lists', + async () => { + const out = join(root, 'out*') + const kept = join(out, 'keep[1]', 'file') + const secrets = join(root, 'secrets?') + const open = join(secrets, 'open[1]') + mkdirSync(join(out, 'keep[1]'), { recursive: true }) + mkdirSync(open, { recursive: true }) + writeFileSync(kept, 'original\n') + writeFileSync(join(secrets, 'key'), 'SECRET\n') + writeFileSync(join(open, 'ok'), 'OPEN\n') + const { stdout } = await sandboxed( + {}, + `echo hi > ${q(join(out, 'new'))} && echo WROTE; ` + + `echo tampered > ${q(kept)} && echo TAMPERED; ` + + `cat ${q(join(secrets, 'key'))} ${q(join(open, 'ok'))} 2>/dev/null; echo END`, + { + cwd: root, + perWrap: { + denyRead: [marked(secrets)], + allowRead: [marked(open)], + allowWrite: [marked(out)], + denyWrite: [marked(join(out, 'keep[1]'))], + }, + }, + ) + expect(stdout).toContain('WROTE') + expect(stdout).not.toContain('TAMPERED') + expect(stdout).not.toContain('SECRET') + expect(stdout).toContain('OPEN') + expect(stdout).toContain('END') + expect(readFileSync(kept, 'utf8')).toBe('original\n') + }, + WRAP_TIMEOUT_MS, + ) + + it.if(isLinux)( + 'is in the lists the violation monitor judges a write by', + async () => { + const out = join(root, 'out*') + const kept = join(out, 'keep[1]') + mkdirSync(kept, { recursive: true }) + let handedOver: LinuxViolationMonitorOptions | undefined + const spy = spyOn( + linuxViolationMonitorModule, + 'startLinuxSandboxViolationMonitor', + ).mockImplementation((_callback, opts) => { + handedOver = opts + return { + observeSocketPath: undefined, + ready: Promise.resolve(), + stop: () => {}, + } + }) + try { + await SandboxManager.reset() + await SandboxManager.initialize( + { + network: noNetwork, + filesystem: { + denyRead: [], + allowWrite: [marked(out)], + denyWrite: [marked(kept)], + }, + }, + undefined, + true, + ) + } finally { + spy.mockRestore() + } + expect(handedOver?.allowWritePaths).toContain(out) + expect(handedOver?.denyWritePaths).toContain(kept) + + // The listener itself, with what the manager handed over: a write the + // sandbox permits is no violation, and one it refuses is. + const lines: string[] = [] + const monitor = startLinuxSandboxViolationMonitor( + violation => lines.push(violation.line), + handedOver!, + ) + await monitor.ready + try { + await new Promise((resolve, reject) => { + const client = connect(monitor.observeSocketPath!, () => { + client.write( + [join(out, 'new'), join(kept, 'file')] + .map(path => JSON.stringify({ syscall: 'openat', path })) + .join('\n') + '\n', + ) + client.end() + }) + client.on('close', () => resolve()) + client.on('error', reject) + }) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(lines).toEqual([`deny openat ${join(kept, 'file')}`]) + } finally { + monitor.stop() + } + }, + WRAP_TIMEOUT_MS, + ) + + it('comes back from getConfig() and updateConfig() as it went in', async () => { + const filesystem = { + denyRead: [marked(starred), join(dir, 'plain')], + allowRead: [marked(join(dir, 'a?'))], + allowWrite: [marked(join(root, 'out*'))], + denyWrite: [marked(join(root, 'keep[1]'))], + } + await initialize(filesystem) + expect(SandboxManager.getConfig()?.filesystem).toEqual(filesystem) + const updated = { + ...filesystem, + denyRead: [marked(join(dir, '[later]'))], + } + SandboxManager.updateConfig({ network: noNetwork, filesystem: updated }) + expect(SandboxManager.getConfig()?.filesystem).toEqual(updated) + expect(SandboxManager.getFsReadConfig().literalDenyOnly).toEqual([ + join(dir, '[later]'), + ]) + }) + + it('comes back from a settings file and from a control line as it went in', () => { + const config = { + network: noNetwork, + filesystem: { + denyRead: [marked(starred), '/plain/*.pem'], + allowRead: [marked('~/a?')], + allowWrite: [marked('./out*')], + denyWrite: [marked(join(root, 'keep[1]/'))], + }, + } + const file = join(root, 'settings.json') + writeFileSync(file, JSON.stringify(config)) + const loaded = loadConfig(file) + expect(loaded.kind).toBe('ok') + expect(loaded.kind === 'ok' && loaded.config.filesystem).toEqual( + config.filesystem, + ) + expect(loadConfigFromString(JSON.stringify(config))?.filesystem).toEqual( + config.filesystem, + ) + }) + + it('is refused by a settings file when the mark is not the mark', () => { + const file = join(root, 'settings.json') + writeFileSync( + file, + JSON.stringify({ + network: noNetwork, + filesystem: { + denyRead: [{ path: starred, literal: false }], + allowWrite: [], + denyWrite: [], + }, + }), + ) + const loaded = loadConfig(file) + expect(loaded.kind).toBe('invalid') + expect(loaded.kind === 'invalid' && loaded.reason).toContain( + 'filesystem.denyRead.0: Expected a path, or { "path": "", "literal": true }', + ) + }) + + it('is refused at the wrap when a config that skipped the schema holds half a mark', async () => { + await initialize({ + denyRead: [{ path: starred } as unknown as FilesystemPathEntry], + }) + const refusal = 'must be a path, or { path, literal: true }' + expect(() => SandboxManager.getFsReadConfig()).toThrow(refusal) + expect(() => SandboxManager.getFsWriteConfig()).toThrow(refusal) + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun:test types .rejects.toThrow() as void; the await is required at runtime + await expect(SandboxManager.wrapWithSandbox('true')).rejects.toThrow( + refusal, + ) + }) +}) + +describe.if(!isWindows)( + 'default write paths under read rules with a name in them', + () => { + const script = (body: string): string => ` + 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}` + + function under(home: string, body: string): unknown { + const result = spawnSync(process.execPath, ['-e', script(body)], { + env: { ...process.env, HOME: home }, + encoding: 'utf8', + timeout: WRAP_TIMEOUT_MS, + }) + if (result.status !== 0) throw new Error(result.stderr) + return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') + } + + let root: string + + beforeAll(() => { + root = freshRoot() + for (const home of ['home', '[home]']) { + mkdirSync(join(root, home, '.claude', 'debug'), { recursive: true }) + mkdirSync(join(root, home, '.npm', '_logs'), { recursive: true }) + } + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('drops a directory a marked deny covers and keeps one a marked allow re-opens', () => { + expect( + under( + join(root, 'home'), + `console.log(JSON.stringify({ + denied: kept(getDefaultWritePaths({ denyRead: [{ path: '~/.claude', literal: true }] })), + reopened: kept(getDefaultWritePaths({ + denyRead: [{ path: '~/.claude', literal: true }], + allowRead: [{ path: '~/.claude/debug', literal: true }], + })), + asPattern: kept(getDefaultWritePaths({ denyRead: [{ path: '~/.c*', literal: true }] })), + }))`, + ), + ).toEqual({ + denied: ['.npm/_logs'], + reopened: ['.npm/_logs', '.claude/debug'], + 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'], + }) + }) + }, +)