mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch. Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults. Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly. This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
/** Policy inheritance through constructor-seeded child session events. */
|
|
|
|
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 '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 { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
|
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
|
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 = ctx.agentLoop.create(
|
|
SessionId('parent'),
|
|
{ provider: 'mock', model: 'mock' },
|
|
{ cwd: workspace },
|
|
)
|
|
return { ctx, parent }
|
|
}
|
|
|
|
function spawnRequest(parent: Agent) {
|
|
return {
|
|
prompt: [{ type: 'text' as const, text: 'child task' }],
|
|
parent,
|
|
signal: new AbortController().signal,
|
|
}
|
|
}
|
|
|
|
function toolResultTexts(agent: Agent): string[] {
|
|
return agent.session.events
|
|
.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('seeds parent overrides into a spawn child before its first request', async () => {
|
|
const script: Script = []
|
|
const { ctx, parent } = await setupWalled(script)
|
|
const blocked = join(workspace, 'spawn-blocked.txt')
|
|
setSandboxMode(parent.session, 'read-only')
|
|
setApprovalPolicy(parent.session, 'never')
|
|
const parentLogLength = parent.session.events.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.events.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(2)
|
|
expect(child.session.header.seedLength).toBeUndefined()
|
|
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
|
|
expect(ctx.approval.overrideOf(child.session)).toBe('never')
|
|
const request = child.session.events.find(
|
|
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
|
|
)
|
|
expect(request?.data.header.system).toContain('Approval prompts are disabled')
|
|
expect(parent.session.events).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.events]
|
|
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.seedLength).toBe(1)
|
|
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
|
|
{ seq: 0, data: { mode: 'workspace-write' } },
|
|
{ seq: 1, 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('does not freeze deployment defaults into an unswitched child', 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.events.some(
|
|
event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
|
|
)).toBe(false)
|
|
expect(child.session.firstLiveSeq).toBe(0)
|
|
} finally {
|
|
await run.dispose()
|
|
}
|
|
})
|
|
})
|