Files
deepseek-harness/packages/sandbox/sandbox-policy/tests/policy.spec.ts
T
kingwl 99f5fab7bc policy: scope seed-boundary slicing to delegation children
Review fix (ds-review-bot on #623): the unconditional slice regressed the
public SessionStore.fork path — a generic fork child gets seedLength but no
policy baseline, so slicing discarded its seed-carried sandbox/approval
switches and silently widened it to the deployment defaults (a forked
read-only/never parent produced a workspace-write/ask child).

overrideOf now branches on baseline presence: with a header baseline (a
delegation child) the fold covers only own post-seed switches — the
baseline captured from the parent's FULL log subsumes seed history; without
one, the whole log — seeded switches ARE the replayed inherited truth. The
permission preset fold scopes the same way. Red-first: generic-fork
seed-carried override tests in both policy suites.
2026-07-26 23:06:15 +08:00

222 lines
9.6 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 { Session, SessionId } from '@deepseek-ai/dsh-session'
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
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 },
})
}
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.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()
const fiber = await ctx.plugin(SandboxPolicyService, {})
expect(ctx.sandboxPolicy).toBeDefined()
await fiber.dispose()
expect(ctx.get('sandboxPolicy')).toBeUndefined()
})
})
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' })
})
})
describe('delegation inheritance (overrideOf over the header baseline)', () => {
/** A session whose header carries the delegation-inheritance baseline. */
function inheritedSession(id: string, meta: { sandboxMode?: string; seedLength?: number } = {}): Session {
const sessionId = SessionId(id)
return new Session(sessionId, undefined, {
version: 0,
id: sessionId,
createdAt: 0,
...meta.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode },
...meta.seedLength === undefined ? {} : { seedLength: meta.seedLength },
})
}
it('overrideOf folds the session log and never falls back to the deployment default', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const parent = session('sess-inherit-parent')
setSandboxMode(parent, 'workspace-write')
setSandboxMode(parent, 'read-only')
expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only')
// undefined, NOT the deployment default — a child whose header froze the
// default would stop following the LIVE default across resumes.
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
})
it('overrideOf reads the header baseline when the log has no own switch', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const child = inheritedSession('sess-inherit-baseline', { sandboxMode: 'read-only' })
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
// resolve() consumes the same chain, so enforcement sees the baseline.
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only')
})
it('a seed-carried stale switch loses to the baseline; an OWN later switch wins over it', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
// The fork seed carried the parent's OLD workspace-write switch (one
// event, so seedLength 1); the delegation-time baseline is read-only.
const child = inheritedSession('sess-inherit-slice', { sandboxMode: 'read-only', seedLength: 1 })
setSandboxMode(child, 'workspace-write')
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
// A switch the child makes ITSELF (after the seed boundary) outranks it.
setSandboxMode(child, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('danger-full-access')
})
it('rejects a header baseline outside the closed mode vocabulary (durable boundary)', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-inherit-invalid', { sandboxMode: 'yolo' })
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/)
})
it('rejects a malformed baseline even when an own switch would win (validation is unconditional)', async () => {
const ctx = await mounted()
const child = inheritedSession('sess-inherit-invalid-own', { sandboxMode: 'yolo' })
// A corrupt or foreign durable record must fail loud on EVERY read — an
// own override must not paper over the malformed header.
setSandboxMode(child, 'read-only')
expect(() => ctx.sandboxPolicy.overrideOf(child)).toThrow(/sandboxMode/)
})
it('a generic SessionStore.fork child (seedLength, NO baseline) keeps its seed-carried override', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
// The public fork path sets seedLength but captures no delegation
// baseline; the seed boundary must not discard the replayed policy state
// it exists to subsume — with nothing to subsume it, seeded switches ARE
// the child's inherited truth.
const child = inheritedSession('sess-generic-fork', { seedLength: 1 })
setSandboxMode(child, 'read-only')
expect(ctx.sandboxPolicy.overrideOf(child)).toBe('read-only')
expect(ctx.sandboxPolicy.resolve({ session: child }).mode).toBe('read-only')
})
})