diff --git a/README.md b/README.md index ad28cb770..388ad5ac9 100644 --- a/README.md +++ b/README.md @@ -787,7 +787,7 @@ With `allowWrite: ["/"]` the pins reach every ancestor, including any other allo A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance. You can configure this with `mandatoryDenySearchDepth`: +**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance: a dangerous file is found down to `a/b/.bashrc`, and a dangerous directory, or the hooks and config of a repository, one level higher up (`a/.vscode`, `a/.claude/commands`, `a/.git/hooks`). Ignore files (`.gitignore`, `.ignore`) do not hide anything from it, and a directory of the user's own that it cannot read is denied whole. A dangerous directory other than a repository's hooks is only seen if it holds a file directly: below the working directory, one that is empty or does not exist yet can be filled. A repository that has no `hooks` directory has an empty file in its place while a command runs, which stops `git init` from being run again there and a hook from being installed. You can configure this with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 6a6a2ab12..fd89cb357 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -7,7 +7,7 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { endianness, tmpdir } from 'node:os' import path, { join } from 'node:path' -import { ripGrep, type RipgrepConfig } from '../utils/ripgrep.js' +import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' import { generateProxyEnvVars, @@ -317,6 +317,69 @@ export function linuxGetCwdMandatoryDenyPaths( return denyPaths } +/** + * The directories below `cwd`, down to `maxDepth`, that ripgrep said it could + * not read, that this process cannot read either, and whose mode is this + * user's to change. What is in one is not known, so the caller denies it whole. + * Exported for testing. + */ +export function unreadableDirectories( + stderr: string, + cwd: string, + maxDepth: number, +): string[] { + const found: string[] = [] + const tried = new Set() + for (const line of stderr.split('\n')) { + // How a message is worded depends on ripgrep's version and on how many + // threads it has, so what stands before each ": " is tried. + const said = line.startsWith('rg: ') ? line.slice(4) : line + for ( + let cut = said.indexOf(': '); + cut !== -1; + cut = said.indexOf(': ', cut + 1) + ) { + for ( + let dir = path.resolve(cwd, said.slice(0, cut)); + dir.startsWith(cwd + path.sep) && !tried.has(dir); + dir = path.dirname(dir) + ) { + tried.add(dir) + try { + // THREAT: the text is only a hint. A name can hold a newline and a + // whole message of its own, and a bind of `link/x` would bring what + // the link leads to INTO the sandbox. So: a directory, with no link + // on the way to it, that cannot be read from here. And this user's: + // nobody else's mode can be given back from inside, and there can + // be thousands of those (`/home`), each of them a mount. + const stat = fs.lstatSync(dir) + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + fs.realpathSync(dir) !== dir + ) { + break + } + } catch (error) { + // What cannot be looked at, because what holds it cannot be + // searched, is stood in for by the directory above it. + if ((error as NodeJS.ErrnoException).code === 'EACCES') continue + break + } + try { + fs.accessSync(dir, fs.constants.R_OK | fs.constants.X_OK) + } catch { + if (path.relative(cwd, dir).split(path.sep).length <= maxDepth) { + found.push(dir) + } + } + break + } + } + } + return found +} + /** * Get mandatory deny paths using ripgrep (Linux only). * Uses a SINGLE ripgrep call with multiple glob patterns for efficiency. @@ -344,8 +407,9 @@ async function linuxGetMandatoryDenyPaths( for (const dirName of dangerousDirectories) { iglobArgs.push('--iglob', `**/${dirName}/**`) } - // Git hooks always blocked in nested repos - iglobArgs.push('--iglob', '**/.git/hooks/**') + // Git hooks always blocked in nested repos. A repository is known by its + // HEAD, so that its hooks are denied before there are any. + iglobArgs.push('--iglob', '**/.git/hooks/**', '--iglob', '**/.git/HEAD') // Git config conditionally blocked in nested repos if (!allowGitConfig) { @@ -355,14 +419,26 @@ async function linuxGetMandatoryDenyPaths( // Single ripgrep call to find all dangerous paths in subdirectories // Limit depth for performance - deeply nested dangerous files are rare // and the security benefit doesn't justify the traversal cost + // + // ripgrep lists files, and its depth is the file's. `sub/.vscode/x` and + // `sub/.git/config` lie at `maxDepth`; `sub/.git/hooks/pre-commit`, of the + // same directory, one level further down. let matches: string[] = [] try { matches = await ripGrep( [ '--files', '--hidden', + // INVARIANT: no file decides what is listed. An ignore file that names + // a directory hides all beneath it, and a configuration file can add + // any flag; both are files in or above the tree. + '--no-ignore', + '--no-config', + // Into a pipe ripgrep writes by the block, and killed it drops the + // block: it would have listed nothing. + '--line-buffered', '--max-depth', - String(maxDepth), + String(maxDepth + 1), ...iglobArgs, '-g', '!**/node_modules/**', @@ -375,42 +451,54 @@ async function linuxGetMandatoryDenyPaths( // Stopped, it found nothing: the caller must not be handed a command // without the denies it would have found. signal.throwIfAborted() - logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`) + // INVARIANT: what ripgrep listed counts, however it ended. It exits 2 if + // there was a directory it could not read, having listed the rest, and is + // killed after ten seconds. What it had not come to by then is not denied. + if (error instanceof RipgrepError) { + matches = error.listed + // A read-only bind also keeps the command from giving the mode back. + denyPaths.push(...unreadableDirectories(error.stderr, cwd, maxDepth)) + } + logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, { + level: 'warn', + }) } - // Process matches + // The names a match can lie under, by path component. + const directoryNames = [ + ...dangerousDirectories, + '.git/hooks', + '.git/config', + '.git/HEAD', + ].map(name => normalizeCaseForComparison(name).split('/')) for (const match of matches) { - const absolutePath = path.resolve(cwd, match) - - // File inside a dangerous directory -> add the directory path - let foundDir = false - for (const dirName of [...dangerousDirectories, '.git']) { - const normalizedDirName = normalizeCaseForComparison(dirName) - const segments = absolutePath.split(path.sep) - const dirIndex = segments.findIndex( - s => normalizeCaseForComparison(s) === normalizedDirName, - ) - if (dirIndex !== -1) { - // For .git, we want hooks/ or config, not the whole .git dir - if (dirName === '.git') { - const gitDir = segments.slice(0, dirIndex + 1).join(path.sep) - if (match.includes('.git/hooks')) { - denyPaths.push(path.join(gitDir, 'hooks')) - } else if (match.includes('.git/config')) { - denyPaths.push(path.join(gitDir, 'config')) - } - } else { - denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep)) + const segments = path + .relative(cwd, path.resolve(cwd, match)) + .split(path.sep) + const lower = segments.map(normalizeCaseForComparison) + // Where the dangerous name begins and how long it is: the first of + // `directoryNames` on the way down, else the file itself. + let at = segments.length - 1 + let length = 1 + // How deep the directory holding the name, `at`, may lie. One level less + // for `directoryNames`, all alike: each costs mounts that keep what holds + // it from being renamed or removed, and bwrap's start grows with them. + let deepest = maxDepth - 1 + search: for (let i = 0; i < lower.length; i++) { + for (const name of directoryNames) { + if (name.every((component, k) => lower[i + k] === component)) { + at = i + length = name.length + deepest = maxDepth - 2 + break search } - foundDir = true - break } } - - // Dangerous file match - if (!foundDir) { - denyPaths.push(absolutePath) - } + if (at > deepest) continue + const found = segments.slice(0, at + length) + if (lower[at] === '.git' && lower[at + 1] === 'head') + found[at + 1] = 'hooks' + denyPaths.push(path.join(cwd, ...found)) } return [...new Set(denyPaths)] diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 8bd4cef6f..3cad0679c 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1153,7 +1153,9 @@ export const SandboxRuntimeConfigSchema = z .max(10) .optional() .describe( - 'Maximum directory depth to search for dangerous files on Linux (default: 3). ' + + 'How deep below the working directory dangerous names are looked for on Linux (default: 3): ' + + 'a dangerous file down to this depth, and a dangerous directory, or the hooks and ' + + 'config of a repository, one level higher up. ' + 'Higher values provide more protection but slower performance.', ), allowPty: z diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 153229327..739037ca7 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -15,7 +15,7 @@ export interface RipgrepConfig { * @param abortSignal AbortSignal to cancel the operation * @param config Ripgrep configuration (command and optional args) * @returns Array of matching lines (one per line of output) - * @throws Error if ripgrep exits with non-zero status (except exit code 1 which means no matches) + * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ export async function ripGrep( args: string[], @@ -48,5 +48,24 @@ export async function ripGrep( // Exit code 1 means "no matches found" - this is normal return [] } - throw new Error(`ripgrep failed with exit code ${code}: ${stderr}`) + // Whole lines only: killed, it can leave half of one. + throw new RipgrepError( + `ripgrep failed with exit code ${code}: ${stderr}`, + stdout.split('\n').slice(0, -1).filter(Boolean), + stderr, + ) +} + +/** ripgrep ended otherwise than by finding something or nothing. */ +export class RipgrepError extends Error { + constructor( + message: string, + /** What it had listed by then. */ + readonly listed: string[], + /** What it said went wrong. The paths in it are the tree's own text. */ + readonly stderr: string, + ) { + super(message) + this.name = 'RipgrepError' + } } diff --git a/test/sandbox/mandatory-deny-paths.test.ts b/test/sandbox/mandatory-deny-paths.test.ts index 5b9ea24fb..4431c274a 100644 --- a/test/sandbox/mandatory-deny-paths.test.ts +++ b/test/sandbox/mandatory-deny-paths.test.ts @@ -6,10 +6,13 @@ import { afterAll, beforeEach, afterEach, + spyOn, } from 'bun:test' import { spawn, spawnSync } from 'node:child_process' import { + chmodSync, mkdirSync, + rmdirSync, rmSync, writeFileSync, readFileSync, @@ -27,6 +30,7 @@ import { import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, + unreadableDirectories, } from '../../src/sandbox/linux-sandbox-utils.js' import { DANGEROUS_FILES, @@ -120,12 +124,18 @@ describe.if(isSupportedPlatform)( cleanupBwrapMountPoints({ force: true }) }) - async function runSandboxedWrite( + function runSandboxedWrite( filePath: string, content: string, + ): Promise<{ success: boolean; stderr: string }> { + return runSandboxed(`echo '${content}' > '${filePath}'`) + } + + async function runSandboxed( + command: string, + ripgrepArgs: string[] = [], ): 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 = { @@ -147,6 +157,7 @@ describe.if(isSupportedPlatform)( needsNetworkRestriction: false, readConfig: undefined, writeConfig, + ripgrepConfig: { command: 'rg', args: ripgrepArgs }, }) } @@ -206,6 +217,331 @@ describe.if(isSupportedPlatform)( } }) + describe('The same names below the working directory', () => { + /** What holds files the host runs or trusts, and a file in each. */ + const heldIn = new Map([ + ['.git/hooks', 'pre-commit'], + ...getDangerousDirectories().map( + dir => [dir, DIRECTORY_PROBE_FILE] as const, + ), + ]) + const existing = [ + ...DANGEROUS_FILES, + '.git/config', + ...[...heldIn].map(([dir, file]) => `${dir}/${file}`), + ] + const populate = (container: string): void => { + for (const dir of heldIn.keys()) { + mkdirSync(join(TEST_DIR, container, dir), { recursive: true }) + } + for (const name of [...existing, 'safe-file.txt']) { + writeFileSync(join(TEST_DIR, container, name), ORIGINAL_CONTENT) + } + } + // How far the default depth reaches on Linux: every name in `sub`, and + // the files alone in `a/b`. + const covered = [ + ['sub', existing], + ['a/b', DANGEROUS_FILES], + ] as const + const beyond = 'a/b/c' + beforeAll(() => ['sub', 'a/b', beyond].forEach(populate)) + + for (const [container, names] of covered) { + for (const name of names) { + it(`blocks writes to ${container}/${name}`, async () => { + const target = `${container}/${name}` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect(result.success).toBe(false) + expect(readFileSync(target, 'utf8')).toBe(ORIGINAL_CONTENT) + }) + } + it(`allows writes to ${container}/safe-file.txt`, async () => { + const target = `${container}/safe-file.txt` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect(result.success).toBe(true) + expect(readFileSync(target, 'utf8').trim()).toBe(MODIFIED_CONTENT) + }) + } + + for (const dir of heldIn.keys()) { + it(`blocks a new file in sub/${dir}/`, async () => { + const target = `sub/${dir}/new-file` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect(result.success).toBe(false) + expect(existsSync(target)).toBe(false) + }) + } + + for (const state of ['empty', 'missing']) { + it(`blocks a new hook in a repository whose hooks directory is ${state}`, async () => { + const repository = `hooks-${state}` + const hooks = `${repository}/.git/hooks` + mkdirSync(join(TEST_DIR, repository, '.git'), { recursive: true }) + writeFileSync( + join(TEST_DIR, repository, '.git', 'HEAD'), + 'ref: refs/heads/main', + ) + if (state === 'empty') mkdirSync(join(TEST_DIR, hooks)) + + const result = await runSandboxed( + `mkdir -p '${hooks}' && echo x > '${hooks}/pre-commit'`, + ) + + expect(result.success).toBe(false) + expect(existsSync(`${hooks}/pre-commit`)).toBe(false) + // The rest of the repository is still the command's to write. + expect( + (await runSandboxedWrite(`${repository}/.git/HEAD`, 'x')).success, + ).toBe(true) + }) + } + + for (const [file, line] of [ + ['.gitignore', 'sub/'], + ['.ignore', '*'], + ] as const) { + it(`blocks them all the same when ${file} holds ${line}`, async () => { + writeFileSync(join(TEST_DIR, file), `${line}\n`) + try { + for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { + const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + + expect([name, result.success]).toEqual([name, false]) + expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) + } + } finally { + rmSync(join(TEST_DIR, file)) + } + }) + } + + it.if(isLinux)( + "blocks them all the same whatever ripgrep's configuration file says", + async () => { + const configuration = join(TEST_DIR, 'ripgrep-configuration') + writeFileSync(configuration, '--glob=!sub\n') + process.env.RIPGREP_CONFIG_PATH = configuration + try { + const result = await runSandboxedWrite( + 'sub/.bashrc', + MODIFIED_CONTENT, + ) + + expect(result.success).toBe(false) + expect(readFileSync('sub/.bashrc', 'utf8')).toBe(ORIGINAL_CONTENT) + } finally { + delete process.env.RIPGREP_CONFIG_PATH + rmSync(configuration) + } + }, + ) + + it('blocks them all the same beside a directory that cannot be read', async () => { + const unreadable = join(TEST_DIR, 'unreadable') + mkdirSync(unreadable, { mode: 0o000 }) + try { + for (const name of ['sub/.bashrc', 'sub/.git/hooks/pre-commit']) { + const result = await runSandboxedWrite(name, MODIFIED_CONTENT) + + expect([name, result.success]).toEqual([name, false]) + expect(readFileSync(name, 'utf8')).toBe(ORIGINAL_CONTENT) + } + } finally { + rmdirSync(unreadable) + } + }) + + for (const [directory, mode] of [ + ['locked', 0o000], + ['locked/.git', 0o000], + // As deep as one of the names can itself lie. + ['locked/.claude/commands', 0o000], + // Can be listed, and nothing in it looked at. + ['locked', 0o444], + // The other way about. + ['locked', 0o111], + ] as const) { + // With one thread, as on one CPU, ripgrep words what it says otherwise. + for (const threads of [[], ['-j1']]) { + it(`blocks them all the same in locked/ when ${directory} has mode ${mode.toString(8)} (rg ${threads.join()})`, async () => { + const names = [ + '.bashrc', + '.git/config', + '.git/hooks/pre-commit', + `.claude/commands/${DIRECTORY_PROBE_FILE}`, + ] + populate('locked') + try { + for (const name of names) { + // Each time: a command that is not held gives the mode back. + chmodSync(join(TEST_DIR, directory), mode) + const result = await runSandboxed( + `chmod 755 locked '${directory}'; echo x > 'locked/${name}'`, + threads, + ) + + expect([name, result.success]).toEqual([name, false]) + } + } finally { + chmodSync(join(TEST_DIR, directory), 0o755) + } + for (const name of names) { + expect(readFileSync(`locked/${name}`, 'utf8')).toBe( + ORIGINAL_CONTENT, + ) + } + }) + } + } + + // root reads a directory of any mode. + it.if(isLinux && process.getuid?.() !== 0)( + 'takes what ripgrep says it could not read for a hint only', + async () => { + const project = join(TEST_DIR, 'hints') + const outside = join(TEST_DIR, 'hints-outside') + for (const directory of [ + join(project, 'is-readable'), + join(project, 'really-locked'), + join(project, 'locked: too'), + join(project, 'one-thread'), + join(project, 'no-prefix'), + join(project, 'listed-only/child'), + join(project, 'entered-only'), + join(project, 'a/b/c/too-deep'), + join(outside, 'behind-a-link'), + ]) { + mkdirSync(directory, { recursive: true }) + } + symlinkSync(outside, join(project, 'link')) + const locked = [ + join(project, 'really-locked'), + join(project, 'locked: too'), + join(project, 'one-thread'), + join(project, 'no-prefix'), + join(project, 'a/b/c/too-deep'), + join(outside, 'behind-a-link'), + ] + // What a name in the tree could make ripgrep say. + const said = [ + 'is-readable', + 'really-locked', + 'a/b/c/too-deep', + 'link/behind-a-link', + '../hints-outside/behind-a-link', + 'not-there/at-all', + 'entered-only/not-there', + 'locked: too', + 'listed-only/child', + ].map( + name => `rg: ${project}/${name}: Permission denied (os error 13)`, + ) + said.push( + `rg: ${project}/one-thread: IO error for operation on ${project}/one-thread: Permission denied (os error 13)`, + `${project}/no-prefix: Permission denied (os error 13)`, + ) + const standIn = join(TEST_DIR, 'says-it-could-not-read') + const asked = join(TEST_DIR, 'was-asked') + writeFileSync( + standIn, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > '${asked}'`, + ...said.map(line => `echo '${line}' >&2`), + 'exit 2', + ].join('\n'), + { mode: 0o755 }, + ) + process.chdir(project) + locked.forEach(directory => chmodSync(directory, 0o000)) + chmodSync(join(project, 'listed-only'), 0o444) + chmodSync(join(project, 'entered-only'), 0o111) + try { + const wrapped = await wrapCommandWithSandboxLinux({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: ['.'], denyWithinAllow: [] }, + ripgrepConfig: { command: standIn }, + }) + + expect(unreadableDirectories(said.join('\n'), project, 3)).toEqual([ + join(project, 'really-locked'), + join(project, 'locked: too'), + join(project, 'listed-only'), + join(project, 'one-thread'), + join(project, 'no-prefix'), + ]) + // Nobody else's mode can be given back from inside. + const uid = process.getuid!() + const getuid = spyOn(process, 'getuid').mockReturnValue(uid + 1) + try { + expect( + unreadableDirectories(said.join('\n'), project, 3), + ).toEqual([]) + } finally { + getuid.mockRestore() + } + expect(wrapped).toContain('really-locked') + expect(readFileSync(asked, 'utf8').split('\n')).toEqual( + expect.arrayContaining([ + '--no-ignore', + '--no-config', + '--line-buffered', + ]), + ) + } finally { + for (const directory of [ + ...locked, + join(project, 'listed-only'), + join(project, 'entered-only'), + ]) { + chmodSync(directory, 0o755) + } + } + }, + ) + + it.if(isLinux)( + 'takes no directory above the working directory for one of the names', + async () => { + const project = join(TEST_DIR, 'above', '.idea', 'project') + mkdirSync(join(project, 'sub'), { recursive: true }) + writeFileSync(join(project, 'sub', '.bashrc'), ORIGINAL_CONTENT) + process.chdir(project) + + expect((await runSandboxedWrite('sub/.bashrc', 'x')).success).toBe( + false, + ) + expect((await runSandboxedWrite('safe-file.txt', 'x')).success).toBe( + true, + ) + }, + ) + + for (const [container, names] of [ + ['a/b', existing.filter(name => name.includes('/'))], + [beyond, existing], + ] as const) { + it.if(isLinux)( + `looks no deeper than the depth says: ${names.length} names in ${container}/`, + async () => { + for (const name of names) { + const target = `${container}/${name}` + const result = await runSandboxedWrite(target, MODIFIED_CONTENT) + + expect([target, result.success]).toEqual([target, true]) + } + }, + 60_000, + ) + } + }) + describe('Safe files should still be writable', () => { it('allows writes to regular files', async () => { const result = await runSandboxedWrite( diff --git a/test/utils/ripgrep.test.ts b/test/utils/ripgrep.test.ts index a7943bbaf..1f351bc84 100644 --- a/test/utils/ripgrep.test.ts +++ b/test/utils/ripgrep.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'bun:test' import { writeFileSync, mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { basename, join } from 'path' -import { ripGrep } from '../../src/utils/ripgrep.js' +import { ripGrep, RipgrepError } from '../../src/utils/ripgrep.js' describe('ripGrep', () => { it('finds matches with default config', async () => { @@ -74,6 +74,30 @@ describe('ripGrep', () => { } }) + it('says what had been listed when it rejects', async () => { + const dir = mkdtempSync(join(tmpdir(), 'rg-listed-')) + try { + const script = join(dir, 'lists-then-fails.cjs') + writeFileSync( + script, + "process.stdout.write('one\\ntwo\\nhalf a li'); process.stderr.write('went wrong'); process.exitCode = 2", + ) + + const error: unknown = await ripGrep( + [], + dir, + new AbortController().signal, + { command: process.execPath, args: [script] }, + ).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RipgrepError) + expect((error as RipgrepError).listed).toEqual(['one', 'two']) + expect((error as RipgrepError).stderr).toBe('went wrong') + } finally { + rmSync(dir, { recursive: true }) + } + }) + it('rejects on exit code > 1', () => { expect( ripGrep(['--invalid-flag-xyz'], '.', new AbortController().signal),