Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ child.on('exit', async code => {
})
```

**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you *execute* is not the command the invocation *represents* (e.g. you wrap an assembled `source <snapshot> && eval '<cmd>'`), also pass `commandText: '<cmd>'`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`.
**Violation attribution (`commandId` / `commandText`).** Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and `annotateStderrWithSandboxFailures(key, stderr)` / `getViolationsForCommand(key)` look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation `commandId` (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you _execute_ is not the command the invocation _represents_ (e.g. you wrap an assembled `source <snapshot> && eval '<cmd>'`), also pass `commandText: '<cmd>'`: it is what `ignoreViolations` command patterns match against and what each violation reports as its `command`.

```typescript
const wrapped = await SandboxManager.wrapWithSandbox(
Expand All @@ -226,7 +226,10 @@ const wrapped = await SandboxManager.wrapWithSandbox(
{ commandId: invocationId, commandText: rawCommand },
)
// ... run it ...
const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr)
const annotated = SandboxManager.annotateStderrWithSandboxFailures(
invocationId,
stderr,
)
```

#### Available exports
Expand Down Expand Up @@ -353,6 +356,8 @@ Uses two different patterns:
- `filesystem.allowWrite` - Array of paths to allow write access. Empty array = no write access.
- `filesystem.denyWrite` - Array of paths to deny write access within allowed paths (takes precedence over allowWrite)

A few paths are writable without being listed: the child's stdio and `/tmp/claude`, and as a convenience `~/.npm/_logs` and `~/.claude/debug`. Those two home directories are dropped when a `denyRead` entry covers them (and kept when an `allowRead` entry beneath that deny re-opens them), so list them in `allowWrite` if you want them writable under a home read-deny.

**Path Syntax (macOS):**

Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax:
Expand Down
15 changes: 1 addition & 14 deletions src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
decodeSandboxedCommand,
containsGlobChars,
globToRegex,
denyGlobRegex,
DANGEROUS_FILES,
getDangerousDirectories,
} from './sandbox-utils.js'
Expand Down Expand Up @@ -137,20 +138,6 @@ function pathFilter(normalizedPath: string): string {
: `(subpath ${escapePath(normalizedPath)})`
}

/**
* Regex for a glob used in a DENY rule: {@link globToRegex} plus an optional
* `/…` tail, so the deny covers everything beneath each match the way
* `subpath` does for literals. Callers strip a trailing `/**` before the
* pattern gets here (removeTrailingGlobSuffix), so `**\/secrets/**` arrives
* as `**\/secrets` and, matched exactly, would deny only the directory
* vnode while `secrets/key` stayed readable. This is what the Linux backend
* already does (a deny masks the whole subtree). Only ever widens a deny.
*/
function denyGlobRegex(normalizedGlob: string): string {
// globToRegex() always returns '^…$'.
return globToRegex(normalizedGlob).slice(0, -1) + '(/.*)?$'
}

/** {@link pathFilter} for deny rules: globs get {@link denyGlobRegex}. */
function denyPathFilter(normalizedPath: string): string {
return containsGlobChars(normalizedPath)
Expand Down
50 changes: 47 additions & 3 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,9 @@ async function initialize(
// apply-seccomp's observer reports every write-intent syscall
// (allowed or not). Only paths bwrap would actually refuse — outside
// allowWrite or inside a denyWrite carve-out — go to the store.
// The defaults are listed in full: a home directory a wrap leaves
// out because it is read-denied sits under that deny's writable
// tmpfs, so a write there succeeds and is not a violation.
allowWritePaths: [
...getDefaultWritePaths(),
...config.filesystem.allowWrite,
Expand Down Expand Up @@ -1150,6 +1153,30 @@ function getCredentialDenyReadPaths(
return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))]
}

/**
* The default write paths under a filesystem policy's read rules, for
* getFsWriteConfig() and wrapWithSandbox() alike. Fed the entries as
* configured, through the pure {@link getCredentialDenyReadPaths}: no glob
* expansion and no credential masking, because getFsWriteConfig() is a
* getter callers reach from permission checks and render paths. A mask entry
* that degrades to a deny is a file and cannot cover a directory, so
* leaving those out changes nothing.
*/
function defaultWritePathsUnder({
denyRead,
allowRead,
credentials,
}: {
denyRead: readonly string[]
allowRead: readonly string[] | undefined
credentials: CredentialsConfig | undefined
}): string[] {
return getDefaultWritePaths({
denyRead: [...denyRead, ...getCredentialDenyReadPaths(credentials)],
allowRead,
})
}

