Skip to content
Merged
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
25 changes: 23 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,20 @@ async function main(): Promise<void> {
controlStream.on('error', onControlError)
}

// Until there is a child to pass an interrupt on to, it stops the
// run. The library's own handlers, which initialize() installs, only
// clean up: left to them the command would be run all the same.
const interrupted = new AbortController()
const interrupt = (signal: NodeJS.Signals): void =>
interrupted.abort(new Error(`interrupted by ${signal}`))
process.once('SIGINT', interrupt)
process.once('SIGTERM', interrupt)
const aboutToSpawn = (): void => {
interrupted.signal.throwIfAborted()
process.off('SIGINT', interrupt)
process.off('SIGTERM', interrupt)
}

// Initialize sandbox with config
logForDebugging('Initializing sandbox...')
await SandboxManager.initialize(runtimeConfig)
Expand Down Expand Up @@ -515,14 +529,21 @@ async function main(): Promise<void> {
// entries to the child as CRT descriptors, so the control fd
// is not among them (an inheritable HANDLE still reaches the
// child, but unnamed — nothing there can find it).
aboutToSpawn()
child = spawn(argv[0], argv.slice(1), {
shell: false,
stdio: 'inherit',
env,
})
} else {
const sandboxedCommand =
await SandboxManager.wrapWithSandbox(command)
// The wrap can take seconds over a large tree.
const sandboxedCommand = await SandboxManager.wrapWithSandbox(
command,
undefined,
undefined,
interrupted.signal,
)
aboutToSpawn()
child = spawn(sandboxedCommand, {
shell: true,
stdio: sandboxedStdio(controlFd),
Expand Down
3 changes: 3 additions & 0 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,9 @@ async function linuxGetMandatoryDenyPaths(
ripgrepConfig,
)
} catch (error) {
// 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}`)
}

Expand Down
23 changes: 21 additions & 2 deletions src/sandbox/read-deny-glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
normalizePathForSandbox,
pathSpellings,
properAncestors,
walkGlobPattern,
type Steps,
finish,
walkGlobPatternSteps,
} from './sandbox-utils.js'

/**
Expand Down Expand Up @@ -79,7 +81,24 @@ export function expandReadDenyGlobLinux(
unlistableDirs?: Set<string>,
listings?: GlobWalkListings,
): string[] {
const walk = walkGlobPattern(globPattern, {
return finish(
expandReadDenyGlobLinuxSteps(
globPattern,
reExposedPaths,
unlistableDirs,
listings,
),
)
}

/** {@link expandReadDenyGlobLinux}, in the walk's steps. */
export function* expandReadDenyGlobLinuxSteps(
globPattern: string,
reExposedPaths: readonly string[],
unlistableDirs?: Set<string>,
listings?: GlobWalkListings,
): Steps<string[]> {
const walk = yield* walkGlobPatternSteps(globPattern, {
withDirectoryForm: true,
followSymlinkedDirectories: true,
listings,
Expand Down
119 changes: 79 additions & 40 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
cleanupBwrapMountPoints,
linuxGetCwdMandatoryDenyPaths,
} from './linux-sandbox-utils.js'
import { expandReadDenyGlobLinux } from './read-deny-glob.js'
import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js'
import {
wrapCommandWithSandboxMacOS,
startMacOSSandboxLogMonitor,
Expand Down Expand Up @@ -84,8 +84,11 @@ import {
globPatternBaseDir,
normalizePathForSandbox,
removeTrailingGlobSuffix,
expandGlobPattern,
walkGlobPatternSteps,
type GlobWalkListings,
type Steps,
finish,
finishInTurns,
attributionKeyFor,
decodeSandboxedCommand,
encodeSandboxedCommand,
Expand Down Expand Up @@ -1265,20 +1268,26 @@ function unionDenyReadPaths(
* it is passed through whatever characters it contains. Expanded as a
* pattern, a name holding `[` matches nothing and the deny is lost.
*/
function resolveReadPathEntries(
function* resolveReadPathEntries(
paths: readonly string[],
expandGlob: (pattern: string) => string[],
expandGlob: (pattern: string) => Steps<string[]>,
literalPaths: readonly string[] = [],
): string[] {
): Steps<string[]> {
const literal = new Set(literalPaths)
return paths.flatMap(p => {
const resolved: string[] = []
for (const p of paths) {
const stripped = removeTrailingGlobSuffix(p)
return getPlatform() === 'linux' &&
if (
getPlatform() === 'linux' &&
!literal.has(p) &&
containsGlobChars(stripped)
? expandGlob(p)
: [stripped]
})
) {
resolved.push(...(yield* expandGlob(p)))
} else {
resolved.push(stripped)
}
}
return resolved
}

/**
Expand All @@ -1298,8 +1307,8 @@ function stripWriteGlobs(paths: readonly string[]): string[] {
})
}

function expandAllowReadGlob(pattern: string): string[] {
const expanded = expandGlobPattern(pattern)
function* expandAllowReadGlob(pattern: string): Steps<string[]> {
const expanded = (yield* walkGlobPatternSteps(pattern)).matches
logForDebugging(
`[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`,
)
Expand All @@ -1326,23 +1335,27 @@ function getFsReadConfig(): FsReadRestrictionConfig {
)
// allowRead (re-allow within denied regions) is resolved first: the
// denyRead glob expansion collapses against it.
const allowPaths = resolveReadPathEntries(
config.filesystem.allowRead ?? [],
expandAllowReadGlob,
const allowPaths = finish(
resolveReadPathEntries(
config.filesystem.allowRead ?? [],
expandAllowReadGlob,
),
)
const reExposedPaths = [...allowPaths, ...getFsWriteConfig().allowOnly]
const unlistableDenyDirs = new Set<string>()
const listings: GlobWalkListings = new Map()
const denyPaths = resolveReadPathEntries(
unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions),
pattern =>
expandReadDenyGlobLinux(
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
),
credentialRestrictions.degradeToDenyPaths,
const denyPaths = finish(
resolveReadPathEntries(
unionDenyReadPaths(config.filesystem.denyRead, credentialRestrictions),
pattern =>
expandReadDenyGlobLinuxSteps(
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
),
credentialRestrictions.degradeToDenyPaths,
),
)

return {
Expand Down Expand Up @@ -1649,6 +1662,7 @@ async function wrapWithSandbox(
const platform = getPlatform()
const commandId = options?.commandId
registerCommandText(command, options)
const startedWith = config

// filesystem.disabled bypasses ALL filesystem rule generation. Both
// platform wrappers treat readConfig/writeConfig === undefined as "no
Expand Down Expand Up @@ -1708,9 +1722,14 @@ async function wrapWithSandbox(
// allowRead is resolved first: on Linux a denyRead glob's expansion is
// collapsed against the paths that re-expose contents under a denied
// directory (allowRead + allowWrite), so both must be final here.
const expandedAllowRead = resolveReadPathEntries(
customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [],
expandAllowReadGlob,
const expandedAllowRead = await finishInTurns(
resolveReadPathEntries(
customConfig?.filesystem?.allowRead ??
config?.filesystem.allowRead ??
[],
expandAllowReadGlob,
),
abortSignal,
)
// The TLS-termination CA cert and the trust bundle the env vars point at
// (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their
Expand All @@ -1725,19 +1744,24 @@ async function wrapWithSandbox(
const reExposedPaths = [...expandedAllowRead, ...writeConfig.allowOnly]
const unlistableDenyDirs = new Set<string>()
const listings: GlobWalkListings = new Map()
const expandedDenyRead = resolveReadPathEntries(
unionDenyReadPaths(
customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [],
credentialRestrictions,
),
pattern =>
expandReadDenyGlobLinux(
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
const expandedDenyRead = await finishInTurns(
resolveReadPathEntries(
unionDenyReadPaths(
customConfig?.filesystem?.denyRead ??
config?.filesystem.denyRead ??
[],
credentialRestrictions,
),
credentialRestrictions.degradeToDenyPaths,
pattern =>
expandReadDenyGlobLinuxSteps(
pattern,
reExposedPaths,
unlistableDenyDirs,
listings,
),
credentialRestrictions.degradeToDenyPaths,
),
abortSignal,
)
readConfig = {
denyOnly: expandedDenyRead,
Expand Down Expand Up @@ -1770,6 +1794,21 @@ async function wrapWithSandbox(
await waitForNetworkInitialization()
}

// INVARIANT: one wrap, one configuration. updateConfig() can run in any of
// the turns above, and what was read before it must not be put together
// with what is read after it: the write list of one configuration with the
// allowGitConfig of another is a policy nobody chose. So the wrap starts
// over. Nothing from here to the platform's wrapper awaits.
if (config !== startedWith) {
return wrapWithSandbox(
command,
binShell,
customConfig,
abortSignal,
options,
)
}

// Check custom config to allow pseudo-terminal (can be applied dynamically)
const allowPty = customConfig?.allowPty ?? config?.allowPty

Expand Down
61 changes: 54 additions & 7 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,50 @@ export interface ExpandGlobOptions {
* pattern, reads the same entries. */
export type GlobWalkListings = Map<string, fs.Dirent[]>

export type GlobWalkOptions = ExpandGlobOptions & {
withDirectoryForm?: boolean
followSymlinkedDirectories?: boolean
/** Handed to every walk of one configuration, so that patterns with a
* base in common list each directory once between them. */
listings?: GlobWalkListings
}

/** Work that can be left between two steps and taken up again. */
export type Steps<T> = Generator<undefined, T, undefined>

/** Runs `steps` to the end, on the spot. */
export function finish<T>(steps: Steps<T>): T {
for (;;) {
const step = steps.next()
if (step.done) return step.value
}
}

/** How long {@link finishInTurns} works before it lets others have a turn. */
const TURN_MS = 10

/**
* Runs `steps` to the end, letting the event loop have a turn every
* {@link TURN_MS}: a walk of a large tree takes seconds, and a caller whose
* thread is held that long can neither draw nor hear that it should stop.
* Rejects with `signal`'s reason once that is aborted, however far it got.
*/
export async function finishInTurns<T>(
steps: Steps<T>,
signal?: AbortSignal,
): Promise<T> {
signal?.throwIfAborted()
let since = performance.now()
for (;;) {
const step = steps.next()
if (!step.done && performance.now() - since < TURN_MS) continue
if (!step.done) await new Promise(resolve => setImmediate(resolve))
signal?.throwIfAborted()
if (step.done) return step.value
since = performance.now()
}
}

/** What one recursive walk of a glob's base directory found; see {@link walkGlobPattern}. */
export interface GlobWalk {
/** Where the walk started, with symlinks resolved (the spelling itself
Expand Down Expand Up @@ -1489,14 +1533,16 @@ export function toForwardSlashes(s: string): string {
*/
export function walkGlobPattern(
globPath: string,
opts: ExpandGlobOptions & {
withDirectoryForm?: boolean
followSymlinkedDirectories?: boolean
/** Handed to every walk of one configuration, so that patterns with a
* base in common list each directory once between them. */
listings?: GlobWalkListings
} = {},
opts: GlobWalkOptions = {},
): GlobWalk {
return finish(walkGlobPatternSteps(globPath, opts))
}

/** {@link walkGlobPattern}, a step to a directory. */
export function* walkGlobPatternSteps(
globPath: string,
opts: GlobWalkOptions = {},
): Steps<GlobWalk> {
const walk: GlobWalk = {
baseLocation: '',
matches: [],
Expand Down Expand Up @@ -1621,6 +1667,7 @@ export function walkGlobPattern(
// others it came with, so only the ones new to this directory are taken.
const fresh = frame.positions.filter(p => !listed.has(p))
if (fresh.length === 0) continue
yield
for (const p of fresh) listed.add(p)
let entries = listings.get(real)
try {
Expand Down
Loading
Loading