inheritance.spec.ts 14 KB

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