/**
* Union the explicit `filesystem.denyRead` with credential-derived
* deny paths. The single source of "what files does this config
Expand Down Expand Up @@ -1246,8 +1273,14 @@ function getFsWriteConfig(): FsWriteRestrictionConfig {
return true
})

// Build allowOnly list: default paths + configured allow paths
const allowOnly = [...getDefaultWritePaths(), ...allowPaths]
const allowOnly = [
...defaultWritePathsUnder({
denyRead: config.filesystem.denyRead,
allowRead: config.filesystem.allowRead,
credentials: config.credentials,
}),
...allowPaths,
]

return {
allowOnly,
Expand Down Expand Up @@ -1576,7 +1609,18 @@ async function wrapWithSandbox(
[],
)
writeConfig = {
allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
allowOnly: [
...defaultWritePathsUnder({
denyRead:
customConfig?.filesystem?.denyRead ??
config?.filesystem.denyRead ??
[],
allowRead:
customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead,
credentials: customConfig?.credentials ?? config?.credentials,
}),
...userAllowWrite,
],
denyWithinAllow: stripWriteGlobs(
customConfig?.filesystem?.denyWrite ??
config?.filesystem.denyWrite ??
Expand Down
140 changes: 123 additions & 17 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ export function expandWindowsEnvRefs(p: string): string {
* Returns the absolute path with symlinks resolved (or normalized glob pattern)
*/
export function normalizePathForSandbox(pathPattern: string): string {
const cwd = process.cwd()
// Windows pre-processing: expand `%USERPROFILE%` / `%HOMEDRIVE%` /
// `%HOMEPATH%`, strip the `\\?\` / `\\?\UNC\` extended prefix (its
// `?` is a literal, not a glob char), and uppercase the drive
Expand Down Expand Up @@ -366,10 +365,10 @@ export function normalizePathForSandbox(pathPattern: string): string {
// tilde was expanded above
} else if (pathPattern.startsWith('./') || pathPattern.startsWith('../')) {
// Convert relative to absolute based on current working directory
normalizedPath = path.resolve(cwd, pathPattern)
normalizedPath = path.resolve(process.cwd(), pathPattern)
} else if (!path.isAbsolute(pathPattern)) {
// Handle other relative paths (e.g., ".", "..", "foo/bar")
normalizedPath = path.resolve(cwd, pathPattern)
normalizedPath = path.resolve(process.cwd(), pathPattern)
}

// For glob patterns, resolve symlinks for the directory portion only
Expand Down Expand Up @@ -420,29 +419,122 @@ export function normalizePathForSandbox(pathPattern: string): string {
return normalizedPath
}

/**
* What the sandbox itself needs writable: the child's stdio and the TMPDIR it
* is handed (generateProxyEnvVars). Kept whatever is read-denied.
*/
const SANDBOX_OWN_WRITE_PATHS: readonly string[] = [
'/dev/stdout',
'/dev/stderr',
'/dev/null',
'/dev/tty',
'/dev/dtracehelper',
'/dev/autofs_nowait',
'/tmp/claude',
'/private/tmp/claude',
]

/**
* Directories under the home directory made writable as a convenience the
* caller never asked for. Every entry is subject to the read-rule check in
* {@link getDefaultWritePaths}.
*/
const HOME_CONVENIENCE_WRITE_DIRS: readonly string[] = [
'.npm/_logs',
'.claude/debug',
]

/**
* Get recommended system paths that should be writable for commands to work properly
*
* WARNING: These default paths are intentionally broad for compatibility but may
* allow access to files from other processes. In highly security-sensitive
* environments, you should configure more restrictive write paths.
*
* With no argument this is the whole list. Given the read rules of a
* filesystem policy, a home convenience directory (~/.npm/_logs,
* ~/.claude/debug) is left out when a `denyRead` entry names it or a
* directory above it: kept, it would be bound back over that deny on Linux
* (readable and writable again) and stay writable on macOS, so the explicit
* denyRead wins over the implicit write allow. It is kept when an `allowRead`
* entry beneath that deny re-opens it, because the caller has already made it
* readable. A caller who wants it writable regardless lists it in
* `allowWrite`. What the sandbox itself needs (stdio, /tmp/claude) is never
* left out.
*
* Pass the entries as configured, not expanded. `dir/**` counts as `dir`,
* and a glob covers a directory when it matches that directory or one above
* it; nothing is listed from disk. On Linux, where the backend expands globs
* against the disk, two things follow: a glob that matches nothing there
* 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.
*/
export function getDefaultWritePaths(): string[] {
const homeDir = homedir()
const recommendedPaths = [
'/dev/stdout',
'/dev/stderr',
'/dev/null',
'/dev/tty',
'/dev/dtracehelper',
'/dev/autofs_nowait',
'/tmp/claude',
'/private/tmp/claude',
path.join(homeDir, '.npm/_logs'),
path.join(homeDir, '.claude/debug'),
export function getDefaultWritePaths(readRules?: {
denyRead: readonly string[]
allowRead?: readonly string[]
}): string[] {
const home = homedir()
const keptDirs =
!readRules || readRules.denyRead.length === 0
? HOME_CONVENIENCE_WRITE_DIRS
: homeDirsNotReadDenied(home, readRules.denyRead, readRules.allowRead)
return [
...SANDBOX_OWN_WRITE_PATHS,
...keptDirs.map(rel => path.join(home, rel)),
]
}

/**
* The {@link HOME_CONVENIENCE_WRITE_DIRS} no `denyRead` entry covers, or
* that an `allowRead` entry beneath the covering deny re-opens.
*/
function homeDirsNotReadDenied(
home: string,
denyRead: readonly string[],
allowRead: readonly string[] = [],
): 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
// convenience directory may not exist yet, so its second spelling is built
// from the home directory, which does.
const homes = [...new Set([home, normalizePathForSandbox(home)])]
const denies = denyRead.map(entry => readRuleCovers(entry))
const reopened = allowRead
.map(entry => removeTrailingGlobSuffix(entry))
.filter(entry => !containsGlobCharsForPlatform(entry))
.map(entry => normalizePathForSandbox(entry))
return HOME_CONVENIENCE_WRITE_DIRS.filter(rel => {
const spellings = homes.map(h => path.join(h, rel))
return !denies.some(
denyCovers =>
spellings.some(denyCovers) &&
!reopened.some(
allow =>
denyCovers(allow) && spellings.some(s => isAtOrUnder(s, allow)),
),
)
})
}

return recommendedPaths
/**
* Whether a read rule covers a path: the path is the rule's own or lies
* beneath it. `dir/**` is `dir`, and a glob covers whatever
* {@link denyGlobRegex} matches.
*/
function readRuleCovers(entry: string): (p: string) => boolean {
const stripped = removeTrailingGlobSuffix(entry)
const rule = normalizePathForSandbox(stripped)
if (containsGlobCharsForPlatform(stripped)) {
try {
const regex = new RegExp(denyGlobRegex(rule))
return p => regex.test(p)
} catch {
// Brackets that do not form a valid class. The entry may be a literal
// file name, so it is compared as one.
}
}
return p => isAtOrUnder(p, rule)
}

/**
Expand Down Expand Up @@ -876,6 +968,20 @@ export function globToRegex(globPattern: string): string {
)
}

/**
* Regex for a glob used in a DENY rule: {@link globToRegex} plus an optional
* `/…` tail, so the deny covers everything beneath each match the way
* `subpath` does for literals. Callers strip a trailing `/**` before the
* pattern gets here (removeTrailingGlobSuffix), so `**\/secrets/**` arrives
* as `**\/secrets` and, matched exactly, would deny only the directory
* vnode while `secrets/key` stayed readable. This is what the Linux backend
* already does (a deny masks the whole subtree). Only ever widens a deny.
*/
export function denyGlobRegex(normalizedGlob: string): string {
// globToRegex() always returns '^…$'.
return globToRegex(normalizedGlob).slice(0, -1) + '(/.*)?$'
}

export interface ExpandGlobOptions {
/**
* Match case-insensitively. Set this on Windows where the
Expand Down
Loading
Loading