diff --git a/packages/code/README.md b/packages/code/README.md index 47771057..47d3c3a0 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -1102,11 +1102,47 @@ This does not attach another checkout's `node_modules`, provision linked lanes o command admission, or recheck existing instances on every command. It does not deduplicate installed dependencies between worktrees or enforce disk quotas. -The next resource-store slice must explicitly grant shared cache paths under SRT, -keep monorepo links and mutable outputs checkout-local, and bound retention. Do not -work around that missing grant by broadening the sandbox root or symlinking another -branch's full installation. Shared download caches alone do not reduce installed -`node_modules` copies. +Shared tool cache grants below do not attach another checkout's installed dependency +tree. Keep monorepo links and mutable outputs checkout-local. Do not broaden the +sandbox root or symlink another branch's full installation. Shared download caches +alone do not reduce installed `node_modules` copies. + +## Shared tool and download resources + +Explicit operator-managed stores can live outside the checkouts: + +```yaml +resources: + - kind: npm-cache + path: /srv/lia-resources/npm + access: read-write + - kind: uv-cache + path: /srv/lia-resources/uv + access: read-write + - kind: playwright-browsers + path: /srv/lia-resources/playwright + access: read-only +``` + +Create each directory as the worker service account with mode `0700`, then populate +browser binaries using the matching Playwright version and `PLAYWRIGHT_BROWSERS_PATH` +outside the coding session. Roots must exist and may not be symlinks or overlap any +registered workspace or worker control state. Linux mount-alias checks cover both +directions. Keep the mount namespace stable while the worker runs. + +The worker grants only these paths and injects `npm_config_cache`, `UV_CACHE_DIR` +and `PLAYWRIGHT_BROWSERS_PATH` from the loaded definition, including in linked lanes +and fresh conversation worktrees. An undeclared process environment variable never +grants filesystem access. Each store root is inode-bound and revalidated before +commands. Read-only stores stay read-only, and speculative programmatic probes +cannot write any shared store. + +Sharing is explicit within one worker's trust domain. Do not share mutable caches +between unrelated principals, store credentials in them, or treat their contents +as trusted worker code. Separate package caches from browsers and mutable browser +profiles, Redis data, build output and test state. Stores currently have no automatic +eviction; the storage-lifecycle slice adds that separately. This shares downloads +and browser binaries, not installed `node_modules` trees. No setup output is sent to the model. Named actions are fixed commands without model-supplied substitution. The bridge diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 054074ed..04d94aaa 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -13,6 +13,7 @@ import { EnvironmentWorkspaceTools, } from './environment.js'; import { prepareCodeEnvironment } from './environment-preparation.js'; +import { assertEnvironmentResourceIsolation } from './environment-resources.js'; import { startFileRelay } from './relay.js'; import { DockerFileRelaySupervisor } from './relay-runtime.js'; import { @@ -837,6 +838,16 @@ async function run( definition: { name: 'preparation-state', root: preparationDirectory }, fingerprint: '', }], roots); } + // Cache contents may be shared explicitly; their grants must never authorize source or control state. + const resourceRoots = environments.flatMap(environment => (environment.resources ?? []).map(resource => ({ + path: resource.path, sourceParents: resource.controlPaths, + definition: { name: 'shared-resource', root: resource.path }, fingerprint: '', + }))); + await assertEnvironmentResourceIsolation(environments.flatMap(environment => environment.resources ?? []), roots.map(root => root.root), [ + identityPath, preparationDirectory, github.privateKeyPath, + ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), + ].filter((path): path is string => path != null)); + await assertEnvironmentDefinitionsOutsideRoots(resourceRoots, roots); const preparationReceipt = (root: string) => join(preparationDirectory, `${createHash('sha256').update(JSON.stringify([codeApiUrl, workerId, root])).digest('hex')}.json`); // Keep an admission boundary even when trusted-VM checkout routing uses a @@ -1110,6 +1121,10 @@ async function run( } : {}), }; + const nativeOptionsForWorkspace = (workspaceId: string): NativeProcessSandboxOptions => ({ + ...nativeOptions, + resources: environments.find(environment => environment.definition.name === workspaceId)?.resources, + }); const nativeCommandSandbox = allowWorkspaceCommands && commandSandboxMode === 'native-srt' ? roots.length > 1 || workspaceLeaseSlots > 1 || conversationWorktreeRoot || linkedWorktreeLanes @@ -1117,12 +1132,12 @@ async function run( new Map( roots.map(root => [ root.id, - { ...nativeOptions, workspaceRoot: root.root, workspaceIdentity: root.identity }, + { ...nativeOptionsForWorkspace(root.id), workspaceRoot: root.root, workspaceIdentity: root.identity }, ]), ), workspaceLeaseSlots, ) - : new NativeProcessWorkspaceCommandSandbox(nativeOptions) + : new NativeProcessWorkspaceCommandSandbox(nativeOptionsForWorkspace(roots[0].id)) : undefined; if (allowWorkspaceCommands && workspaceTools) { workspaceTools = new SandboxWorkspaceTools({ @@ -1161,14 +1176,15 @@ async function run( } const id = internalWorkspaceId(instance.sourceWorkspaceId, instance.id); await nativeCommandSandbox.registerRoot(id, { - ...nativeOptions, + ...nativeOptionsForWorkspace(instance.sourceWorkspaceId), workspaceIdentity: instance.identity, workspaceRoot: instance.root, }); await prepareCodeEnvironment({ root: instance.root, identity: instance.identity, setup, receiptPath: preparationReceipt(instance.root), - context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity]), + context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity, + nativeOptionsForWorkspace(instance.sourceWorkspaceId).resources]), signal, execute: (command, timeoutMs) => nativeCommandSandbox.execute({ protocolVersion: 1, @@ -1225,7 +1241,7 @@ async function run( root.id, { command: { - ...nativeOptions, + ...nativeOptionsForWorkspace(root.id), workspaceIdentity: root.identity, workspaceRoot: root.root, }, @@ -1263,7 +1279,7 @@ async function run( { root: root.root, identity: root.identity, - command: nativeOptions, + command: nativeOptionsForWorkspace(root.id), repositoryInstructions: args.includes('--repository-instructions'), writable: root.writable ?? false, }, @@ -1340,7 +1356,8 @@ async function run( root: environment.definition.root, identity: roots.find(root => root.id === id)!.identity!, setup, receiptPath: preparationReceipt(environment.definition.root), - context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity]), + context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity, + nativeOptionsForWorkspace(id).resources]), signal: controller.signal, execute: async (command, timeoutMs) => { if (!armed) { diff --git a/packages/code/src/environment-preparation.test.ts b/packages/code/src/environment-preparation.test.ts index 51149441..55186064 100644 --- a/packages/code/src/environment-preparation.test.ts +++ b/packages/code/src/environment-preparation.test.ts @@ -25,7 +25,7 @@ async function fixture(t: test.TestContext) { t.after(() => rm(directory, { recursive: true, force: true })); const root = join(directory, 'checkout'); const state = join(directory, 'private'); - await mkdir(root); + await mkdir(root, { mode: 0o700 }); await prepareEnvironmentPreparationDirectory(state); await writeFile(join(root, 'package-lock.json'), 'lock-v1'); const commands: string[] = []; @@ -316,9 +316,9 @@ test( }); t.after(() => sandbox.close()); await sandbox.prepare(); - const execute: EnvironmentPreparationOptions['execute'] = ( - command, - timeoutMs, + const execute = ( + command: string, + timeoutMs: number, ) => sandbox.execute({ protocolVersion: 1, @@ -344,15 +344,13 @@ test( }; assert.equal(await prepareCodeEnvironment(configured), 'prepared'); assert.equal(await prepareCodeEnvironment(configured), 'reused'); - assert.notEqual( - (await execute(`cat '${options.receiptPath}'`, 5000)).exitCode, - 0, - ); - assert.notEqual( - (await execute(`printf forged > '${options.receiptPath}'`, 5000)) - .exitCode, - 0, - ); + const receipt = await readFile(options.receiptPath, 'utf8'); + const protectedRead = await execute(`cat '${options.receiptPath}'`, 5000); + // Linux SRT may mask denied reads with an empty file. Exit status alone + // is not evidence that the protected receipt was exposed. + assert.ok(!protectedRead.stdout?.includes(receipt)); + await execute(`printf forged > '${options.receiptPath}'`, 5000); + assert.equal(await readFile(options.receiptPath, 'utf8'), receipt); assert.equal( await readFile(join(options.root, 'installs.log'), 'utf8'), 'install\n', diff --git a/packages/code/src/environment-resources.test.ts b/packages/code/src/environment-resources.test.ts new file mode 100644 index 00000000..32d1d390 --- /dev/null +++ b/packages/code/src/environment-resources.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + parseCodeEnvironment, + loadCodeEnvironment, + assertEnvironmentDefinitionsOutsideRoots, +} from './environment.js'; +import { + parseEnvironmentResources, + loadEnvironmentResource, + assertEnvironmentResourcesStable, +} from './environment-resources.js'; +import { captureWorkspaceRootIdentity } from './root-identity.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; + +test('shared resources are explicit bounded known cache kinds, not arbitrary environment or filesystem grants', () => { + assert.equal( + parseCodeEnvironment( + 'name: app\nroot: app\nresources:\n - kind: npm-cache\n path: /cache/npm\n access: read-write\n', + ).resources?.[0].kind, + 'npm-cache', + ); + for (const value of [ + [], + [{ kind: 'secrets', path: '/cache', access: 'read-write' }], + [{ kind: 'npm-cache', path: '../cache', access: 'read-write' }], + [{ kind: 'uv-cache', path: '/cache', access: 'all' }], + [ + { + kind: 'uv-cache', + path: '/cache', + access: 'read-only', + env: 'GH_TOKEN', + }, + ], + [1], + [ + { kind: 'uv-cache', path: '/cache', access: 'read-only' }, + { kind: 'uv-cache', path: '/other', access: 'read-only' }, + ], + ]) + assert.throws(() => parseEnvironmentResources(value)); +}); + +test('resource roots must be stable private directories and outside source grants', async t => { + const directory = await mkdtemp(join(tmpdir(), 'code-resources-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'root'); + const cache = join(directory, 'cache'); + await mkdir(root, { mode: 0o700 }); + await mkdir(cache, { mode: 0o700 }); + const resource = { + kind: 'npm-cache' as const, + path: cache, + access: 'read-write' as const, + }; + const loaded = await loadEnvironmentResource(resource); + await assertEnvironmentResourcesStable([loaded]); + await chmod(cache, 0o755); + await assert.rejects(loadEnvironmentResource(resource), /owner-only/); + await chmod(cache, 0o700); + await symlink(cache, join(directory, 'alias')); + await assert.rejects( + loadEnvironmentResource({ + ...resource, + path: join(directory, 'alias'), + }), + /symlink/, + ); + await rename(cache, join(directory, 'old')); + await mkdir(cache, { mode: 0o700 }); + await assert.rejects(assertEnvironmentResourcesStable([loaded]), /changed/); + const file = join(directory, 'app.yaml'); + await writeFile( + file, + `name: app\nroot: ${root}\nresources:\n - kind: npm-cache\n path: ${cache}\n access: read-write\n`, + { mode: 0o600 }, + ); + const environment = await loadCodeEnvironment(file); + const [cacheRoot] = environment.resources!; + await assert.rejects( + assertEnvironmentDefinitionsOutsideRoots( + [ + { + path: cacheRoot.path, + sourceParents: cacheRoot.controlPaths, + fingerprint: '', + definition: { name: 'cache', root: cacheRoot.path }, + }, + ], + [{ id: 'root', root: directory }], + ), + /workspace|root|definition/i, + ); +}); + +test( + 'real native SRT shares only declared stores across independent checkouts and refuses readonly writes', + { + skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', + timeout: 30_000, + }, + async t => { + const directory = await mkdtemp(join(tmpdir(), 'code-resources-live-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const cache = join(directory, 'cache'); + const browser = join(directory, 'browsers'); + await mkdir(cache, { mode: 0o700 }); + await mkdir(browser, { mode: 0o700 }); + await writeFile(join(browser, 'version'), 'pinned-browser'); + const resources = await Promise.all([ + loadEnvironmentResource({ + kind: 'npm-cache', + path: cache, + access: 'read-write', + }), + loadEnvironmentResource({ + kind: 'playwright-browsers', + path: browser, + access: 'read-only', + }), + ]); + const sandboxes = await Promise.all( + ['a', 'b'].map(async name => { + const root = join(directory, name); + await mkdir(root); + const identity = await captureWorkspaceRootIdentity(root); + await writeFile(join(root, 'private'), 'other-checkout'); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: identity.path, + workspaceIdentity: identity, + homeDirectory: directory, + resources, + }); + await sandbox.prepare(); + return sandbox; + }), + ); + t.after(async () => { + for (const sandbox of sandboxes) await sandbox.close(); + }); + const execute = (index: number, command: string) => + sandboxes[index].execute({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command, + timeoutMs: 5000, + }); + const results = await Promise.all( + sandboxes.map((_, i) => + execute( + i, + 'printf "%s\\n" "$npm_config_cache"; printf once >> "$npm_config_cache/marker"; cat "$PLAYWRIGHT_BROWSERS_PATH/version"', + ), + ), + ); + for (const result of results) { + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /pinned-browser/); + } + assert.equal(await readFile(join(cache, 'marker'), 'utf8'), 'onceonce'); + assert.notEqual( + ( + await execute( + 0, + 'printf forbidden > "$PLAYWRIGHT_BROWSERS_PATH/version"', + ) + ).exitCode, + 0, + ); + assert.equal( + await readFile(join(browser, 'version'), 'utf8'), + 'pinned-browser', + ); + assert.notEqual( + (await execute(0, `cat '${join(directory, 'b', 'private')}'`)) + .exitCode, + 0, + ); + await rename(cache, join(directory, 'old-cache')); + await mkdir(cache, { mode: 0o700 }); + await assert.rejects( + execute(0, 'printf not-started > side-effect'), + error => { + assert.equal( + (error as { mutationMayHaveCommitted: boolean }) + .mutationMayHaveCommitted, + false, + ); + return /resource changed/.test((error as Error).message); + }, + ); + await assert.rejects(readFile(join(directory, 'a', 'side-effect')), { + code: 'ENOENT', + }); + }, +); diff --git a/packages/code/src/environment-resources.ts b/packages/code/src/environment-resources.ts new file mode 100644 index 00000000..1bb57440 --- /dev/null +++ b/packages/code/src/environment-resources.ts @@ -0,0 +1,164 @@ +import { lstat, open, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { isAbsolute, relative, sep } from 'node:path'; +import { + assertPrivateStorageAcl, + assertPrivateStorageAncestors, +} from './private-storage.js'; +import { + captureWorkspaceRootIdentity, + matchesWorkspaceRoot, +} from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; +import { + createEnvironmentMountIsolation, + readEnvironmentMountTable, +} from './environment-mount.js'; + +export type EnvironmentResourceKind = + | 'npm-cache' + | 'uv-cache' + | 'playwright-browsers'; +export interface EnvironmentResource { + kind: EnvironmentResourceKind; + path: string; + access: 'read-only' | 'read-write'; +} +export interface LoadedEnvironmentResource extends EnvironmentResource { + identity: WorkspaceRootIdentity; + controlPaths: string[]; +} +export const RESOURCE_ENVIRONMENT_NAMES: Record< + EnvironmentResourceKind, + string +> = { + 'npm-cache': 'npm_config_cache', + 'uv-cache': 'UV_CACHE_DIR', + 'playwright-browsers': 'PLAYWRIGHT_BROWSERS_PATH', +}; + +export function parseEnvironmentResources( + value: unknown, +): EnvironmentResource[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 3) + throw new Error('Invalid environment resources'); + const kinds = new Set(); + return value.map(raw => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + throw new Error('Invalid environment resource'); + const entry = raw as Record; + if ( + Object.keys(entry).some( + key => !['kind', 'path', 'access'].includes(key), + ) || + typeof entry.kind !== 'string' || + !Object.hasOwn(RESOURCE_ENVIRONMENT_NAMES, entry.kind) || + kinds.has(entry.kind) || + typeof entry.path !== 'string' || + !isAbsolute(entry.path) || + entry.path.length > 4096 || + /[\0\r\n]/.test(entry.path) || + (entry.access !== 'read-only' && entry.access !== 'read-write') + ) + throw new Error('Invalid environment resource'); + kinds.add(entry.kind); + return { + kind: entry.kind as EnvironmentResourceKind, + path: entry.path, + access: entry.access, + }; + }); +} + +/** Operator-owned roots only. Resource contents are still untrusted tool data. */ +export async function loadEnvironmentResource( + resource: EnvironmentResource, +): Promise { + const controlPaths = await assertPrivateStorageAncestors(resource.path); + if ((await lstat(resource.path)).isSymbolicLink()) + throw new Error('Environment resource root cannot be a symlink'); + const path = await realpath(resource.path); + const identity = await captureWorkspaceRootIdentity(path); + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const metadata = await handle.stat({ bigint: true }); + if ( + (metadata.mode & 0o077n) !== 0n || + metadata.dev.toString() !== identity.dev || + metadata.ino.toString() !== identity.ino + ) + throw new Error( + 'Environment resource directory must be stable and owner-only', + ); + await assertPrivateStorageAcl(handle, path, true); + } finally { + await handle.close(); + } + return { ...resource, path, identity, controlPaths }; +} + +export async function assertEnvironmentResourcesStable( + resources: readonly LoadedEnvironmentResource[], +): Promise { + for (const resource of resources) { + if (!(await matchesWorkspaceRoot(resource.path, resource.identity))) + throw new Error( + 'Environment resource root changed after admission', + ); + } +} + +export function environmentResourceVariables( + resources: readonly EnvironmentResource[], +): Record { + return Object.fromEntries( + resources.map(resource => [ + RESOURCE_ENVIRONMENT_NAMES[resource.kind], + resource.path, + ]), + ); +} + +export function assertEnvironmentResourceSeparation( + resources: readonly EnvironmentResource[], + roots: readonly string[], +): void { + const contains = (parent: string, child: string) => { + const path = relative(parent, child); + return ( + path === '' || + (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`)) + ); + }; + for (const resource of resources) { + if ( + roots.some( + root => + contains(root, resource.path) || + contains(resource.path, root), + ) + ) + throw new Error( + 'Environment resources must not overlap any registered workspace', + ); + } +} + +export async function assertEnvironmentResourceIsolation( + resources: readonly EnvironmentResource[], + roots: readonly string[], + controls: readonly string[], +): Promise { + if (!resources.length) return; + assertEnvironmentResourceSeparation(resources, [...roots, ...controls]); + const table = await readEnvironmentMountTable(); + if (table !== undefined) { + const check = createEnvironmentMountIsolation(table); + const stores = resources.map(resource => resource.path); + check(stores, roots); + check([...roots, ...controls], stores); + } +} diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index 406e3850..976ffe6d 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -3,6 +3,8 @@ import { constants } from 'node:fs'; import { open, realpath, stat } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { parseDocument } from 'yaml'; +import { parseEnvironmentResources, loadEnvironmentResource } from './environment-resources.js'; +import type { EnvironmentResource, LoadedEnvironmentResource } from './environment-resources.js'; import { assertPrivateStorageAcl, assertPrivateStorageAncestors, @@ -33,6 +35,7 @@ export interface CodeEnvironmentDefinition { reuse?: { inputs: string[]; checkCommand: string; checkTimeoutMs: number }; }; actions?: { name: string; command: string; timeoutMs: number }[]; + resources?: EnvironmentResource[]; } export interface LoadedCodeEnvironment { @@ -41,6 +44,7 @@ export interface LoadedCodeEnvironment { rootPaths?: string[]; definition: CodeEnvironmentDefinition; fingerprint: string; + resources?: LoadedEnvironmentResource[]; } function record(value: unknown): value is Record { @@ -73,7 +77,7 @@ export function parseCodeEnvironment( !record(value) || Object.keys(value).some( key => - !['name', 'root', 'repo', 'ref', 'setup', 'actions'].includes( + !['name', 'root', 'repo', 'ref', 'setup', 'actions', 'resources'].includes( key, ), ) || @@ -173,6 +177,7 @@ export function parseCodeEnvironment( ...(typeof value.ref === 'string' ? { ref: value.ref } : {}), ...(setup ? { setup } : {}), ...(actions ? { actions } : {}), + ...(value.resources !== undefined ? { resources: parseEnvironmentResources(value.resources) } : {}), }; } @@ -328,6 +333,7 @@ export async function loadCodeEnvironment( sourceParents, rootPaths, definition, + ...(definition.resources ? { resources: await Promise.all(definition.resources.map(loadEnvironmentResource)) } : {}), fingerprint: createHash('sha256') .update(JSON.stringify(definition)) .digest('hex'), diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 77fe8269..588f4c33 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -344,6 +344,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan shellPath, programmaticFileUpstream, linkedWorktree, + resources, } = this.options; await this.rpc( 'prepare', @@ -358,6 +359,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan shellPath, programmaticFileUpstream, linkedWorktree, + resources, variables: this.options.maskedEnvironment?.variables, }, }, diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 26c848e5..2701fe44 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -15,6 +15,7 @@ import type { Dirent, Stats } from 'node:fs'; import { access, mkdtemp, open, readdir, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { matchesWorkspaceRoot } from './root-identity.js'; +import { assertEnvironmentResourcesStable, environmentResourceVariables } from './environment-resources.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; import { withWorkspaceRoot, WorkspaceRootAccessError, spawnWithinWorkspace, realpath as rootedRealpath, stat as rootedStat } from './root-access.js'; @@ -168,6 +169,8 @@ type SpawnCommand = ( ) => ChildProcessWithoutNullStreams; export interface NativeSrtWorkspaceCommandSandboxOptions { + /** Explicit operator-approved stores, never inferred from process environment. */ + resources?: import('./environment-resources.js').LoadedEnvironmentResource[]; workspaceIdentity?: WorkspaceRootIdentity; workspaceRoot: string; /** Present when `workspaceRoot` is a verified linked worktree lane of a checkout. */ @@ -530,6 +533,14 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const protectedPaths = await Promise.all( (this.options.protectedPaths ?? []).map(canonicalPath), ); + const resources = this.options.resources ?? []; + await assertEnvironmentResourcesStable(resources); + for (const resource of resources) { + if (isWithin(root, resource.path) || isWithin(resource.path, root) || + protectedPaths.some(path => isWithin(resource.path, path) || isWithin(path, resource.path))) { + throw new WorkspaceToolError('Environment resource overlaps a workspace or worker control path', 'REGISTRATION_INVALID'); + } + } if (protectedPaths.some(path => isWithin(root, path))) { throw new WorkspaceToolError( 'Native sandbox workspace cannot contain worker control files', @@ -676,6 +687,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], allowRead: [ root, + ...resources.map(resource => resource.path), ...laneGitPaths, ...(gitGuardDirectory ? [gitGuardDirectory] : []), ...(canonicalScratchDirectory @@ -684,6 +696,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], allowWrite: [ root, + ...resources.filter(resource => resource.access === 'read-write').map(resource => resource.path), ...writableGitPaths, ...(canonicalScratchDirectory ? [canonicalScratchDirectory] @@ -691,6 +704,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], denyWrite: [ ...protectedPaths, + ...resources.filter(resource => resource.access === 'read-only').map(resource => resource.path), ...deniedInheritedWritablePaths, ...rootGitMetadataDenies, ...(gitGuardDirectory ? [gitGuardDirectory] : []), @@ -762,10 +776,12 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ]; this.baseDenyWritePaths = [ ...protectedPaths, + ...resources.filter(resource => resource.access === 'read-only').map(resource => resource.path), ...deniedInheritedWritablePaths, ]; this.denyWritePaths = [ ...protectedPaths, + ...resources.filter(resource => resource.access === 'read-only').map(resource => resource.path), ...deniedInheritedWritablePaths, ...rootGitMetadataDenies, ...(gitGuardDirectory ? [gitGuardDirectory] : []), @@ -988,6 +1004,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ? { filesystem: { allowRead: [ + ...(this.options.resources ?? []).map(resource => resource.path), canonicalWorkspaceRoot ?? this.canonicalRoot!, canonicalDataDirectory, ...(this.canonicalCommonGitDir @@ -1005,6 +1022,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], denyRead: this.denyReadPaths, denyWrite: [ + ...(this.options.resources ?? []).map(resource => resource.path), this.canonicalRoot!, ...this.denyWritePaths, ], @@ -1138,6 +1156,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } await this.initialize(); + await assertEnvironmentResourcesStable(this.options.resources ?? []).catch(() => { + throw new WorkspaceToolError('Environment resource changed before command dispatch', 'REGISTRATION_INVALID'); + }); const root = workspaceRoot ?? this.canonicalRoot!; let cwd: string; try { @@ -1167,6 +1188,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox : request.command; wrapped = await this.withTemporaryHostEnvironment( { + ...environmentResourceVariables(this.options.resources ?? []), ...this.gitEnvironment, ...(credentialEnvironment ?? {}), ...this.scratchSelectorEnvironment(sandboxScratchDirectory), @@ -1270,6 +1292,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox cwd, env: { ...wrapped.env, + ...environmentResourceVariables(this.options.resources ?? []), ...this.scratchEnvironment(), ...trustedEnvironment, ...this.gitEnvironment,