diff --git a/README.md b/README.md index 780b93423..f68167740 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 7bd052a32..68bb8e3d2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -132,5 +132,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 0884441e5..c6760dbdd 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, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' +import { readNamesOf, writeNamesOf } from './path-entries.js' import { generateProxyEnvVars, buildPosixGitSafeDirEnv, @@ -1945,7 +1946,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 @@ -2041,7 +2042,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 } @@ -2095,7 +2096,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) @@ -2123,10 +2124,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 @@ -2136,7 +2136,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(/\/+$/, '') || '/' @@ -2328,7 +2328,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 } @@ -2477,7 +2477,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/')) { @@ -3163,8 +3163,6 @@ export async function wrapCommandWithSandboxLinux( proxyAuthToken, caCertPath, javaAgentJarPath, - readConfig, - writeConfig, unsetEnvVars, setEnvVars, maskedFileBinds, @@ -3182,6 +3180,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 92f6187b4..d200139db 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -181,8 +181,11 @@ export function* expandReadDenyGlobLinuxSteps( 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 3cad0679c..567f7f103 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() @@ -1201,10 +1225,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) } } @@ -1465,6 +1493,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 4412be9bb..3f49ebe95 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' @@ -703,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 @@ -723,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 @@ -1234,8 +1246,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({ @@ -1262,6 +1274,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 @@ -1269,13 +1283,13 @@ function unionDenyReadPaths( * pattern, a name holding `[` matches nothing and the deny is lost. */ function* resolveReadPathEntries( - paths: readonly string[], + paths: readonly FilesystemPathEntry[], expandGlob: (pattern: string) => Steps, literalPaths: readonly string[] = [], ): Steps { const literal = new Set(literalPaths) const resolved: string[] = [] - for (const p of paths) { + for (const p of spelledOf(paths)) { const stripped = removeTrailingGlobSuffix(p) if ( getPlatform() === 'linux' && @@ -1294,9 +1308,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)) { @@ -1318,9 +1333,10 @@ function* expandAllowReadGlob(pattern: string): Steps { /** * 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) { @@ -1341,12 +1357,19 @@ function getFsReadConfig(): FsReadRestrictionConfig { expandAllowReadGlob, ), ) - const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly] + const reExposedPaths = reExposedBy( + allowPaths, + config.filesystem.allowRead, + getFsWriteConfig(), + ) const unlistableDenyDirs = new Set() const listings: GlobWalkListings = new Map() const denyPaths = finish( resolveReadPathEntries( - unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions), + unionDenyReadPaths( + spelledOf(config.filesystem.denyRead), + credentialRestrictions, + ), pattern => expandReadDenyGlobLinuxSteps( pattern, @@ -1362,6 +1385,10 @@ function getFsReadConfig(): FsReadRestrictionConfig { denyOnly: denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + config.filesystem.denyRead, + config.filesystem.allowRead, + ), } } @@ -1389,6 +1416,10 @@ function getFsWriteConfig(): FsWriteRestrictionConfig { return { allowOnly, denyWithinAllow: denyPaths, + ...literalWriteLists( + config.filesystem.allowWrite, + config.filesystem.denyWrite, + ), } } @@ -1470,23 +1501,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) ) } @@ -1769,6 +1794,10 @@ async function wrapWithSandboxAgain( 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 @@ -1795,15 +1824,19 @@ async function wrapWithSandboxAgain( 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 listings: GlobWalkListings = new Map() const expandedDenyRead = await walked( resolveReadPathEntries( unionDenyReadPaths( - customConfig?.filesystem?.denyRead ?? - config?.filesystem.denyRead ?? - [], + spelledOf( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + ), credentialRestrictions, ), pattern => @@ -1821,6 +1854,10 @@ async function wrapWithSandboxAgain( denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, unlistableDenyDirs: [...unlistableDenyDirs], + ...literalReadLists( + customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, + customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, + ), } } @@ -2474,6 +2511,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) @@ -2492,6 +2530,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) @@ -2509,6 +2548,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 aa664c7ba..78ce8024f 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 3ed93537b..99433e4df 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, @@ -1084,6 +1085,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'], + }) + }) + }, +)