mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
test(snapshot): pin the parent-only override through the assembled headless app
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).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-subagent-policy-inheritance.md: ea8e4626a1bd2ac31c2a7ce207bf9ba73e2e32fc
|
||||
2026-07-25-subagent-policy-inheritance.zh.md: b9e978761af7de6ecf9a2e6ec167e2bb512c9ebf
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
|
||||
2026-07-25-subagent-policy-inheritance.md: fcc91310399dd0bbaae3acc444fbf790ae4d0b3c
|
||||
2026-07-25-subagent-policy-inheritance.zh.md: d06948dc34213e87300bf46ee5f1fc43d8f48fc7
|
||||
|
||||
@@ -34,5 +34,5 @@ A confined child that hits the wall gets the ordinary denial marker; an escalati
|
||||
- A parent's tightened sandbox mode and `'never'` approval stance now bind spawn children, fork children (regardless of seed timing), and grandchildren; the delegation bypass is closed at every depth, with no first-turn timing window (veto, injection, crash). Pinned by the real-wall suite in `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` (a scripted-model child hitting the real `dsh-fs-sandbox` fence through the real `write` tool, asserted on disk state and denial markers — including the delegation-vs-late-switch race, a veto-capable prompt-submit listener, and header durability before any child turn) and the `overrideOf` contract tests in the two service suites (baseline read, seed-boundary precedence, closed-vocabulary rejection).
|
||||
- The baseline rides `SessionHeader` through both persistence backends (a JSONL header-line field; SQLite `sessions` columns with `SCHEMA_VERSION` bumped to 11 — pre-release, no migration), so resume restores it like `delegationDepth`; the child may later be switched independently, its own post-seed events outranking the baseline.
|
||||
- Accepted limits: a parent switch made while a child is already running does not propagate (snapshot semantics); out-of-process backends (`subagent-acp`, subprocess children) inherit nothing here — their policy belongs to the child harness's own deployment, the sandbox Agent Note's deferred phase.
|
||||
- Snapshot coverage runs at deployment strength only: the recorded `subagent-sandbox-inheritance` scenario proves a delegated child confined under a read-only DEPLOYMENT policy through the assembled ACP app, but the automation-only protocol has no session-scoped switch, so a parent-ONLY override (wider default, tightened parent, denied child) is not expressible there. When picked up, the shape is the headless suite's semantic-checkpoint precedent: seed a persisted parent log carrying a real `sandbox/mode` switch, resume it through the Loader-booted cli-demo app via a resume fixture plugin, and let it delegate — a scenario-harness addition (the headless suite has no subagent+sandbox overlay yet), not a new mechanism.
|
||||
- Assembled-app snapshots pin both strengths. The recorded `subagent-sandbox-inheritance` ACP scenario proves a delegated child confined under a read-only DEPLOYMENT policy (the automation-only protocol has no session-scoped switch). The keyless `subagent-inheritance` headless scenario pins the parent-ONLY override 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; the child's real write is denied by the real fence, its persisted header carries the inherited baseline, and disabling the driver's capture makes the scenario fail on the physical disk assertion — the assembled-app red/green anchor for the delegation bypass.
|
||||
- `dsh-subagent-inprocess` declares `dsh-sandbox-policy` and `dsh-user-approval` as peers for the `ctx.get` typing; both remain runtime-optional. `SessionHeader` gains two neutral optional string fields; `SESSION_FORMAT_VERSION` stays 0 (additive, pre-release).
|
||||
|
||||
@@ -34,5 +34,5 @@ Status: implemented
|
||||
- 父级收紧后的沙箱模式与 `'never'` 审批立场现在会约束 spawn 子 agent、fork 子 agent(无论种子时机如何)与孙代 agent;委派旁路在每一层深度都已封死,且不存在任何第一轮次的时序窗口(否决、注入、崩溃)。该行为由 `packages/subagent/subagent-inprocess/tests/inheritance.spec.ts` 中的真实围栏测试套件钉住(脚本化模型驱动的子 agent 通过真实 `write` 工具撞上真实的 `dsh-fs-sandbox` 围栏,按落盘状态与拒绝标记断言——其中包括委派与延迟切换之间的竞态用例、一个具备否决能力的 prompt-submit 监听器用例,以及子 agent 任何轮次开始前的会话头持久性用例),并由两个服务各自测试套件中的 `overrideOf` 契约测试钉住(基线读取、种子边界优先级、封闭词汇拒绝)。
|
||||
- 基线随 `SessionHeader` 通过两个持久化后端存储(JSONL 头部行字段;SQLite `sessions` 表中的列,`SCHEMA_VERSION` 提升到 11——预发布阶段,无迁移),因此恢复时它像 `delegationDepth` 一样被还原;子 agent 之后仍可被独立切换,其自身种子之后的事件优先于基线。
|
||||
- 已接受的限制:子 agent 已在运行时父级再做的切换不会传播(快照语义);进程外后端(`subagent-acp`、子进程形态的子 agent)在这里不继承任何内容:它们的策略归子 harness 自身的部署所有,属于沙箱 Agent Note 中延后的阶段。
|
||||
- 快照覆盖只运行在部署强度上:已录制的 `subagent-sandbox-inheritance` 场景经由组装后的 ACP 应用,证明了一个被委派的子 agent 被约束在只读的部署级策略之下,但这个仅面向自动化的协议没有会话作用域的切换,因此仅父级的覆盖项(更宽的默认值、收紧的父级、被拒的子 agent)在该协议上无法表达。待接手时,其形态沿用 headless 套件的语义检查点先例:预置一份携带真实 `sandbox/mode` 切换的已持久化父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它,再让它发起委派——这是一次场景 harness 的扩充(headless 套件尚无 subagent+沙箱 overlay),而非新机制。
|
||||
- 组装后应用的快照钉住两种强度。已录制的 `subagent-sandbox-inheritance` ACP 场景证明了一个被委派的子 agent 被约束在只读的部署级策略之下(这个仅面向自动化的协议没有会话作用域的切换)。无密钥的 `subagent-inheritance` headless 场景则沿语义检查点先例钉住仅父级的覆盖项:在 workspace-write 的部署默认值之下,预置一份携带真实 `sandbox/mode: read-only` 切换的父级日志,经由一个恢复用的 fixture(测试前置数据)插件在 Loader 启动的 cli-demo 应用中恢复它并发起委派;子 agent 的真实写入被真实围栏拒绝,其持久化的会话头携带继承来的基线,而禁用驱动器的捕获会让该场景在物理落盘断言上失败——这就是委派旁路在组装后应用层面的红/绿锚点。
|
||||
- `dsh-subagent-inprocess` 将 `dsh-sandbox-policy` 与 `dsh-user-approval` 声明为对等依赖(peer dependency),以支撑 `ctx.get` 的类型;两者在运行时仍然可选。`SessionHeader` 新增两个中性的可选字符串字段;`SESSION_FORMAT_VERSION` 保持为 0(仅新增字段,预发布阶段)。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Keyless real-Loader composition for the parent-only override inheritance
|
||||
# snapshot. The deployment default stays WIDE (workspace-write) while the
|
||||
# seeded parent session carries a session-scoped read-only override; the
|
||||
# resumed parent delegates, and only the inheritance capture can confine the
|
||||
# child — remove it and the child writes successfully under the deployment
|
||||
# default, so this scenario is the assembled-app red/green anchor for the
|
||||
# delegation bypass.
|
||||
|
||||
- id: persistence
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: './.sessions'
|
||||
compression: none
|
||||
|
||||
# file/override/childFiles all default to their DSH_SNAPSHOT_* env vars.
|
||||
- id: replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
# The confining filesystem stack: the wide deployment default lives on the
|
||||
# shared policy home; the seeded parent's read-only override must beat it
|
||||
# INSIDE the child for the scenario to deny.
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: workspace-write
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: fs-sandbox
|
||||
name: '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
- id: fs-policy
|
||||
name: '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
agents: []
|
||||
workspaceContext: false
|
||||
skills:
|
||||
enabled: false
|
||||
toolTasks: false
|
||||
goals: false
|
||||
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
maxDepth: 1
|
||||
|
||||
# Await the persisted resume before the headless driver inspects root agents.
|
||||
- id: resumed-agent
|
||||
name: './tests/fixtures/subagent-inheritance-agent.ts'
|
||||
25
examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts
vendored
Normal file
25
examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loader fixture that resumes the seeded read-only parent before CLI dispatch.
|
||||
* @module subagent-inheritance-agent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Fixture plugin name. */
|
||||
export const name = 'subagent-inheritance-agent'
|
||||
/** Services that must exist before the fixture resumes its agent. */
|
||||
export const inject = ['agents', 'agentLoop', 'sessionPersistence']
|
||||
|
||||
/**
|
||||
* Resume the seeded session and bind its exact handle to this fixture's lifetime.
|
||||
* @param ctx - settled agent and persistence services from the Loader tree.
|
||||
* @returns after the resumed agent is published.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<void> {
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: 'subagent-inheritance-parent' as SessionId,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
ctx.effect(() => () => handle.dispose(), 'subagent-inheritance-agent.handle')
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1,"sandboxMode":"read-only"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}
|
||||
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true,"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":22,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type": "session", "version": 0, "id": "subagent-inheritance-child", "createdAt": 2, "delegationDepth": 1}
|
||||
{"type":"turn/start","seq":0,"time":1,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":2,"data":{"content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":3,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":6,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":7,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":8,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"step/end","seq":8,"time":9,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":9,"time":10,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":10,"time":11,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":12,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":13,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":14,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":15,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"step/end","seq":15,"time":16,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":16,"time":17,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}}
|
||||
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"turn/start","seq":4,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}}
|
||||
{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":15,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
|
||||
{"type":"tool/result","seq":16,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false},"sourceEventSeqs":[15],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}}
|
||||
{"type":"step/start","seq":18,"time":0,"data":{"turn":2,"step":2}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":2,"step":2,"content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":25,"time":0,"data":{"turn":2,"step":2}}
|
||||
{"type":"turn/end","seq":26,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1 @@
|
||||
[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "delegate-write", "name": "subagent", "argumentsDelta": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "delegate-write", "name": "subagent", "arguments": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The delegated child was denied by the sandbox. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The delegated child was denied by the sandbox. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]
|
||||
130
examples/headless-agent/tests/subagent-inheritance.snapshot.ts
Normal file
130
examples/headless-agent/tests/subagent-inheritance.snapshot.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
Reference in New Issue
Block a user