inheritance.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Delegation policy through child session events appended before publication:
  3. * the parent's sandbox override plus the pinned `approval/policy: never`.
  4. */
  5. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  6. import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  12. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  13. import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
  14. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  15. import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
  16. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  17. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  18. import ApprovalService from '@deepseek-ai/dsh-user-approval'
  19. import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  20. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  21. import { startInProcessRun } from '../src/index.ts'
  22. type Script = ConstructorParameters<typeof MockAdapter>[0]
  23. const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
  24. const contexts: Context[] = []
  25. let workspace: string
  26. beforeEach(async () => {
  27. workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
  28. })
  29. afterEach(async () => {
  30. for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
  31. await rm(workspace, { recursive: true, force: true })
  32. })
  33. async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
  34. const ctx = new Context()
  35. contexts.push(ctx)
  36. await mountAgentLoopTestDependencies(ctx)
  37. await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
  38. await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
  39. await ctx.plugin(ToolFs)
  40. await ctx.plugin(ApprovalService)
  41. await ctx.plugin(AgentLoop, { agents: [] })
  42. ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
  43. const parent = ctx.agentLoop.create(
  44. SessionId('parent'),
  45. { provider: 'mock', model: 'mock' },
  46. { cwd: workspace },
  47. )
  48. return { ctx, parent }
  49. }
  50. function spawnRequest(parent: Agent) {
  51. return {
  52. label: 'child task',
  53. prompt: [{ type: 'text' as const, text: 'child task' }],
  54. parent,
  55. signal: new AbortController().signal,
  56. descriptor: snapshotSubagentDescriptor({
  57. mode: 'one-shot',
  58. provider: 'spawn',
  59. label: 'child task',
  60. }),
  61. }
  62. }
  63. function toolResultTexts(agent: Agent): string[] {
  64. return agent.session.events
  65. .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  66. .map(event => event.data.message.content
  67. .flatMap(block => block.content)
  68. .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  69. .map(block => block.text)
  70. .join(''))
  71. }
  72. describe('in-process policy inheritance', () => {
  73. it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
  74. const script: Script = []
  75. const { ctx, parent } = await setupWalled(script)
  76. const blocked = join(workspace, 'spawn-blocked.txt')
  77. setSandboxMode(parent.session, 'read-only')
  78. // No parent approval override: the child pin must not depend on one.
  79. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
  80. const parentLogLength = parent.session.events.length
  81. script.push(
  82. toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
  83. textResponse('child done'),
  84. )
  85. const run = await startInProcessRun(spawnRequest(parent), {})
  86. try {
  87. const result = await run.result
  88. const child = run.localAgent as Agent
  89. await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  90. expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
  91. expect(result.stopReason).toBe('completed')
  92. expect(child.session.events.slice(0, 2)).toMatchObject([
  93. { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
  94. { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
  95. ])
  96. expect(child.session.firstLiveSeq).toBe(0)
  97. expect(child.session.header.seedLength).toBeUndefined()
  98. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  99. expect(ctx.approval.overrideOf(child.session)).toBe('never')
  100. const request = child.session.events.find(
  101. (event): event is SessionEvent<'request/header'> => event.type === 'request/header',
  102. )
  103. const runtimeContext = child.session.events.find(
  104. (event): event is SessionEvent<'user/message'> => event.type === 'user/message'
  105. && event.data.source.kind === 'plugin'
  106. && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
  107. )
  108. if (request === undefined || runtimeContext === undefined) throw new Error('child request lacks its runtime policy context')
  109. expect(runtimeContext.seq).toBeLessThan(request.seq)
  110. const contextText = runtimeContext.data.content
  111. .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  112. .map(block => block.text)
  113. .join('\n')
  114. expect(contextText).toContain('Current DSH file policy: read-only')
  115. expect(contextText).toContain('Approval prompts are disabled')
  116. // The statement rides runtime context; the system prompt stays uniform.
  117. expect(contextText).toContain('You are a delegated subagent')
  118. expect(request.data.header.system).not.toContain('Approval prompts are disabled')
  119. expect(request.data.header.system).not.toContain('You are a delegated subagent')
  120. expect(parent.session.events).toHaveLength(parentLogLength)
  121. } finally {
  122. await run.dispose()
  123. }
  124. })
  125. it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
  126. const script: Script = []
  127. const { ctx, parent } = await setupWalled(script)
  128. const blocked = join(workspace, 'fork-blocked.txt')
  129. setSandboxMode(parent.session, 'workspace-write')
  130. const seed = [...parent.session.events]
  131. setSandboxMode(parent.session, 'read-only')
  132. script.push(
  133. toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
  134. textResponse('child done'),
  135. )
  136. const run = await startInProcessRun(spawnRequest(parent), { seed })
  137. try {
  138. await run.result
  139. const child = run.localAgent as Agent
  140. expect(child.session.header.seedLength).toBe(1)
  141. expect(child.session.firstLiveSeq).toBe(seed.length)
  142. // seq 1 is the constructor's end-seed marker.
  143. expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
  144. { seq: 0, data: { mode: 'workspace-write' } },
  145. { seq: 2, data: { mode: 'read-only', source: 'delegation' } },
  146. ])
  147. await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  148. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  149. setSandboxMode(child.session, 'danger-full-access')
  150. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
  151. } finally {
  152. await run.dispose()
  153. }
  154. })
  155. it('captures policy at delegation before asynchronous child creation', async () => {
  156. const script: Script = [textResponse('child done')]
  157. const { ctx, parent } = await setupWalled(script)
  158. setSandboxMode(parent.session, 'read-only')
  159. const starting = startInProcessRun(spawnRequest(parent), {})
  160. setSandboxMode(parent.session, 'danger-full-access')
  161. const run = await starting
  162. try {
  163. await run.result
  164. const child = run.localAgent as Agent
  165. expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
  166. expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
  167. } finally {
  168. await run.dispose()
  169. }
  170. })
  171. it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
  172. const script: Script = []
  173. const { parent } = await setupWalled(script)
  174. const allowed = join(workspace, 'default-allowed.txt')
  175. script.push(
  176. toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
  177. textResponse('child done'),
  178. )
  179. const run = await startInProcessRun(spawnRequest(parent), {})
  180. try {
  181. await run.result
  182. const child = run.localAgent as Agent
  183. expect(await readFile(allowed, 'utf8')).toBe('fine')
  184. expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false)
  185. expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
  186. { seq: 0, data: { policy: 'never', source: 'delegation' } },
  187. ])
  188. expect(child.session.firstLiveSeq).toBe(0)
  189. } finally {
  190. await run.dispose()
  191. }
  192. })
  193. it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
  194. const script: Script = []
  195. const { ctx, parent } = await setupWalled(script)
  196. // A granting answerer proves the pin resolves before any answerer runs.
  197. let consulted = false
  198. ctx.on('approval/request', () => {
  199. consulted = true
  200. return Promise.resolve('allowed-once' as const)
  201. })
  202. const blocked = join(workspace, 'escalation-blocked.txt')
  203. setSandboxMode(parent.session, 'read-only')
  204. script.push(
  205. toolCallResponse('write', 'write', {
  206. file_path: blocked,
  207. content: 'escaped',
  208. sandbox_permissions: 'workspace-write',
  209. justification: 'test escalation from a delegated child',
  210. }),
  211. textResponse('child done'),
  212. )
  213. const run = await startInProcessRun(spawnRequest(parent), {})
  214. try {
  215. await run.result
  216. const child = run.localAgent as Agent
  217. await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  218. expect(consulted).toBe(false)
  219. expect(toolResultTexts(child).join('\n'))
  220. .toContain('the user rejected escalating this operation to "workspace-write"')
  221. const asked = child.session.events.find(
  222. (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
  223. )
  224. const decided = child.session.events.find(
  225. (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
  226. )
  227. expect(asked?.data.toolName).toBe('write')
  228. expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
  229. } finally {
  230. await run.dispose()
  231. }
  232. })
  233. })