diff --git a/packages/code/README.md b/packages/code/README.md index e3051930..a38523a3 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -826,15 +826,34 @@ shared object and ref storage (`.git/objects`, `.git/refs`, `.git/logs/refs`, `.git` stays read-only: configuration, hooks and `info`, the checkout's own `HEAD`, index and merge or rebase state, and sibling metadata. Automatic `gc` and maintenance are disabled, and `git gc` itself cannot run in a lane (it -needs to write `.git/gc.pid` and `packed-refs`); run storage maintenance from -the checkout. Explicit `git prune --expire now`, `git repack -ad`, or LFS -pruning can still delete objects another lane is writing; the sandbox does -**not** prevent this race. Do not enable lanes for agents that run destructive -maintenance until those commands are blocked or serialized. Deleting a branch -or tag also needs the checkout, because Git locks `packed-refs` for every ref -deletion. Each lane has its own durable quarantine guard. A lane -cannot start while its checkout is quarantined, and a checkout cannot start -while any lane beneath it is. +needs to write `.git/gc.pid` and `packed-refs`). A lane's `PATH` starts with +a read-only Git wrapper that refuses `prune`, `gc`, `repack`, `prune-packed`, +`maintenance`, `multi-pack-index`, `for-each-repo` and `git lfs prune`, +including after Git options such as `-C` or `-c`. `for-each-repo` is refused +entirely because Git dispatches its child commands without re-entering the +wrapper. It rejects `git lfs fetch` or `pull` with +`--prune`/`-p`, and `git fetch` or `pull` with `--auto-maintenance`/`--auto-gc`. +It enforces `maintenance.auto=false`, `gc.auto=0` and `help.autocorrect=0` +after caller-supplied Git options, preventing those overrides from restoring +automatic maintenance or correcting a misspelled command to `prune`. +It also refuses **all configured Git aliases**, +including harmless ones: aliases can hide destructive maintenance through +local config, `-c`, `--config-env`, includes or shell commands. Call the +underlying Git command instead, or run the alias from the checkout. Git's +safe pathspec options (`--literal-pathspecs`, `--glob-pathspecs`, +`--noglob-pathspecs`, `--icase-pathspecs`) remain usable in lanes. Run storage +maintenance from the checkout. + +This is a guardrail against accidental commands, **not** a filesystem +boundary: invoking Git by an absolute path, resetting `PATH`, or using a +script that does either bypasses it. Such commands can still delete a sibling +lane's unpublished objects. Do not enable concurrent lanes for agents or +scripts that deliberately bypass the wrapper. The guard requires `/bin/bash` +and a trusted system Git executable; without either, lane commands are not +admitted. Deleting a branch or tag also needs the checkout, because Git locks +`packed-refs` for every ref deletion. Each lane has its own durable quarantine +guard. A lane cannot start while its checkout is quarantined, and a checkout +cannot start while any lane beneath it is. Lanes require native-srt commands and at least two lease slots, and cannot yet be combined with conversation worktrees. Code API must advertise diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 135b9f8e..410caf25 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -805,6 +805,9 @@ async function run( 'Linked worktree lanes cannot be combined with conversation worktrees', ); } + if (linkedWorktreeLanes && process.platform === 'win32') { + throw new Error('Linked worktree Git guard requires a POSIX host'); + } if ( roots.length > 1 && process.env.LIBRECHAT_CODE_WORKSPACE_QUARANTINE_FILE?.trim() diff --git a/packages/code/src/linked-worktree-git-guard.test.ts b/packages/code/src/linked-worktree-git-guard.test.ts new file mode 100644 index 00000000..96f16b4a --- /dev/null +++ b/packages/code/src/linked-worktree-git-guard.test.ts @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { writeLinkedWorktreeGitGuard } from './linked-worktree-git-guard.js'; + +const execFileAsync = promisify(execFile); + +test('lane Git guard prevents destructive maintenance after global options without breaking ordinary Git', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-git-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const wrapper = join(bin, 'git'); + assert.equal((await stat(wrapper)).mode & 0o222, 0, 'the script is not writable'); + assert.match(await readFile(wrapper, 'utf8'), /^#!\/bin\/bash/); + await execFileAsync('/bin/bash', ['-n', wrapper]); + assert.match(await readFile(wrapper, 'utf8'), /run storage maintenance from the checkout/); + + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + assert.match((await run(['--version'])).stdout, /git version/); + assert.equal((await run(['-C', repo, '-c', 'core.quotepath=false', 'status', '--short'])).stdout, ''); + for (const option of ['--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs']) { + assert.equal((await run([option, '-C', repo, 'status', '--short'])).stdout, '', option); + } + await writeFile(join(repo, 'literal[abc].txt'), 'literal\n'); + await run(['--literal-pathspecs', 'add', '--', 'literal[abc].txt']); + assert.match((await run(['status', '--short'])).stdout, /A literal\[abc\]\.txt/); + + // Git has created this object, but no ref points to it yet. Another lane can be + // in exactly this window between writing objects and updating a ref. + await writeFile(join(repo, 'unpublished.txt'), 'from a second lane\n'); + const object = (await run(['hash-object', '-w', 'unpublished.txt'])).stdout.trim(); + const blocked = [ + ['prune', '--expire', 'now'], + ['-C', repo, 'prune', '--expire=now'], + ['-C' + repo, '-c', 'gc.auto=0', '--git-dir=' + join(repo, '.git'), 'prune', '--expire=now'], + ['--no-pager', '-cgc.auto=0', 'gc', '--force'], + ['repack', '-ad'], + ['prune-packed'], + ['maintenance', 'run', '--task=gc'], + ['multi-pack-index', 'expire'], + ['lfs', 'prune'], + ['--literal-pathspecs', 'prune', '--expire', 'now'], + ]; + for (const args of blocked) { + await assert.rejects(run(args), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /run storage maintenance from the checkout/.test(result.stderr ?? ''); + }, `must reject git ${args.join(' ')}`); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + await assert.rejects(run(['--unrecognized-option', 'prune']), /unsupported global option/); + assert.match((await run(['-C', repo, 'status', '--short'])).stdout, /\?\? unpublished\.txt\n/); + + // The guard is deliberately not a sandbox boundary: resetting PATH or using + // an absolute Git binary can still prune shared objects. + await execFileAsync('git', ['prune', '--expire', 'now'], { cwd: repo }); + await assert.rejects(run(['cat-file', '-e', object])); +}); + +test('lane Git guard rejects aliases from checkout config, -c, include.path and --config-env', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-alias-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + const otherRepo = join(parent, 'other'); + await Promise.all([mkdir(bin), mkdir(repo), mkdir(otherRepo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[], extraEnv: Record = {}) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, ...extraEnv, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await run(['config', 'alias.cleanup', 'prune --expire now']); + await run(['config', 'alias.inspect', 'status --short']); + await run(['config', 'alias.indirect', 'cleanup']); + await run(['-C', otherRepo, 'init', '-q', '-b', 'main']); + await run(['-C', otherRepo, 'config', 'alias.othercleanup', 'prune --expire now']); + await writeFile(join(parent, 'aliases.cfg'), '[alias]\n frominclude = prune --expire now\n'); + await writeFile(join(repo, 'unpublished.txt'), 'a different lane wrote this object\n'); + const object = (await run(['hash-object', '-w', 'unpublished.txt'])).stdout.trim(); + const blocked: Array<[string[], Record?]> = [ + [['cleanup']], + [['-C', repo, 'cleanup']], + [['-C', otherRepo, 'othercleanup']], + [['--git-dir=' + join(otherRepo, '.git'), 'othercleanup']], + [['inspect']], + [['indirect']], + [['-c', 'alias.fromoption=prune --expire now', 'fromoption']], + [['-c', 'alias.fromshell=!git prune --expire now', 'fromshell']], + [['--git-dir=' + join(repo, '.git'), '-c', 'alias.fromgitdir=prune --expire now', 'fromgitdir']], + [['-c', `include.path=${join(parent, 'aliases.cfg')}`, 'frominclude']], + [['--config-env=alias.fromenv=LANE_TEST_GIT_ALIAS', 'fromenv'], { LANE_TEST_GIT_ALIAS: 'prune --expire now' }], + ]; + for (const [args, environment] of blocked) { + await assert.rejects(run(args, environment), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /Git aliases are unavailable in linked worktree lanes/.test(result.stderr ?? ''); + }, `must reject Git alias in ${JSON.stringify(args)}`); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + assert.match((await run(['status', '--short'])).stdout, /unpublished\.txt/); +}); + +test('a lane rejects a checkout-defined maintenance alias before pruning another lane’s unpublished object', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-shared-alias-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const checkout = join(parent, 'checkout'); + const bin = join(parent, 'bin'); + await Promise.all([mkdir(checkout), mkdir(bin)]); + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: checkout }); + await execFileAsync('git', [ + '-c', 'user.name=test', '-c', 'user.email=test@example.test', + '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-q', '-m', 'seed', + ], { cwd: checkout }); + await mkdir(join(checkout, '.worktrees')); + const lane = join(checkout, '.worktrees', 'task'); + await execFileAsync('git', ['worktree', 'add', '-q', '-b', 'task', lane], { cwd: checkout }); + await execFileAsync('git', ['config', 'alias.cleanup', 'prune --expire now'], { cwd: checkout }); + await writeLinkedWorktreeGitGuard(bin); + await writeFile(join(lane, 'pending.txt'), 'another lane has not updated its ref\n'); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: lane, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + const object = (await run(['hash-object', '-w', 'pending.txt'])).stdout.trim(); + await assert.rejects(run(['cleanup']), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /Git aliases are unavailable in linked worktree lanes/.test(result.stderr ?? ''); + }); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); +}); + +test('a lane never lets a misspelled maintenance command autocorrect to prune', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-autocorrect-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await run(['config', 'help.autocorrect', 'immediate']); + await writeFile(join(repo, 'unpublished'), 'a sibling has not made this blob reachable'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['prun', '--expire', 'now'], + ['-c', 'help.autocorrect=immediate', 'prun', '--expire=now'], + ]) { + await assert.rejects(run(args), /not a git command|not a Git command|unknown Git command/i); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } +}); + +test('Git options cannot re-enable automatic maintenance for lane commands', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-maintenance-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + const remote = join(parent, 'remote.git'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[], additionalEnv: NodeJS.ProcessEnv = {}) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, ...additionalEnv, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await execFileAsync('git', ['init', '-q', '--bare', remote]); + for (const [key, supplied, enforced] of [ + ['maintenance.auto', 'true', 'false'], + ['gc.auto', '1', '0'], + ['help.autocorrect', 'immediate', '0'], + ]) { + const { stdout } = await run(['-c', `${key}=${supplied}`, 'config', '--get', key]); + assert.equal(stdout.trim(), enforced, `${key} must be enforced after caller-supplied -c`); + } + const { stdout } = await run(['config', '--get', 'maintenance.auto'], { + GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'maintenance.auto', GIT_CONFIG_VALUE_0: 'true', + }); + assert.equal(stdout.trim(), 'false', 'inherited configuration must not re-enable maintenance'); + const configuredGc = await run(['--config-env=gc.auto=LANE_TEST_GC_AUTO', 'config', '--get', 'gc.auto'], { + LANE_TEST_GC_AUTO: '1', + }); + assert.equal(configuredGc.stdout.trim(), '0', 'config-env cannot override the enforced setting'); + await writeFile(join(repo, 'unpublished'), 'not referenced yet'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['fetch', '--auto-maintenance', remote], + ['fetch', '--auto-maintenance=true', remote], + ['-c', 'maintenance.auto=true', 'fetch', '--auto-gc', remote], + ['fetch', '--auto-gc=true', remote], + ['-c', 'gc.auto=1', 'fetch', '--auto-maintenance', remote], + ['pull', '--auto-maintenance', remote], + ]) { + await assert.rejects(run(args), /run storage maintenance from the checkout/); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + await run(['-c', 'maintenance.auto=true', '-c', 'gc.auto=1', 'fetch', '--no-auto-maintenance', remote, 'refs/heads/*:refs/remotes/origin/*']); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); +}); + +test('for-each-repo cannot dispatch maintenance behind the lane Git guard', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-for-each-repo-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await writeFile(join(repo, 'pending'), 'a sibling has not published this object yet\n'); + const object = (await run(['hash-object', '-w', 'pending'])).stdout.trim(); + const dispatcher = ['-c', `maintenance.repo=${repo}`, 'for-each-repo', '--config=maintenance.repo']; + for (const args of [ + [...dispatcher, 'prune', '--expire', 'now'], + [...dispatcher, 'status', '--short'], + ]) { + await assert.rejects(run(args), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /run storage maintenance from the checkout/.test(result.stderr ?? ''); + }, 'Git dispatchers cannot be inspected safely inside a lane'); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + assert.match((await run(['status', '--short'])).stdout, /pending/); +}); + +test('Git LFS fetch and pull cannot request pruning in a lane', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-lfs-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await writeFile(join(repo, 'unpublished'), 'an LFS sibling has no ref yet'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['lfs', 'fetch', '--prune'], + ['lfs', 'fetch', '-p'], + ['lfs', 'fetch', '-rp'], + ['lfs', 'fetch', '--prune', '--recent'], + ['lfs', 'pull', '--prune'], + ['-C', repo, 'lfs', 'fetch', '--prune'], + ]) { + await assert.rejects(run(args), /run storage maintenance from the checkout/); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } +}); diff --git a/packages/code/src/linked-worktree-git-guard.ts b/packages/code/src/linked-worktree-git-guard.ts new file mode 100644 index 00000000..227bfd3f --- /dev/null +++ b/packages/code/src/linked-worktree-git-guard.ts @@ -0,0 +1,123 @@ +import { constants as fsConstants } from 'node:fs'; +import { access, realpath, stat, writeFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; + +import { WorkspaceToolError } from './workspace.js'; + +// Match the system-only executable search used for native programmatic tools. +const GIT_BIN_DIRS = [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/home/linuxbrew/.linuxbrew/bin', +]; +const TRUSTED_GIT_DIRS = [ + ...GIT_BIN_DIRS, + '/opt/homebrew/Cellar', + '/usr/local/Cellar', + '/home/linuxbrew/.linuxbrew/Cellar', + '/usr/lib/git-core', +]; + +async function systemGitExecutable(): Promise { + for (const directory of GIT_BIN_DIRS) { + try { + const executable = await realpath(join(directory, 'git')); + if (!TRUSTED_GIT_DIRS.some(path => executable.startsWith(`${path}${sep}`))) continue; + if (!(await stat(executable)).isFile()) continue; + await access(executable, fsConstants.X_OK); + return executable; + } catch { + // A missing system installation is not a reason to run Git from the workspace or PATH. + } + } + throw new WorkspaceToolError('Trusted Git executable is unavailable for linked worktree lanes', 'COMMAND_UNAVAILABLE'); +} + +/** This guards accidental `git` calls, not absolute executable paths or caller shell aliases. */ +export async function writeLinkedWorktreeGitGuard(directory: string): Promise { + try { + await access('/bin/bash', fsConstants.X_OK); + } catch { + throw new WorkspaceToolError('Linked worktree Git guard requires /bin/bash', 'COMMAND_UNAVAILABLE'); + } + const git = await systemGitExecutable(); + const quotedGit = `'${git.replaceAll("'", "'\\''")}'`; + const script = [ + // Bash arrays preserve global option arguments when checking the same + // effective Git configuration without eval or interpreting alias contents. + '#!/bin/bash', + 'check() {', + ' local -a globals=() forwarded=()', + ' local aliasStatus=0', + ' while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -C|-c|--git-dir|--work-tree|--namespace|--config-env)', + ' if [ "$#" -lt 2 ]; then echo "git: missing global option argument" >&2; return 2; fi', + ' globals+=("$1" "$2"); forwarded+=("$1" "$2"); shift 2 ;;', + ' -C?*|-c?*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*)', + ' globals+=("$1"); forwarded+=("$1"); shift ;;', + ' --bare)', + ' globals+=("$1"); forwarded+=("$1"); shift ;;', + ' -p|-P|--paginate|--no-pager|--no-replace-objects|--no-optional-locks|--exec-path=*)', + ' forwarded+=("$1"); shift ;;', + ' --literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs)', + ' forwarded+=("$1"); shift ;;', + ' --) forwarded+=("$1"); shift; break ;;', + ' -v|--version|-h|--help|--exec-path|--html-path|--man-path|--info-path)', + ' return 0 ;;', + ' -*) echo "git: unsupported global option in linked worktree lane" >&2; return 2 ;;', + ' *) break ;;', + ' esac', + ' done', + ' case "${1:-}" in', + ' prune|gc|repack|prune-packed|maintenance|multi-pack-index|for-each-repo)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' fetch|pull)', + ' for option in "${@:2}"; do', + ' case "$option" in', + ' --auto-maintenance|--auto-maintenance=*|--auto-gc|--auto-gc=*)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' esac', + ' done ;;', + ' lfs)', + ' if [ "${2:-}" = prune ]; then', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1', + ' fi', + ' if [ "${2:-}" = fetch ] || [ "${2:-}" = pull ]; then', + ' for option in "${@:3}"; do', + ' case "$option" in', + ' --prune|--prune=*|-p|-p?*|-[!-]*p*)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' esac', + ' done', + ' fi ;;', + ' esac', + ' if [ "$#" -eq 0 ]; then return 0; fi', + // Git itself loads local config, -C, -c, includes and --config-env, so an + // alias from any of those sources is rejected even when it hides a prune. + // Reject all aliases, including harmless ones: Git shell aliases and nested + // aliases cannot be proven safe by inspecting the first token. + ` ${quotedGit} "\${globals[@]}" config --get "alias.\${1}" >/dev/null 2>&1 || aliasStatus=$?`, + ' case "$aliasStatus" in', + ' 0) echo "git: Git aliases are unavailable in linked worktree lanes; run the underlying command or use the checkout" >&2; return 1 ;;', + ' 1) ;;', + ' *) echo "git: cannot verify Git aliases in linked worktree lane" >&2; return 2 ;;', + ' esac', + // Command-line -c values override both Git config files and GIT_CONFIG_COUNT. + // Add these after every caller global option: fetch can start maintenance + // internally without invoking the PATH guard a second time. + ` exec ${quotedGit} "\${forwarded[@]}" -c maintenance.auto=false -c gc.auto=0 -c help.autocorrect=0 "$@"`, + '}', + 'check "$@" || exit "$?"', + `exec ${quotedGit} "$@"`, + '', + ].join('\n'); + await writeFile(join(directory, 'git'), script, { flag: 'wx', mode: 0o500 }); + await access(join(directory, 'git'), fsConstants.X_OK); +} diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 7ecb06f3..b6fb25bc 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1629,6 +1629,11 @@ test('a linked worktree lane may write only shared Git storage and its own metad assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]); assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + const gitGuard = config.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(gitGuard, 'lane Git guard must be readable'); + assert.ok(!config.filesystem.allowWrite.includes(gitGuard)); + assert.ok(config.filesystem.denyWrite.includes(gitGuard)); + assert.equal((await stat(join(gitGuard, 'git'))).mode & 0o222, 0); const probed = fakeManager(); const prober = new NativeSrtWorkspaceCommandSandbox({ @@ -1642,6 +1647,10 @@ test('a linked worktree lane may write only shared Git storage and its own metad await prober.executeProgrammatic(request, dataDirectory, undefined, { probe: true }); assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(commonGitDir)); assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(commonGitDir)); + const probeGuard = probed.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(probeGuard); + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(probeGuard)); + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.denyWrite?.includes(probeGuard)); await assert.rejects( prepare([commonGitDir]), @@ -1659,3 +1668,75 @@ test('a linked worktree lane may write only shared Git storage and its own metad error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', ); }); + +test('lane commands put the read-only Git guard ahead of the ordinary PATH', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const checkoutRoot = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-guard-'))); + t.after(() => rm(checkoutRoot, { recursive: true, force: true })); + const commonGitDir = join(checkoutRoot, '.git'); + const lane = join(checkoutRoot, '.worktrees', 'task-a'); + const writableGitPaths = [join(commonGitDir, 'objects'), join(commonGitDir, 'worktrees', 'task-a')]; + await Promise.all([lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true }))); + const fake = fakeManager(); + let commandPath: string | undefined; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, + environment: { PATH: '/usr/bin:/bin' }, + manager: fake.manager, + spawnCommand(command, args, options) { + commandPath = options.env?.PATH; + return spawn(command, args, options); + }, + }); + t.after(() => sandbox.close()); + const result = await sandbox.execute({ ...request, command: 'git --version' }); + assert.equal(result.exitCode, 0); + assert.match(result.stdout, /git version/); + const guard = fake.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(guard); + assert.ok(commandPath?.startsWith(`${guard}:`)); + await sandbox.close(); + await assert.rejects(stat(guard), { code: 'ENOENT' }); +}); + +test('linked worktree Git guard is removed when sandbox initialization fails', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const checkoutRoot = await mkdtemp(join(tmpdir(), 'librechat-code-failed-guard-')); + t.after(() => rm(checkoutRoot, { recursive: true, force: true })); + const commonGitDir = join(checkoutRoot, '.git'); + const lane = join(checkoutRoot, '.worktrees', 'task-a'); + const objects = join(commonGitDir, 'objects'); + await Promise.all([lane, objects].map(path => mkdir(path, { recursive: true }))); + const fake = fakeManager({ initializeError: new Error('init failed') }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects] }, + manager: fake.manager, + }); + await assert.rejects(sandbox.prepare(), /init failed/); + const guard = fake.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(guard); + await assert.rejects(stat(guard), { code: 'ENOENT' }); + await sandbox.close(); +}); + +test('linked worktree Git guard refuses Windows rather than admitting an unguarded lane', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-win-guard-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + linkedWorktree: { + checkoutRoot: root, + commonGitDir: join(root, '.git'), + writableGitPaths: [join(root, '.git', 'objects')], + }, + platform: 'win32', + manager: fakeManager().manager, + }); + await assert.rejects(sandbox.prepare(), (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'COMMAND_UNAVAILABLE' && + /POSIX host/.test(error.message), + ); + await sandbox.close(); +}); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 6ac51acc..e635f8e7 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -32,6 +32,7 @@ import { } from './private-storage.js'; import { WorkspaceToolError } from './workspace.js'; import { restoreScratchTraversal } from './native-scratch.js'; +import { writeLinkedWorktreeGitGuard } from './linked-worktree-git-guard.js'; import type { ChildProcessWithoutNullStreams, @@ -323,6 +324,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } private scratchDirectory?: string; private scratchHandle?: FileHandle; + /** A private sibling of scratch, readable but never writable by lane commands. */ + private gitGuardDirectory?: string; private execution?: Promise; private closing?: Promise; private resetFailed = false; @@ -361,6 +364,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox await this.manager.reset().catch(() => { this.resetFailed = true; }); + await this.removeLinkedWorktreeGitGuard().catch(() => undefined); await this.removeScratchDirectory().catch(() => undefined); if (!this.resetFailed) managerOwners.delete(this.manager); this.initialized = undefined; @@ -448,6 +452,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } } const lane = this.options.linkedWorktree; + if (lane && this.platform === 'win32') { + throw new WorkspaceToolError('Linked worktree Git guard requires a POSIX host', 'COMMAND_UNAVAILABLE'); + } const commonGitDir = lane ? await canonicalPath(lane.commonGitDir) : undefined; const checkoutRoot = lane ? await canonicalPath(lane.checkoutRoot) : undefined; const writableGitPaths = lane @@ -485,6 +492,12 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'REGISTRATION_INVALID', ); } + if (lane && !canonicalScratchDirectory) { + throw new WorkspaceToolError('Linked worktree Git guard requires private scratch storage', 'COMMAND_UNAVAILABLE'); + } + const gitGuardDirectory = lane + ? await this.createLinkedWorktreeGitGuard(canonicalScratchDirectory!) + : undefined; const commandPolicy = normalizeNativeSrtCommandPolicy( this.options.commandPolicy, ); @@ -510,6 +523,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowRead: [ root, ...laneGitPaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ...(canonicalScratchDirectory ? [canonicalScratchDirectory] : []), @@ -524,6 +538,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox denyWrite: [ ...protectedPaths, ...deniedInheritedWritablePaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ], allowGitConfig: false, }, @@ -593,6 +608,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.denyWritePaths = [ ...protectedPaths, ...deniedInheritedWritablePaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ]; } @@ -817,6 +833,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ...(this.canonicalCommonGitDir ? [this.canonicalCommonGitDir] : []), + ...(this.gitGuardDirectory + ? [this.gitGuardDirectory] + : []), ], allowWrite: [ ...(canonicalWorkspaceRoot != null @@ -1067,6 +1086,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ? 'NUL' : '/dev/null', GIT_CONFIG_NOSYSTEM: '1', + ...(this.gitGuardDirectory ? { + PATH: `${this.gitGuardDirectory}:${wrapped.env.PATH || this.environment.PATH || '/usr/bin:/bin'}`, + } : {}), }, detached: this.platform !== 'win32', shell: false, @@ -1297,6 +1319,24 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } + private async createLinkedWorktreeGitGuard(scratchDirectory: string): Promise { + if (this.gitGuardDirectory) { + throw new Error('Linked worktree Git guard cleanup is still pending'); + } + // Unlike scratch, this sibling directory is not in filesystem.allowWrite. + const directory = await mkdtemp(join(dirname(scratchDirectory), 'librechat-code-git-')); + this.gitGuardDirectory = directory; + await assertPrivateStorageAncestors(directory); + await writeLinkedWorktreeGitGuard(directory); + return directory; + } + + private async removeLinkedWorktreeGitGuard(): Promise { + if (!this.gitGuardDirectory) return; + await rm(this.gitGuardDirectory, { recursive: true, force: true }); + this.gitGuardDirectory = undefined; + } + private async removeScratchDirectory(): Promise { const scratchDirectory = this.scratchDirectory; if (!scratchDirectory) return; @@ -1340,6 +1380,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } this.initialized = undefined; this.canonicalRoot = undefined; - await this.removeScratchDirectory(); + try { + await this.removeLinkedWorktreeGitGuard(); + } finally { + await this.removeScratchDirectory(); + } } }