From c5a69e05c91ef9782d40dc1f1717fa8e9c2e85ae Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 28 Sep 2026 18:37:39 +0000 Subject: [PATCH] Let a caller mark a path entry literal: { path, literal: true } `denyRead`, `allowRead`, `allowWrite` and `denyWrite` take `{ path, literal: true }` beside a string. Such an entry is a path and never a pattern, whether or not it exists, so it can name a file in a folder whose name holds `[`, `*` or `?`, or one that is not there yet. Marked paths travel beside the spellings, in `literal...` lists on the read and write restriction configs, so no backend reads their characters again. The Linux wrapper folds those lists in once and takes every path it is handed for a name, which an eslint rule holds; macOS compiles a marked path to a subpath filter; Windows takes it down its literal branch and skips one that holds `*` or `?`. `writeRootsOf()` is exported for whoever asks what a command may write. --- README.md | 3 + eslint.config.js | 21 + src/index.ts | 2 + src/sandbox/linux-sandbox-utils.ts | 28 +- src/sandbox/macos-sandbox-utils.ts | 56 +- src/sandbox/path-entries.ts | 188 +++++ src/sandbox/read-deny-glob.ts | 5 +- src/sandbox/sandbox-config.ts | 39 +- src/sandbox/sandbox-manager.ts | 96 ++- src/sandbox/sandbox-schemas.ts | 13 + src/sandbox/sandbox-utils.ts | 69 +- src/sandbox/windows-sandbox-utils.ts | 27 +- test/config-validation.test.ts | 115 +++ test/public-exports.test.ts | 1 + test/sandbox/glob-expand.test.ts | 102 ++- test/sandbox/literal-path-entries.test.ts | 972 ++++++++++++++++++++++ 16 files changed, 1659 insertions(+), 78 deletions(-) create mode 100644 src/sandbox/path-entries.ts create mode 100644 test/sandbox/literal-path-entries.test.ts diff --git a/README.md b/README.md index bd96ada3b..57493ced5 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,9 @@ Examples: - Paths can be absolute (e.g., `/home/user/.ssh`) or relative to the current working directory (e.g., `./src`) - `~` expands to the user's home directory - A deny glob must not end in a separator: the separator becomes part of the compiled pattern, so the pattern can match no path. `denyRead`, `denyWrite` and a `mode: "deny"` credential file reject such an entry at config validation — write `/data/*`, or add a `**` segment to match at any depth. +- To name a path and nothing else, write `{ "path": "/work/*.env", "literal": true }` in place of the string. Such an entry is never a pattern, whether or not the path exists, so it can deny a file that is not there yet in a folder that is not there yet. `~` and relative paths are resolved as for a string, symbolic links are followed as for any path without such characters, and a `/**` at the end is part of the name. `denyRead`, `allowRead`, `allowWrite` and `denyWrite` take this form; `credentials.files` does not. An object without `"literal": true`, or with any other key, is rejected. +- Not covered: on Linux, a `denyRead` for a path that does not exist when the command is wrapped hides nothing in that command, marked or not. On Windows, `*` and `?` cannot be part of a name, so a marked path that holds one is skipped. +- For embedders: the four lists of `SandboxRuntimeConfig` are of type `FilesystemPathEntry[]`, which is `(string | { path: string; literal: true })[]`, and `getConfig()` gives entries back in the form they were given. Code that reads an entry as a string stops compiling; `typeof entry === 'string' ? entry : entry.path` is its path. Three things change without a compile error. A list joined into text prints `[object Object]` for a marked entry. `getFsReadConfig()` and `getFsWriteConfig()` carry marked entries in `literalDenyOnly`, `literalAllowWithinDeny`, `literalAllowOnly` and `literalDenyWithinAllow`, each of which may be absent when it has no entries, so hand those objects on whole instead of rebuilding them from `denyOnly` and `allowOnly`; `writeRootsOf(getFsWriteConfig())` is every path the command may write. And an empty `denyOnly` no longer means that nothing is read-denied. `getDefaultWritePaths()` and `expandWindowsFsPaths()` take the lists as configured. `wrapCommandWithSandboxLinux()` takes every path it is handed for a name, as bubblewrap does, and skips a string in `allowOnly` that has `*`, `?`, `[` or `]` in it unless something is there under that very spelling. #### Other Configuration 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..8753a5b41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { SandboxRuntimeConfig, NetworkConfig, FilesystemConfig, + FilesystemPathEntry, CredentialsConfig, CredentialFileConfig, CredentialEnvVarConfig, @@ -124,6 +125,7 @@ export { // Utility functions export { getDefaultWritePaths } from './sandbox/sandbox-utils.js' +export { writeRootsOf } from './sandbox/path-entries.js' // Platform utilities export { getWslVersion } from './utils/platform.js' diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index b09532eaa..15360b857 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, @@ -1776,7 +1777,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 @@ -1872,7 +1873,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 } @@ -1926,7 +1927,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) @@ -1954,10 +1955,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 @@ -1967,7 +1967,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(/\/+$/, '') || '/' @@ -2159,7 +2159,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 } @@ -2308,7 +2308,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/')) { @@ -2968,8 +2968,6 @@ export async function wrapCommandWithSandboxLinux( proxyAuthToken, caCertPath, javaAgentJarPath, - readConfig, - writeConfig, unsetEnvVars, setEnvVars, maskedFileBinds, @@ -2987,6 +2985,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..c632771d9 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -289,7 +289,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 +298,17 @@ function toLiteralPathEntry(literalPath: string): PathEntry { } } +/** One of the caller's lists as entries: spellings, then marked paths. */ +function callerEntries( + spellings: readonly string[] | undefined, + literalPaths: readonly string[] | undefined, +): PathEntry[] { + return [ + ...(spellings ?? []).map(toPathEntry), + ...(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 +333,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 +342,18 @@ function resolveReadConfig( libraryDenies: readonly PathEntry[], ): ResolvedReadConfig { return { - denies: [...(config.denyOnly || []).map(toPathEntry), ...libraryDenies], + denies: [ + ...callerEntries(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( + config.allowWithinDeny, + config.literalAllowWithinDeny, + ), + writeRoots: [...writeRoots], } } @@ -863,13 +880,24 @@ function generateReadRules( return rules } +/** + * What a write config allows, as entries: read once per profile, for the + * read and the write section alike, so neither can miss the marked paths. + */ +function writeRootEntries( + config: FsWriteRestrictionConfig | undefined, +): PathEntry[] { + return callerEntries(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 +907,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,6 +917,7 @@ function generateWriteRules( // the mandatory entries carry their own literal/glob split. const denyEntries = [ ...(config.denyWithinAllow || []).map(toPathEntry), + ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), ...macGetMandatoryDenyEntries(allowGitConfig), ] @@ -1231,6 +1260,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 +1268,7 @@ function generateSandboxProfile({ readConfig || libraryDenyEntries.length > 0 ? resolveReadConfig( readConfig ?? { denyOnly: [] }, - writeConfig?.allowOnly, + writeRoots, libraryDenyEntries, ) : undefined @@ -1248,7 +1278,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 +1379,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..cee9f199f --- /dev/null +++ b/src/sandbox/path-entries.ts @@ -0,0 +1,188 @@ +/** + * The entries of `denyRead`, `allowRead`, `allowWrite` and `denyWrite` as + * configured: a spelling, which is a pattern when it holds `*`, `?`, `[` or + * `]`, or a path marked `{ path, literal: true }`, which is the name and + * nothing else, whatever it holds. + * + * Marked paths travel beside the spellings, in the `literal…` lists of the + * restriction configs, so that no backend reads their characters again. + */ + +import * as fs from 'node:fs' +import { + containsGlobChars, + expandTilde, + markedLiteralPath, +} from './sandbox-utils.js' +import type { FilesystemPathEntry } from './sandbox-config.js' +import type { + FsReadRestrictionConfig, + FsWriteRestrictionConfig, +} from './sandbox-schemas.js' + +/** + * 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 +} + +/** + * Whether two lists hold the same entries, in any order. Entries are told + * apart by value: a marked entry is a new object after every clone of the + * config, and `/x` is not `{ path: '/x' }`. + */ +export function samePathEntries( + a: readonly FilesystemPathEntry[], + b: readonly FilesystemPathEntry[], +): boolean { + const key = (entry: FilesystemPathEntry): string => + typeof entry === 'string' ? `s:${entry}` : `l:${markedLiteralPath(entry)}` + if (a.length !== b.length) return false + const keys = new Set(a.map(key)) + return b.every(entry => keys.has(key(entry))) +} + +/** + * Whether something is at `spelling`, as the kernel resolves it. `realpath` + * cannot be asked: it folds a `..` by the text first. + */ +function isThereAsSpelled(spelling: string): boolean { + try { + fs.lstatSync(expandTilde(spelling)) + return true + } catch { + return false + } +} + +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 something is + * there under that very spelling. Any other is a pattern and no name: + * resolving a name folds a `..` by the text, so `/home/*\/../.ssh` would + * make `/home/.ssh` writable where nothing is named `*`. + */ +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) || isThereAsSpelled(p), + ), + literalAllowOnly, + }), + denyWithinAllow: [ + ...(config.denyWithinAllow || []), + ...(literalDenyWithinAllow ?? []), + ], + } +} diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 717017035..cc7a9fe68 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -159,8 +159,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 8bd4cef6f..7d240ffaa 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -138,6 +138,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 @@ -319,7 +341,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 @@ -905,19 +927,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() @@ -1199,10 +1223,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) } } @@ -1463,6 +1491,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..a883589f1 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1,3 +1,11 @@ +import { + literalReadLists, + literalWriteLists, + reExposedBy, + samePathEntries, + spelledOf, + writeRootsOf, +} 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 +38,7 @@ import * as fs from 'fs' import { randomBytes } from 'node:crypto' import type { CredentialsConfig, + FilesystemPathEntry, SandboxRuntimeConfig, SeccompConfig, } from './sandbox-config.js' @@ -699,7 +708,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 +728,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 @@ -1230,8 +1242,8 @@ function defaultWritePathsUnder({ allowRead, credentials, }: { - denyRead: readonly string[] - allowRead: readonly string[] | undefined + denyRead: readonly FilesystemPathEntry[] + allowRead: readonly FilesystemPathEntry[] | undefined credentials: CredentialsConfig | undefined }): string[] { return getDefaultWritePaths({ @@ -1258,6 +1270,8 @@ function unionDenyReadPaths( * Strip a trailing `/**` from each read-path entry and, on Linux, replace * any remaining glob with what `expandGlob` returns for it (bubblewrap takes * concrete paths only). Other platforms match globs natively. + * Entries the caller marked literal are not in the result: they travel in + * the `literal…` lists. * * `literalPaths` are the ones the library resolved itself (a masked * credential file that degraded to deny) — each names one file on disk, so @@ -1265,12 +1279,12 @@ function unionDenyReadPaths( * pattern, a name holding `[` matches nothing and the deny is lost. */ function resolveReadPathEntries( - paths: readonly string[], + paths: readonly FilesystemPathEntry[], expandGlob: (pattern: 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) && @@ -1284,9 +1298,10 @@ 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. + * Marked entries are not in the result, as in {@link resolveReadPathEntries}. */ -function stripWriteGlobs(paths: readonly string[]): string[] { - return paths +function stripWriteGlobs(paths: readonly FilesystemPathEntry[]): string[] { + return spelledOf(paths) .map(p => removeTrailingGlobSuffix(p)) .filter(p => { if (getPlatform() === 'linux' && containsGlobChars(p)) { @@ -1308,9 +1323,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) { @@ -1329,10 +1345,17 @@ function getFsReadConfig(): FsReadRestrictionConfig { 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), + unionDenyReadPaths( + spelledOf(config.filesystem.denyRead), + credentialRestrictions, + ), pattern => expandReadDenyGlobLinux(pattern, reExposedPaths, unlistableDenyDirs), credentialRestrictions.degradeToDenyPaths, @@ -1342,6 +1365,10 @@ function getFsReadConfig(): FsReadRestrictionConfig { denyOnly: denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + config.filesystem.denyRead, + config.filesystem.allowRead, + ), } } @@ -1369,6 +1396,10 @@ function getFsWriteConfig(): FsWriteRestrictionConfig { return { allowOnly, denyWithinAllow: denyPaths, + ...literalWriteLists( + config.filesystem.allowWrite, + config.filesystem.denyWrite, + ), } } @@ -1450,23 +1481,17 @@ function rawWindowsFsInputs(c: SandboxRuntimeConfig) { } } -function setEq(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) return false - const s = new Set(a) - return b.every(x => s.has(x)) -} - function sameRawWindowsFsInputs( a: ReturnType, b: ReturnType, ): boolean { return ( a.disabled === b.disabled && - setEq(a.denyRead, b.denyRead) && - setEq(a.denyWrite, b.denyWrite) && - setEq(a.allowRead, b.allowRead) && - setEq(a.allowWrite, b.allowWrite) && - setEq(a.credFiles, b.credFiles) + samePathEntries(a.denyRead, b.denyRead) && + samePathEntries(a.denyWrite, b.denyWrite) && + samePathEntries(a.allowRead, b.allowRead) && + samePathEntries(a.allowWrite, b.allowWrite) && + samePathEntries(a.credFiles, b.credFiles) ) } @@ -1694,6 +1719,10 @@ async function wrapWithSandbox( 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 @@ -1715,11 +1744,17 @@ 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( unionDenyReadPaths( - customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], + spelledOf( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + ), credentialRestrictions, ), pattern => @@ -1730,6 +1765,10 @@ async function wrapWithSandbox( denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, + ), } } @@ -2361,6 +2400,7 @@ function annotateStderrWithSandboxFailures( * sandboxing is disabled. * * Patterns ending with /** are excluded since they work as subpaths. + * So are entries marked literal. */ function getLinuxGlobPatternWarnings(): string[] { // Only warn on Linux/WSL (bubblewrap doesn't support globs) @@ -2379,6 +2419,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 +2437,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 cf4e00d1a..713ad9863 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,6 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' +import type { FilesystemPathEntry } from './sandbox-config.js' /** * Dangerous files that should be protected from writes. @@ -136,6 +137,30 @@ export function containsGlobCharsForPlatform(p: string): boolean { : containsGlobChars(p) } +/** + * 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 `?` @@ -381,14 +406,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, 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 and did not mark keeps the + * character sniffing: there the brackets are the glob syntax it asked for. + * The interior collapse below is not one of the glob branches: `//` and + * `/./` are dead spellings either way. */ export function normalizePathForSandbox( pathPattern: string, @@ -570,10 +596,12 @@ 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. */ export function getDefaultWritePaths(readRules?: { - denyRead: readonly string[] - allowRead?: readonly string[] + denyRead: readonly FilesystemPathEntry[] + allowRead?: readonly FilesystemPathEntry[] }): string[] { const home = homedir() const keptDirs = @@ -592,8 +620,8 @@ export function getDefaultWritePaths(readRules?: { */ function homeDirsNotReadDenied( home: string, - denyRead: readonly string[], - allowRead: readonly string[] = [], + denyRead: readonly FilesystemPathEntry[], + allowRead: readonly FilesystemPathEntry[] = [], ): 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 +632,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 +660,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: FilesystemPathEntry): (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 { diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index ece8fd95b..d15e2711f 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -14,6 +14,7 @@ import { containsGlobCharsWin, expandGlobPattern, isUncPath, + markedLiteralPath, } from './sandbox-utils.js' // Kept on this module's surface for out-of-tree importers; the // implementations live in sandbox-utils.ts. @@ -25,7 +26,7 @@ export { } from './sandbox-utils.js' import { certThumbprint, generateCa, validateCaPair } from './mitm-ca.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,31 @@ 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). + * + * 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) { 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/public-exports.test.ts b/test/public-exports.test.ts index 38b341b27..5e0006cdb 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -64,6 +64,7 @@ const PUBLIC_VALUE_EXPORTS = [ 'windowsStateDir', 'windowsTrustCa', 'windowsTrustCaAsync', + 'writeRootsOf', ] describe('package entry point', () => { diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index b62efa7d3..7d9cf3298 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -14,7 +14,8 @@ import { symlinkSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' +import type { FilesystemPathEntry } from '../../src/sandbox/sandbox-config.js' import { expandGlobPattern, expandTilde, @@ -940,6 +941,105 @@ describe('expandWindowsFsPaths literal branch', () => { }) }) +// ============================================================================ +// expandWindowsFsPaths — beneath a directory with brackets in its name +// ============================================================================ + +/** + * `[` and `]` are characters of a name on Windows: a path that holds them + * and no `*` or `?` is a literal, marked or not. Pinned here on every host; + * the spellings only Windows has are in the suite below. + */ +describe('expandWindowsFsPaths beneath a directory named [WIP] project', () => { + let root: string + let project: string + + beforeAll(() => { + root = realPath(mkdtempSync(join(tmpdir(), 'win-literal-'))) + project = join(root, '[WIP] project') + mkdirSync(join(project, 'keep'), { recursive: true }) + }) + + 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('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) + } + }) +}) + +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'), { recursive: true }) + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + 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']) + }) + }, +) + // ============================================================================ // 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..cce9c5364 --- /dev/null +++ b/test/sandbox/literal-path-entries.test.ts @@ -0,0 +1,972 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + spyOn, +} from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + 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 { + readNamesOf, + samePathEntries, + splitPathEntries, + writeNamesOf, + writeRootsOf, +} from '../../src/sandbox/path-entries.js' +import type { FilesystemPathEntry } from '../../src/sandbox/sandbox-config.js' +import { getDefaultWritePaths } from '../../src/sandbox/sandbox-utils.js' +import { + loadConfig, + loadConfigFromString, +} from '../../src/utils/config-loader.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Entries marked `{ path, literal: true }`, and what the Linux wrapper does + * with a name that holds `*`, `?`, `[` or `]`: see + * src/sandbox/path-entries.ts. + * + * 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, `'\\''`)}'` +} + +const subpath = (p: string): string => `(subpath ${JSON.stringify(p)})` + +function emittedRegexes(profile: string): RegExp[] { + return [...profile.matchAll(/\(regex ("(?:[^"\\]|\\.)*")\)/g)].map( + match => new RegExp(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) +} + +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 } +} + +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('tells a marked path from the same spelling, and compares by value', () => { + const marked = { path: '/a', literal: true as const } + expect(samePathEntries(['/a'], [marked])).toBe(false) + // updateConfig() clones the config, so the same entry is a new object. + expect( + samePathEntries(['/b', marked], [structuredClone(marked), '/b']), + ).toBe(true) + expect(samePathEntries([marked], [marked, marked])).toBe(false) + }) + + 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: the mounts the wrapper asks for +// ============================================================================ + +/** + * bubblewrap takes no patterns, so every path the wrapper is handed is a + * name, and a name is resolved: a trailing separator or `/.` dropped, a + * parent reference folded. Read as a pattern again, a name with glob + * characters keeps its spelling, and each case below loses its mount. + */ +describe.if(isLinux)( + 'Linux wrapper: a name with glob characters, however it is spelled', + () => { + let root: string + let project: string + let sub: string + let file: string + + beforeAll(() => { + root = freshRoot() + project = join(root, PROJECT) + sub = join(project, 'sub') + file = join(sub, 'file') + mkdirSync(join(sub, 'in'), { recursive: true }) + writeFileSync(file, '') + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + type Wrap = Parameters[0] + const mounts = ( + readConfig: Wrap['readConfig'], + writeConfig: Wrap['writeConfig'], + ): Promise => + wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig, + writeConfig, + }) + const mount = (flag: string, source: string, dest = source): string => + `${flag} ${q(source)} ${q(dest)}` + + it.each([ + ['a trailing /.', (p: string) => `${p}/.`], + ['a trailing separator', (p: string) => `${p}/`], + ])( + 'binds an allowRead path back under its own name, spelled with %s', + async (_how, spell) => { + for (const path of [sub, file]) { + const command = await mounts( + { denyOnly: [project], allowWithinDeny: [spell(path)] }, + undefined, + ) + expect(command).toContain(mount('--ro-bind', path)) + } + }, + ) + + it.each([ + ['a trailing separator', (p: string) => `${p}/`], + ['a trailing /.', (p: string) => `${p}/.`], + [ + 'a folder that is not there', + (p: string) => p.replace('/sub', '/absent/../sub'), + ], + ])('masks a denyRead file spelled with %s', async (_how, spell) => { + const command = await mounts({ denyOnly: [spell(file)] }, undefined) + expect(command).toContain(`--ro-bind /dev/null ${q(file)}`) + }) + + it('takes a denyRead that leads to the root for the root', async () => { + const allowWithinDeny = ['/usr', '/etc', '/bin', '/lib', '/lib64'] + const up = '/..'.repeat(project.split('/').length) + const tmpfsMounts = (command: string): string[] => + [...command.matchAll(/--tmpfs (\S+)/g)].map(match => match[1]!) + const forRoot = tmpfsMounts( + await mounts({ denyOnly: ['/'], allowWithinDeny }, undefined), + ) + expect(forRoot.length).toBeGreaterThan(1) + expect( + tmpfsMounts( + await mounts( + { denyOnly: [project + up], allowWithinDeny }, + undefined, + ), + ), + ).toEqual(forRoot) + }) + + it('applies a denyWrite beneath an allowOnly path 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 command = await mounts(undefined, { + allowOnly: [`${sub}/in/..`], + denyWithinAllow: [file], + }) + expect(command).toContain(mount('--bind', sub)) + expect(command).toContain(mount('--ro-bind', file)) + }) + + it.each([ + ['a trailing separator', (p: string) => `${p}/`], + ['a parent reference', (p: string) => p.replace('/sub', '/sub/in/..')], + ])( + 'binds a denyWrite beneath a denied directory once, spelled with %s', + async (_how, spell) => { + // The directory's own read-only bind covers it, as it does for a path + // without glob characters. + const command = await mounts(undefined, { + allowOnly: [root], + denyWithinAllow: [project, spell(sub)], + }) + expect(command).toContain(mount('--ro-bind', project)) + expect(command).not.toContain(mount('--ro-bind', sub)) + }, + ) + + it('needs no placeholder beneath a denied directory spelled through a read-denied one', async () => { + // The directory is recorded where it resolves to. By its spelling the + // read deny on `hidden` would seem to cover it, and the placeholder + // kept for that reason cannot be created beneath a read-only bind. + const hidden = join(project, 'hidden') + mkdirSync(hidden, { recursive: true }) + const absent = join(sub, 'absent') + const command = await mounts( + { denyOnly: [hidden] }, + { + allowOnly: [root], + denyWithinAllow: [`${hidden}/../sub`, absent], + }, + ) + expect(command).toContain(mount('--ro-bind', sub)) + expect(command).not.toContain(`--ro-bind /dev/null ${q(absent)}`) + }) + }, +) + +// ============================================================================ +// 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 }) + writeFileSync(join(project, 'keep', 'file'), 'original\n') + writeFileSync(join(project, 'secret.txt'), 'SECRET-FILE\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)( + '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, + ) + }, +) + +// ============================================================================ +// 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 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() + 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'], + }) + }) + }, +)