From af062c402751f57cf55ebe7c748bb4897bfa21cb Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Wed, 21 Oct 2026 16:44:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(buddy):=20=E4=BF=AE=E5=A4=8D=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E5=B7=A5=E5=85=B7=E9=93=BE=E7=8E=AF=E5=A2=83=E7=BB=A7?= =?UTF-8?q?=E6=89=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .playwright/scripts/__tests__/sandbox.e2e.mjs | 72 ++++- .../scripts/fixtures/sandbox-toolchain.ts | 292 ++++++++++++++++++ .../__tests__/buddyServiceEnvironment.spec.ts | 64 +++- .../main/runtime/buddyServiceEnvironment.ts | 6 +- .../main/sandbox/WindowsSandboxPreparation.ts | 9 +- .../main/sandbox/registerSandboxHostRpc.ts | 4 +- .../platform/filesystem/sensitiveLocations.ts | 18 +- .../platform/filesystem/userHomePaths.ts | 26 ++ .../__tests__/sandboxEnvironment.spec.ts | 180 +++++++++++ .../__tests__/toolchainEnvironment.spec.ts | 60 ++++ .../platform/process/probeWindowsSandbox.ts | 2 +- .../platform/process/sandboxEnvironment.ts | 60 ++-- .../platform/process/toolchainEnvironment.ts | 66 ++++ .../windows/__tests__/sandboxPolicy.spec.ts | 32 ++ .../__tests__/storageSensitivePaths.spec.ts | 24 +- .../service/src/permissions/sensitivePaths.ts | 21 +- .../sandbox/__tests__/macosSandbox.spec.ts | 4 +- .../__tests__/sandboxPermissions.spec.ts | 7 +- .../sandbox/__tests__/shellSandbox.spec.ts | 84 ++++- .../backends/windows/runWindowsSandbox.ts | 3 +- .../service/src/sandbox/shellCapability.ts | 2 +- 21 files changed, 953 insertions(+), 83 deletions(-) create mode 100644 .playwright/scripts/fixtures/sandbox-toolchain.ts create mode 100644 apps/buddy/platform/filesystem/userHomePaths.ts create mode 100644 apps/buddy/platform/process/__tests__/sandboxEnvironment.spec.ts create mode 100644 apps/buddy/platform/process/__tests__/toolchainEnvironment.spec.ts create mode 100644 apps/buddy/platform/process/toolchainEnvironment.ts diff --git a/.playwright/scripts/__tests__/sandbox.e2e.mjs b/.playwright/scripts/__tests__/sandbox.e2e.mjs index 99f6448b4..4950f719f 100644 --- a/.playwright/scripts/__tests__/sandbox.e2e.mjs +++ b/.playwright/scripts/__tests__/sandbox.e2e.mjs @@ -12,9 +12,7 @@ import { expect, test } from '../fixtures/electron.mjs' const buddyRoot = path.resolve('apps/buddy') const require = createRequire(path.join(buddyRoot, 'package.json')) -test('Sandbox preserves production RPC boundaries with large toolchains', async ({ buddy }, testInfo) => { - test.skip(!['win32', 'linux'].includes(process.platform), 'Requires Windows or Linux sandbox resources') - test.setTimeout(600_000) +async function buildSandboxVerification(entry) { const { build } = await import(pathToFileURL(require.resolve('vite')).href) const mainDirectory = path.join(buddyRoot, '.output/build/electron/main') const processes = (await readdir(mainDirectory)).filter(file => /^sandboxProcess-.*\.js$/.test(file)) @@ -36,10 +34,23 @@ test('Sandbox preserves production RPC boundaries with large toolchains', async outDir, target: 'node22', minify: false, - lib: { entry: path.join(buddyRoot, 'electron/main/sandbox/verifySandboxInstallation.ts'), formats: ['es'], fileName: () => 'verify.mjs' }, + lib: { entry, formats: ['es'], fileName: () => 'verify.mjs' }, rollupOptions: { external: ['electron', /^node:/] }, }, }) + return outDir + } + catch (error) { + await rm(outDir, { recursive: true, force: true }) + throw error + } +} + +test('Sandbox preserves production RPC boundaries with large toolchains and empty HOME', async ({ buddy }, testInfo) => { + test.skip(!['win32', 'linux'].includes(process.platform), 'Requires Windows or Linux sandbox resources') + test.setTimeout(600_000) + const outDir = await buildSandboxVerification(path.join(buddyRoot, 'electron/main/sandbox/verifySandboxInstallation.ts')) + try { const instance = await buddy.createInstance('sandbox-scale') const { app, page } = await instance.launch() await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.runtime.getStatus()).status), { timeout: 120_000 }).toBe('ready') @@ -51,6 +62,7 @@ test('Sandbox preserves production RPC boundaries with large toolchains', async const execFile = childProcess.execFile let nativeDiagnostics = '' const inspections = [] + const originalHome = process.env.HOME childProcess.execFile = (...args) => { const start = performance.now() const child = execFile(...args) @@ -79,6 +91,7 @@ test('Sandbox preserves production RPC boundaries with large toolchains', async } process.getBuiltinModule('node:module').syncBuiltinESMExports() try { + process.env.HOME = '' const { verifySandboxInstallation } = process.getBuiltinModule('node:module').createRequire(file)(file) const measurements = await verifySandboxInstallation({ buddyHome: home, @@ -100,6 +113,10 @@ test('Sandbox preserves production RPC boundaries with large toolchains', async throw new Error(`${error.message}; ${nativeDiagnostics}; ${JSON.stringify(inspections)}`) } finally { + if (originalHome === undefined) + delete process.env.HOME + else + process.env.HOME = originalHome childProcess.spawn = spawn childProcess.execFile = execFile process.getBuiltinModule('node:module').syncBuiltinESMExports() @@ -113,6 +130,53 @@ test('Sandbox preserves production RPC boundaries with large toolchains', async } }) +test('Windows runs real mise toolchains through production sandbox RPC', async ({ buddy }, testInfo) => { + test.skip(process.platform !== 'win32' || !process.env.LEXORA_TEST_MISE, 'Requires Windows and LEXORA_TEST_MISE pointing to a real mise.exe') + test.setTimeout(600_000) + expect(path.isAbsolute(process.env.LEXORA_TEST_MISE)).toBe(true) + const outDir = await buildSandboxVerification(path.resolve('.playwright/scripts/fixtures/sandbox-toolchain.ts')) + try { + const instance = await buddy.createInstance('sandbox-mise') + const { app, page } = await instance.launch() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.runtime.getStatus()).status), { timeout: 120_000 }).toBe('ready') + for (const scenario of ['xdg', 'explicit', 'empty-home']) { + await test.step(scenario, async () => { + const report = await app.evaluate(async (_electron, { module, home, root, target, mise, node, scenario }) => { + const file = process.getBuiltinModule('node:url').fileURLToPath(module) + const { join } = process.getBuiltinModule('node:path') + const { verifyWindowsMise } = process.getBuiltinModule('node:module').createRequire(file)(file) + return verifyWindowsMise({ + buddyHome: home, + protectedRoots: [home], + protectionScope: join(home, `mise-${scenario}`), + windowsSandbox: join(root, `.output/build/native/${target.rustTarget}/release/lexora-buddy-sandbox.exe`), + windowsShell: join(process.env.ProgramFiles, 'PowerShell/7/pwsh.exe'), + searchDirectory: join(root, `.output/build/search-tools/${process.platform}-${process.arch}`), + }, { mise, node, scenario }) + }, { + module: pathToFileURL(path.join(outDir, 'verify.mjs')).href, + home: instance.home, + root: buddyRoot, + target: targets[`${process.platform}-${process.arch}`], + mise: process.env.LEXORA_TEST_MISE, + node: process.execPath, + scenario, + }) + await testInfo.attach(`sandbox-mise-${scenario}`, { body: JSON.stringify(report, null, 2), contentType: 'application/json' }) + expect.soft(report.passed, report.error ?? JSON.stringify(report)).toBe(true) + if (!report.passed) + return + expect(report.identity.administrator).toBe(false) + expect(report.checks).toContain(scenario === 'empty-home' ? 'fallback-home-credentials-denied' : 'both-home-credentials-denied') + expect(report.checks).toContain('read-only-toolchain') + }) + } + } + finally { + await rm(outDir, { recursive: true, force: true }) + } +}) + test('Windows prepares bound workspace permissions before any task command', async ({ buddy }) => { test.skip(process.platform !== 'win32', 'Requires the installed Windows sandbox component') test.setTimeout(180_000) diff --git a/.playwright/scripts/fixtures/sandbox-toolchain.ts b/.playwright/scripts/fixtures/sandbox-toolchain.ts new file mode 100644 index 000000000..06c00f429 --- /dev/null +++ b/.playwright/scripts/fixtures/sandbox-toolchain.ts @@ -0,0 +1,292 @@ +import type { MessagePort } from 'node:worker_threads' +import type { SandboxHostOptions } from '../../../apps/buddy/electron/main/sandbox/registerSandboxHostRpc' +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import childProcess, { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, parse } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { MessageChannel } from 'node:worker_threads' +import { registerSandboxHostRpc } from '../../../apps/buddy/electron/main/sandbox/registerSandboxHostRpc' +import { RuntimeRpcPeer } from '../../../apps/buddy/platform/ipc/runtimeRpcPeer' +import { createToolchainEnvironment } from '../../../apps/buddy/platform/process/toolchainEnvironment' +import { ShellSandboxClient } from '../../../apps/buddy/service/src/sandbox/ShellSandboxClient' + +type Scenario = 'xdg' | 'explicit' | 'empty-home' + +interface CommandResult { + name: string + command: string + readOnly?: boolean + exitCode: number | null + output: string + elapsedMs: number +} + +const quote = (value: string) => `'${value.replaceAll('\'', '\'\'')}'` +const setup = promisify(execFile) + +export async function verifyWindowsMise(options: SandboxHostOptions, input: { mise: string, node: string, scenario: Scenario }) { + const report = { + scenario: input.scenario, + passed: false, + error: undefined as string | undefined, + layout: {} as Record, + identity: {} as Record, + setup: [] as CommandResult[], + commands: [] as CommandResult[], + checks: [] as string[], + nativeDiagnostics: '', + } + const originalEnvironment = { ...process.env } + const originalSpawn = childProcess.spawn + childProcess.spawn = ((...args: Parameters) => { + const child = originalSpawn(...args) + if (String(args[0]).endsWith('lexora-buddy-sandbox.exe')) + child.stderr?.on('data', data => report.nativeDiagnostics = `${report.nativeDiagnostics}${data}`.slice(-16_384)) + return child + }) as typeof originalSpawn + syncBuiltinESMExports() + let directory: string | undefined + let disposeRpc: (() => Promise) | undefined + try { + assert.equal(process.platform, 'win32') + assert(isAbsolute(input.mise) && isAbsolute(input.node), 'mise and Node must be absolute paths') + assert(options.windowsShell, 'PowerShell 7 is required') + const shell = options.windowsShell + const identity = await setup(shell, ['-NoLogo', '-NoProfile', '-Command', '$id = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($id); @{ administrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); sid = $id.User.Value; priority = [Diagnostics.Process]::GetCurrentProcess().PriorityClass.ToString() } | ConvertTo-Json -Compress'], { windowsHide: true, timeout: 15_000 }) + report.identity = JSON.parse(identity.stdout) + assert.equal(report.identity.administrator, false, 'Run this fixture with a non-elevated user token') + directory = await realpath(await mkdtemp(join(tmpdir(), 'lexora-mise-'))) + await setup(shell, ['-NoLogo', '-NoProfile', '-Command', `$acl = [Security.AccessControl.DirectorySecurity]::new(); $acl.SetAccessRuleProtection($true, $false); foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value, 'S-1-5-18', 'S-1-5-32-544')) { $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'); $acl.AddAccessRule($rule) }; Set-Acl -LiteralPath ${quote(directory)} -AclObject $acl -ErrorAction Stop`], { windowsHide: true, timeout: 15_000 }) + + const home = join(directory, '主目录 HOME') + const profile = join(directory, '用户 Profile') + const local = join(profile, 'AppData', 'Local') + const workspace = join(directory, '工作区 Space') + const runtime = join(home, 'Node runtime') + const hostTemp = join(directory, '宿主 Temp') + const custom = input.scenario === 'explicit' + const data = custom ? join(home, 'mise 数据') : join(local, 'mise') + const config = custom ? join(home, 'mise 配置') : join(profile, '.config', 'mise') + const state = custom ? join(home, 'mise 状态') : join(profile, '.local', 'state', 'mise') + const cache = custom ? join(home, 'mise 缓存') : join(hostTemp, 'mise') + const mise = join(home, 'mise bin', 'mise.exe') + const node = join(runtime, 'node.exe') + const environment = { + SystemRoot: originalEnvironment.SystemRoot!, + WINDIR: originalEnvironment.SystemRoot!, + ComSpec: join(originalEnvironment.SystemRoot!, 'System32', 'cmd.exe'), + PATHEXT: '.COM;.EXE;.BAT;.CMD', + HOME: input.scenario === 'empty-home' ? '' : home, + USERPROFILE: profile, + HOMEDRIVE: parse(profile).root.slice(0, 2), + HOMEPATH: profile.slice(2), + APPDATA: join(profile, 'AppData', 'Roaming'), + LOCALAPPDATA: local, + TEMP: hostTemp, + TMP: hostTemp, + PATH: [join(data, 'shims'), dirname(mise), join(originalEnvironment.SystemRoot!, 'System32')].join(';'), + MISE_SYSTEM_CONFIG_DIR: join(home, 'mise system'), + ...(!custom ? { XDG_CONFIG_HOME: dirname(config), XDG_STATE_HOME: dirname(state) } : {}), + MISE_OFFLINE: '1', + MISE_HIDE_UPDATE_WARNING: '1', + ...(custom ? { MISE_DATA_DIR: data, MISE_CONFIG_DIR: config, MISE_CACHE_DIR: cache, MISE_STATE_DIR: state } : {}), + } + report.layout = { directory, home, profile, workspace, runtime, data, config, state, cache, mise, node, hostTemp } + for (const path of [home, profile, local, workspace, runtime, hostTemp, data, config, state, cache, dirname(mise), environment.APPDATA, environment.MISE_SYSTEM_CONFIG_DIR]) + await mkdir(path, { recursive: true }) + await copyFile(input.node, node) + await copyFile(input.mise, mise) + await copyFile(join(dirname(input.mise), 'mise-shim.exe'), join(dirname(mise), 'mise-shim.exe')) + const nodeIdentity = JSON.parse((await setup(node, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron??null})'], { env: environment, timeout: 15_000 })).stdout) + assert.equal(nodeIdentity.electron, null, 'Pass Playwright Node, not Electron, as the linked runtime') + report.identity.node = nodeIdentity.node + const ceiling = `ceiling_paths = [${JSON.stringify(directory.replaceAll('\\', '/'))}]\n` + for (const root of [home, workspace]) + await writeFile(join(root, '.miserc.toml'), ceiling) + await writeFile(join(config, 'config.toml'), '[settings]\nparanoid = true\nauto_install = false\noffline = true\n') + const project = join(workspace, 'mise.toml') + await writeFile(project, '[tools]\nnode = "fixture"\n[env]\nLEXORA_TOOLCHAIN_FIXTURE = "MISE_ENV_OK"\n') + const credentials = [...new Set([environment.HOME || profile, profile])].map(root => join(root, '.ssh', 'id_ed25519')) + for (const credential of credentials) { + await mkdir(dirname(credential), { recursive: true }) + await writeFile(credential, 'SYNTHETIC_MISE_CREDENTIAL') + } + for (const args of [['--version'], ['link', 'node@fixture', runtime], ['trust', project], ['reshim'], ['settings', 'get', 'paranoid'], ['settings', 'get', 'auto_install']]) { + const start = performance.now() + const record: CommandResult = { name: args.join(' '), command: args.join(' '), exitCode: null, output: '', elapsedMs: 0 } + report.setup.push(record) + try { + const result = await setup(mise, args, { cwd: home, env: environment, windowsHide: true, timeout: 30_000 }) + Object.assign(record, { exitCode: 0, output: result.stdout + result.stderr }) + if (args[2] === 'paranoid') + assert.equal(result.stdout.trim(), 'true') + if (args[2] === 'auto_install') + assert.equal(result.stdout.trim(), 'false') + } + catch (error) { + const failure = error as Error & { code?: number, stdout?: string, stderr?: string } + Object.assign(record, { exitCode: typeof failure.code === 'number' ? failure.code : null, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}${failure.message}` }) + throw error + } + finally { record.elapsedMs = performance.now() - start } + } + const shimNames = (await readdir(join(data, 'shims'))).filter(file => /^node\.(?:exe|cmd)$/i.test(file)) + assert(shimNames.length > 0, 'Real mise reshim must produce node.exe or node.cmd') + report.layout.shims = shimNames.join(';') + + const cleared = new Set(Object.keys(createToolchainEnvironment(process.env, 'win32'))) + for (const key of Object.keys(process.env)) { + if (cleared.has(key.toUpperCase()) || /^(?:MISE_|XDG_|__MISE|NODE_|ELECTRON_RUN_AS_NODE$)/i.test(key)) + delete process.env[key] + } + Object.assign(process.env, environment) + const channel = new MessageChannel() + const peer = (port: MessagePort) => new RuntimeRpcPeer({ + transport: { + postMessage: message => port.postMessage(message), + subscribe(listener) { + port.on('message', listener) + return () => port.off('message', listener) + }, + }, + }) + const host = peer(channel.port1) + const runtimePeer = peer(channel.port2) + const disposeHost = registerSandboxHostRpc(host, options) + const client = new ShellSandboxClient(runtimePeer) + disposeRpc = async () => { + try { + await client.dispose() + } + finally { + try { + await disposeHost.dispose() + } + finally { + host.close(new Error('Mise verification completed')) + runtimePeer.close(new Error('Mise verification completed')) + channel.port1.close() + channel.port2.close() + } + } + } + const execute = async (name: string, command: string, readOnly = false) => { + command = `$env:MISE_OFFLINE = '1'; $env:MISE_HIDE_UPDATE_WARNING = '1'; ${command}` + const record: CommandResult = { name, command, readOnly, exitCode: null, output: '', elapsedMs: 0 } + report.commands.push(record) + const start = performance.now() + try { + const output: Buffer[] = [] + const result = await client.exec({ command, cwd: workspace, roots: [workspace], workspaceRoots: [workspace], resourceReadRoots: [directory!], additionalDirectories: [], readOnly, timeout: 30 }, { + onData: data => output.push(data), + }, async () => false) + record.exitCode = result.exitCode + record.output = Buffer.concat(output).toString('utf8') + return record + } + catch (error) { + record.output += error instanceof Error ? error.message : String(error) + throw error + } + finally { record.elapsedMs = performance.now() - start } + } + const succeeded = (result: CommandResult) => assert.equal(result.exitCode, 0, JSON.stringify(result)) + await client.prepare([workspace]) + const configRead = await execute('configuration-readable', `Get-Content -LiteralPath ${quote(join(config, 'config.toml'))} -Raw -ErrorAction Stop`) + succeeded(configRead) + await execute('mise-directories', `& ${quote(mise)} doctor --json | ConvertFrom-Json | Select-Object dirs,config_files | ConvertTo-Json -Depth 5 -Compress`) + const shellIdentity = await execute('no-profile', '$id = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($id); @{ administrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); noProfile = [Environment]::GetCommandLineArgs() -contains "-NoProfile" } | ConvertTo-Json -Compress') + succeeded(shellIdentity) + const sandboxIdentity = JSON.parse(shellIdentity.output) + assert.equal(sandboxIdentity.administrator, false) + assert.equal(sandboxIdentity.noProfile, true) + report.identity.sandbox = sandboxIdentity + const version = await execute('mise-version', `& ${quote(mise)} --version; exit $LASTEXITCODE`) + succeeded(version) + assert.match(version.output, /\d{4}\.\d+\.\d+/) + for (const setting of ['paranoid', 'auto_install']) { + const result = await execute(setting, `& ${quote(mise)} settings get ${setting}; exit $LASTEXITCODE`) + succeeded(result) + assert.equal(result.output.trim(), setting === 'paranoid' ? 'true' : 'false') + } + const expression = 'JSON.stringify({marker:process.env.LEXORA_TOOLCHAIN_FIXTURE,execPath:process.execPath,home:process.env.HOME,profile:process.env.USERPROFILE,data:process.env.MISE_DATA_DIR??null,config:process.env.MISE_CONFIG_DIR??null,cache:process.env.MISE_CACHE_DIR??null,state:process.env.MISE_STATE_DIR??null})' + const invocations = [ + ['mise-exec', `& ${quote(mise)} exec -- node -p ${quote(expression)}`], + ...shimNames.map(name => [`shim-${name}`, `& ${quote(join(data, 'shims', name))} -p ${quote(expression)}`]), + ['path-node', `node -p ${quote(expression)}`], + ] + for (const readOnly of [false, false, true]) { + for (const [name, command] of invocations) { + const result = await execute(name!, `${command}; exit $LASTEXITCODE`, readOnly) + succeeded(result) + const records = result.output.split(/\r?\n/).filter(line => line.startsWith('{"marker":')) + assert.equal(records.length, 1, result.output) + const value = JSON.parse(records[0]!) + assert.equal(value.marker, 'MISE_ENV_OK') + assert.equal((await realpath(value.execPath)).toLowerCase(), (await realpath(node)).toLowerCase()) + assert.notEqual((await realpath(value.execPath)).toLowerCase(), (await realpath(input.node)).toLowerCase()) + assert.equal(value.home, environment.HOME || profile) + assert.equal(value.profile, profile) + for (const key of ['data', 'config', 'cache', 'state'] as const) + assert.equal(value[key], custom ? report.layout[key] : null) + } + } + report.checks.push('read-only-toolchain-resources', 'mise-exec-and-real-shims', 'repeat', 'read-only-toolchain', 'home-and-directory-environment', 'no-profile') + for (const readOnly of [false, true]) { + for (const root of [home, profile, data, ...(readOnly ? [workspace] : [])]) { + const forbidden = join(root, `forbidden-${readOnly}.txt`) + const result = await execute('outside-write-denied', `Set-Content -LiteralPath ${quote(forbidden)} -Value forbidden -ErrorAction Stop`, readOnly) + assert.equal(result.exitCode, 1, JSON.stringify(result)) + await assert.rejects(readFile(forbidden), { code: 'ENOENT' }) + } + for (const credential of credentials) { + const result = await execute('credential-read-denied', `Get-Content -LiteralPath ${quote(credential)} -ErrorAction Stop`, readOnly) + assert.equal(result.exitCode, 1, JSON.stringify(result)) + assert(!result.output.includes('SYNTHETIC_MISE_CREDENTIAL'), JSON.stringify(result)) + } + const temporary = await execute('private-temp-write', '$file = Join-Path $env:TEMP "mise-private.txt"; Set-Content -LiteralPath $file -Value "PRIVATE_TEMP_OK" -NoNewline -ErrorAction Stop; @{ marker = (Get-Content -LiteralPath $file -Raw -ErrorAction Stop); temp = $env:TEMP } | ConvertTo-Json -Compress', readOnly) + succeeded(temporary) + const value = JSON.parse(temporary.output) + assert.equal(value.marker, 'PRIVATE_TEMP_OK') + assert.notEqual(value.temp.toLowerCase(), hostTemp.toLowerCase()) + } + report.checks.push('outside-home-and-data-write-denied', credentials.length === 2 ? 'both-home-credentials-denied' : 'fallback-home-credentials-denied', 'private-temp-writable') + report.passed = true + } + catch (error) { + report.error = error instanceof Error ? `${error.stack ?? error.message}\n${JSON.stringify({ code: Reflect.get(error, 'code'), data: Reflect.get(error, 'data') })}` : String(error) + } + finally { + try { + await disposeRpc?.() + } + catch (error) { + report.passed = false + report.error = `${report.error ?? ''}\nRPC cleanup: ${String(error)}` + } + finally { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnvironment)) + delete process.env[key] + } + Object.assign(process.env, originalEnvironment) + childProcess.spawn = originalSpawn + syncBuiltinESMExports() + if (directory) { + try { + await rm(directory, { recursive: true, force: true }) + } + catch (error) { + report.passed = false + report.error = `${report.error ?? ''}\nFixture cleanup: ${String(error)}` + } + } + } + } + return report +} diff --git a/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironment.spec.ts b/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironment.spec.ts index 175f7f79b..daef67df4 100644 --- a/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironment.spec.ts +++ b/apps/buddy/electron/main/runtime/__tests__/buddyServiceEnvironment.spec.ts @@ -1,11 +1,12 @@ import { execFile } from 'node:child_process' import { mkdir, writeFile } from 'node:fs/promises' -import { homedir } from 'node:os' +import { userInfo } from 'node:os' import { join, win32 } from 'node:path' import process from 'node:process' import { promisify } from 'node:util' import { createTemporaryDirectory } from '@buddy-tests/temporaryDirectories' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { createSandboxEnvironment } from '../../../../platform/process/sandboxEnvironment' import { createBuddyServiceEnvironment, resolveBuddySearchToolsDirectory, @@ -14,6 +15,50 @@ import { const executeFile = promisify(execFile) describe('buddyServiceEnvironment', () => { + it.each(['linux', 'darwin', 'win32'] as const)('preserves safe tool locations through the runtime-to-command handoff on %s', (platform) => { + const home = platform === 'win32' ? 'C:\\Users\\Fixture' : '/fixture/home' + const locations = { + XDG_CONFIG_HOME: `${home}/config`, + XDG_DATA_HOME: `${home}/data`, + XDG_CACHE_HOME: `${home}/cache`, + XDG_STATE_HOME: `${home}/state`, + MISE_DATA_DIR: `${home}/mise`, + MISE_CONFIG_DIR: `${home}/mise-config`, + MISE_SHIMS_DIR: `${home}/mise-shims`, + MISE_ENV: 'development', + CARGO_HOME: `${home}/cargo`, + PNPM_HOME: `${home}/pnpm`, + } + const source = Object.freeze({ + HOME: home, + SystemRoot: 'C:\\Windows', + UserProfile: 'C:\\Users\\Fixture', + ...Object.fromEntries(Object.entries(locations).map(([name, value]) => [platform === 'win32' ? name.toLowerCase() : name, value])), + MISE_API_TOKEN: 'synthetic-secret', + UNKNOWN_SECRET: 'synthetic-secret', + MISE_INSTALLS_DIR: '() { synthetic; }', + MISE_CONFIG_FILE: '/fixture/config\0suffix', + NODE_OPTIONS: '--require=/fixture/inject.js', + BASH_ENV: '/fixture/startup.sh', + SHELLOPTS: 'xtrace', + LD_PRELOAD: '/fixture/inject.so', + SSH_AUTH_SOCK: '/fixture/ssh.sock', + DISPLAY: ':1', + HTTP_PROXY: 'http://host.invalid:8080', + NO_PROXY: '*', + }) + const runtime = createBuddyServiceEnvironment(source, `${home}/buddy`, platform) + const command = createSandboxEnvironment(runtime, platform === 'win32' ? 'D:\\private' : '/fixture/private', platform) + + for (const environment of [runtime, command]) { + expect(environment).toMatchObject({ HOME: home, ...locations }) + for (const name of ['MISE_API_TOKEN', 'UNKNOWN_SECRET', 'MISE_INSTALLS_DIR', 'MISE_CONFIG_FILE', 'NODE_OPTIONS', 'BASH_ENV', 'SHELLOPTS', 'LD_PRELOAD']) + expect(environment, name).not.toHaveProperty(name) + } + for (const name of ['SSH_AUTH_SOCK', 'DISPLAY', 'HTTP_PROXY', 'NO_PROXY', 'NODE_USE_ENV_PROXY']) + expect(command, name).not.toHaveProperty(name) + }) + it.each(['linux', 'win32'] as const)('passes only the explicit full-recovery diagnostic switch on %s', (platform) => { const name = platform === 'win32' ? 'lexora_buddy_event_recovery' : 'LEXORA_BUDDY_EVENT_RECOVERY' const source = { [name]: 'full', UNRELATED_SECRET: 'secret' } @@ -114,10 +159,17 @@ describe('buddyServiceEnvironment', () => { }) }) - it.each([undefined, ''])('uses the main process home when Linux HOME is %s', (userHome) => { - const environment = createBuddyServiceEnvironment({ HOME: userHome }, '/data/lexora/buddy', 'linux') - - expect(environment.HOME).toBe(homedir()) + it.each(['linux', 'darwin'] as const)('uses the main process home when HOME is missing or empty on %s', (platform) => { + vi.stubEnv('HOME', '') + try { + for (const userHome of [undefined, '']) { + const environment = createBuddyServiceEnvironment({ HOME: userHome }, '/data/lexora/buddy', platform) + expect(environment.HOME).toBe(userInfo().homedir) + } + } + finally { + vi.unstubAllEnvs() + } }) it.skipIf(process.platform !== 'linux')('lets shell commands read the existing Git identity from user HOME', async () => { diff --git a/apps/buddy/electron/main/runtime/buddyServiceEnvironment.ts b/apps/buddy/electron/main/runtime/buddyServiceEnvironment.ts index e7505d2af..864b44429 100644 --- a/apps/buddy/electron/main/runtime/buddyServiceEnvironment.ts +++ b/apps/buddy/electron/main/runtime/buddyServiceEnvironment.ts @@ -1,10 +1,11 @@ import type { StorageProtection } from '../../../platform/storage/ProfileLocations' -import { homedir } from 'node:os' +import { userInfo } from 'node:os' import process from 'node:process' import { filePathAdapters } from '../../../platform/filesystem/filePaths' import searchTools from '../../../platform/native/searchTools.json' import { createChildProcessEnvironment } from '../../../platform/process/childProcessEnvironment' import { createProxyEnvironment } from '../../../platform/process/proxyEnvironment' +import { createToolchainEnvironment } from '../../../platform/process/toolchainEnvironment' import { resolveBuddyTarget } from '../../../platform/target' import { resolveBuddyPlatform } from '../../../shared/platform' import { isWindows } from '../../../shared/platform/identifiers' @@ -35,7 +36,7 @@ export function createBuddyServiceEnvironment( ): NodeJS.ProcessEnv { const targetPlatform = resolveBuddyPlatform(platform) const environmentSource = !isWindows(targetPlatform.id) && !source.HOME - ? { ...source, HOME: homedir() } + ? { ...source, HOME: userInfo().homedir } : source const recoveryMode = Object.entries(environmentSource).find(([name]) => ( (targetPlatform.environment.caseSensitive ? name : name.toUpperCase()) === 'LEXORA_BUDDY_EVENT_RECOVERY' @@ -44,6 +45,7 @@ export function createBuddyServiceEnvironment( source: environmentSource, platform: targetPlatform, additions: { + ...createToolchainEnvironment(environmentSource, targetPlatform.id), LEXORA_BUDDY_HOME: buddyHome, LEXORA_BUDDY_STORAGE_PROTECTION: storageProtection ? JSON.stringify(storageProtection.sensitiveRoots) : undefined, LEXORA_BUDDY_EVENT_RECOVERY: recoveryMode === 'full' ? 'full' : undefined, diff --git a/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts b/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts index 826a3516c..511eb479e 100644 --- a/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts +++ b/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts @@ -1,7 +1,8 @@ import type { SandboxHostOptions } from './registerSandboxHostRpc' -import { homedir } from 'node:os' +import { userInfo } from 'node:os' import process from 'node:process' import { filePaths } from '../../../platform/filesystem/filePaths' +import { resolveUserHomePaths } from '../../../platform/filesystem/userHomePaths' import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' import { prepareWindowsSandboxFilesystem } from '../../../platform/process/windowsSandboxProcess' import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths' @@ -52,7 +53,7 @@ export class WindowsSandboxPreparation { additionalDirectories: [], readOnly: false, cwd: roots[0]!, - home: homedir(), + home: userInfo().homedir, sensitiveRoots: resolveSensitivePathRoots(), protectedRoots: this.#options.protectedRoots ?? [], searchDirectory: this.#options.searchDirectory, @@ -75,7 +76,6 @@ export class WindowsSandboxPreparation { async #initialize(): Promise { const executable = await resolveWindowsSandbox(this.#options.windowsSandbox) - const home = homedir() const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT if (!systemRoot || !this.#options.windowsShell) throw new Error('SANDBOX_UNAVAILABLE') @@ -87,7 +87,8 @@ export class WindowsSandboxPreparation { ...await existingSandboxPaths(this.#options.protectedRoots ?? []), ] const grants = await Promise.all(protectedPaths.map(path => inspectSandboxPath(path, 'denyRead'))) - grants.push(await inspectSandboxPath(home, 'read')) + for (const home of await existingSandboxPaths(resolveUserHomePaths())) + grants.push(await inspectSandboxPath(home, 'read')) await prepareWindowsSandboxFilesystem(executable, grants, AbortSignal.any([this.#controller.signal, AbortSignal.timeout(900_000)]), { denyReadScope: this.#options.protectionScope ?? this.#options.buddyHome, synchronizeDenials: true, diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts index 8befd5713..84afc49a6 100644 --- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts +++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts @@ -1,6 +1,6 @@ import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import { rm } from 'node:fs/promises' -import { homedir } from 'node:os' +import { userInfo } from 'node:os' import { dirname } from 'node:path' import process from 'node:process' import { utilityProcess } from 'electron' @@ -166,7 +166,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa dispatched = true return await processPeer.request('sandbox.exec', { ...input, - home: homedir(), + home: userInfo().homedir, sensitiveRoots: resolveSensitivePathRoots(), path: process.env.PATH ?? '', privateRoot: directory, diff --git a/apps/buddy/platform/filesystem/sensitiveLocations.ts b/apps/buddy/platform/filesystem/sensitiveLocations.ts index 74d9d5c53..5f9815da3 100644 --- a/apps/buddy/platform/filesystem/sensitiveLocations.ts +++ b/apps/buddy/platform/filesystem/sensitiveLocations.ts @@ -47,17 +47,24 @@ const locations: Record = { export function resolveSensitiveLocations(home: string | null, environment: NodeJS.ProcessEnv): string[] { const definition = locations[currentPlatform.id] + const normalize = (name: string) => currentPlatform.id === 'win32' ? name.toUpperCase() : name + const inherited = new Map(Object.entries(environment).map(([name, value]) => [normalize(name), value])) + const get = (name: string) => inherited.get(normalize(name)) const roots = [...definition.absolute] - const buddyHome = environment.LEXORA_BUDDY_HOME + const buddyHome = get('LEXORA_BUDDY_HOME') if (buddyHome && filePaths.path.isAbsolute(buddyHome)) { roots.push(...['secrets', '../device', '../storage.json'].map(path => filePaths.resolveInput(path, buddyHome))) } - const storageProtection: unknown = environment.LEXORA_BUDDY_STORAGE_PROTECTION ? JSON.parse(environment.LEXORA_BUDDY_STORAGE_PROTECTION) : [] + const protection = get('LEXORA_BUDDY_STORAGE_PROTECTION') + const storageProtection: unknown = protection ? JSON.parse(protection) : [] if (!Array.isArray(storageProtection) || !storageProtection.every(root => typeof root === 'string' && filePaths.path.isAbsolute(root))) throw new Error('STORAGE_INVALID_PROTECTION') roots.push(...storageProtection) - for (const source of definition.environment) { - const bases = [environment[source.key], home && source.homeFallback ? filePaths.path.join(home, source.homeFallback) : null] + for (const source of [ + ...definition.environment, + { key: 'CARGO_HOME', homeFallback: '.cargo', paths: ['credentials', 'credentials.toml'] }, + ]) { + const bases = [get(source.key), home && source.homeFallback ? filePaths.path.join(home, source.homeFallback) : null] for (const base of bases) { if (!base) continue @@ -70,5 +77,8 @@ export function resolveSensitiveLocations(home: string | null, environment: Node } } } + const npmConfig = get('NPM_CONFIG_USERCONFIG') ?? get('npm_config_userconfig') + if (npmConfig && filePaths.path.isAbsolute(npmConfig)) + roots.push(npmConfig) return roots } diff --git a/apps/buddy/platform/filesystem/userHomePaths.ts b/apps/buddy/platform/filesystem/userHomePaths.ts new file mode 100644 index 000000000..38062581c --- /dev/null +++ b/apps/buddy/platform/filesystem/userHomePaths.ts @@ -0,0 +1,26 @@ +import { userInfo } from 'node:os' +import process from 'node:process' +import { currentPlatform } from '../currentPlatform' +import { filePaths } from './filePaths' + +export function resolveUserHomePaths(environment: NodeJS.ProcessEnv = process.env, home: string = userInfo().homedir): string[] { + const windows = currentPlatform.id === 'win32' + const inherited = Object.fromEntries(Object.entries(environment).map(([name, value]) => [windows ? name.toUpperCase() : name, value])) + const candidates = [home, inherited.HOME] + if (windows) { + candidates.push(inherited.USERPROFILE) + if (inherited.HOMEDRIVE && inherited.HOMEPATH) + candidates.push(`${inherited.HOMEDRIVE}${inherited.HOMEPATH}`) + } + const roots = new Map() + for (const candidate of candidates) { + if (!candidate || !filePaths.path.isAbsolute(candidate)) + continue + try { + const root = filePaths.resolveInput(candidate) + roots.set(filePaths.sensitiveKey(root), root) + } + catch {} + } + return [...roots.values()] +} diff --git a/apps/buddy/platform/process/__tests__/sandboxEnvironment.spec.ts b/apps/buddy/platform/process/__tests__/sandboxEnvironment.spec.ts new file mode 100644 index 000000000..e3d2482bf --- /dev/null +++ b/apps/buddy/platform/process/__tests__/sandboxEnvironment.spec.ts @@ -0,0 +1,180 @@ +import { userInfo } from 'node:os' +import { delimiter, join, win32 } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createSandboxEnvironment, createWindowsSandboxEnvironment } from '../sandboxEnvironment' + +describe('sandbox environment', () => { + it.each(['linux', 'darwin'] as const)('preserves user and XDG locations while privatizing temporary files on %s', (platform) => { + const source = Object.freeze({ + HOME: '/fixture/user home', + XDG_CONFIG_HOME: '/fixture/config', + XDG_DATA_HOME: '/fixture/data', + XDG_CACHE_HOME: '/fixture/cache', + XDG_STATE_HOME: '/fixture/state', + USER: 'fixture', + LOGNAME: 'fixture', + LANG: 'zh_CN.UTF-8', + LC_ALL: 'en_US.UTF-8', + TZ: 'UTC', + PATH: '/host/untrusted/bin', + TMPDIR: '/host/tmp', + TEMP: '/host/temp', + TMP: '/host/tmp', + TERM: 'xterm', + }) + + expect(createSandboxEnvironment(source, '/fixture/private', platform)).toEqual({ + HOME: source.HOME, + XDG_CONFIG_HOME: source.XDG_CONFIG_HOME, + XDG_DATA_HOME: source.XDG_DATA_HOME, + XDG_CACHE_HOME: source.XDG_CACHE_HOME, + XDG_STATE_HOME: source.XDG_STATE_HOME, + USER: 'fixture', + LOGNAME: 'fixture', + LANG: 'zh_CN.UTF-8', + LC_ALL: 'en_US.UTF-8', + TZ: 'UTC', + PATH: ['/usr/local/bin', '/usr/bin', '/bin'].join(delimiter), + TMPDIR: join('/fixture/private', 'tmp'), + TERM: 'dumb', + }) + }) + + it.each(['linux', 'darwin'] as const)('uses the real home fallback and leaves unset XDG paths to tool defaults on %s', (platform) => { + for (const home of [undefined, '']) { + expect(createSandboxEnvironment({ HOME: home, XDG_CONFIG_HOME: '', XDG_DATA_HOME: undefined }, '/fixture/private', platform)).toEqual({ + HOME: userInfo().homedir, + TMPDIR: join('/fixture/private', 'tmp'), + PATH: ['/usr/local/bin', '/usr/bin', '/bin'].join(delimiter), + LANG: 'C.UTF-8', + TERM: 'dumb', + }) + } + }) + + it('resolves the OS home even when the parent process HOME is empty', () => { + vi.stubEnv('HOME', '') + try { + expect(createSandboxEnvironment({ HOME: '' }, '/fixture/private', 'linux').HOME).toBe(userInfo().homedir) + } + finally { + vi.unstubAllEnvs() + } + }) + + it.each([undefined, '', 'D:\\Git home'])('preserves case-insensitive Windows user directories and falls back from HOME=%s', (home) => { + expect(createSandboxEnvironment({ + SystemRoot: 'C:\\Windows', + UserProfile: 'C:\\Users\\Fixture', + AppData: 'D:\\Roaming', + LocalAppData: 'D:\\Local', + HomeDrive: 'H:', + HomePath: '\\Fixture', + UserName: 'Fixture', + Home: home, + Temp: 'C:\\host-temp', + Tmp: 'C:\\host-tmp', + Path: 'C:\\untrusted', + }, 'D:\\private', 'win32')).toEqual({ + SystemRoot: 'C:\\Windows', + WINDIR: 'C:\\Windows', + USERPROFILE: 'C:\\Users\\Fixture', + HOME: home || 'C:\\Users\\Fixture', + APPDATA: 'D:\\Roaming', + LOCALAPPDATA: 'D:\\Local', + HOMEDRIVE: 'H:', + HOMEPATH: '\\Fixture', + USERNAME: 'Fixture', + TEMP: 'D:\\private\\tmp', + TMP: 'D:\\private\\tmp', + PATH: 'C:\\Windows\\System32', + }) + }) + + it.each([ + {}, + { SystemRoot: 'C:\\Windows' }, + { UserProfile: 'C:\\Users\\Fixture' }, + ])('rejects missing Windows system or user directory: %j', (source) => { + expect(() => createSandboxEnvironment(source, 'D:\\private', 'win32')) + .toThrow('Windows system or user directory is unavailable') + }) + + it.each(['linux', 'darwin', 'win32'] as const)('drops ambient secrets, injection, session and proxy settings on %s', (platform) => { + const source = { + HOME: '/fixture/home', + SystemRoot: 'C:\\Windows', + USERPROFILE: 'C:\\Users\\Fixture', + MISE_API_TOKEN: 'synthetic-secret', + UNKNOWN_SECRET: 'synthetic-secret', + NODE_OPTIONS: '--require=/fixture/inject.js', + BASH_ENV: '/fixture/startup.sh', + SHELLOPTS: 'xtrace', + LD_PRELOAD: '/fixture/inject.so', + SSH_AUTH_SOCK: '/fixture/ssh.sock', + DISPLAY: ':1', + XDG_RUNTIME_DIR: '/run/user/1000', + HTTP_PROXY: 'http://host.invalid:8080', + https_proxy: 'http://host.invalid:8081', + ALL_PROXY: 'socks5://host.invalid:1080', + no_proxy: '*', + NODE_USE_ENV_PROXY: '1', + MISE_DATA_DIR: '() { synthetic; }', + MISE_CONFIG_DIR: '/fixture/config\0suffix', + } + const environment = createSandboxEnvironment(source, '/fixture/private', platform) + const { HOME, SystemRoot, USERPROFILE, ...excluded } = source + for (const name of Object.keys(excluded)) + expect(environment, name).not.toHaveProperty(name) + expect(environment.HOME).toBe(HOME) + if (platform === 'win32') { + expect(environment.SystemRoot).toBe(SystemRoot) + expect(environment.USERPROFILE).toBe(USERPROFILE) + } + }) + + it.each([undefined, 'http://127.0.0.1:3128'])('uses controlled Windows paths and only an explicitly supplied proxy (%s)', (proxyUrl) => { + const options = { + shell: 'D:\\PowerShell\\pwsh.exe', + systemRoot: 'D:\\Windows', + privateRoot: 'D:\\private', + path: 'D:\\controlled-bin;D:\\Windows\\System32', + proxyUrl, + } + expect(createWindowsSandboxEnvironment({ + SystemRoot: 'C:\\Windows', + UserProfile: 'C:\\Users\\Fixture', + Path: 'C:\\untrusted', + PSModulePath: 'C:\\untrusted-modules', + ComSpec: 'C:\\untrusted.exe', + PATHEXT: '.UNTRUSTED', + HTTP_PROXY: 'http://host.invalid:8080', + https_proxy: 'http://host.invalid:8081', + ALL_PROXY: 'socks5://host.invalid:1080', + NO_PROXY: '*', + no_proxy: '*', + NODE_USE_ENV_PROXY: '1', + }, options)).toEqual({ + SystemRoot: options.systemRoot, + WINDIR: options.systemRoot, + USERPROFILE: 'C:\\Users\\Fixture', + HOME: 'C:\\Users\\Fixture', + TEMP: win32.join(options.privateRoot, 'tmp'), + TMP: win32.join(options.privateRoot, 'tmp'), + PATH: options.path, + ComSpec: 'D:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD', + PSModulePath: 'D:\\PowerShell\\Modules', + POWERSHELL_TELEMETRY_OPTOUT: '1', + ...(proxyUrl + ? { + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + ALL_PROXY: proxyUrl, + NO_PROXY: 'localhost,127.0.0.1,::1', + NODE_USE_ENV_PROXY: '1', + } + : {}), + }) + }) +}) diff --git a/apps/buddy/platform/process/__tests__/toolchainEnvironment.spec.ts b/apps/buddy/platform/process/__tests__/toolchainEnvironment.spec.ts new file mode 100644 index 000000000..137ae2f76 --- /dev/null +++ b/apps/buddy/platform/process/__tests__/toolchainEnvironment.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { createToolchainEnvironment } from '../toolchainEnvironment' + +describe('toolchain environment', () => { + it.each(['linux', 'darwin', 'win32'] as const)('preserves explicit tool locations without accepting arbitrary tool-prefixed values on %s', (platform) => { + const locations = { + XDG_CONFIG_HOME: '/fixture/config', + XDG_DATA_HOME: '/fixture/data', + XDG_CACHE_HOME: '/fixture/cache', + XDG_STATE_HOME: '/fixture/state', + MISE_DATA_DIR: '/fixture/mise', + MISE_CONFIG_DIR: '/fixture/mise-config', + MISE_INSTALLS_DIR: '/fixture/installs', + MISE_CONFIG_FILE: '/fixture/config.toml', + MISE_ENV: 'development', + NVM_DIR: '/fixture/nvm', + FNM_DIR: '/fixture/fnm', + VOLTA_HOME: '/fixture/volta', + ASDF_DATA_DIR: '/fixture/asdf', + CARGO_HOME: '/fixture/cargo', + RUSTUP_HOME: '/fixture/rustup', + GOPATH: '/fixture/go', + JAVA_HOME: '/fixture/java', + PYENV_ROOT: '/fixture/pyenv', + VIRTUAL_ENV: '/fixture/venv', + PNPM_HOME: '/fixture/pnpm', + NPM_CONFIG_USERCONFIG: '/fixture/npmrc', + } + const source = Object.freeze({ + ...locations, + MISE_API_TOKEN: 'synthetic-secret', + MISE_UNKNOWN_SECRET: 'synthetic-secret', + UNRELATED_SECRET: 'synthetic-secret', + NODE_OPTIONS: '--require=/fixture/inject.js', + BASH_ENV: '/fixture/startup.sh', + SHELLOPTS: 'xtrace', + LD_PRELOAD: '/fixture/inject.so', + SSH_AUTH_SOCK: '/fixture/ssh.sock', + DISPLAY: ':1', + HTTP_PROXY: 'http://host.invalid:8080', + }) + + expect(createToolchainEnvironment(source, platform)).toEqual(locations) + }) + + it.each(['linux', 'darwin', 'win32'] as const)('rejects empty, undefined, function-shaped and NUL tool values on %s', (platform) => { + expect(createToolchainEnvironment({ + MISE_DATA_DIR: '() { synthetic; }', + MISE_CONFIG_DIR: '/fixture/config\0suffix', + CARGO_HOME: '', + RUSTUP_HOME: undefined, + PNPM_HOME: '/fixture/pnpm', + }, platform)).toEqual({ PNPM_HOME: '/fixture/pnpm' }) + }) + + it.each(['linux', 'darwin', 'win32'] as const)('normalizes tool variable spelling only on Windows (%s)', (platform) => { + expect(createToolchainEnvironment({ mise_data_dir: '/fixture/mise', Cargo_Home: '/fixture/cargo' }, platform)) + .toEqual(platform === 'win32' ? { MISE_DATA_DIR: '/fixture/mise', CARGO_HOME: '/fixture/cargo' } : {}) + }) +}) diff --git a/apps/buddy/platform/process/probeWindowsSandbox.ts b/apps/buddy/platform/process/probeWindowsSandbox.ts index df681f4cb..f1eb2af16 100644 --- a/apps/buddy/platform/process/probeWindowsSandbox.ts +++ b/apps/buddy/platform/process/probeWindowsSandbox.ts @@ -33,7 +33,7 @@ export async function probeWindowsSandbox(executable: string, shell: string): Pr privateRoot: directory, proxyPort: address.port, grants, - environment: createWindowsSandboxEnvironment({ shell, systemRoot, privateRoot: directory, path: [dirname(shell), join(systemRoot, 'System32')].join(';') }), + environment: createWindowsSandboxEnvironment({ SystemRoot: systemRoot, USERPROFILE: join(directory, 'home') }, { shell, systemRoot, privateRoot: directory, path: [dirname(shell), join(systemRoot, 'System32')].join(';') }), }, { signal: AbortSignal.timeout(15_000), onData: (data) => { output = `${output}${data}`.slice(-4_096) }, onStarted: () => {} }) return exitCode === 0 && output === 'SANDBOX_PROBE_OK' } diff --git a/apps/buddy/platform/process/sandboxEnvironment.ts b/apps/buddy/platform/process/sandboxEnvironment.ts index c4aed9862..c1a3d9837 100644 --- a/apps/buddy/platform/process/sandboxEnvironment.ts +++ b/apps/buddy/platform/process/sandboxEnvironment.ts @@ -1,56 +1,58 @@ -import { delimiter, dirname, join } from 'node:path' +import { userInfo } from 'node:os' +import { delimiter, join, win32 } from 'node:path' import process from 'node:process' import { OPERATING_SYSTEM } from '../../shared/platform/identifiers' +import { createToolchainEnvironment } from './toolchainEnvironment' export const SANDBOX_LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1' -export function createWindowsSandboxEnvironment(options: { shell: string, systemRoot: string, privateRoot: string, path: string, proxyUrl?: string }): Record { - const home = join(options.privateRoot, 'home') - const temporary = join(options.privateRoot, 'tmp') +export function createWindowsSandboxEnvironment(source: NodeJS.ProcessEnv, options: { shell: string, systemRoot: string, privateRoot: string, path: string, proxyUrl?: string }): Record { return { + ...createSandboxEnvironment(source, options.privateRoot, OPERATING_SYSTEM.Windows), SystemRoot: options.systemRoot, WINDIR: options.systemRoot, - ComSpec: join(options.systemRoot, 'System32', 'cmd.exe'), + ComSpec: win32.join(options.systemRoot, 'System32', 'cmd.exe'), PATHEXT: '.COM;.EXE;.BAT;.CMD', PATH: options.path, - PSModulePath: join(dirname(options.shell), 'Modules'), + PSModulePath: win32.join(win32.dirname(options.shell), 'Modules'), POWERSHELL_TELEMETRY_OPTOUT: '1', - HOME: home, - USERPROFILE: home, - APPDATA: home, - LOCALAPPDATA: home, - TEMP: temporary, - TMP: temporary, ...(options.proxyUrl ? { HTTP_PROXY: options.proxyUrl, HTTPS_PROXY: options.proxyUrl, ALL_PROXY: options.proxyUrl, NO_PROXY: SANDBOX_LOOPBACK_NO_PROXY, NODE_USE_ENV_PROXY: '1' } : {}), } } -export function createSandboxEnvironment(source: NodeJS.ProcessEnv, directory: string): NodeJS.ProcessEnv { - if (process.platform === OPERATING_SYSTEM.Windows) { - const systemRoot = source.SystemRoot ?? source.SYSTEMROOT - if (!systemRoot) - throw new Error('Windows system directory is unavailable') +export function createSandboxEnvironment(source: NodeJS.ProcessEnv, directory: string, platform: NodeJS.Platform = process.platform): Record { + const environment = createToolchainEnvironment(source, platform) + if (platform === OPERATING_SYSTEM.Windows) { + const inherited = Object.fromEntries(Object.entries(source).map(([name, value]) => [name.toUpperCase(), value])) + const systemRoot = inherited.SYSTEMROOT + const userProfile = inherited.USERPROFILE + if (!systemRoot || !userProfile) + throw new Error('Windows system or user directory is unavailable') + for (const name of ['APPDATA', 'LOCALAPPDATA', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME']) { + if (inherited[name]) + environment[name] = inherited[name] + } return { + ...environment, SystemRoot: systemRoot, WINDIR: systemRoot, - USERPROFILE: source.USERPROFILE, - LOCALAPPDATA: source.LOCALAPPDATA, - APPDATA: source.APPDATA, - TEMP: join(directory, 'tmp'), - TMP: join(directory, 'tmp'), - PATH: join(systemRoot, 'System32'), + HOME: inherited.HOME || userProfile, + USERPROFILE: userProfile, + TEMP: win32.join(directory, 'tmp'), + TMP: win32.join(directory, 'tmp'), + PATH: win32.join(systemRoot, 'System32'), } } + for (const name of ['USER', 'LOGNAME', 'LC_ALL', 'TZ']) { + if (source[name]) + environment[name] = source[name] + } return { - HOME: join(directory, 'home'), + ...environment, + HOME: source.HOME || userInfo().homedir, TMPDIR: join(directory, 'tmp'), - XDG_CACHE_HOME: join(directory, 'home', '.cache'), - XDG_CONFIG_HOME: join(directory, 'home', '.config'), - XDG_DATA_HOME: join(directory, 'home', '.local', 'share'), PATH: ['/usr/local/bin', '/usr/bin', '/bin'].join(delimiter), LANG: source.LANG || 'C.UTF-8', - ...(source.LC_ALL ? { LC_ALL: source.LC_ALL } : {}), - ...(source.TZ ? { TZ: source.TZ } : {}), TERM: 'dumb', } } diff --git a/apps/buddy/platform/process/toolchainEnvironment.ts b/apps/buddy/platform/process/toolchainEnvironment.ts new file mode 100644 index 000000000..8232c1edd --- /dev/null +++ b/apps/buddy/platform/process/toolchainEnvironment.ts @@ -0,0 +1,66 @@ +import process from 'node:process' + +const TOOLCHAIN_ENVIRONMENT_NAMES = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'MISE_DATA_DIR', + 'MISE_CONFIG_DIR', + 'MISE_CACHE_DIR', + 'MISE_STATE_DIR', + 'MISE_INSTALLS_DIR', + 'MISE_INSTALL_STORE_DIR', + 'MISE_PLUGINS_DIR', + 'MISE_DOWNLOADS_DIR', + 'MISE_SHIMS_DIR', + 'MISE_SYSTEM_CONFIG_DIR', + 'MISE_SYSTEM_DIR', + 'MISE_SYSTEM_DATA_DIR', + 'MISE_SYSTEM_INSTALLS_DIR', + 'MISE_SYSTEM_SHIMS_DIR', + 'MISE_CONFIG_FILE', + 'MISE_GLOBAL_CONFIG_FILE', + 'MISE_ENV', + 'NVM_DIR', + 'NVM_BIN', + 'NVM_INC', + 'FNM_DIR', + 'FNM_MULTISHELL_PATH', + 'VOLTA_HOME', + 'ASDF_DATA_DIR', + 'ASDF_CONFIG_FILE', + 'CARGO_HOME', + 'RUSTUP_HOME', + 'GOPATH', + 'GOROOT', + 'GOBIN', + 'JAVA_HOME', + 'JDK_HOME', + 'SDKMAN_DIR', + 'SDKMAN_CANDIDATES_DIR', + 'GRADLE_USER_HOME', + 'MAVEN_USER_HOME', + 'PYENV_ROOT', + 'PYENV_VERSION', + 'VIRTUAL_ENV', + 'CONDA_PREFIX', + 'CONDA_DEFAULT_ENV', + 'UV_CACHE_DIR', + 'UV_PYTHON_INSTALL_DIR', + 'PNPM_HOME', + 'NPM_CONFIG_CACHE', + 'NPM_CONFIG_PREFIX', + 'NPM_CONFIG_USERCONFIG', +] as const + +export function createToolchainEnvironment(source: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): Record { + const names = new Set(TOOLCHAIN_ENVIRONMENT_NAMES) + const environment: Record = {} + for (const [name, value] of Object.entries(source)) { + const key = platform === 'win32' ? name.toUpperCase() : name + if (names.has(key) && value && !value.startsWith('()') && !value.includes('\0')) + environment[key] = value + } + return environment +} diff --git a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts index 7242bb504..9a8bdd55c 100644 --- a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts +++ b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts @@ -107,6 +107,38 @@ describe('windows sandbox policy assembly', () => { } }) + it('protects credentials in every user home and relocated package-manager store', async () => { + const sensitiveRoots = resolveSensitivePathRoots({ + home, + environment: { + Home: 'D:\\Git Home', + UserProfile: home, + HomeDrive: 'H:', + HomePath: '\\Home', + AppData: 'D:\\Roaming', + LocalAppData: 'D:\\Local', + CARGO_HOME: 'D:\\Cargo', + NPM_CONFIG_USERCONFIG: 'D:\\Config\\npmrc', + }, + }) + const policy = await createWindowsSandboxPolicy({ ...input, sensitiveRoots }, new AbortController().signal) + const denied = policy.grants.filter(grant => grant.access === 'denyRead').map(grant => grant.path) + expect(denied).toEqual(expect.arrayContaining([ + 'C:\\Users\\Fixture\\.ssh', + 'D:\\Git Home\\.ssh', + 'D:\\Git Home\\.npmrc', + 'H:\\Home\\.netrc', + 'D:\\Cargo\\credentials', + 'D:\\Cargo\\credentials.toml', + 'D:\\Config\\npmrc', + 'D:\\Roaming\\gcloud', + ])) + expect(denied).not.toContain('D:\\Git Home\\.config\\mise') + await expect(createWindowsSandboxPolicy({ ...input, sensitiveRoots, roots: ['D:\\Git Home\\.ssh'], workspaceRoots: ['D:\\Git Home\\.ssh'], cwd: 'D:\\Git Home\\.ssh' }, new AbortController().signal)) + .rejects + .toMatchObject({ code: 'SANDBOX_UNAVAILABLE' }) + }) + it('requires host protection data without letting command callers supply it', () => { expect(sandboxProcessInputSchema.safeParse(input).success).toBe(true) expect(sandboxProcessInputSchema.safeParse({ ...input, sensitiveRoots: undefined }).success).toBe(false) diff --git a/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts b/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts index cf9904507..8f000c43b 100644 --- a/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts @@ -1,9 +1,9 @@ -import { tmpdir } from 'node:os' +import { tmpdir, userInfo } from 'node:os' import { join } from 'node:path' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { createBuddyServiceEnvironment } from '../../../../electron/main/runtime/buddyServiceEnvironment' import { profileStorageProtection } from '../../../../platform/storage/ProfileLocations' -import { createSensitivePathMatcher } from '../sensitivePaths' +import { createSensitivePathMatcher, resolveSensitivePathRoots } from '../sensitivePaths' it('protects retained credentials without restricting entire historical directories or inspecting old volumes', () => { const root = join(tmpdir(), 'lexora-sensitive-fixture') @@ -26,6 +26,24 @@ it('protects retained credentials without restricting entire historical director expect(sensitive.matches(join(activeRoot, 'buddy', 'conversations', 'task', 'workspace', 'output.txt'))).toBe(false) }) +it('protects both the OS home and a separate tool home with an empty ambient HOME fallback', () => { + vi.stubEnv('HOME', '') + try { + expect(resolveSensitivePathRoots({ environment: { HOME: '' } })).toContain(join(userInfo().homedir, '.ssh')) + const home = join(tmpdir(), 'toolchain-home-fixture') + const other = join(tmpdir(), 'toolchain-alternate-home') + const cargo = join(tmpdir(), 'toolchain-cargo') + const npmrc = join(tmpdir(), 'toolchain-npm-config') + const matcher = createSensitivePathMatcher({ home, environment: { HOME: other, CARGO_HOME: cargo, NPM_CONFIG_USERCONFIG: npmrc } }) + for (const path of [join(home, '.ssh', 'id_ed25519'), join(other, '.netrc'), join(cargo, 'credentials.toml'), npmrc]) + expect(matcher.matches(path), path).toBe(true) + expect(matcher.matches(join(other, '.config', 'mise', 'config.toml'))).toBe(false) + } + finally { + vi.unstubAllEnvs() + } +}) + it('protects agent credentials without classifying all configuration and skills as secrets', () => { const home = join(tmpdir(), 'credential-matcher') const matcher = createSensitivePathMatcher({ home, environment: {} }) diff --git a/apps/buddy/service/src/permissions/sensitivePaths.ts b/apps/buddy/service/src/permissions/sensitivePaths.ts index d074aac75..dd14f6063 100644 --- a/apps/buddy/service/src/permissions/sensitivePaths.ts +++ b/apps/buddy/service/src/permissions/sensitivePaths.ts @@ -1,7 +1,7 @@ -import { homedir } from 'node:os' import process from 'node:process' import { containsCanonicalPath, filePaths } from '../../../platform/filesystem/filePaths' import { resolveSensitiveLocations } from '../../../platform/filesystem/sensitiveLocations' +import { resolveUserHomePaths } from '../../../platform/filesystem/userHomePaths' const HOME_RELATIVE_ROOTS = [ '.aws', @@ -49,15 +49,16 @@ export function createSensitivePathMatcher( } export function resolveSensitivePathRoots(options: CreateSensitivePathMatcherOptions = {}): string[] { - const home = options.home ?? safeHomedir() const environment = options.environment ?? process.env + const homes = resolveUserHomePaths(environment, options.home) const roots = new Set() - for (const relative of HOME_RELATIVE_ROOTS) { - if (home) + for (const home of homes) { + for (const relative of HOME_RELATIVE_ROOTS) roots.add(filePaths.resolveInput(relative, home)) } - for (const root of [...resolveSensitiveLocations(home, environment), ...(options.additionalRoots ?? [])]) { + const locations = (homes.length ? homes : [null]).flatMap(home => resolveSensitiveLocations(home, environment)) + for (const root of [...locations, ...(options.additionalRoots ?? [])]) { try { roots.add(filePaths.resolveInput(root)) } @@ -68,13 +69,3 @@ export function resolveSensitivePathRoots(options: CreateSensitivePathMatcherOpt return [...roots] } - -function safeHomedir(): string | null { - try { - const home = homedir() - return home ? filePaths.resolveInput(home) : null - } - catch { - return null - } -} diff --git a/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts index 970a94987..f20a8990c 100644 --- a/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts @@ -99,11 +99,11 @@ describe.skipIf(process.platform !== 'darwin' || process.arch !== 'arm64')('macO expect(await readFile(join(workspace, '.git/config'), 'utf8')).toBe('git-preserved') }, 15_000) - it('enforces read-only access while keeping isolated HOME and scratch space usable', async () => { + it('enforces read-only access while preserving the user home and private scratch space', async () => { const run = await execute('set -e; ! touch new-file; test -z "$SSH_AUTH_SOCK$ANTHROPIC_API_KEY"; printf scratch > "$TMPDIR/scratch"; cat "$TMPDIR/scratch"; printf "%s" "$HOME"', { readOnly: true }) expect(run.result, run.output).toEqual({ ok: true, exitCode: 0 }) expect(run.output).toContain('scratch') - expect(run.output).toContain(join(input.privateRoot, 'home')) + expect(run.output).toContain(environment.HOME || home) await expect(access(join(workspace, 'new-file'))).rejects.toThrow() }, 15_000) diff --git a/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts b/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts index ff236793c..94a3c1653 100644 --- a/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/sandboxPermissions.spec.ts @@ -74,7 +74,7 @@ describe('isolated shell permissions', () => { } }) - it('builds a private shell environment without provider secrets, host sockets or proxy inheritance', () => { + it('preserves the user home without provider secrets, host sockets or proxy inheritance', () => { const environment = createSandboxEnvironment({ PATH: '/untrusted/bin', HOME: '/real/home', @@ -87,11 +87,8 @@ describe('isolated shell permissions', () => { DISPLAY: ':0', }, '/tmp/private') expect(environment).toEqual({ - HOME: '/tmp/private/home', + HOME: '/real/home', TMPDIR: '/tmp/private/tmp', - XDG_CACHE_HOME: '/tmp/private/home/.cache', - XDG_CONFIG_HOME: '/tmp/private/home/.config', - XDG_DATA_HOME: '/tmp/private/home/.local/share', PATH: '/usr/local/bin:/usr/bin:/bin', LANG: 'C.UTF-8', TERM: 'dumb', diff --git a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts index c5a12fd5c..e0a91db5b 100644 --- a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts @@ -1,5 +1,6 @@ import type { SandboxProcessInput } from '../../../../shared/permissions/shellSandbox' import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -8,6 +9,7 @@ import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import process from 'node:process' import { setTimeout } from 'node:timers/promises' +import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { verifyOwnedProcessRelease } from '../../../../platform/process/ownedProcessRecovery' import { resolveSandboxDependencies } from '../../../../platform/process/sandboxDependencies' @@ -19,6 +21,7 @@ import { runSandboxCommand } from '../runSandboxCommand' const buddyRoot = process.cwd() const originalEnvironment = { ...process.env } const originalHome = homedir() +const miseExecutable = originalEnvironment.LEXORA_TEST_MISE const quote = (value: string) => `'${value.replaceAll('\'', '\'\\\'\'')}'` describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { @@ -36,7 +39,7 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { await Promise.all([workspace, outside].map(path => mkdir(path, { recursive: true }))) await writeFile(join(outside, 'preserved'), 'outside-preserved') const environment = createSandboxEnvironment(originalEnvironment, privateRoot) - environment.LEXORA_BUDDY_PROCESS_CONTROL = originalEnvironment.LEXORA_BUDDY_PROCESS_CONTROL + environment.LEXORA_BUDDY_PROCESS_CONTROL = originalEnvironment.LEXORA_BUDDY_PROCESS_CONTROL! for (const key of new Set([...Object.keys(process.env), ...Object.keys(environment)])) vi.stubEnv(key, environment[key]) process.chdir(privateRoot) @@ -187,7 +190,77 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { await expect(access(join(workspace, 'new-file'))).rejects.toThrow() }, 15_000) - it('protects host-resolved credentials after HOME and XDG variables are isolated', async () => { + it('resolves existing toolchain configuration across fresh command environments without granting home writes', async () => { + const home = join(directory, 'user-home') + const config = join(directory, 'user-config') + const data = join(directory, 'toolchain-data') + const shims = join(data, 'shims') + await Promise.all([home, join(config, 'mise'), shims, join(data, 'installs')].map(path => mkdir(path, { recursive: true }))) + await writeFile(join(config, 'mise', 'config.toml'), 'TOOLCHAIN_CONFIG\n') + await writeFile(join(data, 'installs', 'version'), 'TOOLCHAIN_VERSION\n') + await writeFile(join(shims, 'toolchain-probe'), '#!/bin/sh\nset -eu\ncat "$XDG_CONFIG_HOME/mise/config.toml"\ncat "$MISE_DATA_DIR/installs/version"\n') + await chmod(join(shims, 'toolchain-probe'), 0o755) + const source = { ...originalEnvironment, HOME: home, XDG_CONFIG_HOME: config, MISE_DATA_DIR: data, MISE_API_TOKEN: 'SYNTHETIC_TOKEN' } + for (let index = 0; index < 2; index++) { + const scratch = await createSandboxDirectory() + try { + const environment = createSandboxEnvironment(source, scratch) + environment.LEXORA_BUDDY_PROCESS_CONTROL = originalEnvironment.LEXORA_BUDDY_PROCESS_CONTROL! + for (const name of new Set([...Object.keys(process.env), ...Object.keys(environment)])) + vi.stubEnv(name, environment[name]) + const command = `set -e; toolchain-probe; test "$HOME" = ${quote(home)}; test -z "${'$'}{MISE_API_TOKEN:-}"; printf scratch > "$TMPDIR/probe"; ! touch "$HOME/must-not-write"; ! touch "$MISE_DATA_DIR/must-not-write"` + const run = await execute(command, {}, { home, privateRoot: scratch, path: `${shims}:${input.path}`, readOnly: true }) + expect(run.result, run.output).toEqual({ ok: true, exitCode: 0 }) + expect(run.output).toContain('TOOLCHAIN_CONFIG\nTOOLCHAIN_VERSION') + await expect(access(join(home, 'must-not-write'))).rejects.toThrow() + await expect(access(join(data, 'must-not-write'))).rejects.toThrow() + } + finally { + await rm(scratch, { recursive: true, force: true }) + } + } + expect(await readFile(join(data, 'installs', 'version'), 'utf8')).toBe('TOOLCHAIN_VERSION\n') + }, 20_000) + + it.skipIf(!miseExecutable).each([false, true])('runs real mise exec and shims against existing installations (custom directories: %s)', async (customDirectories) => { + const home = join(directory, 'mise-home') + const data = customDirectories ? join(directory, 'mise-data') : join(home, '.local/share/mise') + const config = customDirectories ? join(directory, 'mise-config') : join(home, '.config/mise') + const runtime = join(directory, 'node-runtime') + const environment = { + HOME: home, + PATH: '/usr/bin:/bin', + ...(customDirectories ? { MISE_DATA_DIR: data, MISE_CONFIG_DIR: config, MISE_STATE_DIR: join(directory, 'mise-state') } : {}), + } + await Promise.all([home, config, join(runtime, 'bin')].map(path => mkdir(path, { recursive: true }))) + await symlink(process.execPath, join(runtime, 'bin', 'node')) + await writeFile(join(config, 'config.toml'), '[settings]\nparanoid = true\nauto_install = false\n') + await writeFile(join(workspace, 'mise.toml'), '[tools]\nnode = "fixture"\n[env]\nLEXORA_TOOLCHAIN_FIXTURE = "MISE_ENV_OK"\n') + const setup = promisify(execFile) + for (const args of [['link', 'node@fixture', runtime], ['trust', join(workspace, 'mise.toml')], ['reshim']]) + await setup(miseExecutable!, args, { cwd: home, env: environment, timeout: 15_000 }) + for (const command of [ + `${quote(miseExecutable!)} exec -- node -p 'process.env.LEXORA_TOOLCHAIN_FIXTURE'`, + 'node -p \'process.env.LEXORA_TOOLCHAIN_FIXTURE\'', + ]) { + const scratch = await createSandboxDirectory() + try { + const inherited = createSandboxEnvironment(environment, scratch) + inherited.LEXORA_BUDDY_PROCESS_CONTROL = originalEnvironment.LEXORA_BUDDY_PROCESS_CONTROL! + for (const name of new Set([...Object.keys(process.env), ...Object.keys(inherited)])) + vi.stubEnv(name, inherited[name]) + const run = await execute(command, {}, { home, privateRoot: scratch, path: `${join(data, 'shims')}:/usr/bin:/bin`, readOnly: true }) + expect(run.result, run.output).toEqual({ ok: true, exitCode: 0 }) + expect(run.output).toContain('MISE_ENV_OK') + } + finally { + await rm(scratch, { recursive: true, force: true }) + } + } + expect(await readFile(join(workspace, 'mise.toml'), 'utf8')).toContain('node = "fixture"') + }, 45_000) + + it('protects credentials while preserving host HOME and XDG locations', async () => { const home = join(directory, 'host-home') const configuration = join(directory, 'relocated-config') const data = join(directory, 'relocated-data') @@ -200,7 +273,10 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { await writeFile(join(workspace, '.netrc'), 'PROJECT_FIXTURE') await writeFile(join(configuration, 'ordinary.txt'), 'ORDINARY_CONFIGURATION') const sensitiveRoots = resolveSensitivePathRoots({ home, environment }) - const command = `set -e; ${credentials.map(path => `! cat ${quote(path)}`).join('; ')}; cat .netrc; cat ${quote(join(configuration, 'ordinary.txt'))}; test "$HOME" != ${quote(home)}; test "${'$'}{XDG_CONFIG_HOME:-}" != ${quote(configuration)}` + vi.stubEnv('HOME', home) + for (const [name, value] of Object.entries(environment)) + vi.stubEnv(name, value) + const command = `set -e; ${credentials.map(path => `! cat ${quote(path)}`).join('; ')}; cat .netrc; cat "$XDG_CONFIG_HOME/ordinary.txt"; test "$HOME" = ${quote(home)}; test "${'$'}{XDG_CONFIG_HOME:-}" = ${quote(configuration)}` const result = await execute(command, {}, { home, sensitiveRoots, readOnly: true }) expect(result.result, result.output).toEqual({ ok: true, exitCode: 0 }) expect(result.output).not.toContain('SYNTHETIC_CREDENTIAL') @@ -278,7 +354,7 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { : `server.on('error', error => assert.equal(error.code, 'EPERM')); server.listen(path);`} });` try { - const command = `set -e; test -z "$SSH_AUTH_SOCK$DBUS_SESSION_BUS_ADDRESS$ANTHROPIC_API_KEY"; test "$HOME" = ${quote(join(input.privateRoot, 'home'))}; node -e ${quote(script)}` + const command = `set -e; test -z "$SSH_AUTH_SOCK$DBUS_SESSION_BUS_ADDRESS$ANTHROPIC_API_KEY"; test "$HOME" = ${quote(originalEnvironment.HOME || originalHome)}; node -e ${quote(script)}` for (const readOnly of [false, true]) { const { result, output } = await execute(command, {}, { readOnly }) expect(result, output).toEqual({ ok: true, exitCode: 0 }) diff --git a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts index 96c7081c6..c387aa8a3 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts @@ -3,6 +3,7 @@ import type { SandboxBackendInput } from '../../../../../shared/permissions/shel import type { SHELL_SANDBOX_BACKEND } from '../../../../../shared/platform/identifiers' import type { SandboxExecutionOptions } from '../../sandboxExecutionLifecycle' import { randomBytes } from 'node:crypto' +import process from 'node:process' import { createHttpProxyServer } from '@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js' import { resolveParentProxy } from '@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js' import { createResolvedAddressGuard } from '@anthropic-ai/sandbox-runtime/dist/sandbox/resolved-address-guard.js' @@ -43,7 +44,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput