276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
/**
|
|
* The windows-acl per-session grant: the DURABLE record (session-log event
|
|
* fold/provision) plus the SERVER-LIFETIME ACE materialization
|
|
* ({@link AclWriteGrant}), exercised through the REAL
|
|
* LocalSandboxProvider.confine() with a real session store. The Win32 surface
|
|
* is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`),
|
|
* so these assertions run in every CI lane that runs sandbox-local's suites;
|
|
* the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows-
|
|
* acl's own tests on win32 hosts.
|
|
*/
|
|
|
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
|
import { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
|
import { sessionTempDir } from '../src/acl-session.ts'
|
|
|
|
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
|
const mockState = vi.hoisted(() => ({
|
|
grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>,
|
|
addFailure: undefined as Error | undefined,
|
|
disposeFailure: undefined as Error | undefined,
|
|
}))
|
|
|
|
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
|
class MockAclWriteGrant {
|
|
readonly writeSid: string
|
|
readonly added: string[] = []
|
|
disposed = false
|
|
constructor(writeSid: string) {
|
|
this.writeSid = writeSid
|
|
mockState.grants.push(this)
|
|
}
|
|
static create(writeSid: string): MockAclWriteGrant {
|
|
return new MockAclWriteGrant(writeSid)
|
|
}
|
|
add(path: string): void {
|
|
if (mockState.addFailure !== undefined) throw mockState.addFailure
|
|
this.added.push(path)
|
|
}
|
|
dispose(): void {
|
|
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
|
|
this.disposed = true
|
|
}
|
|
}
|
|
return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' }
|
|
})
|
|
|
|
/** One provisioned record event, shaped like the live log's envelope. */
|
|
function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent {
|
|
return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record }
|
|
}
|
|
|
|
async function setup() {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(LocalSandboxProvider, {})
|
|
const sandbox = ctx.sandbox as LocalSandboxProvider
|
|
sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }
|
|
return { ctx, sandbox, fiber }
|
|
}
|
|
|
|
/** A workspace root the policy, the record, and the session cwd all share. */
|
|
function workspaceRoot(): string {
|
|
return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-'))
|
|
}
|
|
|
|
describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
|
const scratch: string[] = []
|
|
|
|
beforeEach(() => {
|
|
mockState.grants = []
|
|
mockState.addFailure = undefined
|
|
mockState.disposeFailure = undefined
|
|
})
|
|
|
|
const cleanup = () => {
|
|
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
|
|
it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => {
|
|
try {
|
|
const { ctx, sandbox, fiber } = await setup()
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
const privateTemp = sessionTempDir('sess-1')
|
|
scratch.push(privateTemp)
|
|
const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } })
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' }
|
|
|
|
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
|
expect(confined.argv).toEqual([
|
|
'node', 'windows-acl-runner.js',
|
|
'--workspace', ws,
|
|
'--temp', privateTemp,
|
|
'--mode', 'workspace-write',
|
|
'--write-sid', 'S-1-4-42-42',
|
|
'--',
|
|
'pwsh', '/Command', 'x',
|
|
])
|
|
expect(mockState.grants).toHaveLength(1)
|
|
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false })
|
|
expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created
|
|
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
|
|
|
// Reuse: the SECOND confine is the map hit — no new grant, no new event.
|
|
const second = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
|
expect(second.argv).toEqual(confined.argv)
|
|
expect(mockState.grants).toHaveLength(1)
|
|
expect(session.events).toHaveLength(1)
|
|
|
|
// Provider dispose revokes the standing grant.
|
|
await fiber.dispose()
|
|
expect(mockState.grants[0]!.disposed).toBe(true)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => {
|
|
try {
|
|
const { ctx, sandbox } = await setup()
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
const privateTemp = sessionTempDir('sess-ro')
|
|
scratch.push(privateTemp)
|
|
const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } })
|
|
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' }
|
|
|
|
const confined = sandbox.confine(['true'], policy)
|
|
expect(confined.argv).toEqual([
|
|
'node', 'windows-acl-runner.js',
|
|
'--workspace', ws,
|
|
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
|
|
'--mode', 'read-only',
|
|
'--write-sid', 'S-1-4-42-42',
|
|
'--',
|
|
'true',
|
|
])
|
|
expect(mockState.grants).toHaveLength(0)
|
|
expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only
|
|
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => {
|
|
try {
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') }
|
|
scratch.push(record.tempDir)
|
|
|
|
const first = await setup()
|
|
const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } })
|
|
// The constructor appends the `session/end-seed` marker, so the log is
|
|
// the seed plus that marker — exactly one acl record among them.
|
|
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
|
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' }
|
|
const confined = first.sandbox.confine(['true'], policy)
|
|
expect(confined.argv).toContain('S-1-4-77-1')
|
|
expect(mockState.grants).toHaveLength(1)
|
|
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] })
|
|
// Replay IS the state: the seeded record satisfies the fold, nothing appended.
|
|
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
|
expect(session.events).toHaveLength(2)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => {
|
|
try {
|
|
const { ctx, sandbox } = await setup()
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') }
|
|
ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } })
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' }
|
|
expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/)
|
|
expect(mockState.grants).toHaveLength(0)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('fails loud without the session store, and when the policy names a session the store does not hold', async () => {
|
|
try {
|
|
const bare = new Context()
|
|
await bare.plugin(LocalSandboxProvider, {})
|
|
const sandbox = bare.sandbox as LocalSandboxProvider
|
|
sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }
|
|
const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' }
|
|
expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/)
|
|
|
|
const { sandbox: withStore } = await setup()
|
|
expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => {
|
|
try {
|
|
const { ctx, sandbox } = await setup()
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } })
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' }
|
|
|
|
// add() throws on the FIRST path: the cleanup dispose() runs and the
|
|
// original error propagates unchanged.
|
|
mockState.addFailure = new Error('grant exploded')
|
|
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
|
|
expect(mockState.grants).toHaveLength(1)
|
|
expect(mockState.grants[0]!.disposed).toBe(true)
|
|
|
|
// add() AND dispose() both throw: both surface as an AggregateError.
|
|
mockState.grants = []
|
|
mockState.addFailure = new Error('grant exploded again')
|
|
mockState.disposeFailure = new Error('cleanup exploded')
|
|
expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => {
|
|
try {
|
|
const { sandbox, fiber } = await setup()
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
|
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
|
expect(confined.argv).toEqual([
|
|
'node', 'windows-acl-runner.js',
|
|
'--workspace', '/ws',
|
|
'--temp', tmpdir(),
|
|
'--mode', 'workspace-write',
|
|
'--',
|
|
'pwsh', '/Command', 'x',
|
|
])
|
|
expect(mockState.grants).toHaveLength(0)
|
|
// Disposing a provider with no grants is a no-op (the empty-map guard).
|
|
await fiber.dispose()
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
|
try {
|
|
const { ctx, sandbox, fiber } = await setup()
|
|
const ws = workspaceRoot()
|
|
scratch.push(ws)
|
|
ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } })
|
|
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' }
|
|
sandbox.confine(['true'], policy)
|
|
expect(mockState.grants).toHaveLength(1)
|
|
|
|
mockState.disposeFailure = new Error('revoke exploded')
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
|
await fiber.dispose()
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure'))
|
|
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
})
|