From b564ddad390872568208b5797d2435e11ed26518 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 19 Oct 2026 16:43:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(buddy):=20=E9=87=8D=E6=9E=84=20Windows?= =?UTF-8?q?=20=E6=B2=99=E7=9B=92=E5=B9=B6=E7=AE=80=E5=8C=96=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .playwright/scripts/__tests__/sandbox.e2e.mjs | 146 +++++ .playwright/scripts/buddy-test.mjs | 2 +- .../electron/main/app/DesktopRuntimeHost.ts | 24 +- .../main/sandbox/WindowsSandboxPreparation.ts | 97 +++ .../main/sandbox/registerSandboxHostRpc.ts | 22 +- .../main/sandbox/verifySandboxInstallation.ts | 94 ++- apps/buddy/native/host/Cargo.toml | 3 + .../__tests__/process_recovery_windows.rs | 11 +- .../host/__tests__/sandbox_adversary.rs | 101 +++- .../host/__tests__/shell_sandbox.windows.mjs | 204 ++++++- .../host/__tests__/shell_sandbox_account.rs | 87 +++ .../host/__tests__/shell_sandbox_acl_state.rs | 348 +++++++++++ .../__tests__/shell_sandbox_filesystem.rs | 235 ++++++++ .../host/__tests__/shell_sandbox_metadata.rs | 3 + apps/buddy/native/host/src/shell_sandbox.rs | 6 +- .../native/host/src/shell_sandbox/account.rs | 562 ++++++++++++++++++ .../host/src/shell_sandbox/acl_state.rs | 402 +++++++++++++ .../native/host/src/shell_sandbox/broker.rs | 311 +++++++--- .../host/src/shell_sandbox/cancellation.rs | 4 +- .../host/src/shell_sandbox/filesystem.rs | 548 ++++++++++++----- .../native/host/src/shell_sandbox/metadata.rs | 197 +----- .../native/host/src/shell_sandbox/network.rs | 62 +- .../native/host/src/shell_sandbox/policy.rs | 242 +------- .../native/host/src/shell_sandbox/process.rs | 355 +++++++---- .../native/host/src/shell_sandbox/security.rs | 34 +- .../native/host/src/shell_sandbox/setup.rs | 6 + .../native/host/src/shell_sandbox/token.rs | 117 ++++ .../process/__tests__/windowsSandbox.spec.ts | 35 +- .../platform/process/probeWindowsSandbox.ts | 15 +- apps/buddy/platform/process/windowsSandbox.ts | 61 +- .../platform/process/windowsSandboxProcess.ts | 22 +- .../windows/__tests__/fileConsumers.spec.ts | 2 +- .../windows/__tests__/sandboxPolicy.spec.ts | 67 +++ apps/buddy/service/src/BuddyService.ts | 23 + .../buddyComposerInputBoundary.spec.ts | 3 + .../createBuddySessionExtensions.ts | 1 + .../resources/createBuddySystemPrompt.ts | 16 +- .../__tests__/ComposerResourceService.spec.ts | 2 +- .../__tests__/sessionMaterials.spec.ts | 3 + .../__tests__/searchDirectoryEntries.spec.ts | 4 +- .../src/permissions/PermissionEngine.ts | 17 +- .../permissions/ToolAuthorizationService.ts | 23 +- .../__tests__/PermissionEngine.spec.ts | 6 +- .../ToolAuthorizationService.spec.ts | 1 + .../__tests__/shellFileDeletion.spec.ts | 8 +- .../__tests__/storageSensitivePaths.spec.ts | 9 + .../src/permissions/permissionContract.ts | 3 +- .../service/src/permissions/sensitivePaths.ts | 19 +- .../service/src/sandbox/ShellSandboxClient.ts | 6 + .../SandboxDirectoryPermissions.spec.ts | 49 +- .../sandbox/__tests__/macosSandbox.spec.ts | 10 +- .../sandbox/__tests__/shellSandbox.spec.ts | 55 +- .../src/sandbox/backends/srt/createPolicy.ts | 13 +- .../src/sandbox/backends/srt/linuxPolicy.ts | 6 +- .../src/sandbox/backends/srt/macosPolicy.ts | 2 +- .../src/sandbox/backends/srt/runSrtSandbox.ts | 4 +- .../sandbox/backends/windows/createPolicy.ts | 31 +- .../backends/windows/runWindowsSandbox.ts | 1 + .../src/sandbox/sandboxFilesystemPolicy.ts | 33 +- .../service/src/sandbox/shellCapability.ts | 9 +- .../service/src/sandbox/shellExecution.ts | 2 + apps/buddy/shared/permissions/shellSandbox.ts | 9 +- apps/buddy/src/i18n/locales/en-US/chat.ts | 15 +- apps/buddy/src/i18n/locales/zh-CN/chat.ts | 15 +- .../DesktopPermissionModeSelector.vue | 2 +- apps/website/src/guide/permissions.md | 14 +- .../buddy/release/verify-windows-sandbox.ps1 | 1 + 67 files changed, 3832 insertions(+), 1008 deletions(-) create mode 100644 .playwright/scripts/__tests__/sandbox.e2e.mjs create mode 100644 apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts create mode 100644 apps/buddy/native/host/__tests__/shell_sandbox_account.rs create mode 100644 apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs create mode 100644 apps/buddy/native/host/src/shell_sandbox/account.rs create mode 100644 apps/buddy/native/host/src/shell_sandbox/acl_state.rs create mode 100644 apps/buddy/native/host/src/shell_sandbox/token.rs create mode 100644 apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts diff --git a/.playwright/scripts/__tests__/sandbox.e2e.mjs b/.playwright/scripts/__tests__/sandbox.e2e.mjs new file mode 100644 index 000000000..99f6448b4 --- /dev/null +++ b/.playwright/scripts/__tests__/sandbox.e2e.mjs @@ -0,0 +1,146 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import targets from '../../../apps/buddy/platform/targets.json' with { type: 'json' } +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) + 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)) + expect(processes).toHaveLength(1) + const outDir = await mkdtemp(path.join(mainDirectory, 'sandbox-verification-')) + try { + await build({ + configFile: false, + root: buddyRoot, + logLevel: 'error', + publicDir: false, + plugins: [{ + name: 'sandbox-verification-process', + enforce: 'pre', + resolveId: source => source.endsWith('?modulePath') ? source : null, + load: id => id.endsWith('?modulePath') ? `export default ${JSON.stringify(path.join(mainDirectory, processes[0]))}` : null, + }], + build: { + outDir, + target: 'node22', + minify: false, + lib: { entry: path.join(buddyRoot, 'electron/main/sandbox/verifySandboxInstallation.ts'), formats: ['es'], fileName: () => 'verify.mjs' }, + rollupOptions: { external: ['electron', /^node:/] }, + }, + }) + 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') + const measurements = await app.evaluate(async (_electron, { module, home, root, target }) => { + const file = process.getBuiltinModule('node:url').fileURLToPath(module) + const { join } = process.getBuiltinModule('node:path') + const childProcess = process.getBuiltinModule('node:child_process') + const spawn = childProcess.spawn + const execFile = childProcess.execFile + let nativeDiagnostics = '' + const inspections = [] + childProcess.execFile = (...args) => { + const start = performance.now() + const child = execFile(...args) + if (String(args[0]).endsWith('lexora-buddy-sandbox.exe')) + child.once('close', () => inspections.push({ action: args[1]?.[0], elapsedMs: performance.now() - start })) + return child + } + const { promisify } = process.getBuiltinModule('node:util') + Object.defineProperty(childProcess.execFile, promisify.custom, { + value: async (...args) => { + const start = performance.now() + try { + return await promisify(execFile)(...args) + } + finally { + if (String(args[0]).endsWith('lexora-buddy-sandbox.exe')) + inspections.push({ action: args[1]?.[0], elapsedMs: performance.now() - start }) + } + }, + }) + childProcess.spawn = (...args) => { + const child = spawn(...args) + if (String(args[0]).endsWith('lexora-buddy-sandbox.exe')) + child.stderr?.on('data', data => nativeDiagnostics = `${nativeDiagnostics}${data}`.slice(-8192)) + return child + } + process.getBuiltinModule('node:module').syncBuiltinESMExports() + try { + const { verifySandboxInstallation } = process.getBuiltinModule('node:module').createRequire(file)(file) + const measurements = await verifySandboxInstallation({ + buddyHome: home, + protectedRoots: [home], + ...(process.platform === 'win32' + ? { + windowsSandbox: join(root, `.output/build/native/${target.rustTarget}/release/lexora-buddy-sandbox.exe`), + windowsShell: join(process.env.ProgramFiles, 'PowerShell/7/pwsh.exe'), + } + : { + sandboxDirectory: join(root, `.output/build/shell-sandbox/${process.platform}-${process.arch}`), + processControl: join(root, `.output/build/native/${target.rustTarget}/release/lexora-buddy-process-control`), + }), + searchDirectory: join(root, `.output/build/search-tools/${process.platform}-${process.arch}`), + }, true) + return { ...measurements, inspections } + } + catch (error) { + throw new Error(`${error.message}; ${nativeDiagnostics}; ${JSON.stringify(inspections)}`) + } + finally { + childProcess.spawn = spawn + childProcess.execFile = execFile + process.getBuiltinModule('node:module').syncBuiltinESMExports() + } + }, { module: pathToFileURL(path.join(outDir, 'verify.mjs')).href, home: instance.home, root: buddyRoot, target: targets[`${process.platform}-${process.arch}`] }) + expect(measurements.repeatedRpcMs).toHaveLength(3) + await testInfo.attach('sandbox-scale', { body: JSON.stringify({ toolchainFiles: 50_000, workspaceFiles: 5_000, ...measurements }), contentType: 'application/json' }) + } + 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) + const instance = await buddy.createInstance('sandbox-binding') + const directory = await mkdtemp(path.join(tmpdir(), 'lexora-sandbox-binding-')) + const shell = path.join(process.env.ProgramFiles, 'PowerShell/7/pwsh.exe') + const explicitWriters = async () => { + const command = `$acl = Get-Acl -LiteralPath '${directory.replaceAll('\'', '\'\'')}'; $writers = @($acl.GetAccessRules($true, $false, [Security.Principal.SecurityIdentifier]) | Where-Object { $_.AccessControlType -eq 'Allow' -and ($_.FileSystemRights -band 2) -ne 0 } | ForEach-Object { $_.IdentityReference.Value }); [Console]::Out.Write((ConvertTo-Json -InputObject $writers -Compress))` + const { stdout } = await promisify(execFile)(shell, ['-NoLogo', '-NoProfile', '-Command', command], { windowsHide: true, timeout: 10_000 }) + return JSON.parse(stdout) + } + try { + await writeFile(path.join(directory, 'before-command.txt'), 'fixture') + const original = await explicitWriters() + const { app, page } = await instance.launch() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.runtime.getStatus()).status), { timeout: 120_000 }).toBe('ready') + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + }, directory) + const space = await page.evaluate(async () => { + const root = await window.lexoraDesktop.localChat.spaces.selectDirectory() + return window.lexoraDesktop.localChat.spaces.create({ name: 'Sandbox binding', memoryScope: 'space_only', primaryDirectory: { id: null, root } }) + }) + await expect.poll(async () => (await explicitWriters()).filter(sid => !original.includes(sid)).length, { timeout: 60_000 }).toBeGreaterThanOrEqual(2) + await page.evaluate(id => window.lexoraDesktop.localChat.spaces.delete(id), space.id) + } + finally { + await instance.stop() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/.playwright/scripts/buddy-test.mjs b/.playwright/scripts/buddy-test.mjs index be6c3cc81..ac9e72e4a 100644 --- a/.playwright/scripts/buddy-test.mjs +++ b/.playwright/scripts/buddy-test.mjs @@ -116,7 +116,7 @@ export async function createBuddyTestRun({ appPath = process.env.LEXORA_TEST_APP assert.equal(new URL(page.url()).protocol, 'data:') return { app: application, page, diagnostics: launchRecord.diagnostics } } - assert.equal(rendererUrl ? new URL(page.url()).origin : new URL(page.url()).protocol, rendererUrl ? new URL(rendererUrl).origin : 'lexora-app:', `Application failed to start: ${await page.locator('body').textContent()}`) + assert.equal(rendererUrl ? new URL(page.url()).origin : new URL(page.url()).protocol, rendererUrl ? new URL(rendererUrl).origin : 'lexora-app:') await page.waitForFunction(async status => (await window.lexoraDesktop?.app.startup.getState())?.status === status, startupStatus, { timeout: 45000 }) const info = await page.evaluate(() => window.lexoraDesktop.app.getInfo()) assert.equal(info.runtimeProfile, 'test') diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 98dd38b97..15fe0896f 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -56,6 +56,8 @@ export class DesktopRuntimeHost { #network: DesktopNetwork | null = null #windowsPowerShell: string | undefined readonly #subscriptions: Array<() => void> = [] + #prepareSandbox: (() => void) | undefined + readonly #sandboxCleanup = new Set>() #sandboxCheck: ReturnType | null = null #sandboxSetup: ReturnType | null = null @@ -106,7 +108,11 @@ export class DesktopRuntimeHost { } setupSandbox(): ReturnType { - this.#sandboxSetup ??= setupWindowsSandbox(this.#sandboxExecutable()).finally(() => { + this.#sandboxSetup ??= setupWindowsSandbox(this.#sandboxExecutable()).then((result) => { + if (result === 'ready') + this.#prepareSandbox?.() + return result + }).finally(() => { this.#sandboxSetup = null this.#sandboxCheck = null }) @@ -173,9 +179,15 @@ export class DesktopRuntimeHost { const sandbox = registerSandboxHostRpc(peer, { buddyHome: environment.paths.buddyHome, protectedRoots: environment.storage.protection.sandboxRoots, + protectionScope: environment.storage.bootstrapRoot, proxyUrl: this.#network!.sandboxProxyUrl, ...this.#sandboxOptions(), }) + const prepareSandbox = () => { + sandbox.prepare() + peer.notify('host.sandbox.ready', {}) + } + this.#prepareSandbox = prepareSandbox sandbox.onDidChange(event => environment.events.publish({ event: `sandbox.supervisor.${event.kind}`, level: event.kind === 'cleanup_failed' ? 'warn' : 'info', @@ -200,7 +212,6 @@ export class DesktopRuntimeHost { return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness') }), registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable), - sandbox.dispose, registerBrowserHostRpc(peer, { createAdapterLease: input => this.#browser.adapter.issueLease(input), getHost: () => this.#browser.host, @@ -208,7 +219,13 @@ export class DesktopRuntimeHost { registerCredentialHostRpc(peer, credentials), ...this.#features.map(feature => feature.bindPeer(peer)), ] - return () => disposers.forEach(dispose => dispose()) + return () => { + if (this.#prepareSandbox === prepareSandbox) + this.#prepareSandbox = undefined + const cleanup = sandbox.dispose().finally(() => this.#sandboxCleanup.delete(cleanup)) + this.#sandboxCleanup.add(cleanup) + disposers.forEach(dispose => dispose()) + } }, diagnosticOutput: environment.diagnostics.createWritable('local-service', { event: 'runtime.supervisor', level: 'error' }), spawnService: (onFatalError, sourceId) => forkBuddyServiceProcess({ @@ -277,6 +294,7 @@ export class DesktopRuntimeHost { () => this.contextPanel.dispose(), () => this.configStore.dispose(), () => this.#service?.stop(), + () => Promise.all(this.#sandboxCleanup), () => this.#network?.stop(), ...this.#features.map(feature => () => feature.stop()), ...this.#subscriptions.splice(0), diff --git a/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts b/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts new file mode 100644 index 000000000..826a3516c --- /dev/null +++ b/apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts @@ -0,0 +1,97 @@ +import type { SandboxHostOptions } from './registerSandboxHostRpc' +import { homedir } from 'node:os' +import process from 'node:process' +import { filePaths } from '../../../platform/filesystem/filePaths' +import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' +import { prepareWindowsSandboxFilesystem } from '../../../platform/process/windowsSandboxProcess' +import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths' +import { createWindowsSandboxPolicy } from '../../../service/src/sandbox/backends/windows/createPolicy' +import { existingSandboxPaths, existingSandboxSensitivePaths, inspectSandboxPath } from '../../../service/src/sandbox/sandboxFilesystemPolicy' +import { SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' + +export class WindowsSandboxPreparation { + readonly #options: SandboxHostOptions + readonly #controller = new AbortController() + readonly #pending = new Map>() + #background: Promise | undefined + #queue: Promise = Promise.resolve() + + constructor(options: SandboxHostOptions) { + this.#options = options + this.start() + } + + start(): void { + if (this.#background || this.#controller.signal.aborted) + return + this.#background = this.#initialize().catch(() => { + this.#background = undefined + if (!this.#controller.signal.aborted) + console.error('SANDBOX_BACKGROUND_PREPARATION_FAILED') + }) + } + + prepare(roots: readonly string[]): Promise { + this.start() + if (!roots.length) + return Promise.resolve() + const key = JSON.stringify([...new Set(roots.map(filePaths.sensitiveKey))].sort()) + const existing = this.#pending.get(key) + if (existing) + return existing + const work = this.#queue.then(async () => { + this.#controller.signal.throwIfAborted() + const executable = await resolveWindowsSandbox(this.#options.windowsSandbox) + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT + if (!systemRoot || !this.#options.windowsShell) + throw new Error('SANDBOX_UNAVAILABLE') + const policy = await createWindowsSandboxPolicy({ + roots: [...roots], + workspaceRoots: [...roots], + resourceReadRoots: [], + additionalDirectories: [], + readOnly: false, + cwd: roots[0]!, + home: homedir(), + sensitiveRoots: resolveSensitivePathRoots(), + protectedRoots: this.#options.protectedRoots ?? [], + searchDirectory: this.#options.searchDirectory, + path: process.env.PATH ?? '', + backend: { kind: SHELL_SANDBOX_BACKEND.Windows, executable, shell: this.#options.windowsShell, systemRoot, protectionScope: this.#options.protectionScope ?? this.#options.buddyHome }, + }, this.#controller.signal) + await prepareWindowsSandboxFilesystem(executable, policy.grants, AbortSignal.any([this.#controller.signal, AbortSignal.timeout(60_000)]), { + denyReadScope: this.#options.protectionScope ?? this.#options.buddyHome, + }) + }).finally(() => this.#pending.delete(key)) + this.#pending.set(key, work) + this.#queue = work.catch(() => {}) + return work + } + + async dispose(): Promise { + this.#controller.abort() + await Promise.allSettled([this.#background, this.#queue]) + } + + 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') + const protectedPaths = [ + ...await existingSandboxSensitivePaths({ + sensitiveRoots: resolveSensitivePathRoots(), + backend: { kind: SHELL_SANDBOX_BACKEND.Windows, executable, shell: this.#options.windowsShell, systemRoot, protectionScope: this.#options.protectionScope ?? this.#options.buddyHome }, + }), + ...await existingSandboxPaths(this.#options.protectedRoots ?? []), + ] + const grants = await Promise.all(protectedPaths.map(path => inspectSandboxPath(path, 'denyRead'))) + 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, + background: true, + }) + } +} diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts index 1abdc04d5..34ee1755e 100644 --- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts +++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts @@ -8,16 +8,19 @@ import { createProxyEnvironment } from '../../../platform/process/proxyEnvironme import { createSandboxDirectory } from '../../../platform/process/sandboxDirectory' import { createSandboxEnvironment } from '../../../platform/process/sandboxEnvironment' import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox' +import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths' import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath' import { Emitter } from '../../../shared/events/Emitter' import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle' -import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema } from '../../../shared/permissions/shellSandbox' +import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema, sandboxPreparationSchema } from '../../../shared/permissions/shellSandbox' import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' import { BuddyServicePeer } from '../runtime/BuddyServicePeer' +import { WindowsSandboxPreparation } from './WindowsSandboxPreparation' export interface SandboxHostOptions { buddyHome: string protectedRoots?: string[] + protectionScope?: string proxyUrl?: string searchDirectory: string sandboxDirectory?: string @@ -29,9 +32,17 @@ export interface SandboxHostOptions { export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: SandboxHostOptions) { const changes = new Emitter>(() => console.error('SANDBOX_HOST_OBSERVER_FAILED')) const publish = (event: Parameters[0]) => changes.fire(Object.freeze(event)) + const preparation = isWindows(process.platform) ? new WindowsSandboxPreparation({ ...options, protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)] }) : undefined const active = new Map() let disposed = false const disposers = [ + peer.onRequest('host.sandbox.prepare', async (params) => { + const { roots } = sandboxPreparationSchema.parse(params) + if (disposed) + throw new Error('SANDBOX_UNAVAILABLE') + await preparation?.prepare(roots) + return { ok: true } + }), peer.onNotification((method, params) => { if (method !== 'host.sandbox.cancel') return @@ -47,6 +58,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa return { ok: false, code: 'SANDBOX_BUSY' } if (disposed || active.size >= 8) return { ok: false, code: 'SANDBOX_BUSY', notStarted: true } + preparation?.start() const controller = new AbortController() active.set(input.requestId, controller) let dispatched = false @@ -141,12 +153,13 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa return await processPeer.request('sandbox.exec', { ...input, home: homedir(), + sensitiveRoots: resolveSensitivePathRoots(), path: process.env.PATH ?? '', privateRoot: directory, protectedRoots: options.protectedRoots ?? [dirname(options.buddyHome)], searchDirectory: options.searchDirectory, backend: windowsSandbox - ? { kind: SHELL_SANDBOX_BACKEND.Windows, executable: windowsSandbox, shell: options.windowsShell, systemRoot } + ? { kind: SHELL_SANDBOX_BACKEND.Windows, executable: windowsSandbox, shell: options.windowsShell, systemRoot, protectionScope: options.protectionScope ?? options.buddyHome } : isLinux(process.platform) ? { kind: SHELL_SANDBOX_BACKEND.Linux, sandboxDirectory: options.sandboxDirectory } : { kind: SHELL_SANDBOX_BACKEND.MacOS }, @@ -163,13 +176,14 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa } }), ] - const dispose = () => { + const dispose = async () => { disposed = true disposers.forEach(dispose => dispose()) for (const controller of active.values()) controller.abort() if (!active.size) changes.dispose() + await preparation?.dispose() } - return { dispose, onDidChange: changes.event } + return { dispose, prepare: () => preparation?.start(), onDidChange: changes.event } } diff --git a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts index 162f3d682..5af8f5b84 100644 --- a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts +++ b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts @@ -16,7 +16,7 @@ import { ShellSandboxError } from '../../../shared/permissions/shellSandbox' import { isWindows } from '../../../shared/platform' import { registerSandboxHostRpc } from './registerSandboxHostRpc' -export async function verifySandboxInstallation(options: SandboxHostOptions): Promise { +export async function verifySandboxInstallation(options: SandboxHostOptions, scale = false): Promise<{ preparationMs: number, firstCommandMs: number, repeatedRpcMs: number[] } | undefined> { const directory = await realpath(await mkdtemp(join(tmpdir(), 'lexora-installed-sandbox-'))) const workspace = join(directory, '工作区 Space') const channel = new MessageChannel() @@ -33,11 +33,38 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr const runtime = createPeer(channel.port2) const disposeHost = registerSandboxHostRpc(host, options) const client = new ShellSandboxClient(runtime) + const measurements: number[] = [] + let preparationMs = 0 + let firstCommandMs = 0 + const events: { elapsed: number, phase: string, started: boolean, process?: string }[] = [] + let commandNumber = 0 + let startedAt = performance.now() + const hostObservation = disposeHost.onDidChange((event) => { + events.push({ elapsed: performance.now() - startedAt, phase: `host.${event.kind}`, started: false }) + }) + const observation = client.onDidChange((change) => { + if (change.kind !== 'backend') + return + const state = change.event.snapshot + events.push({ elapsed: performance.now() - startedAt, phase: state.phase, started: state.started, process: state.process?.phase }) + if (events.length > 16) + events.shift() + }) + const quote = (path: string) => `'${path.replaceAll('\'', '\'\'')}'` + const originalPath = process.env.PATH const powershell = currentPlatform.shell === 'powershell' - const execute = async (command: string, readOnly = false, approve = false, timeout = 10) => { + const execute = async (command: string, readOnly = false, approve = false, timeout = 10, cancellation?: AbortController) => { + commandNumber++ + startedAt = performance.now() + events.length = 0 const output: Buffer[] = [] const result = await client.exec({ command, cwd: workspace, roots: [workspace], workspaceRoots: [workspace], resourceReadRoots: [], additionalDirectories: [], readOnly, timeout }, { - onData: data => output.push(data), + signal: cancellation?.signal, + onData: (data) => { + output.push(data) + if (cancellation && Buffer.concat(output).includes('CANCEL_READY')) + cancellation.abort() + }, }, async () => approve) return { ...result, output: Buffer.concat(output).toString('utf8') } } @@ -62,13 +89,39 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr } if (status !== 'available') throw new Error(`Installed sandbox is unavailable: ${status}`) + if (scale) { + const toolchain = join(directory, 'toolchain') + await mkdir(toolchain) + for (const [root, count] of [[toolchain, 50_000], [workspace, 5_000]] as const) { + for (let offset = 0; offset < count; offset += 100) + await Promise.all(Array.from({ length: 100 }, (_, index) => writeFile(join(root, `fixture-${offset + index}`), 'fixture'))) + } + process.env.PATH = `${toolchain}${powershell ? ';' : ':'}${originalPath ?? ''}` + } + const preparationStarted = performance.now() + await client.prepare([workspace]) + preparationMs = performance.now() - preparationStarted + if (scale && preparationMs > 30_000) + throw new Error(`Installed sandbox exceeded its workspace preparation budget: ${preparationMs} ms`) await writeFile(join(workspace, '.env'), 'synthetic-secret') + const firstCommandStarted = performance.now() const write = await execute(powershell ? 'Set-Content -LiteralPath result -Value ready -NoNewline' : 'printf ready > result') + firstCommandMs = performance.now() - firstCommandStarted + if (scale && firstCommandMs > 5_000) + throw new Error(`Installed sandbox exceeded its first prepared command budget: ${firstCommandMs} ms`) if (write.exitCode !== 0 || await readFile(join(workspace, 'result'), 'utf8') !== 'ready') throw new Error('Installed sandbox could not write its authorized workspace') const secret = await execute(powershell ? 'Get-Content -LiteralPath .env -ErrorAction Stop' : 'cat .env') - if (secret.exitCode === 0 || secret.output.includes('synthetic-secret')) - throw new Error('Installed sandbox exposed a protected file') + if (secret.exitCode !== 0 || !secret.output.includes('synthetic-secret')) + throw new Error('Installed sandbox could not read ordinary workspace content') + const protectedFile = join(options.buddyHome, 'sandbox-protected-fixture') + await writeFile(protectedFile, 'PRIVATE_FIXTURE') + try { + const protectedRead = await execute(powershell ? `Get-Content -LiteralPath ${quote(protectedFile)} -ErrorAction Stop` : `cat ${quote(protectedFile)}`) + if (protectedRead.exitCode === 0 || protectedRead.output.includes('PRIVATE_FIXTURE')) + throw new Error('Installed sandbox exposed application-private data') + } + finally { await rm(protectedFile, { force: true }) } const readOnly = await execute(powershell ? 'Set-Content -LiteralPath forbidden -Value changed -ErrorAction Stop' : 'touch forbidden', true) if (readOnly.exitCode === 0) throw new Error('Installed sandbox allowed a read-only write') @@ -110,12 +163,41 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr if (!(error instanceof ShellSandboxError) || error.code !== 'SANDBOX_TIMEOUT') throw error } + const cancellation = new AbortController() + try { + await execute(powershell ? '[Console]::Out.Write("CANCEL_READY"); Start-Sleep -Seconds 2; Set-Content -LiteralPath cancelled -Value changed' : 'printf CANCEL_READY; (sleep 2; touch cancelled) & wait', false, false, 10, cancellation) + throw new Error('Installed sandbox ignored its cancellation') + } + catch (error) { + if (error !== cancellation.signal.reason && (!(error instanceof ShellSandboxError) || error.code !== 'SANDBOX_CANCELLED')) + throw error + } await setTimeout(2_100) await absent(join(workspace, 'late')) + await absent(join(workspace, 'cancelled')) + if (scale) { + for (let iteration = 0; iteration < 3; iteration++) { + const start = performance.now() + const result = await execute(powershell ? '[Console]::Out.Write(\'SCALE_OK\')' : 'printf SCALE_OK') + const elapsed = performance.now() - start + if (result.exitCode !== 0 || result.output !== 'SCALE_OK' || elapsed > 5_000) + throw new Error(`Installed sandbox exceeded its repeated command budget: ${elapsed} ms; ${JSON.stringify(result)}`) + measurements.push(elapsed) + } + } + return { preparationMs, firstCommandMs, repeatedRpcMs: measurements } + } + catch (error) { + throw new Error(`Installed sandbox verification failed at command ${commandNumber}: ${error instanceof Error ? error.message : String(error)}; ${JSON.stringify(events)}`, { cause: error }) } finally { + observation.dispose() + hostObservation.dispose() + if (originalPath === undefined) + delete process.env.PATH + else process.env.PATH = originalPath await client.dispose() - disposeHost.dispose() + await disposeHost.dispose() host.close(new Error('Installation verification completed')) runtime.close(new Error('Installation verification completed')) channel.port1.close() diff --git a/apps/buddy/native/host/Cargo.toml b/apps/buddy/native/host/Cargo.toml index b1ee178a2..55b745bd0 100644 --- a/apps/buddy/native/host/Cargo.toml +++ b/apps/buddy/native/host/Cargo.toml @@ -138,6 +138,9 @@ windows-sys = { workspace = true, features = [ "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", + "Win32_Security_Authentication_Identity", + "Win32_Security_Cryptography", + "Win32_NetworkManagement_NetManagement", "Win32_NetworkManagement_WindowsFilteringPlatform", "Win32_NetworkManagement_WindowsFirewall", "Win32_Networking_WinSock", diff --git a/apps/buddy/native/host/__tests__/process_recovery_windows.rs b/apps/buddy/native/host/__tests__/process_recovery_windows.rs index 3a4e9d324..ab851bdf3 100644 --- a/apps/buddy/native/host/__tests__/process_recovery_windows.rs +++ b/apps/buddy/native/host/__tests__/process_recovery_windows.rs @@ -3,6 +3,7 @@ use crate::shell_sandbox::process::Job; use std::{ os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, process::{Command, Stdio}, + time::{Duration, Instant}, }; use windows_sys::Win32::{ Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle}, @@ -152,7 +153,15 @@ fn windows_reverification_observes_a_remaining_child_without_terminating_it() { assert!(child.try_wait().unwrap().is_none()); drop(child.stdin.take()); child.wait().unwrap(); - assert_eq!(inspect(identity, None)["reason"], "job_empty"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let result = inspect(identity.clone(), None); + if result["reason"] != "job_active" || Instant::now() >= deadline { + assert_eq!(result["reason"], "job_empty"); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } } #[test] diff --git a/apps/buddy/native/host/__tests__/sandbox_adversary.rs b/apps/buddy/native/host/__tests__/sandbox_adversary.rs index 0e7eb989e..5e0ce5edc 100644 --- a/apps/buddy/native/host/__tests__/sandbox_adversary.rs +++ b/apps/buddy/native/host/__tests__/sandbox_adversary.rs @@ -3,15 +3,64 @@ fn main() -> std::io::Result<()> { let argument = std::env::args() .nth(1) .ok_or(std::io::ErrorKind::InvalidInput)?; - if argument == "--volume-metadata" { + if argument == "--watch-process" { + watch_process() + } else if argument == "--volume-metadata" { let directory = std::env::args().nth(2).unwrap_or_else(|| r"C:\".into()); probe_directory_access(&directory); Ok(()) + } else if argument == "--rewrite-dacl-write" { + rewrite_dacl_write() } else { probe_token(&argument) } } +#[cfg(windows)] +fn rewrite_dacl_write() -> std::io::Result<()> { + use std::{io, ptr}; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, + DACL_SECURITY_INFORMATION, SetFileSecurityW, + }, + }; + let path = std::env::args().nth(2).ok_or(io::ErrorKind::InvalidInput)?; + let wide: Vec = path.encode_utf16().chain([0]).collect(); + let sddl: Vec = "D:(A;;FA;;;WD)".encode_utf16().chain([0]).collect(); + let mut descriptor = ptr::null_mut(); + // SAFETY: Both strings are terminated; this test mutates only its synthetic fixture. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: The descriptor is live and valid for the synchronous file-security update. + let rewrite = + if unsafe { SetFileSecurityW(wide.as_ptr(), DACL_SECURITY_INFORMATION, descriptor) } == 0 { + io::Error::last_os_error().raw_os_error().unwrap_or(-1) + } else { + 0 + }; + // SAFETY: The conversion allocated this descriptor with LocalAlloc. + unsafe { LocalFree(descriptor) }; + let write = std::fs::write(path, "ESCAPED") + .err() + .map_or(0, |error| error.raw_os_error().unwrap_or(-1)); + println!( + "{}", + serde_json::json!({"rewriteDacl": rewrite, "write": write}) + ); + Ok(()) +} + #[cfg(windows)] fn probe_directory_access(directory: &str) { use std::{os::windows::io::FromRawHandle, os::windows::io::OwnedHandle, ptr}; @@ -151,3 +200,53 @@ fn probe_token(path: &str) -> std::io::Result<()> { #[cfg(not(windows))] fn main() {} + +#[cfg(windows)] +fn watch_process() -> std::io::Result<()> { + use std::{ + io, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + time::Duration, + }; + use windows_sys::Win32::{Foundation::WAIT_OBJECT_0, System::Threading::*}; + let pid = std::env::args() + .nth(2) + .ok_or(io::ErrorKind::InvalidInput)? + .parse::() + .map_err(|_| io::ErrorKind::InvalidInput)?; + // SAFETY: The fixture observes only a caller-owned process; no mutation rights are requested. + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: The successful OpenProcess returned one owned handle. + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut peak = 0; + let mut samples = 0; + loop { + let mut count = 0; + // SAFETY: The process handle is live and the count output is writable. + if unsafe { GetProcessHandleCount(handle.as_raw_handle(), &mut count) } != 0 { + peak = peak.max(count); + samples += 1; + } + // SAFETY: A bounded wait observes process exit without changing its state. + if unsafe { WaitForSingleObject(handle.as_raw_handle(), 10) } == WAIT_OBJECT_0 { + break; + } + if samples > Duration::from_secs(120).as_millis() / 10 { + return Err(io::ErrorKind::TimedOut.into()); + } + } + println!( + "{}", + serde_json::json!({"peakHandles": peak, "samples": samples}) + ); + Ok(()) +} diff --git a/apps/buddy/native/host/__tests__/shell_sandbox.windows.mjs b/apps/buddy/native/host/__tests__/shell_sandbox.windows.mjs index 1806b391a..463e974de 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox.windows.mjs +++ b/apps/buddy/native/host/__tests__/shell_sandbox.windows.mjs @@ -1,17 +1,24 @@ /* eslint test/no-import-node-test: off */ import assert from 'node:assert/strict' import { Buffer } from 'node:buffer' -import { spawn } from 'node:child_process' +import { execFile, spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { access, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { access, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' import { createServer } from 'node:net' -import { networkInterfaces, tmpdir } from 'node:os' +import { homedir, networkInterfaces, tmpdir } from 'node:os' import { dirname, isAbsolute, join } from 'node:path' import process from 'node:process' import * as nativeTest from 'node:test' +import { promisify } from 'node:util' const executable = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Lexora Buddy Sandbox', 'lexora-buddy-sandbox.exe') const quote = value => `'${value.replaceAll('\'', '\'\'')}'` +const execFileAsync = promisify(execFile) + +async function setFixtureAcl(path, sddl) { + const command = `$acl=Get-Acl -LiteralPath ${quote(path)}; $acl.SetSecurityDescriptorSddlForm(${quote(sddl)},[Security.AccessControl.AccessControlSections]::Access); Set-Acl -LiteralPath ${quote(path)} -AclObject $acl` + await execFileAsync(join(process.env.ProgramFiles, 'PowerShell', '7', 'pwsh.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference='Stop'; ${command}`], { windowsHide: true, timeout: 15_000 }) +} async function listener(host = '127.0.0.1') { const server = createServer((socket) => { @@ -22,16 +29,29 @@ async function listener(host = '127.0.0.1') { return { server, port: server.address().port } } -async function execute({ command, workspace, grants, port, onReady, onPreparing }) { +async function execute({ command, workspace, grants, port, onReady, onPreparing, measure = false, prepareOnly = false, denyReadScope = dirname(workspace), synchronizeDenials = false }) { const privateRoot = await mkdtemp(join(dirname(workspace), 'private-')) const home = join(privateRoot, 'home') const temporary = join(privateRoot, 'tmp') await Promise.all([home, temporary].map(path => mkdir(path))) const shell = process.env.BUDDY_SANDBOX_TEST_SHELL ?? join(process.env.ProgramFiles, 'PowerShell', '7', 'pwsh.exe') - const child = spawn(executable, ['run'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) - const identities = await Promise.all(grants.map(async (grant) => { + const startedAt = performance.now() + let readyAt + let preparingAt + let releasedAt + const child = spawn(executable, [prepareOnly ? 'prepare' : 'run'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + const observer = measure ? spawn(process.env.BUDDY_SANDBOX_TEST_ADVERSARY, ['--watch-process', String(child.pid)], { windowsHide: true, stdio: ['ignore', 'pipe', 'inherit'] }) : undefined + let observation = '' + observer?.stdout.on('data', data => observation += data) + const observed = observer + ? new Promise((resolve, reject) => { + observer.once('error', reject) + observer.once('close', code => code === 0 ? resolve() : reject(new Error(`Handle observer failed: ${code}`))) + }) + : Promise.resolve() + const identities = await Promise.all([{ path: homedir(), access: 'read' }, ...grants].map(async (grant) => { const metadata = await stat(grant.path, { bigint: true }) - return { ...grant, device: String(metadata.dev), inode: String(metadata.ino) } + return { ...grant, device: grant.device ?? String(metadata.dev), inode: grant.inode ?? String(metadata.ino) } })) const chunks = [] let diagnostics = '' @@ -40,17 +60,21 @@ async function execute({ command, workspace, grants, port, onReady, onPreparing child.stdout.on('data', data => chunks.push(data)) child.stderr.on('data', (data) => { diagnostics += data + if (releasedAt === undefined && diagnostics.includes('"type":"processesReleased"')) + releasedAt = performance.now() if (!preparing && diagnostics.includes('"phase":"filesystem_preparing"')) { preparing = true + preparingAt = performance.now() onPreparing?.(child) } if (!ready && diagnostics.includes('"type":"ready"')) { ready = true + readyAt = performance.now() onReady?.(child) } }) child.stdin.on('error', () => {}) - child.stdin.write(`${JSON.stringify({ command, cwd: workspace, shell, privateRoot, proxyPort: port, grants: identities, environment: { SystemRoot: process.env.SystemRoot, WINDIR: process.env.SystemRoot, ComSpec: join(process.env.SystemRoot, 'System32', 'cmd.exe'), PATHEXT: '.COM;.EXE;.BAT;.CMD', PSModulePath: join(dirname(shell), 'Modules'), PATH: `${dirname(shell)};${join(process.env.SystemRoot, 'System32')}`, HOME: home, USERPROFILE: home, TEMP: temporary, TMP: temporary, APPDATA: home, LOCALAPPDATA: home, POWERSHELL_TELEMETRY_OPTOUT: '1' } })}\n`) + child.stdin.write(`${JSON.stringify(prepareOnly ? { grants: identities, denyReadScope, synchronizeDenials } : { command, cwd: workspace, shell, privateRoot, proxyPort: port, grants: identities, denyReadScope, environment: { SystemRoot: process.env.SystemRoot, WINDIR: process.env.SystemRoot, ComSpec: join(process.env.SystemRoot, 'System32', 'cmd.exe'), PATHEXT: '.COM;.EXE;.BAT;.CMD', PSModulePath: join(dirname(shell), 'Modules'), PATH: `${dirname(shell)};${join(process.env.SystemRoot, 'System32')}`, HOME: home, USERPROFILE: home, TEMP: temporary, TMP: temporary, APPDATA: home, LOCALAPPDATA: home, POWERSHELL_TELEMETRY_OPTOUT: '1' } })}\n`) const timer = setTimeout(() => child.stdin.end(), 60_000) const killTimer = setTimeout(() => child.kill(), 75_000) try { @@ -60,7 +84,8 @@ async function execute({ command, workspace, grants, port, onReady, onPreparing }) const buffer = Buffer.concat(chunks) const output = buffer.subarray(0, 128).includes(0) ? buffer.toString('utf16le') : buffer.toString('utf8') - return { exitCode, output, diagnostics } + await observed + return { exitCode, output, diagnostics, ...(measure ? { measurements: { ...JSON.parse(observation), startupMs: readyAt - startedAt, prepareMs: readyAt - preparingAt, releaseMs: performance.now() - releasedAt, totalMs: performance.now() - startedAt } } : {}) } } finally { clearTimeout(timer) @@ -69,7 +94,7 @@ async function execute({ command, workspace, grants, port, onReady, onPreparing } } -nativeTest.test('Windows LPAC enforcement', { skip: process.platform !== 'win32', timeout: 300_000 }, async (t) => { +nativeTest.test('Windows restricted-token behavior and known limits', { skip: process.platform !== 'win32', timeout: 300_000 }, async (t) => { const adversaryExecutable = process.env.BUDDY_SANDBOX_TEST_ADVERSARY assert.ok(adversaryExecutable, 'Windows acceptance requires its target-specific adversary executable') const state = process.env.BUDDY_SANDBOX_TEST_DIRECTORY ?? tmpdir() @@ -95,6 +120,97 @@ nativeTest.test('Windows LPAC enforcement', { skip: process.platform !== 'win32' const remote = Object.values(networkInterfaces()).flat().find(address => address?.family === 'IPv4' && !address.internal)?.address try { assert.ok(remote, 'Windows acceptance requires a non-loopback network interface') + await t.test('reconciles late command and workspace denials without releasing another configuration', async () => { + const readGrant = { path: workspace, access: 'read' } + const base = { workspace, port: proxy.port, grants: [readGrant] } + const initialized = await execute({ ...base, prepareOnly: true, synchronizeDenials: true }) + assert.equal(initialized.exitCode, 0, JSON.stringify(initialized)) + const late = join(root, 'late-credentials') + await mkdir(late) + const file = join(late, 'token') + await writeFile(file, 'LATE_CREDENTIAL_FIXTURE') + const command = `try {[Console]::Out.Write([IO.File]::ReadAllText(${quote(file)}))} catch {[Console]::Out.Write('DENIED')}` + const denied = await execute({ ...base, command, grants: [readGrant, { path: late, access: 'denyRead' }] }) + assert.equal(denied.exitCode, 0, JSON.stringify(denied)) + assert.equal(denied.output, 'DENIED') + const merged = await execute({ ...base, prepareOnly: true }) + assert.equal(merged.exitCode, 0, JSON.stringify(merged)) + const kept = await execute({ ...base, command }) + assert.equal(kept.exitCode, 0, JSON.stringify(kept)) + assert.equal(kept.output, 'DENIED') + const otherScope = join(root, 'other-profile') + const shared = await execute({ ...base, prepareOnly: true, denyReadScope: otherScope, grants: [readGrant, { path: late, access: 'denyRead' }] }) + assert.equal(shared.exitCode, 0, JSON.stringify(shared)) + const reconciled = await execute({ ...base, prepareOnly: true, synchronizeDenials: true }) + assert.equal(reconciled.exitCode, 0, JSON.stringify(reconciled)) + const sharedRead = await execute({ ...base, command }) + assert.equal(sharedRead.exitCode, 0, JSON.stringify(sharedRead)) + assert.equal(sharedRead.output, 'DENIED') + const released = await execute({ ...base, prepareOnly: true, denyReadScope: otherScope, synchronizeDenials: true }) + assert.equal(released.exitCode, 0, JSON.stringify(released)) + const readable = await execute({ ...base, command }) + assert.equal(readable.exitCode, 0, JSON.stringify(readable)) + assert.equal(readable.output, 'LATE_CREDENTIAL_FIXTURE') + }) + await t.test('records the known ACL limit for host-owned Everyone-writable files outside current capabilities', async () => { + const file = join(root, 'host-owned-public.txt') + await writeFile(file, 'HOST_OWNED') + await setFixtureAcl(file, 'D:P(A;;FA;;;OW)(A;;FRFW;;;WD)') + const observations = [] + for (const access of ['read', 'write']) { + const command = `$r=@{}; foreach($target in @(@{key='public';path=${quote(file)}},@{key='private';path=${quote(outside)}})){try{[IO.File]::WriteAllText($target.path,'CHANGED');$r[$target.key]='allowed'}catch{$r[$target.key]='denied'}}; [Console]::Out.Write(($r|ConvertTo-Json -Compress))` + const result = await execute({ command, workspace, port: proxy.port, grants: [{ path: workspace, access }] }) + assert.equal(result.exitCode, 0, JSON.stringify(result)) + observations.push({ access, ...JSON.parse(result.output) }) + } + t.diagnostic(`Known native ACL write limit, not strict confinement: ${JSON.stringify(observations)}`) + assert.deepEqual(observations, [{ access: 'read', public: 'allowed', private: 'denied' }, { access: 'write', public: 'allowed', private: 'denied' }]) + assert.equal(await readFile(file, 'utf8'), 'CHANGED') + assert.equal(await readFile(outside, 'utf8'), 'OUTSIDE_PRIVATE_FIXTURE') + }) + await t.test('records the known inherited-deny limit while enforcing ordinary and explicit credential denies', async () => { + const credentials = join(root, 'credentials') + await mkdir(credentials) + const entries = ['inherited', 'explicit-public', 'protected-public', 'protected-private', 'direct-public'] + for (const entry of entries) + await writeFile(join(credentials, entry), 'SYNTHETIC_CREDENTIAL') + await setFixtureAcl(join(credentials, 'explicit-public'), 'D:(A;;FA;;;OW)(A;;FR;;;WD)') + await setFixtureAcl(join(credentials, 'protected-public'), 'D:P(A;;FA;;;OW)(A;;FR;;;WD)') + await setFixtureAcl(join(credentials, 'protected-private'), 'D:P(A;;FA;;;OW)') + await setFixtureAcl(join(credentials, 'direct-public'), 'D:P(A;;FA;;;OW)(A;;FR;;;WD)') + const command = `$r=@{}; ${entries.map(entry => `try{$r[${quote(entry)}]=[IO.File]::ReadAllText(${quote(join(credentials, entry))})}catch{$r[${quote(entry)}]='denied'}`).join('; ')}; [Console]::Out.Write(($r|ConvertTo-Json -Compress))` + const result = await execute({ command, workspace, port: proxy.port, grants: [{ path: workspace, access: 'read' }, { path: credentials, access: 'denyRead' }, { path: join(credentials, 'direct-public'), access: 'denyRead' }] }) + assert.equal(result.exitCode, 0, JSON.stringify(result)) + const observations = JSON.parse(result.output) + t.diagnostic(`Known native ACL read limit, not complete credential isolation: ${JSON.stringify(observations)}`) + assert.deepEqual(observations, { 'inherited': 'denied', 'explicit-public': 'SYNTHETIC_CREDENTIAL', 'protected-public': 'SYNTHETIC_CREDENTIAL', 'protected-private': 'denied', 'direct-public': 'denied' }) + }) + await t.test('bounds preparation handles and reuses ACLs for large workspaces and toolchains', async () => { + const tools = join(root, 'toolchain') + const large = join(root, 'large-workspace') + await Promise.all([tools, large].map(path => mkdir(path))) + for (const [path, files] of [[tools, 50_000], [large, 5_000]]) { + for (let offset = 0; offset < files; offset += 100) + await Promise.all(Array.from({ length: 100 }, (_, index) => writeFile(join(path, `fixture-${offset + index}`), 'fixture'))) + } + const command = `[IO.File]::WriteAllText(${quote(join(large, 'result'))},'SCALE_OK'); [Console]::Out.Write([IO.File]::ReadAllText(${quote(join(tools, 'fixture-49999'))}))` + const prepared = await execute({ command, workspace: large, port: proxy.port, grants: [{ path: large, access: 'write' }], measure: true, prepareOnly: true }) + assert.equal(prepared.exitCode, 0, JSON.stringify(prepared)) + assert.equal(prepared.output, '') + assert(prepared.measurements.peakHandles < 300, JSON.stringify(prepared.measurements)) + assert(prepared.measurements.startupMs < 30_000, JSON.stringify(prepared.measurements)) + const results = [] + for (let iteration = 0; iteration < 3; iteration++) { + const result = await execute({ command, workspace: large, port: proxy.port, grants: [{ path: large, access: 'write' }], measure: true }) + assert.equal(result.exitCode, 0, JSON.stringify(result)) + assert.equal(result.output, 'fixture') + assert(result.measurements.peakHandles < 300, JSON.stringify(result.measurements)) + assert(result.measurements.samples > 0, JSON.stringify(result.measurements)) + assert(result.measurements.startupMs < 5_000, JSON.stringify(result.measurements)) + results.push(result.measurements) + } + t.diagnostic(`50000 toolchain files and 5000 workspace files: ${JSON.stringify({ initialization: prepared.measurements, commands: results })}`) + }) await t.test('protects files and permits loopback TCP while denying direct non-loopback access', async () => { const command = ` $r=@{} @@ -110,9 +226,55 @@ foreach($target in @(@{key='proxy';host='127.0.0.1';port=${proxy.port}},@{key='o const result = await execute({ command, workspace, port: proxy.port, grants: [{ path: workspace, access: 'write' }, { path: secret, access: 'denyRead' }] }) await writeFile(join(root, 'report.json'), JSON.stringify(result, null, 2)) assert.equal(result.exitCode, 0, JSON.stringify(result)) - assert.deepEqual(JSON.parse(result.output), { outside: 'denied', secret: 'denied', proxy: 'connected', otherPort: 'connected', ipv6: 'connected', nonLoopback: 'denied' }) + assert.deepEqual(JSON.parse(result.output), { outside: 'OUTSIDE_PRIVATE_FIXTURE', secret: 'denied', proxy: 'connected', otherPort: 'connected', ipv6: 'connected', nonLoopback: 'denied' }) assert.equal(await readFile(join(workspace, 'result.txt'), 'utf8'), 'WORKSPACE_WRITE_FIXTURE') }) + await t.test('enforces ordinary write roots and records the shared-owner revocation limit', async () => { + const permissionsProbe = join(workspace, 'permissions-probe.exe') + await copyFile(adversaryExecutable, permissionsProbe) + const previous = join(root, 'previous-workspace') + await mkdir(previous) + const hostFile = join(previous, 'host.txt') + await writeFile(hostFile, 'HOST') + const variants = [[workspace, [{ path: workspace, access: 'write' }]], [previous, [{ path: previous, access: 'read' }]]] + for (const [index, [cwd, grants]] of variants.entries()) { + const childFile = join(previous, `child-${index}.txt`) + const first = await execute({ command: `[IO.File]::WriteAllText(${quote(childFile)},'CHILD')`, workspace: previous, port: proxy.port, grants: [{ path: previous, access: 'write' }] }) + assert.equal(first.exitCode, 0, JSON.stringify(first)) + const command = `$r=@{}; foreach($path in @(${quote(hostFile)},${quote(childFile)},${quote(join(previous, 'new.txt'))})){try{[IO.File]::WriteAllText($path,'ESCAPED');$r[$path]='written'}catch{$r[$path]='denied'}}; $r.read=[IO.File]::ReadAllText(${quote(childFile)}); $r.writeDacl=(& ${quote(permissionsProbe)} --volume-metadata ${quote(childFile)} | ConvertFrom-Json).writeDacl; $r.aclWrite=(& ${quote(permissionsProbe)} --rewrite-dacl-write ${quote(childFile)} | ConvertFrom-Json); [Console]::Out.Write(($r|ConvertTo-Json -Compress))` + const next = await execute({ command, workspace: cwd, port: proxy.port, grants }) + assert.equal(next.exitCode, 0, JSON.stringify(next)) + const { writeDacl, aclWrite, ...ordinary } = JSON.parse(next.output) + assert.deepEqual(ordinary, { [hostFile]: 'denied', [childFile]: 'denied', [join(previous, 'new.txt')]: 'denied', read: 'CHILD' }) + t.diagnostic(`Shared-owner ACL revocation limit: ${JSON.stringify({ readOnly: index === 1, writeDacl, aclWrite })}`) + assert.deepEqual({ writeDacl, aclWrite }, { writeDacl: 0, aclWrite: { rewriteDacl: 0, write: 0 } }) + assert.equal(await readFile(childFile, 'utf8'), 'ESCAPED') + } + }) + await t.test('preserves explicit workspace and resource exceptions beneath application-private data', async () => { + const parent = join(root, 'application-private') + const current = join(parent, 'workspace') + const resource = join(parent, 'skill') + await Promise.all([current, resource].map(path => mkdir(path, { recursive: true }))) + await writeFile(join(parent, 'private.txt'), 'PRIVATE') + await writeFile(join(resource, 'resource.txt'), 'RESOURCE') + const command = `$r=@{}; [IO.File]::WriteAllText('workspace.txt','WRITABLE'); $r.resource=[IO.File]::ReadAllText(${quote(join(resource, 'resource.txt'))}); try{$r.private=[IO.File]::ReadAllText(${quote(join(parent, 'private.txt'))})}catch{$r.private='denied'}; try{[IO.File]::WriteAllText(${quote(join(resource, 'resource.txt'))},'changed');$r.write='allowed'}catch{$r.write='denied'}; [Console]::Out.Write(($r|ConvertTo-Json -Compress))` + const result = await execute({ command, workspace: current, port: proxy.port, grants: [{ path: current, access: 'write' }, { path: resource, access: 'read' }, { path: parent, access: 'denyRead', exceptions: [current, resource] }] }) + assert.equal(result.exitCode, 0, JSON.stringify(result)) + assert.deepEqual(JSON.parse(result.output), { resource: 'RESOURCE', private: 'denied', write: 'denied' }) + assert.equal(await readFile(join(current, 'workspace.txt'), 'utf8'), 'WRITABLE') + }) + await t.test('rejects a directory identity replaced after authorization', async () => { + const current = join(root, 'replaceable') + await mkdir(current) + const metadata = await stat(current, { bigint: true }) + await rename(current, `${current}-retired`) + await mkdir(current) + const result = await execute({ command: '[IO.File]::WriteAllText(\'must-not-run\',\'changed\')', workspace: current, port: proxy.port, grants: [{ path: current, access: 'write', device: String(metadata.dev), inode: String(metadata.ino) }] }) + assert.notEqual(result.exitCode, 0, JSON.stringify(result)) + assert(!result.diagnostics.includes('"type":"ready"'), result.diagnostics) + await assert.rejects(access(join(current, 'must-not-run'))) + }) await t.test('supports PowerShell paths, file operations and native children', async () => { await writeFile(join(workspace, 'result.txt'), 'delete-fixture') const compatibility = await execute({ command: ` @@ -228,7 +390,7 @@ if (process.argv[2] === 'child') { assert.equal(await readFile(changed, 'utf8'), 'OVERLAP_WRITE') assert.equal(await readFile(outside, 'utf8'), 'OUTSIDE_PRIVATE_FIXTURE') }) - await t.test('does not expose ancestor contents or mutation rights to native children', async () => { + await t.test('allows ordinary ancestor reads without granting mutation rights', async () => { const adversary = join(workspace, 'volume-probe.exe') await copyFile(adversaryExecutable, adversary) const ancestors = [] @@ -247,10 +409,11 @@ if (process.argv[2] === 'child') { const probes = JSON.parse(result.output) await writeFile(join(root, 'ancestor-access.json'), JSON.stringify(probes, null, 2)) for (const path of ancestors) { - const { queryAttributes, readAttributes, ...denied } = probes[path] + const { queryAttributes, readAttributes, listDirectory, ...denied } = probes[path] t.diagnostic(`Ancestor attributes ${path}: query=${queryAttributes}, handle=${readAttributes}`) assert.equal(queryAttributes, 0, path) - assert.deepEqual(denied, { listDirectory: 5, addFile: 5, addDirectory: 5, deleteChild: 5, writeAttributes: 5, writeExtendedAttributes: 5, writeDacl: 5, writeOwner: 5, delete: 5 }, path) + assert.equal(listDirectory, 0, path) + assert.deepEqual(denied, { addFile: 5, addDirectory: 5, deleteChild: 5, writeAttributes: 5, writeExtendedAttributes: 5, writeDacl: 5, writeOwner: 5, delete: 5 }, path) } }) await t.test('keeps concurrent command leases independent until both finish', async () => { @@ -268,14 +431,21 @@ if (process.argv[2] === 'child') { } }) await t.test('recovers after a supervisor exits without releasing its lease', async () => { + const journalDirectory = join(process.env.LOCALAPPDATA, 'Lexora Buddy Sandbox', 'leases') + const denials = await readFile(join(journalDirectory, 'denials-v1.json')) const result = await execute({ command: 'Start-Sleep -Seconds 30', workspace, port: proxy.port, grants: [{ path: workspace, access: 'write' }, { path: secret, access: 'denyRead' }], onReady: child => child.kill() }) assert.notEqual(result.exitCode, 0) const recovered = await execute({ command: '[Console]::Out.Write(\'RECOVERED_AFTER_EXIT\')', workspace, port: proxy.port, grants: [{ path: workspace, access: 'write' }, { path: secret, access: 'denyRead' }] }) assert.equal(recovered.exitCode, 0, JSON.stringify(recovered)) assert.equal(recovered.output, 'RECOVERED_AFTER_EXIT') const metadataDirectory = join(dirname(executable), 'metadata-leases') - assert.deepEqual(await readdir(metadataDirectory), []) - assert.deepEqual(await readdir(join(process.env.LOCALAPPDATA, 'Lexora Buddy Sandbox', 'leases')), []) + assert.deepEqual(await readdir(metadataDirectory).catch((error) => { + if (error.code === 'ENOENT') + return [] + throw error + }), []) + assert.deepEqual(await readdir(journalDirectory), ['denials-v1.json']) + assert.deepEqual(await readFile(join(journalDirectory, 'denials-v1.json')), denials) }) await t.test('cancels preparation before resuming any task command', async () => { const large = join(root, 'preparation') @@ -319,6 +489,8 @@ if (process.argv[2] === 'child') { proxy.server.close() other.server.close() ipv6.server.close() + const reconciled = await execute({ workspace, grants: [], prepareOnly: true, synchronizeDenials: true }) + assert.equal(reconciled.exitCode, 0, JSON.stringify(reconciled)) if (process.env.BUDDY_SANDBOX_TEST_KEEP_FILES !== '1') await rm(root, { recursive: true, force: true }) } diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_account.rs b/apps/buddy/native/host/__tests__/shell_sandbox_account.rs new file mode 100644 index 000000000..e351d3b5c --- /dev/null +++ b/apps/buddy/native/host/__tests__/shell_sandbox_account.rs @@ -0,0 +1,87 @@ +use super::super::security::SecurityDescriptor; +use super::*; +use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + +struct Accounts(Vec); + +impl Drop for Accounts { + fn drop(&mut self) { + for identity in self.0.iter().rev() { + let _ = remove_identity(identity); + } + } +} + +#[test] +#[ignore = "creates isolated local accounts; run explicitly with an elevated Windows token"] +fn account_recovery_preserves_same_named_replacements_and_resumes_durable_provisioning() { + super::super::security::require_host(super::super::security::current_process(), true).unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("protected"); + let descriptor = SecurityDescriptor::new("O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)").unwrap(); + let attributes = descriptor.attributes(false); + // SAFETY: This fresh test-only directory is administrator/SYSTEM-only from creation. + boolean(unsafe { CreateDirectoryW(wide(&directory.to_string_lossy()).as_ptr(), &attributes) }) + .unwrap(); + let name = fresh_name().unwrap(); + let path = directory.join(format!("{name}.account")); + let mut accounts = Accounts(Vec::new()); + let account = provision( + &path, + Account { + name, + password: format!("Lx9!{}{}", unique_id().unwrap(), unique_id().unwrap()), + sid: String::new(), + group_sid: String::new(), + retired: Vec::new(), + }, + ) + .unwrap(); + accounts.0.push(account.identity()); + assert!(account.matches().unwrap()); + + let mut intent = Account::read(&path).unwrap(); + intent.sid.clear(); + intent.group_sid.clear(); + save(&path.with_extension("account-pending"), &intent).unwrap(); + fs::remove_file(&path).unwrap(); + let resumed = repair_record(&path).unwrap(); + assert_eq!(resumed.sid, account.sid); + assert_eq!(resumed.group_sid, account.group_sid); + assert!(!path.with_extension("account-pending").exists()); + assert!(Account::read(&path).unwrap().matches().unwrap()); + + // SAFETY: Only the isolated account created by this test is removed to simulate SID drift. + check(unsafe { NetUserDel(ptr::null(), wide(&account.name).as_ptr()) }).unwrap(); + let name = wide(&account.name); + let password = wide(&format!("Lx9!{}", unique_id().unwrap())); + let info = USER_INFO_1 { + usri1_name: name.as_ptr().cast_mut(), + usri1_password: password.as_ptr().cast_mut(), + usri1_priv: USER_PRIV_USER, + usri1_flags: UF_NORMAL_ACCOUNT | UF_DONT_EXPIRE_PASSWD, + ..Default::default() + }; + // SAFETY: The fixture replacement has a different SID and secret; repair must preserve it. + check(unsafe { NetUserAdd(ptr::null(), 1, ptr::from_ref(&info).cast(), ptr::null_mut()) }) + .unwrap(); + let replacement = Identity { + name: account.name.clone(), + sid: sid_text(&sid(&account.name).unwrap()).unwrap(), + group_sid: String::new(), + }; + accounts.0.push(replacement.clone()); + assert_ne!(replacement.sid, account.sid); + + let repaired = repair_record(&path).unwrap(); + accounts.0.push(repaired.identity()); + assert_ne!(repaired.name, account.name); + assert_ne!(repaired.sid, account.sid); + assert!(repaired.matches().unwrap()); + drop(repaired.logon().unwrap()); + assert_eq!(repaired.retired.len(), 1); + remove_identity(&repaired.retired[0]).unwrap(); + assert!(matches_sid(&replacement.name, &replacement.sid).unwrap()); + assert!(!matches_sid(&format!("{}G", account.name), &account.group_sid).unwrap()); + assert_eq!(repair_record(&path).unwrap().sid, repaired.sid); +} diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs new file mode 100644 index 000000000..c3ecc1241 --- /dev/null +++ b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs @@ -0,0 +1,348 @@ +use super::*; +use std::{sync::mpsc, time::Duration}; +use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, EqualSid, GetAce, GetSecurityDescriptorDacl, +}; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +use windows_sys::Win32::System::SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}; + +fn cancellation() -> (mpsc::Sender<()>, Cancellation) { + super::super::security::require_host(super::super::security::current_process(), false).unwrap(); + struct Input(mpsc::Receiver<()>); + impl Read for Input { + fn read(&mut self, _: &mut [u8]) -> io::Result { + let _ = self.0.recv(); + Ok(0) + } + } + let (send, receive) = mpsc::channel(); + ( + send, + Cancellation::listen(Input(receive), Duration::from_secs(30)), + ) +} + +fn entries(path: &Path, sid: &Sid, deny: bool) -> usize { + let grant = filesystem::inspect(path.to_str().unwrap(), Access::Read).unwrap(); + let file = filesystem::open_grant(&grant).unwrap(); + let mut descriptor = filesystem::read_dacl_descriptor(&file).unwrap(); + let mut dacl = ptr::null_mut(); + let mut present = 0; + let mut defaulted = 0; + // SAFETY: The complete descriptor owns the ACL queried by the test. + boolean(unsafe { + GetSecurityDescriptorDacl( + descriptor.as_mut_ptr().cast(), + &mut present, + &mut dacl, + &mut defaulted, + ) + }) + .unwrap(); + let mut count = 0; + // SAFETY: A successful DACL query returned this live ACL. + for index in 0..unsafe { (*dacl).AceCount } { + let mut entry = ptr::null_mut(); + // SAFETY: This bounded index addresses a live ACE. + boolean(unsafe { GetAce(dacl, u32::from(index), &mut entry) }).unwrap(); + // SAFETY: Every ACE begins with this header. + let header = unsafe { &*entry.cast::() }; + let kind = if deny { + ACCESS_DENIED_ACE_TYPE + } else { + ACCESS_ALLOWED_ACE_TYPE + }; + if header.AceType != kind as u8 { + continue; + } + // SAFETY: Only the simple allow/deny ACE layouts reach this SID comparison. + let ace = unsafe { &*entry.cast::() }; + // SAFETY: Both SIDs have live backing allocations. + if unsafe { EqualSid(ptr::from_ref(&ace.SidStart).cast_mut().cast(), sid.as_ptr()) } != 0 { + count += 1; + } + } + count +} + +#[test] +fn obsolete_denials_preserve_allows_and_other_profiles_until_the_last_owner_releases() { + let temporary = tempfile::tempdir().unwrap(); + let protected = temporary.path().join("protected"); + fs::create_dir(&protected).unwrap(); + let child = protected.join("existing.txt"); + fs::write(&child, "fixture").unwrap(); + let grant = filesystem::inspect(protected.to_str().unwrap(), Access::DenyRead).unwrap(); + let sid = super::super::account::parse_sid("S-1-5-21-161-172-183-194").unwrap(); + let other = super::super::account::parse_sid("S-1-5-21-161-172-183-195").unwrap(); + let (_send, cancellation) = cancellation(); + let file = filesystem::open_grant(&grant).unwrap(); + filesystem::ensure_acl( + &grant, + &file, + &sid, + FILE_GENERIC_READ, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + filesystem::ensure_acl( + &grant, + &file, + &other, + FILE_GENERIC_READ, + DENY_ACCESS, + &cancellation, + ) + .unwrap(); + let state = temporary.path().join("state.json"); + reconcile( + &state, + DenialUpdate::Replace("profile-a"), + &sid, + std::slice::from_ref(&grant), + &cancellation, + ) + .unwrap(); + reconcile( + &state, + DenialUpdate::Replace("profile-b"), + &sid, + std::slice::from_ref(&grant), + &cancellation, + ) + .unwrap(); + reconcile( + &state, + DenialUpdate::Replace("profile-a"), + &sid, + &[], + &cancellation, + ) + .unwrap(); + assert_eq!(entries(&protected, &sid, true), 1); + reconcile( + &state, + DenialUpdate::Replace("profile-b"), + &sid, + &[], + &cancellation, + ) + .unwrap(); + for path in [&protected, &child] { + assert_eq!(entries(path, &sid, true), 0); + assert_eq!(entries(path, &sid, false), 1); + assert_eq!(entries(path, &other, true), 1); + } + assert!(read::(&state, false).unwrap().is_empty()); +} + +#[test] +fn merged_denials_preserve_previous_roots_and_are_reconciled_by_their_owner() { + let temporary = tempfile::tempdir().unwrap(); + let old = temporary.path().join("old"); + let late = temporary.path().join("late"); + fs::create_dir(&old).unwrap(); + let old_grant = filesystem::inspect(old.to_str().unwrap(), Access::DenyRead).unwrap(); + let sid = super::super::account::parse_sid("S-1-5-21-561-572-583-594").unwrap(); + let state = temporary.path().join("state.json"); + let (_send, cancellation) = cancellation(); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[old_grant], + &cancellation, + ) + .unwrap(); + fs::create_dir(&late).unwrap(); + let child = late.join("existing.txt"); + fs::write(&child, "fixture").unwrap(); + let late_grant = filesystem::inspect(late.to_str().unwrap(), Access::DenyRead).unwrap(); + for scope in ["profile", "other-profile"] { + reconcile( + &state, + DenialUpdate::Merge(scope), + &sid, + std::slice::from_ref(&late_grant), + &cancellation, + ) + .unwrap(); + } + let before = filesystem::inspect(state.to_str().unwrap(), Access::Read).unwrap(); + reconcile( + &state, + DenialUpdate::Merge("PROFILE"), + &sid, + std::slice::from_ref(&late_grant), + &cancellation, + ) + .unwrap(); + let after = filesystem::inspect(state.to_str().unwrap(), Access::Read).unwrap(); + assert_eq!((before.device, before.inode), (after.device, after.inode)); + assert_eq!( + read::(&state, false).unwrap()["PROFILE"] + .roots + .len(), + 2 + ); + assert_eq!(entries(&old, &sid, true), 1); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[], + &cancellation, + ) + .unwrap(); + assert_eq!(entries(&old, &sid, true), 0); + assert_eq!(entries(&child, &sid, true), 1); + reconcile( + &state, + DenialUpdate::Replace("other-profile"), + &sid, + &[], + &cancellation, + ) + .unwrap(); + assert_eq!(entries(&child, &sid, true), 0); + assert_eq!(fs::read_to_string(child).unwrap(), "fixture"); + assert!(read::(&state, false).unwrap().is_empty()); +} + +#[test] +fn failed_updates_keep_recovery_intent_and_previous_protection() { + let temporary = tempfile::tempdir().unwrap(); + let old = temporary.path().join("old"); + let new = temporary.path().join("new"); + fs::create_dir(&old).unwrap(); + fs::create_dir(&new).unwrap(); + let old_grant = filesystem::inspect(old.to_str().unwrap(), Access::DenyRead).unwrap(); + let new_grant = filesystem::inspect(new.to_str().unwrap(), Access::DenyRead).unwrap(); + let sid = super::super::account::parse_sid("S-1-5-21-261-272-283-294").unwrap(); + let state = temporary.path().join("state.json"); + let (_send, cancellation) = cancellation(); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[old_grant], + &cancellation, + ) + .unwrap(); + fs::remove_dir(&new).unwrap(); + assert!( + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[new_grant], + &cancellation + ) + .is_err() + ); + assert_eq!(entries(&old, &sid, true), 1); + assert_eq!( + read::(&state, false).unwrap()["PROFILE"] + .roots + .len(), + 2 + ); + fs::create_dir(&new).unwrap(); + let replacement = filesystem::inspect(new.to_str().unwrap(), Access::DenyRead).unwrap(); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[replacement], + &cancellation, + ) + .unwrap(); + assert_eq!(entries(&old, &sid, true), 0); + assert_eq!(entries(&new, &sid, true), 1); + assert_eq!( + read::(&state, false).unwrap()["PROFILE"] + .roots + .len(), + 1 + ); +} + +#[test] +fn reconciliation_never_changes_a_replacement_object() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("protected"); + fs::create_dir(&path).unwrap(); + let grant = filesystem::inspect(path.to_str().unwrap(), Access::DenyRead).unwrap(); + let sid = super::super::account::parse_sid("S-1-5-21-361-372-383-394").unwrap(); + let (_send, cancellation) = cancellation(); + let state = temporary.path().join("state.json"); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[grant], + &cancellation, + ) + .unwrap(); + fs::rename(&path, temporary.path().join("moved")).unwrap(); + fs::create_dir(&path).unwrap(); + let replacement = filesystem::inspect(path.to_str().unwrap(), Access::DenyRead).unwrap(); + let file = filesystem::open_grant(&replacement).unwrap(); + filesystem::ensure_acl( + &replacement, + &file, + &sid, + FILE_GENERIC_READ, + DENY_ACCESS, + &cancellation, + ) + .unwrap(); + reconcile( + &state, + DenialUpdate::Replace("profile"), + &sid, + &[], + &cancellation, + ) + .unwrap(); + assert_eq!(entries(&path, &sid, true), 1); +} + +#[test] +fn owned_acl_cleanup_propagates_without_removing_other_principals() { + let temporary = tempfile::tempdir().unwrap(); + let child = temporary.path().join("existing.txt"); + fs::write(&child, "fixture").unwrap(); + let root = filesystem::inspect(temporary.path().to_str().unwrap(), Access::Write).unwrap(); + let file = filesystem::open_grant(&root).unwrap(); + let sid = super::super::account::parse_sid("S-1-5-21-461-472-483-494").unwrap(); + let other = super::super::account::parse_sid("S-1-5-21-461-472-483-495").unwrap(); + let (_send, cancellation) = cancellation(); + filesystem::ensure_acl( + &root, + &file, + &sid, + FILE_GENERIC_READ, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + filesystem::ensure_acl( + &root, + &file, + &other, + FILE_GENERIC_READ, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + filesystem::revoke_owned(&file, &sid, false, true).unwrap(); + filesystem::revoke_owned(&file, &sid, false, true).unwrap(); + for path in [temporary.path(), &child] { + assert_eq!(entries(path, &sid, false), 0); + assert_eq!(entries(path, &other, false), 1); + } + assert_eq!(fs::read_to_string(child).unwrap(), "fixture"); +} diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs b/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs index 2c9e94b11..aa149435f 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs +++ b/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs @@ -111,3 +111,238 @@ fn temporary_grants_preserve_inheritance_control_without_propagating_to_children assert_eq!(snapshot(&root), before); assert_eq!(snapshot(&child), child_before); } + +#[test] +fn noninheriting_probe_grants_do_not_propagate_existing_parent_entries() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_str().unwrap(); + let root = open_path(path).unwrap(); + let child_path = directory.path().join("child"); + fs::create_dir(&child_path).unwrap(); + let child = open_path(child_path.to_str().unwrap()).unwrap(); + let before = snapshot(&child); + let pending = Profile::create().unwrap(); + change_acl(&root, &pending.sid, FILE_GENERIC_READ, GRANT_ACCESS, true).unwrap(); + let profile = Profile::create().unwrap(); + let mut grant = inspect(path, Access::Read).unwrap(); + grant.inherit = false; + let (_input, cancellation) = pending_cancellation(std::time::Duration::from_secs(30)); + ensure_acl( + &grant, + &root, + &profile.sid, + FILE_GENERIC_READ, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + assert_eq!(snapshot(&child), before); +} + +fn pending_cancellation( + budget: std::time::Duration, +) -> (std::sync::mpsc::Sender<()>, Cancellation) { + struct Input(std::sync::mpsc::Receiver<()>); + impl Read for Input { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + let _ = self.0.recv(); + Ok(0) + } + } + let (sender, receiver) = std::sync::mpsc::channel(); + (sender, Cancellation::listen(Input(receiver), budget)) +} + +#[test] +fn root_preparation_waits_remain_cancellable_and_do_not_lock_other_roots() { + use std::time::{Duration, Instant}; + let directory = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let grant = inspect(directory.path().to_str().unwrap(), Access::Read).unwrap(); + let other_grant = inspect(other.path().to_str().unwrap(), Access::Read).unwrap(); + let (_input, cancellation) = pending_cancellation(Duration::from_secs(10)); + let _lock = AclLock::for_root(&grant, &cancellation).unwrap(); + for interrupted in [false, true] { + let grant = grant.clone(); + let other_grant = other_grant.clone(); + std::thread::spawn(move || { + let (input, cancellation) = pending_cancellation(Duration::from_millis(300)); + drop(AclLock::for_root(&other_grant, &cancellation).unwrap()); + if interrupted { + drop(input); + } + let started = Instant::now(); + let error = AclLock::for_root(&grant, &cancellation).err().unwrap(); + assert_eq!( + error.kind(), + if interrupted { + io::ErrorKind::Interrupted + } else { + io::ErrorKind::TimedOut + } + ); + assert!(started.elapsed() < Duration::from_secs(2)); + }) + .join() + .unwrap(); + } +} + +#[test] +fn read_grants_without_write_dacl_skip_root_updates_and_preserve_strict_grants() { + use std::time::Duration; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_str().unwrap(); + let root = open_path(path).unwrap(); + let original = SecurityDescriptor::new(&snapshot(&root)).unwrap(); + let grant = inspect(path, Access::Read).unwrap(); + let restricted = SecurityDescriptor::new("D:P(A;OICI;FRFX;;;OW)").unwrap(); + // SAFETY: This fixture-owned handle retains WRITE_DAC so the original DACL can be restored. + boolean(unsafe { + SetKernelObjectSecurity( + root.as_raw_handle(), + DACL_SECURITY_INFORMATION, + restricted.as_ptr(), + ) + }) + .unwrap(); + let result = std::panic::catch_unwind(|| { + assert!(!open_grant_handle(&grant).unwrap().can_write_dacl); + let before = snapshot(&root); + let (_input, cancellation) = pending_cancellation(Duration::from_secs(30)); + let _lock = AclLock::for_root(&grant, &cancellation).unwrap(); + std::thread::scope(|scope| { + scope + .spawn(|| { + let profile = Profile::create().unwrap(); + let (_input, cancellation) = pending_cancellation(Duration::from_secs(2)); + let grants = Grants::apply( + &profile.sid, + &profile.sid, + std::slice::from_ref(&grant), + &cancellation, + None, + ) + .unwrap(); + assert!(grants.capabilities.is_empty()); + }) + .join() + .unwrap(); + }); + assert_eq!(snapshot(&root), before); + for access in [Access::Write, Access::DenyRead, Access::DenyWrite] { + let strict = Grant { + access, + ..grant.clone() + }; + assert_eq!( + open_grant(&strict).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + } + let replaced = Grant { + inode: "0".to_owned(), + ..grant.clone() + }; + assert_eq!( + open_grant(&replaced).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + }); + // SAFETY: The original descriptor and fixture's retained writable handle are still live. + boolean(unsafe { + SetKernelObjectSecurity( + root.as_raw_handle(), + DACL_SECURITY_INFORMATION, + original.as_ptr(), + ) + }) + .unwrap(); + if let Err(error) = result { + std::panic::resume_unwind(error); + } +} + +#[test] +fn interrupted_acl_propagation_retries_then_restores_lock_free_reuse() { + use sha2::{Digest, Sha256}; + use std::time::Duration; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_str().unwrap(); + let root = open_path(path).unwrap(); + let user = sid_text(&process_user_sid(current_process()).unwrap()).unwrap(); + let descriptor = SecurityDescriptor::new(&format!("D:PAI(A;OICI;FA;;;{user})")).unwrap(); + // SAFETY: Only the synthetic root receives a deterministic inheritable DACL before children exist. + unsafe { + boolean(SetSecurityDescriptorControl( + descriptor.as_ptr(), + SE_DACL_AUTO_INHERIT_REQ, + SE_DACL_AUTO_INHERIT_REQ, + )) + .unwrap(); + boolean(SetKernelObjectSecurity( + root.as_raw_handle(), + DACL_SECURITY_INFORMATION, + descriptor.as_ptr(), + )) + .unwrap(); + } + let child_path = directory.path().join("existing-child"); + fs::create_dir(&child_path).unwrap(); + let child = open_path(child_path.to_str().unwrap()).unwrap(); + let profile = Profile::create().unwrap(); + let grant = inspect(path, Access::Read).unwrap(); + let mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + let sid = sid_text(&profile.sid).unwrap(); + let before = snapshot(&child); + assert!(before.starts_with("D:AI"), "{before}"); + change_acl(&root, &profile.sid, mask, GRANT_ACCESS, true).unwrap(); + assert_eq!(snapshot(&child), before); + let key = Sha256::digest(format!( + "{}:{}:{}:{sid}:{mask}:{GRANT_ACCESS}:true", + grant.path, grant.device, grant.inode, + )); + let journal = journal_directory().unwrap(); + let pending = journal.join(format!("acl-{key:x}.pending")); + File::create_new(&pending).unwrap().sync_all().unwrap(); + let (_input, cancellation) = pending_cancellation(Duration::from_secs(30)); + { + let _lock = AclLock::acquire().unwrap(); + recover(&journal).unwrap(); + } + assert!(pending.exists()); + ensure_acl( + &grant, + &root, + &profile.sid, + mask, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + assert!(!pending.exists()); + assert!( + snapshot(&child).contains(&sid), + "root: {}; child before: {before}; child after: {}; expected: {sid}", + snapshot(&root), + snapshot(&child), + ); + let _lock = AclLock::for_root(&grant, &cancellation).unwrap(); + std::thread::scope(|scope| { + scope + .spawn(|| { + let (_input, cancellation) = pending_cancellation(Duration::from_secs(2)); + ensure_acl( + &grant, + &root, + &profile.sid, + mask, + GRANT_ACCESS, + &cancellation, + ) + .unwrap(); + }) + .join() + .unwrap(); + }); +} diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_metadata.rs b/apps/buddy/native/host/__tests__/shell_sandbox_metadata.rs index dfc5335c8..328c4949a 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox_metadata.rs +++ b/apps/buddy/native/host/__tests__/shell_sandbox_metadata.rs @@ -1,5 +1,8 @@ +use super::super::security::sid_text; use super::super::security::{Profile, current_process}; use super::*; +use crate::windows_security::process_user_sid; +use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; fn record(path: &Path, profile: &str) -> Record { let canonical = fs::canonicalize(path).unwrap(); diff --git a/apps/buddy/native/host/src/shell_sandbox.rs b/apps/buddy/native/host/src/shell_sandbox.rs index 99a46c159..a8fde156c 100644 --- a/apps/buddy/native/host/src/shell_sandbox.rs +++ b/apps/buddy/native/host/src/shell_sandbox.rs @@ -1,3 +1,5 @@ +mod account; +mod acl_state; mod broker; mod cancellation; mod filesystem; @@ -7,6 +9,7 @@ mod policy; pub(crate) mod process; mod security; mod setup; +mod token; use std::io; @@ -14,7 +17,7 @@ const SERVICE_NAME: &str = "LexoraBuddySandboxNetwork"; const PIPE_NAME: &str = r"\\.\pipe\LexoraBuddySandboxNetwork-v1"; const PROFILE_PREFIX: &str = "Lexora.Buddy.Sandbox."; const PROTOCOL_VERSION: u32 = 1; -const COMPONENT_VERSION: u32 = 3; +const COMPONENT_VERSION: u32 = 4; pub fn run() -> io::Result { let arguments: Vec = std::env::args().skip(1).collect(); @@ -30,6 +33,7 @@ pub fn run() -> io::Result { Ok(0) } "run" => process::run(), + "prepare" => process::prepare(), "status" => setup::status(), "health" => broker::Lease::health(), "setup" => setup::elevate("install"), diff --git a/apps/buddy/native/host/src/shell_sandbox/account.rs b/apps/buddy/native/host/src/shell_sandbox/account.rs new file mode 100644 index 000000000..3cfd7ead7 --- /dev/null +++ b/apps/buddy/native/host/src/shell_sandbox/account.rs @@ -0,0 +1,562 @@ +use std::{ + fs, io, + os::windows::io::OwnedHandle, + path::{Path, PathBuf}, + ptr, +}; + +use serde::{Deserialize, Serialize}; +use windows_sys::Win32::{ + NetworkManagement::NetManagement::*, + Security::{ + Authentication::Identity::{ + LSA_OBJECT_ATTRIBUTES, LSA_UNICODE_STRING, LsaAddAccountRights, LsaClose, + LsaNtStatusToWinError, LsaOpenPolicy, POLICY_CREATE_ACCOUNT, POLICY_LOOKUP_NAMES, + }, + Authorization::ConvertStringSidToSidW, + Cryptography::{ + CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN, + CryptProtectData, CryptUnprotectData, + }, + LOGON32_LOGON_BATCH, LOGON32_LOGON_NETWORK, LOGON32_PROVIDER_DEFAULT, LogonUserW, + LookupAccountNameW, + }, +}; + +use super::security::{LocalMemory, boolean, check, owned, sid_text, unique_id, wide}; +use crate::windows_security::Sid; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Account { + pub(super) sid: String, + password: String, + name: String, + pub(super) group_sid: String, + #[serde(default)] + retired: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Identity { + name: String, + sid: String, + group_sid: String, +} + +fn directory() -> io::Result { + Ok(super::setup::installed_path()? + .parent() + .ok_or(io::ErrorKind::InvalidData)? + .to_owned()) +} + +fn sid(name: &str) -> io::Result { + let mut sid_buffer = [0u32; 17]; + let mut sid_size = size_of_val(&sid_buffer) as u32; + let mut domain = [0u16; 256]; + let mut domain_size = domain.len() as u32; + let mut kind = 0; + // SAFETY: All output buffers have their declared capacities; lookup is limited to the local account. + boolean(unsafe { + LookupAccountNameW( + ptr::null(), + wide(name).as_ptr(), + sid_buffer.as_mut_ptr().cast(), + &mut sid_size, + domain.as_mut_ptr(), + &mut domain_size, + &mut kind, + ) + })?; + // SAFETY: The successful lookup initialized a valid SID in aligned storage. + unsafe { Sid::copy(sid_buffer.as_mut_ptr().cast()) } +} + +pub(super) fn parse_sid(text: &str) -> io::Result { + let mut pointer = ptr::null_mut(); + // SAFETY: NUL-terminated input and writable output; allocation is released after copying. + boolean(unsafe { ConvertStringSidToSidW(wide(text).as_ptr(), &mut pointer) })?; + let _memory = LocalMemory(pointer); + // SAFETY: The conversion initialized this live SID. + unsafe { Sid::copy(pointer) } +} + +impl Account { + fn read(path: &std::path::Path) -> io::Result { + let _file = super::setup::protected_object(path)?; + let account: Self = serde_json::from_slice(&crypt(&fs::read(path)?, false)?)?; + if !valid_name(&account.name) + || account + .retired + .iter() + .any(|identity| !valid_name(&identity.name)) + { + return Err(io::ErrorKind::InvalidData.into()); + } + Ok(account) + } + + fn matches(&self) -> io::Result { + Ok(matches_sid(&self.name, &self.sid)? + && matches_sid(&format!("{}G", self.name), &self.group_sid)?) + } + + fn identity(&self) -> Identity { + Identity { + name: self.name.clone(), + sid: self.sid.clone(), + group_sid: self.group_sid.clone(), + } + } + + pub(super) fn logon(&self) -> io::Result { + self.logon_kind(LOGON32_LOGON_BATCH) + } + + fn logon_kind(&self, kind: u32) -> io::Result { + let mut token = ptr::null_mut(); + // SAFETY: Credentials belong only to the provisioned unprivileged account; batch logon creates a fresh logon SID. + boolean(unsafe { + LogonUserW( + wide(&self.name).as_ptr(), + wide(".").as_ptr(), + wide(&self.password).as_ptr(), + kind, + LOGON32_PROVIDER_DEFAULT, + &mut token, + ) + })?; + owned(token) + } +} + +fn valid_name(name: &str) -> bool { + name.starts_with("Lexora") + && [18, 20].contains(&name.len()) + && name[6..].bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn matches_sid(name: &str, expected: &str) -> io::Result { + if expected.is_empty() { + return Ok(false); + } + match sid(name) { + Ok(actual) => Ok(sid_text(&actual)? == expected), + Err(error) if error.raw_os_error() == Some(1332) => Ok(false), + Err(error) => Err(error), + } +} + +fn owner_name(owner: &Sid) -> io::Result { + use sha2::{Digest, Sha256}; + let hash = format!("{:x}", Sha256::digest(sid_text(owner)?.as_bytes())); + Ok(format!("Lexora{}", &hash[..12])) +} + +pub(super) fn owner_matches(owner: &Sid) -> io::Result { + let path = directory()?.join(format!("{}.account", owner_name(owner)?)); + Ok(!path.exists() || Account::read(&path)?.matches()?) +} + +pub(super) fn for_owner(owner: &Sid) -> io::Result { + let key = owner_name(owner)?; + let path = directory()?.join(format!("{key}.account")); + let pending = directory()?.join(format!("{key}.account-pending")); + if pending.exists() { + return provision(&path, Account::read(&pending)?); + } + if path.exists() { + let account = Account::read(&path)?; + if !account.matches()? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Sandbox account identity requires repair", + )); + } + return Ok(account); + } + let name = match sid(&key) { + Err(error) if error.raw_os_error() == Some(1332) => key.clone(), + Err(error) => return Err(error), + Ok(_) => fresh_name()?, + }; + provision( + &path, + Account { + name, + password: format!("Lx9!{}{}", unique_id()?, unique_id()?), + sid: String::new(), + group_sid: String::new(), + retired: Vec::new(), + }, + ) +} + +fn fresh_name() -> io::Result { + Ok(format!("Lexora{}", &unique_id()?.replace('-', "")[..14])) +} + +fn provision(path: &Path, mut account: Account) -> io::Result { + let pending = path.with_extension("account-pending"); + if !pending.exists() { + save(&pending, &account)?; + } + let name = account.name.clone(); + let name_wide = wide(&name); + let password_wide = wide(&account.password); + let info = USER_INFO_1 { + usri1_name: name_wide.as_ptr().cast_mut(), + usri1_password: password_wide.as_ptr().cast_mut(), + usri1_priv: USER_PRIV_USER, + usri1_flags: UF_NORMAL_ACCOUNT | UF_DONT_EXPIRE_PASSWD | UF_PASSWD_CANT_CHANGE, + ..Default::default() + }; + // SAFETY: Credentials are durable before creation. A retry adopts an existing identity only after authenticating the saved secret. + let result = + unsafe { NetUserAdd(ptr::null(), 1, ptr::from_ref(&info).cast(), ptr::null_mut()) }; + if result == NERR_UserExists { + drop( + account + .logon() + .or_else(|_| account.logon_kind(LOGON32_LOGON_NETWORK))?, + ); + } else { + check(result)?; + } + account.sid = sid_text(&sid(&name)?)?; + account.group_sid = create_group(&account)?; + deny_logons(&parse_sid(&account.sid)?)?; + hide_account(&name, true)?; + drop(account.logon()?); + save(path, &account)?; + fs::remove_file(pending)?; + Ok(account) +} + +fn save(path: &std::path::Path, account: &Account) -> io::Result<()> { + super::acl_state::publish(path, &crypt(&serde_json::to_vec(account)?, true)?, true) +} + +pub(super) fn repair() -> io::Result<()> { + for entry in fs::read_dir(directory()?)? { + let path = entry?.path(); + let Some(_) = path + .file_stem() + .and_then(|key| key.to_str()) + .filter(|key| valid_name(key) && key.len() == 18) + else { + continue; + }; + if path + .extension() + .is_none_or(|extension| extension != "account") + { + continue; + } + let mut account = repair_record(&path)?; + if !account.retired.is_empty() { + let principals = account + .retired + .iter() + .flat_map(|identity| [identity.sid.clone(), identity.group_sid.clone()]) + .filter(|sid| !sid.is_empty()) + .collect::>(); + super::acl_state::cleanup(Some(&principals))?; + for identity in &account.retired { + remove_identity(identity)?; + } + account.retired.clear(); + save(&path, &account)?; + } + } + Ok(()) +} + +fn repair_record(path: &Path) -> io::Result { + let pending = path.with_extension("account-pending"); + let account = if pending.exists() { + provision(path, Account::read(&pending)?)? + } else { + Account::read(path)? + }; + if account.matches()? { + return Ok(account); + } + let mut retired = account.retired.clone(); + retired.push(account.identity()); + provision( + path, + Account { + name: fresh_name()?, + password: format!("Lx9!{}{}", unique_id()?, unique_id()?), + sid: String::new(), + group_sid: String::new(), + retired, + }, + ) +} + +fn remove_identity(identity: &Identity) -> io::Result<()> { + if matches_sid(&identity.name, &identity.sid)? { + // SAFETY: The live account still has the SID recorded in administrator-protected state. + check(unsafe { NetUserDel(ptr::null(), wide(&identity.name).as_ptr()) })?; + hide_account(&identity.name, false)?; + } + let group = format!("{}G", identity.name); + if matches_sid(&group, &identity.group_sid)? { + // SAFETY: Only the recorded group identity is deleted; a same-named replacement is preserved. + check(unsafe { NetLocalGroupDel(ptr::null(), wide(&group).as_ptr()) })?; + } + Ok(()) +} + +fn group_marker(account: &Account) -> io::Result { + use sha2::{Digest, Sha256}; + Ok(format!( + "Lexora sandbox {:x}", + Sha256::digest(account.password.as_bytes()) + )) +} + +fn group_comment(name: &str) -> io::Result { + let mut buffer = ptr::null_mut(); + // SAFETY: The level-1 local group query returns a terminated description in an owned NetAPI buffer. + check(unsafe { NetLocalGroupGetInfo(ptr::null(), wide(name).as_ptr(), 1, &mut buffer) })?; + // SAFETY: A successful level-1 query initializes this pointer. Null descriptions are treated as empty. + let actual = unsafe { (*buffer.cast::()).lgrpi1_comment }; + let mut text = Vec::new(); + if !actual.is_null() { + for index in 0..256 { + // SAFETY: The returned description is terminated; iteration stops before advancing past its terminator. + let value = unsafe { *actual.add(index) }; + if value == 0 { + break; + } + text.push(value); + } + } + // SAFETY: Matching release for the NetAPI query allocation. + unsafe { NetApiBufferFree(buffer.cast()) }; + String::from_utf16(&text).map_err(|_| io::ErrorKind::InvalidData.into()) +} + +fn create_group(account: &Account) -> io::Result { + let name = wide(&format!("{}G", account.name)); + let marker = group_marker(account)?; + let comment = wide(&marker); + let info = LOCALGROUP_INFO_1 { + lgrpi1_name: name.as_ptr().cast_mut(), + lgrpi1_comment: comment.as_ptr().cast_mut(), + }; + // SAFETY: The group has a private durable provisioning marker; an unrelated group is never adopted. + let result = + unsafe { NetLocalGroupAdd(ptr::null(), 1, ptr::from_ref(&info).cast(), ptr::null_mut()) }; + if result == windows_sys::Win32::Foundation::ERROR_ALIAS_EXISTS || result == NERR_GroupExists { + if marker != group_comment(&format!("{}G", account.name))? { + return Err(io::ErrorKind::AlreadyExists.into()); + } + } else { + check(result)?; + } + let user = parse_sid(&account.sid)?; + let member = LOCALGROUP_MEMBERS_INFO_0 { + lgrmi0_sid: user.as_ptr(), + }; + // SAFETY: Only the verified sandbox account joins this dedicated group. + let result = unsafe { + NetLocalGroupAddMembers( + ptr::null(), + name.as_ptr(), + 0, + ptr::from_ref(&member).cast(), + 1, + ) + }; + if result != windows_sys::Win32::Foundation::ERROR_MEMBER_IN_ALIAS { + check(result)?; + } + sid_text(&sid(&format!("{}G", account.name))?) +} + +fn hide_account(name: &str, hide: bool) -> io::Result<()> { + use windows_sys::Win32::System::Registry::*; + let mut key = ptr::null_mut(); + // SAFETY: The service changes only this account's visibility in the Windows sign-in picker. + check(unsafe { + RegCreateKeyExW( + HKEY_LOCAL_MACHINE, + wide(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList") + .as_ptr(), + 0, + ptr::null(), + 0, + KEY_SET_VALUE, + ptr::null(), + &mut key, + ptr::null_mut(), + ) + } as u32)?; + let value = 0u32; + // SAFETY: The value is exactly one DWORD, or the owned account's named value is removed. + let result = unsafe { + if hide { + RegSetValueExW( + key, + wide(name).as_ptr(), + 0, + REG_DWORD, + ptr::from_ref(&value).cast(), + 4, + ) + } else { + RegDeleteValueW(key, wide(name).as_ptr()) + } + } as u32; + // SAFETY: Matching close of the opened registry key. + unsafe { RegCloseKey(key) }; + if !hide && result == 2 { + return Ok(()); + } + check(result) +} + +pub(super) fn uninstall() -> io::Result<()> { + for entry in fs::read_dir(directory()?)? { + let path = entry?.path(); + let Some(_) = path + .file_stem() + .and_then(|key| key.to_str()) + .filter(|key| valid_name(key) && key.len() == 18) + else { + continue; + }; + if path + .extension() + .is_none_or(|extension| extension != "account" && extension != "account-pending") + { + continue; + } + let mut account = Account::read(&path)?; + if account.sid.is_empty() { + match sid(&account.name) { + Ok(actual) => { + drop( + account + .logon() + .or_else(|_| account.logon_kind(LOGON32_LOGON_NETWORK))?, + ); + account.sid = sid_text(&actual)?; + } + Err(error) if error.raw_os_error() == Some(1332) => {} + Err(error) => return Err(error), + } + } + let group = format!("{}G", account.name); + if account.group_sid.is_empty() { + match sid(&group) { + Ok(actual) if group_marker(&account)? == group_comment(&group)? => { + account.group_sid = sid_text(&actual)? + } + Ok(_) => {} + Err(error) if error.raw_os_error() == Some(1332) => {} + Err(error) => return Err(error), + } + } + remove_identity(&account.identity())?; + for identity in &account.retired { + remove_identity(identity)?; + } + fs::remove_file(path)?; + } + Ok(()) +} + +fn crypt(data: &[u8], protect: bool) -> io::Result> { + let input = CRYPT_INTEGER_BLOB { + cbData: data + .len() + .try_into() + .map_err(|_| io::ErrorKind::InvalidInput)?, + pbData: data.as_ptr().cast_mut(), + }; + let mut output = CRYPT_INTEGER_BLOB::default(); + // SAFETY: DPAPI reads the live input and returns an independently allocated output; UI is disabled for service use. + boolean(unsafe { + if protect { + CryptProtectData( + &input, + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE, + &mut output, + ) + } else { + CryptUnprotectData( + &input, + ptr::null_mut(), + ptr::null(), + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } + })?; + let _memory = LocalMemory(output.pbData.cast()); + // SAFETY: The successful DPAPI call returned exactly cbData readable bytes. + Ok(unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize) }.to_vec()) +} + +fn deny_logons(sid: &Sid) -> io::Result<()> { + let attributes = LSA_OBJECT_ATTRIBUTES { + Length: size_of::() as u32, + ..Default::default() + }; + let mut handle = 0isize; + // SAFETY: Local policy is opened only during elevated setup; output receives an owned policy handle. + check(unsafe { + LsaNtStatusToWinError(LsaOpenPolicy( + ptr::null(), + &attributes, + (POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES) as u32, + &mut handle, + )) + })?; + let rights: Vec> = [ + "SeBatchLogonRight", + "SeDenyInteractiveLogonRight", + "SeDenyRemoteInteractiveLogonRight", + "SeDenyNetworkLogonRight", + ] + .iter() + .map(|text| wide(text)) + .collect(); + let rights: Vec = rights + .iter() + .map(|text| LSA_UNICODE_STRING { + Length: ((text.len() - 1) * 2) as u16, + MaximumLength: (text.len() * 2) as u16, + Buffer: text.as_ptr().cast_mut(), + }) + .collect(); + // SAFETY: Rights and SID remain live for the call; the handle is closed regardless of the result. + let result = unsafe { + LsaNtStatusToWinError(LsaAddAccountRights( + handle, + sid.as_ptr(), + rights.as_ptr(), + rights.len() as u32, + )) + }; + // SAFETY: Matching close of the policy handle opened above. + unsafe { LsaClose(handle) }; + check(result) +} + +#[cfg(test)] +#[path = "../../__tests__/shell_sandbox_account.rs"] +mod tests; diff --git a/apps/buddy/native/host/src/shell_sandbox/acl_state.rs b/apps/buddy/native/host/src/shell_sandbox/acl_state.rs new file mode 100644 index 000000000..a2b756f26 --- /dev/null +++ b/apps/buddy/native/host/src/shell_sandbox/acl_state.rs @@ -0,0 +1,402 @@ +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + ptr, +}; + +use serde::{Deserialize, Serialize}; +use windows_sys::Win32::{ + Security::Authorization::DENY_ACCESS, + Storage::FileSystem::{ + CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }, +}; + +use super::{ + account::Account, + cancellation::Cancellation, + filesystem::{self, Access, AclLock, Grant}, + security::{SecurityDescriptor, boolean, owned, sid_text, unique_id, wide}, + setup, +}; +use crate::windows_security::Sid; + +const STATE_LIMIT: u64 = 32 * 1024 * 1024; + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Ledger { + roots: BTreeMap, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Root { + grant: Grant, + principals: Vec, +} + +fn key(grant: &Grant) -> String { + format!( + "{}:{}:{}", + grant.path.to_uppercase(), + grant.device, + grant.inode + ) +} + +fn ledger_path(owner: &Sid) -> io::Result { + let executable = setup::installed_path()?; + let directory = executable.parent().ok_or(io::ErrorKind::InvalidData)?; + setup::protected_object(directory)?; + Ok(directory.join(format!("acl-state-v1-{}.json", sid_text(owner)?))) +} + +fn read Deserialize<'de> + Default>(path: &Path, protected: bool) -> io::Result { + if protected { + match setup::protected_object(path) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(T::default()), + Err(error) => return Err(error), + } + } + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(T::default()), + Err(error) => return Err(error), + }; + let mut bytes = Vec::new(); + file.take(STATE_LIMIT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > STATE_LIMIT { + return Err(io::ErrorKind::InvalidData.into()); + } + serde_json::from_slice(&bytes).map_err(io::Error::from) +} + +pub(super) fn publish(path: &Path, bytes: &[u8], protected: bool) -> io::Result<()> { + if bytes.len() as u64 > STATE_LIMIT { + return Err(io::ErrorKind::InvalidInput.into()); + } + let _parent = if protected { + Some(setup::protected_object( + path.parent().ok_or(io::ErrorKind::InvalidInput)?, + )?) + } else { + None + }; + let pending = path.with_extension(format!("{}.pending-state", unique_id()?)); + let result = (|| { + let mut file = if protected { + let descriptor = SecurityDescriptor::new("O:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)")?; + let attributes = descriptor.attributes(false); + // SAFETY: The fresh temporary file is administrator/SYSTEM-only from creation, under a verified protected parent. + File::from(owned(unsafe { + CreateFileW( + wide(&pending.to_string_lossy()).as_ptr(), + FILE_GENERIC_WRITE, + 0, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + ptr::null_mut(), + ) + })?) + } else { + OpenOptions::new() + .write(true) + .create_new(true) + .open(&pending)? + }; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + // SAFETY: Both paths are in the same protected or host-private directory; publication is atomic and durable. + boolean(unsafe { + MoveFileExW( + wide(&pending.to_string_lossy()).as_ptr(), + wide(&path.to_string_lossy()).as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }) + })(); + if result.is_err() { + let _ = fs::remove_file(pending); + } + result +} + +// The service records intent before the ordinary-user supervisor changes any ACL. +pub(super) fn remember(owner: &Sid, account: &Account, grants: &[Grant]) -> io::Result<()> { + if grants.len() > 1024 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let path = ledger_path(owner)?; + let mut ledger: Ledger = read(&path, true)?; + let owner = sid_text(owner)?; + let capabilities: Vec<_> = grants + .iter() + .filter(|grant| matches!(grant.access, Access::Write)) + .map(|grant| (grant, filesystem::capability(&owner, grant))) + .collect(); + let mut changed = false; + for grant in grants { + if !crate::windows_path::valid(&grant.path) || grant.path.starts_with(r"\\") { + return Err(io::ErrorKind::InvalidInput.into()); + } + let principals = match grant.access { + Access::Read | Access::DenyRead => vec![account.sid.clone()], + Access::Write => vec![ + account.group_sid.clone(), + filesystem::capability(&owner, grant), + ], + Access::DenyWrite => capabilities + .iter() + .filter(|(root, _)| super::policy::contains(&root.path, &grant.path)) + .map(|(_, sid)| sid.clone()) + .collect(), + }; + if principals.is_empty() { + continue; + } + let root = ledger.roots.entry(key(grant)).or_insert_with(|| { + changed = true; + let mut grant = grant.clone(); + grant.exceptions.clear(); + Root { + grant, + principals: Vec::new(), + } + }); + if grant.inherit && !root.grant.inherit { + root.grant.inherit = true; + changed = true; + } + for principal in principals { + if !root.principals.contains(&principal) { + root.principals.push(principal); + changed = true; + } + } + } + if changed { + publish(&path, &serde_json::to_vec(&ledger)?, true)?; + } + Ok(()) +} + +pub(super) fn discard_unpublished() -> io::Result<()> { + let executable = setup::installed_path()?; + let directory = executable.parent().ok_or(io::ErrorKind::InvalidData)?; + let _directory = setup::protected_object(directory)?; + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + if path + .extension() + .is_some_and(|extension| extension == "pending-state") + { + setup::protected_object(&path)?; + fs::remove_file(path)?; + } + } + Ok(()) +} + +pub(super) fn cleanup(principals: Option<&[String]>) -> io::Result<()> { + let executable = setup::installed_path()?; + let directory = executable.parent().ok_or(io::ErrorKind::InvalidData)?; + let _directory = setup::protected_object(directory)?; + let mut remaining = 0; + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("acl-state-v1-S-1-") || !name.ends_with(".json") { + continue; + } + let mut ledger: Ledger = read(&path, true)?; + for root in ledger.roots.values_mut() { + if principals.is_some_and(|selected| { + !root + .principals + .iter() + .any(|principal| selected.contains(principal)) + }) { + continue; + } + let file = filesystem::open_grant(&root.grant); + root.principals.retain(|principal| { + if principals.is_some_and(|selected| !selected.contains(principal)) { + return true; + } + let result = file + .as_ref() + .map_err(|error| { + io::Error::new(error.kind(), "Recorded ACL root is unavailable") + }) + .and_then(|file| { + filesystem::revoke_owned( + file, + &super::account::parse_sid(principal)?, + false, + root.grant.inherit, + ) + }); + if result.is_err() { + remaining += 1; + true + } else { + false + } + }); + } + ledger.roots.retain(|_, root| !root.principals.is_empty()); + if ledger.roots.is_empty() { + fs::remove_file(path)?; + } else { + publish(&path, &serde_json::to_vec(&ledger)?, true)?; + } + } + if remaining != 0 { + eprintln!( + "{}", + serde_json::json!({"type":"diagnostic","phase":"acl_cleanup_incomplete","remaining":remaining,"nativeCode":null}) + ); + } + Ok(()) +} + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct DenialScope { + principal: String, + roots: BTreeMap, +} + +type Denials = BTreeMap; + +pub(super) enum DenialUpdate<'a> { + Merge(&'a str), + Replace(&'a str), +} + +pub(super) fn update_denials( + update: DenialUpdate<'_>, + principal: &Sid, + grants: &[Grant], + cancellation: &Cancellation, +) -> io::Result<()> { + let scope = match update { + DenialUpdate::Merge(scope) | DenialUpdate::Replace(scope) => scope, + }; + if scope.is_empty() || scope.len() > 4096 || grants.len() > 1024 { + return Err(io::ErrorKind::InvalidInput.into()); + } + if matches!(update, DenialUpdate::Merge(_)) && grants.is_empty() { + return Ok(()); + } + let _lock = AclLock::for_denials(cancellation)?; + let path = filesystem::journal_directory()?.join("denials-v1.json"); + reconcile(&path, update, principal, grants, cancellation) +} + +fn reconcile( + path: &Path, + update: DenialUpdate<'_>, + principal: &Sid, + grants: &[Grant], + cancellation: &Cancellation, +) -> io::Result<()> { + let (scope, replace) = match update { + DenialUpdate::Merge(scope) => (scope.to_uppercase(), false), + DenialUpdate::Replace(scope) => (scope.to_uppercase(), true), + }; + let mut state: Denials = read(path, false)?; + let principal_text = sid_text(principal)?; + let previous = state + .get(&scope) + .filter(|old| old.principal == principal_text) + .cloned() + .unwrap_or_default(); + let mut desired = if replace { + BTreeMap::new() + } else { + previous.roots.clone() + }; + desired.extend(grants.iter().map(|grant| (key(grant), grant.clone()))); + if desired.is_empty() && previous.roots.is_empty() { + return Ok(()); + } + let mut intent = previous.roots.clone(); + intent.extend(desired.clone()); + let changed = state + .get(&scope) + .is_none_or(|old| old.principal != principal_text || old.roots.keys().ne(intent.keys())); + state.insert( + scope.to_owned(), + DenialScope { + principal: principal_text.clone(), + roots: intent, + }, + ); + if changed { + publish(path, &serde_json::to_vec(&state)?, false)?; + } + for grant in grants { + cancellation.check()?; + let file = filesystem::open_grant(grant)?; + filesystem::ensure_acl( + grant, + &file, + principal, + filesystem::denial_mask(grant.access)?, + DENY_ACCESS, + cancellation, + )?; + } + for (key, grant) in &previous.roots { + cancellation.check()?; + if desired.contains_key(key) + || state.iter().any(|(other, entry)| { + other != &scope + && entry.principal == principal_text + && entry.roots.contains_key(key) + }) + { + continue; + } + let _lock = AclLock::for_root(grant, cancellation)?; + match filesystem::open_grant(grant) { + Ok(file) => filesystem::revoke_owned(&file, principal, true, grant.inherit)?, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::InvalidInput + ) => {} + Err(error) => return Err(error), + } + } + let removed = previous.roots.keys().ne(desired.keys()); + if desired.is_empty() { + state.remove(&scope); + } else { + state.insert( + scope.to_owned(), + DenialScope { + principal: principal_text, + roots: desired, + }, + ); + } + if removed { + publish(path, &serde_json::to_vec(&state)?, false)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "../../__tests__/shell_sandbox_acl_state.rs"] +mod tests; diff --git a/apps/buddy/native/host/src/shell_sandbox/broker.rs b/apps/buddy/native/host/src/shell_sandbox/broker.rs index 38eb65c10..1e08ab06b 100644 --- a/apps/buddy/native/host/src/shell_sandbox/broker.rs +++ b/apps/buddy/native/host/src/shell_sandbox/broker.rs @@ -32,7 +32,8 @@ use windows_sys::Win32::{ use super::{ PIPE_NAME, PROTOCOL_VERSION, SERVICE_NAME, metadata, - network::{Network, valid_profile}, + network::Network, + process::{Child, LaunchRequest}, security::{ SecurityDescriptor, at, boolean, current_process, owned, process_image, require_host, wide, }, @@ -51,12 +52,13 @@ enum Request { Health { protocol: u32, }, - Lease { + Account { protocol: u32, - profile: String, - #[serde(rename = "proxyPort")] - proxy_port: u16, - roots: Vec, + grants: Vec, + }, + Launch { + protocol: u32, + command: LaunchRequest, }, } @@ -127,15 +129,9 @@ fn publish(state: u32, error: u32) { } enum State { - Pending { - bytes: Vec, - deadline: Instant, - }, - Active { - profile: String, - filters: Vec, - metadata: Box, - }, + Preparing, + Pending { bytes: Vec, deadline: Instant }, + Active { child: Child, job: OwnedHandle }, } struct Client { pipe: File, @@ -155,7 +151,6 @@ fn serve() -> io::Result<()> { })?; let network = Network::open()?; network.verify()?; - network.recover()?; metadata::recover()?; let mut listener = listener(true)?; let mut clients: Vec = Vec::new(); @@ -183,21 +178,33 @@ fn serve() -> io::Result<()> { drop(guard); let mut index = 0; while index < clients.len() { - let alive = handle_client(&mut clients[index], &network).unwrap_or(false); + let alive = match handle_client(&mut clients[index], &network) { + Ok(alive) => alive, + Err(error) => { + let phase = error + .get_ref() + .and_then(|error| error.downcast_ref::()); + let code = phase + .map_or_else(|| error.raw_os_error(), |phase| phase.source.raw_os_error()) + .unwrap_or(31); + let reply = format!( + "{}\n", + serde_json::json!({ "error": code, "phase": phase.map(|error| error.phase) }) + ); + let _ = clients[index].pipe.write_all(reply.as_bytes()); + false + } + }; if alive { index += 1; } else { let mut client = clients.swap_remove(index); - if let State::Active { - profile, - filters, - mut metadata, - } = client.state - { - let files = metadata.finish(); - let network = network.revoke(&profile, &filters); - drop(metadata); - files.and(network)?; + let leased = matches!(client.state, State::Preparing | State::Active { .. }); + if let State::Active { child, job } = client.state { + drop(child); + drop(job); + } + if leased { let _ = client.pipe.write_all(b"done\n"); } } @@ -263,7 +270,9 @@ fn authenticate_client(pipe: &File) -> io::Result { // SAFETY: Query/synchronize access pins the process identity without granting process mutation. let process = owned(unsafe { OpenProcess( - PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + PROCESS_QUERY_LIMITED_INFORMATION + | PROCESS_SYNCHRONIZE + | windows_sys::Win32::System::Threading::PROCESS_DUP_HANDLE, 0, pid, ) @@ -284,7 +293,7 @@ fn handle_client(client: &mut Client, network: &Network) -> io::Result { } let available = available(&client.pipe)?; match &mut client.state { - State::Active { .. } => Ok(available == 0), + State::Preparing | State::Active { .. } => Ok(available == 0), State::Pending { bytes, deadline } => { if Instant::now() >= *deadline || available as usize > REQUEST_LIMIT { return Ok(false); @@ -306,59 +315,208 @@ fn handle_client(client: &mut Client, network: &Network) -> io::Result { } let request: Request = serde_json::from_slice(&bytes[..end]).map_err(|_| io::ErrorKind::InvalidData)?; - let (profile, roots) = match request { + match request { Request::Health { protocol } if protocol == PROTOCOL_VERSION => { - client.pipe.write_all(b"ready\n")?; + let owner = + crate::windows_security::process_user_sid(client.owner.as_raw_handle())?; + let healthy = super::account::owner_matches(&owner)?; + writeln!(client.pipe, "{}", serde_json::json!({ "healthy": healthy }))?; return Ok(false); } - Request::Lease { - protocol, - profile, - proxy_port, - roots, - } if protocol == PROTOCOL_VERSION && valid_profile(&profile) && proxy_port != 0 => { - (profile, roots) + Request::Account { protocol, grants } if protocol == PROTOCOL_VERSION => { + let owner = + crate::windows_security::process_user_sid(client.owner.as_raw_handle())?; + let account = super::account::for_owner(&owner)?; + network.ensure_account(&account.sid)?; + super::acl_state::remember(&owner, &account, &grants)?; + let job = super::process::Job::new()?; + let security = SecurityDescriptor::new(&format!( + "D:P(A;;GA;;;SY)(A;;GA;;;{})", + super::security::sid_text(&owner)? + ))?; + // SAFETY: The fresh job has no processes and grants control only to SYSTEM and this authenticated supervisor's owner. + boolean(unsafe { + SetKernelObjectSecurity( + job.0.as_raw_handle(), + DACL_SECURITY_INFORMATION, + security.as_ptr(), + ) + })?; + let handle = RemoteHandle::new(&client.owner, job.0.as_raw_handle())?; + let mut reply = serde_json::to_vec( + &serde_json::json!({ "sid": account.sid, "group": account.group_sid, "job": handle.value as usize, "name": job.name() }), + )?; + reply.push(b'\n'); + client.pipe.write_all(&reply)?; + handle.handoff(); + job.handoff(); + client.state = State::Preparing; + return Ok(true); + } + Request::Launch { protocol, command } if protocol == PROTOCOL_VERSION => { + let owner = + crate::windows_security::process_user_sid(client.owner.as_raw_handle())?; + let account = super::account::for_owner(&owner)?; + network.verify_account(&account.sid)?; + let (child, job) = + super::process::launch(&command, client.owner.as_raw_handle(), &account)?; + let process = RemoteHandle::new(&client.owner, child.process.as_raw_handle())?; + let thread = RemoteHandle::new(&client.owner, child.thread.as_raw_handle())?; + client.state = State::Active { child, job }; + let mut reply = serde_json::to_vec( + &serde_json::json!({ "process": process.value as usize, "thread": thread.value as usize }), + )?; + reply.push(b'\n'); + client.pipe.write_all(&reply)?; + process.handoff(); + thread.handoff(); } _ => return Ok(false), - }; - let metadata = metadata::Lease::apply(&profile, &roots, client.owner.as_raw_handle())?; - let filters = network.grant(&profile)?; - client.state = State::Active { - profile, - filters, - metadata: Box::new(metadata), - }; - client.pipe.write_all(b"ready\n")?; + } Ok(true) } } } +struct RemoteHandle<'a> { + owner: &'a OwnedHandle, + value: windows_sys::Win32::Foundation::HANDLE, + transferred: bool, +} + +impl<'a> RemoteHandle<'a> { + fn new( + owner: &'a OwnedHandle, + source: windows_sys::Win32::Foundation::HANDLE, + ) -> io::Result { + Ok(Self { + owner, + value: super::process::duplicate( + current_process(), + source, + owner.as_raw_handle(), + false, + )?, + transferred: false, + }) + } + + fn handoff(mut self) { + self.transferred = true; + } +} + +impl Drop for RemoteHandle<'_> { + fn drop(&mut self) { + if self.transferred { + return; + } + let mut local = ptr::null_mut(); + // SAFETY: The pinned recipient owns this unpublished duplicate; failed publication must close it in that process. + if unsafe { + windows_sys::Win32::Foundation::DuplicateHandle( + self.owner.as_raw_handle(), + self.value, + current_process(), + &mut local, + 0, + 0, + windows_sys::Win32::Foundation::DUPLICATE_CLOSE_SOURCE + | windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS, + ) + } != 0 + { + drop(owned(local)); + } + } +} + pub(super) struct Lease(File); impl Lease { - pub(super) fn connect( - profile: &str, - port: u16, - roots: Vec, - ) -> io::Result { - Self::request(Request::Lease { + pub(super) fn account( + grants: Vec, + ) -> io::Result<( + Self, + crate::windows_security::Sid, + crate::windows_security::Sid, + super::process::Job, + )> { + let (lease, reply) = Self::request(Request::Account { protocol: PROTOCOL_VERSION, - profile: profile.to_owned(), - proxy_port: port, - roots, - }) + grants, + })?; + let handle = owned( + reply + .get("job") + .and_then(serde_json::Value::as_u64) + .ok_or(io::ErrorKind::InvalidData)? + as windows_sys::Win32::Foundation::HANDLE, + )?; + let name = reply + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or(io::ErrorKind::InvalidData)?; + let sid = super::account::parse_sid( + reply + .get("sid") + .and_then(serde_json::Value::as_str) + .ok_or(io::ErrorKind::InvalidData)?, + )?; + let group = super::account::parse_sid( + reply + .get("group") + .and_then(serde_json::Value::as_str) + .ok_or(io::ErrorKind::InvalidData)?, + )?; + Ok(( + lease, + sid, + group, + super::process::Job::received(handle, name.to_owned()), + )) + } + + pub(super) fn connect(command: LaunchRequest) -> io::Result<(Self, Child)> { + let (lease, reply) = Self::request(Request::Launch { + protocol: PROTOCOL_VERSION, + command, + })?; + let handle = |name: &str| -> io::Result { + let value = reply + .get(name) + .and_then(serde_json::Value::as_u64) + .ok_or(io::ErrorKind::InvalidData)?; + owned(value as windows_sys::Win32::Foundation::HANDLE) + }; + Ok(( + lease, + Child { + process: handle("process")?, + thread: handle("thread")?, + }, + )) } pub(super) fn health() -> io::Result { require_host(current_process(), false)?; setup::configuration_valid(&setup::installed_path()?)?; - Self::request(Request::Health { + let (_, reply) = Self::request(Request::Health { protocol: PROTOCOL_VERSION, })?; - Ok(0) + match reply.get("healthy").and_then(serde_json::Value::as_bool) { + Some(true) => Ok(0), + Some(false) => { + eprintln!( + "{}", + serde_json::json!({"type":"error","code":"SANDBOX_NEEDS_UPDATE","nativeCode":null}) + ); + Ok(125) + } + None => Err(io::ErrorKind::InvalidData.into()), + } } - fn request(request: Request) -> io::Result { + fn request(request: Request) -> io::Result<(Self, serde_json::Value)> { at("service_start", setup::start_service())?; let deadline = Instant::now() + Duration::from_secs(10); let mut pipe = loop { @@ -379,18 +537,33 @@ impl Lease { let mut message = serde_json::to_vec(&request)?; message.push(b'\n'); pipe.write_all(&message)?; - while available(&pipe)? < 6 { - if Instant::now() >= deadline { + let mut reply = Vec::new(); + loop { + if Instant::now() >= deadline || reply.len() > 1024 { return Err(io::ErrorKind::TimedOut.into()); } - std::thread::sleep(Duration::from_millis(25)); - } - let mut reply = [0; 6]; - pipe.read_exact(&mut reply)?; - if reply != *b"ready\n" { - return Err(io::ErrorKind::InvalidData.into()); + let count = available(&pipe)?; + if count == 0 { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + let mut bytes = vec![0; (count as usize).min(1024)]; + let count = pipe.read(&mut bytes)?; + reply.extend_from_slice(&bytes[..count]); + if reply.last() == Some(&b'\n') { + let value: serde_json::Value = serde_json::from_slice(&reply)?; + if let Some(error) = value.get("error").and_then(serde_json::Value::as_i64) { + if let Some(phase) = value.get("phase").and_then(serde_json::Value::as_str) { + eprintln!( + "{}", + serde_json::json!({ "type": "diagnostic", "phase": phase, "nativeCode": error }) + ); + } + return Err(io::Error::from_raw_os_error(error as i32)); + } + return Ok((Self(pipe), value)); + } } - Ok(Self(pipe)) } pub(super) fn alive(&self) -> bool { diff --git a/apps/buddy/native/host/src/shell_sandbox/cancellation.rs b/apps/buddy/native/host/src/shell_sandbox/cancellation.rs index f85f69615..e3e27d84d 100644 --- a/apps/buddy/native/host/src/shell_sandbox/cancellation.rs +++ b/apps/buddy/native/host/src/shell_sandbox/cancellation.rs @@ -13,7 +13,7 @@ pub(super) struct Cancellation { } impl Cancellation { - pub(super) fn listen(mut input: impl Read + Send + 'static) -> Self { + pub(super) fn listen(mut input: impl Read + Send + 'static, budget: Duration) -> Self { let cancelled = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&cancelled); std::thread::spawn(move || { @@ -23,7 +23,7 @@ impl Cancellation { }); Self { cancelled, - preparation_deadline: Instant::now() + Duration::from_secs(60), + preparation_deadline: Instant::now() + budget, } } diff --git a/apps/buddy/native/host/src/shell_sandbox/filesystem.rs b/apps/buddy/native/host/src/shell_sandbox/filesystem.rs index 02bf9dde8..0e2e09594 100644 --- a/apps/buddy/native/host/src/shell_sandbox/filesystem.rs +++ b/apps/buddy/native/host/src/shell_sandbox/filesystem.rs @@ -1,6 +1,6 @@ use std::{ fs::{self, File, OpenOptions}, - io::{self, Read, Write}, + io::{self, Read}, os::windows::{ fs::OpenOptionsExt, io::{AsRawHandle, OwnedHandle}, @@ -11,7 +11,7 @@ use std::{ use serde::{Deserialize, Serialize}; use windows_sys::Win32::{ - Foundation::{ERROR_INSUFFICIENT_BUFFER, WAIT_ABANDONED, WAIT_OBJECT_0}, + Foundation::{ERROR_INSUFFICIENT_BUFFER, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT}, Security::{ Authorization::{ EXPLICIT_ACCESS_W, GRANT_ACCESS, REVOKE_ACCESS, SetEntriesInAclW, TRUSTEE_IS_SID, @@ -40,7 +40,7 @@ use super::security::{ }; use crate::windows_security::{Sid, process_user_sid}; -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct Grant { pub(super) path: String, @@ -57,7 +57,7 @@ fn inherited() -> bool { true } -#[derive(Clone, Copy, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(super) enum Access { Read, @@ -66,106 +66,363 @@ pub(super) enum Access { DenyWrite, } -pub(super) struct Grants<'a> { - sid: &'a Sid, - handles: Vec, - validation_handles: Vec, - journal: Option, - roots: Vec, +pub(super) struct Grants { + _handles: Vec, + pub(super) capabilities: Vec, +} + +struct OpenedGrant { + file: File, + can_write_dacl: bool, +} + +pub(super) fn capability(owner: &str, grant: &Grant) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(format!( + "v4:{owner}:{}:{}:{}", + grant.path.to_uppercase(), + grant.device, + grant.inode + )); + let parts: Vec<_> = hash[..16] + .as_chunks::<4>() + .0 + .iter() + .map(|part| u32::from_le_bytes(*part).to_string()) + .collect(); + format!("S-1-5-21-{}", parts.join("-")) +} + +pub(super) fn denial_mask(access: Access) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::{ + FILE_APPEND_DATA, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, WRITE_OWNER, + }; + match access { + Access::DenyRead => { + Ok(FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE) + } + Access::DenyWrite => Ok(FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_ATTRIBUTES + | FILE_WRITE_EA + | DELETE + | WRITE_DAC + | WRITE_OWNER), + _ => Err(io::ErrorKind::InvalidInput.into()), + } +} + +pub(super) fn revoke_owned( + file: &File, + sid: &Sid, + deny_only: bool, + inherit: bool, +) -> io::Result<()> { + use windows_sys::Win32::Security::Authorization::{SE_FILE_OBJECT, SetSecurityInfo}; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, DeleteAce, EqualSid, GetAce, INHERITED_ACE, + }; + use windows_sys::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, + }; + let mut descriptor = read_dacl_descriptor(file)?; + let mut dacl = ptr::null_mut(); + let mut present = 0; + let mut defaulted = 0; + // SAFETY: The descriptor owns the ACL throughout inspection and mutation. + boolean(unsafe { + GetSecurityDescriptorDacl( + descriptor.as_mut_ptr().cast(), + &mut present, + &mut dacl, + &mut defaulted, + ) + })?; + if present == 0 || dacl.is_null() { + return Err(io::ErrorKind::PermissionDenied.into()); + } + // SAFETY: The live descriptor contains this complete ACL. Reverse traversal preserves remaining indexes. + for index in (0..unsafe { (*dacl).AceCount }).rev() { + let mut entry = ptr::null_mut(); + // SAFETY: The index remains within the current ACL after deleting only higher indexes. + boolean(unsafe { GetAce(dacl, u32::from(index), &mut entry) })?; + // SAFETY: Every ACE has this header; only simple allow/deny entries are cast below. + let header = unsafe { &*entry.cast::() }; + if u32::from(header.AceFlags) & INHERITED_ACE != 0 + || (header.AceType != ACCESS_DENIED_ACE_TYPE as u8 + && (deny_only || header.AceType != ACCESS_ALLOWED_ACE_TYPE as u8)) + { + continue; + } + // SAFETY: Both simple ACE types contain a valid variable-sized SID at SidStart. + let ace = unsafe { &*entry.cast::() }; + // SAFETY: Both SIDs have live backing storage. + if unsafe { EqualSid(ptr::from_ref(&ace.SidStart).cast_mut().cast(), sid.as_ptr()) } != 0 { + // SAFETY: This removes only the matched explicit entry, preserving every other principal and inherited ACE. + boolean(unsafe { DeleteAce(dacl, u32::from(index)) })?; + } + } + if inherit { + // SAFETY: Reapplying the current DACL also completes an interrupted inheritance removal without restoring an old snapshot. + check(unsafe { + SetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + dacl, + ptr::null_mut(), + ) + }) + } else { + // SAFETY: This descriptor preserves the original controls and updates only the root object. + boolean(unsafe { + SetKernelObjectSecurity( + file.as_raw_handle(), + DACL_SECURITY_INFORMATION, + descriptor.as_mut_ptr().cast(), + ) + }) + } } -impl<'a> Grants<'a> { +impl Grants { pub(super) fn apply( - profile: &'a Profile, + user: &Sid, + group: &Sid, grants: &[Grant], cancellation: &Cancellation, + denials: Option>, ) -> io::Result { + use windows_sys::Win32::Security::Authorization::DENY_ACCESS; cancellation.check()?; - if grants.is_empty() || grants.len() > 1024 { + if grants.is_empty() + || grants.len() > 1024 + || (denials.is_none() + && grants + .iter() + .any(|grant| matches!(grant.access, Access::DenyRead))) + { return Err(io::ErrorKind::InvalidInput.into()); } let directory = journal_directory()?; - let _lock = AclLock::acquire()?; - recover(&directory)?; - let mut result = Self { - sid: &profile.sid, - handles: Vec::new(), - validation_handles: Vec::new(), - journal: None, - roots: grants - .iter() - .filter(|grant| matches!(grant.access, Access::Read | Access::Write)) - .cloned() - .collect(), - }; + { + let _lock = AclLock::acquire()?; + recover(&directory)?; + } + let owner = sid_text(&process_user_sid(current_process())?)?; + let mut handles = Vec::new(); for grant in grants { cancellation.check()?; if super::policy::contains(&directory.to_string_lossy(), &grant.path) { return Err(io::ErrorKind::PermissionDenied.into()); } - result.validation_handles.push(open_grant(grant)?); + handles.push(open_grant_handle(grant)?); } - let grants = super::policy::compile(grants, cancellation)?; - let pinned = grants + let mut caps = Vec::new(); + for (grant, handle) in grants .iter() - .map(|grant| { - cancellation.check()?; - open_grant(grant) - }) - .collect::>>()?; - result.journal = Some(Journal::create(directory, profile, &grants, &result.roots)?); - profile.register()?; - for (grant, file) in grants.iter().zip(pinned) { + .zip(&handles) + .filter(|(grant, _)| matches!(grant.access, Access::Write)) + { + let name = capability(&owner, grant); + let cap = super::account::parse_sid(&name)?; + let mask = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; + ensure_acl(grant, &handle.file, group, mask, GRANT_ACCESS, cancellation)?; + ensure_acl(grant, &handle.file, &cap, mask, GRANT_ACCESS, cancellation)?; + caps.push((grant, name, cap)); + } + if let Some(update) = denials { + let denials: Vec<_> = grants + .iter() + .filter(|grant| matches!(grant.access, Access::DenyRead)) + .cloned() + .collect(); + super::acl_state::update_denials(update, user, &denials, cancellation)?; + } + for (grant, handle) in grants.iter().zip(&handles) { cancellation.check()?; - let mask = match grant.access { - Access::Read => FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, - Access::Write => { - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE + let file = &handle.file; + match grant.access { + Access::Read => { + if !handle.can_write_dacl { + continue; + } + if let Err(error) = ensure_acl( + grant, + file, + user, + FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + GRANT_ACCESS, + cancellation, + ) && error.kind() != io::ErrorKind::PermissionDenied + { + return Err(error); + } } - _ => return Err(io::ErrorKind::InvalidData.into()), - }; - result.handles.push(file); - let file = result.handles.last().ok_or(io::ErrorKind::InvalidData)?; - change_acl(file, result.sid, mask, GRANT_ACCESS, grant.inherit)?; + Access::DenyWrite => { + for (root, _, cap) in &caps { + if super::policy::contains(&root.path, &grant.path) { + ensure_acl( + grant, + file, + cap, + denial_mask(grant.access)?, + DENY_ACCESS, + cancellation, + )?; + } + } + } + Access::Write | Access::DenyRead => {} + } } - Ok(result) + Ok(Self { + _handles: handles, + capabilities: caps.into_iter().map(|(_, name, _)| name).collect(), + }) } +} - pub(super) fn finish(&mut self) -> io::Result<()> { - if self.journal.is_none() { - return Ok(()); - } - let _lock = AclLock::acquire()?; - let mut failure = None; - for root in &self.roots { - if let Err(error) = revoke_tree(root, self.sid) { - failure.get_or_insert(error); - } - } - for file in &self.handles { - if let Err(error) = change_acl(file, self.sid, 0, REVOKE_ACCESS, false) { - failure.get_or_insert(error); - } - } - if let Some(error) = failure { - return Err(error); +pub(super) fn ensure_acl( + grant: &Grant, + file: &File, + sid: &Sid, + mask: u32, + mode: i32, + cancellation: &Cancellation, +) -> io::Result<()> { + ensure_acl_inner(grant, file, sid, mask, mode, cancellation, false) +} + +fn ensure_acl_inner( + grant: &Grant, + file: &File, + sid: &Sid, + mask: u32, + mode: i32, + cancellation: &Cancellation, + locked: bool, +) -> io::Result<()> { + use sha2::{Digest, Sha256}; + use windows_sys::Win32::Security::Authorization::{ + DENY_ACCESS, SE_FILE_OBJECT, SetSecurityInfo, + }; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, EqualSid, GetAce, INHERITED_ACE, + }; + use windows_sys::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, + }; + let key = Sha256::digest(format!( + "{}:{}:{}:{}:{mask}:{mode}:{}", + grant.path, + grant.device, + grant.inode, + sid_text(sid)?, + grant.inherit + )); + let pending = journal_directory()?.join(format!("acl-{key:x}.pending")); + let interrupted = pending.exists(); + let inheritance = if grant.inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + let mut descriptor = read_dacl_descriptor(file)?; + let mut dacl = ptr::null_mut(); + let mut present = 0; + let mut defaulted = 0; + // SAFETY: The aligned buffer owns the complete self-relative descriptor. + boolean(unsafe { + GetSecurityDescriptorDacl( + descriptor.as_mut_ptr().cast(), + &mut present, + &mut dacl, + &mut defaulted, + ) + })?; + if present == 0 || dacl.is_null() { + return Err(io::ErrorKind::PermissionDenied.into()); + } + // SAFETY: GetSecurityDescriptorDacl returned a valid ACL in the live descriptor. + for index in 0..unsafe { (*dacl).AceCount } { + let mut entry = ptr::null_mut(); + // SAFETY: The index is bounded by AceCount; output receives a pointer into the live ACL. + boolean(unsafe { GetAce(dacl, u32::from(index), &mut entry) })?; + // SAFETY: Every ACL entry starts with an ACE header; these two simple ACE types share the same layout. + let header = unsafe { &*entry.cast::() }; + let kind = if mode == DENY_ACCESS { + ACCESS_DENIED_ACE_TYPE + } else { + ACCESS_ALLOWED_ACE_TYPE + }; + if header.AceType != kind as u8 { + continue; } - if let Some(journal) = self.journal.take() { - journal.remove()?; + // SAFETY: This is a simple allow/deny ACE, whose mask and SID immediately follow its header. + let ace = unsafe { &*entry.cast::() }; + if !interrupted + && u32::from(ace.Header.AceFlags) & INHERITED_ACE == 0 + && (!grant.inherit || u32::from(ace.Header.AceFlags) & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) == inheritance) + && ace.Mask & mask == mask + // SAFETY: Only simple allow/deny ACEs reach this comparison; their SidStart is a complete variable-sized SID. + && unsafe { EqualSid(ptr::from_ref(&ace.SidStart).cast_mut().cast(), sid.as_ptr()) } != 0 + { + return Ok(()); } - Ok(()) } -} - -impl Drop for Grants<'_> { - fn drop(&mut self) { - if self.finish().is_err() { - eprintln!( - "{}", - serde_json::json!({"type":"cleanupError","code":"SANDBOX_ACL_CLEANUP_FAILED"}) - ); + if !locked { + let _lock = AclLock::for_root(grant, cancellation)?; + return ensure_acl_inner(grant, file, sid, mask, mode, cancellation, true); + } + if !grant.inherit { + let result = change_acl(file, sid, mask, mode, false); + if interrupted && result.is_ok() { + fs::remove_file(pending)?; } + return result; + } + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: mode, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid.as_ptr().cast(), + ..Default::default() + }, + }; + let mut updated = ptr::null_mut(); + // SAFETY: SID and ACL remain live; this adds only the dedicated identity's ACE. + check(unsafe { SetEntriesInAclW(1, &entry, dacl, &mut updated) })?; + let _updated = LocalMemory(updated.cast()); + if !interrupted { + File::create_new(&pending)?.sync_all()?; + } + // SAFETY: The pinned root is unchanged; Windows propagates inheritance once without retaining a handle per descendant. + let result = check(unsafe { + SetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + updated, + ptr::null_mut(), + ) + }); + if result.is_ok() + || (matches!(grant.access, Access::Read) + && result + .as_ref() + .is_err_and(|error| error.kind() == io::ErrorKind::PermissionDenied)) + { + fs::remove_file(pending)?; } + result } pub(super) struct AclLock(OwnedHandle); @@ -183,21 +440,52 @@ impl AclLock { { return Err(io::ErrorKind::InvalidInput.into()); } + Self::named(user, &format!("Global\\LexoraBuddySandboxAcl-{user}"), None) + } + + pub(super) fn for_root(grant: &Grant, cancellation: &Cancellation) -> io::Result { + use sha2::{Digest, Sha256}; + let user = sid_text(&process_user_sid(current_process())?)?; + let key = Sha256::digest(format!( + "{}:{}:{}", + grant.path.to_uppercase(), + grant.device, + grant.inode + )); + Self::named( + &user, + &format!("Global\\LexoraBuddySandboxRoot-{user}-{key:x}"), + Some(cancellation), + ) + } + + pub(super) fn for_denials(cancellation: &Cancellation) -> io::Result { + let user = sid_text(&process_user_sid(current_process())?)?; + Self::named( + &user, + &format!("Global\\LexoraBuddySandboxDenials-{user}"), + Some(cancellation), + ) + } + + fn named(user: &str, name: &str, cancellation: Option<&Cancellation>) -> io::Result { let descriptor = SecurityDescriptor::new(&format!("D:P(A;;GA;;;{user})(A;;GA;;;SY)(A;;GA;;;BA)"))?; let attributes = descriptor.attributes(false); // SAFETY: The mutex is scoped to the host user, with no AppContainer access or inherited handle. - let handle = owned(unsafe { - CreateMutexW( - &attributes, - 0, - wide(&format!("Global\\LexoraBuddySandboxAcl-{user}")).as_ptr(), - ) - })?; - // SAFETY: The owned mutex remains live through the bounded wait and matching ReleaseMutex. - match unsafe { WaitForSingleObject(handle.as_raw_handle(), 10000) } { - WAIT_OBJECT_0 | WAIT_ABANDONED => Ok(Self(handle)), - _ => Err(io::ErrorKind::TimedOut.into()), + let handle = owned(unsafe { CreateMutexW(&attributes, 0, wide(name).as_ptr()) })?; + loop { + if let Some(cancellation) = cancellation { + cancellation.check()?; + } + let interval = if cancellation.is_some() { 100 } else { 10_000 }; + // SAFETY: The owned mutex remains live through each bounded wait and matching ReleaseMutex. + match unsafe { WaitForSingleObject(handle.as_raw_handle(), interval) } { + WAIT_OBJECT_0 | WAIT_ABANDONED => return Ok(Self(handle)), + WAIT_TIMEOUT if cancellation.is_some() => continue, + WAIT_TIMEOUT => return Err(io::ErrorKind::TimedOut.into()), + _ => return Err(io::Error::last_os_error()), + } } } } @@ -215,12 +503,12 @@ pub(super) fn inspect(path: &str, access: Access) -> io::Result { }) } -fn open_path(path: &str) -> io::Result { +fn open_path_with_access(path: &str, access: u32) -> io::Result { if !crate::windows_path::valid(path) || path.starts_with(r"\\") { return Err(io::ErrorKind::InvalidInput.into()); } OpenOptions::new() - .access_mode(READ_CONTROL | WRITE_DAC | FILE_READ_ATTRIBUTES) + .access_mode(access | FILE_READ_ATTRIBUTES) .share_mode( FILE_SHARE_READ | FILE_SHARE_WRITE @@ -265,13 +553,32 @@ pub(super) fn identity(file: &File, path: &str) -> io::Result<(String, String)> )) } -fn open_grant(grant: &Grant) -> io::Result { - let file = open_path(&grant.path)?; +fn open_path(path: &str) -> io::Result { + open_path_with_access(path, READ_CONTROL | WRITE_DAC) +} + +pub(super) fn open_grant(grant: &Grant) -> io::Result { + Ok(open_grant_handle(grant)?.file) +} + +fn open_grant_handle(grant: &Grant) -> io::Result { + let (file, can_write_dacl) = match open_path(&grant.path) { + Err(error) + if matches!(grant.access, Access::Read) + && error.kind() == io::ErrorKind::PermissionDenied => + { + (open_path_with_access(&grant.path, READ_CONTROL)?, false) + } + result => (result?, true), + }; let (device, inode) = identity(&file, &grant.path)?; if device != grant.device || inode != grant.inode { return Err(io::ErrorKind::InvalidInput.into()); } - Ok(file) + Ok(OpenedGrant { + file, + can_write_dacl, + }) } #[derive(Deserialize, Serialize)] @@ -282,60 +589,7 @@ struct Record { #[serde(default)] roots: Vec, } -struct Journal { - path: PathBuf, - file: File, -} -impl Journal { - fn create( - directory: PathBuf, - profile: &Profile, - grants: &[Grant], - roots: &[Grant], - ) -> io::Result { - let pending_path = directory.join(format!("{}.pending", profile.name)); - let pending = PendingJournal(pending_path); - let path = directory.join(format!("{}.json", profile.name)); - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .share_mode(FILE_SHARE_READ) - .open(&pending.0)?; - serde_json::to_writer( - &mut file, - &Record { - profile: profile.name.clone(), - grants: grants.to_vec(), - roots: roots.to_vec(), - }, - )?; - file.flush()?; - if file.metadata()?.len() > 64 * 1024 * 1024 { - return Err(io::ErrorKind::InvalidData.into()); - } - file.sync_all()?; - drop(file); - fs::rename(&pending.0, &path)?; - let file = OpenOptions::new() - .write(true) - .share_mode(FILE_SHARE_READ) - .open(&path)?; - Ok(Self { path, file }) - } - fn remove(self) -> io::Result<()> { - drop(self.file); - fs::remove_file(self.path) - } -} - -struct PendingJournal(PathBuf); -impl Drop for PendingJournal { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } -} - -fn journal_directory() -> io::Result { +pub(super) fn journal_directory() -> io::Result { let directory = super::setup::known_folder(&windows_sys::Win32::UI::Shell::FOLDERID_LocalAppData)? .join("Lexora Buddy Sandbox") diff --git a/apps/buddy/native/host/src/shell_sandbox/metadata.rs b/apps/buddy/native/host/src/shell_sandbox/metadata.rs index 576ebfa53..7cb9ab1ec 100644 --- a/apps/buddy/native/host/src/shell_sandbox/metadata.rs +++ b/apps/buddy/native/host/src/shell_sandbox/metadata.rs @@ -1,39 +1,31 @@ use std::{ - collections::BTreeMap, fs::{self, File, OpenOptions}, - io::{self, Read, Write}, - os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + io::{self, Read}, + os::windows::fs::OpenOptionsExt, path::{Path, PathBuf}, ptr, }; use serde::{Deserialize, Serialize}; use windows_sys::Win32::{ - Foundation::HANDLE, Security::{ - ACCESS_ALLOWED_ACE, ACE_HEADER, - Authorization::{GRANT_ACCESS, REVOKE_ACCESS}, - GetAce, GetSecurityDescriptorDacl, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE, - TOKEN_QUERY, + ACCESS_ALLOWED_ACE, ACE_HEADER, Authorization::REVOKE_ACCESS, GetAce, + GetSecurityDescriptorDacl, }, Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, - FILE_GENERIC_WRITE, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, READ_CONTROL, - WRITE_DAC, - }, - System::{ - SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}, - Threading::OpenProcessToken, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, FILE_SHARE_WRITE, READ_CONTROL, WRITE_DAC, }, + System::SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}, }; use super::{ - filesystem::{self, Access, AclLock, Grant}, + filesystem::{self, AclLock}, network::valid_profile, - security::{boolean, derive_sid, owned, sid_text}, + security::{boolean, derive_sid}, setup, }; -use crate::windows_security::{Sid, process_user_sid}; +use crate::windows_security::Sid; const RECORD_LIMIT: usize = 2 * 1024 * 1024; @@ -45,18 +37,6 @@ pub(super) struct Directory { inode: String, } -pub(super) fn roots(grants: &[Grant]) -> Vec { - grants - .iter() - .filter(|grant| matches!(grant.access, Access::Read | Access::Write)) - .map(|grant| Directory { - path: grant.path.clone(), - device: grant.device.clone(), - inode: grant.inode.clone(), - }) - .collect() -} - pub(super) fn capability(profile: &str) -> io::Result { if !valid_profile(profile) { return Err(io::ErrorKind::InvalidInput.into()); @@ -74,151 +54,6 @@ struct Record { null_device: bool, } -pub(super) struct Lease { - sid: Sid, - user: String, - handles: Vec, - null_device: File, - profile_sid: Sid, - _roots: Vec, - journal: Option, -} - -impl Lease { - pub(super) fn apply(profile: &str, roots: &[Directory], owner: HANDLE) -> io::Result { - if roots.is_empty() || roots.len() > 1024 { - return Err(io::ErrorKind::InvalidInput.into()); - } - let sid = capability(profile)?; - let profile_sid = derive_sid(profile)?; - let null_device = open_null()?; - let user = sid_text(&process_user_sid(owner)?)?; - let _lock = AclLock::for_user(&user)?; - let pinned_roots = { - let mut token = ptr::null_mut(); - // SAFETY: The broker pins the authenticated host process; root validation uses only its original user rights. - boolean(unsafe { OpenProcessToken(owner, TOKEN_QUERY | TOKEN_DUPLICATE, &mut token) })?; - let token = owned(token)?; - // SAFETY: No ACL is changed while impersonating, and the guard always reverts on this service thread. - boolean(unsafe { ImpersonateLoggedOnUser(token.as_raw_handle()) })?; - let _impersonation = Impersonation; - roots - .iter() - .map(Directory::open) - .collect::>>()? - }; - let mut directories = BTreeMap::new(); - for root in roots { - for path in Path::new(&root.path).ancestors().skip(1) { - let path = path.to_str().ok_or(io::ErrorKind::InvalidInput)?; - let key = path.to_uppercase(); - if directories.contains_key(&key) { - continue; - } - if directories.len() >= 4096 { - return Err(io::ErrorKind::InvalidInput.into()); - } - let file = open(path)?; - let (device, inode) = filesystem::identity(&file, path)?; - if !file.metadata()?.is_dir() { - return Err(io::ErrorKind::InvalidInput.into()); - } - if has_capability(&file, &sid)? { - return Err(io::ErrorKind::AlreadyExists.into()); - } - directories.insert( - key, - ( - Directory { - path: path.to_owned(), - device, - inode, - }, - file, - ), - ); - } - } - let (directories, handles) = directories.into_values().unzip(); - let journal = publish( - &journal_directory()?, - &Record { - profile: profile.to_owned(), - user: user.clone(), - directories, - null_device: true, - }, - )?; - let mut lease = Self { - sid, - user, - handles, - null_device, - profile_sid, - _roots: pinned_roots, - journal: Some(journal), - }; - let result = lease.handles.iter().try_for_each(|file| { - filesystem::change_acl(file, &lease.sid, FILE_READ_ATTRIBUTES, GRANT_ACCESS, false) - }); - let result = result.and_then(|()| { - filesystem::change_acl( - &lease.null_device, - &lease.profile_sid, - FILE_GENERIC_READ | FILE_GENERIC_WRITE, - GRANT_ACCESS, - false, - ) - }); - if let Err(error) = result { - lease.finish()?; - return Err(error); - } - Ok(lease) - } - - pub(super) fn finish(&mut self) -> io::Result<()> { - if self.journal.is_none() { - return Ok(()); - } - let _lock = AclLock::for_user(&self.user)?; - let mut failure = None; - for file in &self.handles { - if let Err(error) = revoke(file, &self.sid) { - failure.get_or_insert(error); - } - } - if let Err(error) = filesystem::change_acl( - &self.null_device, - &self.profile_sid, - 0, - REVOKE_ACCESS, - false, - ) { - failure.get_or_insert(error); - } - if let Some(error) = failure { - return Err(error); - } - if let Some(path) = &self.journal { - fs::remove_file(path)?; - } - self.journal = None; - Ok(()) - } -} - -impl Drop for Lease { - fn drop(&mut self) { - if self.finish().is_err() { - eprintln!( - "{}", - serde_json::json!({"type":"cleanupError","code":"SANDBOX_METADATA_CLEANUP_FAILED"}) - ); - } - } -} - impl Directory { fn open(&self) -> io::Result { let file = open(&self.path)?; @@ -320,7 +155,9 @@ fn journal_directory() -> io::Result { Ok(directory) } +#[cfg(test)] fn publish(directory: &Path, record: &Record) -> io::Result { + use std::io::Write; let bytes = serde_json::to_vec(record)?; if bytes.len() > RECORD_LIMIT { return Err(io::ErrorKind::InvalidInput.into()); @@ -407,13 +244,3 @@ fn recover_record(path: &Path, profile: &str) -> io::Result<()> { #[cfg(test)] #[path = "../../__tests__/shell_sandbox_metadata.rs"] mod tests; - -struct Impersonation; -impl Drop for Impersonation { - fn drop(&mut self) { - // SAFETY: Reverts this thread's authenticated-client impersonation; failure must never continue as another user. - if unsafe { RevertToSelf() } == 0 { - std::process::abort(); - } - } -} diff --git a/apps/buddy/native/host/src/shell_sandbox/network.rs b/apps/buddy/native/host/src/shell_sandbox/network.rs index b955f1375..da081bb61 100644 --- a/apps/buddy/native/host/src/shell_sandbox/network.rs +++ b/apps/buddy/native/host/src/shell_sandbox/network.rs @@ -15,7 +15,7 @@ use windows_sys::{ core::GUID, }; -use super::security::{check, derive_sid, wide}; +use super::security::{SecurityDescriptor, check, derive_sid, wide}; use crate::windows_security::Sid; const PROVIDER: GUID = GUID::from_u128(0x56b83290_4c62_4a13_9920_248a5301ec0d); @@ -93,7 +93,11 @@ impl Network { } pub(super) fn grant(&self, profile: &str) -> io::Result> { - let sid = derive_sid(profile)?; + let sid = if valid_profile(profile) { + derive_sid(profile)? + } else { + super::account::parse_sid(profile)? + }; // SAFETY: The engine is live; the transaction is closed on every path below. check(unsafe { FwpmTransactionBegin0(self.0, 0) })?; let result: io::Result> = (|| { @@ -111,7 +115,9 @@ impl Network { unsafe { FwpmTransactionAbort0(self.0) }; } let filters = result?; - if let Err(error) = loopback(&sid, true) { + if valid_profile(profile) + && let Err(error) = loopback(&sid, true) + { self.revoke(profile, &filters)?; return Err(error); } @@ -122,16 +128,22 @@ impl Network { &self, index: usize, profile: &str, - sid: &Sid, + _sid: &Sid, allow_loopback: bool, ) -> io::Result { let layer = LAYERS[index]; - let mut conditions = vec![condition( - FWPM_CONDITION_ALE_PACKAGE_ID, - FWP_SID, - FWP_CONDITION_VALUE0_0 { - sid: sid.as_ptr().cast(), + let descriptor = SecurityDescriptor::new(&format!("D:(A;;CC;;;{profile})"))?; + let mut blob = FWP_BYTE_BLOB { + // SAFETY: SecurityDescriptor owns a valid self-relative security descriptor. + size: unsafe { + windows_sys::Win32::Security::GetSecurityDescriptorLength(descriptor.as_ptr()) }, + data: descriptor.as_ptr().cast(), + }; + let mut conditions = vec![condition( + FWPM_CONDITION_ALE_USER_ID, + FWP_SECURITY_DESCRIPTOR_TYPE, + FWP_CONDITION_VALUE0_0 { sd: &mut blob }, )]; let mut v4 = FWP_V4_ADDR_AND_MASK { addr: 0x7f000000, @@ -215,7 +227,9 @@ impl Network { } pub(super) fn revoke(&self, profile: &str, filters: &[u64]) -> io::Result<()> { - loopback(&derive_sid(profile)?, false)?; + if valid_profile(profile) { + loopback(&derive_sid(profile)?, false)?; + } for id in filters { // SAFETY: These IDs were returned for this product's own per-command filters. check(unsafe { FwpmFilterDeleteById0(self.0, *id) })?; @@ -223,7 +237,7 @@ impl Network { Ok(()) } - pub(super) fn recover(&self) -> io::Result<()> { + fn filters(&self) -> io::Result>> { let mut profiles: BTreeMap> = BTreeMap::new(); for layer in LAYERS { let mut provider = PROVIDER; @@ -268,7 +282,7 @@ impl Network { }; let profile = std::str::from_utf8(data).map_err(|_| io::ErrorKind::InvalidData)?; - if !valid_profile(profile) { + if !valid_profile(profile) && super::account::parse_sid(profile).is_err() { return Err(io::ErrorKind::InvalidData.into()); } profiles @@ -284,7 +298,29 @@ impl Network { unsafe { FwpmFilterDestroyEnumHandle0(self.0, enumeration) }; result?; } - for (profile, filters) in profiles { + Ok(profiles) + } + + pub(super) fn ensure_account(&self, sid: &str) -> io::Result<()> { + if !self.filters()?.contains_key(sid) { + self.grant(sid)?; + } + self.verify_account(sid) + } + + pub(super) fn verify_account(&self, sid: &str) -> io::Result<()> { + let filters = self.filters()?; + if filters + .get(sid) + .is_none_or(|ids| ids.len() != LAYERS.len() * 2) + { + return Err(io::ErrorKind::PermissionDenied.into()); + } + Ok(()) + } + + pub(super) fn recover(&self) -> io::Result<()> { + for (profile, filters) in self.filters()? { self.revoke(&profile, &filters)?; } Ok(()) diff --git a/apps/buddy/native/host/src/shell_sandbox/policy.rs b/apps/buddy/native/host/src/shell_sandbox/policy.rs index de06b9257..7191d2655 100644 --- a/apps/buddy/native/host/src/shell_sandbox/policy.rs +++ b/apps/buddy/native/host/src/shell_sandbox/policy.rs @@ -1,37 +1,4 @@ -use std::{ - collections::{BTreeMap, HashSet}, - fs::{self, OpenOptions}, - io, - os::windows::{ - fs::{MetadataExt, OpenOptionsExt}, - io::AsRawHandle, - }, - ptr, -}; - -use windows_sys::{ - Wdk::Foundation::{NtQueryObject, ObjectBasicInformation}, - Win32::{ - Foundation::{ERROR_ACCESS_DENIED, HANDLE}, - Globalization::{CSTR_EQUAL, CompareStringOrdinal}, - Security::{ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE, TOKEN_QUERY}, - Storage::FileSystem::{ - DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_DATA, - FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, WRITE_DAC, WRITE_OWNER, - }, - System::{ - SystemServices::MAXIMUM_ALLOWED, Threading::OpenProcessToken, - WindowsProgramming::PUBLIC_OBJECT_BASIC_INFORMATION, - }, - }, -}; - -use super::{ - cancellation::Cancellation, - filesystem::{Access, Grant, inspect}, - security::{boolean, owned}, -}; +use windows_sys::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; pub(super) fn contains(root: &str, path: &str) -> bool { let root: Vec = root.trim_end_matches('\\').encode_utf16().collect(); @@ -50,210 +17,3 @@ pub(super) fn contains(root: &str, path: &str) -> bool { ) == CSTR_EQUAL } } - -pub(super) fn compile(grants: &[Grant], cancellation: &Cancellation) -> io::Result> { - let mut output = Vec::new(); - let mut visited = HashSet::new(); - for grant in grants - .iter() - .filter(|grant| matches!(grant.access, Access::Read | Access::Write)) - { - expand( - grant.clone(), - grants, - &mut output, - &mut visited, - cancellation, - 0, - )?; - } - Ok(output) -} - -fn expand( - mut grant: Grant, - policy: &[Grant], - output: &mut Vec, - visited: &mut HashSet, - cancellation: &Cancellation, - depth: usize, -) -> io::Result<()> { - cancellation.check()?; - if visited.contains(&grant.path.to_uppercase()) { - return Ok(()); - } - if output.len() >= 100_000 || depth > 128 { - return Err(io::ErrorKind::InvalidInput.into()); - } - grant.access = if policy.iter().any(|boundary| { - matches!(boundary.access, Access::Write) && contains(&boundary.path, &grant.path) - }) { - Access::Write - } else { - Access::Read - }; - for boundary in policy - .iter() - .filter(|boundary| contains(&boundary.path, &grant.path)) - { - match boundary.access { - Access::DenyRead - if !boundary - .exceptions - .iter() - .any(|path| contains(path, &grant.path)) => - { - return Ok(()); - } - Access::DenyWrite => grant.access = Access::Read, - _ => {} - } - } - grant.inherit = true; - visited.insert(grant.path.to_uppercase()); - let access = grant.access; - let path = grant.path.clone(); - output.push(grant); - if fs::symlink_metadata(&path)?.is_dir() { - for entry in fs::read_dir(path)? { - let entry = entry?; - if fs::symlink_metadata(entry.path())?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT - != 0 - { - continue; - } - expand( - inspect(&entry.path().to_string_lossy(), access)?, - policy, - output, - visited, - cancellation, - depth + 1, - )?; - } - } - Ok(()) -} - -pub(super) fn verify_denials( - process: HANDLE, - grants: &[Grant], - cancellation: &Cancellation, -) -> io::Result<()> { - let mut probes = BTreeMap::new(); - for grant in grants { - let mask = match grant.access { - Access::DenyRead => { - FILE_READ_DATA - | FILE_WRITE_DATA - | FILE_APPEND_DATA - | DELETE - | WRITE_DAC - | WRITE_OWNER - } - Access::DenyWrite | Access::Read => { - FILE_WRITE_DATA - | FILE_APPEND_DATA - | FILE_WRITE_EA - | FILE_WRITE_ATTRIBUTES - | DELETE - | WRITE_DAC - | WRITE_OWNER - } - Access::Write => continue, - }; - let mut exceptions = grant.exceptions.clone(); - if matches!(grant.access, Access::Read) { - exceptions.extend( - grants - .iter() - .filter(|boundary| matches!(boundary.access, Access::Write)) - .map(|boundary| boundary.path.clone()), - ); - } - collect_probes(&grant.path, mask, &exceptions, &mut probes, cancellation, 0)?; - } - let mut token = ptr::null_mut(); - // SAFETY: The exact child is still suspended; only query/duplication rights are requested for same-user impersonation. - boolean(unsafe { OpenProcessToken(process, TOKEN_QUERY | TOKEN_DUPLICATE, &mut token) })?; - let token = owned(token)?; - // SAFETY: No user command runs on this thread; impersonation is checked before performing access-only probes. - boolean(unsafe { ImpersonateLoggedOnUser(token.as_raw_handle()) })?; - let _impersonation = Impersonation; - for (path, forbidden) in probes { - cancellation.check()?; - let file = match OpenOptions::new() - .access_mode(MAXIMUM_ALLOWED) - .share_mode(7) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - { - Ok(file) => file, - Err(error) if error.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) => continue, - Err(error) => return Err(error), - }; - let mut information = PUBLIC_OBJECT_BASIC_INFORMATION::default(); - // SAFETY: This queries access already granted to the exact probe handle, without modifying or reading file contents. - let status = unsafe { - NtQueryObject( - file.as_raw_handle(), - ObjectBasicInformation, - ptr::from_mut(&mut information).cast(), - size_of_val(&information) as u32, - ptr::null_mut(), - ) - }; - if status < 0 || information.GrantedAccess & forbidden != 0 { - return Err(io::ErrorKind::PermissionDenied.into()); - } - } - Ok(()) -} - -fn collect_probes( - path: &str, - mask: u32, - exceptions: &[String], - probes: &mut BTreeMap, - cancellation: &Cancellation, - depth: usize, -) -> io::Result<()> { - cancellation.check()?; - if exceptions.iter().any(|exception| contains(exception, path)) { - return Ok(()); - } - if probes.len() >= 100_000 || depth > 128 { - return Err(io::ErrorKind::InvalidInput.into()); - } - let metadata = fs::symlink_metadata(path)?; - let mask = mask - | if metadata.is_dir() { - FILE_DELETE_CHILD - } else { - 0 - }; - *probes.entry(path.to_owned()).or_default() |= mask; - if metadata.is_dir() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 { - for entry in fs::read_dir(path)? { - collect_probes( - &entry?.path().to_string_lossy(), - mask & !FILE_DELETE_CHILD, - exceptions, - probes, - cancellation, - depth + 1, - )?; - } - } - Ok(()) -} - -struct Impersonation; -impl Drop for Impersonation { - fn drop(&mut self) { - // SAFETY: This guard exists only after successful impersonation on this same thread. - if unsafe { RevertToSelf() } == 0 { - std::process::abort(); - } - } -} diff --git a/apps/buddy/native/host/src/shell_sandbox/process.rs b/apps/buddy/native/host/src/shell_sandbox/process.rs index d1d1696e6..f2ce82b5b 100644 --- a/apps/buddy/native/host/src/shell_sandbox/process.rs +++ b/apps/buddy/native/host/src/shell_sandbox/process.rs @@ -8,27 +8,27 @@ use std::{ time::{Duration, Instant}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use windows_sys::Win32::{ Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_ALREADY_EXISTS, GetLastError, WAIT_OBJECT_0, + DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, + WAIT_OBJECT_0, }, - Security::{SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES}, + Security::SECURITY_ATTRIBUTES, Storage::FileSystem::FILE_GENERIC_READ, System::{ Console::{GetStdHandle, STD_OUTPUT_HANDLE}, JobObjects::*, - SystemServices::SE_GROUP_ENABLED, Threading::*, }, }; use super::{ + acl_state::DenialUpdate, broker::Lease, cancellation::Cancellation, filesystem::{Access, Grant, Grants, inspect}, - metadata, - security::{Profile, at, boolean, capability, current_process, owned, require_host, wide}, + security::{at, boolean, current_process, owned, require_host, wide}, setup, }; @@ -41,16 +41,13 @@ struct Request { private_root: String, environment: BTreeMap, grants: Vec, + #[serde(default)] + deny_read_scope: Option, proxy_port: u16, } pub(super) fn run() -> io::Result { require_host(current_process(), false)?; - let job = at("job", Job::new())?; - eprintln!( - "{}", - serde_json::json!({"type":"supervisor","protocol":1,"identity":crate::supervised_shell::windows::identity(&job)?}) - ); if fs::canonicalize(std::env::current_exe()?)? != fs::canonicalize(setup::installed_path()?)? { return Err(io::ErrorKind::PermissionDenied.into()); } @@ -63,19 +60,39 @@ pub(super) fn run() -> io::Result { return Err(io::ErrorKind::InvalidInput.into()); } let request: Request = serde_json::from_slice(&bytes)?; - let cancellation = Cancellation::listen(input); + let cancellation = Cancellation::listen(input, Duration::from_secs(60)); cancellation.check()?; validate(&request)?; - let profile = at("profile", Profile::create())?; + let (preparation, account, group, job) = at( + "job", + Lease::account( + request + .grants + .iter() + .filter(|grant| !super::policy::contains(&request.private_root, &grant.path)) + .cloned() + .collect(), + ), + )?; + eprintln!( + "{}", + serde_json::json!({"type":"supervisor","protocol":1,"identity":crate::supervised_shell::windows::identity(&job)?}) + ); let mut policy = request.grants; policy.push(inspect(&request.private_root, Access::Write)?); eprintln!( "{}", serde_json::json!({"type":"diagnostic","phase":"filesystem_preparing","nativeCode":null}) ); - let mut grants = at( + let grants = at( "filesystem", - Grants::apply(&profile, &policy, &cancellation), + Grants::apply( + &account, + &group, + &policy, + &cancellation, + request.deny_read_scope.as_deref().map(DenialUpdate::Merge), + ), )?; cancellation.check()?; let script = Path::new(&request.private_root).join("command.ps1"); @@ -92,32 +109,26 @@ pub(super) fn run() -> io::Result { )?; drop(script_file); cancellation.check()?; - let lease = at( - "boundary", - Lease::connect(&profile.name, request.proxy_port, metadata::roots(&policy)), - )?; - cancellation.check()?; - let child = at( + let (lease, child) = at( "launch", - launch( - &profile, - &job, - &request.shell, - &script, - &request.cwd, - &request.environment, - ), - )?; - at( - "verify_boundary", - super::policy::verify_denials(child.process.as_raw_handle(), &policy, &cancellation), + Lease::connect(LaunchRequest { + shell: request.shell, + script: script.to_string_lossy().into_owned(), + cwd: request.cwd, + environment: request.environment, + capabilities: grants.capabilities.clone(), + job: job.0.as_raw_handle() as usize, + // SAFETY: stdout belongs to the supervisor; the authenticated broker duplicates it without inheriting control input. + output: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } as usize, + }), )?; + preparation.finish()?; cancellation.check()?; eprintln!( "{}", serde_json::json!({"type":"ready","protocol":super::PROTOCOL_VERSION}) ); - // SAFETY: The process is still suspended, already confined to its AppContainer and kill-on-close job. + // SAFETY: The process is still suspended, already confined to its restricted token and kill-on-close job. if unsafe { ResumeThread(child.thread.as_raw_handle()) } == u32::MAX { return Err(io::Error::last_os_error()); } @@ -142,12 +153,80 @@ pub(super) fn run() -> io::Result { drop(child); drop(job); lease.finish()?; - grants.finish()?; drop(grants); - drop(profile); result } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Preparation { + grants: Vec, + #[serde(default, rename = "denyReadScope")] + deny_read_scope: Option, + #[serde(default, rename = "synchronizeDenials")] + synchronize_denials: bool, + #[serde(default)] + background: bool, +} + +pub(super) fn prepare() -> io::Result { + require_host(current_process(), false)?; + if fs::canonicalize(std::env::current_exe()?)? != fs::canonicalize(setup::installed_path()?)? { + return Err(io::ErrorKind::PermissionDenied.into()); + } + let mut input = BufReader::new(io::stdin()); + let mut bytes = Vec::new(); + (&mut input) + .take(2 * 1024 * 1024 + 1) + .read_until(b'\n', &mut bytes)?; + if bytes.len() > 2 * 1024 * 1024 || bytes.last() != Some(&b'\n') { + return Err(io::ErrorKind::InvalidInput.into()); + } + let request: Preparation = serde_json::from_slice(&bytes)?; + if request.background { + use windows_sys::Win32::System::Threading::{ + PROCESS_MODE_BACKGROUND_BEGIN, SetPriorityClass, + }; + // SAFETY: Only this preparation process enters background mode; no task command runs here. + boolean(unsafe { SetPriorityClass(current_process(), PROCESS_MODE_BACKGROUND_BEGIN) })?; + } + let (preparation, account, group, job) = at("job", Lease::account(request.grants.clone()))?; + eprintln!( + "{}", + serde_json::json!({"type":"supervisor","protocol":1,"identity":crate::supervised_shell::windows::identity(&job)?}) + ); + let cancellation = Cancellation::listen(input, Duration::from_secs(900)); + eprintln!( + "{}", + serde_json::json!({"type":"diagnostic","phase":"filesystem_preparing","nativeCode":null}) + ); + let _grants = at( + "filesystem", + Grants::apply( + &account, + &group, + &request.grants, + &cancellation, + request.deny_read_scope.as_deref().map(|scope| { + if request.synchronize_denials { + DenialUpdate::Replace(scope) + } else { + DenialUpdate::Merge(scope) + } + }), + ), + )?; + cancellation.check()?; + preparation.finish()?; + job.finish()?; + eprintln!( + "{}", + serde_json::json!({"type":"processesReleased","protocol":1}) + ); + eprintln!("{}", serde_json::json!({"type":"ready","protocol":1})); + Ok(0) +} + fn validate(request: &Request) -> io::Result<()> { for path in [&request.cwd, &request.shell, &request.private_root] { if !crate::windows_path::valid(path) || path.starts_with(r"\\") { @@ -182,12 +261,20 @@ fn validate(request: &Request) -> io::Result<()> { Ok(()) } -pub(crate) struct Job(pub(crate) OwnedHandle, String); +pub(crate) struct Job(pub(crate) OwnedHandle, String, bool); impl Job { - fn new() -> io::Result { + pub(super) fn new() -> io::Result { Self::create(true) } + pub(super) fn received(handle: OwnedHandle, name: String) -> Self { + Self(handle, name, true) + } + + pub(super) fn handoff(mut self) { + self.2 = false; + } + pub(crate) fn host() -> io::Result { Self::create(false) } @@ -210,7 +297,7 @@ impl Job { if existed { return Err(io::ErrorKind::AlreadyExists.into()); } - let job = Self(handle, name); + let job = Self(handle, name, true); let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; // SAFETY: The live job receives an initialized limits struct; breakaway is deliberately absent. @@ -276,7 +363,7 @@ impl Job { impl Drop for Job { fn drop(&mut self) { - if self.finish().is_err() { + if self.2 && self.finish().is_err() { // Preserve the recovery journal rather than remove grants while a descendant may still be exiting. std::process::exit(125); } @@ -294,28 +381,91 @@ impl Drop for Child { } } -fn launch( - profile: &Profile, - job: &Job, - shell: &str, - script: &Path, - cwd: &str, - environment: &BTreeMap, -) -> io::Result { - let mut inherited_output = ptr::null_mut(); - // SAFETY: Only a duplicate of stdout is made inheritable; the control pipe and job handle remain private. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct LaunchRequest { + pub(super) shell: String, + pub(super) script: String, + pub(super) cwd: String, + pub(super) environment: BTreeMap, + pub(super) capabilities: Vec, + pub(super) job: usize, + pub(super) output: usize, +} + +pub(super) fn duplicate( + source: HANDLE, + handle: HANDLE, + destination: HANDLE, + inherit: bool, +) -> io::Result { + let mut output = ptr::null_mut(); + // SAFETY: The source/destination are authenticated live process handles; only explicitly supplied handles are copied. boolean(unsafe { DuplicateHandle( - current_process(), - GetStdHandle(STD_OUTPUT_HANDLE), - current_process(), - &mut inherited_output, + source, + handle, + destination, + &mut output, 0, - 1, + i32::from(inherit), DUPLICATE_SAME_ACCESS, ) })?; - let output = owned(inherited_output)?; + Ok(output) +} + +#[derive(Debug)] +pub(super) struct LaunchError { + pub(super) phase: &'static str, + pub(super) source: io::Error, +} +impl std::fmt::Display for LaunchError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.phase) + } +} +impl std::error::Error for LaunchError {} +fn launch_phase(phase: &'static str, result: io::Result) -> io::Result { + result.map_err(|source| io::Error::new(source.kind(), LaunchError { phase, source })) +} + +pub(super) fn launch( + request: &LaunchRequest, + client: HANDLE, + account: &super::account::Account, +) -> io::Result<(Child, OwnedHandle)> { + if request.capabilities.is_empty() + || request.environment.len() > 128 + || request.script.contains(['\0', '"']) + || request.shell.contains(['\0', '"']) + || request.cwd.contains('\0') + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let base = launch_phase("account_logon", account.logon())?; + let owner = crate::windows_security::process_user_sid(client)?; + let token = launch_phase( + "restrict_token", + super::token::restrict( + base.as_raw_handle(), + &request.capabilities, + &owner, + &super::account::parse_sid(&account.sid)?, + ), + )?; + let job = owned(duplicate( + client, + request.job as HANDLE, + current_process(), + false, + )?)?; + let output = owned(duplicate( + client, + request.output as HANDLE, + current_process(), + true, + )?)?; let attributes = SECURITY_ATTRIBUTES { nLength: size_of::() as u32, lpSecurityDescriptor: ptr::null_mut(), @@ -334,38 +484,7 @@ fn launch( ) })?; let handles = [input.as_raw_handle(), output.as_raw_handle()]; - let registry = capability("registryRead")?; - let com = capability("lpacCom")?; - let instrumentation = capability("lpacInstrumentation")?; - let internet = capability("internetClientServer")?; - let volume_metadata = metadata::capability(&profile.name)?; - let mut entries = [ - ®istry, - &com, - &instrumentation, - &internet, - &volume_metadata, - ] - .map(|sid| SID_AND_ATTRIBUTES { - Sid: sid.as_ptr(), - Attributes: SE_GROUP_ENABLED as u32, - }); - let capabilities = SECURITY_CAPABILITIES { - AppContainerSid: profile.sid.as_ptr(), - Capabilities: entries.as_mut_ptr(), - CapabilityCount: entries.len() as u32, - Reserved: 0, - }; - let mut attribute_list = Attributes::new(3)?; - let application_packages_policy = 1u32; - attribute_list.set( - PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY as usize, - &application_packages_policy, - )?; - attribute_list.set( - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, - &capabilities, - )?; + let mut attribute_list = Attributes::new(1)?; attribute_list.set(PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, &handles)?; let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = size_of::() as u32; @@ -375,42 +494,50 @@ fn launch( startup.StartupInfo.hStdError = output.as_raw_handle(); startup.lpAttributeList = attribute_list.pointer(); let mut command = wide(&format!( - "\"{shell}\" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{}\"", - script.display() + "\"{}\" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{}\"", + request.shell, request.script )); - let mut environment: Vec = environment + let mut environment: Vec = request + .environment .iter() .flat_map(|(key, value)| wide(&format!("{key}={value}"))) .chain([0]) .collect(); let mut information = PROCESS_INFORMATION::default(); - // SAFETY: All startup/attribute/environment buffers stay live; the child starts suspended with an explicit two-handle inheritance list. - boolean(unsafe { - CreateProcessW( - wide(shell).as_ptr(), - command.as_mut_ptr(), - ptr::null(), - ptr::null(), - 1, - EXTENDED_STARTUPINFO_PRESENT - | CREATE_UNICODE_ENVIRONMENT - | CREATE_NO_WINDOW - | CREATE_SUSPENDED, - environment.as_mut_ptr().cast(), - wide(cwd).as_ptr(), - &startup.StartupInfo, - &mut information, - ) - })?; + launch_phase( + "create_process", + // SAFETY: All startup buffers stay live; the child starts suspended with an explicit handle inheritance list. + boolean(unsafe { + CreateProcessAsUserW( + token.as_raw_handle(), + wide(&request.shell).as_ptr(), + command.as_mut_ptr(), + ptr::null(), + ptr::null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_UNICODE_ENVIRONMENT + | CREATE_NO_WINDOW + | CREATE_SUSPENDED, + environment.as_mut_ptr().cast(), + wide(&request.cwd).as_ptr(), + &startup.StartupInfo, + &mut information, + ) + }), + )?; let child = Child { process: owned(information.hProcess)?, thread: owned(information.hThread)?, }; - // SAFETY: The child is suspended and cannot create descendants before this job assignment completes. - boolean(unsafe { - AssignProcessToJobObject(job.0.as_raw_handle(), child.process.as_raw_handle()) - })?; - Ok(child) + launch_phase( + "assign_job", + // SAFETY: The suspended child cannot create descendants before assignment to the job created in the same session. + boolean(unsafe { + AssignProcessToJobObject(job.as_raw_handle(), child.process.as_raw_handle()) + }), + )?; + Ok((child, job)) } pub(crate) struct Attributes { diff --git a/apps/buddy/native/host/src/shell_sandbox/security.rs b/apps/buddy/native/host/src/shell_sandbox/security.rs index 64f20bee2..b2b14b275 100644 --- a/apps/buddy/native/host/src/shell_sandbox/security.rs +++ b/apps/buddy/native/host/src/shell_sandbox/security.rs @@ -15,10 +15,7 @@ use windows_sys::{ SDDL_REVISION_1, }, DeriveCapabilitySidsFromName, FreeSid, GetTokenInformation, - Isolation::{ - CreateAppContainerProfile, DeleteAppContainerProfile, - DeriveAppContainerSidFromAppContainerName, - }, + Isolation::{DeleteAppContainerProfile, DeriveAppContainerSidFromAppContainerName}, PSID, SECURITY_ATTRIBUTES, TOKEN_INFORMATION_CLASS, TOKEN_QUERY, TokenElevation, TokenIsAppContainer, }, @@ -134,7 +131,9 @@ pub(super) fn token_flag(process: HANDLE, class: TOKEN_INFORMATION_CLASS) -> io: } pub(super) fn require_host(process: HANDLE, elevated: bool) -> io::Result<()> { - if token_flag(process, TokenIsAppContainer)? || token_flag(process, TokenElevation)? != elevated + if token_flag(process, windows_sys::Win32::Security::TokenIsRestricted)? + || token_flag(process, TokenIsAppContainer)? + || token_flag(process, TokenElevation)? != elevated { return Err(io::ErrorKind::PermissionDenied.into()); } @@ -248,6 +247,7 @@ pub(super) struct Profile { } impl Profile { + #[cfg(test)] pub(super) fn create() -> io::Result { let id = unique_id()?; let name = format!("{}{}", super::PROFILE_PREFIX, id); @@ -263,30 +263,6 @@ impl Profile { name, }) } - - pub(super) fn register(&self) -> io::Result<()> { - let text = wide(&self.name); - let mut sid = ptr::null_mut(); - // SAFETY: This is a new random profile with no network or broad resource capabilities. - check(unsafe { - CreateAppContainerProfile( - text.as_ptr(), - text.as_ptr(), - text.as_ptr(), - ptr::null(), - 0, - &mut sid, - ) - } as u32)?; - // SAFETY: CreateAppContainerProfile returned a valid allocated SID. - let copied = unsafe { Sid::copy(sid) }; - // SAFETY: The SID is copied before its matching release. - unsafe { FreeSid(sid) }; - if copied? != self.sid { - return Err(io::ErrorKind::InvalidData.into()); - } - Ok(()) - } } impl Drop for Profile { diff --git a/apps/buddy/native/host/src/shell_sandbox/setup.rs b/apps/buddy/native/host/src/shell_sandbox/setup.rs index 06de2778c..92f0ef26a 100644 --- a/apps/buddy/native/host/src/shell_sandbox/setup.rs +++ b/apps/buddy/native/host/src/shell_sandbox/setup.rs @@ -153,6 +153,8 @@ pub(super) fn install() -> io::Result { stop_idle(&service)?; } super::metadata::recover()?; + super::acl_state::discard_unpublished()?; + super::account::repair()?; let source = std::env::current_exe()?; if target.exists() { protected_object(&target)?; @@ -246,6 +248,7 @@ pub(super) fn install() -> io::Result { })?; let network = Network::open()?; network.install()?; + network.recover()?; super::security::at("network_configuration", network.verify())?; register_uninstaller(&target)?; Ok(0) @@ -313,6 +316,9 @@ pub(super) fn uninstall() -> io::Result { )?; stop_idle(&service)?; super::metadata::recover()?; + super::acl_state::discard_unpublished()?; + super::acl_state::cleanup(None)?; + super::account::uninstall()?; Network::open()?.uninstall()?; // SAFETY: Only the fixed product service is removed, after its active leases have ended. boolean(unsafe { DeleteService(service.0) })?; diff --git a/apps/buddy/native/host/src/shell_sandbox/token.rs b/apps/buddy/native/host/src/shell_sandbox/token.rs new file mode 100644 index 000000000..c3d064b73 --- /dev/null +++ b/apps/buddy/native/host/src/shell_sandbox/token.rs @@ -0,0 +1,117 @@ +use super::{ + account, + security::{SecurityDescriptor, boolean, owned, sid_text}, +}; +use crate::windows_security::Sid; +use std::{ + io, + os::windows::io::{AsRawHandle, OwnedHandle}, + ptr, +}; +use windows_sys::Win32::{ + Foundation::HANDLE, + Security::{ + CreateRestrictedToken, DISABLE_MAX_PRIVILEGE, GetSecurityDescriptorDacl, + GetTokenInformation, LUA_TOKEN, SID_AND_ATTRIBUTES, SetTokenInformation, + TOKEN_DEFAULT_DACL, TOKEN_GROUPS, TokenDefaultDacl, TokenGroups, WRITE_RESTRICTED, + }, + System::SystemServices::SE_GROUP_LOGON_ID, +}; + +pub(super) fn restrict( + base: HANDLE, + capabilities: &[String], + owner: &Sid, + user: &Sid, +) -> io::Result { + if capabilities.is_empty() || capabilities.len() > 1024 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let logon = logon_sid(base)?; + let everyone = Sid::well_known(windows_sys::Win32::Security::WinWorldSid)?; + let caps = capabilities + .iter() + .map(|text| account::parse_sid(text)) + .collect::>>()?; + let entries: Vec = caps + .iter() + .chain([user, &logon, &everyone]) + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid.as_ptr(), + Attributes: 0, + }) + .collect(); + let mut token = ptr::null_mut(); + // SAFETY: All restrictions have live SID backing; the privileged service token is never used as the base. + boolean(unsafe { + CreateRestrictedToken( + base, + DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED, + 0, + ptr::null(), + 0, + ptr::null(), + entries.len() as u32, + entries.as_ptr(), + &mut token, + ) + })?; + let token = owned(token)?; + let security = SecurityDescriptor::new(&format!( + "D:P(A;;RC;;;OW)(A;;GA;;;SY)(A;;GA;;;{})(A;;GA;;;{})", + sid_text(&logon)?, + sid_text(owner)? + ))?; + let mut dacl = ptr::null_mut(); + let mut present = 0; + let mut defaulted = 0; + // SAFETY: The descriptor owns a complete DACL; SetTokenInformation copies it before the backing allocation is released. + unsafe { + boolean(GetSecurityDescriptorDacl( + security.as_ptr(), + &mut present, + &mut dacl, + &mut defaulted, + ))?; + let value = TOKEN_DEFAULT_DACL { DefaultDacl: dacl }; + boolean(SetTokenInformation( + token.as_raw_handle(), + TokenDefaultDacl, + ptr::from_ref(&value).cast(), + size_of_val(&value) as u32, + ))?; + } + Ok(token) +} + +fn logon_sid(token: HANDLE) -> io::Result { + let mut needed = 0; + // SAFETY: The first query obtains only the required length for this live token. + unsafe { GetTokenInformation(token, TokenGroups, ptr::null_mut(), 0, &mut needed) }; + if needed < size_of::() as u32 || needed > 1024 * 1024 { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut storage = vec![0usize; (needed as usize).div_ceil(size_of::())]; + // SAFETY: Aligned output has at least the reported capacity. + boolean(unsafe { + GetTokenInformation( + token, + TokenGroups, + storage.as_mut_ptr().cast(), + needed, + &mut needed, + ) + })?; + // SAFETY: A successful query initialized the struct and trailing groups within the buffer. + let groups = unsafe { &*storage.as_ptr().cast::() }; + // SAFETY: GroupCount belongs to the successfully initialized variable-sized token information. + let groups = + unsafe { std::slice::from_raw_parts(groups.Groups.as_ptr(), groups.GroupCount as usize) }; + for group in groups { + if group.Attributes & SE_GROUP_LOGON_ID as u32 == SE_GROUP_LOGON_ID as u32 { + // SAFETY: Group SIDs are backed by the live token-information allocation. + return unsafe { Sid::copy(group.Sid) }; + } + } + Err(io::ErrorKind::PermissionDenied.into()) +} diff --git a/apps/buddy/platform/process/__tests__/windowsSandbox.spec.ts b/apps/buddy/platform/process/__tests__/windowsSandbox.spec.ts index b3c9eb47e..50a479d15 100644 --- a/apps/buddy/platform/process/__tests__/windowsSandbox.spec.ts +++ b/apps/buddy/platform/process/__tests__/windowsSandbox.spec.ts @@ -50,7 +50,7 @@ describe('windows sandbox component compatibility', () => { { protocol: 2, version: 1 }, { protocol: 1 }, { protocol: 1, version: 0 }, - new Error('Legacy component does not support version'), + Object.assign(new Error('Legacy component does not support version'), { code: 125 }), ])('requires repair and blocks execution for incompatible or unidentified components: %j', async (version) => { installedVersion = version await expect(checkWindowsSandbox(bundled)).resolves.toBe('needs_repair') @@ -70,19 +70,46 @@ describe('windows sandbox component compatibility', () => { expect(execute.mock.calls.every(([path]) => path === bundled)).toBe(true) }) - it('requires a healthy network service even when versions match', async () => { + it('keeps a service failure unavailable without requesting a component update', async () => { healthError = new Error('Service unavailable') + await expect(checkWindowsSandbox(bundled)).resolves.toBe('unavailable') + await expect(resolveWindowsSandbox(bundled)).rejects.toMatchObject({ code: 'SANDBOX_UNAVAILABLE' }) + healthError = undefined + await expect(checkWindowsSandbox(bundled)).resolves.toBe('available') + }) + + it('preserves the update action for a confirmed sandbox account identity mismatch', async () => { + healthError = Object.assign(new Error('Identity mismatch'), { + code: 125, + stderr: JSON.stringify({ type: 'error', code: 'SANDBOX_NEEDS_UPDATE', nativeCode: null }), + }) await expect(checkWindowsSandbox(bundled)).resolves.toBe('needs_repair') await expect(resolveWindowsSandbox(bundled)).rejects.toMatchObject({ code: 'SANDBOX_UNAVAILABLE' }) }) - it('blocks commands when the installed component fails the real-command probe', async () => { + it.each(['version', 'health'])('keeps a %s timeout distinct from a component requiring an update', async (phase) => { + const timeout = Object.assign(new Error('Timed out'), { killed: true, signal: 'SIGTERM', code: null }) + if (phase === 'version') + installedVersion = timeout + else + healthError = timeout + await expect(checkWindowsSandbox(bundled)).resolves.toBe('unavailable') + await expect(setupWindowsSandbox(bundled)).resolves.toBe('failed') + }) + + it('reports a failed health probe without repeating the probe for each command', async () => { probe.mockResolvedValue(false) await expect(checkWindowsSandbox(bundled)).resolves.toBe('incompatible') - await expect(resolveWindowsSandbox(bundled)).rejects.toMatchObject({ code: 'SANDBOX_UNAVAILABLE' }) + await expect(resolveWindowsSandbox(bundled)).resolves.toBe(installed) await expect(setupWindowsSandbox(bundled)).resolves.toBe('incompatible') }) + it('keeps transient probe failures distinct from platform incompatibility', async () => { + probe.mockRejectedValue(new Error('Sandbox preparation interrupted')) + await expect(checkWindowsSandbox(bundled)).resolves.toBe('unavailable') + await expect(setupWindowsSandbox(bundled)).resolves.toBe('failed') + }) + it('returns ready only after installation health and command verification succeed', async () => { await expect(setupWindowsSandbox(bundled)).resolves.toBe('ready') installedVersion = { protocol: 1, version: 2 } diff --git a/apps/buddy/platform/process/probeWindowsSandbox.ts b/apps/buddy/platform/process/probeWindowsSandbox.ts index 96f5879dc..df681f4cb 100644 --- a/apps/buddy/platform/process/probeWindowsSandbox.ts +++ b/apps/buddy/platform/process/probeWindowsSandbox.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -10,7 +10,7 @@ export async function probeWindowsSandbox(executable: string, shell: string): Pr const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT if (!systemRoot) return false - const directory = await mkdtemp(join(tmpdir(), 'lexora-sandbox-check-')) + const directory = await realpath(await mkdtemp(join(tmpdir(), 'lexora-sandbox-check-'))) const listener = createServer(socket => socket.destroy()) try { await Promise.all(['home', 'tmp'].map(path => mkdir(join(directory, path)))) @@ -18,7 +18,13 @@ export async function probeWindowsSandbox(executable: string, shell: string): Pr const address = listener.address() if (!address || typeof address === 'string') return false - const metadata = await stat(directory, { bigint: true }) + const paths = [directory] + for (let parent = dirname(directory); !paths.includes(parent); parent = dirname(parent)) + paths.push(parent) + const grants = await Promise.all(paths.map(async (path) => { + const metadata = await stat(path, { bigint: true }) + return { path, inherit: path === directory, access: path === directory ? 'write' as const : 'read' as const, device: String(metadata.dev), inode: String(metadata.ino) } + })) let output = '' const exitCode = await runWindowsSandboxProcess(executable, { command: '$ErrorActionPreference=\'Stop\'; if (!$PWD.Path -or $PWD.Path -ne [IO.Path]::GetDirectoryName($env:HOME)) { throw \'cwd\' }; $file=Join-Path $env:TEMP \'roundtrip\'; Set-Content -LiteralPath $file -Value \'probe\'; if ((Get-Content -LiteralPath $file -Raw).Trim() -ne \'probe\') { throw \'read\' }; Remove-Item -LiteralPath $file; if (Test-Path -LiteralPath $file) { throw \'delete\' }; & $env:ComSpec /d /c \'exit 0\'; if ($LASTEXITCODE -ne 0) { throw \'child\' }; [Console]::Out.Write(\'SANDBOX_PROBE_OK\')', @@ -26,12 +32,11 @@ export async function probeWindowsSandbox(executable: string, shell: string): Pr shell, privateRoot: directory, proxyPort: address.port, - grants: [{ path: directory, access: 'write', device: String(metadata.dev), inode: String(metadata.ino) }], + grants, environment: createWindowsSandboxEnvironment({ 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' } - catch { return false } finally { await new Promise(resolve => listener.close(() => resolve())) await rm(directory, { recursive: true, force: true }) diff --git a/apps/buddy/platform/process/windowsSandbox.ts b/apps/buddy/platform/process/windowsSandbox.ts index 9c25c1744..9cbc87f71 100644 --- a/apps/buddy/platform/process/windowsSandbox.ts +++ b/apps/buddy/platform/process/windowsSandbox.ts @@ -23,27 +23,52 @@ async function inspect(executable: string) { return statusSchema.parse(JSON.parse(stdout)) } -async function componentVersion(executable: string): Promise { - const { stdout } = await execute(executable, ['version'], { - env: createWindowsHostEnvironment(executable, process.env), - windowsHide: true, - timeout: 10_000, - maxBuffer: 16 * 1024, - }) - return versionSchema.parse(JSON.parse(stdout)).version +async function componentVersion(executable: string): Promise { + let stdout: string + try { + ({ stdout } = await execute(executable, ['version'], { + env: createWindowsHostEnvironment(executable, process.env), + windowsHide: true, + timeout: 10_000, + maxBuffer: 16 * 1024, + })) + } + catch (error) { + if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' && !('killed' in error && error.killed)) + return undefined + throw error + } + try { + return versionSchema.parse(JSON.parse(stdout)).version + } + catch { return undefined } } -async function healthy(executable: string, status: z.infer): Promise { +async function compatible(executable: string, status: z.infer): Promise { if (!status.healthy) return false + const [bundledVersion, installedVersion] = await Promise.all([executable, status.path].map(componentVersion)) + return bundledVersion !== undefined && bundledVersion === installedVersion +} + +async function verifyService(executable: string): Promise { try { - const [bundledVersion, installedVersion] = await Promise.all([executable, status.path].map(componentVersion)) - if (bundledVersion !== installedVersion) - return false - await execute(status.path, ['health'], { env: createWindowsHostEnvironment(status.path, process.env), windowsHide: true, timeout: 15_000, maxBuffer: 16 * 1024 }) + await execute(executable, ['health'], { env: createWindowsHostEnvironment(executable, process.env), windowsHide: true, timeout: 15_000, maxBuffer: 16 * 1024 }) return true } - catch { return false } + catch (error) { + if (error && typeof error === 'object' && 'stderr' in error && typeof error.stderr === 'string' && !('killed' in error && error.killed)) { + try { + const diagnostic = z.object({ type: z.literal('error'), code: z.literal('SANDBOX_NEEDS_UPDATE'), nativeCode: z.null() }).strict().safeParse(JSON.parse(error.stderr.trim())) + if (diagnostic.success) + return false + } + catch {} + } + const failure = z.object({ code: z.union([z.string(), z.number()]).nullable().optional(), killed: z.boolean().optional(), signal: z.string().nullable().optional() }).safeParse(error) + console.error('SANDBOX_SERVICE_UNAVAILABLE', failure.success ? failure.data : {}) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') + } } export async function checkWindowsSandbox(executable: string | undefined): Promise { @@ -53,7 +78,9 @@ export async function checkWindowsSandbox(executable: string | undefined): Promi const status = await inspect(executable) if (!status.installed) return 'needs_setup' - if (!await healthy(executable, status)) + if (!await compatible(executable, status)) + return 'needs_repair' + if (!await verifyService(status.path)) return 'needs_repair' return await probeWindowsSandbox(status.path, await resolveWindowsPowerShell()) ? 'available' : 'incompatible' } @@ -64,9 +91,9 @@ export async function resolveWindowsSandbox(executable: string | undefined): Pro if (!executable) throw new ShellSandboxError('SANDBOX_UNAVAILABLE') const status = await inspect(executable) - if (!status.installed || !await healthy(executable, status)) + if (!status.installed || !await compatible(executable, status)) throw new ShellSandboxError('SANDBOX_UNAVAILABLE') - if (!await probeWindowsSandbox(status.path, await resolveWindowsPowerShell())) + if (!await verifyService(status.path)) throw new ShellSandboxError('SANDBOX_UNAVAILABLE') return status.path } diff --git a/apps/buddy/platform/process/windowsSandboxProcess.ts b/apps/buddy/platform/process/windowsSandboxProcess.ts index 226fd60df..59ce18381 100644 --- a/apps/buddy/platform/process/windowsSandboxProcess.ts +++ b/apps/buddy/platform/process/windowsSandboxProcess.ts @@ -14,11 +14,14 @@ export interface WindowsSandboxRequest { cwd: string shell: string privateRoot: string - grants: readonly { path: string, access: 'read' | 'write' | 'denyRead' | 'denyWrite', device: string, inode: string, exceptions?: string[] }[] + grants: readonly { path: string, access: 'read' | 'write' | 'denyRead' | 'denyWrite', device: string, inode: string, inherit?: boolean, exceptions?: string[] }[] + denyReadScope?: string proxyPort: number environment: Record } +type WindowsSandboxPreparationRequest = Pick & { synchronizeDenials?: boolean, background?: boolean } + const diagnosticSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('ready'), protocol: z.literal(1) }).strict(), z.object({ type: z.literal('error'), code: z.string(), nativeCode: z.number().nullable() }).strict(), @@ -33,6 +36,21 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind onData: (data: Buffer) => void onStarted: () => void onProcess?: (process: SandboxProcessState) => void +}): Promise { + return runProcess(executable, 'run', request, options) +} + +export async function prepareWindowsSandboxFilesystem(executable: string, grants: WindowsSandboxRequest['grants'], signal: AbortSignal, options: Omit = {}): Promise { + const code = await runProcess(executable, 'prepare', { grants, ...options }, { signal, onData: () => {}, onStarted: () => {} }) + if (code !== 0) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') +} + +async function runProcess(executable: string, mode: 'run' | 'prepare', request: WindowsSandboxRequest | WindowsSandboxPreparationRequest, options: { + signal: AbortSignal + onData: (data: Buffer) => void + onStarted: () => void + onProcess?: (process: SandboxProcessState) => void }): Promise { options.signal.throwIfAborted() return new Promise((resolve, reject) => { @@ -43,7 +61,7 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind let exited = false let launchError: Error | undefined let forceKill: ReturnType | undefined - const child = spawn(executable, ['run'], { cwd: request.privateRoot, env: createWindowsHostEnvironment(executable, process.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) + const child = spawn(executable, [mode], { cwd: 'privateRoot' in request ? request.privateRoot : undefined, env: createWindowsHostEnvironment(executable, process.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) child.once('spawn', () => options.onProcess?.({ phase: 'spawned', pid: child.pid })) const closeUnknownOutput = () => { if (exited && child.stderr.readableEnded && !processesReleased) diff --git a/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts b/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts index 3b4cfb2a6..0a58e876c 100644 --- a/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts +++ b/apps/buddy/platform/windows/__tests__/fileConsumers.spec.ts @@ -87,7 +87,7 @@ describe('windows file consumers with simulated filesystem', () => { expect(sensitive.matches('C:\\workspace\\.env.example')).toBe(false) expect(sensitive.matches('C:\\workspace\\.env.dev.example')).toBe(false) expect(sensitive.matches('C:\\workspace\\.env.production.template')).toBe(false) - expect(sensitive.matches('C:\\workspace\\.env.dev')).toBe(true) + expect(sensitive.matches('C:\\workspace\\.env.dev')).toBe(false) }) it.each(['D:\\outside\\index.html', 'C:\\Workspace\\index.html'])('rejects browser targets outside the canonical root: %s', async (entry) => { diff --git a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts new file mode 100644 index 000000000..71d51d688 --- /dev/null +++ b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths' +import { createWindowsSandboxPolicy } from '../../../service/src/sandbox/backends/windows/createPolicy' +import { sandboxCommandSchema, sandboxProcessInputSchema } from '../../../shared/permissions/shellSandbox' +import { SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' + +vi.mock('../../currentPlatform', async () => { + const { resolveBuddyPlatform } = await import('../../../shared/platform') + return { currentPlatform: resolveBuddyPlatform('win32') } +}) +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, ...actual.win32 } +}) +vi.mock('node:fs/promises', () => ({ + realpath: async (path: string) => path === 'Z:\\MappedTools' ? '\\\\server\\tools' : path, + stat: async () => ({ isDirectory: () => true, dev: 1n, ino: 2n }), +})) + +const home = 'C:\\Users\\Fixture' +const input = { + command: 'Write-Output ok', + requestId: '00000000-0000-4000-8000-000000000001', + roots: ['C:\\workspace'], + workspaceRoots: ['C:\\workspace'], + resourceReadRoots: [], + additionalDirectories: [], + cwd: 'C:\\workspace', + home, + privateRoot: 'C:\\private', + protectedRoots: [], + sensitiveRoots: resolveSensitivePathRoots({ home, environment: { APPDATA: 'D:\\Roaming', LOCALAPPDATA: 'D:\\Local', SystemRoot: 'C:\\Windows' } }), + readOnly: false, + backend: { kind: SHELL_SANDBOX_BACKEND.Windows, protectionScope: home, executable: 'C:\\sandbox.exe', shell: 'C:\\PowerShell\\pwsh.exe', systemRoot: 'C:\\Windows' }, + searchDirectory: 'C:\\search', + path: '', +} + +describe('windows sandbox policy assembly', () => { + it('retains local PATH tools without turning UNC or device paths into mandatory grants', async () => { + const policy = await createWindowsSandboxPolicy({ ...input, path: '"D:\\Tools";\\\\server\\tools;//server/share;\\\\?\\C:\\tools;C:relative;\\root-relative;C:\\NUL\\..\\tools;Z:\\MappedTools' }, new AbortController().signal) + expect(policy.path.split(';')).toEqual(['C:\\PowerShell', 'C:\\Windows\\System32', 'C:\\search', 'D:\\Tools']) + expect(policy.grants.filter(grant => grant.access === 'read').map(grant => grant.path)).toEqual(policy.path.split(';')) + expect(sandboxCommandSchema.safeParse({ command: input.command, requestId: input.requestId, cwd: '\\\\server\\tools', roots: ['\\\\server\\tools'], readOnly: false }).success).toBe(false) + }) + + it('protects redirected credentials on another volume and rejects them as workspace roots', async () => { + const policy = await createWindowsSandboxPolicy(input, new AbortController().signal) + expect(policy.grants.filter(grant => grant.access === 'denyRead').map(grant => grant.path)).toEqual(expect.arrayContaining([ + 'C:\\Users\\Fixture\\.netrc', + 'C:\\Users\\Fixture\\_netrc', + 'D:\\Roaming\\gcloud', + 'D:\\Local\\Microsoft\\Vault', + 'D:\\Local\\Google\\Chrome\\User Data', + ])) + expect(policy.grants.map(grant => grant.path)).not.toContain('C:\\Windows\\System32\\config') + for (const root of ['D:\\Roaming\\gcloud', 'C:\\Windows\\System32\\config']) { + await expect(createWindowsSandboxPolicy({ ...input, roots: [root], workspaceRoots: [root], cwd: root }, 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) + expect(sandboxCommandSchema.safeParse({ command: input.command, requestId: input.requestId, cwd: input.cwd, roots: input.roots, readOnly: false, sensitiveRoots: [] }).success).toBe(false) + }) +}) diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 6ac36268d..ea9e757c0 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -861,7 +861,30 @@ export async function startBuddyService( shellSandbox: await host.start('runtime.shell_sandbox', ({ defer }) => { const sandbox = new ShellSandboxClient(options.rpc) const diagnostics = observeSandboxClient(sandbox, record) + const prepareSpaces = (spaceId?: string) => { + if (currentPlatform.shell !== 'powershell') + return + for (const space of spaceService.list()) { + if (space.revokedAt || (spaceId && space.id !== spaceId)) + continue + const roots = [...(space.primaryDirectory ? [space.primaryDirectory] : []), ...space.additionalDirectories] + .filter(directory => !directory.revokedAt) + .map(directory => directory.canonicalRoot) + void sandbox.prepare(roots).catch(() => record({ event: 'sandbox.workspace_preparation.failed', level: 'warn', spaceId: space.id })) + } + } + const preparedSpaces = spaceService.onDidCommit((event) => { + if (event.facets.includes('directories') && event.kind !== 'deleted') + prepareSpaces(event.spaceId) + }) + const installed = options.rpc.onNotification((method) => { + if (method === 'host.sandbox.ready') + prepareSpaces() + }) + prepareSpaces() defer(async () => { + preparedSpaces.dispose() + installed() await sandbox.dispose() diagnostics.dispose() }) diff --git a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts index 239dc93ee..570447bd1 100644 --- a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts +++ b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts @@ -13,6 +13,8 @@ import { estimateTokens, ModelRuntime } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' import { afterEach, describe, expect, it } from 'vitest' import { createOutputPresentationExtension } from '../../../artifacts/outputPresentationExtension' +import { PermissionEngine } from '../../../permissions/PermissionEngine' +import { createSensitivePathMatcher } from '../../../permissions/sensitivePaths' import { ToolAuthorizationService } from '../../../permissions/ToolAuthorizationService' import { createInputReferenceExtension } from '../../extensions/inputReferenceExtension' import { createToolPolicyExtension } from '../../extensions/toolPolicyExtension' @@ -683,6 +685,7 @@ async function createFixture(options: { ...options.approvalDecision ? [createToolPolicyExtension({ authorization: new ToolAuthorizationService({ + engine: new PermissionEngine({ sensitive: createSensitivePathMatcher({ additionalRoots: [join(root, '.env.media')] }) }), approvalAvailable: true, approvalPolicy: 'policy', approvalService: { request: async () => ({ approvalId: 'offline-approval', decision: options.approvalDecision! }) }, diff --git a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts index 0dcf43461..2c64d545b 100644 --- a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts +++ b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts @@ -137,6 +137,7 @@ export async function createBuddySessionExtensions( creationSignal.throwIfAborted() const authorization = new ToolAuthorizationService({ applySandboxDirectory: (run, grant) => sandboxDirectories.grant(run, grant), + getSandboxDirectories: run => sandboxDirectories.get(run), applyGrant: async (proposal) => { const mutation = await services.directoryGrants.grant(proposal) services.directoryGrants.assertCurrent(proposal.owner, mutation.grant.id) diff --git a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts index 2c0f42989..600efea3e 100644 --- a/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts +++ b/apps/buddy/service/src/agent/resources/createBuddySystemPrompt.ts @@ -1,6 +1,7 @@ import type { BuddyApprovalPolicy } from '../../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile' -import { resolveShellExecution } from '../../sandbox/shellExecution' +import { SHELL_SANDBOX_BACKEND } from '../../../../shared/platform/identifiers' +import { resolveShellExecution, WINDOWS_SANDBOX_FILESYSTEM_LIMITS } from '../../sandbox/shellExecution' export const LEXORA_BUDDY_BASE_SYSTEM_PROMPT = [ 'You are Lexora Buddy, the user\'s local personal AI companion.', @@ -47,7 +48,10 @@ export function createBuddySystemPrompt(options: CreateBuddySystemPromptOptions) export function createBuddyExecutionPrompt(executionProfile: BuddyExecutionProfile, platform?: NodeJS.Platform, activeToolNames?: readonly string[]): string { const execution = resolveShellExecution(executionProfile, platform) - return createExecutionProfilePrompt(executionProfile, execution.dialect, execution.boundary === 'sandbox', activeToolNames) + const prompt = createExecutionProfilePrompt(executionProfile, execution.dialect, execution.boundary === 'sandbox', activeToolNames) + return execution.boundary === 'sandbox' && execution.backend === SHELL_SANDBOX_BACKEND.Windows && (activeToolNames?.includes(execution.dialect) ?? true) + ? `${prompt}\n${WINDOWS_SANDBOX_FILESYSTEM_LIMITS}` + : prompt } function createExecutionProfilePrompt( @@ -76,10 +80,10 @@ function createExecutionProfilePrompt( 'This conversation uses the local read-only execution profile.', 'Pi built-in tools keep their native names; Lexora-owned tools use lexora_ prefixed names.', hasTool('read') ? 'Native file tools can read ordinary files outside the authorized directories; sensitive reads still require approval. This does not expand other tools\' boundaries.' : '', - 'Writing and deleting are blocked in this profile, and no approval can lift that; say plainly that the profile has to change before you can modify anything, and do not retry the call.', + 'File tools block writing and deleting in this profile, and no approval can lift that. Do not use commands or OS permission gaps to modify user files; the profile must change before making those changes.', hasShell ? isolatedShell - ? `${shellName} commands run in an OS sandbox: authorized directories are read-only, credentials and host IPC are hidden, and only private temporary files may be written. Arbitrary command syntax is available for inspection; host execution cannot be enabled in this profile.` + ? `${shellName} commands run with a read-only OS sandbox policy. Arbitrary command syntax is available for inspection; write only private temporary files. Do not access protected credential paths. Host execution cannot be enabled in this profile.` : `Only commands that are safe to run without confirmation are available through ${shellName}; use them for inspection and diagnosis.` : '', 'Network access and external tools remain separate approval boundaries; local read-only mode does not auto-approve them.', @@ -90,11 +94,11 @@ function createExecutionProfilePrompt( 'Pi built-in tools keep their native names; Lexora-owned tools use lexora_ prefixed names.', hasShell ? isolatedShell - ? `${shellName} runs in an OS sandbox. It can read authorized directories and installed toolchains, and modify authorized directories except protected locations. Credentials, host IPC and root repository Git metadata writes are unavailable. The harness requests network authorization when the destination is not covered by the user's current turn grants. Isolated execution is never silently retried on the host.` + ? `${shellName} runs in an OS sandbox. Ordinary files are readable without directory approval. Modify only authorized directories and respect protected locations; do not access protected credential paths or write repository Git metadata. The harness requests network authorization when the destination is not covered by the user's current turn grants. Isolated execution is never silently retried on the host.` : 'Host tools run with the Lexora Buddy service user\'s operating-system permissions. Buddy policy may allow, block, or request product approval before execution.' : '', hasShell && isolatedShell && hasTool('lexora_authorize_directory') - ? 'Use lexora_authorize_directory with read or write access and a reason for another directory. This expands only isolated shell permissions for the current run, never saved grants or other tools. Prefer read when inspection suffices.' + ? 'Use lexora_authorize_directory with read or write access and a reason for another directory. Additional write authorization applies only to isolated shell commands in the current run, never saved grants or other tools. Windows may reuse read-access preparation across runs. Prefer read when inspection suffices.' : '', hasShell && isolatedShell && hasTool('lexora_host_shell') ? 'Only when the user task genuinely requires host access, use lexora_host_shell; its approval explicitly lifts sandbox restrictions for that command. Do not use host access to retry a declined request.' diff --git a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts index 6ae7a200b..a43299d71 100644 --- a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts +++ b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts @@ -986,7 +986,7 @@ describe('composer resource import', () => { expect(service.list('partial')).toContainEqual(retained) expect(facts.flatMap(fact => fact.resources).filter(resource => resource.resourceId === retained.resourceId)).toHaveLength(1) await writeFile(join(directory, '.env'), 'synthetic=value') - await expect(service.selectSpaceFile({ ...input, resourceId: 'sensitive', source: { ...input.source, relativePath: '.env' } })).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) + await expect(service.selectSpaceFile({ ...input, resourceId: 'environment', source: { ...input.source, relativePath: '.env' } })).resolves.toMatchObject({ localReference: { name: '.env', path: join(directory, '.env') } }) await writeFile(join(directory, 'note.txt'), Uint8Array.of(255)) await expect(resolveInputs(service, 'draft-1', content, { branchId: null, conversationId: null, spaceId: 'space-1' })).resolves.toMatchObject([{ localReference: { sizeBytes: 1 } }]) await rm(join(directory, 'note.txt')) diff --git a/apps/buddy/service/src/conversations/__tests__/sessionMaterials.spec.ts b/apps/buddy/service/src/conversations/__tests__/sessionMaterials.spec.ts index d146584c0..dfd12af67 100644 --- a/apps/buddy/service/src/conversations/__tests__/sessionMaterials.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/sessionMaterials.spec.ts @@ -9,6 +9,8 @@ import { createBuddyUserContent } from '../../../../shared/conversation/buddyUse import { createBuddyInputReference, createBuddyInputReferenceMessage } from '../../agent/context/BuddyInputReference' import { ArtifactService } from '../../artifacts/ArtifactService' import { RunEventProjector } from '../../events/RunEventProjector' +import { PermissionEngine } from '../../permissions/PermissionEngine' +import { createSensitivePathMatcher } from '../../permissions/sensitivePaths' import { ToolAuthorizationService } from '../../permissions/ToolAuthorizationService' import { createArtifactRepository } from '../../storage/artifactRepository' import { createAttachmentRepository } from '../../storage/attachmentRepository' @@ -90,6 +92,7 @@ async function fixture() { const controller = new AbortController() const run = { runId: 'run-current', signal: controller.signal, flushProjectedEvents: async () => {}, onToolExecutionAuthorized: async () => {} } const authorization = new ToolAuthorizationService({ + engine: new PermissionEngine({ sensitive: createSensitivePathMatcher({ additionalRoots: [join(root, '.env')] }) }), approvalService: { request: async () => ({ approvalId: 'denied', decision: 'denied' }) }, approvalAvailable: true, approvalPolicy: 'policy', diff --git a/apps/buddy/service/src/directories/__tests__/searchDirectoryEntries.spec.ts b/apps/buddy/service/src/directories/__tests__/searchDirectoryEntries.spec.ts index 1726188f7..41603e2a5 100644 --- a/apps/buddy/service/src/directories/__tests__/searchDirectoryEntries.spec.ts +++ b/apps/buddy/service/src/directories/__tests__/searchDirectoryEntries.spec.ts @@ -52,13 +52,13 @@ describe('directory browsing and explicit deep search', () => { expect((await search('node_modules/')).entries.map(entry => entry.name)).toEqual(['asset.avif']) }) - it('rejects traversal, hides sensitive files and excludes escaping symlinks', async () => { + it('rejects traversal and escaping symlinks while exposing ordinary dotfiles', async () => { const { root, workspace, search } = await fixture() await writeFile(join(root, 'outside.txt'), 'outside') await writeFile(join(workspace, '.env'), 'synthetic') await writeFile(join(workspace, '.env.example'), 'synthetic') await symlink(join(root, 'outside.txt'), join(workspace, 'escape.txt')) - expect((await search('')).entries.map(entry => entry.name)).toEqual(['.env.example']) + expect((await search('')).entries.map(entry => entry.name)).toEqual(['.env', '.env.example']) await expect(search('../')).rejects.toMatchObject({ code: 'PATH_OUTSIDE_GRANTED_DIRECTORY' }) }) }) diff --git a/apps/buddy/service/src/permissions/PermissionEngine.ts b/apps/buddy/service/src/permissions/PermissionEngine.ts index b636d7fc3..9019f7109 100644 --- a/apps/buddy/service/src/permissions/PermissionEngine.ts +++ b/apps/buddy/service/src/permissions/PermissionEngine.ts @@ -12,8 +12,10 @@ import type { import type { SensitivePathMatcher } from './sensitivePaths' import { stat } from 'node:fs/promises' import process from 'node:process' +import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' import { sandboxDirectoryRequestSchema } from '../../../shared/permissions/shellSandbox' import { ShellPolicy } from '../approvals/ShellPolicy' +import { validateSandboxDirectory } from '../sandbox/SandboxDirectoryPermissions' import { classifyPath, PathClassificationError, toGrantRoot } from './classifyPath' import { lookupPermissionOutcome } from './decisionTable' import { allow, ask, deny } from './permissionContract' @@ -148,9 +150,22 @@ export class PermissionEngine { if (!path.isDirectory) return deny('INVALID_PATH', 'invalid') const metadata = await stat(path.canonicalPath, { bigint: true }) + const directory = { access: input.access, path: path.canonicalPath, device: String(metadata.dev), inode: String(metadata.ino) } + if (!request.forceAsk) { + if (path.zone === 'workspace' || path.zone === 'granted') + return allow() + for (const grant of request.sandboxDirectories ?? []) { + if ((grant.access === 'write' || grant.access === input.access) && containsCanonicalPath(grant.path, path.canonicalPath)) { + await validateSandboxDirectory(grant) + return allow() + } + } + } + if (!request.forceAsk && input.access === 'read') + return { type: 'allow', sandboxDirectory: directory } return finalizeDecision(request, ask({ kind: input.access, - sandboxDirectory: { access: input.access, path: path.canonicalPath, reason: input.reason, device: String(metadata.dev), inode: String(metadata.ino) }, + sandboxDirectory: { ...directory, reason: input.reason }, summary: 'Expand isolated shell directory access for this run only', })) } diff --git a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts index 9a8f833a6..2882c41c1 100644 --- a/apps/buddy/service/src/permissions/ToolAuthorizationService.ts +++ b/apps/buddy/service/src/permissions/ToolAuthorizationService.ts @@ -22,6 +22,7 @@ export interface ToolAuthorizationOptions { cwd: string owner: GrantOwner getGrants: () => readonly DirectoryGrant[] + getSandboxDirectories?: (run: BuddyExtensionRunContext) => readonly SandboxDirectoryGrant[] applyGrant?: (grant: GrantProposal) => Promise | void applySandboxDirectory?: (run: BuddyExtensionRunContext, grant: SandboxDirectoryGrant) => Promise executionPermissions?: ToolExecutionPermissions @@ -139,6 +140,7 @@ export class ToolAuthorizationService { requireApproval: declared.requireApproval, shellBoundary: declared.shellBoundary, grants: options.getGrants(), + sandboxDirectories: options.getSandboxDirectories?.(run), owner: options.owner, paths: declared.paths, profile: options.fileAccessLimit === 'read_only' ? 'read_only' : settings.executionProfile, @@ -196,18 +198,19 @@ export class ToolAuthorizationService { throw error } } + if (decision.sandboxDirectory) { + if (!options.applySandboxDirectory) + return 'DIRECTORY_GRANT_FAILED' + try { + await options.applySandboxDirectory(run, decision.sandboxDirectory) + } + catch { + signal.throwIfAborted() + return 'SANDBOX_DIRECTORY_CHANGED' + } + } if (decision.type === 'ask') { signal.throwIfAborted() - if (decision.sandboxDirectory) { - if (!options.applySandboxDirectory) - return 'DIRECTORY_GRANT_FAILED' - try { - await options.applySandboxDirectory(run, decision.sandboxDirectory) - } - catch { - return 'SANDBOX_DIRECTORY_CHANGED' - } - } if (decision.grant && approvedOnce) { try { if (!options.applyGrant) diff --git a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts index d202f236e..bd0244390 100644 --- a/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/PermissionEngine.spec.ts @@ -73,7 +73,7 @@ describe('permissionEngine', () => { it('uses canonical sensitive-path checks for shell reads and explicit Git content diffs', async () => { const fixture = await createGitFixture() - const engine = createEngine(fixture.home) + const engine = new PermissionEngine({ sensitive: createSensitivePathMatcher({ home: fixture.home, additionalRoots: [join(fixture.workspace, '.env')] }) }) await writeFile(join(fixture.workspace, 'package.json'), '{}') await writeFile(join(fixture.workspace, '.env'), 'TEST_SECRET=not-a-real-secret') await executeFile('git', ['add', '--', '.env', 'package.json'], { cwd: fixture.workspace }) @@ -109,7 +109,7 @@ describe('permissionEngine', () => { arguments: { command: 'git diff --cached -- package.json' }, toolName: 'bash', }))).resolves.toEqual({ type: 'allow' }) - const powershell = new PermissionEngine({ platform: 'win32', sensitive: createSensitivePathMatcher({ home: fixture.home }) }) + const powershell = new PermissionEngine({ platform: 'win32', sensitive: createSensitivePathMatcher({ home: fixture.home, additionalRoots: [join(fixture.workspace, '.env')] }) }) for (const toolName of ['powershell', 'lexora_host_shell']) { await expect(powershell.decide(request(fixture, { arguments: { command: 'git diff -- .env' }, @@ -149,7 +149,7 @@ describe('permissionEngine', () => { it('resolves historical and renamed diff targets before permitting their contents', async () => { const fixture = await createGitFixture() - const engine = createEngine(fixture.home) + const engine = new PermissionEngine({ sensitive: createSensitivePathMatcher({ home: fixture.home, additionalRoots: [join(fixture.workspace, '.env')] }) }) await writeFile(join(fixture.workspace, '.env'), 'TEST_SECRET=placeholder\n') await writeFile(join(fixture.workspace, 'notes.md'), 'before\n') await executeFile('git', ['add', '.'], { cwd: fixture.workspace }) diff --git a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts index 2561c3761..7f9a6601b 100644 --- a/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/ToolAuthorizationService.spec.ts @@ -68,6 +68,7 @@ describe('harness tool authorization', () => { cwd: workspace, owner: { id: 'conversation', kind: 'conversation' as const }, getGrants: () => grants, + getSandboxDirectories: (context: BuddyExtensionRunContext) => directories.get(context), executionPermissions, applySandboxDirectory: (context: BuddyExtensionRunContext, grant: Parameters[1]) => directories.grant(context, grant), } diff --git a/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts b/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts index 0d974ec05..30f0e25d7 100644 --- a/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/shellFileDeletion.spec.ts @@ -101,8 +101,8 @@ describe.skipIf(process.platform === 'win32')('shell file deletion permissions', }) }) - it('denies sensitive names and canonical sensitive targets even in full access', async () => { - const fixture = await createFixture() + it('denies explicitly protected paths and their canonical targets even in full access', async () => { + const fixture = await createFixture(true) await writeFile(join(fixture.workspace, '.env'), 'SYNTHETIC=fixture') await symlink(join(fixture.workspace, '.env'), join(fixture.workspace, 'public.vue')) for (const profile of ['read_only', 'workspace_write', 'full_access'] as const) { @@ -225,7 +225,7 @@ describe.skipIf(process.platform === 'win32')('shell file deletion permissions', }) }) -async function createFixture() { +async function createFixture(protectEnvironment = false) { const root = await realpath(await mkdtemp(join(tmpdir(), 'lexora-delete-'))) roots.push(root) const home = join(root, 'home') @@ -241,7 +241,7 @@ async function createFixture() { { canonicalRoot: workspace, grantId: 'workspace-1', kind: 'workspace', root: workspace }, { canonicalRoot: granted, grantId: 'granted-1', kind: 'granted', root: granted }, ] - const engine = new PermissionEngine({ platform: 'linux', sensitive: createSensitivePathMatcher({ home }) }) + const engine = new PermissionEngine({ platform: 'linux', sensitive: createSensitivePathMatcher({ home, additionalRoots: protectEnvironment ? [join(workspace, '.env')] : [] }) }) return { engine, granted, grants, home, outside, workspace } } diff --git a/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts b/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts index 20865c972..cf9904507 100644 --- a/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/storageSensitivePaths.spec.ts @@ -25,3 +25,12 @@ it('protects retained credentials without restricting entire historical director } expect(sensitive.matches(join(activeRoot, 'buddy', 'conversations', 'task', 'workspace', 'output.txt'))).toBe(false) }) + +it('protects agent credentials without classifying all configuration and skills as secrets', () => { + const home = join(tmpdir(), 'credential-matcher') + const matcher = createSensitivePathMatcher({ home, environment: {} }) + for (const path of ['.codex/auth.json', '.claude/.credentials.json', '.pi/agent/auth.json', '.docker/config.json', '.ssh/id_ed25519', '.netrc', '_netrc']) + expect(matcher.matches(join(home, path)), path).toBe(true) + for (const path of ['.codex/config.toml', '.codex/skills/example/SKILL.md', '.claude/settings.json', '.pi/agent/settings.json', 'project/.env', 'project/test.pem', 'project/.netrc', 'project/_netrc']) + expect(matcher.matches(join(home, path)), path).toBe(false) +}) diff --git a/apps/buddy/service/src/permissions/permissionContract.ts b/apps/buddy/service/src/permissions/permissionContract.ts index 69cab19d1..235a43f26 100644 --- a/apps/buddy/service/src/permissions/permissionContract.ts +++ b/apps/buddy/service/src/permissions/permissionContract.ts @@ -62,7 +62,7 @@ export type PermissionDecision summary: string type: 'ask' } - | { type: 'allow' } + | { type: 'allow', sandboxDirectory?: SandboxDirectoryGrant } | { code: PermissionDenialCode source: PermissionDenialSource @@ -80,6 +80,7 @@ export interface PermissionRequest { requireApproval?: boolean shellBoundary?: 'sandbox' grants: readonly PermissionGrant[] + sandboxDirectories?: readonly SandboxDirectoryGrant[] owner: GrantOwner paths?: readonly PermissionPath[] profile: BuddyExecutionProfile diff --git a/apps/buddy/service/src/permissions/sensitivePaths.ts b/apps/buddy/service/src/permissions/sensitivePaths.ts index 64baa18f8..d074aac75 100644 --- a/apps/buddy/service/src/permissions/sensitivePaths.ts +++ b/apps/buddy/service/src/permissions/sensitivePaths.ts @@ -5,24 +5,20 @@ import { resolveSensitiveLocations } from '../../../platform/filesystem/sensitiv const HOME_RELATIVE_ROOTS = [ '.aws', - '.claude', - '.codex', - '.docker', + '.claude/.credentials.json', + '.codex/auth.json', + '.docker/config.json', '.gnupg', '.kube', '.mozilla', + '.netrc', '.npmrc', '.pgpass', - '.pi', + '.pi/agent/auth.json', '.ssh', + '_netrc', ] -const SENSITIVE_BASENAME_PATTERN - = /^(?:\.env(?:\..+)?|\.netrc|_netrc|\.pgpass|id_(?:rsa|dsa|ecdsa|ed25519)|.*\.pem|.*\.p12|.*\.pfx)$/i - -const SECRET_TEMPLATE_BASENAME_PATTERN - = /^\.env(?:\..+)?\.(?:example|sample|template|defaults?|dist|schema)$/i - export interface SensitivePathMatcher { matches: (canonicalPath: string) => boolean } @@ -47,9 +43,6 @@ export function createSensitivePathMatcher( catch { return false } - const name = filePaths.path.basename(path) - if (SENSITIVE_BASENAME_PATTERN.test(name) && !SECRET_TEMPLATE_BASENAME_PATTERN.test(name)) - return true return sensitiveRoots.some(root => containsCanonicalPath(root, filePaths.sensitiveKey(path))) }, } diff --git a/apps/buddy/service/src/sandbox/ShellSandboxClient.ts b/apps/buddy/service/src/sandbox/ShellSandboxClient.ts index ed41d4db2..95d23dbce 100644 --- a/apps/buddy/service/src/sandbox/ShellSandboxClient.ts +++ b/apps/buddy/service/src/sandbox/ShellSandboxClient.ts @@ -45,6 +45,12 @@ export class ShellSandboxClient { }) } + async prepare(roots: readonly string[]): Promise { + if (this.#disposed) + throw new ShellSandboxError('SANDBOX_UNAVAILABLE') + await this.#peer.request('host.sandbox.prepare', { roots }, 70_000) + } + async exec( input: Omit, options: ExecOptions, diff --git a/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts b/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts index 4cd6f0ce0..4df034ad9 100644 --- a/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/SandboxDirectoryPermissions.spec.ts @@ -54,6 +54,7 @@ describe('run-scoped shell directory permissions', () => { authorization: new ToolAuthorizationService({ applyGrant: (grant) => { persisted.push(grant) }, applySandboxDirectory: (context, grant) => permissions.grant(context, grant), + getSandboxDirectories: context => permissions.get(context), approvalAvailable: options.approvalAvailable ?? true, approvalPolicy: 'policy', approvalService: { @@ -108,13 +109,10 @@ describe('run-scoped shell directory permissions', () => { expect(JSON.stringify(events)).not.toContain(outside) }) - it('grants read access only to the reviewed directory and keeps saved grants unchanged', async () => { + it('makes ordinary reads available without approval and keeps saved grants unchanged', async () => { const harness = createHarness() await expect(harness.invoke()).resolves.toBeUndefined() - expect(harness.reviews).toMatchObject([{ - kind: 'read', - sandboxDirectory: { path: outside, access: 'read', reason: 'Inspect the reference files' }, - }]) + expect(harness.reviews).toEqual([]) expect(harness.permissions.get(harness.run)).toEqual([{ path: outside, access: 'read', @@ -130,23 +128,46 @@ describe('run-scoped shell directory permissions', () => { const harness = createHarness() await harness.invoke() await harness.invoke({ path: outside, access: 'write', reason: 'Update the requested output' } satisfies SandboxDirectoryRequest) - expect(harness.reviews.map(review => review.kind)).toEqual(['read', 'write']) + expect(harness.reviews.map(review => review.kind)).toEqual(['write']) expect(harness.permissions.get(harness.run)).toMatchObject([{ path: outside, access: 'write' }]) expect(harness.permissions.getWriteGrants(harness.run)).toMatchObject([{ canonicalRoot: outside, kind: 'granted' }]) expect(harness.persisted).toEqual([]) }) + it('reuses covered directory access without expanding its scope or prompting again', async () => { + const harness = createHarness() + const nested = join(outside, 'nested') + await mkdir(nested) + await harness.invoke({ path: workspace, access: 'write', reason: 'Use the existing workspace' }) + expect(harness.reviews).toEqual([]) + expect(harness.permissions.get(harness.run)).toEqual([]) + await harness.invoke() + await harness.invoke({ path: nested, access: 'read', reason: 'Inspect a child' }) + await harness.invoke() + expect(harness.reviews).toHaveLength(0) + expect(harness.permissions.get(harness.run)).toMatchObject([{ path: outside, access: 'read' }]) + await harness.invoke({ path: outside, access: 'write', reason: 'Update output' }) + await harness.invoke({ path: nested, access: 'write', reason: 'Update nested output' }) + expect(harness.reviews).toHaveLength(1) + expect(harness.permissions.get(harness.run)).toMatchObject([{ path: outside, access: 'write' }]) + harness.permissions.clear(harness.run) + await harness.invoke({ path: nested, access: 'write', reason: 'Request after revocation' }) + expect(harness.reviews).toHaveLength(2) + expect(harness.permissions.get(harness.run)).toMatchObject([{ path: nested, access: 'write' }]) + expect(harness.persisted).toEqual([]) + }) + it('allows an explicit read expansion in read-only mode but rejects a write expansion', async () => { const harness = createHarness({ profile: 'read_only' }) await expect(harness.invoke()).resolves.toBeUndefined() await expect(harness.invoke({ path: outside, access: 'write', reason: 'Write output' })).resolves.toMatchObject({ block: true, reason: 'READ_ONLY_PROFILE' }) expect(harness.permissions.get(harness.run)).toMatchObject([{ access: 'read' }]) - expect(harness.reviews).toHaveLength(1) + expect(harness.reviews).toHaveLength(0) }) it('does not grant access when denied', async () => { const harness = createHarness({ approve: async () => 'denied' }) - await expect(harness.invoke()).resolves.toMatchObject({ block: true }) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toMatchObject({ block: true }) expect(harness.permissions.get(harness.run)).toEqual([]) expect(harness.authorized).toEqual([]) expect(harness.persisted).toEqual([]) @@ -154,14 +175,14 @@ describe('run-scoped shell directory permissions', () => { it('applies a turn approval to the reviewed directory without persisting it', async () => { const harness = createHarness({ approve: async () => 'approved_for_turn' }) - await expect(harness.invoke()).resolves.toBeUndefined() - expect(harness.permissions.get(harness.run)).toMatchObject([{ path: outside, access: 'read' }]) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toBeUndefined() + expect(harness.permissions.get(harness.run)).toMatchObject([{ path: outside, access: 'write' }]) expect(harness.persisted).toEqual([]) }) it('rejects expansion without an interactive approval surface', async () => { const harness = createHarness({ approvalAvailable: false }) - await expect(harness.invoke()).resolves.toMatchObject({ block: true, reason: 'APPROVAL_UNAVAILABLE_IN_BACKGROUND' }) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toMatchObject({ block: true, reason: 'APPROVAL_UNAVAILABLE_IN_BACKGROUND' }) expect(harness.permissions.get(harness.run)).toEqual([]) expect(harness.reviews).toEqual([]) }) @@ -193,7 +214,7 @@ describe('run-scoped shell directory permissions', () => { const harness = createHarness({ approve: async () => { throw new ApprovalCancelledError() } }) - await expect(harness.invoke()).resolves.toMatchObject({ block: true, reason: 'APPROVAL_CANCELLED' }) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toMatchObject({ block: true, reason: 'APPROVAL_CANCELLED' }) expect(harness.permissions.get(harness.run)).toEqual([]) expect(harness.authorized).toEqual([]) }) @@ -203,7 +224,7 @@ describe('run-scoped shell directory permissions', () => { harness.controller.abort() return 'approved_once' } }) - await expect(harness.invoke()).resolves.toMatchObject({ block: true }) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toMatchObject({ block: true }) expect(harness.permissions.get(harness.run)).toEqual([]) expect(harness.authorized).toEqual([]) }) @@ -216,7 +237,7 @@ describe('run-scoped shell directory permissions', () => { return 'approved_once' }, }) - await expect(harness.invoke()).resolves.toMatchObject({ block: true, reason: 'SANDBOX_DIRECTORY_CHANGED' }) + await expect(harness.invoke({ path: outside, access: 'write', reason: 'Update requested files' })).resolves.toMatchObject({ block: true, reason: 'SANDBOX_DIRECTORY_CHANGED' }) expect(harness.permissions.get(harness.run)).toEqual([]) expect(harness.authorized).toEqual([]) diff --git a/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts index 3114d6da3..970a94987 100644 --- a/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/macosSandbox.spec.ts @@ -10,6 +10,7 @@ import { setTimeout } from 'node:timers/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSandboxDirectory } from '../../../../platform/process/sandboxDirectory' import { createSandboxEnvironment } from '../../../../platform/process/sandboxEnvironment' +import { resolveSensitivePathRoots } from '../../permissions/sensitivePaths' import { runSrtSandbox } from '../backends/srt/runSrtSandbox' import { runSandboxCommand } from '../runSandboxCommand' @@ -45,6 +46,7 @@ describe.skipIf(process.platform !== 'darwin' || process.arch !== 'arm64')('macO path: environment.PATH ?? '', privateRoot, protectedRoots: [], + sensitiveRoots: resolveSensitivePathRoots({ home, environment }), roots: [workspace], workspaceRoots: [workspace], additionalDirectories: [], @@ -72,7 +74,7 @@ describe.skipIf(process.platform !== 'darwin' || process.arch !== 'arm64')('macO return { result, output: Buffer.concat(output).toString('utf8') } } - it('keeps workspace writes while denying secrets, symlink escapes and Git changes', async () => { + it('allows ordinary reads while constraining writes and Git metadata', async () => { await mkdir(join(workspace, '.git')) await writeFile(join(workspace, '.git/config'), 'git-preserved') await writeFile(join(workspace, '.env'), 'synthetic-secret') @@ -81,7 +83,7 @@ describe.skipIf(process.platform !== 'darwin' || process.arch !== 'arm64')('macO const exitCode = await runSrtSandbox({ ...input, backend: { kind: 'macos-srt' }, - command: `set -e; printf created > output; cat output; ! cat .env; ! cat escape/preserved; ! sh -c 'echo changed > .git/config'; ! sh -c ${quote(`echo changed > ${quote(join(outside, 'preserved'))}`)}`, + command: `set -e; printf created > output; cat output; cat .env; cat escape/preserved; ! sh -c 'echo changed > .git/config'; ! sh -c ${quote(`echo changed > ${quote(join(outside, 'preserved'))}`)}`, }, { signal: AbortSignal.timeout(10_000), onStarted: () => {}, @@ -91,8 +93,8 @@ describe.skipIf(process.platform !== 'darwin' || process.arch !== 'arm64')('macO const text = Buffer.concat(output).toString('utf8') expect(exitCode, text).toBe(0) expect(text).toContain('created') - expect(text).not.toContain('synthetic-secret') - expect(text).not.toContain('outside-preserved') + expect(text).toContain('synthetic-secret') + expect(text).toContain('outside-preserved') expect(await readFile(join(outside, 'preserved'), 'utf8')).toBe('outside-preserved') expect(await readFile(join(workspace, '.git/config'), 'utf8')).toBe('git-preserved') }, 15_000) diff --git a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts index 0d1b3c43b..c765b4c47 100644 --- a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveSandboxDependencies } from '../../../../platform/process/sandboxDependencies' import { createSandboxDirectory } from '../../../../platform/process/sandboxDirectory' import { createSandboxEnvironment } from '../../../../platform/process/sandboxEnvironment' +import { resolveSensitivePathRoots } from '../../permissions/sensitivePaths' import { runSandboxCommand } from '../runSandboxCommand' const buddyRoot = process.cwd() @@ -47,6 +48,7 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { path: originalEnvironment.PATH ?? '', privateRoot, protectedRoots: [], + sensitiveRoots: resolveSensitivePathRoots({ home: originalHome, environment: originalEnvironment }), readOnly: false, requestId: randomUUID(), roots: [workspace], @@ -81,12 +83,12 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { await writeFile(join(workspace, '.env'), 'synthetic-secret') await writeFile(join(workspace, '.env.example'), 'example-allowed') await symlink(outside, join(workspace, 'escape')) - const { result, output } = await execute(`set -e; printf created > file; cat file; rm file; cat .env.example; ! cat .env; ! cat escape/preserved; ! sh -c 'echo changed > .git/config'; ! sh -c ${quote(`echo changed > ${join(outside, 'preserved')}`)}`, { onLifecycle: event => events.push(event) }) - expect(result).toEqual({ ok: true, exitCode: 0 }) + const { result, output } = await execute(`set -e; printf created > file; cat file; rm file; cat .env.example; cat .env; cat escape/preserved; ! sh -c 'echo changed > .git/config'; ! sh -c ${quote(`echo changed > ${join(outside, 'preserved')}`)}`, { onLifecycle: event => events.push(event) }) + expect(result, output).toEqual({ ok: true, exitCode: 0 }) expect(output).toContain('created') expect(output).toContain('example-allowed') - expect(output).not.toContain('synthetic-secret') - expect(output).not.toContain('outside-preserved') + expect(output).toContain('synthetic-secret') + expect(output).toContain('outside-preserved') expect(await readFile(join(outside, 'preserved'), 'utf8')).toBe('outside-preserved') expect(await readFile(join(workspace, '.git/config'), 'utf8')).toBe('git-preserved') await expect(access(join(workspace, 'file'))).rejects.toThrow() @@ -107,27 +109,52 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { it('enforces read-only writes in the kernel while retaining private scratch space', async () => { await writeFile(join(workspace, 'preserved'), 'workspace-preserved') - const { result } = await execute('set -e; ! rm preserved; ! touch new-file; printf scratch > "$TMPDIR/scratch"; test "$(cat "$TMPDIR/scratch")" = scratch; if test -w /tmp; then scratch=$(mktemp /tmp/standard-XXXXXX); printf private > "$scratch"; test "$(cat "$scratch")" = private; rm "$scratch"; fi', {}, { readOnly: true }) - expect(result).toEqual({ ok: true, exitCode: 0 }) + const { result, output } = await execute('set -e; ! rm preserved; ! touch new-file; printf scratch > "$TMPDIR/scratch"; test "$(cat "$TMPDIR/scratch")" = scratch; if test -w /tmp; then scratch=$(mktemp /tmp/standard-XXXXXX); printf private > "$scratch"; test "$(cat "$scratch")" = private; rm "$scratch"; fi', {}, { readOnly: true }) + expect(result, output).toEqual({ ok: true, exitCode: 0 }) expect(await readFile(join(workspace, 'preserved'), 'utf8')).toBe('workspace-preserved') await expect(access(join(workspace, 'new-file'))).rejects.toThrow() }, 15_000) + it('protects host-resolved credentials after HOME and XDG variables are isolated', async () => { + const home = join(directory, 'host-home') + const configuration = join(directory, 'relocated-config') + const data = join(directory, 'relocated-data') + const environment = { XDG_CONFIG_HOME: configuration, XDG_DATA_HOME: data } + const credentials = [join(home, '.netrc'), join(home, '_netrc'), join(home, '.ssh', 'id_ed25519'), join(configuration, 'gcloud', 'credentials.db'), join(data, 'keyrings', 'login.keyring')] + for (const path of credentials) { + await mkdir(resolve(path, '..'), { recursive: true }) + await writeFile(path, 'SYNTHETIC_CREDENTIAL') + } + 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)}` + 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') + expect(result.output).toContain('PROJECT_FIXTURE') + expect(result.output).toContain('ORDINARY_CONFIGURATION') + const forbidden = await execute('printf must-not-run', {}, { home, sensitiveRoots, cwd: join(configuration, 'gcloud'), roots: [join(configuration, 'gcloud')], workspaceRoots: [join(configuration, 'gcloud')] }) + expect(forbidden.result).toMatchObject({ ok: false, code: 'SANDBOX_UNAVAILABLE' }) + expect(forbidden.output).not.toContain('must-not-run') + }, 15_000) + it('limits additional read permission across interpreters and descendants without silently granting write access', async () => { const metadata = await stat(outside, { bigint: true }) const grant = { access: 'read' as const, path: outside, device: String(metadata.dev), inode: String(metadata.ino) } const target = join(outside, 'preserved') const command = `set -e; node -e ${quote(`const fs=require('node:fs'); console.log(fs.readFileSync(${JSON.stringify(target)},'utf8')); try { fs.writeFileSync(${JSON.stringify(target)},'changed'); process.exit(1) } catch(e) { if(!['EACCES','EROFS'].includes(e.code)) throw e }`)}; ! sh -c ${quote(`rm ${quote(target)}`)}` const read = await execute(command, {}, { additionalDirectories: [grant] }) - expect(read.result).toEqual({ ok: true, exitCode: 0 }) + expect(read.result, read.output).toEqual({ ok: true, exitCode: 0 }) expect(read.output).toContain('outside-preserved') expect(await readFile(target, 'utf8')).toBe('outside-preserved') const write = await execute(`printf updated > ${quote(target)}`, {}, { additionalDirectories: [{ ...grant, access: 'write' }] }) expect(write.result).toEqual({ ok: true, exitCode: 0 }) expect(await readFile(target, 'utf8')).toBe('updated') - const expired = await execute(`cat ${quote(target)}`) - expect(expired.result).not.toMatchObject({ exitCode: 0 }) - expect(expired.output).not.toContain('updated') + const expired = await execute(`cat ${quote(target)}; ! sh -c ${quote(`echo forbidden > ${quote(target)}`)}`) + expect(expired.result).toMatchObject({ exitCode: 0 }) + expect(expired.output).toContain('updated') + expect(await readFile(target, 'utf8')).toBe('updated') }, 30_000) it('reads an installed skill package while keeping it immutable and sibling product data private', async () => { @@ -165,7 +192,7 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { const host = net.createConnection(${JSON.stringify(hostSocket)}); host.on('connect', () => { throw new Error('Host socket accessible') }); host.on('error', async error => { - assert.equal(error.code, ${JSON.stringify(dependencies.privateUnixSockets ? 'EACCES' : 'EPERM')}); + assert.ok(['ENOENT', ${JSON.stringify(dependencies.privateUnixSockets ? 'EACCES' : 'EPERM')}].includes(error.code)); const path = process.env.TMPDIR + '/private.sock'; const server = net.createServer(socket => socket.end('private-reply')); ${dependencies.privateUnixSockets @@ -179,7 +206,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'))}; ${dependencies.privateUnixSockets ? 'test -w /tmp;' : ''} node -e ${quote(script)}` + 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)}` for (const readOnly of [false, true]) { const { result, output } = await execute(command, {}, { readOnly }) expect(result, output).toEqual({ ok: true, exitCode: 0 }) @@ -195,10 +222,10 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { const helper = join(privateRoot, 'legacy-helper') await writeFile(helper, `#!/bin/sh\nif [ "$1" = --probe-sandbox-ipc ]; then\n printf '{"protocol":1,"privateUnixSockets":false}\\n'\nelse\n exec ${quote(originalEnvironment.LEXORA_BUDDY_PROCESS_CONTROL!)} "$@"\nfi\n`, { mode: 0o700 }) vi.stubEnv('LEXORA_BUDDY_PROCESS_CONTROL', helper) - const command = `set -e; printf legacy > owned; ! cat ${quote(join(outside, 'preserved'))}; node -e ${quote(`require('node:net').createServer().on('error', error => { if(error.code !== 'EPERM') throw error }).listen(process.env.TMPDIR + '/denied.sock')`)}` + const command = `set -e; printf legacy > owned; cat ${quote(join(outside, 'preserved'))}; node -e ${quote(`require('node:net').createServer().on('error', error => { if(error.code !== 'EPERM') throw error }).listen(process.env.TMPDIR + '/denied.sock')`)}` const { result, output } = await execute(command) expect(result, output).toEqual({ ok: true, exitCode: 0 }) - expect(output).not.toContain('outside-preserved') + expect(output).toContain('outside-preserved') expect(await readFile(join(workspace, 'owned'), 'utf8')).toBe('legacy') }) diff --git a/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts index 2bd33129c..2f4d7ecdf 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts @@ -1,10 +1,9 @@ import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime' import type { SrtSandboxInput } from '../../../../../shared/permissions/shellSandbox' -import { delimiter, join } from 'node:path' -import { containsCanonicalPath } from '../../../../../platform/filesystem/filePaths' +import { delimiter } from 'node:path' import { resolveSandboxDependencies } from '../../../../../platform/process/sandboxDependencies' import { OPERATING_SYSTEM } from '../../../../../shared/platform/identifiers' -import { createSandboxFilesystemPolicy, existingSandboxPaths, SANDBOX_TOOLCHAIN_PATHS } from '../../sandboxFilesystemPolicy' +import { createSandboxFilesystemPolicy, existingSandboxPaths } from '../../sandboxFilesystemPolicy' import { createLinuxSystemPolicy } from './linuxPolicy' import { createMacosSystemPolicy } from './macosPolicy' @@ -14,14 +13,12 @@ export async function createSandboxPolicy(input: SrtSandboxInput, signal: AbortS const dependencies = await resolveSandboxDependencies({ backend: input.backend, searchDirectory: input.searchDirectory }, signal) const { rg, srtRoot } = dependencies const system = dependencies.platform === OPERATING_SYSTEM.Linux ? await createLinuxSystemPolicy(dependencies) : createMacosSystemPolicy() - const policy = await createSandboxFilesystemPolicy(input, rg, signal) - const toolchains = await existingSandboxPaths(SANDBOX_TOOLCHAIN_PATHS.map(path => join(input.home, path))) + const policy = await createSandboxFilesystemPolicy(input, signal) const searchPaths = await existingSandboxPaths(input.path.split(delimiter).filter(path => path.startsWith('/'))) - const pathEntries = searchPaths.filter(path => [...toolchains, ...system.pathRoots].some(root => containsCanonicalPath(root, path))) - const runtimePaths = await existingSandboxPaths([...system.readPaths, ...toolchains, input.searchDirectory]) + const runtimePaths = await existingSandboxPaths([...system.readPaths, input.searchDirectory]) const paths = (access: typeof policy[number]['access']) => policy.filter(grant => grant.access === access).map(grant => grant.path) return { - path: [...new Set([...pathEntries, ...system.pathEntries])].join(delimiter), + path: [...new Set([...searchPaths, ...system.pathEntries])].join(delimiter), config: { ...system.runtime, ripgrep: { command: rg }, diff --git a/apps/buddy/service/src/sandbox/backends/srt/linuxPolicy.ts b/apps/buddy/service/src/sandbox/backends/srt/linuxPolicy.ts index fdf68d00f..2a898060b 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/linuxPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/linuxPolicy.ts @@ -1,17 +1,13 @@ import type { SrtSandboxDependencies } from '../../../../../platform/process/sandboxDependencies' import type { OPERATING_SYSTEM } from '../../../../../shared/platform/identifiers' -import { readdir } from 'node:fs/promises' import { join } from 'node:path' const SYSTEM_READ_PATHS = ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc/ssl', '/etc/ca-certificates', '/etc/pki', '/etc/alternatives', '/etc/ld.so.cache', '/etc/ld.so.conf', '/etc/ld.so.conf.d', '/etc/localtime', '/etc/passwd', '/etc/group', '/etc/nsswitch.conf', '/etc/resolv.conf', '/etc/hosts', '/etc/services'] export async function createLinuxSystemPolicy(dependencies: Extract) { - const denyRead = (await readdir('/', { withFileTypes: true })) - .filter(entry => !entry.isSymbolicLink() && !['proc', 'dev', 'sys'].includes(entry.name)) - .map(entry => `/${entry.name}`) return { readPaths: [...SYSTEM_READ_PATHS, join(dependencies.srtRoot, 'vendor'), ...(dependencies.privateUnixSockets ? [dependencies.bwrap, dependencies.processControl] : [])], - denyRead, + denyRead: [] as string[], denyWrite: [dependencies.sandboxDirectory], pathRoots: ['/usr', '/bin'], pathEntries: ['/usr/local/bin', '/usr/bin', '/bin'], diff --git a/apps/buddy/service/src/sandbox/backends/srt/macosPolicy.ts b/apps/buddy/service/src/sandbox/backends/srt/macosPolicy.ts index aaf898902..98e5044e9 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/macosPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/macosPolicy.ts @@ -3,7 +3,7 @@ const SYSTEM_READ_PATHS = ['/usr', '/bin', '/sbin', '/System/Library', '/System/ export function createMacosSystemPolicy() { return { readPaths: SYSTEM_READ_PATHS, - denyRead: ['/'], + denyRead: [] as string[], denyWrite: [] as string[], pathRoots: ['/usr', '/bin', '/opt/homebrew'], pathEntries: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'], diff --git a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts index e598738c0..66c783998 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts @@ -3,10 +3,8 @@ import type { OwnedProcessSettlement } from '../../../../../platform/process/sup import type { SrtSandboxInput } from '../../../../../shared/permissions/shellSandbox' import type { SandboxExecutionOptions } from '../../sandboxExecutionLifecycle' import { spawn } from 'node:child_process' -import { join } from 'node:path' import process from 'node:process' import { SandboxManager } from '@anthropic-ai/sandbox-runtime' -import { containsCanonicalPath } from '../../../../../platform/filesystem/filePaths' import { SANDBOX_LOOPBACK_NO_PROXY } from '../../../../../platform/process/sandboxEnvironment' import { runSupervisedShellProcess } from '../../../../../platform/process/supervisedShellProcess' import { createSandboxPolicy } from './createPolicy' @@ -62,7 +60,7 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec ...launch.env, PATH: path, LEXORA_BUDDY_SANDBOX_BWRAP: config.bwrapPath, - LEXORA_BUDDY_SANDBOX_TMP: [...input.roots, ...input.additionalDirectories.map(grant => grant.path), ...input.resourceReadRoots].some(root => containsCanonicalPath(root, '/tmp')) ? '/tmp' : join(input.privateRoot, 'tmp'), + LEXORA_BUDDY_SANDBOX_TMP: '/tmp', }, }, { signal: options.signal, diff --git a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts index a1b25e6fd..bdf5332af 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts @@ -1,15 +1,26 @@ import type { SandboxBackendInput } from '../../../../../shared/permissions/shellSandbox' import type { SHELL_SANDBOX_BACKEND } from '../../../../../shared/platform/identifiers' +import type { SandboxFilesystemPolicyInput } from '../../sandboxFilesystemPolicy' import { dirname, join } from 'node:path' -import { containsCanonicalPath } from '../../../../../platform/filesystem/filePaths' -import { createSandboxFilesystemPolicy, existingSandboxPaths, inspectSandboxPath, SANDBOX_TOOLCHAIN_PATHS } from '../../sandboxFilesystemPolicy' +import { validateWindowsFilePath } from '../../../../../platform/windows/filePath' +import { createSandboxFilesystemPolicy, existingSandboxPaths, inspectSandboxPath } from '../../sandboxFilesystemPolicy' -export async function createWindowsSandboxPolicy(input: SandboxBackendInput, signal: AbortSignal) { - const grants = await createSandboxFilesystemPolicy(input, join(input.searchDirectory, 'rg.exe'), signal) - const toolchains = await existingSandboxPaths(SANDBOX_TOOLCHAIN_PATHS.map(path => join(input.home, path))) - const runtime = [...toolchains, input.searchDirectory] - for (const path of runtime.filter(path => !grants.some(grant => (grant.access === 'read' || grant.access === 'write') && containsCanonicalPath(grant.path, path)))) - grants.push(await inspectSandboxPath(path, 'read')) - const paths = [dirname(input.backend.shell), join(input.backend.systemRoot, 'System32'), input.searchDirectory, ...input.path.split(';').filter(path => runtime.some(root => containsCanonicalPath(root, path)))] - return { grants, path: [...new Set(paths)].join(';') } +type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick, 'backend' | 'path' | 'searchDirectory'> + +export async function createWindowsSandboxPolicy(input: WindowsSandboxPolicyInput, signal: AbortSignal) { + const grants = await createSandboxFilesystemPolicy(input, signal) + const paths: string[] = [] + for (const entry of input.path.split(';')) { + try { + const path = validateWindowsFilePath(entry.replace(/^"|"$/g, '')) + if (/^[a-z]:\\/i.test(path)) + paths.push(path) + } + catch {} + } + const localPaths = (await existingSandboxPaths(paths)).filter(path => /^[a-z]:\\/i.test(path)) + const roots = [...new Set([...await existingSandboxPaths([dirname(input.backend.shell), join(input.backend.systemRoot, 'System32'), input.searchDirectory]), ...localPaths])] + for (const root of roots) + grants.push(await inspectSandboxPath(root, 'read')) + return { grants, path: roots.join(';') } } diff --git a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts index 2defb17f5..b0119de0e 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/runWindowsSandbox.ts @@ -41,6 +41,7 @@ export async function runWindowsSandbox(input: SandboxBackendInput { +export type SandboxFilesystemPolicyInput = Pick + +export function existingSandboxSensitivePaths(input: Pick): Promise { + const systemCredentials = input.backend.kind === SHELL_SANDBOX_BACKEND.Windows ? filePaths.sensitiveKey(join(input.backend.systemRoot, 'System32', 'config')) : undefined + return existingSandboxPaths(input.sensitiveRoots.filter(path => filePaths.sensitiveKey(path) !== systemCredentials)) +} + +export async function createSandboxFilesystemPolicy(input: SandboxFilesystemPolicyInput, signal: AbortSignal): Promise { signal.throwIfAborted() await Promise.all(input.additionalDirectories.map(validateSandboxDirectory)) const resources = (input.resourceReadRoots ?? []).filter(root => !input.roots.some(parent => containsCanonicalPath(parent, root))) const roots = [...new Set([...input.roots, ...input.additionalDirectories.map(grant => grant.path), ...resources])] const workspaces = input.workspaceRoots.filter(root => input.roots.includes(root)) - const sensitiveOptions = { home: input.home, environment: input.backend.kind === SHELL_SANDBOX_BACKEND.Windows ? { SystemRoot: input.backend.systemRoot } : {} } - const sensitiveRoots = resolveSensitivePathRoots(sensitiveOptions) - const sensitive = createSensitivePathMatcher(sensitiveOptions) + const sensitiveRoots = input.sensitiveRoots const grants: SandboxFilesystemGrant[] = [] for (const root of roots) { signal.throwIfAborted() @@ -66,18 +64,7 @@ export async function createSandboxFilesystemPolicy(input: SandboxProcessInput, if (!roots.some(root => containsCanonicalPath(root, input.cwd))) throw new ShellSandboxError('SANDBOX_UNAVAILABLE') - const secrets: string[] = [] - for (const root of roots.filter(root => !roots.some(parent => parent !== root && containsCanonicalPath(parent, root)))) { - try { - const result = await execute(rg, ['--files', '--hidden', '--no-ignore', '--null', ...SECRET_GLOBS.flatMap(glob => ['--iglob', glob]), '--', root], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, signal, timeout: 15_000, windowsHide: true }) - secrets.push(...result.stdout.split('\0').filter(path => path && sensitive.matches(path))) - } - catch (error) { - if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 1) - throw error - } - } - for (const path of await existingSandboxPaths([...secrets, ...sensitiveRoots.filter(path => roots.some(root => containsCanonicalPath(root, path)))])) + for (const path of await existingSandboxSensitivePaths(input)) grants.push(await inspectSandboxPath(path, 'denyRead')) for (const path of await existingSandboxPaths(input.protectedRoots)) { const grant = await inspectSandboxPath(path, 'denyRead') diff --git a/apps/buddy/service/src/sandbox/shellCapability.ts b/apps/buddy/service/src/sandbox/shellCapability.ts index 3778d607c..bedab3396 100644 --- a/apps/buddy/service/src/sandbox/shellCapability.ts +++ b/apps/buddy/service/src/sandbox/shellCapability.ts @@ -19,6 +19,7 @@ import { ShellSandboxError } from '../../../shared/permissions/shellSandbox' import { OPERATING_SYSTEM } from '../../../shared/platform/identifiers' import { toPiToolGroup } from '../agent/extensions/piToolGroup' import { ApprovalCancelledError, ApprovalExpiredError } from '../approvals/ApprovalService' +import { WINDOWS_SANDBOX_FILESYSTEM_LIMITS } from './shellExecution' export const SHELL_SANDBOX_EXTENSION = 'lexora-shell-sandbox' @@ -96,10 +97,10 @@ export function createShellCapability(options: { }) pi.registerTool({ ...native, - description: `${native.description}\nThe current permission mode determines the execution boundary. Read-only and workspace modes use an OS sandbox; full access runs on the host. Sandbox reads and writes require the corresponding directory authorization. ${networkBoundary} No automatic host fallback.`, + description: `${native.description}\nThe current permission mode determines the execution boundary. Read-only and workspace modes use an OS sandbox; full access runs on the host. Ordinary files are readable without directory approval. Sandbox policy permits writes only in authorized directories and denies reads of known credential and application-private paths. ${currentPlatform.id === OPERATING_SYSTEM.Windows ? `${WINDOWS_SANDBOX_FILESYSTEM_LIMITS} ` : ''}${networkBoundary} No automatic host fallback.`, promptGuidelines: [ - `Use ${shellName} for ordinary commands, pipelines, scripts and project builds under the current permission mode. In isolated modes, read failures outside granted directories require a directory authorization; do not disguise paths or use another interpreter to evade a denial.`, - 'In isolated modes, the sandbox has a private temporary HOME and TMPDIR. Host credentials, desktop sockets and repository Git metadata writes are not available. Use lexora_authorize_directory with the least access needed for additional directories; its permissions expire with the current run and do not apply to other tools. Use lexora_host_shell only when the requested task genuinely needs host access; if its definition is missing, search that exact name with lexora_tool_search. Never use it to retry a request the user denied.', + `Use ${shellName} for ordinary commands, pipelines, scripts and project builds under the current permission mode. Ordinary reads need no approval. If the OS denies an ordinary directory, lexora_authorize_directory can prepare read access without a prompt. Do not retry protected credential paths through another interpreter or execution boundary.`, + 'In isolated modes, the sandbox has a private temporary HOME and TMPDIR. Host credential variables and desktop IPC variables are not inherited. Do not access protected credential paths or write repository Git metadata. Use lexora_authorize_directory with the least access needed for additional directories; additional write authorization expires with the current run. Windows may reuse read-access preparation and ACL material across runs; this does not authorize writes or other tools. Use lexora_host_shell only when the requested task genuinely needs host access; if its definition is missing, search that exact name with lexora_tool_search. Never use it to retry a request the user denied.', ], async execute(toolCallId, parameters, signal, onUpdate, context) { const run = options.getRunContext() @@ -194,7 +195,7 @@ export function createShellCapability(options: { pi.registerTool(defineTool({ name: 'lexora_authorize_directory', label: 'Authorize a directory', - description: 'Request access to one existing directory for isolated shell commands in the CURRENT RUN only. Choose read for inspection, write only when the task requires creating, editing or deleting files. Explain why access is needed. Write includes read. Permissions expire at run completion, cancellation or restart; they do not grant other tools access or change saved conversation/Space permissions. Credential restrictions remain. Read-only mode cannot grant write access.', + description: 'Request access to one existing directory for isolated shell commands. Ordinary reads do not require approval. Request write only when the task requires creating, editing or deleting files outside existing writable directories. Explain why access is needed. Write includes read. Additional write authorization expires at run completion, cancellation or restart. Windows may retain read-access preparation and ACL material; the native filesystem limits still apply. This tool does not authorize other tools or change saved conversation/Space permissions. Do not bypass credential restrictions. Read-only mode cannot grant write access.', parameters: directoryParameters, async execute() { const directories = options.directoryPermissions.get(options.getRunContext()).map(({ path, access }) => ({ path, access })) diff --git a/apps/buddy/service/src/sandbox/shellExecution.ts b/apps/buddy/service/src/sandbox/shellExecution.ts index a9ed2414c..88433b949 100644 --- a/apps/buddy/service/src/sandbox/shellExecution.ts +++ b/apps/buddy/service/src/sandbox/shellExecution.ts @@ -3,6 +3,8 @@ import process from 'node:process' import { resolveBuddyPlatform } from '../../../shared/platform' import { getPiShellToolName } from '../agent/extensions/piBuiltinTools' +export const WINDOWS_SANDBOX_FILESYSTEM_LIMITS = 'Windows native filesystem isolation has limits: host files writable by Everyone may remain writable outside authorized directories, including in read-only mode; explicit public read permissions on sensitive files may bypass an inherited directory deny. Files created or given custom permissions by earlier commands may remain writable after authorization is revoked. These OS permissions do not authorize access: follow the task permissions and never exploit these limits.' + export function resolveShellExecution(profile: BuddyExecutionProfile, platform: NodeJS.Platform = process.platform) { const dialect = getPiShellToolName(platform) const { sandbox: backend } = resolveBuddyPlatform(platform) diff --git a/apps/buddy/shared/permissions/shellSandbox.ts b/apps/buddy/shared/permissions/shellSandbox.ts index 3397eda5a..8cf0cb1b9 100644 --- a/apps/buddy/shared/permissions/shellSandbox.ts +++ b/apps/buddy/shared/permissions/shellSandbox.ts @@ -53,15 +53,20 @@ export const sandboxCommandSchema = z.object({ export type SandboxCommand = z.infer +export const sandboxPreparationSchema = z.object({ + roots: z.array(pathSchema).max(128), +}).strict() + export const sandboxProcessInputSchema = sandboxCommandSchema.extend({ home: pathSchema, privateRoot: pathSchema, protectedRoots: z.array(pathSchema).max(32), + sensitiveRoots: z.array(pathSchema).max(128), searchDirectory: pathSchema, backend: z.discriminatedUnion('kind', [ z.object({ kind: z.literal(SHELL_SANDBOX_BACKEND.Linux), sandboxDirectory: pathSchema }).strict(), z.object({ kind: z.literal(SHELL_SANDBOX_BACKEND.MacOS) }).strict(), - z.object({ kind: z.literal(SHELL_SANDBOX_BACKEND.Windows), executable: pathSchema, shell: pathSchema, systemRoot: pathSchema }).strict(), + z.object({ kind: z.literal(SHELL_SANDBOX_BACKEND.Windows), executable: pathSchema, shell: pathSchema, systemRoot: pathSchema, protectionScope: pathSchema }).strict(), ]), path: z.string().max(32_768), }) @@ -101,7 +106,7 @@ export class ShellSandboxError extends Error { constructor(code: ShellSandboxError['code']) { const hints: Record = { - SANDBOX_UNAVAILABLE: 'The sandbox is unavailable. Enable or repair it in the permission menu. Related commands remain blocked until the sandbox is available.', + SANDBOX_UNAVAILABLE: 'The sandbox is unavailable. Check its status in the permission menu before retrying. Related commands remain blocked until the sandbox is available.', SANDBOX_FAILED: 'The sandboxed command could not complete. Do not assume it made no changes.', SANDBOX_CANCELLED: 'Command cancelled.', SANDBOX_TIMEOUT: 'Command timed out.', diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts index 27f8f995e..4e4a5d028 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -9,7 +9,7 @@ export default { 'desktop.chat.shellSandboxStatus.available': 'Sandbox available', 'desktop.chat.shellSandboxStatus.unavailable': 'Sandbox unavailable', 'desktop.chat.shellSandboxStatus.needs_setup': 'Sandbox setup required', - 'desktop.chat.shellSandboxStatus.needs_repair': 'Sandbox needs repair', + 'desktop.chat.shellSandboxStatus.needs_repair': 'Sandbox update required', 'desktop.chat.shellSandboxStatus.incompatible': 'Sandbox compatibility check failed', 'desktop.chat.shellSandboxStatus.unsupported': 'Sandbox unavailable', 'desktop.chat.shellSandboxStatus.unknown': 'Sandbox status unknown', @@ -18,18 +18,19 @@ export default { 'desktop.chat.shellSandboxHint.available': 'Task commands run in the sandbox. Execution is blocked if the sandbox cannot be established.', 'desktop.chat.shellSandboxHint.unavailable': 'The sandbox could not be established, so related commands are blocked. Check system settings and try again.', 'desktop.chat.shellSandboxHint.needs_setup': 'Enable the Windows sandbox before first use. Related commands remain blocked until setup finishes.', - 'desktop.chat.shellSandboxHint.needs_repair': 'The sandbox component is missing, outdated, or incorrectly configured. Repair it to continue.', + 'desktop.chat.shellSandboxHint.needs_repair': 'Update the sandbox component to continue running commands. Windows will request administrator approval.', 'desktop.chat.shellSandboxHint.incompatible': 'This system did not pass the sandbox compatibility check, so related commands are blocked.', 'desktop.chat.shellSandboxSetup.enable': 'Enable Windows sandbox', - 'desktop.chat.shellSandboxSetup.repair': 'Repair sandbox', + 'desktop.chat.shellSandboxSetup.repair': 'Update sandbox', 'desktop.chat.shellSandboxSetup.recheck': 'Check again', 'desktop.chat.shellSandboxSetup.title': 'Enable Windows sandbox', - 'desktop.chat.shellSandboxSetup.description': 'Task commands will run in the Windows sandbox. Windows will request administrator approval.', + 'desktop.chat.shellSandboxSetup.updateTitle': 'Update Windows sandbox', + 'desktop.chat.shellSandboxSetup.description': 'Windows will request administrator approval to install or update the sandbox component. Publicly writable files may be changed outside authorized directories, and custom public permissions may expose sensitive files. Write access to files created or given custom permissions by earlier commands may not be fully revoked.', 'desktop.chat.shellSandboxSetup.confirm': 'Continue', 'desktop.chat.shellSandboxSetup.waiting': 'Waiting for Windows…', - 'desktop.chat.shellSandboxSetup.cancelled': 'Administrator approval was cancelled. The sandbox is not enabled.', + 'desktop.chat.shellSandboxSetup.cancelled': 'Administrator approval was cancelled. The sandbox state is unchanged.', 'desktop.chat.shellSandboxSetup.incompatible': 'The sandbox is installed, but this system did not pass the compatibility check.', - 'desktop.chat.shellSandboxSetup.failed': 'Could not enable the sandbox. Check Windows security settings and try again.', + 'desktop.chat.shellSandboxSetup.failed': 'Could not complete sandbox setup. Check Windows security settings and try again.', 'desktop.chat.shellSandboxSetup.busy': 'Sandboxed tasks are still running. Try again shortly.', 'desktop.chat.shellSandboxHint.unsupported': 'This platform does not support the sandbox. Related commands require full access mode.', 'desktop.chat.shellSandboxHint.unknown': 'The sandbox status could not be read. Check again.', @@ -243,7 +244,7 @@ export default { 'desktop.chat.permissionModePolicyDescription': 'Automatically allow safe queries, file edits and verified ordinary-file deletions in authorized directories; review other operations according to policy.', 'desktop.chat.permissionModePolicySandboxDescription': 'Run commands in a sandbox with writes to authorized directories; ask separately for external network access, extra directories and host execution.', 'desktop.chat.permissionModeReadOnlyDescription': 'Read files and run safe read-only diagnostics.', - 'desktop.chat.permissionModeReadOnlySandboxDescription': 'Read files; run commands in a read-only sandbox that cannot modify host files.', + 'desktop.chat.permissionModeReadOnlySandboxDescription': 'Read files; run commands under a read-only sandbox policy.', 'desktop.chat.permissionModeTitle': 'How should Buddy approve actions?', 'desktop.chat.chooseEffort': 'Choose effort', 'desktop.chat.copied': 'Copied', diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts index 75b159f95..ac3e457c8 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -7,7 +7,7 @@ export default { 'desktop.chat.shellSandboxStatus.available': '沙盒可用', 'desktop.chat.shellSandboxStatus.unavailable': '沙盒不可用', 'desktop.chat.shellSandboxStatus.needs_setup': '待启用沙盒', - 'desktop.chat.shellSandboxStatus.needs_repair': '沙盒需要修复', + 'desktop.chat.shellSandboxStatus.needs_repair': '沙盒待更新', 'desktop.chat.shellSandboxStatus.incompatible': '沙盒兼容性检查未通过', 'desktop.chat.shellSandboxStatus.unsupported': '沙盒不可用', 'desktop.chat.shellSandboxStatus.unknown': '沙盒状态未知', @@ -16,18 +16,19 @@ export default { 'desktop.chat.shellSandboxHint.available': '任务命令将在沙盒中运行;无法建立沙盒时会阻止执行。', 'desktop.chat.shellSandboxHint.unavailable': '当前无法建立沙盒,相关命令已阻止。请检查系统设置后重试。', 'desktop.chat.shellSandboxHint.needs_setup': '首次使用前需要启用 Windows 沙盒。启用前,相关命令不会执行。', - 'desktop.chat.shellSandboxHint.needs_repair': '沙盒组件缺失、版本不匹配或配置异常。请修复后重试。', + 'desktop.chat.shellSandboxHint.needs_repair': '需要更新沙盒组件后才能继续执行命令。Windows 将请求管理员确认。', 'desktop.chat.shellSandboxHint.incompatible': '当前系统未通过沙盒兼容性检查,相关命令已阻止。', 'desktop.chat.shellSandboxSetup.enable': '启用 Windows 沙盒', - 'desktop.chat.shellSandboxSetup.repair': '修复沙盒', + 'desktop.chat.shellSandboxSetup.repair': '更新沙盒', 'desktop.chat.shellSandboxSetup.recheck': '重新检测', 'desktop.chat.shellSandboxSetup.title': '启用 Windows 沙盒', - 'desktop.chat.shellSandboxSetup.description': '启用后,任务命令将在 Windows 沙盒中运行。Windows 将请求管理员确认。', + 'desktop.chat.shellSandboxSetup.updateTitle': '更新 Windows 沙盒', + 'desktop.chat.shellSandboxSetup.description': 'Windows 将请求管理员确认以安装或更新沙盒组件。公开可写文件可能在授权目录外被修改,敏感文件的自定义公开权限可能允许读取;历史命令创建或改过权限的文件也可能无法完全收回写权限。', 'desktop.chat.shellSandboxSetup.confirm': '继续', 'desktop.chat.shellSandboxSetup.waiting': '等待 Windows 完成…', - 'desktop.chat.shellSandboxSetup.cancelled': '已取消管理员确认,沙盒尚未启用。', + 'desktop.chat.shellSandboxSetup.cancelled': '已取消管理员确认,沙盒状态保持不变。', 'desktop.chat.shellSandboxSetup.incompatible': '沙盒已安装,但当前系统未通过兼容性检查。', - 'desktop.chat.shellSandboxSetup.failed': '未能启用沙盒。请检查 Windows 安全设置后重试。', + 'desktop.chat.shellSandboxSetup.failed': '未能完成沙盒设置。请检查 Windows 安全设置后重试。', 'desktop.chat.shellSandboxSetup.busy': '仍有沙盒任务正在运行,请稍后重试。', 'desktop.chat.shellSandboxHint.unsupported': '当前平台不支持沙盒,相关命令需要使用完全访问模式。', 'desktop.chat.shellSandboxHint.unknown': '无法读取沙盒状态,请重新检测。', @@ -241,7 +242,7 @@ export default { 'desktop.chat.permissionModePolicyDescription': '自动放行安全查询,以及授权目录内的文件编辑和已验证的普通文件删除;其他操作按规则审批。', 'desktop.chat.permissionModePolicySandboxDescription': '命令在沙盒内自动执行,可修改授权目录;外部网络、追加目录及本机直接执行另行确认。', 'desktop.chat.permissionModeReadOnlyDescription': '读取文件并运行安全的只读诊断。', - 'desktop.chat.permissionModeReadOnlySandboxDescription': '读取文件;命令在只读沙盒内执行,不能修改本机文件。', + 'desktop.chat.permissionModeReadOnlySandboxDescription': '读取文件;命令按只读策略在沙盒内执行。', 'desktop.chat.permissionModeTitle': '如何审批 Buddy 操作?', 'desktop.chat.chooseEffort': '选择强度', 'desktop.chat.copied': '已复制', diff --git a/apps/buddy/src/modules/prompt-input/components/DesktopPermissionModeSelector.vue b/apps/buddy/src/modules/prompt-input/components/DesktopPermissionModeSelector.vue index 1d4e3e714..6ce6dabf5 100644 --- a/apps/buddy/src/modules/prompt-input/components/DesktopPermissionModeSelector.vue +++ b/apps/buddy/src/modules/prompt-input/components/DesktopPermissionModeSelector.vue @@ -208,7 +208,7 @@ async function confirmSetup() { :show="setupOpen" preset="dialog" type="info" - :title="t('desktop.chat.shellSandboxSetup.title')" + :title="t(availability.action === 'repair' ? 'desktop.chat.shellSandboxSetup.updateTitle' : 'desktop.chat.shellSandboxSetup.title')" :closable="!isSettingUp" :mask-closable="!isSettingUp" :close-on-esc="!isSettingUp" diff --git a/apps/website/src/guide/permissions.md b/apps/website/src/guide/permissions.md index e3d7c9a04..9a0e899dc 100644 --- a/apps/website/src/guide/permissions.md +++ b/apps/website/src/guide/permissions.md @@ -13,7 +13,7 @@ description: "了解 Lexora 的只读、沙箱和完全访问模式,如何处 | 模式 | 主要行为 | 适合什么时候 | | --- | --- | --- | -| 本机只读 | 读取文件,运行允许的只读诊断,不修改本机文件 | 阅读、分析、审查现有材料 | +| 本机只读 | 读取文件,拒绝文件工具写入,命令使用只读策略 | 阅读、分析、审查现有材料 | | 人工审批 | 修改、命令和外部交互需要批准 | 希望逐项确认操作 | | 智能审批 | 按规则放行安全查询和授权范围内的操作,其他操作请求确认 | 在熟悉的目录中持续工作 | | 完全访问 | 命令使用你的本机权限执行,部分工具仍可能需要确认 | 明确了解任务所需的广泛权限时 | @@ -36,7 +36,7 @@ description: "了解 Lexora 的只读、沙箱和完全访问模式,如何处 ## 目录授权与上下文 -为任务或空间选择工作目录后,Lexora 才能围绕这组文件工作。需要读写其他位置时,按界面提示追加授权。 +工作目录确定任务默认的文件位置和可写范围。普通文件读取不需要逐目录授权;写入其他位置时,按界面提示追加授权。策略会限制已知凭据和应用私有数据的访问;Windows 的执行边界见下文。 引用文字并不等于授予其来源目录的全部访问权;伙伴的工具选择、Skill 说明也不会绕过任务的权限上限。 @@ -46,4 +46,14 @@ description: "了解 Lexora 的只读、沙箱和完全访问模式,如何处 如果希望新任务使用固定的默认模式,可以在「设置 → 运行时」调整。先在少量任务中确认行为,再更改默认值。 +## Windows 原生沙箱的边界 + +Windows 原生沙箱优先兼容本机工具,不提供任务间的强隔离,也不能保证任意文件权限下的严格只读或凭据完全隔离: + +- 向所有用户开放写权限的文件,可能在授权目录外被命令修改,包括只读模式下。 +- 敏感目录内具有单独公开读取权限的文件,可能绕过目录的读取限制。 +- 命令先前创建或自行改过权限的文件,在切换只读或撤销目录授权后,可能仍可写入。 + +这些系统权限不代表你授权了相应操作。处理不可信程序或需要严格隔离的任务时,应使用独立的执行环境。 + 了解数据去向和备份方法,继续阅读[数据与备份](./data-and-backup)。 diff --git a/packaging/buddy/release/verify-windows-sandbox.ps1 b/packaging/buddy/release/verify-windows-sandbox.ps1 index f519e1ae2..00c8119c4 100644 --- a/packaging/buddy/release/verify-windows-sandbox.ps1 +++ b/packaging/buddy/release/verify-windows-sandbox.ps1 @@ -84,6 +84,7 @@ catch { Set-Content -LiteralPath $scriptPath -Value $script -Encoding UTF8 $credential = [PSCredential]::new("$env:COMPUTERNAME\$userName", $password) $child = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`"" -WorkingDirectory $directory -Credential $credential -LoadUserProfile -WindowStyle Hidden -PassThru + $null = $child.Handle if (-not $child.WaitForExit($(if ($InstallerExecutable) { 540000 } else { 360000 }))) { throw 'Windows sandbox acceptance timed out.' } if ($child.ExitCode -ne 0) { throw "Windows sandbox acceptance failed: $($child.ExitCode). Report: $log" } if (-not (Test-Path -LiteralPath $log)) { throw 'Windows sandbox acceptance did not produce a report.' } From d9d56b6c54bf6ef8fe620ba0bee91f32fe421063 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 19 Oct 2026 16:51:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(buddy):=20=E9=87=8D=E6=9E=84=20Windows?= =?UTF-8?q?=20=E6=B2=99=E7=9B=92=E5=B9=B6=E7=AE=80=E5=8C=96=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../host/__tests__/shell_sandbox_acl_state.rs | 16 ++++++++++----- .../__tests__/shell_sandbox_filesystem.rs | 20 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs index c3ecc1241..4110254bf 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs +++ b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs @@ -7,6 +7,12 @@ use windows_sys::Win32::Security::{ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::System::SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}; +fn temporary_directory() -> tempfile::TempDir { + let canonical = fs::canonicalize(std::env::temp_dir()).unwrap(); + let path = canonical.to_str().unwrap().strip_prefix(r"\\?\").unwrap(); + tempfile::tempdir_in(path).unwrap() +} + fn cancellation() -> (mpsc::Sender<()>, Cancellation) { super::super::security::require_host(super::super::security::current_process(), false).unwrap(); struct Input(mpsc::Receiver<()>); @@ -68,7 +74,7 @@ fn entries(path: &Path, sid: &Sid, deny: bool) -> usize { #[test] fn obsolete_denials_preserve_allows_and_other_profiles_until_the_last_owner_releases() { - let temporary = tempfile::tempdir().unwrap(); + let temporary = temporary_directory(); let protected = temporary.path().join("protected"); fs::create_dir(&protected).unwrap(); let child = protected.join("existing.txt"); @@ -140,7 +146,7 @@ fn obsolete_denials_preserve_allows_and_other_profiles_until_the_last_owner_rele #[test] fn merged_denials_preserve_previous_roots_and_are_reconciled_by_their_owner() { - let temporary = tempfile::tempdir().unwrap(); + let temporary = temporary_directory(); let old = temporary.path().join("old"); let late = temporary.path().join("late"); fs::create_dir(&old).unwrap(); @@ -213,7 +219,7 @@ fn merged_denials_preserve_previous_roots_and_are_reconciled_by_their_owner() { #[test] fn failed_updates_keep_recovery_intent_and_previous_protection() { - let temporary = tempfile::tempdir().unwrap(); + let temporary = temporary_directory(); let old = temporary.path().join("old"); let new = temporary.path().join("new"); fs::create_dir(&old).unwrap(); @@ -271,7 +277,7 @@ fn failed_updates_keep_recovery_intent_and_previous_protection() { #[test] fn reconciliation_never_changes_a_replacement_object() { - let temporary = tempfile::tempdir().unwrap(); + let temporary = temporary_directory(); let path = temporary.path().join("protected"); fs::create_dir(&path).unwrap(); let grant = filesystem::inspect(path.to_str().unwrap(), Access::DenyRead).unwrap(); @@ -312,7 +318,7 @@ fn reconciliation_never_changes_a_replacement_object() { #[test] fn owned_acl_cleanup_propagates_without_removing_other_principals() { - let temporary = tempfile::tempdir().unwrap(); + let temporary = temporary_directory(); let child = temporary.path().join("existing.txt"); fs::write(&child, "fixture").unwrap(); let root = filesystem::inspect(temporary.path().to_str().unwrap(), Access::Write).unwrap(); diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs b/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs index aa149435f..fff0f3d02 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs +++ b/apps/buddy/native/host/__tests__/shell_sandbox_filesystem.rs @@ -5,6 +5,12 @@ use windows_sys::Win32::Security::GetKernelObjectSecurity; use super::*; +fn temporary_directory() -> tempfile::TempDir { + let canonical = fs::canonicalize(std::env::temp_dir()).unwrap(); + let path = canonical.to_str().unwrap().strip_prefix(r"\\?\").unwrap(); + tempfile::tempdir_in(path).unwrap() +} + fn snapshot(file: &File) -> String { let mut storage = vec![0usize; 16384]; let mut needed = 0; @@ -41,7 +47,7 @@ fn snapshot(file: &File) -> String { #[test] fn temporary_grants_preserve_legacy_explicit_entries_matching_the_parent() { - let directory = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); let parent = open_path(directory.path().to_str().unwrap()).unwrap(); let user = sid_text(&process_user_sid(current_process()).unwrap()).unwrap(); let legacy = format!("D:(A;OICI;FA;;;{user})"); @@ -78,7 +84,7 @@ fn temporary_grants_preserve_legacy_explicit_entries_matching_the_parent() { #[test] fn temporary_grants_preserve_inheritance_control_without_propagating_to_children() { - let directory = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); let root = open_path(directory.path().to_str().unwrap()).unwrap(); let user = sid_text(&process_user_sid(current_process()).unwrap()).unwrap(); let descriptor = SecurityDescriptor::new(&format!("D:PAI(A;OICI;FA;;;{user})")).unwrap(); @@ -114,7 +120,7 @@ fn temporary_grants_preserve_inheritance_control_without_propagating_to_children #[test] fn noninheriting_probe_grants_do_not_propagate_existing_parent_entries() { - let directory = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); let path = directory.path().to_str().unwrap(); let root = open_path(path).unwrap(); let child_path = directory.path().join("child"); @@ -156,8 +162,8 @@ fn pending_cancellation( #[test] fn root_preparation_waits_remain_cancellable_and_do_not_lock_other_roots() { use std::time::{Duration, Instant}; - let directory = tempfile::tempdir().unwrap(); - let other = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); + let other = temporary_directory(); let grant = inspect(directory.path().to_str().unwrap(), Access::Read).unwrap(); let other_grant = inspect(other.path().to_str().unwrap(), Access::Read).unwrap(); let (_input, cancellation) = pending_cancellation(Duration::from_secs(10)); @@ -191,7 +197,7 @@ fn root_preparation_waits_remain_cancellable_and_do_not_lock_other_roots() { #[test] fn read_grants_without_write_dacl_skip_root_updates_and_preserve_strict_grants() { use std::time::Duration; - let directory = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); let path = directory.path().to_str().unwrap(); let root = open_path(path).unwrap(); let original = SecurityDescriptor::new(&snapshot(&root)).unwrap(); @@ -267,7 +273,7 @@ fn read_grants_without_write_dacl_skip_root_updates_and_preserve_strict_grants() fn interrupted_acl_propagation_retries_then_restores_lock_free_reuse() { use sha2::{Digest, Sha256}; use std::time::Duration; - let directory = tempfile::tempdir().unwrap(); + let directory = temporary_directory(); let path = directory.path().to_str().unwrap(); let root = open_path(path).unwrap(); let user = sid_text(&process_user_sid(current_process()).unwrap()).unwrap(); From 3dfb456c2c552db9b9fbe6a55e53e3fe47341e2b Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 19 Oct 2026 16:55:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(buddy):=20=E9=87=8D=E6=9E=84=20Windows?= =?UTF-8?q?=20=E6=B2=99=E7=9B=92=E5=B9=B6=E7=AE=80=E5=8C=96=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs index 4110254bf..3dd407469 100644 --- a/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs +++ b/apps/buddy/native/host/__tests__/shell_sandbox_acl_state.rs @@ -14,7 +14,6 @@ fn temporary_directory() -> tempfile::TempDir { } fn cancellation() -> (mpsc::Sender<()>, Cancellation) { - super::super::security::require_host(super::super::security::current_process(), false).unwrap(); struct Input(mpsc::Receiver<()>); impl Read for Input { fn read(&mut self, _: &mut [u8]) -> io::Result {