219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
/**
|
|
* Tests for the sandbox-policy home: the deployment default (mode +
|
|
* workspaceRoot) the service exposes, and the per-session `sandbox/mode`
|
|
* override kit (fold + write path) both enforcing families read.
|
|
*/
|
|
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve, sep } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SandboxPolicyService, config)
|
|
return ctx
|
|
}
|
|
|
|
function session(id: string, cwd?: string): Session {
|
|
const sessionId = SessionId(id)
|
|
return new Session(sessionId, undefined, {
|
|
version: 0,
|
|
id: sessionId,
|
|
createdAt: 0,
|
|
...cwd === undefined ? {} : { cwd },
|
|
})
|
|
}
|
|
|
|
function agentFor(activeSession: Session): Agent {
|
|
return { session: activeSession } as unknown as Agent
|
|
}
|
|
|
|
async function policySection(ctx: Context, activeSession: Session): Promise<string | undefined> {
|
|
return (await ctx.systemPrompt.assemble({ agent: agentFor(activeSession) }))
|
|
.sections.find(section => section.name === 'sandbox:policy')?.text
|
|
}
|
|
|
|
describe('SandboxPolicyService', () => {
|
|
it('defaults to read-only under the process cwd', async () => {
|
|
const ctx = await mounted()
|
|
expect(ctx.sandboxPolicy.defaultMode).toBe('read-only')
|
|
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd()))
|
|
})
|
|
|
|
it('carries a configured mode and resolves the workspace root absolute', async () => {
|
|
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws/../ws/./sub' })
|
|
expect(ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
|
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
|
|
})
|
|
|
|
it('resolves the deployment policy for an agentless call', async () => {
|
|
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
|
|
expect(ctx.sandboxPolicy.resolve()).toEqual({
|
|
mode: 'workspace-write',
|
|
workspaceRoot: resolve('/fallback'),
|
|
})
|
|
})
|
|
|
|
it('resolves each session mode and cwd together without changing the fallback', async () => {
|
|
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
|
|
const first = session('sess-first', '/projects/first')
|
|
const second = session('sess-second', '/projects/second')
|
|
setSandboxMode(second, 'read-only')
|
|
|
|
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
|
|
mode: 'workspace-write',
|
|
workspaceRoot: resolve('/projects/first'),
|
|
})
|
|
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
|
|
mode: 'read-only',
|
|
workspaceRoot: resolve('/projects/second'),
|
|
})
|
|
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
|
|
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
|
|
expect(ctx.sandboxPolicy.resolve()).toEqual({
|
|
mode: 'workspace-write',
|
|
workspaceRoot: resolve('/fallback'),
|
|
})
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')('resolves a symlink-sensitive session cwd with POSIX component semantics', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-'))
|
|
try {
|
|
const lexical = join(root, 'lexical')
|
|
const physical = join(root, 'physical')
|
|
const child = join(physical, 'child')
|
|
mkdirSync(lexical)
|
|
mkdirSync(child, { recursive: true })
|
|
const link = join(lexical, 'link')
|
|
symlinkSync(child, link, 'dir')
|
|
const cwd = `${link}${sep}..`
|
|
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
|
|
|
|
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
|
|
mode: 'workspace-write',
|
|
workspaceRoot: realpathSync.native(physical),
|
|
})
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('lets an approved mode outrank the session mode while retaining its root', async () => {
|
|
const ctx = await mounted({ workspaceRoot: '/fallback' })
|
|
const active = session('sess-approved', '/projects/approved')
|
|
setSandboxMode(active, 'read-only')
|
|
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
|
|
mode: 'danger-full-access',
|
|
workspaceRoot: resolve('/projects/approved'),
|
|
})
|
|
})
|
|
|
|
it('uses the configured root when a session has no cwd', async () => {
|
|
const ctx = await mounted({ workspaceRoot: '/fallback' })
|
|
expect(ctx.sandboxPolicy.resolve({ session: session('sess-no-cwd') }).workspaceRoot).toBe(resolve('/fallback'))
|
|
})
|
|
|
|
it('rejects a mode outside the closed vocabulary at load', async () => {
|
|
const ctx = new Context()
|
|
// schemastery rejects the union violation when the plugin loads.
|
|
await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow()
|
|
})
|
|
|
|
it('unregisters cleanly from a child fiber (HMR safety)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
const fiber = await ctx.plugin(SandboxPolicyService, {})
|
|
expect(ctx.sandboxPolicy).toBeDefined()
|
|
expect(await policySection(ctx, session('sess-hmr'))).toContain('read-only')
|
|
await fiber.dispose()
|
|
expect(ctx.get('sandboxPolicy')).toBeUndefined()
|
|
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'sandbox:policy')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('sandbox:policy request context', () => {
|
|
async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(SandboxPolicyService, config)
|
|
return ctx
|
|
}
|
|
|
|
it('states the fresh read-only consequences before a tool attempt', async () => {
|
|
const ctx = await promptMounted()
|
|
const text = await policySection(ctx, session('sess-read-only', '/projects/read-only'))
|
|
expect(text).toBe('Current DSH file sandbox policy: read-only. Ordinary file writes, edits, and file-mutating shell effects are denied; required sinks such as `/dev/null` may remain writable. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.')
|
|
})
|
|
|
|
it('states canonical workspace and temporary roots under workspace-write', async () => {
|
|
const ctx = await promptMounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
|
|
const active = session('sess-workspace-write', '/projects/../projects/current')
|
|
const policy = ctx.sandboxPolicy.resolve({ session: active })
|
|
const roots = writableRoots(policy)
|
|
const text = await policySection(ctx, active)
|
|
expect(text).toBe(`Current DSH file sandbox policy: workspace-write. File writes, edits, and file-mutating shell effects are limited to these canonical writable roots: ${roots.map(root => JSON.stringify(root)).join(', ')}. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.`)
|
|
expect(roots[0]).toBe(resolve('/projects/current'))
|
|
expect(roots).toContain(canonicalPath('/tmp'))
|
|
})
|
|
|
|
it('states that danger-full-access adds no DSH file restriction without claiming wider authority', async () => {
|
|
const ctx = await promptMounted({ mode: 'danger-full-access' })
|
|
const text = await policySection(ctx, session('sess-danger', '/projects/current'))
|
|
expect(text).toBe('Current DSH file sandbox policy: danger-full-access. The DSH file sandbox does not restrict file operations. Host OS permissions and other policies still apply. This policy does not govern network or process access.')
|
|
})
|
|
|
|
it('reflects the latest durable switch on the next assembly and stays byte-stable otherwise', async () => {
|
|
const ctx = await promptMounted()
|
|
const active = session('sess-switch', '/projects/current')
|
|
const first = await policySection(ctx, active)
|
|
expect(await policySection(ctx, active)).toBe(first)
|
|
|
|
setSandboxMode(active, 'danger-full-access')
|
|
const danger = await policySection(ctx, active)
|
|
expect(danger).toContain('does not restrict file operations')
|
|
expect(await policySection(ctx, active)).toBe(danger)
|
|
|
|
setSandboxMode(active, 'workspace-write')
|
|
expect(await policySection(ctx, active)).toContain(JSON.stringify(resolve('/projects/current')))
|
|
})
|
|
|
|
it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => {
|
|
const active = session('sess-resume', '/projects/current')
|
|
setSandboxMode(active, 'workspace-write')
|
|
const resumed = new Session(active.id, active.events, active.header)
|
|
const ctx = await promptMounted({ mode: 'read-only' })
|
|
|
|
expect(await policySection(ctx, resumed)).toContain('workspace-write')
|
|
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'sandbox:policy')?.text).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('the sandbox/mode session kit', () => {
|
|
it('SANDBOX_MODES lists every mode for advertisement and validation', () => {
|
|
expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
|
|
})
|
|
|
|
it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
|
|
const session = new Session(SessionId('sess-fold'))
|
|
expect(effectiveSandboxMode(session.events)).toBeUndefined()
|
|
setSandboxMode(session, 'workspace-write')
|
|
setSandboxMode(session, 'read-only')
|
|
expect(effectiveSandboxMode(session.events)).toBe('read-only')
|
|
})
|
|
|
|
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
|
|
const session = new Session(SessionId('sess-write'))
|
|
setSandboxMode(session, 'danger-full-access')
|
|
const modeEvents = session.events.filter(e => e.type === 'sandbox/mode')
|
|
expect(modeEvents).toHaveLength(1)
|
|
expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' })
|
|
})
|
|
})
|