Skip to content
Closed
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ 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`.
**Spawning without a shell (`wrapWithSandboxArgv`).** `SandboxManager.wrapWithSandboxArgv(command)` returns `{ argv, env }` for `spawn(argv[0], argv.slice(1), { shell: false, env })`. On Linux `argv` is the `bwrap` invocation itself, one element per option word, so a large mount profile is never squeezed into a single `sh -c` argument: Linux rejects any single argument over `MAX_ARG_STRLEN` (128 KiB on 4 KiB-page kernels) with `E2BIG`, which the string form can hit under a broad `denyRead`. Prefer it on Linux; the `srt` CLI uses it on every platform, and the string form logs a `[sandbox-runtime] WARNING` (with a per-mount-type breakdown) when its rendered line would exceed that cap. When the effective Linux config needs no sandbox at all, `argv` is `[shell, '-c', command]`, where `shell` is `binShell` or its `/bin/bash` default. On macOS `argv` is `[shell, '-c', <wrapWithSandbox result>]`; on Windows `wrapWithSandboxArgv` is the only supported entry point (`wrapWithSandbox` throws there). `describeBwrapArgv(argv)` breaks a vector down by mount type and byte size for diagnostics; for the `[shell, '-c', script]` forms it reports the script as `innerCommandBytes` and files everything under `other`.

**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 +228,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 @@ -372,10 +377,16 @@ Examples:

**Path Syntax (Linux):**

**Linux currently does not support glob matching.** Use literal paths only:
bubblewrap binds concrete paths, so glob support is narrower than on macOS:

- `allowWrite` / `denyWrite` take literal paths only; a glob pattern there is skipped.
- `denyRead` / `allowRead` accept the same glob syntax as macOS, expanded to the matching entries when the command is wrapped (a file that appears later is not covered). A `denyRead` pattern ending in `/**` becomes one mount per matched directory rather than one per file beneath it: the directory is a tmpfs inside the sandbox, exactly as a literal directory `denyRead` is, so writes into it do not reach the host. An entry reached through a symlink is denied at the path the link resolves to.

Examples:

- `"allowWrite": ["src/"]` - Allow write to `src/` directory
- `"denyRead": ["/home/user/.ssh"]` - Deny read to SSH directory
- `"denyRead": ["**/build/**"]` - Deny read to every `build/` directory under the current directory
- `"denyRead": ["/home"], "allowRead": ["."]` - Deny read to all of `/home`, but re-allow the current directory

**All platforms:**
Expand Down
59 changes: 32 additions & 27 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logForDebugging } from './utils/debug.js'
import { loadConfig, loadConfigFromString } from './utils/config-loader.js'
import * as readline from 'readline'
import * as fs from 'fs'
import * as net from 'net'
import * as path from 'path'
import * as os from 'os'

Expand Down Expand Up @@ -36,6 +37,22 @@ function getDefaultConfig(): SandboxRuntimeConfig {
}
}

/**
* A readable stream over the control fd. A pipe or socket is read through a
* libuv stream handle, driven by the event loop; fs.createReadStream would
* park a threadpool thread in a blocking read(2) that process.exit() then
* waits for, so srt would outlive the wrapped command until the parent
* closed the fd. A regular file has no such wait and keeps the fs stream.
* Either way the fd never keeps srt alive on its own.
*/
function openControlFd(fd: number): NodeJS.ReadableStream {
const stat = fs.fstatSync(fd)
if (stat.isFIFO() || stat.isSocket()) {
return new net.Socket({ fd, readable: true, writable: false }).unref()
}
return fs.createReadStream('', { fd })
}

