From 16263293ab7c21c1c23f65b1cba052c6439beb21 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 21 Oct 2026 00:11:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(windows):=20=E6=B2=99=E7=9B=92=E5=87=86?= =?UTF-8?q?=E5=A4=87=E6=97=B6=E8=B7=B3=E8=BF=87=E6=97=A0=E6=9D=83=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=9A=84=20PATH=20=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沙盒准备会对 PATH 中的每个目录做 realpath,但只容忍目录不存在(ENOENT、ENOTDIR)。 遇到当前用户无权读取的目录(例如位于其他用户配置目录下的 PATH 条目)会抛出 EPERM, 导致整个沙盒准备失败,第一条命令还没执行就返回“runtime request failed”。 现在对 PATH 条目逐个处理,遇到 EPERM 或 EACCES 的条目直接跳过:这类目录既无法被授权, 也无法被搜索。仅跳过 PATH 条目,shell、系统与搜索目录,以及受保护和敏感路径出错时仍然失败。 新增两个测试:无权读取的 PATH 条目被跳过;无权读取的必要目录仍然失败。 Co-Authored-By: Claude Sonnet 5.5 --- .../windows/__tests__/sandboxPolicy.spec.ts | 15 ++++++++++++++- .../sandbox/backends/windows/createPolicy.ts | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts index 71d51d688..e890fe155 100644 --- a/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts +++ b/apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts @@ -13,7 +13,11 @@ 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, + realpath: async (path: string) => { + if (path === 'D:\\Locked') + throw Object.assign(new Error('EPERM: operation not permitted, realpath'), { code: 'EPERM' }) + return path === 'Z:\\MappedTools' ? '\\\\server\\tools' : path + }, stat: async () => ({ isDirectory: () => true, dev: 1n, ino: 2n }), })) @@ -44,6 +48,15 @@ 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('skips PATH entries the user cannot access instead of failing the whole preparation', async () => { + const policy = await createWindowsSandboxPolicy({ ...input, path: 'D:\\Locked;D:\\Tools' }, new AbortController().signal) + expect(policy.path.split(';')).toEqual(['C:\\PowerShell', 'C:\\Windows\\System32', 'C:\\search', 'D:\\Tools']) + }) + + it('still fails when a required directory cannot be inspected', async () => { + await expect(createWindowsSandboxPolicy({ ...input, searchDirectory: 'D:\\Locked' }, new AbortController().signal)).rejects.toMatchObject({ code: 'EPERM' }) + }) + 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/backends/windows/createPolicy.ts b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts index bdf5332af..bc66f5431 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts @@ -7,6 +7,10 @@ import { createSandboxFilesystemPolicy, existingSandboxPaths, inspectSandboxPath type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick, 'backend' | 'path' | 'searchDirectory'> +function isAccessDenied(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && ['EPERM', 'EACCES'].includes(String(error.code)) +} + export async function createWindowsSandboxPolicy(input: WindowsSandboxPolicyInput, signal: AbortSignal) { const grants = await createSandboxFilesystemPolicy(input, signal) const paths: string[] = [] @@ -18,7 +22,18 @@ export async function createWindowsSandboxPolicy(input: WindowsSandboxPolicyInpu } catch {} } - const localPaths = (await existingSandboxPaths(paths)).filter(path => /^[a-z]:\\/i.test(path)) + const localPaths: string[] = [] + for (const path of paths) { + try { + localPaths.push(...(await existingSandboxPaths([path])).filter(item => /^[a-z]:\\/i.test(item))) + } + catch (error) { + // A PATH entry this user cannot read (for example under another user's profile) can be neither granted nor searched. + // Only PATH entries are skipped; the shell, system and search directories still fail closed. + if (!isAccessDenied(error)) + throw error + } + } 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')) From 996e990cb69cb8a12b000a866c0d25f596261bcc Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Wed, 21 Oct 2026 09:30:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(sandbox):=20=E5=AE=8C=E5=96=84=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E8=AE=BF=E9=97=AE=20PATH=20=E7=9A=84=E5=A4=84?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=9B=9E=E5=BD=92=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sandboxPathPermissions.native.spec.ts | 136 ++++++++++++++++++ .../windows/__tests__/sandboxPolicy.spec.ts | 51 +++++-- .../sandbox/__tests__/shellSandbox.spec.ts | 52 ++++++- .../src/sandbox/backends/srt/createPolicy.ts | 2 +- .../sandbox/backends/windows/createPolicy.ts | 40 +++--- 5 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 apps/buddy/platform/windows/__tests__/sandboxPathPermissions.native.spec.ts 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 e890fe155..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') } @@ -14,11 +16,17 @@ vi.mock('node:path', async (importOriginal) => { }) vi.mock('node:fs/promises', () => ({ realpath: async (path: string) => { - if (path === 'D:\\Locked') - throw Object.assign(new Error('EPERM: operation not permitted, realpath'), { code: 'EPERM' }) + const code = failures.realpath.get(path) + if (code) + throw Object.assign(new Error(code), { code }) return path === 'Z:\\MappedTools' ? '\\\\server\\tools' : path }, - stat: async () => ({ isDirectory: () => true, dev: 1n, ino: 2n }), + 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' @@ -41,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']) @@ -48,13 +61,35 @@ 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('skips PATH entries the user cannot access instead of failing the whole preparation', async () => { - const policy = await createWindowsSandboxPolicy({ ...input, path: 'D:\\Locked;D:\\Tools' }, new AbortController().signal) + 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('still fails when a required directory cannot be inspected', async () => { - await expect(createWindowsSandboxPolicy({ ...input, searchDirectory: 'D:\\Locked' }, new AbortController().signal)).rejects.toMatchObject({ code: 'EPERM' }) + 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 () => { 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 bc66f5431..815640ee8 100644 --- a/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts +++ b/apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts @@ -7,35 +7,35 @@ import { createSandboxFilesystemPolicy, existingSandboxPaths, inspectSandboxPath type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick, 'backend' | 'path' | 'searchDirectory'> -function isAccessDenied(error: unknown): boolean { - return !!error && typeof error === 'object' && 'code' in error && ['EPERM', 'EACCES'].includes(String(error.code)) -} - export async function createWindowsSandboxPolicy(input: WindowsSandboxPolicyInput, signal: AbortSignal) { const grants = await createSandboxFilesystemPolicy(input, signal) - const paths: string[] = [] + const roots = new Set(await existingSandboxPaths([dirname(input.backend.shell), join(input.backend.systemRoot, 'System32'), input.searchDirectory])) + for (const root of roots) + grants.push(await inspectSandboxPath(root, 'read')) + for (const entry of input.path.split(';')) { + signal.throwIfAborted() + let path: string try { - const path = validateWindowsFilePath(entry.replace(/^"|"$/g, '')) - if (/^[a-z]:\\/i.test(path)) - paths.push(path) + path = validateWindowsFilePath(entry.replace(/^"|"$/g, '')) } - catch {} - } - const localPaths: string[] = [] - for (const path of paths) { + catch { + continue + } + if (!/^[a-z]:\\/i.test(path)) + continue try { - localPaths.push(...(await existingSandboxPaths([path])).filter(item => /^[a-z]:\\/i.test(item))) + const [root] = await existingSandboxPaths([path]) + if (!root || !/^[a-z]:\\/i.test(root) || roots.has(root)) + continue + const grant = await inspectSandboxPath(root, 'read') + roots.add(root) + grants.push(grant) } catch (error) { - // A PATH entry this user cannot read (for example under another user's profile) can be neither granted nor searched. - // Only PATH entries are skipped; the shell, system and search directories still fail closed. - if (!isAccessDenied(error)) + if (!error || typeof error !== 'object' || !('code' in error) || !['EPERM', 'EACCES'].includes(String(error.code))) throw error } } - 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(';') } }