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'],
+ })
+ })
+ },
+)