async function main(): Promise<void> {
const program = new Command()

Expand Down Expand Up @@ -215,11 +232,8 @@ async function main(): Promise<void> {
let controlReader: readline.Interface | null = null
if (options.controlFd !== undefined) {
try {
const controlStream = fs.createReadStream('', {
fd: options.controlFd,
})
controlReader = readline.createInterface({
input: controlStream,
input: openControlFd(options.controlFd),
crlfDelay: Infinity,
})

Expand Down Expand Up @@ -285,29 +299,20 @@ async function main(): Promise<void> {
),
)

// Wrap the command with sandbox restrictions. On Windows
// the wrapper returns an argv array that MUST be spawned
// with {shell:false} — that's the boundary keeping the
// command bytes off the host shell. On other platforms
// we keep the existing shell-string path.
let child
if (process.platform === 'win32') {
// env carries the proxy vars the sandboxed child must inherit.
const { argv, env } =
await SandboxManager.wrapWithSandboxArgv(command)
child = spawn(argv[0], argv.slice(1), {
shell: false,
stdio: 'inherit',
env,
})
} else {
const sandboxedCommand =
await SandboxManager.wrapWithSandbox(command)
child = spawn(sandboxedCommand, {
shell: true,
stdio: 'inherit',
})
}
// Wrap the command with sandbox restrictions as an argv vector
// spawned with {shell:false}. On Windows that is the boundary
// keeping the command bytes off the host shell; on Linux it is
// the bwrap invocation itself, one element per word, so a large
// mount profile is never squeezed into a single `sh -c` argument
// (the kernel caps each argv element at MAX_ARG_STRLEN). env
// carries the proxy vars the sandboxed child must inherit.
const { argv, env } =
await SandboxManager.wrapWithSandboxArgv(command)
const child = spawn(argv[0], argv.slice(1), {
shell: false,
stdio: 'inherit',
env,
})

// Handle process exit
child.on('exit', (code, signal) => {
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export type {
// Platform-specific utilities
export type { SandboxViolationEvent } from './sandbox/macos-sandbox-utils.js'
export { type SandboxDependencyCheck } from './sandbox/linux-sandbox-utils.js'
export {
type BwrapArgvSummary,
type BwrapArgvTerm,
describeBwrapArgv,
describeBwrapStringOverflow,
LINUX_MAX_ARG_STRLEN,
} from './sandbox/bwrap-argv.js'

// Windows install/status API
export {
Expand Down
148 changes: 148 additions & 0 deletions src/sandbox/bwrap-argv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Size diagnostics for a bwrap argv, for an embedder that hits E2BIG or wants
* to warn before it does. Pure; never touches the filesystem.
*/

/** Mount/env categories {@link describeBwrapArgv} breaks a bwrap argv into. */
export type BwrapArgvTerm =
| 'roBindSelf'
| 'roBindDevNull'
| 'roBindOther'
| 'bind'
| 'tmpfs'
| 'setenv'
| 'other'

/**
* Size breakdown of a bwrap argv; see {@link describeBwrapArgv}. Every byte
* count is what execve() charges: UTF-8 length + 1 (the NUL) per element.
*/
export interface BwrapArgvSummary {
/** The whole vector. */
totalBytes: number
/** The largest single element: the number to compare against Linux's
* per-argument MAX_ARG_STRLEN (128 KiB on 4 KiB-page kernels). */
largestArgBytes: number
/** The inner shell script: the last element after `--`, or after the
* `-c` of the `[shell, '-c', script]` vector wrapWithSandboxArgv returns
* when no sandbox applies; 0 without either trailer. */
innerCommandBytes: number
/** Per term, its occurrences and the bytes of its words. A mount/env
* option and its operands count once; under `other` every remaining
* element counts on its own. The terms partition the vector. */
terms: Record<BwrapArgvTerm, { count: number; bytes: number }>
}

type BwrapOptionSpec = { arity: number; term: BwrapArgvTerm }

/**
* The bwrap options that get a term of their own: operand count and term.
* Every other `--option` is a bare flag under `other` and its operands are
* re-read as bare words (`--dev /dev` is two `other` elements) — the same
* accounting, unless an operand is itself spelled like one of these options
* or `--`, which nothing this package emits does. A Map, so an operand
* spelled like an Object.prototype member (`constructor`) cannot resolve to
* one.
*/
const BWRAP_OPTIONS: ReadonlyMap<string, BwrapOptionSpec> = new Map<
string,
BwrapOptionSpec
>([
['--ro-bind', { arity: 2, term: 'roBindOther' }], // refined by its operands
['--bind', { arity: 2, term: 'bind' }],
['--tmpfs', { arity: 1, term: 'tmpfs' }],
['--setenv', { arity: 2, term: 'setenv' }],
])
const BARE_FLAG: BwrapOptionSpec = { arity: 0, term: 'other' }

/** Linux's per-argument cap (MAX_ARG_STRLEN, 32 pages) on 4 KiB-page kernels. */
export const LINUX_MAX_ARG_STRLEN = 128 * 1024

/**
* Break a bwrap argv (as returned by wrapCommandWithSandboxLinuxArgv) down
* by mount/env term with execve()-style byte accounting.
*/
export function describeBwrapArgv(argv: readonly string[]): BwrapArgvSummary {
const argBytes = (s: string): number => Buffer.byteLength(s, 'utf8') + 1
const terms: BwrapArgvSummary['terms'] = {
roBindSelf: { count: 0, bytes: 0 },
roBindDevNull: { count: 0, bytes: 0 },
roBindOther: { count: 0, bytes: 0 },
bind: { count: 0, bytes: 0 },
tmpfs: { count: 0, bytes: 0 },
setenv: { count: 0, bytes: 0 },
other: { count: 0, bytes: 0 },
}
let totalBytes = 0
let largestArgBytes = 0

// Every element is accounted exactly once, so the totals ride along. A
// mount/env option and its operands count once; `other` counts per element.
const account = (term: BwrapArgvTerm, from: number, to: number): void => {
terms[term].count += term === 'other' ? to - from : 1
for (let k = from; k < to; k++) {
const byteCount = argBytes(argv[k]!)
terms[term].bytes += byteCount
totalBytes += byteCount
if (byteCount > largestArgBytes) largestArgBytes = byteCount
}
}

let innerCommandBytes = 0
let i = 0 // argv[0], the executable, falls through to BARE_FLAG
while (i < argv.length) {
const option = argv[i]!
// bwrap has no `-c` option, so one right after argv[0] marks the
// `[shell, '-c', script]` form (no sandbox needed), not a bwrap vector.
if (option === '--' || (i === 1 && option === '-c')) {
// Trailer: shell, '-c', inner script.
account('other', i, argv.length)
if (argv.length - 1 > i) {
innerCommandBytes = argBytes(argv[argv.length - 1]!)
}
break
}
const spec = BWRAP_OPTIONS.get(option) ?? BARE_FLAG
const end = Math.min(i + 1 + spec.arity, argv.length)
let term = spec.term
if (option === '--ro-bind') {
const src = argv[i + 1]
const dest = argv[i + 2]
term =
src === '/dev/null'
? 'roBindDevNull'
: src !== undefined && src === dest
? 'roBindSelf'
: 'roBindOther'
}
account(term, i, end)
i = end
}

return { totalBytes, largestArgBytes, innerCommandBytes, terms }
}

/**
* The warning to raise when `wrapped` — `argv` rendered for `sh -c` — would
* exceed Linux's per-argument cap as that one argument, or undefined when it
* fits. A warning rather than a refusal: 16 KiB-page kernels allow 512 KiB,
* and the kernel already fails such a spawn loudly (E2BIG); the package's
* job is to say which mounts did it and which output form avoids it.
*/
export function describeBwrapStringOverflow(
argv: readonly string[],
wrapped: string,
): string | undefined {
const bytes = Buffer.byteLength(wrapped, 'utf8') + 1
if (bytes <= LINUX_MAX_ARG_STRLEN) return undefined
const { terms, innerCommandBytes } = describeBwrapArgv(argv)
const t = (term: BwrapArgvTerm): string =>
`${terms[term].count} (${terms[term].bytes} B)`
return (
`[sandbox-runtime] WARNING: the bwrap command line is ${bytes} bytes as a single sh -c argument, ` +
`over Linux MAX_ARG_STRLEN (${LINUX_MAX_ARG_STRLEN} on 4 KiB-page kernels); spawn will fail with E2BIG. ` +
`/dev/null masks ${t('roBindDevNull')}, tmpfs ${t('tmpfs')}, other ro-binds ${t('roBindOther')}, ` +
`self ro-binds ${t('roBindSelf')}, binds ${t('bind')}, setenv ${t('setenv')}, inner script ${innerCommandBytes} B. ` +
`Use SandboxManager.wrapWithSandboxArgv() (one element per word) or deny enclosing directories instead of file globs.`
)
}
Loading
Loading