Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof createWindowsSandboxPolicy>[0] }) => Promise<void>) {
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<typeof input>[] = [
{ 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)
})
54 changes: 51 additions & 3 deletions apps/buddy/platform/windows/__tests__/sandboxPolicy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>(), stat: new Map<string, string>() }))

vi.mock('../../currentPlatform', async () => {
const { resolveBuddyPlatform } = await import('../../../shared/platform')
return { currentPlatform: resolveBuddyPlatform('win32') }
Expand All @@ -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'
Expand All @@ -37,13 +49,49 @@ 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'])
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.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([
Expand Down
52 changes: 51 additions & 1 deletion apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<SandboxProcessInput>[] = [
{ 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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 25 additions & 10 deletions apps/buddy/service/src/sandbox/backends/windows/createPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,33 @@ type WindowsSandboxPolicyInput = SandboxFilesystemPolicyInput & Pick<SandboxBack

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 {
continue
}
if (!/^[a-z]:\\/i.test(path))
continue
try {
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) {
if (!error || typeof error !== 'object' || !('code' in error) || !['EPERM', 'EACCES'].includes(String(error.code)))
throw error
}
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(';') }
return { grants, path: [...roots].join(';') }
}