mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Review fix (ds-review-bot on #623): the ACP scenario runs at deployment strength (the automation protocol has no session-scoped switch), so the assembled-app path could not detect the delegation bypass itself. The new keyless subagent-inheritance headless scenario closes that on the semantic-checkpoint precedent: a seeded parent log carrying a real sandbox/mode: read-only switch under a workspace-write deployment default is resumed through the Loader-booted cli-demo app via a resume fixture plugin and delegates through the real subagent tool; the child's real write is denied by the real dsh-fs-sandbox fence (physical ENOENT assertion), its persisted header carries the inherited baseline, and both logs pin as expected outputs. Verified red: disabling the driver's capture makes the scenario fail on the disk assertion (the child writes under the deployment default).
131 lines
6.7 KiB
TypeScript
131 lines
6.7 KiB
TypeScript
/**
|
|
* Keyless assembled-app snapshot for parent-only policy inheritance: the
|
|
* deployment default stays WIDE (workspace-write on the shared policy home)
|
|
* while the seeded parent session carries a session-scoped `sandbox/mode:
|
|
* read-only` override; the Loader-booted headless app resumes it, the parent
|
|
* delegates through the real subagent tool, and the child's real `write`
|
|
* hits the real `dsh-fs-sandbox` fence. Only the delegation-inheritance
|
|
* capture can confine the child here — remove it and the child inherits
|
|
* nothing, writes `inherited.txt` successfully under the deployment default,
|
|
* and every assertion below fails. This is the assembled-app red/green
|
|
* anchor the ACP scenario cannot express (the automation protocol has no
|
|
* session-scoped switch).
|
|
*/
|
|
|
|
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Context } from 'cordis'
|
|
import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
|
|
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url))
|
|
const replayOverride = join(fixtureDir, 'replay.override.json')
|
|
const childReplay = join(fixtureDir, 'child.replay.jsonl')
|
|
const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
|
|
const childExpected = join(fixtureDir, 'child.expected.jsonl')
|
|
const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url))
|
|
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
|
|
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
|
const sessionId = SessionId('subagent-inheritance-parent')
|
|
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
|
|
const task = 'Delegate the write probe to a subagent.'
|
|
|
|
/**
|
|
* Seed the parent: a completed turn whose ONLY policy fact is a session-scoped
|
|
* `sandbox/mode: read-only` switch — the deployment default stays wider, so
|
|
* the child's confinement below can come from inheritance alone.
|
|
*/
|
|
async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
const meta: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sessionId,
|
|
createdAt: 1,
|
|
cwd,
|
|
delegationDepth: 0,
|
|
}
|
|
const events: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
|
{ type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } },
|
|
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
try {
|
|
await ctx.sessionPersistence.create(meta)
|
|
await ctx.sessionPersistence.append(sessionId, events)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
}
|
|
|
|
describe('parent-only override inheritance snapshot', () => {
|
|
it('confines a delegated child through the assembled headless app', async () => {
|
|
let cwd = ''
|
|
const result = await runLoaderSmoke({
|
|
label: 'subagent inheritance headless stream-json snapshot',
|
|
tempDirPrefix: 'dsh-subagent-inherit-',
|
|
binScript,
|
|
configPath,
|
|
binArgs: ['--config', configPath, '--output-format', 'stream-json', task],
|
|
tsconfigPath,
|
|
env: {
|
|
// The primary fixture path must exist for llm-replay's config guard;
|
|
// the override sidecar fully replaces the derived parent script.
|
|
DSH_SNAPSHOT_FILE: replayOverride,
|
|
DSH_SNAPSHOT_OVERRIDE: replayOverride,
|
|
DSH_SNAPSHOT_CHILD_FILES: childReplay,
|
|
},
|
|
prepare: async (runCwd) => {
|
|
cwd = runCwd
|
|
await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd)
|
|
},
|
|
inspect: async (runCwd) => {
|
|
// THE physical fact: the child's write never reached the disk. Under
|
|
// the deployment default (workspace-write) alone it would succeed.
|
|
await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
|
|
// Collect both persisted logs (parent resumed turn + child run).
|
|
const sessionsDir = join(runCwd, '.sessions')
|
|
const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl'))
|
|
const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8')))
|
|
const headerOf = (content: string): Record<string, unknown> =>
|
|
JSON.parse(content.split('\n')[0] ?? '{}') as Record<string, unknown>
|
|
const parent = logs.find(content => content.includes('"subagent-inheritance-parent"'))
|
|
const child = logs.find(content => typeof headerOf(content).parentSession === 'string')
|
|
if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log')
|
|
|
|
// The inherited baseline is the child's durable header record.
|
|
expect(headerOf(child).sandboxMode).toBe('read-only')
|
|
|
|
const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd }
|
|
const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context))
|
|
const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context))
|
|
if (refreshing) {
|
|
await writeFile(parentExpected, normalizedParent)
|
|
await writeFile(childExpected, normalizedChild)
|
|
}
|
|
expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
|
|
expect(normalizedChild).toBe(await readFile(childExpected, 'utf8'))
|
|
// The child's real write was denied by the real fence.
|
|
expect(normalizedChild).toContain('file access denied under read-only mode')
|
|
},
|
|
})
|
|
|
|
expect(result.stderr).toBe('')
|
|
const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
|
expect(records.at(-1)).toMatchObject({
|
|
type: 'result',
|
|
success: true,
|
|
sessionId,
|
|
result: 'The delegated child was denied by the sandbox. PARENT_DONE',
|
|
reason: { kind: 'completed' },
|
|
})
|
|
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
|
})
|