From c8686d3ec7787634db119cc422785f9139963cd0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 02:00:44 -0400 Subject: [PATCH 01/10] feat(code): register explicitly selected project roots --- packages/code/README.md | 36 ++++ packages/code/src/cli.ts | 34 ++- packages/code/src/project-roots.test.ts | 271 ++++++++++++++++++++++++ packages/code/src/project-roots.ts | 86 ++++++++ 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 packages/code/src/project-roots.test.ts create mode 100644 packages/code/src/project-roots.ts diff --git a/packages/code/README.md b/packages/code/README.md index 48bf769d..f98636f4 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -51,6 +51,42 @@ chosen non-overlapping project directories with `--workspace` or `--environment` Do not also register their parent directory. Treat the inventory as a snapshot; normal workspace admission must validate any directory selected from it. +## Register selected projects + +After pairing, use paths from `projects --root` to register individual checkouts: + +```bash +librechat-code run --project-root /srv/projects \ + --project web --project services/api \ + --allow-workspace-writes --allow-workspace-commands +``` + +Only the explicitly listed checkouts become execution roots. The discovery +directory is not registered, and adding a new sibling repository does not grant +access to it. In LibreChat, select the project in the existing workspace picker; +the conversation stores that selection for subsequent tools and approval resumes. +An agent's default workspace and the user's recent selection work as before. + +Project IDs are derived from the canonical discovery directory and relative +project path, not the branch or selection order. Keep both paths unchanged across +restarts to retain chat bindings. Moving a checkout changes its ID. These are +registration IDs, not the root-local IDs printed by the inventory command. + +Up to 32 selected projects are supported. Each must be a standalone Git checkout; +linked worktrees, symlink traversal, overlapping roots, and duplicate selections +are rejected. Existing native sandbox, command/write permissions, lease-slot and +quarantine rules still apply. This mode cannot be combined with `--environment`, +`--worker-dir`, `--workspace`, default-workspace, or workspace ID/name settings. +Existing registrations are not migrated automatically; use a new conversation +when switching registration mode. Non-Git directories still use the existing +workspace flags. Named environment setup/actions still use `--environment`. + +This reuses the existing workspace protocol and does not enable programmatic +tool calling in LibreChat. PTC must separately preserve the selected workspace +through initial execution, plain-code fallback, and replay before its gate can +be enabled. No worker restart or deployment is performed by this command's +installation alone; update your worker service arguments explicitly. + ## Pair Hardened deployments use a one-time code instead of copying a long-lived diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 7406e267..089f3b31 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -6,6 +6,7 @@ import { basename, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { discoverProjects } from './projects.js'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { loadCodeEnvironment, assertEnvironmentDefinitionsOutsideRoots, @@ -309,6 +310,26 @@ async function run( runtimeSessionId?: string, args: string[] = [], ): Promise { + const projectArgs = projectRootArguments(args); + if ( + projectArgs && + (runtimeSessionId != null || + args.some(arg => + ['--environment', '--worker-dir', '--default-workspace', + '--workspace', '--workspace-id', '--workspace-name'].some( + flag => arg === flag || arg.startsWith(`${flag}=`), + ), + ) || + [process.env.LIBRECHAT_CODE_WORKER_DIR, + process.env.LIBRECHAT_CODE_WORKSPACE_ID, + process.env.LIBRECHAT_CODE_WORKSPACE_NAME].some(value => value?.trim()) || + process.env.LIBRECHAT_CODE_DEFAULT_WORKSPACE?.trim().toLowerCase() === 'true') + ) { + throw new Error('Project selection cannot be combined with other workspace registration settings'); + } + const projectRoots = projectArgs + ? await loadProjectRoots(projectArgs.root, projectArgs.projects) + : []; const environmentPaths: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--environment') { @@ -426,11 +447,13 @@ async function run( runtimeSessionId == null && (fileRelayUpstream?.length ?? 0) > 0; const workspaceId = + projectRoots[0]?.id ?? environments[0]?.definition.name ?? option(args, '--workspace-id') ?? process.env.LIBRECHAT_CODE_WORKSPACE_ID?.trim() ?? 'primary'; const explicitWorkerDirectory = + projectRoots[0]?.root ?? environments[0]?.definition.root ?? (runtimeSessionId == null ? nonEmpty( @@ -465,8 +488,11 @@ async function run( 'LIBRECHAT_CODE_COMMAND_SANDBOX must be native-srt or runtime', ); } - if (environments.length && commandSandboxMode !== 'native-srt') { - throw new Error('Environment definitions require native-srt'); + if ( + (environments.length || projectRoots.length) && + commandSandboxMode !== 'native-srt' + ) { + throw new Error('Environment definitions and project selections require native-srt'); } if ( environments.some(environment => environment.definition.setup) && @@ -577,6 +603,7 @@ async function run( root: canonicalWorkerDirectory, writable: allowWorkspaceWrites, name: + projectRoots[0]?.name ?? environments[0]?.definition.name ?? option(args, '--workspace-name') ?? process.env.LIBRECHAT_CODE_WORKSPACE_NAME?.trim() ?? @@ -597,6 +624,9 @@ async function run( writable: allowWorkspaceWrites, }); } + for (const project of projectRoots.slice(1)) { + roots.push({ ...project, writable: allowWorkspaceWrites }); + } await assertEnvironmentDefinitionsOutsideRoots(environments, roots); for (let i = 0; i < args.length; i++) { if ( diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts new file mode 100644 index 00000000..058a3a8e --- /dev/null +++ b/packages/code/src/project-roots.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import type { BridgeWorkerCapabilities } from './protocol.js'; + +const exec = promisify(execFile); +test( + 'real worker CLI registers only explicitly selected project roots', + { timeout: 10000 }, + async t => { + const root = await fixture(t); + let accept: (capabilities: BridgeWorkerCapabilities) => void = () => {}; + const registered = new Promise(resolve => { + accept = resolve; + }); + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + response.setHeader('Content-Type', 'application/json'); + if (request.url?.endsWith('/bridge/workers/register')) { + const body = JSON.parse( + Buffer.concat(chunks).toString() + ) as { + workerId: string; + incarnationId: string; + capabilities: BridgeWorkerCapabilities; + }; + accept(body.capabilities); + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + incarnationId: body.incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60000, + }) + ); + } else + response.end( + JSON.stringify({ + protocolVersion: 1, + serverElapsedMs: 0, + }) + ); + }); + }); + await new Promise(resolve => + server.listen(0, '127.0.0.1', resolve) + ); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + '--project', + 'nested/api', + ], + { + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: `http://127.0.0.1:${address.port}/v1`, + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + stdio: 'ignore', + } + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + const capabilities = await registered; + child.kill(); + await once(child, 'exit'); + assert.deepEqual( + capabilities.workspaceTools?.workspaces?.map( + workspace => workspace.name + ), + ['app', 'nested/api'] + ); + assert.ok( + capabilities.workspaceTools?.workspaces?.every(workspace => + workspace.id.startsWith('project-') + ) + ); + assert.equal(JSON.stringify(capabilities).includes(root), false); + } +); + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'selected-projects-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const name of ['app', 'nested/api']) { + await mkdir(join(root, name), { recursive: true }); + await exec('git', ['init', '--initial-branch=dev', join(root, name)]); + } + return root; +} + +test('selected projects retain identity across order and branch changes without granting their parent', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + assert.deepEqual( + selected.map(project => project.name), + ['app', 'nested/api'] + ); + assert.ok( + selected.every(project => project.root !== root && !project.writable) + ); + await exec('git', [ + '-C', + join(root, 'app'), + 'symbolic-ref', + 'HEAD', + 'refs/heads/next', + ]); + const restarted = await loadProjectRoots(root, ['nested/api', 'app']); + assert.equal(restarted[1].id, selected[0].id); + assert.equal(restarted[0].id, selected[1].id); + const otherRoot = await fixture(t); + assert.notEqual( + (await loadProjectRoots(otherRoot, ['app']))[0].id, + selected[0].id + ); +}); + +test('real file operations use the selected project boundary', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + const tools = await LocalWorkspaceTools.create({ + workspaces: selected.map(project => ({ ...project, writable: true })), + }); + await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected[1].id, + path: 'created.txt', + content: 'second project', + }); + assert.equal( + await readFile(join(root, 'nested/api/created.txt'), 'utf8'), + 'second project' + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: selected[0].id, + path: '../nested/api/created.txt', + }) + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'nested/api/created.txt', + }) + ); + await assert.rejects(readFile(join(root, 'app/created.txt'))); +}); + +test('rejects missing, escaped, aliased, duplicate and linked-worktree selections', async t => { + const root = await fixture(t); + await mkdir(join(root, 'linked')); + await writeFile( + join(root, 'linked/.git'), + 'gitdir: ../app/.git/worktrees/linked\n' + ); + await symlink(join(root, 'app'), join(root, 'alias'), 'dir'); + for (const projects of [ + [], + ['..'], + ['/tmp'], + ['missing'], + ['alias'], + ['linked'], + ['app', './app'], + Array(33).fill('app'), + ]) { + await assert.rejects(loadProjectRoots(root, projects)); + } + await assert.rejects( + loadProjectRoots(root, ['.']), + /standalone Git checkout/ + ); +}); + +test('project CLI arguments require explicit bounded selections', () => { + assert.equal(projectRootArguments(['run']), undefined); + assert.deepEqual( + projectRootArguments([ + 'run', + '--project-root=/srv/projects', + '--project', + 'app', + '--project=nested/api', + ]), + { root: '/srv/projects', projects: ['app', 'nested/api'] } + ); + for (const args of [ + ['--project-root'], + ['--project-root=/srv'], + ['--project=app'], + ['--project-root=/srv', '--project-root=/other', '--project=app'], + ['--project-root=/srv', '--project', '--allow-workspace-writes'], + ]) { + assert.throws(() => projectRootArguments(args)); + } +}); + +test('CLI rejects mixed registration and overlapping selected projects before connecting', async t => { + const root = await fixture(t); + await exec('git', ['init', '--initial-branch=dev', root]); + for (const extra of [ + ['--worker-dir', root], + ['--project', '.'], + ]) { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + ...extra, + ], + { + encoding: 'utf8', + timeout: 5000, + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1', + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + } + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /cannot be combined|must not overlap/); + } +}); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts new file mode 100644 index 00000000..28180152 --- /dev/null +++ b/packages/code/src/project-roots.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto'; +import { lstat, realpath } from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; +import { discoverProjects } from './projects.js'; +import type { LocalWorkspaceConfig } from './workspace.js'; + +/** Explicit operator selections, not an automatically expanding execution grant. */ +export async function loadProjectRoots( + directory: string, + selections: string[] +): Promise { + if (!selections.length || selections.length > 32) + throw new Error('Choose between 1 and 32 projects'); + const root = await realpath(directory); + const paths = new Set(); + const projects: LocalWorkspaceConfig[] = []; + for (const selection of selections) { + if (!selection || isAbsolute(selection) || selection.includes('\0')) + throw new Error('Project paths must be relative to --project-root'); + const path = resolve(root, selection); + const rel = relative(root, path); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) + throw new Error('Project paths must stay inside --project-root'); + const canonical = await realpath(path); + if (canonical !== path || !(await lstat(path)).isDirectory()) + throw new Error( + 'Selected projects must be directories without symlink traversal' + ); + if (paths.has(canonical)) + throw new Error('Duplicate project selection'); + paths.add(canonical); + const inventory = await discoverProjects({ + root: canonical, + maxProjects: 1, + }); + if ( + inventory.truncated || + !inventory.projects.some(project => project.path === '.') + ) + throw new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree' + ); + const portablePath = rel.split(sep).join('/') || '.'; + projects.push({ + id: `project-${createHash('sha256') + .update(`${root}\0${portablePath}`) + .digest('hex') + .slice(0, 32)}`, + name: (portablePath === '.' ? basename(root) : portablePath).slice( + 0, + 64 + ), + root: canonical, + }); + } + return projects; +} + +export function projectRootArguments( + args: string[] +): { root: string; projects: string[] } | undefined { + let root: string | undefined; + const projects: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + const flag = arg.split('=')[0]; + if (flag !== '--project-root' && flag !== '--project') continue; + const value = arg.includes('=') + ? arg.slice(flag.length + 1) + : args[++index]; + if (!value || value.startsWith('--')) + throw new Error(`${flag} requires a value`); + if (flag === '--project') projects.push(value); + else { + if (root !== undefined) + throw new Error('Only one --project-root may be supplied'); + root = value; + } + } + if (root === undefined && !projects.length) return undefined; + if (root === undefined || !projects.length) + throw new Error( + '--project-root requires at least one --project relative/path' + ); + return { root, projects }; +} From d393a26d6472d4dfbc7551c08d01e9b692881495 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 02:06:43 -0400 Subject: [PATCH 02/10] fix(code): reject shared Git metadata for selected projects --- packages/code/src/cli.ts | 10 +++++----- packages/code/src/project-roots.test.ts | 12 ++++++++++++ packages/code/src/project-roots.ts | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 089f3b31..ccf5cd4e 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -488,11 +488,11 @@ async function run( 'LIBRECHAT_CODE_COMMAND_SANDBOX must be native-srt or runtime', ); } - if ( - (environments.length || projectRoots.length) && - commandSandboxMode !== 'native-srt' - ) { - throw new Error('Environment definitions and project selections require native-srt'); + if (environments.length && commandSandboxMode !== 'native-srt') { + throw new Error('Environment definitions require native-srt'); + } + if (projectRoots.length && commandSandboxMode !== 'native-srt') { + throw new Error('Project selections require native-srt'); } if ( environments.some(environment => environment.definition.setup) && diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts index 058a3a8e..37e6ea4b 100644 --- a/packages/code/src/project-roots.test.ts +++ b/packages/code/src/project-roots.test.ts @@ -236,6 +236,18 @@ test('project CLI arguments require explicit bounded selections', () => { } }); +test('rejects a directory-form git marker redirecting to shared metadata', async t => { + const root = await fixture(t); + await writeFile( + join(root, 'app/.git/commondir'), + '../../nested/api/.git\n' + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/ + ); +}); + test('CLI rejects mixed registration and overlapping selected projects before connecting', async t => { const root = await fixture(t); await exec('git', ['init', '--initial-branch=dev', root]); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts index 28180152..2b261ff3 100644 --- a/packages/code/src/project-roots.ts +++ b/packages/code/src/project-roots.ts @@ -29,6 +29,21 @@ export async function loadProjectRoots( if (paths.has(canonical)) throw new Error('Duplicate project selection'); paths.add(canonical); + const commonDirectory = await lstat( + resolve(canonical, '.git', 'commondir') + ).catch(error => { + if ( + !(error instanceof Error) || + !('code' in error) || + (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + ) + throw error; + return undefined; + }); + if (commonDirectory) + throw new Error( + 'Selected projects must not share a Git common directory' + ); const inventory = await discoverProjects({ root: canonical, maxProjects: 1, From 958d333497d2c85b6b5b3eb20e529cb063aec9b7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 02:18:58 -0400 Subject: [PATCH 03/10] fix(code): pin selected project identity through executor admission --- packages/code/src/cli.ts | 4 ++- packages/code/src/native-process.ts | 2 ++ packages/code/src/native-sandbox.ts | 9 ++++++ packages/code/src/project-roots.test.ts | 39 +++++++++++++++++++++++++ packages/code/src/project-roots.ts | 12 +++++++- packages/code/src/root-identity.ts | 27 +++++++++++++++++ packages/code/src/workspace.ts | 9 ++++++ 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/code/src/root-identity.ts diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index ccf5cd4e..9e3b5071 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -601,6 +601,7 @@ async function run( { id: workspaceId, root: canonicalWorkerDirectory, + identity: projectRoots[0]?.identity, writable: allowWorkspaceWrites, name: projectRoots[0]?.name ?? @@ -910,6 +911,7 @@ async function run( }); const nativeOptions: NativeProcessSandboxOptions = { workspaceRoot: canonicalWorkerDirectory!, + workspaceIdentity: roots[0]?.identity, commandPolicy, protectedPaths: [ identityPath, @@ -949,7 +951,7 @@ async function run( new Map( roots.map(root => [ root.id, - { ...nativeOptions, workspaceRoot: root.root }, + { ...nativeOptions, workspaceRoot: root.root, workspaceIdentity: root.identity }, ]), ), workspaceLeaseSlots, diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 81bbd9c4..70cb2bd1 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -336,6 +336,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan child.on('disconnect', lost); const { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, @@ -348,6 +349,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan { options: { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 550d0d52..603b8e5f 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -13,6 +13,8 @@ import { import { constants as fsConstants } from 'node:fs'; import { access, mkdtemp, open, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; @@ -157,6 +159,7 @@ type SpawnCommand = ( ) => ChildProcessWithoutNullStreams; export interface NativeSrtWorkspaceCommandSandboxOptions { + workspaceIdentity?: WorkspaceRootIdentity; workspaceRoot: string; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -343,6 +346,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } const root = await realpath(this.options.workspaceRoot); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(root, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before sandbox admission', 'REGISTRATION_INVALID'); + } if (!(await stat(root)).isDirectory()) { throw new WorkspaceToolError( 'Native sandbox workspace is unavailable', @@ -800,6 +806,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox sandboxScratchDirectory?: string, workspaceRoot?: string, ): Promise { + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed after sandbox admission', 'REGISTRATION_INVALID'); + } if ( !isWorkspaceToolRequest(request) || request.operation !== 'execute_command' diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts index 37e6ea4b..991afeb3 100644 --- a/packages/code/src/project-roots.test.ts +++ b/packages/code/src/project-roots.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, mkdir, readFile, + rename, rm, symlink, writeFile, @@ -18,6 +19,7 @@ import test from 'node:test'; import type { TestContext } from 'node:test'; import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { LocalWorkspaceTools } from './workspace.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import type { BridgeWorkerCapabilities } from './protocol.js'; const exec = promisify(execFile); @@ -248,6 +250,43 @@ test('rejects a directory-form git marker redirecting to shared metadata', async ); }); +test('replacement after selection cannot become a file or native execution root', async t => { + const root = await fixture(t); + const outside = await fixture(t); + const [selected] = await loadProjectRoots(root, ['app']); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ ...selected, writable: true }], + }); + await rename(selected.root, `${selected.root}-previous`); + await symlink(join(outside, 'app'), selected.root, 'dir'); + await assert.rejects( + LocalWorkspaceTools.create({ workspaces: [selected] }), + /Invalid workspace registration/ + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected.id, + path: 'escaped.txt', + content: 'blocked', + }), + /changed after admission/ + ); + const executor = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: selected.root, + workspaceIdentity: selected.identity, + }); + try { + await assert.rejects(executor.prepare()); + } finally { + await executor.close().catch(() => undefined); + } + await assert.rejects(readFile(join(outside, 'app/escaped.txt')), { + code: 'ENOENT', + }); +}); + test('CLI rejects mixed registration and overlapping selected projects before connecting', async t => { const root = await fixture(t); await exec('git', ['init', '--initial-branch=dev', root]); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts index 2b261ff3..22f86ebf 100644 --- a/packages/code/src/project-roots.ts +++ b/packages/code/src/project-roots.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { lstat, realpath } from 'node:fs/promises'; import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; import { discoverProjects } from './projects.js'; +import { matchesWorkspaceRoot } from './root-identity.js'; import type { LocalWorkspaceConfig } from './workspace.js'; /** Explicit operator selections, not an automatically expanding execution grant. */ @@ -22,7 +23,8 @@ export async function loadProjectRoots( if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('Project paths must stay inside --project-root'); const canonical = await realpath(path); - if (canonical !== path || !(await lstat(path)).isDirectory()) + const directoryIdentity = await lstat(path); + if (canonical !== path || !directoryIdentity.isDirectory()) throw new Error( 'Selected projects must be directories without symlink traversal' ); @@ -56,7 +58,15 @@ export async function loadProjectRoots( 'Select a standalone Git checkout, not a parent directory or linked worktree' ); const portablePath = rel.split(sep).join('/') || '.'; + const identity = { + path: canonical, + dev: directoryIdentity.dev, + ino: directoryIdentity.ino, + }; + if (!(await matchesWorkspaceRoot(canonical, identity))) + throw new Error('Selected project changed during admission'); projects.push({ + identity, id: `project-${createHash('sha256') .update(`${root}\0${portablePath}`) .digest('hex') diff --git a/packages/code/src/root-identity.ts b/packages/code/src/root-identity.ts new file mode 100644 index 00000000..be4d7ec4 --- /dev/null +++ b/packages/code/src/root-identity.ts @@ -0,0 +1,27 @@ +import { lstat, realpath } from 'node:fs/promises'; + +export interface WorkspaceRootIdentity { + path: string; + dev: number; + ino: number; +} + +/** Revalidation of a trusted snapshot, never a fresh grant to a replacement. */ +export async function matchesWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity +): Promise { + if (root !== identity.path) return false; + try { + const current = await lstat(root); + return ( + current.isDirectory() && + !current.isSymbolicLink() && + current.dev === identity.dev && + current.ino === identity.ino && + (await realpath(root)) === identity.path + ); + } catch { + return false; + } +} diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 91e89f54..f3c54ba9 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -5,6 +5,8 @@ import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promi import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; import { BRIDGE_PROTOCOL_VERSION, @@ -66,6 +68,7 @@ export type { }; export interface LocalWorkspaceConfig { + identity?: WorkspaceRootIdentity; id: string; name?: string; root: string; @@ -334,6 +337,7 @@ async function readConfinedFile( } interface WorkspaceRoot { + identity?: WorkspaceRootIdentity; root: string; writable: boolean; } @@ -1496,6 +1500,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { let canonicalRoot: string; try { canonicalRoot = await realpath(workspace.root); + if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); } catch { throw new WorkspaceToolError( @@ -1504,6 +1509,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); } roots.set(workspace.id, { + identity: workspace.identity, root: canonicalRoot, writable: workspace.writable === true, }); @@ -1540,6 +1546,9 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { throw new WorkspaceToolError('Unknown workspace', 'INVALID_REQUEST'); } const { root } = workspace; + if (workspace.identity && !await matchesWorkspaceRoot(root, workspace.identity)) { + throw new WorkspaceToolError('Selected project changed after admission', 'REGISTRATION_INVALID'); + } if ( request.operation === 'write_file' || From 196fc7b8f35354f6810755d85e4a99b764c68fa5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 02:25:00 -0400 Subject: [PATCH 04/10] fix(code): preserve full filesystem identity precision --- packages/code/src/project-roots.ts | 6 +++--- packages/code/src/root-identity.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts index 22f86ebf..08ab74e0 100644 --- a/packages/code/src/project-roots.ts +++ b/packages/code/src/project-roots.ts @@ -23,7 +23,7 @@ export async function loadProjectRoots( if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('Project paths must stay inside --project-root'); const canonical = await realpath(path); - const directoryIdentity = await lstat(path); + const directoryIdentity = await lstat(path, { bigint: true }); if (canonical !== path || !directoryIdentity.isDirectory()) throw new Error( 'Selected projects must be directories without symlink traversal' @@ -60,8 +60,8 @@ export async function loadProjectRoots( const portablePath = rel.split(sep).join('/') || '.'; const identity = { path: canonical, - dev: directoryIdentity.dev, - ino: directoryIdentity.ino, + dev: directoryIdentity.dev.toString(), + ino: directoryIdentity.ino.toString(), }; if (!(await matchesWorkspaceRoot(canonical, identity))) throw new Error('Selected project changed during admission'); diff --git a/packages/code/src/root-identity.ts b/packages/code/src/root-identity.ts index be4d7ec4..3ddfe453 100644 --- a/packages/code/src/root-identity.ts +++ b/packages/code/src/root-identity.ts @@ -2,8 +2,8 @@ import { lstat, realpath } from 'node:fs/promises'; export interface WorkspaceRootIdentity { path: string; - dev: number; - ino: number; + dev: string; + ino: string; } /** Revalidation of a trusted snapshot, never a fresh grant to a replacement. */ @@ -13,12 +13,12 @@ export async function matchesWorkspaceRoot( ): Promise { if (root !== identity.path) return false; try { - const current = await lstat(root); + const current = await lstat(root, { bigint: true }); return ( current.isDirectory() && !current.isSymbolicLink() && - current.dev === identity.dev && - current.ino === identity.ino && + current.dev.toString() === identity.dev && + current.ino.toString() === identity.ino && (await realpath(root)) === identity.path ); } catch { From abf6202efe1e29b7c10a06113d057c59cfa57ecf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 02:29:08 -0400 Subject: [PATCH 05/10] Check selected project identity before replay staging --- packages/code/src/native-sandbox.test.ts | 27 ++++++++++++++++++++++++ packages/code/src/native-sandbox.ts | 3 +++ 2 files changed, 30 insertions(+) diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 3252ee50..6c863f21 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -236,6 +236,33 @@ test('programmatic probes use a copy-on-write workspace without mutating the pro assert.equal(await readFile(join(snapshot, 'state.txt'), 'utf8'), 'probe-only'); }); +test('programmatic probes reject a replaced selected project before copying', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-probe-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + const identity = await stat(root, { bigint: true }); + let copies = 0; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand() { + copies++; + throw new Error('must not copy a replaced project'); + }, + }); + t.after(() => sandbox.close()); + const executionDirectory = await sandbox.createExecutionDirectory(); + await rename(root, join(parent, 'original')); + await mkdir(root); + await assert.rejects( + sandbox.createProgrammaticProbeWorkspace(executionDirectory), + /Selected project changed before probe staging/, + ); + assert.equal(copies, 0); +}); + test('programmatic probes do not hide clone implementation failures as unsupported filesystems', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 603b8e5f..020870c8 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -580,6 +580,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox signal?: AbortSignal, ): Promise { await this.initialize(); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + } const scratchDirectory = this.scratchDirectory; const root = this.canonicalRoot; let parent: string; From 6d0c0743ae9eee09dedddbd7486719579de0df46 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 07:35:18 -0400 Subject: [PATCH 06/10] Anchor replay copies to the verified working directory --- packages/code/src/native-sandbox.test.ts | 35 ++++++++++++++++++++++++ packages/code/src/native-sandbox.ts | 13 ++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 6c863f21..60a77651 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -236,6 +236,41 @@ test('programmatic probes use a copy-on-write workspace without mutating the pro assert.equal(await readFile(join(snapshot, 'state.txt'), 'utf8'), 'probe-only'); }); +test('selected replay copy stays on the verified directory after pathname replacement', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-copy-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand(command, args, options) { + assert.equal(command, '/bin/sh'); + const racedArgs = [...args]; + racedArgs[1] = racedArgs[1].replace('exec /bin/cp', + 'mv "$COPY_ROOT" "$COPY_ROOT.old" && mkdir "$COPY_ROOT" && printf replacement > "$COPY_ROOT/identity.txt" && exec /bin/cp'); + return spawn(command, racedArgs, { ...options, env: { ...options.env, COPY_ROOT: root } }); + }, + }); + t.after(() => sandbox.close()); + const directory = await sandbox.createExecutionDirectory(); + let snapshot: string; + try { + snapshot = await sandbox.createProgrammaticProbeWorkspace(directory); + } catch (error) { + if (error instanceof CopyOnWriteCloneUnavailableError) { + t.skip('host filesystem does not support copy-on-write cloning'); + return; + } + throw error; + } + assert.equal(await readFile(join(root, 'identity.txt'), 'utf8'), 'replacement'); + assert.equal(await readFile(join(snapshot, 'identity.txt'), 'utf8'), 'original'); +}); + test('programmatic probes reject a replaced selected project before copying', async t => { const parent = await mkdtemp(join(tmpdir(), 'librechat-project-probe-')); t.after(() => rm(parent, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 020870c8..7b88f046 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -617,8 +617,19 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.platform === 'darwin' ? ['-cR', root, destination] : ['--archive', '--reflink=always', root, destination]; + const identity = this.options.workspaceIdentity; + // The shell pins its working directory before validation. Both the + // verifier and exec'd cp inherit that same directory, even if renamed. + const copyCommand = identity ? '/bin/sh' : '/bin/cp'; + const copyArgs = identity ? [ + '-c', + '"$1" -e \'const s=require("node:fs").statSync(".",{bigint:true});if(!s.isDirectory()||s.dev.toString()!==process.argv[1]||s.ino.toString()!==process.argv[2])process.exit(1)\' "$2" "$3" && shift 3 && exec /bin/cp "$@"', + 'copy-selected-project', process.execPath, identity.dev, identity.ino, + ...args.slice(0, -2), '.', destination, + ] : args; await new Promise((resolveCopy, rejectCopy) => { - const child = this.spawnCommand('/bin/cp', args, { + const child = this.spawnCommand(copyCommand, copyArgs, { + ...(identity ? { cwd: root } : {}), env: { PATH: this.environment.PATH, LANG: this.environment.LANG, From 434145a1b095c6c8b8d04b43d98d7759c62cb08d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 09:26:40 -0400 Subject: [PATCH 07/10] fix: Bind Selected Project Operations to Held Directory Descriptors --- .github/workflows/ci.yml | 5 + packages/code/README.md | 18 +- packages/code/src/instructions.ts | 2 +- packages/code/src/native-sandbox.test.ts | 62 +++- packages/code/src/native-sandbox.ts | 46 ++- packages/code/src/root-access.test.ts | 256 +++++++++++++++ packages/code/src/root-access.ts | 379 +++++++++++++++++++++++ packages/code/src/root-exec.ts | 18 ++ packages/code/src/workspace.ts | 25 +- 9 files changed, 780 insertions(+), 31 deletions(-) create mode 100644 packages/code/src/root-access.test.ts create mode 100644 packages/code/src/root-access.ts create mode 100644 packages/code/src/root-exec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07622ee7..72f32178 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,11 @@ jobs: node-version: 24.16.0 - run: npm ci - run: npm run build + - name: Selected project root containment tests + run: | + command -v rg || brew install ripgrep + node --test dist/root-access.test.js + node --test --test-name-pattern='selected command|selected replay copy|programmatic probes reject' dist/native-sandbox.test.js - name: Native environment containment tests run: node --test dist/environment.test.js - name: Native ACL and credential lifecycle tests diff --git a/packages/code/README.md b/packages/code/README.md index f98636f4..720a0b35 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -81,11 +81,19 @@ Existing registrations are not migrated automatically; use a new conversation when switching registration mode. Non-Git directories still use the existing workspace flags. Named environment setup/actions still use `--environment`. -This reuses the existing workspace protocol and does not enable programmatic -tool calling in LibreChat. PTC must separately preserve the selected workspace -through initial execution, plain-code fallback, and replay before its gate can -be enabled. No worker restart or deployment is performed by this command's -installation alone; update your worker service arguments explicitly. +Selected projects require macOS or Linux (including WSL2). Each request opens +and verifies the admitted directory, then retains that descriptor through file +access, repository-instruction loading, command startup, and replay copying. +Renaming a project cannot redirect an in-flight request to a replacement checkout; +subsequent requests reject the changed identity. Restart with an explicitly +selected replacement to admit it. Descriptors close when requests settle, and +independent workspaces do not share a current directory or global execution lock. + +This reuses the existing workspace protocol. Programmatic tool calling requires +a LibreChat version that preserves the selected workspace across initial +execution and replay, plus the worker's normal programmatic prerequisites. +Installation alone does not restart workers or change registration; update your +worker service arguments explicitly. ## Pair diff --git a/packages/code/src/instructions.ts b/packages/code/src/instructions.ts index ee3e8127..1a1e8cad 100644 --- a/packages/code/src/instructions.ts +++ b/packages/code/src/instructions.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { open, lstat, realpath, stat } from 'node:fs/promises'; +import { open, lstat, realpath, stat } from './root-access.js'; import { createHash } from 'node:crypto'; import { resolve, relative, isAbsolute, sep } from 'node:path'; diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 60a77651..e610a0fc 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { access, chmod, @@ -236,6 +237,57 @@ test('programmatic probes use a copy-on-write workspace without mutating the pro assert.equal(await readFile(join(snapshot, 'state.txt'), 'utf8'), 'probe-only'); }); +test('selected command cwd stays bound when replacement happens while wrapping', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-command-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager({ beforeWrap: async () => { + await rename(root, `${root}.old`); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'replacement'); + } }).manager, + }); + t.after(() => sandbox.close()); + const result = await sandbox.execute({ ...request, command: 'cat identity.txt; printf written > result.txt' }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, 'original'); + assert.equal(await readFile(join(`${root}.old`, 'result.txt'), 'utf8'), 'written'); + await assert.rejects(access(join(root, 'result.txt'))); +}); + +test('selected command cancellation kills the exec trampoline process group', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-cancel-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = await realpath(parent); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager().manager, + }); + t.after(() => sandbox.close()); + const controller = new AbortController(); + const running = sandbox.execute({ ...request, timeoutMs: 5000, + command: 'printf started > started; sleep 3; printf late > late' }, controller.signal); + const rejected = assert.rejects(running, error => error instanceof WorkspaceToolError && error.code === 'EXECUTION_ABORTED'); + const deadline = Date.now() + 3000; + while (true) { + try { await access(join(root, 'started')); break; } catch { /* Wait for the actual child. */ } + if (Date.now() > deadline) throw new Error('Selected command did not start'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + controller.abort(); + await rejected; + await new Promise(resolve => setTimeout(resolve, 3100)); + await assert.rejects(access(join(root, 'late'))); +}); + test('selected replay copy stays on the verified directory after pathname replacement', async t => { const parent = await mkdtemp(join(tmpdir(), 'librechat-project-copy-')); t.after(() => rm(parent, { recursive: true, force: true })); @@ -248,11 +300,11 @@ test('selected replay copy stays on the verified directory after pathname replac workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, manager: fakeManager().manager, spawnCommand(command, args, options) { - assert.equal(command, '/bin/sh'); - const racedArgs = [...args]; - racedArgs[1] = racedArgs[1].replace('exec /bin/cp', - 'mv "$COPY_ROOT" "$COPY_ROOT.old" && mkdir "$COPY_ROOT" && printf replacement > "$COPY_ROOT/identity.txt" && exec /bin/cp'); - return spawn(command, racedArgs, { ...options, env: { ...options.env, COPY_ROOT: root } }); + assert.equal(command, process.execPath); + renameSync(root, `${root}.old`); + mkdirSync(root); + writeFileSync(join(root, 'identity.txt'), 'replacement'); + return spawn(command, args, options); }, }); t.after(() => sandbox.close()); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 7b88f046..84602a35 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -15,6 +15,7 @@ import { access, mkdtemp, open, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { matchesWorkspaceRoot } from './root-identity.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; +import { withWorkspaceRoot, WorkspaceRootAccessError, spawnWithinWorkspace, realpath as rootedRealpath, stat as rootedStat } from './root-access.js'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; @@ -579,6 +580,16 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox executionDirectory: string, signal?: AbortSignal, ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity, + () => this.createBoundProgrammaticProbeWorkspace(executionDirectory, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + throw error; + } + } + + private async createBoundProgrammaticProbeWorkspace(executionDirectory: string, signal?: AbortSignal): Promise { await this.initialize(); if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); @@ -618,17 +629,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ? ['-cR', root, destination] : ['--archive', '--reflink=always', root, destination]; const identity = this.options.workspaceIdentity; - // The shell pins its working directory before validation. Both the - // verifier and exec'd cp inherit that same directory, even if renamed. - const copyCommand = identity ? '/bin/sh' : '/bin/cp'; - const copyArgs = identity ? [ - '-c', - '"$1" -e \'const s=require("node:fs").statSync(".",{bigint:true});if(!s.isDirectory()||s.dev.toString()!==process.argv[1]||s.ino.toString()!==process.argv[2])process.exit(1)\' "$2" "$3" && shift 3 && exec /bin/cp "$@"', - 'copy-selected-project', process.execPath, identity.dev, identity.ino, - ...args.slice(0, -2), '.', destination, - ] : args; + const copyArgs = identity ? [...args.slice(0, -2), '.', destination] : args; await new Promise((resolveCopy, rejectCopy) => { - const child = this.spawnCommand(copyCommand, copyArgs, { + const child = spawnWithinWorkspace(this.spawnCommand, '/bin/cp', copyArgs, { ...(identity ? { cwd: root } : {}), env: { PATH: this.environment.PATH, @@ -819,6 +822,23 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox customConfig?: Partial, sandboxScratchDirectory?: string, workspaceRoot?: string, + ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, workspaceRoot ? undefined : this.options.workspaceIdentity, + () => this.executeBound(request, signal, trustedEnvironment, customConfig, sandboxScratchDirectory, workspaceRoot)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceExecuteCommandRequest, + signal?: AbortSignal, + trustedEnvironment?: NodeJS.ProcessEnv, + customConfig?: Partial, + sandboxScratchDirectory?: string, + workspaceRoot?: string, ): Promise { if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { throw new WorkspaceToolError('Selected project changed after sandbox admission', 'REGISTRATION_INVALID'); @@ -842,8 +862,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const root = workspaceRoot ?? this.canonicalRoot!; let cwd: string; try { - cwd = await realpath(resolve(root, request.cwd ?? '.')); - if (!isWithin(root, cwd) || !(await stat(cwd)).isDirectory()) + cwd = await rootedRealpath(resolve(root, request.cwd ?? '.')); + if (!isWithin(root, cwd) || !(await rootedStat(cwd)).isDirectory()) throw new Error('invalid cwd'); } catch { throw new WorkspaceToolError( @@ -962,7 +982,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox (resolvePromise, reject) => { let child: ChildProcessWithoutNullStreams; try { - child = this.spawnCommand( + child = spawnWithinWorkspace(this.spawnCommand, wrapped.argv[0], wrapped.argv.slice(1), { diff --git a/packages/code/src/root-access.test.ts b/packages/code/src/root-access.test.ts new file mode 100644 index 00000000..3e555f76 --- /dev/null +++ b/packages/code/src/root-access.test.ts @@ -0,0 +1,256 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + WorkspaceRootAccess, + withWorkspaceRoot, + open, + realpath, + stat, + lstat, + rename, + link, + unlink, + spawn, +} from './root-access.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import type { WorkspaceToolRequest } from './protocol.js'; + +test('held root file operations cannot be redirected by replacing its pathname', async () => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-access-')), + ); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'original'); + const identity = await fs.stat(root, { bigint: true }); + try { + await withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'replacement'); + assert.equal( + await realpath(join(root, 'original')), + join(root, 'original'), + ); + assert.equal((await stat(root)).isDirectory(), true); + assert.equal( + (await lstat(join(root, 'original'))).isFile(), + true, + ); + const reader = await open(join(root, 'original'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'original'); + } finally { + await reader.close(); + } + const writer = await open( + join(root, 'new'), + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ); + try { + await writer.writeFile('new'); + await writer.sync(); + } finally { + await writer.close(); + } + await link(join(root, 'new'), join(root, 'linked')); + await rename(join(root, 'new'), join(root, 'renamed')); + await unlink(join(root, 'linked')); + const child = spawn('/bin/sh', ['-c', 'cat original'], { + cwd: root, + }); + let output = ''; + let error = ''; + child.stdout.on('data', chunk => { + output += chunk; + }); + child.stderr.on('data', chunk => { + error += chunk; + }); + child.stdin.end(); + const code = await new Promise(resolve => + child.on('close', resolve), + ); + assert.equal(code, 0, error); + assert.equal(output, 'original'); + }, + ); + assert.equal( + await fs.readFile(join(root, 'original'), 'utf8'), + 'replacement', + ); + assert.equal( + await fs.readFile(join(`${root}.old`, 'renamed'), 'utf8'), + 'new', + ); + assert.deepEqual(await fs.readdir(root), ['original']); + await assert.rejects( + WorkspaceRootAccess.open(root, { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }), + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +for (const operation of [ + 'read_file', + 'write_file', + 'edit_file', + 'list_files', + 'search_text', + 'instructions', +] as const) { + test(`selected ${operation} stays bound when the root is replaced after acquisition`, async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-caller-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original.txt'), 'original'); + await fs.writeFile(join(root, 'AGENTS.md'), 'Original instructions'); + const identity = await fs.stat(root, { bigint: true }); + const tools = await LocalWorkspaceTools.create({ + repositoryInstructions: true, + workspaces: [ + { + id: 'selected', + root, + writable: true, + identity: { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + }, + ], + }); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const access = await originalOpen(...args); + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile( + join(root, 'replacement.txt'), + 'replacement', + ); + await fs.writeFile( + join(root, 'AGENTS.md'), + 'Replacement instructions', + ); + return access; + }, + ); + if (operation === 'instructions') { + const descriptors = await tools.instructionDescriptors(); + const { createHash } = await import('node:crypto'); + assert.equal( + descriptors?.get('selected')?.[0].sha256, + createHash('sha256') + .update('Original instructions') + .digest('hex'), + ); + } else { + const request = { + protocolVersion: 1, + workspaceId: 'selected', + operation, + ...(operation === 'write_file' + ? { path: 'new.txt', content: 'created', overwrite: false } + : {}), + ...(operation === 'read_file' ? { path: 'original.txt' } : {}), + ...(operation === 'edit_file' + ? { + path: 'original.txt', + oldText: 'original', + newText: 'edited', + } + : {}), + ...(operation === 'search_text' ? { query: 'original' } : {}), + } as WorkspaceToolRequest; + const result = await tools.execute(request); + assert.equal( + JSON.stringify(result).includes('replacement.txt'), + false, + ); + if (operation === 'read_file') + assert.equal( + (result as { content: string }).content, + 'original', + ); + if (operation === 'write_file') + assert.equal( + await fs.readFile(join(`${root}.old`, 'new.txt'), 'utf8'), + 'created', + ); + if (operation === 'edit_file') + assert.equal( + await fs.readFile( + join(`${root}.old`, 'original.txt'), + 'utf8', + ), + 'edited', + ); + } + assert.deepEqual((await fs.readdir(root)).sort(), [ + 'AGENTS.md', + 'replacement.txt', + ]); + }); +} + +test('simultaneous roots retain independent descriptor contexts and release on failure', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-concurrency-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await Promise.all( + ['a', 'b'].map(async name => { + const root = join(directory, name); + await fs.mkdir(root); + await fs.writeFile(join(root, 'value'), name); + const identity = await fs.stat(root, { bigint: true }); + await assert.rejects( + withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + const file = await open(join(root, 'value'), 'r'); + try { + assert.equal(await file.readFile('utf8'), name); + } finally { + await file.close(); + } + throw new Error('cancelled request'); + }, + ), + /cancelled request/, + ); + }), + ); +}); diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts new file mode 100644 index 00000000..4420a455 --- /dev/null +++ b/packages/code/src/root-access.ts @@ -0,0 +1,379 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { spawn as spawnProcess } from 'node:child_process'; +import { constants, closeSync, fstatSync, readlinkSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + SpawnOptionsWithoutStdio, + ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import koffi from 'koffi'; +import type { WorkspaceRootIdentity } from './root-identity.js'; + +const lib = ['darwin', 'linux'].includes(process.platform) + ? koffi.load(null) + : undefined; +const nativeOpenAt = lib?.func( + 'int openat(int dirfd, const char *path, int flags, ...)', +); +const O_CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000; +const openAt = nativeOpenAt + ? (fd: number, path: string, flags: number, mode: number): number => + nativeOpenAt(fd, path, flags | O_CLOEXEC, 'unsigned int', mode) + : undefined; +const renameAt = lib?.func( + 'int renameat(int fromfd, const char *from, int tofd, const char *to)', +); +const linkAt = lib?.func( + 'int linkat(int fromfd, const char *from, int tofd, const char *to, int flags)', +); +const unlinkAt = lib?.func( + 'int unlinkat(int dirfd, const char *path, int flags)', +); +const getPath = + process.platform === 'darwin' + ? lib!.func('int fcntl(int fd, int command, ...)') + : undefined; + +function nativeError(): NodeJS.ErrnoException { + const errno = koffi.errno(); + const code = + Object.entries(koffi.os.errno).find( + ([, value]) => value === errno, + )?.[0] ?? 'EIO'; + return Object.assign( + new Error(`Workspace descriptor access failed: ${code}`), + { code }, + ); +} + +function checked(fd: number): number { + if (fd < 0) throw nativeError(); + return fd; +} + +function descriptorPath(fd: number): string { + return process.platform === 'linux' + ? `/proc/self/fd/${fd}` + : `/dev/fd/${fd}`; +} + +function physicalPath(fd: number): string { + if (process.platform === 'linux') return readlinkSync(descriptorPath(fd)); + const buffer = Buffer.alloc(1024); + if (!getPath || getPath(fd, 50 /* F_GETPATH */, 'void *', buffer) !== 0) + throw nativeError(); + return buffer.subarray(0, buffer.indexOf(0)).toString(); +} + +function offset(root: string, path: string): string { + const value = relative(root, path); + if (isAbsolute(value) || value === '..' || value.startsWith(`..${sep}`)) { + throw Object.assign(new Error('Path is outside the held workspace'), { + code: 'EACCES', + }); + } + return value || '.'; +} + +/** A request owns one directory descriptor, not a replaceable pathname grant. */ +export class WorkspaceRootAccessError extends Error {} +export class WorkspaceRootAccess { + private constructor( + readonly path: string, + readonly handle: fs.FileHandle, + ) {} + + static async open( + path: string, + identity: WorkspaceRootIdentity, + ): Promise { + if (!openAt || path !== identity.path) + throw new WorkspaceRootAccessError( + 'Selected project root access is unavailable', + ); + const handle = await fs + .open( + path, + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW, + ) + .catch(() => { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + }); + try { + const current = await handle.stat({ bigint: true }); + if ( + !current.isDirectory() || + current.dev.toString() !== identity.dev || + current.ino.toString() !== identity.ino + ) { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + } + return new WorkspaceRootAccess(path, handle); + } catch (error) { + await handle.close(); + throw error; + } + } + + close(): Promise { + return this.handle.close(); + } + + directory(path: string): number { + const fd = checked( + openAt!( + this.handle.fd, + offset(this.path, path), + constants.O_RDONLY | constants.O_DIRECTORY, + 0, + ), + ); + try { + this.canonical(fd); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } + } + + private canonical(fd: number): string { + return resolve( + this.path, + offset(physicalPath(this.handle.fd), physicalPath(fd)), + ); + } + + private parent(path: string): { fd: number; name: string } { + const local = offset(this.path, path); + const fd = checked( + openAt!( + this.handle.fd, + dirname(local), + constants.O_RDONLY | constants.O_DIRECTORY, + 0, + ), + ); + try { + this.canonical(fd); + return { fd, name: basename(local) }; + } catch (error) { + closeSync(fd); + throw error; + } + } + + async openFile( + path: string, + flags: number, + mode = 0o666, + ): Promise { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = checked(openAt!(parent.fd, parent.name, flags, mode)); + // Node owns the duplicate, so callers retain native FileHandle semantics + // for asynchronous I/O, fsync, ownership and deterministic close. + const accessMode = flags & (constants.O_WRONLY | constants.O_RDWR); + const duplicate = await fs.open( + descriptorPath(fd), + accessMode | constants.O_NONBLOCK, + ); + try { + const expected = fstatSync(fd, { bigint: true }); + const actual = await duplicate.stat({ bigint: true }); + if (expected.dev !== actual.dev || expected.ino !== actual.ino) + throw new Error('Descriptor duplication changed identity'); + return duplicate; + } catch (error) { + await duplicate.close(); + throw error; + } + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + stat(path: string, follow = true): ReturnType { + const parent = this.parent(path); + let fd: number | undefined; + try { + const flags = + process.platform === 'linux' + ? 0x200000 /* O_PATH */ | + (follow ? 0 : constants.O_NOFOLLOW) + : 0x8000 /* O_EVTONLY */ | + (follow ? 0 : 0x200000) /* O_SYMLINK */; + fd = checked(openAt!(parent.fd, parent.name, flags, 0)); + return fstatSync(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + realpath(path: string): string { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = checked( + openAt!( + parent.fd, + parent.name, + constants.O_RDONLY | constants.O_NONBLOCK, + 0, + ), + ); + return this.canonical(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + install(from: string, to: string, link: boolean): void { + const source = this.parent(from); + let target: ReturnType | undefined; + try { + target = this.parent(to); + const result = link + ? linkAt!(source.fd, source.name, target.fd, target.name, 0) + : renameAt!(source.fd, source.name, target.fd, target.name); + if (result !== 0) throw nativeError(); + } finally { + closeSync(source.fd); + if (target) closeSync(target.fd); + } + } + + unlink(path: string): void { + const parent = this.parent(path); + try { + if (unlinkAt!(parent.fd, parent.name, 0) !== 0) throw nativeError(); + } finally { + closeSync(parent.fd); + } + } +} + +const context = new AsyncLocalStorage(); +export async function withWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity | undefined, + action: () => Promise, +): Promise { + if (!identity) return action(); + const access = await WorkspaceRootAccess.open(root, identity); + try { + return await context.run(access, action); + } finally { + await access.close(); + } +} + +type SpawnCommand = ( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => ChildProcessWithoutNullStreams; +export function spawnWithinWorkspace( + spawner: SpawnCommand, + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +): ChildProcessWithoutNullStreams { + const access = context.getStore(); + if (!access) return spawner(command, args, options); + if (typeof options.cwd !== 'string') + throw new Error('Workspace process requires a working directory'); + const fd = access.directory(options.cwd); + try { + const env = { ...(options.env ?? process.env) }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + return spawner( + process.execPath, + [ + fileURLToPath(new URL('./root-exec.js', import.meta.url)), + command, + ...args, + ], + { + ...options, + cwd: '/', + env, + // The trusted bootstrap consumes fd 3 before exec. Commands receive no + // root descriptor, bridge socket or new long-lived supervising process. + stdio: [ + ...(Array.isArray(options.stdio) + ? options.stdio.slice(0, 3) + : ['pipe', 'pipe', 'pipe']), + fd, + ], + } as SpawnOptionsWithoutStdio, + ); + } finally { + closeSync(fd); + } +} + +export const spawn = (( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => + spawnWithinWorkspace( + spawnProcess, + command, + args, + options, + )) as typeof spawnProcess; + +// Only workspace filesystem consumers import these adapters. Unselected legacy +// roots retain their existing behavior; concurrent selected roots never share fd state. +export const open = async ( + path: string, + flags: number | 'r', + mode?: number, +): Promise => + context + .getStore() + ?.openFile(path, flags === 'r' ? constants.O_RDONLY : flags, mode) ?? + fs.open(path, flags, mode); +export const stat = async (path: string) => + context.getStore()?.stat(path) ?? fs.stat(path); +export const lstat = async (path: string) => + context.getStore()?.stat(path, false) ?? fs.lstat(path); +export const realpath = async (path: string): Promise => + context.getStore()?.realpath(path) ?? fs.realpath(path); +export const rename = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, false); + else await fs.rename(from, to); +}; +export const link = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, true); + else await fs.link(from, to); +}; +export const unlink = async (path: string): Promise => { + const access = context.getStore(); + if (access) access.unlink(path); + else await fs.unlink(path); +}; diff --git a/packages/code/src/root-exec.ts b/packages/code/src/root-exec.ts new file mode 100644 index 00000000..4cd7132c --- /dev/null +++ b/packages/code/src/root-exec.ts @@ -0,0 +1,18 @@ +import koffi from 'koffi'; + +// Private exec trampoline. The parent supplies an already validated directory +// on fd 3. fchdir is process-local here and never changes the bridge's cwd. +const lib = koffi.load(null); +const fchdir = lib.func('int fchdir(int fd)'); +const close = lib.func('int close(int fd)'); +const execvp = lib.func('int execvp(const char *file, const char **argv)'); +const fcntl = lib.func('int fcntl(int fd, int command, ...)'); +const args = process.argv.slice(2); +if (args.length === 0 || fchdir(3) !== 0 || close(3) !== 0) process.exit(125); +// Node marks its standard streams close-on-exec during startup. Preserve only +// the three conventional streams; every internal descriptor stays closed. +for (const fd of [0, 1, 2]) { + if (fcntl(fd, 2 /* F_SETFD */, 'int', 0) !== 0) process.exit(125); +} +execvp(args[0], [...args, null]); +process.exit(126); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 4837dd56..65757d39 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,7 +1,6 @@ -import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; +import { link, lstat, open, realpath, rename, stat, unlink, spawn, withWorkspaceRoot, WorkspaceRootAccessError } from './root-access.js'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; @@ -1439,8 +1438,8 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { async instructionDescriptors() { if (!this.repositoryInstructions) return undefined; - const entries = await Promise.all([...this.roots].map(async ([id, { root }]) => { - const snapshot = await readRepositoryInstructions(root); + const entries = await Promise.all([...this.roots].map(async ([id, { root, identity }]) => { + const snapshot = await withWorkspaceRoot(root, identity, () => readRepositoryInstructions(root)); return [id, snapshot ? [snapshot.descriptor] : []] as const; })); return new Map(entries); @@ -1513,6 +1512,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { try { canonicalRoot = await realpath(workspace.root); if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); + await withWorkspaceRoot(canonicalRoot, workspace.identity, async () => undefined); if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); } catch { throw new WorkspaceToolError( @@ -1542,6 +1542,20 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { async execute( request: WorkspaceToolRequest, signal?: AbortSignal, + ): Promise { + const workspace = this.roots.get(request?.workspaceId); + if (!workspace?.identity) return this.executeBound(request, signal); + try { + return await withWorkspaceRoot(workspace.root, workspace.identity, () => this.executeBound(request, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceToolRequest, + signal?: AbortSignal, ): Promise { if (signal?.aborted) { throw new WorkspaceToolError( @@ -1560,9 +1574,6 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { throw new WorkspaceToolError('Unknown workspace', 'INVALID_REQUEST'); } const { root } = workspace; - if (workspace.identity && !await matchesWorkspaceRoot(root, workspace.identity)) { - throw new WorkspaceToolError('Selected project changed after admission', 'REGISTRATION_INVALID'); - } if ( request.operation === 'write_file' || From 6ca0593bf66ff0c3390bd6b8809b7a053c98b5fa Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 09:29:22 -0400 Subject: [PATCH 08/10] test: Cover Selected Project PTC and Load Native Fixtures Before Platform Simulation --- packages/code/src/cli-slots.test.ts | 3 ++- packages/code/src/native-programmatic-live.test.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/code/src/cli-slots.test.ts b/packages/code/src/cli-slots.test.ts index 484f0dc0..bd119846 100644 --- a/packages/code/src/cli-slots.test.ts +++ b/packages/code/src/cli-slots.test.ts @@ -120,7 +120,8 @@ test('CLI preserves distinct case-sensitive roots on a non-Linux platform', asyn [ '--input-type=module', '-e', - `Object.defineProperty(process, 'platform', {value:'darwin'}); process.argv=[process.execPath,...${JSON.stringify(argv)}]; await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)});`, + // Load native code for the real host before simulating only CLI platform policy. + `await import('koffi'); Object.defineProperty(process, 'platform', {value:'darwin'}); process.argv=[process.execPath,...${JSON.stringify(argv)}]; await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)});`, ], { encoding: 'utf8', diff --git a/packages/code/src/native-programmatic-live.test.ts b/packages/code/src/native-programmatic-live.test.ts index 2bc22356..92cab6ea 100644 --- a/packages/code/src/native-programmatic-live.test.ts +++ b/packages/code/src/native-programmatic-live.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -8,17 +8,20 @@ import type { AddressInfo } from 'node:net'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { resolveNativeSrtCommandPolicy } from './native-policy.js'; -test('real SRT prevents speculative network effects under trusted-vm', { +for (const selected of [false, true]) { +test(`real SRT prevents speculative network effects under trusted-vm${selected ? ' for a selected project' : ''}`, { skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', timeout: 30_000, }, async () => { - const root = await mkdtemp(join(tmpdir(), 'native-ptc-effects-')); + const root = await realpath(await mkdtemp(join(tmpdir(), 'native-ptc-effects-'))); + const identity = await stat(root, { bigint: true }); let effects = 0; const server = createServer((_req, res) => { effects += 1; res.end('ok'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const port = (server.address() as AddressInfo).port; const executor = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: root, + ...(selected ? { workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) } } : {}), commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'), programmaticFileUpstream: `http://127.0.0.1:${port}`, }); @@ -39,3 +42,4 @@ test('real SRT prevents speculative network effects under trusted-vm', { } } }); +} From 4c24751ce1ad9bbae7e447bd15aeb3c1a867dc93 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 09:34:33 -0400 Subject: [PATCH 09/10] fix: Keep Native Root Bindings Worker-Local and Verify Directory Ancestry --- packages/code/src/cli-slots.test.ts | 3 +- packages/code/src/root-access.test.ts | 50 +++++++++++++++ packages/code/src/root-access.ts | 90 ++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/code/src/cli-slots.test.ts b/packages/code/src/cli-slots.test.ts index bd119846..484f0dc0 100644 --- a/packages/code/src/cli-slots.test.ts +++ b/packages/code/src/cli-slots.test.ts @@ -120,8 +120,7 @@ test('CLI preserves distinct case-sensitive roots on a non-Linux platform', asyn [ '--input-type=module', '-e', - // Load native code for the real host before simulating only CLI platform policy. - `await import('koffi'); Object.defineProperty(process, 'platform', {value:'darwin'}); process.argv=[process.execPath,...${JSON.stringify(argv)}]; await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)});`, + `Object.defineProperty(process, 'platform', {value:'darwin'}); process.argv=[process.execPath,...${JSON.stringify(argv)}]; await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)});`, ], { encoding: 'utf8', diff --git a/packages/code/src/root-access.test.ts b/packages/code/src/root-access.test.ts index 3e555f76..a543ba3f 100644 --- a/packages/code/src/root-access.test.ts +++ b/packages/code/src/root-access.test.ts @@ -19,6 +19,56 @@ import { import { LocalWorkspaceTools } from './workspace.js'; import type { WorkspaceToolRequest } from './protocol.js'; +test('held roots allow internal directory links and reject external ancestors', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-links-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'root'); + await fs.mkdir(join(root, 'nested'), { recursive: true }); + await fs.mkdir(join(directory, 'outside')); + await fs.writeFile(join(root, 'nested', 'value'), 'inside'); + await fs.symlink('nested', join(root, 'inside')); + await fs.symlink('../outside', join(root, 'outside')); + const identity = await fs.stat(root, { bigint: true }); + const originalOpen = WorkspaceRootAccess.open; + let held: WorkspaceRootAccess | undefined; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + held = await originalOpen(...args); + return held; + }, + ); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + assert.equal( + (await lstat(join(root, 'inside'))).isSymbolicLink(), + true, + ); + const reader = await open(join(root, 'inside', 'value'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'inside'); + } finally { + await reader.close(); + } + await assert.rejects( + open( + join(root, 'outside', 'escape'), + constants.O_CREAT | constants.O_WRONLY, + 0o600, + ), + { code: 'EACCES' }, + ); + }, + ); + assert.equal(held?.handle.fd, -1); + assert.deepEqual(await fs.readdir(join(directory, 'outside')), []); +}); + test('held root file operations cannot be redirected by replacing its pathname', async () => { const directory = await fs.realpath( await fs.mkdtemp(join(tmpdir(), 'root-access-')), diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts index 4420a455..6c73cc21 100644 --- a/packages/code/src/root-access.ts +++ b/packages/code/src/root-access.ts @@ -11,17 +11,39 @@ import { sep, } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import type { SpawnOptionsWithoutStdio, ChildProcessWithoutNullStreams, } from 'node:child_process'; -import koffi from 'koffi'; import type { WorkspaceRootIdentity } from './root-identity.js'; -const lib = ['darwin', 'linux'].includes(process.platform) - ? koffi.load(null) - : undefined; -const nativeOpenAt = lib?.func( +type NativeCall = (...args: (string | number | Buffer)[]) => number; +interface NativeLibrary { + func(signature: string): NativeCall; +} +interface NativeRuntime { + load(path: null): NativeLibrary; + errno(): number; + os: { errno: Record }; +} +let nativeRuntime: NativeRuntime | undefined; +let library: NativeLibrary | undefined; +function runtime(): NativeRuntime { + // Code API imports workspace contracts without installing native worker + // dependencies. Load the POSIX implementation only for selected roots. + return (nativeRuntime ??= createRequire(import.meta.url)('koffi') as NativeRuntime); +} +function bind(signature: string): NativeCall | undefined { + if (!['darwin', 'linux'].includes(process.platform)) return undefined; + let call: NativeCall | undefined; + return (...args) => { + library ??= runtime().load(null); + call ??= library.func(signature); + return call(...args); + }; +} +const nativeOpenAt = bind( 'int openat(int dirfd, const char *path, int flags, ...)', ); const O_CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000; @@ -29,24 +51,22 @@ const openAt = nativeOpenAt ? (fd: number, path: string, flags: number, mode: number): number => nativeOpenAt(fd, path, flags | O_CLOEXEC, 'unsigned int', mode) : undefined; -const renameAt = lib?.func( +const renameAt = bind( 'int renameat(int fromfd, const char *from, int tofd, const char *to)', ); -const linkAt = lib?.func( +const linkAt = bind( 'int linkat(int fromfd, const char *from, int tofd, const char *to, int flags)', ); -const unlinkAt = lib?.func( - 'int unlinkat(int dirfd, const char *path, int flags)', -); +const unlinkAt = bind('int unlinkat(int dirfd, const char *path, int flags)'); const getPath = process.platform === 'darwin' - ? lib!.func('int fcntl(int fd, int command, ...)') + ? bind('int fcntl(int fd, int command, ...)') : undefined; function nativeError(): NodeJS.ErrnoException { - const errno = koffi.errno(); + const errno = runtime().errno(); const code = - Object.entries(koffi.os.errno).find( + Object.entries(runtime().os.errno).find( ([, value]) => value === errno, )?.[0] ?? 'EIO'; return Object.assign( @@ -144,6 +164,7 @@ export class WorkspaceRootAccess { ), ); try { + this.assertDirectoryAncestor(fd); this.canonical(fd); return fd; } catch (error) { @@ -159,6 +180,46 @@ export class WorkspaceRootAccess { ); } + /** Paths are presentation, not proof of ancestry: a renamed root can make + * an outside symlink target temporarily occupy its old textual prefix. */ + private assertDirectoryAncestor(fd: number): void { + const root = fstatSync(this.handle.fd, { bigint: true }); + let current = fd; + try { + for (let depth = 0; depth <= 128; depth++) { + const identity = fstatSync(current, { bigint: true }); + if (identity.dev === root.dev && identity.ino === root.ino) + return; + const parent = checked( + openAt!( + current, + '..', + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW, + 0, + ), + ); + if (current !== fd) closeSync(current); + current = parent; + const ancestor = fstatSync(parent, { bigint: true }); + if ( + ancestor.dev === identity.dev && + ancestor.ino === identity.ino + ) + break; + } + throw Object.assign( + new Error( + 'Directory is outside the held workspace or exceeds its ancestry limit', + ), + { code: 'EACCES' }, + ); + } finally { + if (current !== fd) closeSync(current); + } + } + private parent(path: string): { fd: number; name: string } { const local = offset(this.path, path); const fd = checked( @@ -170,6 +231,7 @@ export class WorkspaceRootAccess { ), ); try { + this.assertDirectoryAncestor(fd); this.canonical(fd); return { fd, name: basename(local) }; } catch (error) { @@ -219,7 +281,7 @@ export class WorkspaceRootAccess { ? 0x200000 /* O_PATH */ | (follow ? 0 : constants.O_NOFOLLOW) : 0x8000 /* O_EVTONLY */ | - (follow ? 0 : 0x200000) /* O_SYMLINK */; + (follow ? 0 : 0x200000); /* O_SYMLINK */ fd = checked(openAt!(parent.fd, parent.name, flags, 0)); return fstatSync(fd); } finally { From 2b7629dca4c3424b9cad4c98bdcd8a851ff33108 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 16 Sep 2026 09:55:54 -0400 Subject: [PATCH 10/10] fix: Anchor Project Admission and Preserve Search Permissions --- packages/code/src/project-roots.test.ts | 109 ++++++++++++++++----- packages/code/src/project-roots.ts | 120 +++++++++++++++++------- packages/code/src/root-access.test.ts | 53 +++++++++++ packages/code/src/root-access.ts | 69 +++++++++----- 4 files changed, 270 insertions(+), 81 deletions(-) diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts index 991afeb3..f88e523f 100644 --- a/packages/code/src/project-roots.test.ts +++ b/packages/code/src/project-roots.test.ts @@ -21,8 +21,69 @@ import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { LocalWorkspaceTools } from './workspace.js'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import type { BridgeWorkerCapabilities } from './protocol.js'; +import { WorkspaceRootAccess } from './root-access.js'; const exec = promisify(execFile); +test('admission validates the captured root even while a replacement checkout occupies its path', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await writeFile( + join(selected, '.git/commondir'), + '../../nested/api/.git\n', + ); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/, + ); + assert.equal( + await readFile(join(selected, '.git/commondir'), 'utf8'), + '../../nested/api/.git\n', + ); +}); + +test('admission cannot borrow a replacement checkout Git validity', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await rm(join(selected, '.git/HEAD')); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /standalone Git checkout/, + ); +}); test( 'real worker CLI registers only explicitly selected project roots', { timeout: 10000 }, @@ -39,7 +100,7 @@ test( response.setHeader('Content-Type', 'application/json'); if (request.url?.endsWith('/bridge/workers/register')) { const body = JSON.parse( - Buffer.concat(chunks).toString() + Buffer.concat(chunks).toString(), ) as { workerId: string; incarnationId: string; @@ -53,19 +114,19 @@ test( incarnationId: body.incarnationId, registeredAt: new Date().toISOString(), leaseTtlMs: 60000, - }) + }), ); } else response.end( JSON.stringify({ protocolVersion: 1, serverElapsedMs: 0, - }) + }), ); }); }); await new Promise(resolve => - server.listen(0, '127.0.0.1', resolve) + server.listen(0, '127.0.0.1', resolve), ); t.after(() => { server.closeAllConnections(); @@ -93,7 +154,7 @@ test( LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', }, stdio: 'ignore', - } + }, ); t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); @@ -103,17 +164,17 @@ test( await once(child, 'exit'); assert.deepEqual( capabilities.workspaceTools?.workspaces?.map( - workspace => workspace.name + workspace => workspace.name, ), - ['app', 'nested/api'] + ['app', 'nested/api'], ); assert.ok( capabilities.workspaceTools?.workspaces?.every(workspace => - workspace.id.startsWith('project-') - ) + workspace.id.startsWith('project-'), + ), ); assert.equal(JSON.stringify(capabilities).includes(root), false); - } + }, ); async function fixture(t: TestContext) { @@ -131,10 +192,10 @@ test('selected projects retain identity across order and branch changes without const selected = await loadProjectRoots(root, ['app', 'nested/api']); assert.deepEqual( selected.map(project => project.name), - ['app', 'nested/api'] + ['app', 'nested/api'], ); assert.ok( - selected.every(project => project.root !== root && !project.writable) + selected.every(project => project.root !== root && !project.writable), ); await exec('git', [ '-C', @@ -149,7 +210,7 @@ test('selected projects retain identity across order and branch changes without const otherRoot = await fixture(t); assert.notEqual( (await loadProjectRoots(otherRoot, ['app']))[0].id, - selected[0].id + selected[0].id, ); }); @@ -168,7 +229,7 @@ test('real file operations use the selected project boundary', async t => { }); assert.equal( await readFile(join(root, 'nested/api/created.txt'), 'utf8'), - 'second project' + 'second project', ); await assert.rejects( tools.execute({ @@ -176,7 +237,7 @@ test('real file operations use the selected project boundary', async t => { operation: 'read_file', workspaceId: selected[0].id, path: '../nested/api/created.txt', - }) + }), ); await assert.rejects( tools.execute({ @@ -184,7 +245,7 @@ test('real file operations use the selected project boundary', async t => { operation: 'read_file', workspaceId: 'primary', path: 'nested/api/created.txt', - }) + }), ); await assert.rejects(readFile(join(root, 'app/created.txt'))); }); @@ -194,7 +255,7 @@ test('rejects missing, escaped, aliased, duplicate and linked-worktree selection await mkdir(join(root, 'linked')); await writeFile( join(root, 'linked/.git'), - 'gitdir: ../app/.git/worktrees/linked\n' + 'gitdir: ../app/.git/worktrees/linked\n', ); await symlink(join(root, 'app'), join(root, 'alias'), 'dir'); for (const projects of [ @@ -211,7 +272,7 @@ test('rejects missing, escaped, aliased, duplicate and linked-worktree selection } await assert.rejects( loadProjectRoots(root, ['.']), - /standalone Git checkout/ + /standalone Git checkout/, ); }); @@ -225,7 +286,7 @@ test('project CLI arguments require explicit bounded selections', () => { 'app', '--project=nested/api', ]), - { root: '/srv/projects', projects: ['app', 'nested/api'] } + { root: '/srv/projects', projects: ['app', 'nested/api'] }, ); for (const args of [ ['--project-root'], @@ -242,11 +303,11 @@ test('rejects a directory-form git marker redirecting to shared metadata', async const root = await fixture(t); await writeFile( join(root, 'app/.git/commondir'), - '../../nested/api/.git\n' + '../../nested/api/.git\n', ); await assert.rejects( loadProjectRoots(root, ['app']), - /Git common directory/ + /Git common directory/, ); }); @@ -261,7 +322,7 @@ test('replacement after selection cannot become a file or native execution root' await symlink(join(outside, 'app'), selected.root, 'dir'); await assert.rejects( LocalWorkspaceTools.create({ workspaces: [selected] }), - /Invalid workspace registration/ + /Invalid workspace registration/, ); await assert.rejects( tools.execute({ @@ -271,7 +332,7 @@ test('replacement after selection cannot become a file or native execution root' path: 'escaped.txt', content: 'blocked', }), - /changed after admission/ + /changed after admission/, ); const executor = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: selected.root, @@ -314,7 +375,7 @@ test('CLI rejects mixed registration and overlapping selected projects before co LIBRECHAT_CODE_WORKER_ID: 'test-worker', LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', }, - } + }, ); assert.notEqual(result.status, 0); assert.match(result.stderr, /cannot be combined|must not overlap/); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts index 08ab74e0..06e910ec 100644 --- a/packages/code/src/project-roots.ts +++ b/packages/code/src/project-roots.ts @@ -1,14 +1,93 @@ import { createHash } from 'node:crypto'; import { lstat, realpath } from 'node:fs/promises'; import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; -import { discoverProjects } from './projects.js'; +import { + lstat as rootedLstat, + spawn, + withWorkspaceRoot, +} from './root-access.js'; import { matchesWorkspaceRoot } from './root-identity.js'; import type { LocalWorkspaceConfig } from './workspace.js'; +/** Validate the selected checkout itself, never rediscover it via its pathname. */ +async function validateCheckout(root: string): Promise { + const marker = await rootedLstat(resolve(root, '.git')).catch( + () => undefined, + ); + if (!marker?.isDirectory() || marker.isSymbolicLink()) + throw new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ); + const common = await rootedLstat(resolve(root, '.git', 'commondir')).catch( + error => { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error; + return undefined; + }, + ); + if (common) + throw new Error( + 'Selected projects must not share a Git common directory', + ); + await new Promise((accept, reject) => { + const child = spawn( + 'git', + [ + '--no-optional-locks', + '--git-dir=.git', + '--work-tree=.', + '-c', + 'core.fsmonitor=false', + 'rev-parse', + '--is-inside-work-tree', + ], + { + cwd: root, + env: { + PATH: process.env.PATH, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + LC_ALL: 'C', + }, + }, + ); + let output = ''; + let exceeded = false; + const timer = setTimeout(() => { + exceeded = true; + child.kill('SIGKILL'); + }, 1500); + child.stdout.on('data', (chunk: Buffer) => { + if (output.length + chunk.length > 4096) { + exceeded = true; + child.kill('SIGKILL'); + } else output += chunk.toString(); + }); + child.stderr.resume(); + child.stdin.end(); + child.once('error', reject); + child.once('close', code => { + clearTimeout(timer); + if (!exceeded && code === 0 && output.trim() === 'true') accept(); + else + reject( + new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ), + ); + }); + }); +} + /** Explicit operator selections, not an automatically expanding execution grant. */ export async function loadProjectRoots( directory: string, - selections: string[] + selections: string[], ): Promise { if (!selections.length || selections.length > 32) throw new Error('Choose between 1 and 32 projects'); @@ -26,43 +105,20 @@ export async function loadProjectRoots( const directoryIdentity = await lstat(path, { bigint: true }); if (canonical !== path || !directoryIdentity.isDirectory()) throw new Error( - 'Selected projects must be directories without symlink traversal' + 'Selected projects must be directories without symlink traversal', ); if (paths.has(canonical)) throw new Error('Duplicate project selection'); paths.add(canonical); - const commonDirectory = await lstat( - resolve(canonical, '.git', 'commondir') - ).catch(error => { - if ( - !(error instanceof Error) || - !('code' in error) || - (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') - ) - throw error; - return undefined; - }); - if (commonDirectory) - throw new Error( - 'Selected projects must not share a Git common directory' - ); - const inventory = await discoverProjects({ - root: canonical, - maxProjects: 1, - }); - if ( - inventory.truncated || - !inventory.projects.some(project => project.path === '.') - ) - throw new Error( - 'Select a standalone Git checkout, not a parent directory or linked worktree' - ); const portablePath = rel.split(sep).join('/') || '.'; const identity = { path: canonical, dev: directoryIdentity.dev.toString(), ino: directoryIdentity.ino.toString(), }; + await withWorkspaceRoot(canonical, identity, () => + validateCheckout(canonical), + ); if (!(await matchesWorkspaceRoot(canonical, identity))) throw new Error('Selected project changed during admission'); projects.push({ @@ -73,7 +129,7 @@ export async function loadProjectRoots( .slice(0, 32)}`, name: (portablePath === '.' ? basename(root) : portablePath).slice( 0, - 64 + 64, ), root: canonical, }); @@ -82,7 +138,7 @@ export async function loadProjectRoots( } export function projectRootArguments( - args: string[] + args: string[], ): { root: string; projects: string[] } | undefined { let root: string | undefined; const projects: string[] = []; @@ -105,7 +161,7 @@ export function projectRootArguments( if (root === undefined && !projects.length) return undefined; if (root === undefined || !projects.length) throw new Error( - '--project-root requires at least one --project relative/path' + '--project-root requires at least one --project relative/path', ); return { root, projects }; } diff --git a/packages/code/src/root-access.test.ts b/packages/code/src/root-access.test.ts index a543ba3f..bb757464 100644 --- a/packages/code/src/root-access.test.ts +++ b/packages/code/src/root-access.test.ts @@ -19,6 +19,59 @@ import { import { LocalWorkspaceTools } from './workspace.js'; import type { WorkspaceToolRequest } from './protocol.js'; +test('search-only directories support known files and command cwd without enumeration', async t => { + if (process.getuid?.() === 0) + return t.skip( + 'requires an unprivileged user to verify search permissions', + ); + const root = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-search-')), + ); + const nested = join(root, 'nested'); + await fs.mkdir(nested); + await fs.writeFile(join(nested, 'known'), 'known-value'); + const identity = await fs.stat(root, { bigint: true }); + t.after(async () => { + await fs.chmod(root, 0o700); + await fs.chmod(nested, 0o700); + await fs.rm(root, { recursive: true, force: true }); + }); + await fs.chmod(root, 0o111); + await fs.chmod(nested, 0o111); + await assert.rejects(fs.readdir(nested), { code: 'EACCES' }); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + const reader = await open(join(nested, 'known'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'known-value'); + } finally { + await reader.close(); + } + assert.equal((await stat(nested)).isDirectory(), true); + assert.equal(await realpath(nested), nested); + await new Promise((accept, reject) => { + const child = spawn('/bin/cat', ['known'], { cwd: nested }); + let output = ''; + child.stdout!.on('data', chunk => { + output += chunk.toString(); + }); + child.once('error', reject); + child.once('close', code => { + try { + assert.equal(code, 0); + assert.equal(output, 'known-value'); + accept(); + } catch (error) { + reject(error); + } + }); + }); + }, + ); +}); + test('held roots allow internal directory links and reject external ancestors', async t => { const directory = await fs.realpath( await fs.mkdtemp(join(tmpdir(), 'root-links-')), diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts index 6c73cc21..4aeb6100 100644 --- a/packages/code/src/root-access.ts +++ b/packages/code/src/root-access.ts @@ -32,7 +32,9 @@ let library: NativeLibrary | undefined; function runtime(): NativeRuntime { // Code API imports workspace contracts without installing native worker // dependencies. Load the POSIX implementation only for selected roots. - return (nativeRuntime ??= createRequire(import.meta.url)('koffi') as NativeRuntime); + return (nativeRuntime ??= createRequire(import.meta.url)( + 'koffi', + ) as NativeRuntime); } function bind(signature: string): NativeCall | undefined { if (!['darwin', 'linux'].includes(process.platform)) return undefined; @@ -47,6 +49,12 @@ const nativeOpenAt = bind( 'int openat(int dirfd, const char *path, int flags, ...)', ); const O_CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000; +// Anchors need search, not directory enumeration permission. +const DIRECTORY_ACCESS = + constants.O_DIRECTORY | + (process.platform === 'darwin' + ? 0x40000000 /* O_SEARCH */ + : 0x200000) /* O_PATH */; const openAt = nativeOpenAt ? (fd: number, path: string, flags: number, mode: number): number => nativeOpenAt(fd, path, flags | O_CLOEXEC, 'unsigned int', mode) @@ -121,12 +129,7 @@ export class WorkspaceRootAccess { 'Selected project root access is unavailable', ); const handle = await fs - .open( - path, - constants.O_RDONLY | - constants.O_DIRECTORY | - constants.O_NOFOLLOW, - ) + .open(path, DIRECTORY_ACCESS | constants.O_NOFOLLOW) .catch(() => { throw new WorkspaceRootAccessError( 'Selected project changed after admission', @@ -159,7 +162,7 @@ export class WorkspaceRootAccess { openAt!( this.handle.fd, offset(this.path, path), - constants.O_RDONLY | constants.O_DIRECTORY, + DIRECTORY_ACCESS, 0, ), ); @@ -194,9 +197,7 @@ export class WorkspaceRootAccess { openAt!( current, '..', - constants.O_RDONLY | - constants.O_DIRECTORY | - constants.O_NOFOLLOW, + DIRECTORY_ACCESS | constants.O_NOFOLLOW, 0, ), ); @@ -223,12 +224,7 @@ export class WorkspaceRootAccess { private parent(path: string): { fd: number; name: string } { const local = offset(this.path, path); const fd = checked( - openAt!( - this.handle.fd, - dirname(local), - constants.O_RDONLY | constants.O_DIRECTORY, - 0, - ), + openAt!(this.handle.fd, dirname(local), DIRECTORY_ACCESS, 0), ); try { this.assertDirectoryAncestor(fd); @@ -282,7 +278,7 @@ export class WorkspaceRootAccess { (follow ? 0 : constants.O_NOFOLLOW) : 0x8000 /* O_EVTONLY */ | (follow ? 0 : 0x200000); /* O_SYMLINK */ - fd = checked(openAt!(parent.fd, parent.name, flags, 0)); + fd = this.metadataDescriptor(parent.fd, parent.name, flags); return fstatSync(fd); } finally { if (fd !== undefined) closeSync(fd); @@ -290,17 +286,40 @@ export class WorkspaceRootAccess { } } + private metadataDescriptor( + parent: number, + name: string, + flags: number, + ): number { + if (process.platform === 'darwin') { + // O_EVTONLY still requests read permission on a directory. Search-only + // descriptors preserve known-path metadata/cwd access without enumeration. + const directory = openAt!( + parent, + name, + DIRECTORY_ACCESS | + (flags & 0x200000 /* O_SYMLINK */ + ? constants.O_NOFOLLOW + : 0), + 0, + ); + if (directory >= 0) return directory; + const error = nativeError(); + if (error.code !== 'ENOTDIR' && error.code !== 'ELOOP') throw error; + } + return checked(openAt!(parent, name, flags, 0)); + } + realpath(path: string): string { const parent = this.parent(path); let fd: number | undefined; try { - fd = checked( - openAt!( - parent.fd, - parent.name, - constants.O_RDONLY | constants.O_NONBLOCK, - 0, - ), + fd = this.metadataDescriptor( + parent.fd, + parent.name, + process.platform === 'linux' + ? 0x200000 /* O_PATH */ + : 0x8000 /* O_EVTONLY */, ); return this.canonical(fd); } finally {