| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- /**
- * Delegation policy through child session events appended before publication:
- * the parent's Auto identity and sandbox override plus the pinned
- * `approval/policy: never`.
- */
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
- import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { Context } from '@deepseek-ai/cordis'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
- import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
- import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
- import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
- import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
- import ApprovalService from '@deepseek-ai/dsh-user-approval'
- import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
- import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
- import { startInProcessRun } from '../src/index.ts'
- type Script = ConstructorParameters<typeof MockAdapter>[0]
- const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
- const contexts: Context[] = []
- let workspace: string
- beforeEach(async () => {
- workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
- })
- afterEach(async () => {
- for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
- await rm(workspace, { recursive: true, force: true })
- })
- async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
- const ctx = new Context()
- contexts.push(ctx)
- await mountAgentLoopTestDependencies(ctx)
- await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
- await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
- await ctx.plugin(ToolFs)
- await ctx.plugin(ApprovalService)
- await ctx.plugin(AgentLoop, { agents: [] })
- ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
- const parent = await ctx.agentLoop.create(
- SessionId('parent'),
- { provider: 'mock', model: 'mock' },
- { cwd: workspace },
- )
- return { ctx, parent }
- }
- function spawnRequest(parent: Agent) {
- return {
- label: 'child task',
- prompt: [{ type: 'text' as const, text: 'child task' }],
- parent,
- signal: new AbortController().signal,
- descriptor: snapshotSubagentDescriptor({
- mode: 'one-shot',
- provider: 'spawn',
- label: 'child task',
- }),
- }
- }
- function toolResultTexts(agent: Agent): string[] {
- return agent.session.snapshotEvents()
- .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
- .map(event => event.data.message.content
- .flatMap(block => block.content)
- .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
- .map(block => block.text)
- .join(''))
- }
- describe('in-process policy inheritance', () => {
- it.each(['auto', 'danger-full-access'] as const)(
- 'records the parent %s identity before publishing a DSH in-process child',
- async (preset) => {
- const { ctx, parent } = await setupWalled([textResponse('child done')])
- parent.session.append('permission/preset', { preset })
- setSandboxMode(parent.session, 'danger-full-access')
- ctx.provide('permissionPresets', {
- current: (session: Session) => session === parent.session ? preset : 'custom',
- } as never)
- const run = await startInProcessRun(spawnRequest(parent), {})
- try {
- await run.result
- const child = run.localAgent as Agent
- expect(child.session.snapshotEvents().slice(0, 3)).toMatchObject([
- { type: 'sandbox/mode', seq: 0, data: { mode: 'danger-full-access', source: 'delegation' } },
- { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
- { type: 'permission/preset', seq: 2, data: { preset } },
- ])
- } finally {
- await run.dispose()
- }
- },
- )
- it.each([
- { seedPreset: 'auto', preset: 'danger-full-access' },
- { seedPreset: 'danger-full-access', preset: 'auto' },
- ] as const)('captures $preset before child creation and overrides the $seedPreset fork prefix', async ({ seedPreset, preset }) => {
- const { ctx, parent } = await setupWalled([textResponse('child done')])
- parent.session.append('permission/preset', { preset: seedPreset })
- setSandboxMode(parent.session, 'danger-full-access')
- const seed = parent.session.snapshotEvents()
- parent.session.append('permission/preset', { preset })
- let currentPreset: 'auto' | 'danger-full-access' = preset
- ctx.provide('permissionPresets', {
- current: (session: Session) => session === parent.session ? currentPreset : 'custom',
- } as never)
- const starting = startInProcessRun(spawnRequest(parent), { seed })
- currentPreset = seedPreset
- parent.session.append('permission/preset', { preset: seedPreset })
- const run = await starting
- try {
- await run.result
- const child = run.localAgent as Agent
- expect(child.session.snapshotEvents().filter(event => event.type === 'permission/preset')).toMatchObject([
- { data: { preset: seedPreset } },
- { data: { preset } },
- ])
- } finally {
- await run.dispose()
- }
- })
- it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
- const script: Script = []
- const { ctx, parent } = await setupWalled(script)
- const blocked = join(workspace, 'spawn-blocked.txt')
- setSandboxMode(parent.session, 'read-only')
- // No parent approval override: the child pin must not depend on one.
- expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
- const parentLogLength = parent.session.snapshotEvents().length
- script.push(
- toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
- textResponse('child done'),
- )
- const run = await startInProcessRun(spawnRequest(parent), {})
- try {
- const result = await run.result
- const child = run.localAgent as Agent
- await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
- expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
- expect(result.stopReason).toBe('completed')
- expect(child.session.snapshotEvents().slice(0, 2)).toMatchObject([
- { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
- { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
- ])
- expect(child.session.firstLiveSeq).toBe(0)
- expect(child.session.header.isSeeded).toBe(false)
- expect(child.session.inheritedEventCount).toBe(0)
- expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
- expect(ctx.approval.overrideOf(child.session)).toBe('never')
- const request = child.session.snapshotEvents().find(
- (event): event is SessionEvent<'request/header'> => event.type === 'request/header',
- )
- const systemNode = child.session.snapshotEvents().find(
- (event): event is SessionEvent<'system/message'> => event.type === 'system/message',
- )
- const runtimeContext = child.session.snapshotEvents().find(
- (event): event is SessionEvent<'user/message'> => event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
- )
- if (request === undefined || systemNode === undefined || runtimeContext === undefined) {
- throw new Error('child request lacks its system node or runtime policy context')
- }
- expect(systemNode.seq).toBeLessThan(runtimeContext.seq)
- expect(runtimeContext.seq).toBeLessThan(request.seq)
- const contextText = runtimeContext.data.content
- .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
- .map(block => block.text)
- .join('\n')
- expect(contextText).toContain('Current DSH file policy: read-only')
- expect(contextText).toContain('Approval prompts are disabled')
- // The statement rides runtime context; the system node (surface node 0) stays uniform.
- expect(contextText).toContain('You are a delegated subagent')
- const systemHead = child.session.deriveMessages()[0]
- if (systemHead?.role !== 'system') throw new Error('child surface node 0 is not a system message')
- expect(child.session.surface.nodes[0]).toBe(systemNode.seq)
- const systemText = systemHead.content
- .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
- .map(block => block.text)
- .join('\n')
- expect(systemText).not.toContain('Approval prompts are disabled')
- expect(systemText).not.toContain('You are a delegated subagent')
- expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength)
- } finally {
- await run.dispose()
- }
- })
- it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
- const script: Script = []
- const { ctx, parent } = await setupWalled(script)
- const blocked = join(workspace, 'fork-blocked.txt')
- setSandboxMode(parent.session, 'workspace-write')
- const seed = parent.session.snapshotEvents()
- setSandboxMode(parent.session, 'read-only')
- script.push(
- toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
- textResponse('child done'),
- )
- const run = await startInProcessRun(spawnRequest(parent), { seed })
- try {
- await run.result
- const child = run.localAgent as Agent
- expect(child.session.header.isSeeded).toBe(true)
- expect(child.session.inheritedEventCount).toBe(1)
- expect(child.session.firstLiveSeq).toBe(seed.length)
- // seq 1 is the constructor's end-seed marker.
- expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([
- { seq: 0, data: { mode: 'workspace-write' } },
- { seq: 2, data: { mode: 'read-only', source: 'delegation' } },
- ])
- await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
- expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
- setSandboxMode(child.session, 'danger-full-access')
- expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
- } finally {
- await run.dispose()
- }
- })
- it('captures policy at delegation before asynchronous child creation', async () => {
- const script: Script = [textResponse('child done')]
- const { ctx, parent } = await setupWalled(script)
- setSandboxMode(parent.session, 'read-only')
- const starting = startInProcessRun(spawnRequest(parent), {})
- setSandboxMode(parent.session, 'danger-full-access')
- const run = await starting
- try {
- await run.result
- const child = run.localAgent as Agent
- expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
- expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
- } finally {
- await run.dispose()
- }
- })
- it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
- const script: Script = []
- const { parent } = await setupWalled(script)
- const allowed = join(workspace, 'default-allowed.txt')
- script.push(
- toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
- textResponse('child done'),
- )
- const run = await startInProcessRun(spawnRequest(parent), {})
- try {
- await run.result
- const child = run.localAgent as Agent
- expect(await readFile(allowed, 'utf8')).toBe('fine')
- expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false)
- expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([
- { seq: 0, data: { policy: 'never', source: 'delegation' } },
- ])
- expect(child.session.firstLiveSeq).toBe(0)
- } finally {
- await run.dispose()
- }
- })
- it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
- const script: Script = []
- const { ctx, parent } = await setupWalled(script)
- // A granting answerer proves the pin resolves before any answerer runs.
- let consulted = false
- ctx.on('approval/request', () => {
- consulted = true
- return Promise.resolve('allowed-once' as const)
- })
- const blocked = join(workspace, 'escalation-blocked.txt')
- setSandboxMode(parent.session, 'read-only')
- script.push(
- toolCallResponse('write', 'write', {
- file_path: blocked,
- content: 'escaped',
- sandbox_permissions: 'workspace-write',
- justification: 'test escalation from a delegated child',
- }),
- textResponse('child done'),
- )
- const run = await startInProcessRun(spawnRequest(parent), {})
- try {
- await run.result
- const child = run.localAgent as Agent
- await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
- expect(consulted).toBe(false)
- expect(toolResultTexts(child).join('\n'))
- .toContain('the user rejected escalating this operation to "workspace-write"')
- const asked = child.session.snapshotEvents().find(
- (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
- )
- const decided = child.session.snapshotEvents().find(
- (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
- )
- expect(asked?.data.toolName).toBe('write')
- expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
- } finally {
- await run.dispose()
- }
- })
- })
|