Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ With `allowWrite: ["/"]` the pins reach every ancestor, including any other allo

A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too.

**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance. You can configure this with `mandatoryDenySearchDepth`:
**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance: a dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`:

```json
{
Expand Down
156 changes: 122 additions & 34 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { spawn, spawnSync } from 'node:child_process'
import type { ChildProcess } from 'node:child_process'
import { endianness, tmpdir } from 'node:os'
import path, { join } from 'node:path'
import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js'
import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js'
import { buildJavaToolOptions } from './java-proxy-agent.js'
import {
generateProxyEnvVars,
Expand Down Expand Up @@ -317,6 +317,69 @@ export function linuxGetCwdMandatoryDenyPaths(
return denyPaths
}

/**
* The directories below `cwd`, down to `maxDepth`, that ripgrep said it could
* not read, that this process cannot read either, and whose mode is this
* user's to change. What is in one is not known, so the caller denies it whole.
* Exported for testing.
*/
export function unreadableDirectories(
stderr: string,
cwd: string,
maxDepth: number,
): string[] {
const found: string[] = []
const tried = new Set<string>()
for (const line of stderr.split('\n')) {
// How a message is worded depends on ripgrep's version and on how many
// threads it has, so what stands before each ": " is tried.
const said = line.startsWith('rg: ') ? line.slice(4) : line
for (
let cut = said.indexOf(': ');
cut !== -1;
cut = said.indexOf(': ', cut + 1)
) {
for (
let dir = path.resolve(cwd, said.slice(0, cut));
dir.startsWith(cwd + path.sep) && !tried.has(dir);
dir = path.dirname(dir)
) {
tried.add(dir)
try {
// THREAT: the text is only a hint. A name can hold a newline and a
// whole message of its own, and a bind of `link/x` would bring what
// the link leads to INTO the sandbox. So: a directory, with no link
// on the way to it, that cannot be read from here. And this user's:
// nobody else's mode can be given back from inside, and there can
// be thousands of those (`/home`), each of them a mount.
const stat = fs.lstatSync(dir)
if (
!stat.isDirectory() ||
stat.uid !== process.getuid?.() ||
fs.realpathSync(dir) !== dir
) {
break
}
} catch (error) {
// What cannot be looked at, because what holds it cannot be
// searched, is stood in for by the directory above it.
if ((error as NodeJS.ErrnoException).code === 'EACCES') continue
break
}
try {
fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK)
} catch {
if (path.relative(cwd, dir).split(path.sep).length <= maxDepth) {
found.push(dir)
}
}
break
}
}
}
return found
}

/**
* Get mandatory deny paths using ripgrep (Linux only).
* Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
Expand Down Expand Up @@ -344,8 +407,9 @@ async function linuxGetMandatoryDenyPaths(
for (const dirName of dangerousDirectories) {
iglobArgs.push('--iglob', `**/${dirName}/**`)
}
// Git hooks always blocked in nested repos
iglobArgs.push('--iglob', '**/.git/hooks/**')
// Git hooks always blocked in nested repos. A repository is known by its
// HEAD, so that its hooks are denied before there are any.
iglobArgs.push('--iglob', '**/.git/hooks/**', '--iglob', '**/.git/HEAD')

// Git config conditionally blocked in nested repos
if (!allowGitConfig) {
Expand All @@ -355,14 +419,26 @@ async function linuxGetMandatoryDenyPaths(
// Single ripgrep call to find all dangerous paths in subdirectories
// Limit depth for performance - deeply nested dangerous files are rare
// and the security benefit doesn't justify the traversal cost
//
// ripgrep lists files, and its depth is the file's. `sub/.vscode/x` and
// `sub/.git/config` lie at `maxDepth`; `sub/.git/hooks/pre-commit`, of the
// same directory, one level further down.
let matches: string[] = []
try {
matches = await ripGrep(
[
'--files',
'--hidden',
// INVARIANT: no file decides what is listed. An ignore file that names
// a directory hides all beneath it, and a configuration file can add
// any flag; both are files in or above the tree.
'--no-ignore',
'--no-config',
// Into a pipe ripgrep writes by the block, and killed it drops the
// block: it would have listed nothing.
'--line-buffered',
'--max-depth',
String(maxDepth),
String(maxDepth + 1),
...iglobArgs,
'-g',
'!**/node_modules/**',
Expand All @@ -375,42 +451,54 @@ async function linuxGetMandatoryDenyPaths(
// Stopped, it found nothing: the caller must not be handed a command
// without the denies it would have found.
signal.throwIfAborted()
logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`)
// INVARIANT: what ripgrep listed counts, however it ended. It exits 2 if
// there was a directory it could not read, having listed the rest, and is
// killed after ten seconds. What it had not come to by then is not denied.
if (error instanceof RipgrepError) {
matches = error.listed
// A read-only bind also keeps the command from giving the mode back.
denyPaths.push(...unreadableDirectories(error.stderr, cwd, maxDepth))
}
logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, {
level: 'warn',
})
}

// Process matches
// The names a match can lie under, by path component.
const directoryNames = [
...dangerousDirectories,
'.git/hooks',
'.git/config',
'.git/HEAD',
].map(name => normalizeCaseForComparison(name).split('/'))
for (const match of matches) {
const absolutePath = path.resolve(cwd, match)

// File inside a dangerous directory -> add the directory path
let foundDir = false
for (const dirName of [...dangerousDirectories, '.git']) {
const normalizedDirName = normalizeCaseForComparison(dirName)
const segments = absolutePath.split(path.sep)
const dirIndex = segments.findIndex(
s => normalizeCaseForComparison(s) === normalizedDirName,
)
if (dirIndex !== -1) {
// For .git, we want hooks/ or config, not the whole .git dir
if (dirName === '.git') {
const gitDir = segments.slice(0, dirIndex + 1).join(path.sep)
if (match.includes('.git/hooks')) {
denyPaths.push(path.join(gitDir, 'hooks'))
} else if (match.includes('.git/config')) {
denyPaths.push(path.join(gitDir, 'config'))
}
} else {
denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep))
const segments = path
.relative(cwd, path.resolve(cwd, match))
.split(path.sep)
const lower = segments.map(normalizeCaseForComparison)
// Where the dangerous name begins and how long it is: the first of
// `directoryNames` on the way down, else the file itself.
let at = segments.length - 1
let length = 1
// How deep the directory holding the name, `at`, may lie. One level less
// for `directoryNames`, all alike: each costs mounts that keep what holds
// it from being renamed or removed, and bwrap's start grows with them.
let deepest = maxDepth - 1
search: for (let i = 0; i < lower.length; i++) {
for (const name of directoryNames) {
if (name.every((component, k) => lower[i + k] === component)) {
at = i
length = name.length
deepest = maxDepth - 2
break search
}
foundDir = true
break
}
}

// Dangerous file match
if (!foundDir) {
denyPaths.push(absolutePath)
}
if (at > deepest) continue
const found = segments.slice(0, at + length)
if (lower[at] === '.git' && lower[at + 1] === 'head')
found[at + 1] = 'hooks'
denyPaths.push(path.join(cwd, ...found))
}

return [...new Set(denyPaths)]
Expand Down
4 changes: 3 additions & 1 deletion src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,7 +1153,9 @@ export const SandboxRuntimeConfigSchema = z
.max(10)
.optional()
.describe(
'Maximum directory depth to search for dangerous files on Linux (default: 3). ' +
'How deep below the working directory dangerous names are looked for on Linux (default: 3): ' +
'a dangerous file down to this depth, and a dangerous directory, or the hooks and ' +
'config of a repository, one level higher up. ' +
'Higher values provide more protection but slower performance.',
),
allowPty: z
Expand Down
23 changes: 21 additions & 2 deletions src/utils/ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface RipgrepConfig {
* @param abortSignal AbortSignal to cancel the operation
* @param config Ripgrep configuration (command and optional args)
* @returns Array of matching lines (one per line of output)
* @throws Error if ripgrep exits with non-zero status (except exit code 1 which means no matches)
* @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches)
*/
export async function ripGrep(
args: string[],
Expand Down Expand Up @@ -48,5 +48,24 @@ export async function ripGrep(
// Exit code 1 means "no matches found" - this is normal
return []
}
throw new Error(`ripgrep failed with exit code ${code}: ${stderr}`)
// Whole lines only: killed, it can leave half of one.
throw new RipgrepError(
`ripgrep failed with exit code ${code}: ${stderr}`,
stdout.split('\n').slice(0, -1).filter(Boolean),
stderr,
)
}

/** ripgrep ended otherwise than by finding something or nothing. */
export class RipgrepError extends Error {
constructor(
message: string,
/** What it had listed by then. */
readonly listed: string[],
/** What it said went wrong. The paths in it are the tree's own text. */
readonly stderr: string,
) {
super(message)
this.name = 'RipgrepError'
}
}
Loading
Loading