Files
deepseek-harness/packages/sandbox/sandbox-policy/tests/policy.spec.ts
kingwl 6fa2377e34 subagent: capture overrides at delegation; stamp ahead of prompt vetoes
Review fixes (ds-review-bot on #623):

- Capture-at-delegation: the driver now reads overrideOf(parent.session)
  for both knobs synchronously before its first await, and the prompt-submit
  listener stamps those captured values — a parent switch racing the child's
  asynchronous creation belongs to the parent's future, not the child. The
  inheritOverride(parent, child) service method is split into its two halves
  (overrideOf / stampOverride) accordingly.
- Veto safety: the one-shot prompt-submit listener registers with
  prepend: true, so a veto-capable listener (a denying UserPromptSubmit
  hook) cannot close the child's first turn without the durable stamp.

Both regressions are pinned red-first in inheritance.spec.ts: the
delegation-vs-late-switch race (delegate tool flips the caller wider while
the creation transaction is pending) and a blocking prompt-submit listener
(stamp survives a promptless first turn). Service contract tests renamed to
the split API; READMEs and the bilingual Agent Note updated.
2026-07-25 10:51:25 +08:00

184 lines
7.4 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('resolves a symlink-sensitive session cwd with filesystem 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, process.platform === 'win32' ? 'junction' : '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 + stampOverride)', () => {
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
it('overrideOf folds to the LAST override 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 stamped with the
// default would stop following the LIVE default across resumes.
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
})
it('stampOverride appends the captured mode through the canonical write path', async () => {
const ctx = await mounted()
const child = session('sess-inherit-child')
ctx.sandboxPolicy.stampOverride(child, 'read-only')
const stamped = modeEvents(child)
expect(stamped).toHaveLength(1)
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
})
it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => {
const ctx = await mounted()
const child = session('sess-inherit-dedup-child')
// A fork seed can already carry the parent's switch; stamping again would
// append a redundant event on every delegation.
setSandboxMode(child, 'read-only')
ctx.sandboxPolicy.stampOverride(child, 'read-only')
expect(modeEvents(child)).toHaveLength(1)
})
})