diff --git a/apps/buddy/platform/windows/__tests__/sandboxPathPermissions.native.spec.ts b/apps/buddy/platform/windows/__tests__/sandboxPathPermissions.native.spec.ts new file mode 100644 index 000000000..c71d02c5b --- /dev/null +++ b/apps/buddy/platform/windows/__tests__/sandboxPathPermissions.native.spec.ts @@ -0,0 +1,136 @@ +import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, realpath, rename, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { createWindowsSandboxPolicy } from '../../../service/src/sandbox/backends/windows/createPolicy' +import { inspectSandboxPath } from '../../../service/src/sandbox/sandboxFilesystemPolicy' +import { SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers' + +const execute = promisify(execFile) + +async function powershell(root: string, script: string) { + const executable = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + return execute(executable, [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(`$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';$env:PSModulePath=Join-Path $PSHOME 'Modules';$root=$env:LEXORA_ACL_TEST_ROOT;${script}`, 'utf16le').toString('base64'), + ], { windowsHide: true, timeout: 30_000, env: { ...process.env, LEXORA_ACL_TEST_ROOT: root } }) +} + +async function withDeniedPath(run: (fixture: { root: string, denied: string, input: Parameters[0] }) => Promise) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'buddy-path-acl-'))) + const parent = join(root, 'denied') + const denied = join(parent, 'child') + try { + await Promise.all(['workspace', 'home', 'shell', 'system/System32', 'search', 'normal-a', 'normal-b', 'denied/child', 'denied/System32'].map(path => mkdir(join(root, path), { recursive: true }))) + await powershell(root, ` + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + if([Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'An ordinary user token is required'} + $paths=@('denied','denied\\child','denied\\System32') + $saved=@($paths|ForEach-Object {@{relative=$_;sddl=(Get-Acl -LiteralPath (Join-Path $root $_)).Sddl}}) + ConvertTo-Json -InputObject $saved|Set-Content -LiteralPath (Join-Path $root 'acl.json') -Encoding utf8 + $path=Join-Path $root 'denied' + $acl=Get-Acl -LiteralPath $path + $rule=[Security.AccessControl.FileSystemAccessRule]::new($identity.User,[Security.AccessControl.FileSystemRights]::ReadAndExecute,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Deny) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $path -AclObject $acl + `) + expect(await realpath(parent)).toBe(parent) + await expect(realpath(denied)).rejects.toMatchObject({ code: expect.stringMatching(/^(?:EPERM|EACCES)$/) }) + await expect(realpath(join(parent, 'System32'))).rejects.toMatchObject({ code: expect.stringMatching(/^(?:EPERM|EACCES)$/) }) + const workspace = join(root, 'workspace') + await run({ + root, + denied, + input: { + roots: [workspace], + workspaceRoots: [workspace], + resourceReadRoots: [], + additionalDirectories: [], + cwd: workspace, + home: join(root, 'home'), + protectedRoots: [], + sensitiveRoots: [], + readOnly: false, + backend: { + kind: SHELL_SANDBOX_BACKEND.Windows, + executable: join(root, 'unused-sandbox.exe'), + shell: join(root, 'shell', 'pwsh.exe'), + systemRoot: join(root, 'system'), + protectionScope: join(root, 'home'), + }, + searchDirectory: join(root, 'search'), + path: [join(root, 'normal-a'), denied, join(root, 'normal-b')].join(';'), + }, + }) + } + finally { + const saved = await readFile(join(root, 'acl.json'), 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') + throw error + return undefined + }) + if (saved) { + await powershell(root, ` + $saved=Get-Content -Raw -LiteralPath (Join-Path $root 'acl.json')|ConvertFrom-Json + $acl=[Security.AccessControl.DirectorySecurity]::new() + $acl.SetSecurityDescriptorSddlForm($saved[0].sddl,[Security.AccessControl.AccessControlSections]::Access) + Set-Acl -LiteralPath (Join-Path $root 'denied') -AclObject $acl + foreach($entry in $saved){ + if((Get-Acl -LiteralPath (Join-Path $root $entry.relative)).Sddl -cne $entry.sddl){throw 'Fixture SDDL was not restored'} + } + `) + expect(await realpath(denied)).toBe(denied) + } + await rm(root, { recursive: true, force: true }) + } +} + +describe.skipIf(process.platform !== 'win32')('windows sandbox policy with real ACL denial', () => { + it('omits an inaccessible optional PATH child while retaining readable tools and their identities', async () => { + await withDeniedPath(async ({ root, denied, input }) => { + const policy = await createWindowsSandboxPolicy(input, new AbortController().signal) + const expected = ['shell', 'system/System32', 'search', 'normal-a', 'normal-b'].map(path => join(root, path)) + expect(policy.path.split(';')).toEqual(expected) + expect(policy.grants.some(grant => grant.path === denied)).toBe(false) + expect(policy.grants.filter(grant => grant.access === 'read')).toEqual(await Promise.all(expected.map(path => inspectSandboxPath(path, 'read')))) + expect(policy.grants.filter(grant => grant.access === 'write').map(grant => grant.path)).toEqual(input.roots) + }) + }, 60_000) + + it('fails closed for inaccessible shell, System32, search, workspace, protected and sensitive roots', async () => { + await withDeniedPath(async ({ root, denied, input }) => { + const cases: Partial[] = [ + { backend: { ...input.backend, shell: join(denied, 'pwsh.exe') } }, + { backend: { ...input.backend, systemRoot: join(root, 'denied') } }, + { searchDirectory: denied }, + { roots: [denied], workspaceRoots: [denied], cwd: denied }, + { protectedRoots: [denied] }, + { sensitiveRoots: [denied] }, + ] + for (const changes of cases) + await expect(createWindowsSandboxPolicy({ ...input, ...changes }, new AbortController().signal)).rejects.toMatchObject({ code: expect.stringMatching(/^(?:EPERM|EACCES)$/) }) + }) + }, 60_000) + + it('does not swallow a replaced explicit grant identity when PATH also contains an inaccessible child', async () => { + await withDeniedPath(async ({ root, input }) => { + const directory = join(root, 'additional') + await mkdir(directory) + const grant = await inspectSandboxPath(directory, 'read') + await rename(directory, join(root, 'original-additional')) + await mkdir(directory) + expect((await inspectSandboxPath(directory, 'read')).inode).not.toBe(grant.inode) + await expect(createWindowsSandboxPolicy({ + ...input, + additionalDirectories: [{ path: grant.path, access: 'read', device: grant.device, inode: grant.inode }], + }, new AbortController().signal)).rejects.toMatchObject({ code: 'SANDBOX_DIRECTORY_CHANGED' }) + }) + }, 60_000) +}) diff --git a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts index 71d51d688..7242bb504 100644 --- a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts +++ b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts @@ -1,9 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, 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' +const failures = vi.hoisted(() => ({ realpath: new Map(), stat: new Map() })) + vi.mock('../../currentPlatform', async () => { const { resolveBuddyPlatform } = await import('../../../shared/platform') return { currentPlatform: resolveBuddyPlatform('win32') } @@ -13,8 +15,18 @@ vi.mock('node:path', async (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 }), + realpath: async (path: string) => { + const code = failures.realpath.get(path) + if (code) + throw Object.assign(new Error(code), { code }) + return path === 'Z:\\MappedTools' ? '\\\\server\\tools' : path + }, + stat: async (path: string) => { + const code = failures.stat.get(path) + if (code) + throw Object.assign(new Error(code), { code }) + return { isDirectory: () => true, dev: 1n, ino: 2n } + }, })) const home = 'C:\\Users\\Fixture' @@ -37,6 +49,11 @@ const input = { } describe('windows sandbox policy assembly', () => { + beforeEach(() => { + failures.realpath.clear() + failures.stat.clear() + }) + 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']) @@ -44,6 +61,37 @@ describe('windows sandbox policy assembly', () => { expect(sandboxCommandSchema.safeParse({ command: input.command, requestId: input.requestId, cwd: '\\\\server\\tools', roots: ['\\\\server\\tools'], readOnly: false }).success).toBe(false) }) + it.each([ + ['realpath', 'EPERM'], + ['realpath', 'EACCES'], + ['stat', 'EPERM'], + ['stat', 'EACCES'], + ] as const)('skips inaccessible optional PATH entries during %s (%s)', async (operation, code) => { + failures[operation].set('D:\\Locked', code) + const policy = await createWindowsSandboxPolicy({ ...input, path: 'D:\\Locked;D:\\Tools;D:\\Tools' }, 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(policy.grants.some(grant => grant.path === 'D:\\Locked')).toBe(false) + }) + + it.each(['realpath', 'stat'] as const)('retains required directory failures during %s', async (operation) => { + const required = ['C:\\workspace', 'C:\\PowerShell', 'C:\\Windows\\System32', 'C:\\search', 'D:\\Roaming\\gcloud', 'C:\\protected'] + for (const path of required) { + failures[operation].set(path, 'EPERM') + await expect(createWindowsSandboxPolicy({ ...input, path: 'D:\\Tools', protectedRoots: ['C:\\protected'] }, new AbortController().signal)) + .rejects + .toMatchObject({ code: 'EPERM' }) + failures[operation].clear() + } + }) + + it.each(['realpath', 'stat'] as const)('does not swallow unexpected PATH IO errors during %s', async (operation) => { + failures[operation].set('D:\\Broken', 'EIO') + await expect(createWindowsSandboxPolicy({ ...input, path: 'D:\\Broken;D:\\Tools' }, new AbortController().signal)) + .rejects + .toMatchObject({ code: 'EIO' }) + }) + 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([ diff --git a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts index 2a2d1e99f..c5a12fd5c 100644 --- a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts +++ b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts @@ -1,7 +1,7 @@ import type { SandboxProcessInput } from '../../../../shared/permissions/shellSandbox' import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' -import { access, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { createServer as createUnixServer } from 'node:net' import { homedir, tmpdir } from 'node:os' @@ -108,6 +108,56 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => { expect(JSON.stringify(events)).not.toMatch(/synthetic-secret|example-allowed|printf/) }, 15_000) + it.skipIf(process.getuid?.() === 0)('executes a PATH tool past an inaccessible optional directory without expanding writes', async () => { + const blocked = join(directory, 'blocked') + const inaccessible = join(blocked, 'bin') + const tools = join(directory, 'tools') + await mkdir(inaccessible, { recursive: true }) + await mkdir(tools) + const tool = join(tools, 'lexora-path-probe') + await writeFile(tool, '#!/bin/sh\nprintf PATH_TOOL_OK\n') + await chmod(tool, 0o755) + try { + await chmod(blocked, 0) + await expect(realpath(inaccessible)).rejects.toMatchObject({ code: 'EACCES' }) + const { result, output } = await execute(`set -e; lexora-path-probe; printf created > path-result; ! sh -c ${quote(`printf changed > ${quote(join(outside, 'preserved'))}`)}`, {}, { + path: `${inaccessible}:${tools}:${input.path}`, + }) + expect(result, output).toEqual({ ok: true, exitCode: 0 }) + expect(output).toContain('PATH_TOOL_OK') + expect(await readFile(join(workspace, 'path-result'), 'utf8')).toBe('created') + expect(await readFile(join(outside, 'preserved'), 'utf8')).toBe('outside-preserved') + } + finally { + await chmod(blocked, 0o700) + } + }, 15_000) + + it.skipIf(process.getuid?.() === 0)('rejects inaccessible required roots before the shell command can run', async () => { + const blocked = join(directory, 'blocked') + const inaccessible = join(blocked, 'required') + await mkdir(inaccessible, { recursive: true }) + try { + await chmod(blocked, 0) + await expect(realpath(inaccessible)).rejects.toMatchObject({ code: 'EACCES' }) + const required: Partial[] = [ + { cwd: inaccessible, roots: [inaccessible], workspaceRoots: [inaccessible] }, + { protectedRoots: [inaccessible] }, + { sensitiveRoots: [inaccessible] }, + { searchDirectory: inaccessible }, + ] + for (const overrides of required) { + const { result, output } = await execute('printf must-not-run > path-forbidden; printf unexpected-command', {}, overrides) + expect(result).toMatchObject({ ok: false }) + expect(output).not.toContain('unexpected-command') + await expect(access(join(workspace, 'path-forbidden'))).rejects.toThrow() + } + } + finally { + await chmod(blocked, 0o700) + } + }, 15_000) + it('recovers the native SRT command domain when final service persistence is lost', async () => { const identities: string[] = [] const receiptDirectory = join(directory, 'receipts') diff --git a/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts index 2f4d7ecdf..ad89631cf 100644 --- a/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/srt/createPolicy.ts @@ -14,7 +14,7 @@ export async function createSandboxPolicy(input: SrtSandboxInput, signal: AbortS const { rg, srtRoot } = dependencies const system = dependencies.platform === OPERATING_SYSTEM.Linux ? await createLinuxSystemPolicy(dependencies) : createMacosSystemPolicy() const policy = await createSandboxFilesystemPolicy(input, signal) - const searchPaths = await existingSandboxPaths(input.path.split(delimiter).filter(path => path.startsWith('/'))) + const searchPaths = input.path.split(delimiter).filter(path => path.startsWith('/')) 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 { diff --git a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts index bdf5332af..815640ee8 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts @@ -9,18 +9,33 @@ type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick /^[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(';') } + return { grants, path: [...roots].join(';') } }