From c90e44aa49b009a042c0abaf0fa7d6610faf2f92 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 29 Sep 2026 14:43:06 -0400 Subject: [PATCH 1/2] fix: Grant lane Git reads entry by entry so write binds survive a read-denied home A read grant on the whole common Git directory masked the write binds beneath it whenever the checkout lived inside the worker home, which SRT re-binds under a tmpfs (writes first, then reads). Lanes could not create their own index.lock or ref locks. Grant each entry off the writable paths instead, and add a real-SRT regression test with a Linux CI job. --- .github/workflows/ci.yml | 24 ++++ .../code/src/linked-worktrees-live.test.ts | 103 ++++++++++++++++++ packages/code/src/linked-worktrees.test.ts | 16 ++- packages/code/src/linked-worktrees.ts | 36 +++++- packages/code/src/native-process.test.ts | 1 + packages/code/src/native-sandbox.test.ts | 40 ++++--- packages/code/src/native-sandbox.ts | 28 +++-- 7 files changed, 221 insertions(+), 27 deletions(-) create mode 100644 packages/code/src/linked-worktrees-live.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4076d8f6..f7393284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,30 @@ jobs: LIBRECHAT_CODE_LIVE_SRT_TESTS: '1' run: node --test dist/environment-live.test.js + linux-native-sandbox-tests: + name: Linux Native Sandbox Tests + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: packages/code + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.16.0 + - name: Install bubblewrap + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap socat ripgrep + # Ubuntu 24.04 blocks unprivileged user namespaces through AppArmor; bwrap needs them. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: npm ci + - run: npm run build + - name: Linked worktree lane containment + env: + LIBRECHAT_CODE_LIVE_SRT_TESTS: '1' + run: node --test dist/linked-worktrees-live.test.js + lambda-microvm-provisioning: name: Lambda MicroVM Provisioning runs-on: ubuntu-latest diff --git a/packages/code/src/linked-worktrees-live.test.ts b/packages/code/src/linked-worktrees-live.test.ts new file mode 100644 index 00000000..1cd7f359 --- /dev/null +++ b/packages/code/src/linked-worktrees-live.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; + +import { verifyLinkedWorktree } from './linked-worktrees.js'; +import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js'; +import { resolveNativeSrtCommandPolicy } from './native-policy.js'; + +const execFileAsync = promisify(execFile); +const IDENTITY = ['-c', 'user.name=lane', '-c', 'user.email=lane@example.com', '-c', 'commit.gpgsign=false']; + +async function git(cwd: string, ...args: string[]): Promise { + return (await execFileAsync('git', [...IDENTITY, ...args], { cwd })).stdout.trim(); +} + +async function snapshot(paths: string[]): Promise> { + const entries = await Promise.all( + paths.map(async (path) => [path, await readFile(path, 'utf8').catch(() => null)] as const), + ); + return Object.fromEntries(entries); +} + +/** + * The worker's home is read-denied, so a checkout beneath it exercises the + * sandbox's tmpfs re-binding; one beneath the system temporary directory does not. + */ +for (const [location, parent] of [ + ['beneath the worker home', homedir()], + ['outside the worker home', tmpdir()], +] as const) { + test(`real SRT lets a linked worktree lane commit while checkout and sibling Git metadata stay intact (${location})`, { + skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', + timeout: 60_000, + }, async (t) => { + const base = await realpath(await mkdtemp(join(parent, 'lane-live-'))); + t.after(() => rm(base, { recursive: true, force: true })); + const root = join(base, 'repo'); + await mkdir(root); + await git(root, 'init', '-q', '-b', 'main'); + await writeFile(join(root, 'tracked.txt'), 'checkout\n'); + await git(root, 'add', 'tracked.txt'); + await git(root, 'commit', '-qm', 'init'); + await mkdir(join(root, '.worktrees')); + await git(root, 'worktree', 'add', '-q', '-b', 'task-a', '.worktrees/task-a'); + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); + + const commonGitDir = join(root, '.git'); + const protectedFiles = [ + join(commonGitDir, 'HEAD'), + join(commonGitDir, 'index'), + join(commonGitDir, 'config'), + join(commonGitDir, 'MERGE_HEAD'), + join(commonGitDir, 'hooks', 'pre-commit'), + join(commonGitDir, 'worktrees', 'task-b', 'HEAD'), + join(root, 'tracked.txt'), + ]; + const before = await snapshot(protectedFiles); + const lane = await verifyLinkedWorktree(root, 'task-a'); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane.root, + workspaceIdentity: lane.identity, + linkedWorktree: { + checkoutRoot: lane.checkoutRoot, + commonGitDir: lane.commonGitDir, + writableGitPaths: lane.writableGitPaths, + readableGitPaths: lane.readableGitPaths, + }, + commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'), + environment: { PATH: process.env.PATH, LANG: 'C.UTF-8' }, + }); + t.after(() => sandbox.close()); + const run = (command: string) => + sandbox.execute({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'lane', + command, + timeoutMs: 30_000, + maxOutputBytes: 16_384, + }); + const gitInLane = `git ${IDENTITY.join(' ')}`; + + const committed = await run( + `printf lane > lane.txt && ${gitInLane} add lane.txt && ${gitInLane} commit -qm lane && ${gitInLane} branch lane-extra`, + ); + assert.equal(committed.exitCode, 0, committed.stderr); + + for (const path of protectedFiles) { + await run(`printf tampered > '${path}'`); + } + await sandbox.close(); + + assert.equal(await git(root, 'log', '-1', '--format=%s', 'task-a'), 'lane'); + assert.equal(await git(root, 'rev-parse', '--verify', '-q', 'refs/heads/lane-extra').then(Boolean), true); + assert.deepEqual(await snapshot(protectedFiles), before); + assert.equal(await git(root, 'status', '--porcelain', '--untracked-files=no'), ''); + await assert.rejects(stat(join(commonGitDir, 'MERGE_HEAD')), { code: 'ENOENT' }); + }); +} diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts index 53ec156e..e1bb2399 100644 --- a/packages/code/src/linked-worktrees.test.ts +++ b/packages/code/src/linked-worktrees.test.ts @@ -70,6 +70,13 @@ test('verifies a linked worktree of the checkout and lists the only Git paths it join(root, '.git', 'lfs'), join(root, '.git', 'worktrees', 'task-a'), ]); + const gitPath = (...path: string[]) => join(root, '.git', ...path); + for (const path of [gitPath('config'), gitPath('hooks'), gitPath('HEAD'), gitPath('index'), gitPath('logs', 'HEAD'), gitPath('worktrees', 'task-b')]) { + assert.ok(lane.readableGitPaths.includes(path), path); + } + for (const path of [gitPath(), gitPath('logs'), gitPath('worktrees'), ...lane.writableGitPaths]) { + assert.ok(!lane.readableGitPaths.includes(path), path); + } }); @@ -225,7 +232,7 @@ test('lane file tools are confined to the worktree and report the public workspa ); }); -test('lane commands register a confined root once, whatever siblings come and go', async (t) => { +test('lane commands register a confined root and refresh it when the shared Git entries change', async (t) => { const { parent, root } = await checkout(); t.after(() => rm(parent, { recursive: true, force: true })); const { pool, calls } = recordingPool(); @@ -253,6 +260,7 @@ test('lane commands register a confined root once, whatever siblings come and go assert.equal(registered.linkedWorktree?.checkoutRoot, root); assert.ok(!registered.linkedWorktree?.writableGitPaths.includes(join(root, '.git'))); + assert.ok(!registered.linkedWorktree?.readableGitPaths.includes(join(root, '.git'))); await rejects(tools.execute({ ...command, @@ -262,9 +270,13 @@ test('lane commands register a confined root once, whatever siblings come and go await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); await tools.execute(command); + await tools.execute(command); assert.deepEqual( calls.slice(3).map((call) => call.action), - ['execute'], + ['unregister', 'register', 'execute', 'execute'], + ); + assert.ok( + calls[4]!.options!.linkedWorktree!.readableGitPaths.includes(join(root, '.git', 'worktrees', 'task-b')), ); }); diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index e049aa6e..a48c86e7 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import { lstat, open, realpath } from 'node:fs/promises'; -import { isAbsolute, join, resolve } from 'node:path'; +import { lstat, open, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, resolve, sep } from 'node:path'; import { isValidLinkedWorktreeName } from './protocol.js'; import { @@ -48,6 +48,13 @@ export interface VerifiedLinkedWorktree { commonGitDir: string; /** Shared object and ref storage plus the lane's own metadata; nothing else in the common Git directory. */ writableGitPaths: string[]; + /** + * Every other entry of the common Git directory, read-only. Listed one by one + * rather than granting the whole directory: a read grant on an ancestor of a + * writable path masks that path's write bind when the checkout sits inside a + * read-denied directory such as the worker's home. + */ + readableGitPaths: string[]; } export interface LinkedWorktreeWorkspaceToolsOptions { @@ -193,7 +200,26 @@ export async function verifyLinkedWorktree( ...LINKED_WORKTREE_SHARED_GIT_PATHS.map((path) => join(commonGitDir, path)), metadata, ]; - return { root, identity, checkoutRoot: checkout, commonGitDir, writableGitPaths }; + const readableGitPaths = (await gitPathsBeside(commonGitDir, writableGitPaths)).sort(); + return { root, identity, checkoutRoot: checkout, commonGitDir, writableGitPaths, readableGitPaths }; +} + +/** Entries beneath `directory` off every writable path; symlinks are never granted. */ +async function gitPathsBeside(directory: string, writable: readonly string[]): Promise { + const entries = await readdir(directory).catch(() => [] as string[]); + const nested = await Promise.all( + entries.map(async (entry): Promise => { + const path = join(directory, entry); + if (writable.includes(path)) return []; + const status = await lstat(path).catch(() => undefined); + if (status == null || status.isSymbolicLink()) return []; + if (writable.some((candidate) => candidate.startsWith(`${path}${sep}`))) { + return status.isDirectory() ? await gitPathsBeside(path, writable) : []; + } + return [path]; + }), + ); + return nested.flat(); } function publicResult(result: WorkspaceToolResult, workspaceId: string): WorkspaceToolResult { @@ -303,7 +329,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { return await cached.value; } - /** Register the lane's command root, replacing it when its identity or writable Git paths changed. */ + /** Register the lane's command root, replacing it when its identity or Git paths changed. */ private async registerCommandRoot( internalId: string, lane: VerifiedLinkedWorktree, @@ -318,6 +344,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { lane.identity.dev, lane.identity.ino, lane.writableGitPaths, + lane.readableGitPaths, ]); const registered = this.commandRoots.get(internalId); if (registered !== fingerprint) { @@ -330,6 +357,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { checkoutRoot: lane.checkoutRoot, commonGitDir: lane.commonGitDir, writableGitPaths: lane.writableGitPaths, + readableGitPaths: lane.readableGitPaths, }, }); this.commandRoots.set(internalId, fingerprint); diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 8abf263c..8c424d8e 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -156,6 +156,7 @@ test('executor forwards the linked worktree policy to the sandbox process', asyn checkoutRoot: '/checkout', commonGitDir: '/checkout/.git', writableGitPaths: ['/checkout/.git/objects', '/checkout/.git/refs'], + readableGitPaths: ['/checkout/.git/config', '/checkout/.git/hooks'], }; const sandbox = new NativeProcessWorkspaceCommandSandbox( { workspaceRoot: '/checkout/.worktrees/task-a', linkedWorktree }, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index b6fb25bc..36aa42a6 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1609,14 +1609,15 @@ test('a linked worktree lane may write only shared Git storage and its own metad join(commonGitDir, 'refs'), join(commonGitDir, 'worktrees', 'task-a'), ]; + const readableGitPaths = [join(commonGitDir, 'config'), join(commonGitDir, 'hooks')]; await Promise.all( [lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true })), ); - const prepare = async (paths: string[]) => { + const prepare = async (paths: string[], readable = readableGitPaths) => { const fake = fakeManager(); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths, readableGitPaths: readable }, environment: { PATH: '/usr/bin' }, manager: fake.manager, }); @@ -1628,7 +1629,11 @@ test('a linked worktree lane may write only shared Git storage and its own metad const config = await prepare(writableGitPaths); assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]); assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); - assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + // A read grant on the common directory would mask the write binds beneath it. + assert.ok(!config.filesystem.allowRead?.includes(commonGitDir)); + for (const path of [...readableGitPaths, ...writableGitPaths]) { + assert.ok(config.filesystem.allowRead?.includes(path), path); + } 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)); @@ -1638,25 +1643,33 @@ test('a linked worktree lane may write only shared Git storage and its own metad const probed = fakeManager(); const prober = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths, readableGitPaths }, environment: { PATH: '/usr/bin' }, manager: probed.manager, }); t.after(() => prober.close()); const dataDirectory = await prober.createExecutionDirectory(); await prober.executeProgrammatic(request, dataDirectory, undefined, { probe: true }); - assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(commonGitDir)); - assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(commonGitDir)); + for (const path of [...readableGitPaths, ...writableGitPaths]) { + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(path), path); + assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(path), path); + } 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]), - (error: unknown) => - error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', - ); + for (const [paths, readable] of [ + [[commonGitDir], readableGitPaths], + [writableGitPaths, [commonGitDir]], + [writableGitPaths, [join(commonGitDir, 'worktrees')]], + ] as const) { + await assert.rejects( + prepare([...paths], [...readable]), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', + ); + } const siblingMetadata = join(commonGitDir, 'worktrees', 'task-b'); await mkdir(siblingMetadata, { recursive: true }); @@ -1681,7 +1694,7 @@ test('lane commands put the read-only Git guard ahead of the ordinary PATH', asy let commandPath: string | undefined; const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths, readableGitPaths: [] }, environment: { PATH: '/usr/bin:/bin' }, manager: fake.manager, spawnCommand(command, args, options) { @@ -1711,7 +1724,7 @@ test('linked worktree Git guard is removed when sandbox initialization fails', a const fake = fakeManager({ initializeError: new Error('init failed') }); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects] }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects], readableGitPaths: [] }, manager: fake.manager, }); await assert.rejects(sandbox.prepare(), /init failed/); @@ -1730,6 +1743,7 @@ test('linked worktree Git guard refuses Windows rather than admitting an unguard checkoutRoot: root, commonGitDir: join(root, '.git'), writableGitPaths: [join(root, '.git', 'objects')], + readableGitPaths: [], }, platform: 'win32', manager: fakeManager().manager, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index e635f8e7..14931b1f 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -172,10 +172,12 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { linkedWorktree?: { /** The checkout that owns the worktree; trusted as a Git safe directory. */ checkoutRoot: string; - /** `/.git`: readable, but writable only at `writableGitPaths`. */ + /** `/.git`: readable only at the listed paths, writable only at `writableGitPaths`. */ commonGitDir: string; /** Shared objects and refs plus the lane's own metadata beneath `commonGitDir`. */ writableGitPaths: string[]; + /** The remaining entries of `commonGitDir`, none an ancestor of a writable path. */ + readableGitPaths: string[]; }; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -310,8 +312,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox private readonly platform: NodeJS.Platform; private initialized?: Promise; private canonicalRoot?: string; - /** A lane's shared Git directory; replay probes of the lane must read it too. */ - private canonicalCommonGitDir?: string; + /** A lane's granted Git paths; its replay probes read them all, writing none. */ + private laneGitPaths: string[] = []; private runtimeConfig?: SandboxRuntimeConfig; private denyReadPaths: string[] = []; private denyWritePaths: string[] = []; @@ -460,6 +462,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const writableGitPaths = lane ? await Promise.all(lane.writableGitPaths.map(canonicalPath)) : []; + const readableGitPaths = lane + ? await Promise.all(lane.readableGitPaths.map(canonicalPath)) + : []; if ( lane && (commonGitDir == null || @@ -473,6 +478,13 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox path !== resolve(lane.writableGitPaths[index]!) || path === commonGitDir || !isWithin(commonGitDir, path), + ) || + readableGitPaths.some( + (path, index) => + path !== resolve(lane.readableGitPaths[index]!) || + path === commonGitDir || + !isWithin(commonGitDir, path) || + writableGitPaths.some(writable => isWithin(path, writable)), )) ) { throw new WorkspaceToolError( @@ -480,7 +492,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'REGISTRATION_INVALID', ); } - const laneGitPaths = commonGitDir ? [commonGitDir] : []; + /** Never the common directory itself: its read bind would mask the writable binds beneath it. */ + const laneGitPaths = [...readableGitPaths, ...writableGitPaths]; const canonicalScratchDirectory = await this.createScratchDirectory(sharedScratchPaths); if ( @@ -597,7 +610,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox unrestrictedNetwork ? async () => true : undefined, ); this.canonicalRoot = root; - this.canonicalCommonGitDir = commonGitDir; + this.laneGitPaths = laneGitPaths; this.runtimeConfig = config; this.denyReadPaths = [ home, @@ -830,9 +843,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowRead: [ canonicalWorkspaceRoot ?? this.canonicalRoot!, canonicalDataDirectory, - ...(this.canonicalCommonGitDir - ? [this.canonicalCommonGitDir] - : []), + ...this.laneGitPaths, ...(this.gitGuardDirectory ? [this.gitGuardDirectory] : []), @@ -1380,6 +1391,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } this.initialized = undefined; this.canonicalRoot = undefined; + this.laneGitPaths = []; try { await this.removeLinkedWorktreeGitGuard(); } finally { From 3828aa1a5db401e43908b9d238831df92590c35d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 29 Sep 2026 15:09:38 -0400 Subject: [PATCH 2/2] fix: Re-bind lane Git writes after the common read bind instead of granting entries Per-entry read grants left the common Git directory as a writable tmpfs placeholder inside the worker home (pack-refs could strand refs there) and pinned replaced files to stale inodes. Grant the whole directory read-only again and, on Linux, list each existing writable Git directory as a deeper read-deny so SRT re-binds its write mount after the ancestor read bind. --- .../code/src/linked-worktrees-live.test.ts | 9 +++- packages/code/src/linked-worktrees.test.ts | 16 +----- packages/code/src/linked-worktrees.ts | 36 ++------------ packages/code/src/native-process.test.ts | 1 - packages/code/src/native-sandbox.test.ts | 49 +++++++++---------- packages/code/src/native-sandbox.ts | 48 ++++++++++-------- 6 files changed, 64 insertions(+), 95 deletions(-) diff --git a/packages/code/src/linked-worktrees-live.test.ts b/packages/code/src/linked-worktrees-live.test.ts index 1cd7f359..c9052a35 100644 --- a/packages/code/src/linked-worktrees-live.test.ts +++ b/packages/code/src/linked-worktrees-live.test.ts @@ -54,6 +54,7 @@ for (const [location, parent] of [ join(commonGitDir, 'index'), join(commonGitDir, 'config'), join(commonGitDir, 'MERGE_HEAD'), + join(commonGitDir, 'packed-refs'), join(commonGitDir, 'hooks', 'pre-commit'), join(commonGitDir, 'worktrees', 'task-b', 'HEAD'), join(root, 'tracked.txt'), @@ -67,7 +68,6 @@ for (const [location, parent] of [ checkoutRoot: lane.checkoutRoot, commonGitDir: lane.commonGitDir, writableGitPaths: lane.writableGitPaths, - readableGitPaths: lane.readableGitPaths, }, commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'), environment: { PATH: process.env.PATH, LANG: 'C.UTF-8' }, @@ -92,10 +92,15 @@ for (const [location, parent] of [ for (const path of protectedFiles) { await run(`printf tampered > '${path}'`); } + // Packing must not move refs into storage that vanishes with the sandbox. + await run(`${gitInLane} pack-refs --all`); await sandbox.close(); + for (const branch of ['main', 'task-a', 'task-b', 'lane-extra']) { + assert.ok(await git(root, 'rev-parse', '--verify', '-q', `refs/heads/${branch}`), branch); + } + assert.equal(await git(root, 'log', '-1', '--format=%s', 'task-a'), 'lane'); - assert.equal(await git(root, 'rev-parse', '--verify', '-q', 'refs/heads/lane-extra').then(Boolean), true); assert.deepEqual(await snapshot(protectedFiles), before); assert.equal(await git(root, 'status', '--porcelain', '--untracked-files=no'), ''); await assert.rejects(stat(join(commonGitDir, 'MERGE_HEAD')), { code: 'ENOENT' }); diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts index e1bb2399..53ec156e 100644 --- a/packages/code/src/linked-worktrees.test.ts +++ b/packages/code/src/linked-worktrees.test.ts @@ -70,13 +70,6 @@ test('verifies a linked worktree of the checkout and lists the only Git paths it join(root, '.git', 'lfs'), join(root, '.git', 'worktrees', 'task-a'), ]); - const gitPath = (...path: string[]) => join(root, '.git', ...path); - for (const path of [gitPath('config'), gitPath('hooks'), gitPath('HEAD'), gitPath('index'), gitPath('logs', 'HEAD'), gitPath('worktrees', 'task-b')]) { - assert.ok(lane.readableGitPaths.includes(path), path); - } - for (const path of [gitPath(), gitPath('logs'), gitPath('worktrees'), ...lane.writableGitPaths]) { - assert.ok(!lane.readableGitPaths.includes(path), path); - } }); @@ -232,7 +225,7 @@ test('lane file tools are confined to the worktree and report the public workspa ); }); -test('lane commands register a confined root and refresh it when the shared Git entries change', async (t) => { +test('lane commands register a confined root once, whatever siblings come and go', async (t) => { const { parent, root } = await checkout(); t.after(() => rm(parent, { recursive: true, force: true })); const { pool, calls } = recordingPool(); @@ -260,7 +253,6 @@ test('lane commands register a confined root and refresh it when the shared Git assert.equal(registered.linkedWorktree?.checkoutRoot, root); assert.ok(!registered.linkedWorktree?.writableGitPaths.includes(join(root, '.git'))); - assert.ok(!registered.linkedWorktree?.readableGitPaths.includes(join(root, '.git'))); await rejects(tools.execute({ ...command, @@ -270,13 +262,9 @@ test('lane commands register a confined root and refresh it when the shared Git await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); await tools.execute(command); - await tools.execute(command); assert.deepEqual( calls.slice(3).map((call) => call.action), - ['unregister', 'register', 'execute', 'execute'], - ); - assert.ok( - calls[4]!.options!.linkedWorktree!.readableGitPaths.includes(join(root, '.git', 'worktrees', 'task-b')), + ['execute'], ); }); diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index a48c86e7..e049aa6e 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import { lstat, open, readdir, realpath } from 'node:fs/promises'; -import { isAbsolute, join, resolve, sep } from 'node:path'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; import { isValidLinkedWorktreeName } from './protocol.js'; import { @@ -48,13 +48,6 @@ export interface VerifiedLinkedWorktree { commonGitDir: string; /** Shared object and ref storage plus the lane's own metadata; nothing else in the common Git directory. */ writableGitPaths: string[]; - /** - * Every other entry of the common Git directory, read-only. Listed one by one - * rather than granting the whole directory: a read grant on an ancestor of a - * writable path masks that path's write bind when the checkout sits inside a - * read-denied directory such as the worker's home. - */ - readableGitPaths: string[]; } export interface LinkedWorktreeWorkspaceToolsOptions { @@ -200,26 +193,7 @@ export async function verifyLinkedWorktree( ...LINKED_WORKTREE_SHARED_GIT_PATHS.map((path) => join(commonGitDir, path)), metadata, ]; - const readableGitPaths = (await gitPathsBeside(commonGitDir, writableGitPaths)).sort(); - return { root, identity, checkoutRoot: checkout, commonGitDir, writableGitPaths, readableGitPaths }; -} - -/** Entries beneath `directory` off every writable path; symlinks are never granted. */ -async function gitPathsBeside(directory: string, writable: readonly string[]): Promise { - const entries = await readdir(directory).catch(() => [] as string[]); - const nested = await Promise.all( - entries.map(async (entry): Promise => { - const path = join(directory, entry); - if (writable.includes(path)) return []; - const status = await lstat(path).catch(() => undefined); - if (status == null || status.isSymbolicLink()) return []; - if (writable.some((candidate) => candidate.startsWith(`${path}${sep}`))) { - return status.isDirectory() ? await gitPathsBeside(path, writable) : []; - } - return [path]; - }), - ); - return nested.flat(); + return { root, identity, checkoutRoot: checkout, commonGitDir, writableGitPaths }; } function publicResult(result: WorkspaceToolResult, workspaceId: string): WorkspaceToolResult { @@ -329,7 +303,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { return await cached.value; } - /** Register the lane's command root, replacing it when its identity or Git paths changed. */ + /** Register the lane's command root, replacing it when its identity or writable Git paths changed. */ private async registerCommandRoot( internalId: string, lane: VerifiedLinkedWorktree, @@ -344,7 +318,6 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { lane.identity.dev, lane.identity.ino, lane.writableGitPaths, - lane.readableGitPaths, ]); const registered = this.commandRoots.get(internalId); if (registered !== fingerprint) { @@ -357,7 +330,6 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { checkoutRoot: lane.checkoutRoot, commonGitDir: lane.commonGitDir, writableGitPaths: lane.writableGitPaths, - readableGitPaths: lane.readableGitPaths, }, }); this.commandRoots.set(internalId, fingerprint); diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 8c424d8e..8abf263c 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -156,7 +156,6 @@ test('executor forwards the linked worktree policy to the sandbox process', asyn checkoutRoot: '/checkout', commonGitDir: '/checkout/.git', writableGitPaths: ['/checkout/.git/objects', '/checkout/.git/refs'], - readableGitPaths: ['/checkout/.git/config', '/checkout/.git/hooks'], }; const sandbox = new NativeProcessWorkspaceCommandSandbox( { workspaceRoot: '/checkout/.worktrees/task-a', linkedWorktree }, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 36aa42a6..5b79aa1a 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1609,17 +1609,17 @@ test('a linked worktree lane may write only shared Git storage and its own metad join(commonGitDir, 'refs'), join(commonGitDir, 'worktrees', 'task-a'), ]; - const readableGitPaths = [join(commonGitDir, 'config'), join(commonGitDir, 'hooks')]; await Promise.all( [lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true })), ); - const prepare = async (paths: string[], readable = readableGitPaths) => { + const prepare = async (paths: string[], platform: NodeJS.Platform = 'linux') => { const fake = fakeManager(); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths, readableGitPaths: readable }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths }, environment: { PATH: '/usr/bin' }, manager: fake.manager, + platform, }); t.after(() => sandbox.close()); await sandbox.prepare(); @@ -1629,10 +1629,16 @@ test('a linked worktree lane may write only shared Git storage and its own metad const config = await prepare(writableGitPaths); assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]); assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); - // A read grant on the common directory would mask the write binds beneath it. - assert.ok(!config.filesystem.allowRead?.includes(commonGitDir)); - for (const path of [...readableGitPaths, ...writableGitPaths]) { - assert.ok(config.filesystem.allowRead?.includes(path), path); + assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + // Deeper read denies make SRT re-bind each writable Git directory after the + // read-only bind of the common directory (Linux tmpfs re-binding order). + for (const path of writableGitPaths) { + assert.ok(config.filesystem.denyRead.includes(path), path); + } + assert.ok(!config.filesystem.denyRead.includes(join(commonGitDir, 'lfs'))); + const darwin = await prepare(writableGitPaths, 'darwin'); + for (const path of writableGitPaths) { + assert.ok(!darwin.filesystem.denyRead.includes(path), path); } const gitGuard = config.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); assert.ok(gitGuard, 'lane Git guard must be readable'); @@ -1643,33 +1649,25 @@ test('a linked worktree lane may write only shared Git storage and its own metad const probed = fakeManager(); const prober = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths, readableGitPaths }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, environment: { PATH: '/usr/bin' }, manager: probed.manager, }); t.after(() => prober.close()); const dataDirectory = await prober.createExecutionDirectory(); await prober.executeProgrammatic(request, dataDirectory, undefined, { probe: true }); - for (const path of [...readableGitPaths, ...writableGitPaths]) { - assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(path), path); - assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(path), path); - } + 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)); - for (const [paths, readable] of [ - [[commonGitDir], readableGitPaths], - [writableGitPaths, [commonGitDir]], - [writableGitPaths, [join(commonGitDir, 'worktrees')]], - ] as const) { - await assert.rejects( - prepare([...paths], [...readable]), - (error: unknown) => - error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', - ); - } + await assert.rejects( + prepare([commonGitDir]), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', + ); const siblingMetadata = join(commonGitDir, 'worktrees', 'task-b'); await mkdir(siblingMetadata, { recursive: true }); @@ -1694,7 +1692,7 @@ test('lane commands put the read-only Git guard ahead of the ordinary PATH', asy let commandPath: string | undefined; const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths, readableGitPaths: [] }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, environment: { PATH: '/usr/bin:/bin' }, manager: fake.manager, spawnCommand(command, args, options) { @@ -1724,7 +1722,7 @@ test('linked worktree Git guard is removed when sandbox initialization fails', a const fake = fakeManager({ initializeError: new Error('init failed') }); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, - linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects], readableGitPaths: [] }, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects] }, manager: fake.manager, }); await assert.rejects(sandbox.prepare(), /init failed/); @@ -1743,7 +1741,6 @@ test('linked worktree Git guard refuses Windows rather than admitting an unguard checkoutRoot: root, commonGitDir: join(root, '.git'), writableGitPaths: [join(root, '.git', 'objects')], - readableGitPaths: [], }, platform: 'win32', manager: fakeManager().manager, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 14931b1f..49fe6ecf 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -172,12 +172,10 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { linkedWorktree?: { /** The checkout that owns the worktree; trusted as a Git safe directory. */ checkoutRoot: string; - /** `/.git`: readable only at the listed paths, writable only at `writableGitPaths`. */ + /** `/.git`: readable, but writable only at `writableGitPaths`. */ commonGitDir: string; /** Shared objects and refs plus the lane's own metadata beneath `commonGitDir`. */ writableGitPaths: string[]; - /** The remaining entries of `commonGitDir`, none an ancestor of a writable path. */ - readableGitPaths: string[]; }; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -312,8 +310,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox private readonly platform: NodeJS.Platform; private initialized?: Promise; private canonicalRoot?: string; - /** A lane's granted Git paths; its replay probes read them all, writing none. */ - private laneGitPaths: string[] = []; + /** A lane's shared Git directory; replay probes of the lane must read it too. */ + private canonicalCommonGitDir?: string; private runtimeConfig?: SandboxRuntimeConfig; private denyReadPaths: string[] = []; private denyWritePaths: string[] = []; @@ -462,9 +460,6 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const writableGitPaths = lane ? await Promise.all(lane.writableGitPaths.map(canonicalPath)) : []; - const readableGitPaths = lane - ? await Promise.all(lane.readableGitPaths.map(canonicalPath)) - : []; if ( lane && (commonGitDir == null || @@ -478,13 +473,6 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox path !== resolve(lane.writableGitPaths[index]!) || path === commonGitDir || !isWithin(commonGitDir, path), - ) || - readableGitPaths.some( - (path, index) => - path !== resolve(lane.readableGitPaths[index]!) || - path === commonGitDir || - !isWithin(commonGitDir, path) || - writableGitPaths.some(writable => isWithin(path, writable)), )) ) { throw new WorkspaceToolError( @@ -492,8 +480,26 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'REGISTRATION_INVALID', ); } - /** Never the common directory itself: its read bind would mask the writable binds beneath it. */ - const laneGitPaths = [...readableGitPaths, ...writableGitPaths]; + const laneGitPaths = commonGitDir ? [commonGitDir] : []; + /** + * On Linux, SRT hides a read-denied directory (such as the worker home) under a + * tmpfs and then re-binds writes before reads, so the read-only bind of the whole + * common Git directory would mask its writable descendants. SRT processes read + * denies shallow-first, re-binding each one's writes on top, so listing every + * existing writable Git directory as a deeper deny restores its write bind after + * the ancestor read bind. The common directory stays a live, read-only host + * directory: nothing can be created at its top level. + */ + const laneWriteRebinds = + this.platform === 'linux' + ? ( + await Promise.all( + writableGitPaths.map(async path => + (await stat(path).catch(() => undefined))?.isDirectory() ? path : undefined, + ), + ) + ).filter((path): path is string => path != null) + : []; const canonicalScratchDirectory = await this.createScratchDirectory(sharedScratchPaths); if ( @@ -532,6 +538,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ...sharedScratchPaths.filter(path => deniedInheritedWritablePaths.includes(path), ), + ...laneWriteRebinds, ], allowRead: [ root, @@ -610,7 +617,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox unrestrictedNetwork ? async () => true : undefined, ); this.canonicalRoot = root; - this.laneGitPaths = laneGitPaths; + this.canonicalCommonGitDir = commonGitDir; this.runtimeConfig = config; this.denyReadPaths = [ home, @@ -843,7 +850,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowRead: [ canonicalWorkspaceRoot ?? this.canonicalRoot!, canonicalDataDirectory, - ...this.laneGitPaths, + ...(this.canonicalCommonGitDir + ? [this.canonicalCommonGitDir] + : []), ...(this.gitGuardDirectory ? [this.gitGuardDirectory] : []), @@ -1391,7 +1400,6 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } this.initialized = undefined; this.canonicalRoot = undefined; - this.laneGitPaths = []; try { await this.removeLinkedWorktreeGitGuard(); } finally {