inheritance.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. /** Policy inheritance through child session events appended before publication. */
  2. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  3. import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { Context } from 'cordis'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  10. import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
  11. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  12. import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
  13. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  14. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  15. import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
  16. import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  17. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  18. import { startInProcessRun } from '../src/index.ts'
  19. type Script = ConstructorParameters<typeof MockAdapter>[0]
  20. const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
  21. const contexts: Context[] = []
  22. let workspace: string
  23. beforeEach(async () => {
  24. workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
  25. })
  26. afterEach(async () => {
  27. for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
  28. await rm(workspace, { recursive: true, force: true })
  29. })
  30. async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
  31. const ctx = new Context()
  32. contexts.push(ctx)
  33. await mountAgentLoopTestDependencies(ctx)
  34. await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
  35. await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
  36. await ctx.plugin(ToolFs)
  37. await ctx.plugin(ApprovalService)
  38. await ctx.plugin(AgentLoop, { agents: [] })
  39. ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
  40. const parent = ctx.agentLoop.create(
  41. SessionId('parent'),
  42. { provider: 'mock', model: 'mock' },
  43. { cwd: workspace },
  44. )
  45. return { ctx, parent }
  46. }
  47. function spawnRequest(parent: Agent) {
  48. return {
  49. label: 'child task',
  50. prompt: [{ type: 'text' as const, text: 'child task' }],
  51. parent,
  52. signal: new AbortController().signal,
  53. descriptor: snapshotSubagentDescriptor({
  54. mode: 'one-shot',
  55. provider: 'spawn',
  56. label: 'child task',
  57. }),
  58. }
  59. }
  60. function toolResultTexts(agent: Agent): string[] {
  61. return agent.session.events
  62. .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  63. .map(event => event.data.message.content
  64. .flatMap(block => block.content)
  65. .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  66. .map(block => block.text)
  67. .join(''))
  68. }
  69. describe('in-process policy inheritance', () => {
  70. it('records parent overrides before publishing a spawn child', async () => {
  71. const script: Script = []
  72. const { ctx, parent } = await setupWalled(script)
  73. const blocked = join(workspace, 'spawn-blocked.txt')
  74. setSandboxMode(parent.session, 'read-only')
  75. setApprovalPolicy(parent.session, 'never')
  76. const parentLogLength = parent.session.events.length
  77. script.push(
  78. toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
  79. textResponse('child done'),
  80. )
  81. const run = await startInProcessRun(spawnRequest(parent), {})
  82. try {
  83. const result = await run.result
  84. const child = run.localAgent as Agent
  85. await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  86. expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
  87. expect(result.stopReason).toBe('completed')
  88. expect(child.session.events.slice(0, 2)).toMatchObject([
  89. { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
  90. { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
  91. ])
  92. expect(child.session.firstLiveSeq).toBe(0)
  93. expect(child.session.header.seedLength).toBeUndefined()
  94. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  95. expect(ctx.approval.overrideOf(child.session)).toBe('never')
  96. const request = child.session.events.find(
  97. (event): event is SessionEvent<'request/header'> => event.type === 'request/header',
  98. )
  99. const runtimeContext = child.session.events.find(
  100. (event): event is SessionEvent<'user/message'> => event.type === 'user/message'
  101. && event.data.source.kind === 'plugin'
  102. && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
  103. )
  104. if (request === undefined || runtimeContext === undefined) throw new Error('child request lacks its runtime policy context')
  105. expect(runtimeContext.seq).toBeLessThan(request.seq)
  106. const contextText = runtimeContext.data.content
  107. .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  108. .map(block => block.text)
  109. .join('\n')
  110. expect(contextText).toContain('Current DSH file policy: read-only')
  111. expect(contextText).toContain('Approval prompts are disabled')
  112. expect(request.data.header.system).not.toContain('Approval prompts are disabled')
  113. expect(parent.session.events).toHaveLength(parentLogLength)
  114. } finally {
  115. await run.dispose()
  116. }
  117. })
  118. it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
  119. const script: Script = []
  120. const { ctx, parent } = await setupWalled(script)
  121. const blocked = join(workspace, 'fork-blocked.txt')
  122. setSandboxMode(parent.session, 'workspace-write')
  123. const seed = [...parent.session.events]
  124. setSandboxMode(parent.session, 'read-only')
  125. script.push(
  126. toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
  127. textResponse('child done'),
  128. )
  129. const run = await startInProcessRun(spawnRequest(parent), { seed })
  130. try {
  131. await run.result
  132. const child = run.localAgent as Agent
  133. expect(child.session.header.seedLength).toBe(1)
  134. expect(child.session.firstLiveSeq).toBe(seed.length)
  135. // seq 1 is the constructor's end-seed marker.
  136. expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
  137. { seq: 0, data: { mode: 'workspace-write' } },
  138. { seq: 2, data: { mode: 'read-only', source: 'delegation' } },
  139. ])
  140. await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  141. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  142. setSandboxMode(child.session, 'danger-full-access')
  143. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
  144. } finally {
  145. await run.dispose()
  146. }
  147. })
  148. it('captures policy at delegation before asynchronous child creation', async () => {
  149. const script: Script = [textResponse('child done')]
  150. const { ctx, parent } = await setupWalled(script)
  151. setSandboxMode(parent.session, 'read-only')
  152. const starting = startInProcessRun(spawnRequest(parent), {})
  153. setSandboxMode(parent.session, 'danger-full-access')
  154. const run = await starting
  155. try {
  156. await run.result
  157. const child = run.localAgent as Agent
  158. expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
  159. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  160. } finally {
  161. await run.dispose()
  162. }
  163. })
  164. it('does not freeze deployment defaults into an unswitched child', async () => {
  165. const script: Script = []
  166. const { parent } = await setupWalled(script)
  167. const allowed = join(workspace, 'default-allowed.txt')
  168. script.push(
  169. toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
  170. textResponse('child done'),
  171. )
  172. const run = await startInProcessRun(spawnRequest(parent), {})
  173. try {
  174. await run.result
  175. const child = run.localAgent as Agent
  176. expect(await readFile(allowed, 'utf8')).toBe('fine')
  177. expect(child.session.events.some(
  178. event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
  179. )).toBe(false)
  180. expect(child.session.firstLiveSeq).toBe(0)
  181. } finally {
  182. await run.dispose()
  183. }
  184. })
  185. })