diff --git a/README.md b/README.md index 5846b9ddd..6cc5a4a68 100644 --- a/README.md +++ b/README.md @@ -690,7 +690,7 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command — except at a `commondir` or `config.worktree`, where `/dev/null` is what git cannot read, and the placeholder above is written there instead. A leftover empty directory cannot be told from anyone else's and is left alone. **Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. @@ -715,7 +715,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config` +- Git hooks and config: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. The file is removed after the command; one left behind by a killed process is rewritten and removed by the next wrap, and an empty `commondir`, which git refuses outright, is repaired the same way. For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules//`, which covers a single-segment submodule name. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: @@ -727,7 +727,17 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach. macOS uses glob patterns which block both existing and new files. +**Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox: + +- removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed; +- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason; +- `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules//hooks/` and writes its config; +- from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`; +- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`. + +**Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`. + +**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those. It fails closed: a directory it cannot read is denied whole, and every other failure aborts the command rather than sandboxing it with a partial deny list — a scan that could not be run at all, one that does not finish in time, and one that fails for a reason naming no path under the working directory to deny in its place. Each of those is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check. **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. @@ -735,7 +745,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, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/credential-mask-files.ts b/src/sandbox/credential-mask-files.ts index cc05e8299..b07318184 100644 --- a/src/sandbox/credential-mask-files.ts +++ b/src/sandbox/credential-mask-files.ts @@ -53,7 +53,9 @@ export interface MaskedFileBind { } /** - * Manager-owned temp dir holding the fake files. + * Manager-owned temp dir holding the fake files. The Linux backend keeps a + * second store of its own for the placeholders its git redirect denies bind + * from, which need this same guarantee for this same reason. * * INVARIANT: this directory must never be writable from inside the sandbox. * The Linux layer enforces this by emitting `--ro-bind ` @@ -66,6 +68,13 @@ export interface MaskedFileBind { export class MaskedFileStore { private dir: string | undefined private readonly byKey = new Map() + private readonly dirPrefix: string + + /** `dirPrefix` names this store's temp directory, so what it holds is + * recognisable on disk and one store's directory is never another's. */ + constructor(dirPrefix: string = MASKED_FILE_STORE_PREFIX) { + this.dirPrefix = dirPrefix + } /** * Write `sentinel` to a fake file for `key` and return its path. @@ -75,7 +84,7 @@ export class MaskedFileStore { */ write(key: string, sentinel: string): string { if (this.dir === undefined) { - this.dir = fs.mkdtempSync(join(tmpdir(), 'srt-credmask-')) + this.dir = fs.mkdtempSync(join(tmpdir(), this.dirPrefix)) } let fakePath = this.byKey.get(key) if (fakePath === undefined) { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index fda30c58e..d83c1573e 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -7,7 +7,8 @@ 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 } from '../utils/ripgrep.js' +import { ripGrep, RipgrepError } from '../utils/ripgrep.js' +import type { RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { generateProxyEnvVars, @@ -23,10 +24,17 @@ import { isStrictlyUnder, getDangerousDirectories, } from './sandbox-utils.js' +import { + gitDirDenyPaths, + gitFileDenyPaths, + gitRedirectPlaceholder, + submoduleGitDirs, +} from './mandatory-deny-paths.js' import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, } from './sandbox-schemas.js' +import { MaskedFileStore } from './credential-mask-files.js' import { getApplySeccompBinaryPath } from './generate-seccomp-filter.js' import type { SeccompConfig } from './sandbox-config.js' @@ -74,7 +82,7 @@ export interface LinuxSandboxParams { enableWeakerNestedSandbox?: boolean allowAllUnixSockets?: boolean binShell?: string - ripgrepConfig?: { command: string; args?: string[] } + ripgrepConfig?: RipgrepConfig /** Maximum directory depth to search for dangerous files (default: 3) */ mandatoryDenySearchDepth?: number /** Allow writes to .git/config files (default: false) */ @@ -246,6 +254,154 @@ function hasFileAncestor(targetPath: string): boolean { return false } +/** + * What `file` holds, when it is a regular file of no more than `limit` + * bytes; undefined for anything else, which is everything this needs to tell + * from the placeholders it compares against. + */ +function fileContentsWithin(file: string, limit: number): string | undefined { + try { + const stat = fs.lstatSync(file) + if (!stat.isFile() || stat.size > limit) return undefined + return fs.readFileSync(file, 'utf8') + } catch { + return undefined + } +} + +/** + * Make `dest` ready for the read-only bind that denies it, where it is one + * of the files git reads back (`gitRedirectPlaceholder` in + * src/sandbox/mandatory-deny-paths.ts), and return what to bind there. + * + * bubblewrap makes the mount point for an absent destination itself, with + * ensure_file(): an empty file — and that file is on the HOST, where it is + * what every git command outside the sandbox reads for as long as this one + * runs, and for good if this process is killed. git refuses to run at all in + * a repository whose commondir it cannot read, so the mount point is made + * here instead, holding the placeholder, and tracked for the cleanup. An + * empty file already there is rewritten to the placeholder and tracked the + * same way: no bytes at all is what git refuses, so nothing puts that there + * on purpose. Where the placeholder is itself empty an empty file IS + * legitimate, and only one of the shape bubblewrap leaves + * ({@link isStaleBwrapMountPoint}) is taken for this wrapper's own. + * + * What is bound over it is the store's copy rather than the file, so what + * the sandboxed command reads there is not what a host process rewrites + * between this call and the mount. A destination already holding the + * placeholder, or a redirect git can read, is bound from itself instead. + * + * Throws {@link LinuxSandboxProfileError} when neither can be prepared: what + * is left is /dev/null, which costs the repository every git command inside + * the sandbox and, through the mount point, outside it as well, so the + * command is refused rather than run behind that. + */ +function gitRedirectMountPoint(dest: string, placeholder: string): string { + const takeOwnership = (): void => { + redirectMountPointBytes.set(dest, placeholder) + bwrapMountPoints.add(dest) + registerExitCleanupHandler() + } + + try { + // Exclusive: a file that appeared since the deny loop looked is never + // truncated, it falls to the existing-file arms below. + fs.writeFileSync(dest, placeholder, { flag: 'wx', mode: 0o644 }) + takeOwnership() + logForDebugging( + `[Sandbox Linux] Wrote the mount point ${dest} holding ${JSON.stringify(placeholder)}, so git reads no redirect there while the command runs`, + ) + return gitRedirectStoreFile(dest, placeholder) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw placeholderUnavailable( + dest, + 'its mount point could not be made', + err, + ) + } + } + + const contents = fileContentsWithin(dest, Buffer.byteLength(placeholder)) + if (contents === '' && placeholder !== '') { + try { + // bubblewrap makes its mount points read-only (ensure_file(dest, + // 0444)), and the process that owns one need not be root, so the mode + // it was left with is no reason to leave the repository broken. + try { + fs.writeFileSync(dest, placeholder) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EACCES') throw err + fs.chmodSync(dest, 0o644) + fs.writeFileSync(dest, placeholder) + } + } catch (err) { + throw placeholderUnavailable( + dest, + 'the empty file left there could not be rewritten', + err, + ) + } + takeOwnership() + logForDebugging( + `[Sandbox Linux] Rewrote ${dest}, left empty by a sandbox that did not clean up, to ${JSON.stringify(placeholder)}: git reads no bytes there as a fault, not as "no redirect"`, + ) + return gitRedirectStoreFile(dest, placeholder) + } + if ( + contents === placeholder && + (placeholder !== '' || isStaleBwrapMountPoint(dest)) + ) { + // This wrapper's own, left by a process that could not clean up: nothing + // else writes exactly these bytes there, and for an empty placeholder + // nothing else leaves a file of that shape. Bound from itself — the + // swap would put back what is already in it — and removed with the rest. + takeOwnership() + logForDebugging( + `[Sandbox Linux] ${dest} already holds the placeholder ${JSON.stringify(placeholder)} an earlier sandbox left; binding it from itself and taking it away with this wrap's mount points`, + ) + return dest + } + logForDebugging( + `[Sandbox Linux] Binding ${dest} from itself: what it holds is not the ${JSON.stringify(placeholder)} placeholder, so it is not this wrapper's to replace`, + ) + return dest +} + +/** The store's copy of `placeholder`, made on first use of that content. */ +function gitRedirectStoreFile(dest: string, placeholder: string): string { + try { + // Keyed by content, so one file serves every destination that needs it; + // the store rewrites it on each use, which is also what revalidates it. + return gitRedirectStore.write(placeholder, placeholder) + } catch (err) { + throw placeholderUnavailable( + dest, + 'no placeholder file could be written to the temporary directory', + err, + ) + } +} + +/** Refuse the wrap, once, naming what could not be prepared and why. */ +function placeholderUnavailable( + dest: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `Cannot deny ${dest} without stopping git from working in that ` + + `repository: ${what} (${errorText(cause)}). git refuses to run at all ` + + `against a redirect file it cannot read, so the command was not run ` + + `rather than sandboxed behind a deny that breaks it` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError( + 'deny_placeholder_unavailable', + message, + cause, + ) +} + /** * Find the first non-existent path component. * E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists, @@ -270,10 +426,86 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } +/** Where `parts` first occurs as consecutive segments of `segments`, or -1. */ +function indexOfSegmentRun(segments: string[], parts: string[]): number { + return segments.findIndex((_, i) => + parts.every((part, j) => segments[i + j] === part), + ) +} + +/** + * What a failed ripgrep run said about itself, in the two kinds of line it + * can hold: the paths under `cwd` it could not read, and the lines that name + * no such path. Denying the first is how the scan fails closed — their + * contents are unknown, so a nested repository inside one must not stay + * writable — while nothing here stands in for the second, which is why the + * caller refuses the wrap over one. rg reports `: `; a path + * holding `: ` is cut short at it, which denies an ancestor and so only ever + * denies more. + */ +function ripgrepFailureDiagnostics( + stderr: string, + cwd: string, +): { unreadablePaths: string[]; linesNamingNoPath: string[] } { + const prefix = cwd + path.sep + const unreadablePaths = new Set() + const linesNamingNoPath: string[] = [] + for (const line of stderr.split('\n')) { + if (line.trim() === '') continue + const start = line.indexOf(prefix) + const rest = start === -1 ? '' : line.slice(start) + const end = rest.indexOf(': ') + const candidate = (end === -1 ? rest : rest.slice(0, end)).trimEnd() + if (candidate.length > prefix.length) unreadablePaths.add(candidate) + else linesNamingNoPath.push(line) + } + return { unreadablePaths: [...unreadablePaths], linesNamingNoPath } +} + +/** + * Refuse the wrap, naming what the scan did instead of delivering the deny + * paths below `cwd`. Sandboxing on a listing that stops somewhere unknown is + * how a nested repository keeps writable hooks, so the wrap is refused. + */ +function denyScanFailed( + cwd: string, + what: string, + cause: unknown, +): LinuxSandboxProfileError { + const message = + `The ripgrep scan of ${cwd} ${what} (${errorText(cause)}), so what it ` + + `would have denied below there is unknown: the command was not run ` + + `rather than sandboxed behind a deny list that may leave a nested ` + + `repository's hooks writable` + logForDebugging(`[Sandbox Linux] ${message}`, { level: 'warn' }) + return new LinuxSandboxProfileError('deny_scan_failed', message, cause) +} + +/** + * Every git directory a deny must cover once `gitDir` is one: its own + * hooks/ and config, and the same for each submodule git directory under + * its `modules` (what a commit inside that submodule runs), plus whatever + * the walk could not see through. + */ +function gitDirTreeDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + const modules = submoduleGitDirs(path.join(gitDir, 'modules')) + return [ + ...modules.unreadableDirs, + ...[gitDir, ...modules.gitDirs].flatMap(dir => + gitDirDenyPaths(dir, allowGitConfig), + ), + ] +} + /** * The part of the mandatory deny set that follows from the cwd alone: the - * dangerous files and directories resolved against it, plus `.git/hooks` and - * (unless the caller allows git config) `.git/config`. + * dangerous files and directories resolved against it, and what its own + * `.git` leads to — the repository's hooks, config and redirect files and + * those of its submodule git directories, or, where `.git` is a pointer + * file, the file itself and the git directories it names. * {@link linuxGetMandatoryDenyPaths} adds the nested matches its ripgrep scan * finds on top of these. Split out so a consumer that must not scan — the * violation monitor, which needs the same denies to judge a write bwrap @@ -283,47 +515,93 @@ export function linuxGetCwdMandatoryDenyPaths( allowGitConfig = false, ): string[] { const cwd = process.cwd() - // Note: Settings files are added at the callsite in sandbox-manager.ts - const denyPaths = [ - // Dangerous files in CWD - ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), - // Dangerous directories in CWD - ...getDangerousDirectories().map(d => path.resolve(cwd, d)), - ] + const denyPaths = cwdDangerousDenyPaths(cwd) - // Git hooks and config are only denied when .git exists as a directory. - // In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so - // .git/hooks can never exist — denying it would cause bwrap to fail. - // When .git doesn't exist at all, mounting at .git would block its - // creation and break git init. const dotGitPath = path.resolve(cwd, '.git') - let dotGitIsDirectory = false + let dotGitStat: fs.Stats | undefined try { - dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory() + dotGitStat = fs.statSync(dotGitPath) } catch { - // .git doesn't exist + // No .git: nothing is denied, since a mount at .git would block `git init`. } + if (dotGitStat?.isDirectory()) { + denyPaths.push(...gitDirTreeDenyPaths(dotGitPath, allowGitConfig)) + } else if (dotGitStat?.isFile()) { + // A pointer file (linked worktree, submodule checkout) has no hooks/ + // beneath it, and binding a path under a file makes bwrap fail. + denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig)) + } + + return denyPaths +} - if (dotGitIsDirectory) { - // Git hooks always blocked for security - denyPaths.push(path.resolve(cwd, '.git/hooks')) +/** + * The deny paths `cwd` has whatever its `.git` turns out to be, and whatever + * can be read of it. Settings files are added at the callsite in + * src/sandbox/sandbox-manager.ts. + */ +function cwdDangerousDenyPaths(cwd: string): string[] { + return [ + // Dangerous files in CWD + ...DANGEROUS_FILES.map(f => path.resolve(cwd, f)), + // Dangerous directories in CWD + ...getDangerousDirectories().map(d => path.resolve(cwd, d)), + ] +} - // Git config conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - denyPaths.push(path.resolve(cwd, '.git/config')) +/** + * {@link linuxGetCwdMandatoryDenyPaths} for the violation monitor, which is + * started once for the session and must not fail over one repository: the + * same paths, or - where a `.git` pointer or a `commondir` names something + * that cannot be resolved the way git resolves it - this directory's plain + * deny paths, with a warning. Every wrap in such a repository still refuses + * its command outright, so what this decides is which refused write the + * monitor reports, never what bubblewrap enforces. + */ +export function linuxGetMonitorCwdDenyPaths(allowGitConfig: boolean): string[] { + try { + return linuxGetCwdMandatoryDenyPaths(allowGitConfig) + } catch (err) { + const cwd = process.cwd() + const dotGitPath = path.resolve(cwd, '.git') + logForDebugging( + `[Sandbox Linux] Could not resolve ${dotGitPath} the way git does (${errorText(err)}); the violation monitor judges writes against ${cwd}'s plain deny paths instead. Every wrapped command in it is refused until that is fixed.`, + { level: 'warn' }, + ) + let isPointerFile = false + try { + isPointerFile = fs.statSync(dotGitPath).isFile() + } catch { + // Gone since, or unreachable: neither shape's denies apply. } + return [ + ...cwdDangerousDenyPaths(cwd), + // A pointer file is itself a deny, and what it leads to is exactly + // what could not be followed; a git directory's own hooks and config + // need nothing followed to name. + ...(isPointerFile + ? [dotGitPath] + : gitDirDenyPaths(dotGitPath, allowGitConfig)), + ] } - - return denyPaths } /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. - * With --max-depth limiting, this is fast enough to run on each command without memoization. + * + * Runs on each command without memoization. `--max-depth` keeps that to + * milliseconds on ordinary trees, but `--no-ignore` means gitignored data + * within the depth is walked too: measured at about +100 ms per command on a + * tree with 150k ignored files three levels down. A scan that does not + * deliver what is below the working directory aborts the wrap rather than + * sandboxing with a deny list of unknown completeness: one that could not be + * run at all, one that does not finish inside {@link ripGrep}'s timeout, and + * one that fails for a reason no deny stands in for. The single failure that + * is not fatal is a directory it could not read, which is denied whole. */ async function linuxGetMandatoryDenyPaths( - ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' }, + ripgrepConfig: RipgrepConfig = { command: 'rg' }, maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, @@ -336,6 +614,16 @@ async function linuxGetMandatoryDenyPaths( const denyPaths = linuxGetCwdMandatoryDenyPaths(allowGitConfig) + // Each nested repository the scan finds, once: the same walk the cwd's own + // git directory already had above, which every file listed under it leads + // back to. + const seenGitDirs = new Set([path.resolve(cwd, '.git')]) + const denyGitDir = (gitDir: string): void => { + if (seenGitDirs.has(gitDir)) return + seenGitDirs.add(gitDir) + denyPaths.push(...gitDirTreeDenyPaths(gitDir, allowGitConfig)) + } + // Build iglob args for all patterns in one ripgrep call const iglobArgs: string[] = [] for (const fileName of DANGEROUS_FILES) { @@ -344,13 +632,15 @@ 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 config conditionally blocked in nested repos - if (!allowGitConfig) { - iglobArgs.push('--iglob', '**/.git/config') - } + // A nested repository is recognised by ANY regular file directly inside its + // .git directory, so its hooks/ and config are denied at the depth the + // repository itself is found, not one level further down where the hook + // files sit (with the default depth, a repository directly under cwd has + // .git/config within reach but .git/hooks/* beyond it). Detection must not + // depend on any one file the sandboxed command could move aside, nor on + // allowGitConfig, which governs what is denied and not what is found. + // A FILE named .git is a worktree/submodule pointer (gitFileDenyPaths). + iglobArgs.push('--iglob', '**/.git/*', '--iglob', '**/.git') // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare @@ -361,6 +651,10 @@ async function linuxGetMandatoryDenyPaths( [ '--files', '--hidden', + // .gitignore, .ignore and .rgignore are writable inside the sandbox: + // honouring them would let one command hide a nested repository + // from the next command's scan. + '--no-ignore', '--max-depth', String(maxDepth), ...iglobArgs, @@ -372,41 +666,71 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig, ) } catch (error) { - logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`) + if (!(error instanceof RipgrepError)) { + // The run never got far enough to report anything of its own: the + // binary could not be spawned or this process is out of descriptors — + // the missing dependency the start-up check refuses on, met later — or + // the caller aborted, which is its own answer and travels as itself. + if ((error as { name?: unknown }).name === 'AbortError') throw error + throw denyScanFailed(cwd, 'could not be run', error) + } + if (error.timedOut) { + // The command that runs next is the one that could have made the tree + // slow to walk, so a truncated listing is not something to sandbox on: + // an unreached nested repository would be one with writable hooks. + throw denyScanFailed(cwd, 'did not finish', error) + } + // An unreadable directory makes rg exit non-zero after listing the rest + // of the tree; those matches still count, and each directory it could not + // read is denied whole, since what it holds is unknown. That is the only + // failure a deny stands in for: where the run named something else, or + // nothing at all, whatever it did not reach would stay writable. + const { unreadablePaths, linesNamingNoPath } = ripgrepFailureDiagnostics( + error.stderr, + cwd, + ) + if (unreadablePaths.length === 0 || linesNamingNoPath.length > 0) { + throw denyScanFailed( + cwd, + 'failed for a reason no deny stands in for', + error, + ) + } + matches = error.partialMatches + denyPaths.push(...unreadablePaths) + logForDebugging( + `[Sandbox] ripgrep scan of ${cwd} could not read ${unreadablePaths.length} of the directories under it, which are denied whole; the ${matches.length} paths it did list still count: ${error}`, + { level: 'warn' }, + ) } - // Process matches + const dirPatterns = dangerousDirectories.map(d => + normalizeCaseForComparison(d).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)) - } - foundDir = true - break - } + // rg prefixes each match with its target, cwd, and does not follow + // symlinks, so every line is under it. Segments are compared relative to + // cwd, so a dangerous name in cwd's own location never counts. + const relative = path.relative(cwd, match).split(path.sep) + const lowered = relative.map(normalizeCaseForComparison) + + const dirRun = dirPatterns + .map(parts => ({ parts, at: indexOfSegmentRun(lowered, parts) })) + .find(({ at }) => at !== -1) + if (dirRun) { + // The directory, not the file, so files created in it later are covered. + const end = dirRun.at + dirRun.parts.length + denyPaths.push(path.join(cwd, ...relative.slice(0, end))) + continue } - - // Dangerous file match - if (!foundDir) { - denyPaths.push(absolutePath) + const gitAt = lowered.indexOf('.git') + if (gitAt === -1) { + denyPaths.push(match) + } else if (gitAt < relative.length - 1) { + denyGitDir(path.join(cwd, ...relative.slice(0, gitAt + 1))) + } else if (relative.length > 1) { + // cwd's own pointer file is handled above, before the scan. + denyPaths.push(...gitFileDenyPaths(match, allowGitConfig)) } } @@ -419,6 +743,25 @@ async function linuxGetMandatoryDenyPaths( // be cleaned up explicitly. const bwrapMountPoints: Set = new Set() +// What this wrapper wrote at a mount point of its own making, keyed by a +// path in bwrapMountPoints: the cleanup takes such a file away only while it +// still holds exactly those bytes. Only the git redirect denies make one — +// every other mount point is bwrap's, and empty (see gitRedirectMountPoint). +const redirectMountPointBytes = new Map() + +/** Temp-directory prefix of the store below, so it is nobody else's. */ +export const GIT_REDIRECT_STORE_PREFIX = 'srt-gitredirect-' + +// The placeholders the git redirect denies bind over their mount points: one +// file per distinct content for the life of the process, rewritten on each +// use, and pinned read-only inside every sandbox that mounts one. It is the +// masked-file store's own class for the reason that store exists — a bind +// exposes the source file itself, so a command that can reach the source +// under a name it can write chooses what git reads at the denied path. +// Emptied by cleanupBwrapMountPoints({ force: true }), which reset() and the +// process-exit handler call. +const gitRedirectStore = new MaskedFileStore(GIT_REDIRECT_STORE_PREFIX) + // The source of the empty-directory mount points: at most one at a time, made // on first use, reused while a sandbox is running, and removed with the mount // points — never before, because a live bind's source must stay. @@ -750,6 +1093,24 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** + * A deny path git reads back — a git directory's `commondir` or + * `config.worktree` — could not be given a stand-in git accepts: neither + * the mount point on the host nor the placeholder in the temporary + * directory could be written. Mounting /dev/null there instead would stop + * git working in that repository altogether, so the command is refused. + * Whatever the wrap did make is tracked and goes with the next cleanup. + */ + | 'deny_placeholder_unavailable' + /** + * The ripgrep scan the mandatory denies below the working directory come + * from did not deliver them: it could not be run, it was killed before it + * finished, or it failed for a reason that names no path under the working + * directory to deny in its place. Sandboxing on what it did list would + * leave whatever it never reached — a nested repository's hooks — writable, + * so the command is refused instead. + */ + | 'deny_scan_failed' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the @@ -913,7 +1274,12 @@ function registerExitCleanupHandler(): void { * stops applying inside that sandbox. * * Pass `{ force: true }` to delete unconditionally — used by the process-exit - * handler and reset() where deferral is not meaningful. + * handler and reset() where deferral is not meaningful. That is also what + * empties the store of git redirect placeholders, which is per process. + * + * A mount point this wrapper wrote itself rather than left to bwrap (a git + * redirect deny's, holding what {@link gitRedirectMountPoint} put there) is + * removed while it still holds exactly that, as an empty one is. * * Also closes the `--args` profiles the wraps of this batch opened. */ @@ -934,10 +1300,18 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { for (const mountPoint of bwrapMountPoints) { try { - // Only remove if it's still the empty file/directory bwrap created. - // If something else has written real content, leave it alone. + // Only remove if it's still the empty file/directory bwrap created, or + // — for a mount point this wrapper wrote itself, which a git redirect + // deny does — the exact bytes it wrote there. If something else has + // written real content, leave it alone. const stat = fs.statSync(mountPoint) - if (stat.isFile() && stat.size === 0) { + const written = redirectMountPointBytes.get(mountPoint) + const stillAsCreated = + stat.size === 0 || + (written !== undefined && + stat.size === Buffer.byteLength(written) && + fs.readFileSync(mountPoint, 'utf8') === written) + if (stat.isFile() && stillAsCreated) { fs.unlinkSync(mountPoint) logForDebugging( `[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`, @@ -958,6 +1332,13 @@ export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void { } } bwrapMountPoints.clear() + redirectMountPointBytes.clear() + if (opts?.force) { + // The placeholders outlive a batch of wraps the way the masked-file + // store's fakes do: what ends them is the session (or the process), not + // a command. A live bind's source has to stay until then. + gitRedirectStore.dispose() + } if (emptyMountSourceDir !== undefined) { try { // rmdirSync, not a recursive remove: it neither follows a symlink nor @@ -1648,7 +2029,7 @@ async function generateFilesystemArgs( writeConfig: FsWriteRestrictionConfig | undefined, maskedFileBinds: Array<{ realPath: string; fakePath: string }> | undefined, maskedFileStoreDir: string | undefined, - ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' }, + ripgrepConfig: RipgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal?: AbortSignal, @@ -1686,6 +2067,15 @@ async function generateFilesystemArgs( // tampered with in between) would leave the binds already emitted pointing // at a directory this same call has just judged not ours. let emptySource: string | undefined + // Deny destinations that are files git reads back, and the placeholder + // each needs, filled in by the loop below and acted on when the bind is + // emitted: preparing one writes a file on the HOST as well (see + // gitRedirectMountPoint), so a bind the emission drops as hidden by a + // read-deny tmpfs must not have cost anything. + const pendingGitRedirects = new Map() + // The store directory a placeholder came from, pinned read-only with the + // other mount sources at the end. Set only where one was actually used. + let gitRedirectSourceDir: string | undefined // Where a mount given `p` lands: `p` fully resolved, every symlink on the // way and not one hop. One resolution per path per wrap, so every predicate // below sees the same answer, and none before the mandatory-deny scan's @@ -2116,15 +2506,16 @@ async function generateFilesystemArgs( } return stubSkipVetoInputs } + const mandatoryDenyPaths = await linuxGetMandatoryDenyPaths( + ripgrepConfig, + mandatoryDenySearchDepth, + allowGitConfig, + abortSignal, + ) // Deny writes within allowed paths (user-specified + mandatory denies) const denyPaths = [ ...(writeConfig.denyWithinAllow || []), - ...(await linuxGetMandatoryDenyPaths( - ripgrepConfig, - mandatoryDenySearchDepth, - allowGitConfig, - abortSignal, - )), + ...mandatoryDenyPaths, ] // Duplicate deny entries must be collapsed: a duplicate @@ -2414,9 +2805,14 @@ async function generateFilesystemArgs( // that branch. Under a read-only denied directory nothing needs // covering (the file is already unwritable there), but it is still // tracked: it is no more the caller's file for being there. + // + // Not for a file git reads back: /dev/null is exactly what it must not + // be covered with, and the existing-path branch below binds the + // placeholder over an empty one. if ( (isWithinAnyAllowedWritePath(path.dirname(normalizedPath)) || isWithinAnyAllowedWritePath(normalizedPath)) && + gitRedirectPlaceholder(normalizedPath) === undefined && isStaleBwrapMountPoint(normalizedPath) ) { if (!coveredBySafeReadOnlyDenyDir(normalizedPath)) { @@ -2489,6 +2885,19 @@ async function generateFilesystemArgs( // of /dev/null. This prevents the component from appearing as a file // which breaks tools that expect to traverse it as a directory. const isIntermediate = firstNonExistent !== normalizedPath + // A placeholder git reads, where the leaf is one of the files it + // reads back (gitRedirectPlaceholder): /dev/null there makes git + // refuse to run in the repository at all, not just refuse the write + // this deny is for. Decided from the resolved path, which every + // spelling of one file shares, rather than from the deny entry: + // a caller's own denyWrite naming the same file must not miss it. + const gitRedirectStub = isIntermediate + ? undefined + : gitRedirectPlaceholder(normalizedPath) + // The redirect source is not built here: it is a file on the host + // as well as a bind, and this bind may still be dropped below as + // hidden by a read-deny tmpfs. /dev/null stands in the buffer + // until the emission replaces it (pendingGitRedirects). const source = isIntermediate ? (emptySource ??= ensureEmptyMountSourceDir()) : '/dev/null' @@ -2506,7 +2915,12 @@ async function generateFilesystemArgs( // deeper deny that asked for a directory. const placeholderAt = placeholderSourceArgIndex.get(firstNonExistent) if (placeholderAt !== undefined) { - if (isIntermediate) denyWriteArgs[placeholderAt] = source + if (isIntermediate) { + denyWriteArgs[placeholderAt] = source + // The destination has to be a directory for the deeper deny, + // so it is no longer a file git reads back. + pendingGitRedirects.delete(firstNonExistent) + } logForDebugging( `[Sandbox Linux] Reusing the mount point at ${firstNonExistent} to block creation of ${normalizedPath}`, ) @@ -2517,6 +2931,9 @@ async function generateFilesystemArgs( firstNonExistent, denyWriteArgs.length - 2, ) + if (gitRedirectStub !== undefined) { + pendingGitRedirects.set(firstNonExistent, gitRedirectStub) + } // First writer wins for a destination several denies share (the // reuse branch above returns before reaching this), and the record // is purely additive: it only gives the tmpfs and mask comparisons @@ -2527,7 +2944,11 @@ async function generateFilesystemArgs( registerExitCleanupHandler() logForDebugging( `[Sandbox Linux] Mounted ${ - isIntermediate ? 'empty dir' : '/dev/null' + isIntermediate + ? 'empty dir' + : gitRedirectStub === undefined + ? '/dev/null' + : 'a git redirect placeholder' } at ${firstNonExistent} to block creation of ${normalizedPath}`, ) } else if (ancestorIsWithinReadOnlyDeny) { @@ -2573,6 +2994,18 @@ async function generateFilesystemArgs( ) } } + // A file git reads back is bound from itself here only if it holds a + // redirect git can read. The usual way to find one that does not is + // a previous wrap's own mount point for the absent case, left empty + // by a process that could not clean up: binding that would deny the + // same write and cost the repository every git command, since git + // refuses to run at all against a commondir it cannot read. Which of + // the two it is is settled when the bind is emitted, along with what + // the host reads there (gitRedirectMountPoint). + const gitRedirectStub = gitRedirectPlaceholder(normalizedPath) + if (gitRedirectStub !== undefined) { + pendingGitRedirects.set(normalizedPath, gitRedirectStub) + } denyWriteArgs.push('--ro-bind', normalizedPath, normalizedPath) denyWriteRawDests.set(normalizedPath, rawPath) } else { @@ -2856,7 +3289,16 @@ async function generateFilesystemArgs( } continue } - args.push(denyWriteArgs[i]!, denyWriteArgs[i + 1]!, dest) + // This bind lands, so the file git reads at `dest` is settled now: the + // mount point on the host is written here, not left to bwrap, and the + // placeholder bound over it comes from the store. + const pendingRedirect = pendingGitRedirects.get(dest) + let source = denyWriteArgs[i + 1]! + if (pendingRedirect !== undefined) { + source = gitRedirectMountPoint(dest, pendingRedirect) + if (source !== dest) gitRedirectSourceDir = path.dirname(source) + } + args.push(denyWriteArgs[i]!, source, dest) emittedDenyWriteDests.push(dest) // The tmpfs / mask re-application passes below ask "does this bind sit // above a read-denied path?". A bind at the resolved dest also re-exposes @@ -2921,6 +3363,16 @@ async function generateFilesystemArgs( args.push('--ro-bind', maskedFileStoreDir, maskedFileStoreDir) } + // INVARIANT, for the same reason and with the same remedy: the store of git + // redirect placeholders. A bind exposes the source file itself, so a + // command able to write it chooses what git reads at the DENIED path. The + // three mount sources here are siblings under the temp directory, each its + // own mkdtemp, so no one of these binds can cover another whatever the + // order; all three sit after every mount that could cover them. + if (gitRedirectSourceDir !== undefined) { + args.push('--ro-bind', gitRedirectSourceDir, gitRedirectSourceDir) + } + // INVARIANT, for the same reason: the empty directory the placeholders above // bind from must never be writable from inside the sandbox. It lives under // the system temp dir, so a caller's allowWrite or a host $TMPDIR under a diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4c6a3a4df..28d2a7c10 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -1,5 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { spawn } from 'child_process' +import * as fs from 'fs' import * as path from 'path' import { logForDebugging } from '../utils/debug.js' import { whichSync } from '../utils/which.js' @@ -18,6 +19,11 @@ import { DANGEROUS_FILES, getDangerousDirectories, } from './sandbox-utils.js' +import { + gitDirDenyPaths, + gitFileDenyPaths, + submoduleGitDirs, +} from './mandatory-deny-paths.js' import { shouldIgnoreViolation } from './sandbox-violation-store.js' import type { @@ -83,15 +89,19 @@ export interface MacOSSandboxParams { } /** - * The mandatory write denies (no filesystem scanning). Each name appears - * twice: once as the path in the cwd, and once as a pattern for the same - * name anywhere beneath it, which macOS matches via a regex. + * The mandatory write denies. Each dangerous name appears twice: once as the + * path in the cwd, and once as a pattern for the same name anywhere beneath + * it, which macOS matches via a regex. The git denies add the working + * directory's own repository, read off disk — the `.git` pointer it may be, + * or the git directories under its `.git/modules` — so those depend on the + * tree at cwd. * - * Both are anchored at the cwd, and the cwd is a name on disk that may - * contain `[`, `*` or `?`. So the first is a literal entry, and the second - * carries the cwd as its anchor — only the `**\/` tail is pattern. - * Compiled as one glob, a cwd like `a[b/c]d` would turn into a character - * class and every one of these denies would match nothing. + * Every pattern here is anchored at the cwd and every path read off disk is a + * literal entry, because both are names a glob would misread: the cwd is a + * name on disk that may contain `[`, `*` or `?`, and a submodule's directory + * is named by whatever the sandboxed command put under `.git/modules`. + * Compiled as one glob, `a[b/c]d` turns into a character class and the deny + * matches nothing at all — including the very directory it was built from. */ export function macGetMandatoryDenyEntries( allowGitConfig = false, @@ -115,19 +125,69 @@ export function macGetMandatoryDenyEntries( entries.push(beneathCwd(`**/${dirName}/**`)) } - // Git hooks are always blocked for security - entries.push(literal('.git/hooks')) - entries.push(beneathCwd('**/.git/hooks/**')) + // Nested repositories and the submodule git directories they keep under + // .git/modules/ are matched by pattern: there is no scan on macOS, and a + // glob covers a git directory that does not exist yet as well. The + // submodule name is a single segment here — a nested repository's + // `vendor/lib` submodule is not covered — because a `**` in the middle + // would also match any component named config or hooks (a branch named + // feature/config, a submodule named config), which fails ordinary git + // operations that worked before. + for (const gitDirPattern of ['**/.git', '**/.git/modules/*']) { + for (const pattern of gitDirDenyPaths(gitDirPattern, allowGitConfig)) { + entries.push(beneathCwd(pattern)) + } + } - // Git config - conditionally blocked based on allowGitConfig setting - if (!allowGitConfig) { - entries.push(literal('.git/config')) - entries.push(beneathCwd('**/.git/config')) + // The working directory's own repository is enumerated instead: literals + // are exact whatever a submodule is named, and each one pins its + // directories against being renamed out from under the deny. The producers + // in mandatory-deny-paths.ts return the string arrays the Linux backend + // binds, and every string in them was read off the filesystem, so each + // becomes a literal entry here. + const dotGit = path.resolve(cwd, '.git') + entries.push( + ...gitDirDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ) + let dotGitStat: fs.Stats | undefined + try { + dotGitStat = fs.statSync(dotGit) + } catch { + // no .git here + } + if (dotGitStat?.isFile()) { + // cwd checked out as a linked worktree or submodule: .git is a pointer + // file. Nested pointer files are matched by vnode type instead + // (gitPointerFilter), which cannot follow them. + entries.push( + ...gitFileDenyPaths(dotGit, allowGitConfig).map(toLiteralPathEntry), + ) + } else if (dotGitStat?.isDirectory()) { + const modules = submoduleGitDirs(path.join(dotGit, 'modules')) + entries.push(...modules.unreadableDirs.map(toLiteralPathEntry)) + for (const gitDir of modules.gitDirs) { + entries.push( + ...gitDirDenyPaths(gitDir, allowGitConfig).map(toLiteralPathEntry), + ) + } } return entries } +/** + * SBPL filter matching a regular file named `.git` anywhere under cwd, a + * `gitdir:` pointer. Matched by vnode type so a repository's .git DIRECTORY + * stays writable. Anchored at the cwd like the mandatory patterns, so a cwd + * whose own name holds a bracket is escaped into the regex rather than + * compiled as a character class that matches neither it nor anything under + * it. + */ +function gitPointerFilter(): string { + const cwd = normalizePathForSandbox(process.cwd(), { literal: true }) + return `(require-all (vnode-type REGULAR-FILE) ${pathFilter(anchoredGlobEntry(cwd, '**/.git'))})` +} + export interface SandboxViolationEvent { line: string command?: string @@ -902,7 +962,25 @@ function generateWriteRules( for (const entry of ungrouped) { denyFilters.add(denyPathFilter(entry)) } + const gitPointer = gitPointerFilter() + denyFilters.add(gitPointer) rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag)) + // An existing pointer cannot be rewritten; `git worktree add` still creates + // new ones, but only inside the write roots, since this allow follows the + // denies. User and mandatory denies are re-applied to creation by the rule + // below, and removing or renaming over an existing pointer by the + // file-write-unlink deny after it. + if (allowFilters.size > 0) { + const createPointer = `(require-all ${gitPointer} (require-any ${[...allowFilters].join(' ')}))` + rules.push( + ...renderRule( + 'allow', + ['file-write-create'], + new Set([createPointer]), + logTag, + ), + ) + } // Block file movement to prevent bypass via mv/rename. A grouped path // contributes its regex, the pin for its parent directory, and the @@ -925,6 +1003,15 @@ function generateWriteRules( ), ) + // Unlink only, so creating a pointer stays allowed: the rule above and the + // read section's re-allow of file-write-unlink for write roots would + // otherwise leave `rm lib/.git` and `mv evil lib/.git` open, which is the + // same rewrite the file-write* deny blocks. Emitted last; nothing after it + // in the profile re-allows unlink. + rules.push( + ...renderRule('deny', ['file-write-unlink'], new Set([gitPointer]), logTag), + ) + return rules } diff --git a/src/sandbox/mandatory-deny-paths.ts b/src/sandbox/mandatory-deny-paths.ts new file mode 100644 index 000000000..6e8f0787f --- /dev/null +++ b/src/sandbox/mandatory-deny-paths.ts @@ -0,0 +1,595 @@ +import * as fs from 'fs' +import * as path from 'path' +import { logForDebugging } from '../utils/debug.js' + +/** The path is absent, as opposed to unreadable or otherwise unverifiable. */ +function isAbsenceError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +/** + * The path is longer than the filesystem allows, so no file can occupy it: + * git's own stat of it fails, and a sandboxed command cannot create a git + * directory there either. + */ +function isUnusablePathError(err: unknown): boolean { + return (err as NodeJS.ErrnoException | undefined)?.code === 'ENAMETOOLONG' +} + +/** + * How much of a `.git` pointer or a `commondir` is read: what git's + * `read_gitfile_gently` accepts for a pointer file, so one larger than this + * is not a pointer git follows either. `commondir` has no bound in git, so + * one this large is read by git and not by this, and fails closed. + */ +const MAX_GIT_METADATA_BYTES = 1024 * 1024 + +/** + * Symlink hops allowed while resolving one path, the limit Linux itself + * applies before it gives up with ELOOP. + */ +const MAX_SYMLINK_HOPS = 40 + +/** + * Depth bound for the `.git/modules` walk. A submodule's name is its path + * (`vendor/lib`) and submodules nest, so the walk descends both name segments + * and nested `modules` directories; this bounds a hostile or looping tree, not + * a real one, and is deliberately unrelated to the ripgrep scan's depth. + */ +export const MAX_SUBMODULE_WALK_DEPTH = 10 + +/** + * Entries whose presence makes a directory a git directory. git needs HEAD + * and objects; config and hooks are what this file protects, so a directory + * holding either is treated as one even if HEAD has been moved aside. + */ +const GIT_DIR_MARKERS = new Set(['HEAD', 'config', 'hooks', 'objects']) + +/** What {@link gitDirKind} concluded about a `gitdir:`/`commondir` target. */ +type GitDirKind = 'git-dir' | 'absent' | 'other' | 'unreadable' | 'unusable' + +/** What {@link readGitMetadataFile} found at a `.git` file or a `commondir`. */ +type GitMetadata = + /** The whole file, within the size git accepts. */ + | { kind: 'contents'; bytes: Buffer } + /** Absent, or not the regular file git requires: nothing git reads here. */ + | { kind: 'none' } + /** Longer than {@link MAX_GIT_METADATA_BYTES}. */ + | { kind: 'too-large' } + +/** + * A git metadata file whose target cannot be worked out the way git works it + * out. Thrown rather than logged, and not swallowed by + * {@link gitFileDenyPaths}: sandboxing with a deny list that misses the hooks + * and config git reads is worse than not sandboxing, which is the same call + * the Linux backend makes for a scan that does not finish in time. + */ +export class GitMetadataError extends Error {} + +/** Directories found under a `.git/modules`, and what could not be read. */ +export interface SubmoduleScan { + /** The submodule git directories. */ + gitDirs: string[] + /** + * Directories the walk could not see through: what lies under them is + * unknown, so they are denied whole rather than left writable with a git + * directory possibly inside. Three things produce one — a directory the walk + * could not list, an entry it could not stat, and, once per branch that + * reaches {@link MAX_SUBMODULE_WALK_DEPTH}, the `modules` beneath the + * directory it stopped at (or that directory itself, when it is not a git + * directory). For the first two the recorded path is the deepest ancestor + * this process can reach, which can be the `.git/modules` root itself. + * + * A whole-directory deny is read-only for everything beneath it, a + * submodule's `objects`, `refs` and `index` included, so git writes inside a + * tree that trips one stop working. + */ + unreadableDirs: string[] +} + +/** + * The files inside a git directory that send git to a git directory or a + * config other than the one it opened, and what must stand in for one where + * it does not exist. + * + * Denying a path that is not there means mounting something at it, and git + * reads whichever of these two it finds: it refuses to run at all against a + * `commondir` it cannot read, which both an empty file and a bound /dev/null + * are (git rejects a commondir it reads zero bytes from, and a bind mount + * carries nodev, so the device is unreadable). Each placeholder is what git + * concludes with the file absent: `.` makes git resolve the git directory it + * opened as its own common directory, and an empty `config.worktree` reads as + * no worktree config at all. + * + * That is not invisible. The file's mere existence sets git's + * `different_commondir`, so `git rev-parse --git-common-dir` and `--git-path` + * print the absolute real path where they printed a relative one, and a + * script that compares `--git-dir` with `--git-common-dir` to decide "this is + * a linked worktree" answers yes for an ordinary repository while the deny + * stands. No content avoids that: the alternative is git refusing to run. + * + * An empty placeholder also says that an empty file at that path is a + * legitimate one to leave alone; a non-empty placeholder says the opposite, + * which is what lets the Linux backend repair an empty `commondir` (see + * `gitRedirectMountPoint` in src/sandbox/linux-sandbox-utils.ts). + */ +const GIT_REDIRECT_FILES: ReadonlyArray<{ + name: string + placeholder: string + /** Denied even where the caller allows writes to the git config. */ + deniedWithConfigAllowed: boolean +}> = [ + // Moves the hooks and config git reads to another directory entirely. + { name: 'commondir', placeholder: '.\n', deniedWithConfigAllowed: true }, + // Read instead of `config` wherever extensions.worktreeConfig is on. + { name: 'config.worktree', placeholder: '', deniedWithConfigAllowed: false }, +] + +/** + * The paths inside a git directory through which a write becomes code the + * host's git runs later: hooks/ always, config (core.fsmonitor, core.editor, + * core.hooksPath and the like) unless the caller allows config writes, and + * the redirect files of {@link GIT_REDIRECT_FILES}, each under the same + * condition as the file it redirects. + */ +export function gitDirDenyPaths( + gitDir: string, + allowGitConfig: boolean, +): string[] { + const redirectFiles = (deniedWithConfigAllowed: boolean): string[] => + GIT_REDIRECT_FILES.filter( + file => file.deniedWithConfigAllowed === deniedWithConfigAllowed, + ).map(file => path.join(gitDir, file.name)) + + const denyPaths = [path.join(gitDir, 'hooks'), ...redirectFiles(true)] + if (!allowGitConfig) { + denyPaths.push(path.join(gitDir, 'config'), ...redirectFiles(false)) + } + return denyPaths +} + +/** + * What must stand in for `denyPath` where it does not exist, or undefined for + * a path git does not read this way. Decided by the basename, so every + * spelling of one path - a tilde, a relative form, a trailing slash, a + * symlinked prefix - answers the same. See {@link GIT_REDIRECT_FILES}. + */ +export function gitRedirectPlaceholder(denyPath: string): string | undefined { + const name = path.basename(denyPath) + return GIT_REDIRECT_FILES.find(file => file.name === name)?.placeholder +} + +/** + * Deny paths for a `.git` file, the `gitdir:` pointer of a linked worktree or + * submodule checkout: the file itself plus the hooks/ and config git reads + * through it (the named git directory's, and for a linked worktree its + * commondir's as well). + * + * A `..` in either path can land the kernel somewhere other than where the + * path folds to on paper, so both directories are denied — see + * {@link gitMetadataTargets}. + * + * Throws {@link GitMetadataError} when the pointer or the `commondir` names + * something this cannot resolve the way git does; the wrap is then refused + * rather than applied with a deny list that may not cover the directory git + * uses. + */ +export function gitFileDenyPaths( + gitFile: string, + allowGitConfig: boolean, +): string[] { + const denyPaths = [gitFile] + try { + const pointer = readGitMetadataFile(gitFile) + if (pointer.kind === 'too-large') { + // git refuses a .git file this large outright, so it leads nowhere. + logForDebugging( + `[Sandbox] ${gitFile} is larger than the ${MAX_GIT_METADATA_BYTES} bytes git accepts for a .git file, so git does not follow it either; denying only the file itself`, + { level: 'warn' }, + ) + return denyPaths + } + const target = + pointer.kind === 'contents' + ? parseGitdirPointer(pointer.bytes, gitFile) + : undefined + if (target === undefined) return denyPaths + const gitDirs = gitMetadataTargets(path.dirname(gitFile), target) + for (const gitDir of gitDirs) { + denyPaths.push(...gitDirTargetDenyPaths(gitDir, allowGitConfig, gitFile)) + } + + // A linked worktree's git directory holds the path of the main one, whose + // hooks and config its commits run. git reads it out of the directory it + // opened, so each candidate above has its own. + for (const gitDir of gitDirs) { + const commonFile = path.join(gitDir, 'commondir') + const common = readGitMetadataFile(commonFile) + if (common.kind === 'too-large') { + // git reads commondir whole, with no size limit of its own, so a + // file past this bound still names the directory whose hooks git + // runs. + throw new GitMetadataError( + `[Sandbox] ${commonFile} is larger than ${MAX_GIT_METADATA_BYTES} bytes; refusing to sandbox without the git directory it names`, + ) + } + const commonTarget = + common.kind === 'contents' + ? gitMetadataPath(common.bytes, commonFile) + : undefined + if (commonTarget === undefined) continue + for (const commonDir of gitMetadataTargets(gitDir, commonTarget)) { + if (commonDir === gitDir) continue + denyPaths.push( + ...gitDirTargetDenyPaths(commonDir, allowGitConfig, commonFile), + ) + } + } + } catch (err) { + if (err instanceof GitMetadataError) throw err + // A dangling pointer names nothing git would read. A pointer this process + // cannot read is one the host's git cannot read either, so the file + // itself is the whole deny; an unreadable TARGET is denied whole by + // gitDirTargetDenyPaths instead. + if (!isAbsenceError(err)) { + logForDebugging( + `[Sandbox] Could not follow ${gitFile}, denying only the file itself: ${err}`, + { level: 'warn' }, + ) + } + } + return denyPaths +} + +/** + * Git directories of the submodules under `modulesDir` (a repository's + * .git/modules), nested submodules included. A submodule's name is its path, + * so one can sit several levels down (modules/vendor/lib), hence the walk. + */ +export function submoduleGitDirs(modulesDir: string): SubmoduleScan { + const scan: SubmoduleScan = { gitDirs: [], unreadableDirs: [] } + collectSubmoduleGitDirs(modulesDir, 0, scan, new Set()) + return scan +} + +function collectSubmoduleGitDirs( + dir: string, + depth: number, + scan: SubmoduleScan, + visited: Set, +): void { + const entries = listDirectory(dir, scan) + if (entries === undefined) return + for (const entry of entries) { + const child = path.join(dir, entry.name) + // git accepts a symlinked entry under .git/modules, and Dirent.isDirectory + // is false for one, so the link is followed — and the realpath recorded, + // since a link back up would otherwise loop until the depth bound. + if (!isDirectory(entry, child, scan)) continue + const visitKey = realPathOrSelf(child) + if (visited.has(visitKey)) continue + visited.add(visitKey) + + const childEntries = listDirectory(child, scan) + if (childEntries === undefined) continue + const isGitDir = childEntries.some(e => GIT_DIR_MARKERS.has(e.name)) + if (isGitDir) scan.gitDirs.push(child) + + if (depth + 1 >= MAX_SUBMODULE_WALK_DEPTH) { + // Nothing below here is inspected, so what the walk would have descended + // into is denied whole, like a directory it could not list: a submodule + // git directory nested deeper would otherwise keep its hooks and config + // writable. For a git directory that is the `modules` beneath it (absent + // or not — a sandboxed command must not be able to create one and hide a + // git directory inside), NOT the directory itself, whose objects, refs + // and index stay writable so git still works in that submodule. + const denied = isGitDir ? path.join(child, 'modules') : child + scan.unreadableDirs.push(denied) + logForDebugging( + `[Sandbox] Stopped the .git/modules walk below ${child} at depth ${MAX_SUBMODULE_WALK_DEPTH}, denying ${denied} whole`, + { level: 'warn' }, + ) + continue + } + collectSubmoduleGitDirs( + isGitDir ? path.join(child, 'modules') : child, + depth + 1, + scan, + visited, + ) + } +} + +/** Entries of `dir`, or undefined when it is absent or (recorded) unreadable. */ +function listDirectory( + dir: string, + scan: SubmoduleScan, +): fs.Dirent[] | undefined { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + // Absent is the common case: no submodules, or none nested in this one. + if (!isAbsenceError(err)) { + const denied = deepestReachableAncestor(dir) ?? dir + scan.unreadableDirs.push(denied) + logForDebugging( + `[Sandbox] Could not list ${dir}, denying ${denied} whole: ${err}`, + { level: 'warn' }, + ) + } + return undefined + } +} + +/** Whether `entry` is a directory, following a symlink to one. */ +function isDirectory( + entry: fs.Dirent, + entryPath: string, + scan: SubmoduleScan, +): boolean { + if (entry.isDirectory()) return true + if (!entry.isSymbolicLink()) return false + try { + return fs.statSync(entryPath).isDirectory() + } catch (err) { + if (!isAbsenceError(err)) { + scan.unreadableDirs.push(deepestReachableAncestor(entryPath) ?? entryPath) + } + return false + } +} + +/** + * Deny paths for a directory a `gitdir:` or `commondir` names. An existing + * directory that is not a git directory is left alone: file content must not + * be able to point the deny list at, say, a Rails `config/`. An absent one is + * still denied, so the sandboxed command cannot create the target and fill it + * with hooks before the host's git first uses it. + */ +function gitDirTargetDenyPaths( + target: string, + allowGitConfig: boolean, + source: string, +): string[] { + const kind = gitDirKind(target) + switch (kind) { + case 'git-dir': + case 'absent': + return gitDirDenyPaths(target, allowGitConfig) + case 'unreadable': { + const denied = deepestReachableAncestor(target) ?? target + logForDebugging( + `[Sandbox] Could not read ${target} named by ${source}, denying ${denied} whole`, + { level: 'warn' }, + ) + return [denied] + } + case 'unusable': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is longer than the filesystem allows: no file can be there for git to read or for a command to create; denying only ${source}`, + { level: 'warn' }, + ) + return [] + case 'other': + logForDebugging( + `[Sandbox] ${source} names ${target}, which is not a git directory; denying only ${source}`, + { level: 'warn' }, + ) + return [] + } +} + +/** + * Whether `dir` is a git directory. Deliberately looser than git's + * `is_git_directory` (a valid HEAD plus objects/ and refs/): every directory + * git accepts has a HEAD entry, so this accepts those and some besides, + * which only ever denies more. + * + * Narrower than {@link GIT_DIR_MARKERS}, which the `.git/modules` walk uses: + * a directory here is named by file content a sandboxed command can write, so + * accepting `config` or `hooks` would let that content aim a deny at any + * directory at all. The walk reaches only what lies under `.git/modules` — + * except through a symlinked entry there, which a command able to write under + * it can aim at one directory of its choosing. + */ +function gitDirKind(dir: string): GitDirKind { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + if (isUnusablePathError(err)) return 'unusable' + return isAbsenceError(err) ? 'absent' : 'unreadable' + } + return entries.some(e => e.name === 'HEAD' || e.name === 'objects') + ? 'git-dir' + : 'other' +} + +/** + * The contents of `file`, or what stopped this from reading it the way git + * does. The path is one a sandboxed command may create: a FIFO there would + * block the host on every later wrap (hence O_NONBLOCK and the type check). + */ +function readGitMetadataFile(file: string): GitMetadata { + // O_NONBLOCK is POSIX-only; this file's callers are the Linux and macOS + // backends, and 0 leaves the flags as they were. + const nonBlocking = fs.constants.O_NONBLOCK ?? 0 + let fd: number + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | nonBlocking) + } catch (err) { + if (isAbsenceError(err) || isUnusablePathError(err)) return { kind: 'none' } + throw err + } + try { + // From the open file description, so it describes what was actually + // opened rather than what the path named a moment ago. + const stat = fs.fstatSync(fd) + if (!stat.isFile()) return { kind: 'none' } + if (stat.size > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + // Sized from that stat rather than from the bound, and one byte past it + // so a file that grew since is read on rather than taken for whole; + // readSync can also stop short of the length it was given. + let buffer = Buffer.alloc(stat.size + 1) + let read = 0 + for (;;) { + if (read === buffer.length) { + if (buffer.length > MAX_GIT_METADATA_BYTES) return { kind: 'too-large' } + const grown = Buffer.alloc(MAX_GIT_METADATA_BYTES + 1) + buffer.copy(grown) + buffer = grown + } + const chunk = fs.readSync(fd, buffer, read, buffer.length - read, read) + if (chunk === 0) break + read += chunk + } + return { kind: 'contents', bytes: buffer.subarray(0, read) } + } finally { + fs.closeSync(fd) + } +} + +/** + * The `gitdir:` target of a pointer file, or undefined when git would not + * follow the file at all. git requires the 8-byte prefix at offset 0 — no + * leading whitespace, no other spelling — and everything after it is path. + */ +function parseGitdirPointer( + contents: Buffer, + file: string, +): string | undefined { + const prefix = Buffer.from('gitdir: ') + if (!contents.subarray(0, prefix.length).equals(prefix)) return undefined + return gitMetadataPath(contents.subarray(prefix.length), file) +} + +/** + * The path a git metadata file names, by git's rules rather than a line + * reader's: `\n` and `\r` are stripped from the END OF THE FILE (so a newline + * inside the path is part of it), nothing is trimmed, and what is left is a C + * string and ends at the first NUL. `read_gitfile_gently` and + * `get_common_dir_noenv` in git's setup.c both read this way. + * + * This re-implements that parsing instead of asking git, because the library + * has to work where git is not installed, a wrap would otherwise spawn a + * process per pointer per command, and running git inside a checkout the + * sandboxed command can write is the hazard these denies exist to contain; + * test/sandbox/git-pointer-parity.test.ts is what keeps the two in step, + * resolving a corpus of pointer shapes both ways and comparing. + * + * Throws {@link GitMetadataError} for a path whose bytes are not valid UTF-8: + * a JavaScript string of them names a different file than git opens, so + * there is no path here to deny. + */ +function gitMetadataPath(contents: Buffer, file: string): string | undefined { + let end = contents.length + while ( + end > 0 && + (contents[end - 1] === 0x0a || contents[end - 1] === 0x0d) + ) { + end-- + } + const nul = contents.subarray(0, end).indexOf(0) + const pathBytes = contents.subarray(0, nul === -1 ? end : nul) + if (pathBytes.length === 0) return undefined + + const decoded = pathBytes.toString('utf8') + if (!Buffer.from(decoded, 'utf8').equals(pathBytes)) { + throw new GitMetadataError( + `[Sandbox] ${file} names a path that is not valid UTF-8; refusing to sandbox with a deny list that would name a different directory than git opens`, + ) + } + return decoded +} + +/** + * The git directories a `gitdir:` or `commondir` value read in `base` leads + * to: the path as it folds on paper, which is the spelling these denies have + * always used, and — when a `..` in it could send the kernel elsewhere — the + * directory the kernel actually reaches. git hands the two strings to stat + * joined and unnormalised, so a symlink is followed before a later `..` + * applies and `a/link/../b` need not be `a/b`. Both are denied when they + * differ: git opens one of them, and denying the other costs one bind. + */ +function gitMetadataTargets(base: string, target: string): string[] { + const lexical = path.resolve(base, target) + if (!target.split('/').includes('..')) return [lexical] + const root = path.parse(lexical).root + const physical = physicalPath(physicalPath(root, base), target) + // Both sides resolved the same way, so a base that merely spells itself + // differently (a /var that is a symlink to /private/var) is no difference. + return physical === physicalPath(root, lexical) + ? [lexical] + : [lexical, physical] +} + +/** + * Where the kernel lands walking `target` from `base`, symlinks followed as + * it meets them. `path.resolve` folds `..` lexically, and `fs.realpathSync` + * folds its argument the same way before resolving it, so neither answers + * this; a walk also reaches a tail that does not exist yet, which realpath + * cannot. A component that cannot be walked — missing, unreadable, or a loop + * past the hop limit — ends it, since the kernel cannot traverse one either + * and nothing beyond it can redirect the path; the rest is taken as written + * and classified by {@link gitDirTargetDenyPaths} like any other target. + */ +function physicalPath(base: string, target: string): string { + let current = path.isAbsolute(target) ? path.parse(target).root : base + let pending = target.split('/') + let hops = 0 + while (pending.length > 0) { + const name = pending.shift() + if (name === undefined || name === '' || name === '.') continue + if (name === '..') { + current = path.dirname(current) + continue + } + const next = path.join(current, name) + let link: string | undefined + try { + if (fs.lstatSync(next).isSymbolicLink()) link = fs.readlinkSync(next) + } catch { + return path.join(next, ...pending) + } + if (link === undefined) { + current = next + continue + } + // A loop is where the walk stops, and what it hands back: the rest of + // the path folded past it would name a directory this cannot vouch for, + // while the link itself reads as unreadable and is denied whole. + hops += 1 + if (hops > MAX_SYMLINK_HOPS) return next + // A link's own target is walked in its place, from the directory holding + // it unless it is absolute. + if (path.isAbsolute(link)) current = path.parse(link).root + pending = [...link.split('/'), ...pending] + } + return current +} + +/** + * The deepest ancestor of `target` (itself included) this process can still + * stat. Denying that directory fails closed when the path below it cannot be + * inspected: nothing under it is writable in the sandbox. + */ +function deepestReachableAncestor(target: string): string | undefined { + for (let dir = target; ; dir = path.dirname(dir)) { + try { + if (fs.lstatSync(dir).isDirectory()) return dir + } catch { + // Unreachable at this level; try the parent. + } + if (path.dirname(dir) === dir) return undefined + } +} + +/** `target` with symlinks resolved, or itself when that fails. */ +function realPathOrSelf(target: string): string { + try { + return fs.realpathSync(target) + } catch { + return target + } +} diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index fedd50196..501c8a716 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -46,7 +46,7 @@ import { checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, - linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { @@ -702,7 +702,10 @@ async function initialize( // 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 - // filters, nor the ripgrep scan for nested dangerous paths; and it still + // filters, nor the ripgrep scan for nested dangerous paths; a repository + // whose git metadata cannot be resolved leaves it the cwd's plain denies + // rather than failing this whole start-up (every wrap there is still + // refused); and it still // reports writes bwrap permits through `--dev`, `--proc` and the tmpfs // over each read-denied directory. Started once, so both lists are fixed // at the cwd and configuration of this call. @@ -730,7 +733,7 @@ async function initialize( // them, so the monitor must not judge by them either. ...(config.filesystem.disabled ? [] - : linuxGetCwdMandatoryDenyPaths(getAllowGitConfig())), + : linuxGetMonitorCwdDenyPaths(getAllowGitConfig())), ], ignoreViolations: config.ignoreViolations, resolveCommandText, diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index c246d9171..dd9ed71a1 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -7,8 +7,12 @@ export interface RipgrepConfig { args?: string[] /** Override argv[0] when spawning (for multicall binaries that dispatch on argv[0]) */ argv0?: string + /** How long the run may take before it is killed (default: 10 s). */ + timeoutMs?: number } +const DEFAULT_RIPGREP_TIMEOUT_MS = 10_000 + /** * Check if ripgrep (rg) is available synchronously * Returns true if rg is installed, false otherwise @@ -18,13 +22,44 @@ export function hasRipgrepSync(): boolean { } /** - * Execute ripgrep with the given arguments + * ripgrep exited with an error status. `partialMatches` is what it listed + * before that: rg reports an unreadable directory with exit code 2 after + * printing every match it could reach, and names each one in `stderr`. + * `timedOut` says the run was killed instead of finishing, so what it listed + * is a prefix of an unknown whole rather than everything it could reach. + */ +export class RipgrepError extends Error { + readonly partialMatches: string[] + readonly stderr: string + readonly timedOut: boolean + + constructor( + message: string, + partialMatches: string[], + stderr: string, + timedOut: boolean, + ) { + super(message) + this.partialMatches = partialMatches + this.stderr = stderr + this.timedOut = timedOut + } +} + +/** + * Execute ripgrep with the given arguments. + * + * The run is `--null`-delimited: a path may contain a newline, and a run cut + * short by the timeout can end mid-path, so line splitting would turn one + * path into two and hand back a truncated one. Output is split on NUL and an + * unterminated tail is dropped. + * * @param args Command-line arguments to pass to rg * @param target Target directory or file to search * @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) + * @returns Array of matching paths + * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ export async function ripGrep( args: string[], @@ -32,30 +67,57 @@ export async function ripGrep( abortSignal: AbortSignal, config: RipgrepConfig = { command: 'rg' }, ): Promise { - const { command, args: commandArgs = [], argv0 } = config + const { + command, + args: commandArgs = [], + argv0, + timeoutMs = DEFAULT_RIPGREP_TIMEOUT_MS, + } = config - const child = spawn(command, [...commandArgs, ...args, target], { + const child = spawn(command, [...commandArgs, '--null', ...args, target], { argv0, signal: abortSignal, - timeout: 10_000, + timeout: timeoutMs, windowsHide: true, }) - const [stdout, stderr, code] = await Promise.all([ + const [stdout, stderr, exit] = await Promise.all([ text(child.stdout), text(child.stderr), - new Promise((resolve, reject) => { - child.on('close', resolve) - child.on('error', reject) - }), + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.on('close', (code, signal) => resolve({ code, signal })) + child.on('error', reject) + }, + ), ]) - if (code === 0) { - return stdout.trim().split('\n').filter(Boolean) + const matches = splitNullDelimited(stdout) + if (exit.code === 0) { + return matches } - if (code === 1) { + if (exit.code === 1) { // Exit code 1 means "no matches found" - this is normal return [] } - throw new Error(`ripgrep failed with exit code ${code}: ${stderr}`) + // A null exit code means the child was killed rather than exiting. An + // abort rejects through the error handler above, so here it is the timeout. + const timedOut = exit.code === null + throw new RipgrepError( + timedOut + ? `ripgrep was killed by ${exit.signal ?? 'a signal'} after ${timeoutMs} ms: ${stderr}` + : `ripgrep failed with exit code ${exit.code}: ${stderr}`, + matches, + stderr, + timedOut, + ) +} + +/** NUL-terminated records, dropping an unterminated (truncated) last one. */ +function splitNullDelimited(output: string): string[] { + const records = output.split('\0') + // A complete run ends with a terminator, so the tail is empty; anything + // else is a record the run was cut off in the middle of. + records.pop() + return records.filter(Boolean) } diff --git a/test/sandbox/git-pointer-parity.test.ts b/test/sandbox/git-pointer-parity.test.ts new file mode 100644 index 000000000..62fc53576 --- /dev/null +++ b/test/sandbox/git-pointer-parity.test.ts @@ -0,0 +1,792 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' +import { + GitMetadataError, + gitFileDenyPaths, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Differential tests for the `.git` pointer and `commondir` parsing in + * mandatory-deny-paths.ts, which re-implements git's own (see the comment on + * gitMetadataPath there for why it is not a call to git). Hand-written cases + * only check the shapes their author thought of; what matters here is that + * there is no shape this resolves confidently to one directory while git + * opens another. So every case is resolved twice — once by gitFileDenyPaths, + * once by `git rev-parse` run in the pointer's own directory — and the rule + * is: wherever git follows the pointer, that directory's hooks and config + * are in the deny list, or the wrap was refused outright. Denying more than + * git follows is fine; denying less is the bug. + * + * Skipped where git is not installed. Windows is out of scope for this file + * (mandatory-deny-paths serves the Linux and macOS backends) and several + * shapes here — a newline in a directory name, bytes that are not valid + * UTF-8 — cannot exist there. + */ +const HAS_GIT = + spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0 + +describe.if(!isWindows && HAS_GIT)('git pointer parsing parity', () => { + let root: string + let caseCount = 0 + + beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'git-pointer-parity-'))) + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + /** What precedes the path. Only `gitdir: ` at byte 0 is one git follows. */ + const PREFIXES = { + plain: Buffer.from('gitdir: '), + noSpace: Buffer.from('gitdir:'), + twoSpaces: Buffer.from('gitdir: '), + tab: Buffer.from('gitdir:\t'), + leadingSpace: Buffer.from(' gitdir: '), + byteOrderMark: Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('gitdir: '), + ]), + upperCase: Buffer.from('GITDIR: '), + } + + /** + * What follows it. git strips `\n` and `\r` from the end of the file and + * nothing else, and the path ends at the first NUL. + */ + const TRAILERS = { + none: Buffer.alloc(0), + newline: Buffer.from('\n'), + crlf: Buffer.from('\r\n'), + mixedEndings: Buffer.from('\n\r\n\r'), + nineThousandNewlines: Buffer.from('\n'.repeat(9000)), + sixtyFourKiBOfNewlines: Buffer.from('\n'.repeat(64 * 1024)), + trailingSpaces: Buffer.from(' \n'), + secondLine: Buffer.from('\nnot-a-git-dir\n'), + embeddedNul: Buffer.from('\0/elsewhere\n'), + } + + /** How the target is spelled. Every component of each exists on disk. */ + const SPELLINGS = { + absolute: (_checkout: string, target: string): string => target, + relative: (checkout: string, target: string): string => + relative(checkout, target), + dotSlash: (checkout: string, target: string): string => + `./${relative(checkout, target)}`, + doubledSlash: (checkout: string, target: string): string => + relative(checkout, target).replace('/', '//'), + trailingSlash: (checkout: string, target: string): string => + `${relative(checkout, target)}/`, + backThroughDotDot: (checkout: string, target: string): string => + join('..', basename(checkout), '..', relative(dirname(checkout), target)), + // These two build the symlink they then walk through, and return a + // string rather than a join(), since a `..` after a symlink is exactly + // what a lexical fold would take out. + viaSymlinkThenDotDot: (checkout: string, target: string): string => { + const side = join(dirname(target), 'side') + mkdirSync(side, { recursive: true }) + symlinkSync(side, join(checkout, 'hop')) + return `hop/../${basename(target)}` + }, + viaSymlinkThenTwoDotDots: (checkout: string, target: string): string => { + const deeper = join(dirname(target), 'side', 'deeper') + mkdirSync(deeper, { recursive: true }) + mkdirSync(join(checkout, 'sub'), { recursive: true }) + symlinkSync(deeper, join(checkout, 'sub', 'hop')) + return `sub/hop/../../${basename(target)}` + }, + } + + /** + * The target's shape. git follows a pointer only to a directory its + * `is_git_directory` accepts: a valid HEAD, plus objects/ and refs/ found + * through the target's own `commondir`. + */ + const TARGETS = { + gitDir: (caseDir: string): string => makeGitDir(join(caseDir, 'target')), + realGitInit: (caseDir: string): string => { + const repo = join(caseDir, 'initialized') + expect(runGit(caseDir, ['init', '-q', repo])).not.toBeUndefined() + return join(repo, '.git') + }, + headIsASymlink: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'target')) + rmSync(join(gitDir, 'HEAD')) + symlinkSync('refs/heads/main', join(gitDir, 'HEAD')) + return gitDir + }, + symlinkToAGitDir: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real-target')) + const link = join(caseDir, 'target') + symlinkSync(gitDir, link) + return link + }, + throughASymlinkedParent: (caseDir: string): string => { + const gitDir = makeGitDir(join(caseDir, 'real', 'target')) + symlinkSync(dirname(gitDir), join(caseDir, 'link')) + return join(caseDir, 'link', basename(gitDir)) + }, + /** HEAD and a commondir, no objects/ or refs/: a linked worktree's. */ + worktreeGitDir: (caseDir: string): string => { + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(worktree, 'commondir'), '../..\n') + return worktree + }, + ordinaryDirectory: (caseDir: string): string => { + const target = join(caseDir, 'target') + mkdirSync(join(target, 'config'), { recursive: true }) + return target + }, + absent: (caseDir: string): string => join(caseDir, 'target'), + nameHoldsANewline: (caseDir: string): string => + makeGitDir(join(caseDir, 'two\nlines')), + nameIsNotAscii: (caseDir: string): string => + makeGitDir(join(caseDir, 'ziel-日本-🌱')), + } + + interface PointerCase { + prefix: keyof typeof PREFIXES + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + target: keyof typeof TARGETS + /** Pad the file with newline bytes to exactly this size. */ + padTo?: number + /** The `.git` file is a symlink to the regular file holding the bytes. */ + pointerIsASymlink?: boolean + } + + const MAX_GITFILE_SIZE = 1024 * 1024 + + /** A directory git's `is_git_directory` accepts. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'objects'), { recursive: true }) + mkdirSync(join(gitDir, 'refs'), { recursive: true }) + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + return gitDir + } + + /** + * git, with the host's configuration and environment kept out of it, so + * what it resolves is the pointer file and nothing else. Returns its + * output with the single newline it adds removed — a directory name may + * end in a space or hold a newline of its own — or undefined when it + * failed, which for `rev-parse` here means it did not follow the pointer. + */ + function runGit(cwd: string, args: string[]): string | undefined { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: root, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + } + for (const name of [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_INDEX_FILE', + 'GIT_CEILING_DIRECTORIES', + 'GIT_DISCOVERY_ACROSS_FILESYSTEM', + 'GIT_NAMESPACE', + 'GIT_CONFIG', + 'GIT_CONFIG_COUNT', + ]) { + delete env[name] + } + const result = spawnSync( + 'git', + ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=/dev/null', ...args], + { cwd, env, encoding: 'utf8' }, + ) + if (result.status !== 0) return undefined + return result.stdout.endsWith('\n') + ? result.stdout.slice(0, -1) + : result.stdout + } + + /** The directory `git rev-parse ` names in `checkout`, if any. */ + function gitResolves(checkout: string, arg: string): string | undefined { + const printed = runGit(checkout, ['rev-parse', arg]) + if (printed === undefined || printed === '') return undefined + const absolute = isAbsolute(printed) ? printed : resolve(checkout, printed) + const real = realPathOrSelf(absolute) + // Discovery walks up from cwd when a pointer is merely absent, so a + // repository above the scratch tree is not this pointer's target. + return real.startsWith(root + sep) ? real : undefined + } + + function realPathOrSelf(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } + } + + /** Each deny path, keyed by the real directory it sits in. */ + function coverage(denyPaths: string[]): Set { + return new Set( + denyPaths.map(p => join(realPathOrSelf(dirname(p)), basename(p))), + ) + } + + const GUARDED_NAMES = ['hooks', 'config', 'config.worktree', 'commondir'] + + type Verdict = 'checked' | 'refused' | 'not-followed' + + function assertParity( + label: string, + pointer: string, + checkout: string, + ): Verdict { + let denyPaths: string[] + try { + denyPaths = gitFileDenyPaths(pointer, false) + } catch (err) { + if (!(err instanceof GitMetadataError)) throw err + // Refusing to sandbox covers everything, including what git follows. + return 'refused' + } + const gitDir = gitResolves(checkout, '--absolute-git-dir') + if (gitDir === undefined) return 'not-followed' + const commonDir = gitResolves(checkout, '--git-common-dir') + const covered = coverage(denyPaths) + const missing = [gitDir, commonDir] + .filter((d): d is string => d !== undefined) + .flatMap(d => GUARDED_NAMES.map(name => join(d, name))) + .filter(p => !covered.has(p)) + expect({ label, missing }).toEqual({ label, missing: [] }) + return 'checked' + } + + /** Build one case on disk and resolve it both ways. */ + function runPointerCase(spec: PointerCase, labelPrefix = ''): Verdict { + const label = labelPrefix + JSON.stringify(spec) + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + + const target = TARGETS[spec.target](caseDir) + const spelled = SPELLINGS[spec.spelling](checkout, target) + let contents = Buffer.concat([ + PREFIXES[spec.prefix], + Buffer.from(spelled), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + + const pointer = join(checkout, '.git') + if (spec.pointerIsASymlink === true) { + const regular = join(checkout, 'pointer-file') + writeFileSync(regular, contents) + symlinkSync(regular, pointer) + } else { + writeFileSync(pointer, contents) + } + return assertParity(label, pointer, checkout) + } + + const FIXED_CASES: PointerCase[] = [ + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'dotSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'doubledSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'trailingSlash', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'backThroughDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'crlf', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'mixedEndings', + target: 'gitDir', + }, + // The reported bypass: padding past the bound this used to read to. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'sixtyFourKiBOfNewlines', + target: 'gitDir', + }, + // The size git accepts for a gitfile, exactly and one byte past it. + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'none', + target: 'gitDir', + padTo: MAX_GITFILE_SIZE + 1, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'trailingSpaces', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'secondLine', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'embeddedNul', + target: 'gitDir', + }, + { + prefix: 'noSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'twoSpaces', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'tab', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'leadingSpace', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'byteOrderMark', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'upperCase', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'headIsASymlink', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'throughASymlinkedParent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'nineThousandNewlines', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'ordinaryDirectory', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'nameHoldsANewline', + }, + { + prefix: 'plain', + spelling: 'absolute', + trailer: 'newline', + target: 'nameIsNotAscii', + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'newline', + target: 'gitDir', + pointerIsASymlink: true, + }, + { + prefix: 'plain', + spelling: 'relative', + trailer: 'nineThousandNewlines', + target: 'gitDir', + pointerIsASymlink: true, + }, + // A `..` after a symlink: the kernel follows the link first, so these + // land somewhere a lexical fold of the path never visits. + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'nineThousandNewlines', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'realGitInit', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'symlinkToAGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenDotDot', + trailer: 'newline', + target: 'absent', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'gitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'newline', + target: 'worktreeGitDir', + }, + { + prefix: 'plain', + spelling: 'viaSymlinkThenTwoDotDots', + trailer: 'embeddedNul', + target: 'gitDir', + }, + ] + + it('resolves a corpus of pointer shapes the way git does', () => { + const verdicts = FIXED_CASES.map(runPointerCase) + // A corpus git followed nothing in would assert nothing at all. + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 120_000) + + it('resolves randomly mixed pointer shapes the way git does', () => { + // Deterministic, so a failing mix is reproducible from the seed below. + const seed = 0x515 + const random = mulberry32(seed) + const specs: PointerCase[] = Array.from({ length: 150 }, () => ({ + // Weighted to the one prefix git follows: the others are worth some + // cases, but they all stop at the same place. + prefix: + random() < 0.5 + ? 'plain' + : pick(random, Object.keys(PREFIXES) as (keyof typeof PREFIXES)[]), + spelling: pick( + random, + Object.keys(SPELLINGS) as (keyof typeof SPELLINGS)[], + ), + trailer: pick(random, Object.keys(TRAILERS) as (keyof typeof TRAILERS)[]), + target: pick(random, Object.keys(TARGETS) as (keyof typeof TARGETS)[]), + ...(random() < 0.1 + ? { padTo: MAX_GITFILE_SIZE + (random() < 0.5 ? 0 : 1) } + : {}), + pointerIsASymlink: random() < 0.1, + })) + const verdicts = specs.map((spec, index) => + runPointerCase(spec, `seed ${seed}, case ${index}: `), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(20) + }, 300_000) + + it('resolves a .. against the directory the pointer file is really in', () => { + // The base counts as much as the target: with the checkout reached + // through a symlink, `..` pops the directory the kernel is in, not the + // one the path was spelled from, and git follows it there. + const caseDir = join(root, `pointer-${caseCount++}`) + mkdirSync(join(caseDir, 'nested', 'checkout'), { recursive: true }) + const checkout = join(caseDir, 'checkout-link') + symlinkSync(join(caseDir, 'nested', 'checkout'), checkout) + makeGitDir(join(caseDir, 'nested', 'target')) + const pointer = join(checkout, '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + expect(assertParity('symlinked pointer directory', pointer, checkout)).toBe( + 'checked', + ) + }) + + // Linux only: the target has to exist for git to follow it, and macOS + // filesystems refuse a name that is not valid UTF-8 (EILSEQ on mkdir). The + // refusal itself is platform-independent and covered everywhere by the + // unit case in mandatory-deny-paths.test.ts, which needs no such target. + it.if(isLinux)( + 'refuses to sandbox on a pointer whose path is not valid UTF-8', + () => { + // The one shape with no honest answer: the bytes name a directory git + // opens and a JavaScript string of them names a different one, so there + // is nothing to put in the deny list. + const caseDir = join(root, `pointer-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const target = Buffer.concat([ + Buffer.from(join(caseDir, 'target-')), + Buffer.from([0xff]), + ]) + mkdirSync(Buffer.concat([target, Buffer.from('/objects')]), { + recursive: true, + }) + mkdirSync(Buffer.concat([target, Buffer.from('/refs')]), { + recursive: true, + }) + writeFileSync( + Buffer.concat([target, Buffer.from('/HEAD')]), + 'ref: refs/heads/main\n', + ) + const pointer = join(checkout, '.git') + writeFileSync( + pointer, + Buffer.concat([Buffer.from('gitdir: '), target, Buffer.from('\n')]), + ) + + // git follows it, so anything short of refusing would be a deny list + // for a directory other than the one whose hooks run. + expect( + runGit(checkout, ['rev-parse', '--absolute-git-dir']), + ).not.toBeUndefined() + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }, + ) + + /** The same shapes in a linked worktree's `commondir`, which has no prefix. */ + interface CommonDirCase { + spelling: keyof typeof SPELLINGS + trailer: keyof typeof TRAILERS + /** Bytes before the path. git trims none of them. */ + leading: string + padTo?: number + } + + function runCommonDirCase(spec: CommonDirCase): Verdict { + const label = JSON.stringify(spec) + const caseDir = join(root, `commondir-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + + let contents = Buffer.concat([ + Buffer.from(spec.leading), + Buffer.from(SPELLINGS[spec.spelling](worktree, main)), + TRAILERS[spec.trailer], + ]) + if (spec.padTo !== undefined && contents.length < spec.padTo) { + contents = Buffer.concat([ + contents, + Buffer.from('\n'.repeat(spec.padTo - contents.length)), + ]) + } + writeFileSync(join(worktree, 'commondir'), contents) + + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktree}\n`) + return assertParity(label, pointer, checkout) + } + + it('resolves a corpus of commondir shapes the way git does', () => { + const verdicts: Verdict[] = [] + // Every trailer against each way of leading the path — git trims none of + // it — and then every spelling of the path itself. + for (const leading of ['', ' ', '\t']) { + for (const trailer of Object.keys( + TRAILERS, + ) as (keyof typeof TRAILERS)[]) { + verdicts.push( + runCommonDirCase({ leading, spelling: 'relative', trailer }), + ) + } + } + for (const spelling of Object.keys( + SPELLINGS, + ) as (keyof typeof SPELLINGS)[]) { + verdicts.push( + runCommonDirCase({ leading: '', spelling, trailer: 'newline' }), + ) + } + verdicts.push( + runCommonDirCase({ + leading: '', + spelling: 'relative', + trailer: 'none', + padTo: MAX_GITFILE_SIZE, + }), + ) + expect(verdicts.filter(v => v === 'checked').length).toBeGreaterThan(10) + }, 300_000) + + it('refuses to sandbox on a commondir past the size it reads', () => { + // git reads commondir whole, with no bound of its own, so a file past + // this one still names the git directory whose hooks a commit runs. + const caseDir = join(root, `commondir-${caseCount++}`) + const checkout = join(caseDir, 'checkout') + mkdirSync(checkout, { recursive: true }) + const main = makeGitDir(join(caseDir, 'main.git')) + const worktree = join(main, 'worktrees', 'wt') + mkdirSync(join(worktree, 'hooks'), { recursive: true }) + writeFileSync(join(worktree, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync( + join(worktree, 'commondir'), + '../..'.padEnd(MAX_GITFILE_SIZE + 1, '\n'), + ) + const pointer = join(checkout, '.git') + writeFileSync(pointer, `gitdir: ${worktree}\n`) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) +}) + +function mulberry32(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function pick(random: () => number, values: readonly T[]): T { + const value = values[Math.floor(random() * values.length)] + if (value === undefined) throw new Error('nothing to pick from') + return value +} diff --git a/test/sandbox/macos-glob-deny-reemit.test.ts b/test/sandbox/macos-glob-deny-reemit.test.ts index 89e778ee7..5db96f61f 100644 --- a/test/sandbox/macos-glob-deny-reemit.test.ts +++ b/test/sandbox/macos-glob-deny-reemit.test.ts @@ -453,10 +453,10 @@ describe.if(isMacOS)('macOS write enforcement for glob denies', () => { expect(readFileSync(join(PROJECT, 'plain.txt'), 'utf8')).toBe('X\n') }) - it("mandatory **/.git/hooks/** still blocks a nested repo's hooks (regression guard)", () => { - // The mandatory patterns are anchored at process.cwd(); this pattern - // already carried its own /** tail before the subtree change, so this - // guards that the change keeps it working rather than fixing it. + it("mandatory **/.git/hooks still blocks a nested repo's hooks (regression guard)", () => { + // The mandatory patterns are anchored at process.cwd(). The pattern + // names the hooks directory itself, and the deny covers everything + // beneath it the way a literal subpath deny does. process.chdir(PROJECT) const hook = join(PROJECT, 'vendor', 'dep', '.git', 'hooks', 'pre-commit') const newHook = join( diff --git a/test/sandbox/macos-literal-deny-brackets.test.ts b/test/sandbox/macos-literal-deny-brackets.test.ts index ede2c31c3..5c78d9122 100644 --- a/test/sandbox/macos-literal-deny-brackets.test.ts +++ b/test/sandbox/macos-literal-deny-brackets.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, afterAll, beforeAll } from 'bun:test' import { spawnSync } from 'node:child_process' -import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' @@ -21,9 +27,8 @@ import { isMacOS, isWindows } from '../helpers/platform.js' * spelling is resolved against it, and a cwd may contain `[`, `*` or `?`. * Compiled as a glob, `a[b/c]d` turns into a one-character class and the * filter stops matching the directory it was built from, so the deny - * covers nothing. The `**\/.git/hooks/**` pattern does not make up for - * it: it covers what is inside the directory, never the directory vnode, - * which is what `mv` and `ln -s` operate on. + * covers nothing. The `**\/.git/hooks` pattern does not make up for it: + * it hangs off the same cwd, so the same bracket pair takes it out too. * * The profile tests only inspect generated SBPL and run on every POSIX * host; the enforcement tests run the profile under sandbox-exec. @@ -102,10 +107,10 @@ describe.if(!isWindows)( it('keeps the cwd literal in the subtree patterns', () => { const profile = wrap(tree, 'true') - // `**\/.git/hooks/**` is anchored at the cwd, so only the tail is a + // `**\/.git/hooks` is anchored at the cwd, so only the tail is a // pattern; the cwd itself is escaped into the regex. const anchor = tree.work.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const expected = `^${anchor}/(.*/)?\\.git/hooks/.*(/.*)?$` + const expected = `^${anchor}/(.*/)?\\.git/hooks(/.*)?$` expect(profile).toContain(`(regex ${JSON.stringify(expected)})`) }) @@ -399,6 +404,252 @@ describe.if(isMacOS)( }, ) +/** + * `/.git` as a directory, with a submodule git directory and a + * directory the `.git/modules` walk stops at, each named across a bracket + * pair the way a submodule name can be: a submodule's name is its path, so + * `a[b/c]d` is an ordinary one, and it is the sandboxed command that chooses + * it. + */ +interface ModulesTree { + /** Bracket-free write root, so only the denies are under test. */ + root: string + /** The bracketed working directory. */ + work: string + /** `/.git/modules/s[m/o]d`, a submodule git directory. */ + submodule: string + /** `/.git/modules/u[n/r]d`, where the walk stops. */ + unreadable: string +} + +function modulesTree(prefix: string): ModulesTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const modules = join(work, '.git', 'modules') + const submodule = join(modules, 's[m', 'o]d') + const unreadable = join(modules, 'u[n', 'r]d') + mkdirSync(join(submodule, 'hooks'), { recursive: true }) + mkdirSync(join(submodule, 'objects'), { recursive: true }) + writeFileSync(join(submodule, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(work, '.git', 'HEAD'), 'ref: refs/heads/main\n') + mkdirSync(unreadable, { recursive: true }) + // A loop the walk cannot stat, so it stops and denies the directory + // holding it whole — the same record an unlistable directory produces. + symlinkSync('loop', join(unreadable, 'loop')) + return { root, work, submodule, unreadable } +} + +/** + * `/.git` as a pointer file, naming a git directory whose `commondir` + * names another. Every directory here is one the file's own contents chose. + */ +interface PointerTree { + root: string + work: string + /** The `.git` pointer file in the cwd. */ + pointer: string + /** `/g[h/i]j`, the git directory the pointer names. */ + gitDir: string + /** `/c[o/m]n`, the git directory its `commondir` names. */ + commonDir: string +} + +function pointerTree(prefix: string): PointerTree { + const root = join(realpathSync(tmpdir()), `${prefix}-${Date.now()}`) + const work = join(root, ...BRACKET_SEGMENTS) + const gitDir = join(root, 'g[h', 'i]j') + const commonDir = join(root, 'c[o', 'm]n') + mkdirSync(work, { recursive: true }) + mkdirSync(gitDir, { recursive: true }) + mkdirSync(commonDir, { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitDir, 'commondir'), `${commonDir}\n`) + const pointer = join(work, '.git') + writeFileSync(pointer, `gitdir: ${gitDir}\n`) + return { root, work, pointer, gitDir, commonDir } +} + +/** The four paths inside a git directory the mandatory denies name. */ +const GIT_DIR_LEAVES = ['hooks', 'commondir', 'config', 'config.worktree'] + +/** + * The git directories the macOS denies cover are read off the filesystem, + * and what they are called is up to the sandboxed command. Compiled as a + * glob, `[m/o]` is a one-character class, so a deny built from + * `.git/modules/s[m/o]d` matches neither that directory nor anything else — + * and a `.gitmodules` naming that submodule makes the host's next + * `git submodule update --init` run whatever hook was planted in it. + */ +describe.if(!isWindows)( + 'macOS profile: git directories read off disk under a bracketed cwd', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function profile(): string { + return wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + } + + it('denies a submodule git directory by subpath', () => { + const text = profile() + for (const leaf of GIT_DIR_LEAVES) { + const denied = join(tree.submodule, leaf) + expect(text).toContain(`(subpath ${JSON.stringify(denied)})`) + expect(text).not.toContain(sniffedFilter(denied, '(/.*)?$')) + } + }) + + it('denies the directory the walk stopped at by subpath', () => { + const text = profile() + expect(text).toContain(`(subpath ${JSON.stringify(tree.unreadable)})`) + expect(text).not.toContain(sniffedFilter(tree.unreadable, '(/.*)?$')) + }) + + it('leaves no regex carrying a bracket read off the filesystem', () => { + // A spelling sniffed as a pattern keeps its brackets verbatim, since + // they are the glob syntax it is taken to be asking for. An anchored + // pattern escapes the part of it the library built. + for (const regex of emittedRegexes(profile())) { + for (const opening of ['s[m/', 'u[n/', 'a[b/']) { + expect(regex).not.toContain(opening) + } + } + }) + + it('anchors the nested-repository patterns at the escaped cwd', () => { + const anchor = tree.work.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const text = profile() + // A nested repository, and a nested repository's submodule, have no + // path on disk to enumerate: they are matched by pattern, hung off the + // cwd, which is escaped into the regex like any other literal. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git/modules/[^/]*/hooks(/.*)?$`)})`, + ) + // Same for the `.git` pointer filter, matched by vnode type. + expect(text).toContain( + `(regex ${JSON.stringify(`^${anchor}/(.*/)?\\.git$`)})`, + ) + }) + }, +) + +/** + * A pointer file's target, and the target its `commondir` names, are paths + * the sandboxed command wrote into a file. Both are followed the way git + * follows them, and both are names on disk once followed. + */ +describe.if(!isWindows)( + 'macOS profile: a bracketed git directory named by a pointer file', + () => { + let tree: PointerTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = pointerTree('bracket-pointer-profile') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + it('denies the pointer and both git directories by subpath', () => { + const text = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }) + const denied = [ + tree.pointer, + ...GIT_DIR_LEAVES.map(leaf => join(tree.gitDir, leaf)), + ...GIT_DIR_LEAVES.map(leaf => join(tree.commonDir, leaf)), + ] + for (const path of denied) { + expect(text).toContain(`(subpath ${JSON.stringify(path)})`) + expect(text).not.toContain(sniffedFilter(path, '(/.*)?$')) + } + }) + }, +) + +describe.if(isMacOS)( + 'macOS sandbox: a bracketed submodule git directory keeps its hooks', + () => { + let tree: ModulesTree + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + tree = modulesTree('bracket-modules-exec') + process.chdir(tree.work) + }) + + afterAll(() => { + process.chdir(originalCwd) + rmSync(tree.root, { recursive: true, force: true }) + }) + + function run(command: string): { status: number | null; stderr: string } { + const result = spawnSync( + wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [tree.root], denyWithinAllow: [] }, + }), + { + shell: true, + encoding: 'utf8', + timeout: 10000, + // Assert on the message, so pin the language it is written in. + env: { ...process.env, LC_ALL: 'C' }, + }, + ) + return { status: result.status, stderr: result.stderr || '' } + } + + it('writes elsewhere in that git directory (sanity check)', () => { + const target = join(tree.submodule, 'objects', 'written') + const result = run(`echo ok > ${JSON.stringify(target)}`) + expect(result.status).toBe(0) + }) + + it('refuses a hook planted in that git directory', () => { + const target = join(tree.submodule, 'hooks', 'pre-commit') + const result = run(`echo planted > ${JSON.stringify(target)}`) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + }) + + it('refuses a write in the directory the walk stopped at', () => { + const target = join(tree.unreadable, 'planted') + const result = run(`echo planted > ${JSON.stringify(target)}`) + expect(result.status).not.toBe(0) + expect(result.stderr.toLowerCase()).toContain('operation not permitted') + }) + }, +) + /** * The deny-glob compiler exists twice on purpose: a string-taking one in * `sandbox-utils.ts`, where a caller's configured spelling is all pattern, diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index c53d50393..43b40e709 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -9,17 +9,28 @@ import { } from 'bun:test' import { spawn, spawnSync } from 'node:child_process' import { + chmodSync, mkdirSync, + readdirSync, + mkdtempSync, + renameSync, rmSync, writeFileSync, readFileSync, symlinkSync, existsSync, statSync, + realpathSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getPlatform } from '../../src/utils/platform.js' +import { + indexOfMount, + lastIndexOfMount, + lastMountAt, +} from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' import { wrapCommandWithSandboxMacOS, macGetMandatoryDenyEntries, @@ -27,8 +38,21 @@ import { import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, + linuxGetMonitorCwdDenyPaths, + GIT_REDIRECT_STORE_PREFIX, + LinuxSandboxProfileError, } from '../../src/sandbox/linux-sandbox-utils.js' -import { isLinux, isSupportedPlatform } from '../helpers/platform.js' +import { + GitMetadataError, + MAX_SUBMODULE_WALK_DEPTH, + gitDirDenyPaths, + gitFileDenyPaths, + gitRedirectPlaceholder, + submoduleGitDirs, +} from '../../src/sandbox/mandatory-deny-paths.js' +import { isLinux, isSupportedPlatform, isWindows } from '../helpers/platform.js' +import type { RipgrepConfig } from '../../src/utils/ripgrep.js' /** * Integration tests for mandatory deny paths. @@ -45,6 +69,12 @@ describe.if(isSupportedPlatform)( 'Mandatory Deny Paths - Integration Tests', () => { const TEST_DIR = join(tmpdir(), `mandatory-deny-integration-${Date.now()}`) + // A read-denied region outside cwd, so the read section emits its + // operation-specific unlink/create rules (which a deny has to survive). + const READ_DENY_DIR = join( + tmpdir(), + `mandatory-deny-readdeny-${Date.now()}`, + ) const ORIGINAL_CONTENT = 'ORIGINAL' const MODIFIED_CONTENT = 'MODIFIED' let originalCwd: string @@ -52,6 +82,8 @@ describe.if(isSupportedPlatform)( beforeAll(() => { originalCwd = process.cwd() mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(READ_DENY_DIR, { recursive: true }) + writeFileSync(join(READ_DENY_DIR, 'secret.txt'), ORIGINAL_CONTENT) // Create ALL dangerous files from DANGEROUS_FILES writeFileSync(join(TEST_DIR, '.bashrc'), ORIGINAL_CONTENT) @@ -112,6 +144,98 @@ describe.if(isSupportedPlatform)( ) writeFileSync(join(TEST_DIR, '.git', 'index'), ORIGINAL_CONTENT) + // A nested repository directly under cwd, with `nested/` gitignored: + // its config sits at the default scan depth, its hook files one past + // it, and an ignore file must not hide either from the scan. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'hooks'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'nested', 'src'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + writeFileSync(join(TEST_DIR, 'nested', 'src', 'ok.txt'), ORIGINAL_CONTENT) + writeFileSync(join(TEST_DIR, '.gitignore'), 'nested/\nlib/\n') + // The nested repository has a submodule of its own. + mkdirSync(join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'hooks'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'nested', '.git', 'modules', 'dep', 'HEAD'), + 'ref: x', + ) + // A submodule: its git directory lives under cwd's .git/modules and its + // checkout has a .git FILE pointing there. + mkdirSync(join(TEST_DIR, '.git', 'modules', 'lib', 'hooks'), { + recursive: true, + }) + writeFileSync(join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'config'), + ORIGINAL_CONTENT, + ) + writeFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + ORIGINAL_CONTENT, + ) + mkdirSync(join(TEST_DIR, 'lib'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'lib', '.git'), + 'gitdir: ../.git/modules/lib', + ) + // A linked worktree of this repository checked out inside it: its + // .git file points at .git/worktrees/wt, whose commondir is the main + // .git, so the hooks a commit in the worktree runs are the main ones. + mkdirSync(join(TEST_DIR, '.git', 'worktrees', 'wt'), { recursive: true }) + writeFileSync(join(TEST_DIR, '.git', 'worktrees', 'wt', 'HEAD'), 'ref: x') + writeFileSync( + join(TEST_DIR, '.git', 'worktrees', 'wt', 'commondir'), + '../..\n', + ) + mkdirSync(join(TEST_DIR, 'wt-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'wt-checkout', '.git'), + `gitdir: ${join(TEST_DIR, '.git', 'worktrees', 'wt')}`, + ) + // A checkout whose .git pointer carries 9,000 newline bytes after the + // path. git strips them and follows the pointer, so its target needs + // the same denies an unpadded pointer's does. + mkdirSync(join(TEST_DIR, 'padded-target', 'hooks'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-target', 'HEAD'), + 'ref: refs/heads/main', + ) + writeFileSync(join(TEST_DIR, 'padded-target', 'config'), ORIGINAL_CONTENT) + mkdirSync(join(TEST_DIR, 'padded-checkout'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'padded-checkout', '.git'), + `gitdir: ${join(TEST_DIR, 'padded-target')}${'\n'.repeat(9000)}`, + ) + // A nested .claude/commands one level down (a name spanning two + // segments), within reach only of a deeper scan. + mkdirSync(join(TEST_DIR, 'pkg', '.claude', 'commands'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'pkg', '.claude', 'commands', 'x.md'), + ORIGINAL_CONTENT, + ) + // A working directory whose own location has a dangerous name in it. + mkdirSync(join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, '.vscode', 'ext', 'foo', 'sub', '.gitconfig'), + ORIGINAL_CONTENT, + ) + // Create safe file within .claude that SHOULD be writable (not commands/agents) writeFileSync( join(TEST_DIR, '.claude', 'some-other-file.txt'), @@ -122,6 +246,7 @@ describe.if(isSupportedPlatform)( afterAll(() => { process.chdir(originalCwd) rmSync(TEST_DIR, { recursive: true, force: true }) + rmSync(READ_DENY_DIR, { recursive: true, force: true }) }) beforeEach(() => { @@ -136,16 +261,27 @@ describe.if(isSupportedPlatform)( cleanupBwrapMountPoints({ force: true }) }) - async function runSandboxedWrite( - filePath: string, - content: string, + interface SandboxRunOptions { + mandatoryDenySearchDepth?: number + allowGitConfig?: boolean + allowOnly?: string[] + /** + * A read config makes the read section emit its own + * operation-specific unlink/create rules, which the write section's + * denies have to survive. + */ + readConfig?: { denyOnly: string[]; allowWithinDeny?: string[] } + } + + async function runSandboxed( + command: string, + opts: SandboxRunOptions = {}, ): Promise<{ success: boolean; stderr: string }> { const platform = getPlatform() - const command = `echo '${content}' > '${filePath}'` // Allow writes to current directory, but mandatory denies should still block dangerous files const writeConfig = { - allowOnly: ['.'], + allowOnly: opts.allowOnly ?? ['.'], denyWithinAllow: [], // Empty - relying on mandatory denies } @@ -154,15 +290,18 @@ describe.if(isSupportedPlatform)( wrappedCommand = wrapCommandWithSandboxMacOS({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, + allowGitConfig: opts.allowGitConfig, }) } else { wrappedCommand = await wrapCommandWithSandboxLinux({ command, needsNetworkRestriction: false, - readConfig: undefined, + readConfig: opts.readConfig, writeConfig, + mandatoryDenySearchDepth: opts.mandatoryDenySearchDepth, + allowGitConfig: opts.allowGitConfig, }) } @@ -178,6 +317,27 @@ describe.if(isSupportedPlatform)( } } + /** + * The write did not land. On Linux the mount point for an absent deny + * path stays until the cleanup: bwrap's own empty file, or, for a file + * git reads back, the placeholder the wrap wrote there (`expected`). On + * macOS nothing is created. + */ + function expectNotWritten(absolutePath: string, expected = ''): void { + const content = existsSync(absolutePath) + ? readFileSync(absolutePath, 'utf8') + : '' + expect(content).toBe(isLinux ? expected : '') + } + + async function runSandboxedWrite( + filePath: string, + content: string, + opts: SandboxRunOptions = {}, + ): Promise<{ success: boolean; stderr: string }> { + return runSandboxed(`echo '${content}' > '${filePath}'`, opts) + } + describe('Dangerous files should be blocked', () => { it('blocks writes to .bashrc', async () => { const result = await runSandboxedWrite('.bashrc', MODIFIED_CONTENT) @@ -267,6 +427,709 @@ describe.if(isSupportedPlatform)( }) }) + describe('Nested repositories, submodules and worktree pointers', () => { + it("blocks writes to a nested repository's .git/config even when gitignored", async () => { + const result = await runSandboxedWrite( + 'nested/.git/config', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it("blocks writes to a nested repository's existing hook at the default depth", async () => { + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it("blocks a nested repository's hooks when only its HEAD is within the scan depth", async () => { + // With allowGitConfig the scan does not look for .git/config, and the + // hook files lie one level past the default depth. + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it('blocks creating a new hook in a nested repository', async () => { + const result = await runSandboxedWrite( + 'nested/.git/hooks/post-checkout', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(existsSync('nested/.git/hooks/post-checkout')).toBe(false) + }) + + it('keeps the rest of a nested repository writable', async () => { + const result = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + expect(readFileSync('nested/src/ok.txt', 'utf8').trim()).toBe( + MODIFIED_CONTENT, + ) + }) + + it.if(isLinux && process.getuid?.() !== 0)( + 'keeps what the scan found when a directory under cwd is unreadable', + async () => { + mkdirSync('unreadable', { mode: 0o000 }) + try { + const result = await runSandboxedWrite( + 'nested/.git/config', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + chmodSync('unreadable', 0o755) + rmSync('unreadable', { recursive: true, force: true }) + } + }, + ) + + it("blocks writes to a submodule's config under .git/modules", async () => { + const result = await runSandboxedWrite( + '.git/modules/lib/config', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('.git/modules/lib/config', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + }) + + it("blocks creating a hook in a submodule's git directory", async () => { + const result = await runSandboxedWrite( + '.git/modules/lib/hooks/post-checkout', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(existsSync('.git/modules/lib/hooks/post-checkout')).toBe(false) + }) + + it("blocks creating a hook in a nested repository's submodule", async () => { + const result = await runSandboxedWrite( + 'nested/.git/modules/dep/hooks/post-checkout', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(existsSync('nested/.git/modules/dep/hooks/post-checkout')).toBe( + false, + ) + }) + + it("blocks repointing a submodule checkout's .git file", async () => { + const result = await runSandboxedWrite( + 'lib/.git', + 'gitdir: /tmp/elsewhere', + ) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + }) + + it('blocks removing an existing .git pointer file', async () => { + const result = await runSandboxed('rm -f lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + }) + + it('blocks renaming a file over an existing .git pointer', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'decoy'), 'gitdir: /tmp/elsewhere') + try { + const result = await runSandboxed('mv -f lib/decoy lib/.git', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(false) + expect(readFileSync('lib/.git', 'utf8')).toBe( + 'gitdir: ../.git/modules/lib', + ) + } finally { + rmSync(join(TEST_DIR, 'lib', 'decoy'), { force: true }) + } + }) + + it('still removes an ordinary file with the same read config', async () => { + writeFileSync(join(TEST_DIR, 'lib', 'plain.txt'), ORIGINAL_CONTENT) + try { + const result = await runSandboxed('rm -f lib/plain.txt', { + readConfig: { denyOnly: [READ_DENY_DIR] }, + }) + + expect(result.success).toBe(true) + expect(existsSync(join(TEST_DIR, 'lib', 'plain.txt'))).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'lib', 'plain.txt'), { force: true }) + } + }) + + it("blocks creating a commondir in the repository's git directory", async () => { + // git reads hooks and config through commondir, so a write here + // moves every deny below to a directory of the command's choosing. + const result = await runSandboxedWrite('.git/commondir', 'decoy') + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, '.git', 'commondir'), '.\n') + }) + + it("blocks creating a commondir in a submodule's git directory", async () => { + const result = await runSandboxedWrite( + '.git/modules/lib/commondir', + 'decoy', + ) + + expect(result.success).toBe(false) + expectNotWritten( + join(TEST_DIR, '.git', 'modules', 'lib', 'commondir'), + '.\n', + ) + }) + + it("blocks creating a nested repository's commondir", async () => { + const result = await runSandboxedWrite('nested/.git/commondir', 'decoy') + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, 'nested', '.git', 'commondir'), '.\n') + }) + + it('blocks creating .git/config.worktree', async () => { + // Read instead of .git/config wherever extensions.worktreeConfig is + // on, which `git sparse-checkout init` turns on. + const result = await runSandboxedWrite( + '.git/config.worktree', + 'fsmonitor = touch pwned', + ) + + expect(result.success).toBe(false) + expectNotWritten(join(TEST_DIR, '.git', 'config.worktree')) + }) + + it('allows .git/config.worktree when allowGitConfig is true', async () => { + try { + const result = await runSandboxedWrite( + '.git/config.worktree', + 'bare = false', + { allowGitConfig: true }, + ) + + expect(result.success).toBe(true) + } finally { + rmSync(join(TEST_DIR, '.git', 'config.worktree'), { force: true }) + } + }) + + it('finds a submodule git directory whose HEAD was moved aside', async () => { + const head = join(TEST_DIR, '.git', 'modules', 'lib', 'HEAD') + renameSync(head, `${head}.bak`) + try { + const result = await runSandboxedWrite( + '.git/modules/lib/hooks/pre-commit', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect( + readFileSync( + join(TEST_DIR, '.git', 'modules', 'lib', 'hooks', 'pre-commit'), + 'utf8', + ), + ).toBe(ORIGINAL_CONTENT) + } finally { + renameSync(`${head}.bak`, head) + } + }) + + it.if(isLinux)( + 'finds a nested repository whose HEAD was moved aside', + async () => { + // With allowGitConfig the scan does not look for config either, and + // the hook files are one level past the default depth: the + // repository has to be recognised by whatever else its .git holds. + const head = join(TEST_DIR, 'nested', '.git', 'HEAD') + renameSync(head, `${head}.bak`) + try { + const result = await runSandboxedWrite( + 'nested/.git/hooks/pre-commit', + MODIFIED_CONTENT, + { allowGitConfig: true }, + ) + + expect(result.success).toBe(false) + expect(readFileSync('nested/.git/hooks/pre-commit', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } finally { + renameSync(`${head}.bak`, head) + } + }, + ) + + it.if(isLinux)( + 'does not follow a .git file that names an ordinary directory', + async () => { + // `gitdir: ..` from app/tools would otherwise make app/config and + // app/hooks — an ordinary Rails-shaped tree — read-only. + mkdirSync(join(TEST_DIR, 'app', 'config'), { recursive: true }) + mkdirSync(join(TEST_DIR, 'app', 'tools'), { recursive: true }) + writeFileSync(join(TEST_DIR, 'app', 'tools', '.git'), 'gitdir: ..') + try { + const result = await runSandboxedWrite( + 'app/config/settings.yml', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(true) + } finally { + rmSync(join(TEST_DIR, 'app'), { recursive: true, force: true }) + } + }, + ) + + it.if(isLinux)( + 'blocks filling in the git directory a dangling .git file names', + async () => { + mkdirSync(join(TEST_DIR, 'dangling'), { recursive: true }) + writeFileSync( + join(TEST_DIR, 'dangling', '.git'), + 'gitdir: ../dangling-gitdir', + ) + try { + const result = await runSandboxed( + 'mkdir -p dangling-gitdir/hooks && echo X > dangling-gitdir/hooks/pre-commit', + ) + + expect(result.success).toBe(false) + expect( + existsSync( + join(TEST_DIR, 'dangling-gitdir', 'hooks', 'pre-commit'), + ), + ).toBe(false) + } finally { + rmSync(join(TEST_DIR, 'dangling'), { recursive: true, force: true }) + rmSync(join(TEST_DIR, 'dangling-gitdir'), { + recursive: true, + force: true, + }) + } + }, + ) + + it.if(isLinux)( + 'refuses to sandbox at all when the scan does not finish', + async () => { + // One complete path, one the run was cut off in the middle of, and + // then a run that outlives its timeout: what it did not reach is + // unknown, so there is nothing safe to wrap the next command with. + const error = await wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig: { + command: '/bin/sh', + args: [ + '-c', + 'printf "%s\\0%s" "$PWD/a/.git/HEAD" "$PWD/b/.gi"; exec sleep 30', + ], + timeoutMs: 200, + }, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/did not finish/) + }, + ) + + it.if(isLinux && process.getuid?.() !== 0)( + 'denies a directory the scan could not read', + async () => { + mkdirSync(join(TEST_DIR, 'locked', '.git', 'hooks'), { + recursive: true, + }) + writeFileSync( + join(TEST_DIR, 'locked', '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + chmodSync(join(TEST_DIR, 'locked'), 0o000) + try { + const result = await runSandboxed( + 'chmod 755 locked && echo X > locked/.git/hooks/pre-commit', + ) + + expect(result.success).toBe(false) + expect(result.stderr).not.toBe('') + } finally { + chmodSync(join(TEST_DIR, 'locked'), 0o755) + rmSync(join(TEST_DIR, 'locked'), { recursive: true, force: true }) + } + }, + ) + + /** + * What the scan does when it fails, driven by a fake rg: the run that + * fails for real needs a directory this process cannot read, and as + * root — which CI is — there is none, so none of this would be + * exercised there. The last arm is the real thing, where the uid allows. + */ + describe.if(isLinux)('when the scan fails', () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + + const wrapWith = ( + ripgrepConfig: RipgrepConfig, + abortSignal?: AbortSignal, + ): Promise => + wrapCommandWithSandboxLinux({ + command: 'echo hi', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig, + abortSignal, + }) + + /** A fake rg: `matches` NUL-terminated on stdout, `stderr`, exit 2. */ + const failingRipgrep = ( + matches: string[], + stderr: string, + ): RipgrepConfig => ({ + command: '/bin/sh', + args: [ + '-c', + `printf '%s\\0' ${matches.map(match => `'${match}'`).join(' ')}; ` + + `printf '%s\\n' '${stderr}' >&2; exit 2`, + ], + }) + + it('keeps what it listed and denies the directory it could not read', async () => { + const cwd = process.cwd() + const locked = join(cwd, 'locked') + const hooks = join(cwd, 'nested', '.git', 'hooks') + const config = join(cwd, 'nested', '.git', 'config') + mkdirSync(locked, { recursive: true }) + try { + const command = await wrapWith( + failingRipgrep( + [config], + `rg: ${locked}: Permission denied (os error 13)`, + ), + ) + + // The nested repository the partial listing named is denied as + // if the run had finished, and what it could not read is denied + // whole, since a nested repository inside it would be unseen. + expect(lastMountAt(command, hooks)).toBe( + `--ro-bind ${hooks} ${hooks}`, + ) + expect(lastMountAt(command, config)).toBe( + `--ro-bind ${config} ${config}`, + ) + expect(lastMountAt(command, locked)).toBe( + `--ro-bind ${locked} ${locked}`, + ) + } finally { + rmSync(locked, { recursive: true, force: true }) + } + }) + + it('refuses the wrap when the failure names no path to deny', async () => { + // Nothing here stands in for a failure of this shape, so what the + // run never reached would stay writable. + const error = await wrapWith( + failingRipgrep( + [join(process.cwd(), 'nested', '.git', 'config')], + 'rg: unrecognized option --frobnicate', + ), + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch( + /failed for a reason no deny stands in for/, + ) + expect((error as Error).message).toMatch(/was not run/) + }) + + it('refuses the wrap when the scan could not be run at all', async () => { + const error = await wrapWith({ + command: join(TEST_DIR, 'no-such-ripgrep'), + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(LinuxSandboxProfileError) + expect((error as LinuxSandboxProfileError).code).toBe( + 'deny_scan_failed', + ) + expect((error as Error).message).toMatch(/could not be run/) + }) + + it("lets the caller's own abort through as itself", async () => { + const controller = new AbortController() + controller.abort() + + const error = await wrapWith( + { command: '/bin/sh', args: ['-c', 'true'] }, + controller.signal, + ).catch((e: unknown) => e) + + expect((error as Error).name).toBe('AbortError') + expect(error).not.toBeInstanceOf(LinuxSandboxProfileError) + }) + + it.if(process.getuid?.() !== 0 && bwrapCanNamespace())( + 'denies a nested repository the real rg could not walk past', + async () => { + const cwd = process.cwd() + const blind = join(cwd, 'blind') + const hook = join(cwd, 'nested', '.git', 'hooks', 'pre-commit') + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { allowOnly: [cwd], denyWithinAllow: [] }, + }) + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd, + }) + + mkdirSync(blind, { recursive: true }) + try { + // The first command leaves a directory the next command's scan + // cannot read: that scan fails, and the hooks of the nested + // repository beside it must be denied all the same. + const first = run(await wrap(`echo ${BOOTED} && chmod 000 blind`)) + expect(first.stdout).toContain(BOOTED) + expect(first.status).toBe(0) + cleanupBwrapMountPoints({ force: true }) + + const second = run( + await wrap(`echo ${BOOTED}; echo X > ${hook} || echo DENIED`), + ) + expect(second.stdout).toContain(BOOTED) + expect(second.stdout).toContain('DENIED') + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + } finally { + chmodSync(blind, 0o755) + rmSync(blind, { recursive: true, force: true }) + } + }, + 60000, + ) + }) + + it('still lets a command create a .git file where none exists', async () => { + mkdirSync('fresh-checkout', { recursive: true }) + try { + const result = await runSandboxedWrite( + 'fresh-checkout/.git', + 'gitdir: ../.git/modules/fresh', + ) + + expect(result.success).toBe(true) + expect(readFileSync('fresh-checkout/.git', 'utf8').trim()).toBe( + 'gitdir: ../.git/modules/fresh', + ) + } finally { + rmSync('fresh-checkout', { recursive: true, force: true }) + } + }) + + it('does not let a .git file be created outside the allowed write paths', async () => { + const opts = { allowOnly: [join(TEST_DIR, 'nested', 'src')] } + mkdirSync('unlisted', { recursive: true }) + try { + const pointer = await runSandboxedWrite( + 'unlisted/.git', + 'gitdir: /tmp/elsewhere', + opts, + ) + expect(pointer.success).toBe(false) + expect(existsSync('unlisted/.git')).toBe(false) + + const control = await runSandboxedWrite( + 'nested/src/ok.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + } finally { + rmSync('unlisted', { recursive: true, force: true }) + } + }) + + describe('from a linked worktree checkout', () => { + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'wt-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'wt-checkout', 'notes.txt'), { force: true }) + }) + + it("blocks the main repository's hooks, which the worktree's commits run", async () => { + const hook = join(TEST_DIR, '.git', 'hooks', 'pre-commit') + const denied = await runSandboxedWrite(hook, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(hook, 'utf8')).toBe(ORIGINAL_CONTENT) + + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + }) + + it("blocks rewriting the worktree's commondir", async () => { + // It names the git directory whose hooks and config a commit here + // runs, so it chooses what the denies below apply to. + const commondir = join( + TEST_DIR, + '.git', + 'worktrees', + 'wt', + 'commondir', + ) + const denied = await runSandboxedWrite(commondir, '../../decoy', opts) + + expect(denied.success).toBe(false) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + }) + + it("blocks repointing the checkout's own .git file", async () => { + const original = readFileSync('.git', 'utf8') + const result = await runSandboxedWrite( + '.git', + 'gitdir: /tmp/elsewhere', + opts, + ) + + expect(result.success).toBe(false) + expect(readFileSync('.git', 'utf8')).toBe(original) + }) + }) + + describe('from a checkout whose pointer is padded with newlines', () => { + // git strips them from the end of the file and follows the pointer, + // so reading less of the file than git does would leave the target's + // hooks and config writable. + const opts = { allowOnly: [TEST_DIR] } + beforeEach(() => { + process.chdir(join(TEST_DIR, 'padded-checkout')) + }) + afterEach(() => { + rmSync(join(TEST_DIR, 'padded-checkout', 'notes.txt'), { + force: true, + }) + }) + + it("blocks the padded pointer's target, as an unpadded one's", async () => { + const config = join(TEST_DIR, 'padded-target', 'config') + const denied = await runSandboxedWrite(config, MODIFIED_CONTENT, opts) + expect(denied.success).toBe(false) + expect(readFileSync(config, 'utf8')).toBe(ORIGINAL_CONTENT) + + const hook = join(TEST_DIR, 'padded-target', 'hooks', 'pre-commit') + const hookWrite = await runSandboxedWrite( + hook, + MODIFIED_CONTENT, + opts, + ) + expect(hookWrite.success).toBe(false) + expectNotWritten(hook) + + const control = await runSandboxedWrite( + 'notes.txt', + MODIFIED_CONTENT, + opts, + ) + expect(control.success).toBe(true) + }) + }) + + it("matches dangerous names below cwd only, not in cwd's own location", async () => { + process.chdir(join(TEST_DIR, '.vscode', 'ext', 'foo')) + + const denied = await runSandboxedWrite( + 'sub/.gitconfig', + MODIFIED_CONTENT, + ) + expect(denied.success).toBe(false) + expect(readFileSync('sub/.gitconfig', 'utf8')).toBe(ORIGINAL_CONTENT) + + const control = await runSandboxedWrite('sub/ok.txt', MODIFIED_CONTENT) + expect(control.success).toBe(true) + }) + + it('denies a nested .claude/commands as a directory once the scan reaches it', async () => { + // pkg/.claude/commands/x.md is four segments deep: found with a + // depth of 4 (macOS matches by pattern at any depth), and then the + // whole directory is read-only, new files included. + const existing = await runSandboxedWrite( + 'pkg/.claude/commands/x.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(existing.success).toBe(false) + expect(readFileSync('pkg/.claude/commands/x.md', 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + + const created = await runSandboxedWrite( + 'pkg/.claude/commands/new.md', + MODIFIED_CONTENT, + { mandatoryDenySearchDepth: 4 }, + ) + expect(created.success).toBe(false) + expect(existsSync('pkg/.claude/commands/new.md')).toBe(false) + }) + }) + describe('Dangerous directories should be blocked', () => { it('blocks writes to .vscode/', async () => { const result = await runSandboxedWrite( @@ -926,10 +1789,10 @@ describe.if(isSupportedPlatform)( denyWithinAllow: [] as string[], } - // linuxGetMandatoryDenyPaths adds .git/hooks to deny list. - // .git exists as a file, so .git/hooks doesn't exist. - // The code will try to mount /dev/null at .git/hooks, but bwrap - // can't create a mount point there because .git is a file. + // .git is a pointer file here, so it goes through + // gitFileDenyPaths: the file itself and the hooks and config it + // leads to are denied, and nothing is mounted under the file + // (bwrap could not create a mount point there). const wrappedCommand = await wrapCommandWithSandboxLinux({ command: 'echo hello', needsNetworkRestriction: false, @@ -948,7 +1811,6 @@ describe.if(isSupportedPlatform)( // should not cause the sandbox to fail. expect(result.status).toBe(0) expect(result.stdout.trim()).toBe('hello') - cleanupBwrapMountPoints() } finally { process.chdir(originalDir) @@ -1185,3 +2047,1115 @@ describe('macGetMandatoryDenyEntries - Unit Tests', () => { expect(hasGitConfigPattern).toBe(true) }) }) +describe('Git metadata deny paths - Unit Tests', () => { + let dir: string + + beforeEach(() => { + // Real, so a temporary directory that is itself reached through a + // symlink (macOS /var) does not make every path here two paths. + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-deny-paths-'))) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + /** A directory git would accept as a git directory. */ + function makeGitDir(gitDir: string): string { + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + writeFileSync(join(gitDir, 'HEAD'), 'ref: refs/heads/main') + return gitDir + } + + /** A checkout whose `.git` file holds exactly `contents`. */ + function writePointer(checkout: string, contents: string | Buffer): string { + mkdirSync(join(dir, checkout), { recursive: true }) + const pointer = join(dir, checkout, '.git') + writeFileSync(pointer, contents) + return pointer + } + + function makePointer(checkout: string, target: string): string { + return writePointer(checkout, `gitdir: ${target}\n`) + } + + it('denies commondir in every git directory, and config.worktree with config', () => { + expect(gitDirDenyPaths('/repo/.git', false)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + '/repo/.git/config', + '/repo/.git/config.worktree', + ]) + expect(gitDirDenyPaths('/repo/.git', true)).toEqual([ + '/repo/.git/hooks', + '/repo/.git/commondir', + ]) + }) + + it('gives the files git reads a placeholder that is no redirect', () => { + // `.` is the git directory itself, which is where git looks when there + // is no commondir, and no config.worktree reads as an empty one does. + expect(gitRedirectPlaceholder('/repo/.git/commondir')).toBe('.\n') + expect(gitRedirectPlaceholder('/repo/.git/config.worktree')).toBe('') + expect(gitRedirectPlaceholder('/repo/.git/config')).toBeUndefined() + expect(gitRedirectPlaceholder('/repo/.git/hooks')).toBeUndefined() + }) + + it('gives a placeholder to the denied files that need one, and no others', () => { + // Both lists come off one table, so a redirect file added to it is + // denied AND has a stand-in: neither can gain an entry the other misses, + // which is how a new one would end up mounted from /dev/null. + expect( + gitDirDenyPaths('/repo/.git', false).filter( + denyPath => gitRedirectPlaceholder(denyPath) !== undefined, + ), + ).toEqual(['/repo/.git/commondir', '/repo/.git/config.worktree']) + }) + + it('follows a pointer to the git directory it names', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it("follows a linked worktree's commondir as well", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('leaves a pointer that names an ordinary directory alone', () => { + // `gitdir: ..` from app/tools would otherwise deny app/config and + // app/hooks, which are an ordinary tree and not a git directory. + mkdirSync(join(dir, 'app', 'config'), { recursive: true }) + const pointer = makePointer(join('app', 'tools'), '..') + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }) + + it('blocks the git directory a dangling pointer names from being filled in', () => { + const pointer = makePointer('checkout', '../not-created-yet') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'not-created-yet'), false), + ]) + }) + + it('follows a pointer padded with the newline bytes git strips', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${'\n'.repeat(9000)}`, + ) + + // git strips them from the end of the whole file, so this is a pointer + // it follows; reading less of the file than git does would leave the + // target's hooks and config out of the deny list. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('counts trailing spaces as part of the path, as git does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const padded = `${gitDir}${' '.repeat(200)}` + const pointer = writePointer('checkout', `gitdir: ${padded}\n`) + + // Nothing is trimmed but the newline, so the pointer names a directory + // that does not exist — denied against being created, not confused for + // the real git directory next to it. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(padded, false), + ]) + }) + + it('follows a pointer to a path no file can occupy nowhere', () => { + // Past the filesystem's name limit: git's own stat of it fails, and a + // sandboxed command cannot create a git directory there either, so + // there is nothing below the pointer to deny. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}${' '.repeat(9000)}\n`, + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }) + + it('follows a pointer of the largest size git accepts, and no larger', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const maxSize = 1024 * 1024 + const head = `gitdir: ${gitDir}` + + const atBound = writePointer( + 'checkout', + head + '\n'.repeat(maxSize - head.length), + ) + expect(statSync(atBound).size).toBe(maxSize) + expect(gitFileDenyPaths(atBound, false)).toEqual([ + atBound, + ...gitDirDenyPaths(gitDir, false), + ]) + + // One byte more is a .git file git refuses outright, so it leads nowhere. + const pastBound = writePointer( + 'past-bound', + head + '\n'.repeat(maxSize + 1 - head.length), + ) + expect(statSync(pastBound).size).toBe(maxSize + 1) + expect(gitFileDenyPaths(pastBound, false)).toEqual([pastBound]) + }) + + it.if(!isWindows)('takes a path that spans lines whole, as git does', () => { + // Only the newline bytes at the END of the file are stripped, so one in + // the middle is part of the directory name. + const gitDir = makeGitDir(join(dir, 'two\nlines')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\n`) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('stops the path at the first NUL byte, as a C string does', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + `gitdir: ${gitDir}\0/../elsewhere\n`, + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('strips a CRLF line ending', () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer('checkout', `gitdir: ${gitDir}\r\n`) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }) + + it('refuses to sandbox at all on a path that is not valid UTF-8', () => { + // Decoded, those bytes name a different directory than git opens, so + // there is no deny list to be had — and sandboxing without one would + // leave the hooks git runs writable. + const gitDir = makeGitDir(join(dir, 'gitdir')) + const pointer = writePointer( + 'checkout', + Buffer.concat([ + Buffer.from(`gitdir: ${gitDir}`), + Buffer.from([0xff]), + Buffer.from('\n'), + ]), + ) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) + + it("follows a commondir padded past the pointer's own bound", () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + `../..${'\n'.repeat(9000)}`, + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(main, false), + ]) + }) + + it('does not trim a commondir, which git reads byte for byte', () => { + const main = makeGitDir(join(dir, 'main.git')) + const worktreeGitDir = makeGitDir(join(dir, 'main.git', 'worktrees', 'wt')) + // A leading space makes this a relative path beginning with a space, + // which is where git looks too — not the main git directory. + writeFileSync(join(worktreeGitDir, 'commondir'), ` ${main}\n`) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + const denyPaths = gitFileDenyPaths(pointer, false) + expect(denyPaths).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(join(worktreeGitDir, ` ${main}`), false), + ]) + expect(denyPaths).not.toContain(join(main, 'hooks')) + }) + + it.if(!isWindows)( + 'denies where a .. after a symlink lands, and the lexical path too', + () => { + // checkout/hop is real/side, so the kernel reads hop/../evil as + // real/evil while folding it on paper gives checkout/evil. git opens + // the first; denying only the second leaves its hooks writable. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + }, + ) + + it.if(!isWindows)('denies both when both are git directories', () => { + const lexical = makeGitDir(join(dir, 'checkout', 'evil')) + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: hop/../evil\n') + symlinkSync(join(dir, 'real', 'side'), join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(lexical, false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)('walks a chain of symlinks as the kernel does', () => { + // first is second, second is an absolute path to real/side/deeper. + const physical = makeGitDir(join(dir, 'real', 'evil')) + mkdirSync(join(dir, 'real', 'side', 'deeper'), { recursive: true }) + const pointer = writePointer('checkout', 'gitdir: first/../../evil\n') + symlinkSync('second', join(dir, 'checkout', 'first')) + symlinkSync( + join(dir, 'real', 'side', 'deeper'), + join(dir, 'checkout', 'second'), + ) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'evil'), false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)( + 'takes the rest of a path as written past what is not there', + () => { + // The kernel cannot traverse a missing directory, so nothing after it + // redirects the path: gone/x is denied against being created, and so + // is the lexical checkout/x. + const pointer = writePointer('checkout', 'gitdir: hop/../x\n') + symlinkSync('gone/deeper', join(dir, 'checkout', 'hop')) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'x'), false), + ...gitDirDenyPaths(join(dir, 'checkout', 'gone', 'x'), false), + ]) + }, + ) + + it.if(!isWindows)('denies what it can reach when symlinks loop', () => { + const pointer = writePointer('checkout', 'gitdir: loopA/../evil\n') + symlinkSync(join(dir, 'checkout', 'loopB'), join(dir, 'checkout', 'loopA')) + symlinkSync(join(dir, 'checkout', 'loopA'), join(dir, 'checkout', 'loopB')) + + // Past the hop limit the walk stops on the loop itself, which is where + // the deny goes: the whole directory that still reads. + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'checkout', 'evil'), false), + join(dir, 'checkout'), + ]) + }) + + it.if(!isWindows)('resolves a commondir the same way', () => { + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + const physical = makeGitDir(join(dir, 'real', 'common')) + mkdirSync(join(dir, 'real', 'side'), { recursive: true }) + symlinkSync(join(dir, 'real', 'side'), join(worktreeGitDir, 'hop')) + writeFileSync(join(worktreeGitDir, 'commondir'), 'hop/../common\n') + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(worktreeGitDir, false), + ...gitDirDenyPaths(join(worktreeGitDir, 'common'), false), + ...gitDirDenyPaths(physical, false), + ]) + }) + + it.if(!isWindows)( + 'resolves a .. against the directory the pointer file is really in', + () => { + // The checkout is reached through a symlink, so `..` pops the + // directory the kernel is in and not the one the path was spelled + // from — nested/target, not dir/target. + const physical = makeGitDir(join(dir, 'nested', 'target')) + mkdirSync(join(dir, 'nested', 'checkout'), { recursive: true }) + symlinkSync(join(dir, 'nested', 'checkout'), join(dir, 'link')) + const pointer = join(dir, 'link', '.git') + writeFileSync(pointer, 'gitdir: ../target\n') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(join(dir, 'target'), false), + ...gitDirDenyPaths(physical, false), + ]) + }, + ) + + it('refuses to sandbox at all on a commondir past the size it reads', () => { + // git reads commondir whole and with no bound of its own, so one this + // large still names the git directory whose hooks a commit here runs. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + + expect(() => gitFileDenyPaths(pointer, false)).toThrow(GitMetadataError) + }) + + it.if(isLinux)( + 'hands the monitor plain deny paths for a repository it cannot read', + () => { + // The violation monitor starts once for the session, so one + // repository whose metadata cannot be resolved must not take the whole + // start-up with it. Every wrap in it is still refused. + const worktreeGitDir = makeGitDir(join(dir, 'wt.git')) + writeFileSync( + join(worktreeGitDir, 'commondir'), + '../main.git'.padEnd(1024 * 1024 + 1, '\n'), + ) + const pointer = makePointer('wt-checkout', worktreeGitDir) + const checkout = join(dir, 'wt-checkout') + const originalCwd = process.cwd() + process.chdir(checkout) + try { + expect(() => linuxGetCwdMandatoryDenyPaths(false)).toThrow( + GitMetadataError, + ) + + const monitored = linuxGetMonitorCwdDenyPaths(false) + + expect(monitored).toContain(pointer) + expect(monitored).toContain(join(checkout, '.bashrc')) + } finally { + process.chdir(originalCwd) + } + }, + ) + + it.if(!isWindows)( + 'does not block on a FIFO left where a git directory keeps its commondir', + () => { + const gitDir = makeGitDir(join(dir, 'gitdir')) + expect(spawnSync('mkfifo', [join(gitDir, 'commondir')]).status).toBe(0) + const pointer = makePointer('checkout', '../gitdir') + + expect(gitFileDenyPaths(pointer, false)).toEqual([ + pointer, + ...gitDirDenyPaths(gitDir, false), + ]) + }, + ) + + it.if(!isWindows)( + 'does not block on a FIFO left where a pointer file goes', + () => { + mkdirSync(join(dir, 'checkout'), { recursive: true }) + const pointer = join(dir, 'checkout', '.git') + expect(spawnSync('mkfifo', [pointer]).status).toBe(0) + + expect(gitFileDenyPaths(pointer, false)).toEqual([pointer]) + }, + ) + + it('recognises a submodule git directory without a HEAD', () => { + const gitDir = join(dir, 'modules', 'lib') + mkdirSync(join(gitDir, 'hooks'), { recursive: true }) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: [gitDir], + unreadableDirs: [], + }) + }) + + it('walks a submodule name that spans several segments, and nested ones', () => { + const outer = makeGitDir(join(dir, 'modules', 'vendor', 'lib')) + const inner = makeGitDir(join(outer, 'modules', 'dep')) + + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs.sort()).toEqual([outer, inner].sort()) + }) + + it.if(!isWindows)('follows a symlinked entry under modules', () => { + const gitDir = makeGitDir(join(dir, 'elsewhere')) + mkdirSync(join(dir, 'modules'), { recursive: true }) + symlinkSync(gitDir, join(dir, 'modules', 'lib')) + + expect(submoduleGitDirs(join(dir, 'modules')).gitDirs).toEqual([ + join(dir, 'modules', 'lib'), + ]) + }) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'denies a directory under modules it could not list', + () => { + const locked = join(dir, 'modules', 'locked') + makeGitDir(join(locked, 'deep')) + chmodSync(locked, 0o000) + try { + const scan = submoduleGitDirs(join(dir, 'modules')) + expect(scan.gitDirs).toEqual([]) + expect(scan.unreadableDirs).toEqual([locked]) + } finally { + chmodSync(locked, 0o755) + } + }, + ) + + /** The directory the walk stops at: MAX_SUBMODULE_WALK_DEPTH levels down. */ + function boundDir(): string { + return join( + dir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + } + + it.each([ + // A git directory below the bound is never seen, so what the walk would + // have descended into is denied whole in its place. + ['a plain directory', false, (bound: string) => bound], + // When the bound directory is a git directory itself it is BOTH: its own + // hooks and config are denied by path, and only the `modules` beneath it + // is denied whole — denying the git directory would take its objects, + // refs and index with it and stop git working in that submodule. + ['a git directory', true, (bound: string) => join(bound, 'modules')], + ])( + 'stops the modules walk at its depth bound, with %s there', + (_label, boundIsGitDir, denied) => { + const bound = boundDir() + if (boundIsGitDir) makeGitDir(bound) + makeGitDir(join(bound, 'deep')) + + expect(submoduleGitDirs(join(dir, 'modules'))).toEqual({ + gitDirs: boundIsGitDir ? [bound] : [], + unreadableDirs: [denied(bound)], + }) + }, + ) + + it.if(isLinux)( + 'keeps a bound git directory writable except for its hooks, config and modules', + async () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const bound = join( + gitDir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + makeGitDir(bound) + mkdirSync(join(bound, 'objects'), { recursive: true }) + + const originalCwd = process.cwd() + process.chdir(checkout) + try { + const wrap = (command: string): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + + // The covering deny is the `modules` beneath it, not the git directory + // itself: denying that whole would take its objects, refs and index. + // An ancestor pin spells that same self-bind, so where it sits is + // what tells the two apart: pins are spliced in before the write + // root's own bind, which makes the tree writable again, and a deny + // bind is emitted after it. + const command = await wrap('true') + const writeRootBind = indexOfMount( + command, + '--bind', + checkout, + checkout, + ) + expect(writeRootBind).toBeGreaterThan(-1) + expect( + lastIndexOfMount(command, '--ro-bind', bound, bound), + ).toBeLessThan(writeRootBind) + expect(command).toContain(join(bound, 'modules')) + expect(command).toContain(`--ro-bind ${join(bound, 'hooks')} `) + + // Where bwrap can run, prove it: what git needs writable in that + // submodule still is, and what makes a write into code is not. + if (bwrapCanNamespace()) { + const result = spawnSync( + await wrap( + `sh -c 'echo o > ${join(bound, 'objects', 'x')} && ` + + `! echo h > ${join(bound, 'hooks', 'x')} && ` + + `! echo c > ${join(bound, 'config')} && ` + + `! mkdir -p ${join(bound, 'modules', 'sub')} && ` + + `echo SRT_BOUND_OK'`, + ), + { shell: true, encoding: 'utf8', timeout: 30000, cwd: checkout }, + ) + expect(result.stdout).toContain('SRT_BOUND_OK') + } + } finally { + cleanupBwrapMountPoints({ force: true }) + process.chdir(originalCwd) + } + }, + 60000, + ) + + it('denies the bound git directory by pattern on macOS, without contradiction', () => { + const checkout = join(dir, 'repo') + const gitDir = makeGitDir(join(checkout, '.git')) + const bound = join( + gitDir, + 'modules', + ...Array.from({ length: MAX_SUBMODULE_WALK_DEPTH }, () => 'x'), + ) + makeGitDir(bound) + + const originalCwd = process.cwd() + process.chdir(checkout) + try { + // Profile generation is pure string building, so this runs anywhere. + const profile = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [checkout], denyWithinAllow: [] }, + }) + expect(profile).toContain(`(subpath "${join(bound, 'modules')}")`) + expect(profile).toContain(`(subpath "${join(bound, 'hooks')}")`) + // No deny covering the git directory whole, which would take its + // objects, refs and index with it. + expect(profile).not.toContain(`(subpath "${bound}")`) + } finally { + process.chdir(originalCwd) + } + }) +}) +/** + * Denying a path that is not there means mounting something at it, and two of + * these the host's git reads back: it refuses to run at all against a + * `commondir` it cannot read, and neither a bound /dev/null nor an empty file + * is one. So these denies write their own mount point, holding what git + * concludes with no file there, and bind a read-only copy of the same bytes + * over it: the host's git keeps working for as long as the command runs, and + * the sandbox's does too. + */ +describe.if(isLinux)('Placeholders for the files git reads', () => { + /** Echoed by every command that runs for real, so nothing concludes + * anything from a sandbox that never started. */ + const BOOTED = 'BOOTED' + const LIVE = bwrapCanNamespace() && Bun.which('git') !== null + /** Enough to commit without a hook, an identity or a signature. */ + const IDENT = [ + '-c', + 'user.name=t', + '-c', + 'user.email=t@t', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + ] + let dir: string + const savedCwd = process.cwd() + const savedTmpdir = process.env.TMPDIR + + beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-redirect-'))) + }) + + afterEach(() => { + process.chdir(savedCwd) + // Forced cleanup also empties the placeholder store, so no case here + // leaves one of its directories behind. + cleanupBwrapMountPoints({ force: true }) + if (savedTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = savedTmpdir + rmSync(dir, { recursive: true, force: true }) + }) + + /** A checkout whose `.git` git would accept, wrapped with it as the cwd. */ + function makeCheckout(name: string): string { + const checkout = join(dir, name) + mkdirSync(join(checkout, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(checkout, '.git', 'HEAD'), 'ref: refs/heads/main') + writeFileSync(join(checkout, '.git', 'config'), '[core]\n') + return checkout + } + + function wrapIn( + checkout: string, + command = 'true', + opts: { denyWithinAllow?: string[]; allowOnly?: string[] } = {}, + ): Promise { + process.chdir(checkout) + return wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + allowAllUnixSockets: true, + readConfig: undefined, + writeConfig: { + allowOnly: opts.allowOnly ?? [checkout], + denyWithinAllow: opts.denyWithinAllow ?? [], + }, + }) + } + + /** + * What bwrap is given to mount at `dest`, of the words `--flag src dest`. + * Throws where nothing is mounted there, so an assertion about the source + * cannot pass, or read as undefined, on a wrap that emitted no such mount. + */ + function mountSource(command: string, dest: string): string { + const mount = lastMountAt(command, dest) + if (mount === undefined) { + throw new Error(`nothing is mounted at ${dest}`) + } + const [, source, mounted] = mount.split(' ') + if (source === undefined || mounted !== dest) { + throw new Error(`the mount at ${dest} has no source: ${mount}`) + } + return source + } + + function git( + cwd: string, + args: string[], + ): { status: number | null; stdout: string } { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, LC_ALL: 'C' }, + }) + return { status: result.status, stdout: `${result.stdout}${result.stderr}` } + } + + /** A repository with one commit, made by git itself. */ + function gitRepo(name: string): string { + const repo = join(dir, name) + mkdirSync(repo, { recursive: true }) + expect( + git(repo, ['-c', 'init.defaultBranch=main', 'init', '-q', '.']), + ).toMatchObject({ status: 0 }) + writeFileSync(join(repo, 'index.js'), 'console.log(1)\n') + expect(git(repo, ['add', 'index.js'])).toMatchObject({ status: 0 }) + expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'one'])).toMatchObject({ + status: 0, + }) + return repo + } + + async function waitFor(ready: () => boolean, what: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (ready()) return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`timed out waiting for ${what}`) + } + + it('binds a commondir that is not there from a placeholder holding "."', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const source = mountSource(await wrapIn(checkout), commondir) + + expect(source).not.toBe('/dev/null') + expect(readFileSync(source, 'utf8')).toBe('.\n') + // The mount point is this wrapper's own rather than the empty file + // bubblewrap would make: it is what the HOST's git reads meanwhile. + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + }) + + it('reads the placeholder off the file, not off the deny entry', async () => { + // A caller's own deny for the same file comes first and wins the dedup, + // so the decision has to be made from the path the entry resolves to: + // made from the entry's spelling, this one missed and bound /dev/null, + // and the repository lost every git command for the length of the wrap. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + const command = await wrapIn(checkout, 'true', { + denyWithinAllow: ['./.git/commondir/'], + }) + + expect(readFileSync(mountSource(command, commondir), 'utf8')).toBe('.\n') + }) + + it('binds a commondir that is there from itself', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '../..\n') + + expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + }) + + it('takes its own mount point away, and a changed one never', async () => { + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + + await wrapIn(checkout) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + + // A mount point something has written a real redirect into is not this + // wrapper's to remove, the same way an empty one that gained content is + // not: the cleanup compares what is there with what it wrote. + await wrapIn(checkout) + writeFileSync(commondir, '../..\n') + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(commondir, 'utf8')).toBe('../..\n') + }) + + it('repairs a commondir left empty rather than covering it', async () => { + // The shape bubblewrap's own ensure_file() leaves, which the wrap takes + // for a mount point an earlier sandbox left behind and covers with + // /dev/null — the one thing a commondir must never be covered with. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '', { mode: 0o444 }) + + const source = mountSource(await wrapIn(checkout), commondir) + + expect(source).not.toBe('/dev/null') + expect(readFileSync(source, 'utf8')).toBe('.\n') + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('binds a commondir already holding the placeholder from itself', async () => { + // What a killed process leaves: nothing in memory knows the file is a + // mount point, but nothing else writes exactly those bytes there, so + // the next wrap claims it, binds it from itself and removes it. + const checkout = makeCheckout('repo') + const commondir = join(checkout, '.git', 'commondir') + writeFileSync(commondir, '.\n') + + expect(mountSource(await wrapIn(checkout), commondir)).toBe(commondir) + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(commondir)).toBe(false) + }) + + it('leaves alone an empty config.worktree it did not leave there', async () => { + // An empty config.worktree reads as no worktree config at all, so one is + // legitimate and this wrapper never claims it — unless it has the shape + // bubblewrap leaves, which nothing writing a config on purpose does. + const checkout = makeCheckout('repo') + const legitimate = join(checkout, '.git', 'config.worktree') + writeFileSync(legitimate, '', { mode: 0o644 }) + + expect(mountSource(await wrapIn(checkout), legitimate)).toBe(legitimate) + + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(legitimate)).toBe(true) + + // The shape bubblewrap leaves, which nothing writing a config on + // purpose has: read-only, empty, one link. + chmodSync(legitimate, 0o444) + await wrapIn(checkout) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(legitimate)).toBe(false) + }) + + it('leaves every other absent deny on /dev/null', async () => { + const checkout = makeCheckout('repo') + + expect(mountSource(await wrapIn(checkout), join(checkout, '.bashrc'))).toBe( + '/dev/null', + ) + }) + + it('mints one placeholder file for the process, not one per wrap', async () => { + process.env.TMPDIR = dir + const checkout = makeCheckout('repo') + const stores = (): string[] => + readdirSync(dir).filter(entry => + entry.startsWith(GIT_REDIRECT_STORE_PREFIX), + ) + expect(stores()).toHaveLength(0) + + for (let wrap = 0; wrap < 20; wrap++) { + await wrapIn(checkout) + // Not forced: the store is per process, so a batch of wraps ending + // must not empty it — and the mount point goes, so the next wrap + // takes the absent-path branch again. + cleanupBwrapMountPoints() + } + + expect(stores()).toHaveLength(1) + // One file per distinct placeholder, whatever the number of wraps: the + // repository's absent commondir and its absent config.worktree. + expect(readdirSync(join(dir, stores()[0]!))).toHaveLength(2) + }) + + it('refuses the command when no placeholder can be written', async () => { + // Decided once, logged once, and fail-closed: /dev/null is the only + // deny left, and it costs the repository every git command. Refusing + // says so; sandboxing behind it would not. + const checkout = makeCheckout('repo') + // So that no deny needs the shared empty directory, whose own source + // this temp directory would break first. + mkdirSync(join(checkout, '.claude')) + const notADirectory = join(dir, 'not-a-directory') + writeFileSync(notADirectory, '') + process.env.TMPDIR = notADirectory + + const refusal = await wrapIn(checkout).then( + () => undefined, + (err: unknown) => err, + ) + + expect(refusal).toBeInstanceOf(LinuxSandboxProfileError) + expect((refusal as LinuxSandboxProfileError).code).toBe( + 'deny_placeholder_unavailable', + ) + }) + + it.if(LIVE)( + 'keeps the placeholder out of the reach of the sandboxed command', + async () => { + // The store sits under $TMPDIR, which is routinely inside an allowed + // write path; the bind exposes the source file itself, so a command + // able to rewrite it would choose what git reads at the denied path. + process.env.TMPDIR = dir + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + const source = mountSource( + await wrapIn(repo, 'true', { + allowOnly: [repo, dir], + }), + commondir, + ) + cleanupBwrapMountPoints() + + const probe = [ + `echo ${BOOTED}`, + `chmod 666 ${source} 2>&1 || echo CHMOD_REFUSED`, + `echo poison > ${source} 2>&1 || echo WRITE_REFUSED`, + `cat ${source}`, + `git rev-parse --git-common-dir`, + ].join('; ') + const command = await wrapIn(repo, probe, { allowOnly: [repo, dir] }) + // Memoised by content, so the command names the source this wrap binds. + expect(mountSource(command, commondir)).toBe(source) + + const run = spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: repo, + env: { ...process.env, LC_ALL: 'C' }, + }) + const output = `${run.stdout}${run.stderr}` + + expect(output).toContain(BOOTED) + expect(output).toContain('Read-only file system') + expect(output).toContain('CHMOD_REFUSED') + expect(output).toContain('WRITE_REFUSED') + expect(output).not.toContain('poison') + // git still resolves its own git directory as its common directory. + expect(output).toContain(join(repo, '.git')) + }, + ) + + it.if(LIVE)( + "keeps the host's git working while a wrapped command runs", + async () => { + const sub = gitRepo('sub') + const repo = gitRepo('repo') + expect( + git(repo, [ + '-c', + 'protocol.file.allow=always', + ...IDENT, + 'submodule', + 'add', + '-q', + sub, + 'lib', + ]), + ).toMatchObject({ status: 0 }) + expect(git(repo, [...IDENT, 'commit', '-q', '-m', 'lib'])).toMatchObject({ + status: 0, + }) + expect( + git(repo, ['worktree', 'add', '-q', join(dir, 'wt')]), + ).toMatchObject({ status: 0 }) + const commondir = join(repo, '.git', 'commondir') + const submoduleCommondir = join( + repo, + '.git', + 'modules', + 'lib', + 'commondir', + ) + + // Runs until the host releases it, so the host's git is exercised with + // the sandbox up and the mount points in place. + const command = await wrapIn( + repo, + `echo ${BOOTED} > up; i=0; while [ ! -f release ] && [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done; echo ${BOOTED}`, + ) + const child = spawn(command, { + shell: true, + cwd: repo, + stdio: 'ignore', + }) + try { + await waitFor( + () => existsSync(join(repo, 'up')), + 'the sandbox to start', + ) + + // Both mount points are on the host, holding a redirect git accepts. + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(readFileSync(submoduleCommondir, 'utf8')).toBe('.\n') + for (const args of [ + ['status', '--porcelain'], + ['log', '--oneline'], + ['worktree', 'list'], + ['rev-parse', '--git-common-dir'], + ]) { + expect({ args, ...git(repo, args) }).toMatchObject({ status: 0 }) + } + expect( + git(repo, [ + ...IDENT, + 'commit', + '--allow-empty', + '-q', + '-m', + 'during', + ]), + ).toMatchObject({ status: 0 }) + expect(git(join(repo, 'lib'), ['status', '--porcelain'])).toMatchObject( + { + status: 0, + }, + ) + } finally { + writeFileSync(join(repo, 'release'), '') + await new Promise(resolve => child.on('close', resolve)) + } + expect(child.exitCode).toBe(0) + + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(existsSync(submoduleCommondir)).toBe(false) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + }, + ) + + it.if(LIVE)( + 'leaves a killed wrap behind a commondir git reads, and repairs an empty one', + async () => { + const repo = gitRepo('repo') + const commondir = join(repo, '.git', 'commondir') + + // What a killed process leaves now: the host's git works on it, and + // the next wrap takes it away. + writeFileSync(commondir, '.\n') + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + expect(git(repo, ['log', '--oneline'])).toMatchObject({ status: 0 }) + await wrapIn(repo) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + + // What an older release leaves: git refuses every command in the + // repository until the next wrap rewrites it. + writeFileSync(commondir, '', { mode: 0o444 }) + const broken = git(repo, ['status', '--porcelain']) + expect(broken.status).not.toBe(0) + expect(broken.stdout).toContain('commondir') + + await wrapIn(repo) + expect(readFileSync(commondir, 'utf8')).toBe('.\n') + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + cleanupBwrapMountPoints() + expect(existsSync(commondir)).toBe(false) + expect(git(repo, ['status', '--porcelain'])).toMatchObject({ status: 0 }) + }, + ) + + it.if(LIVE)( + 'leaves git working across wraps, and the commondir unwritable', + async () => { + const repo = gitRepo('repo') + + const run = (command: string) => + spawnSync(command, { + shell: true, + encoding: 'utf8', + timeout: 30000, + cwd: repo, + env: { ...process.env, LC_ALL: 'C' }, + }) + // By name: the dotfile denies stub their absent paths, and the mount + // points bwrap leaves for them are not files `git add -A` can stage. + const commit = (file: string) => + `echo ${BOOTED} && git add ${file} && git ${IDENT.join(' ')} ` + + `commit -q -m ${file} && echo COMMIT_OK` + + writeFileSync(join(repo, 'one.js'), 'console.log(1)\n') + const first = run(await wrapIn(repo, commit('one.js'))) + expect(first.stdout).toContain(BOOTED) + expect(first.status).toBe(0) + expect(first.stdout).toContain('COMMIT_OK') + + // No cleanupBwrapMountPoints() in between: the first wrap's mount point + // for the absent commondir is still sitting in the git directory, and + // the second wrap's scan finds it there. + writeFileSync(join(repo, 'two.js'), 'console.log(2)\n') + const second = run(await wrapIn(repo, commit('two.js'))) + expect(second.stdout).toContain(BOOTED) + expect(second.status).toBe(0) + expect(second.stdout).toContain('COMMIT_OK') + + const write = run( + await wrapIn(repo, 'echo ../decoy > .git/commondir || echo DENIED'), + ) + expect(write.stdout).toContain('DENIED') + expect(existsSync(join(repo, '.git', 'commondir'))).toBe(true) + cleanupBwrapMountPoints({ force: true }) + expect(existsSync(join(repo, '.git', 'commondir'))).toBe(false) + }, + ) +}) diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index 26136d325..142f0deac 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'bun:test' -import { writeFileSync, mkdtempSync, rmSync } from 'fs' +import { chmodSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { ripGrep } from '../../src/utils/ripgrep.js' +import { ripGrep, RipgrepError } from '../../src/utils/ripgrep.js' +import { isWindows } from '../helpers/platform.js' describe('ripGrep', () => { it('finds matches with default config', async () => { @@ -43,7 +44,7 @@ describe('ripGrep', () => { try { const script = join(dir, 'echo-argv0.cjs') // ripGrep appends target as the last arg; ignore it and print argv0 - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -60,7 +61,7 @@ describe('ripGrep', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-noargv0-')) try { const script = join(dir, 'echo-argv0.cjs') - writeFileSync(script, 'process.stdout.write(process.argv0)') + writeFileSync(script, "process.stdout.write(process.argv0 + '\\0')") const results = await ripGrep([], dir, new AbortController().signal, { command: process.execPath, @@ -79,4 +80,95 @@ describe('ripGrep', () => { ripGrep(['--invalid-flag-xyz'], '.', new AbortController().signal), ).rejects.toThrow(/ripgrep failed/) }) + + it.if(!isWindows)( + 'carries what a failed run listed and what it said, whatever the uid', + async () => { + // What the caller denies comes off these two, and the run that + // produces them for real needs a directory this process cannot read — + // which as root there is none of. A fake rg fails the same way + // everywhere; the arm below does it with the real one where it can. + const error = await ripGrep([], '.', new AbortController().signal, { + command: '/bin/sh', + args: [ + '-c', + 'printf "/found/a\\0"; ' + + 'printf "rg: /found/locked: Permission denied (os error 13)\\n" >&2; ' + + 'exit 2', + ], + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).partialMatches).toEqual(['/found/a']) + expect((error as RipgrepError).stderr).toContain('/found/locked') + expect((error as RipgrepError).timedOut).toBe(false) + }, + ) + + it.if(!isWindows)( + 'drops a path a killed run was cut off in the middle of', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-timeout-')) + try { + const error = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + // A complete record, then a truncated one, then a run that outlives + // the timeout. exec so the kill reaches whatever holds stdout. + args: ['-c', 'printf "/found/a\\0/trunc"; exec sleep 30'], + timeoutMs: 200, + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).timedOut).toBe(true) + expect((error as RipgrepError).partialMatches).toEqual(['/found/a']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + ) + + it.if(!isWindows)( + 'keeps a path containing a newline in one piece', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-newline-')) + try { + const results = await ripGrep([], dir, new AbortController().signal, { + command: '/bin/sh', + args: ['-c', 'printf "/a/nl\\ndir/.git/HEAD\\0"'], + }) + + expect(results).toEqual(['/a/nl\ndir/.git/HEAD']) + } finally { + rmSync(dir, { recursive: true }) + } + }, + ) + + it.if(!isWindows && process.getuid?.() !== 0)( + 'hands back what rg listed before an unreadable directory failed the run', + async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-test-')) + mkdirSync(join(dir, 'locked')) + writeFileSync(join(dir, 'a.txt'), 'hello') + chmodSync(join(dir, 'locked'), 0o000) + try { + const error = await ripGrep( + ['--files'], + dir, + new AbortController().signal, + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).partialMatches).toEqual([ + join(dir, 'a.txt'), + ]) + // The caller denies what rg could not read, so the paths must survive. + expect((error as RipgrepError).stderr).toContain(join(dir, 'locked')) + expect((error as RipgrepError).timedOut).toBe(false) + } finally { + chmodSync(join(dir, 'locked'), 0o755) + rmSync(dir, { recursive: true }) + } + }, + ) })