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..c9052a35 --- /dev/null +++ b/packages/code/src/linked-worktrees-live.test.ts @@ -0,0 +1,108 @@ +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, 'packed-refs'), + 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, + }, + 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}'`); + } + // 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.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/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index b6fb25bc..5b79aa1a 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1612,13 +1612,14 @@ test('a linked worktree lane may write only shared Git storage and its own metad await Promise.all( [lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true })), ); - const prepare = async (paths: string[]) => { + const prepare = async (paths: string[], platform: NodeJS.Platform = 'linux') => { const fake = fakeManager(); const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: lane, linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths }, environment: { PATH: '/usr/bin' }, manager: fake.manager, + platform, }); t.after(() => sandbox.close()); await sandbox.prepare(); @@ -1629,6 +1630,16 @@ 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)); + // 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'); assert.ok(!config.filesystem.allowWrite.includes(gitGuard)); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index e635f8e7..49fe6ecf 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -481,6 +481,25 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } 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 ( @@ -519,6 +538,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ...sharedScratchPaths.filter(path => deniedInheritedWritablePaths.includes(path), ), + ...laneWriteRebinds, ], allowRead: [ root,