Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
T
kingwl 99f5fab7bc policy: scope seed-boundary slicing to delegation children
Review fix (ds-review-bot on #623): the unconditional slice regressed the
public SessionStore.fork path — a generic fork child gets seedLength but no
policy baseline, so slicing discarded its seed-carried sandbox/approval
switches and silently widened it to the deployment defaults (a forked
read-only/never parent produced a workspace-write/ask child).

overrideOf now branches on baseline presence: with a header baseline (a
delegation child) the fold covers only own post-seed switches — the
baseline captured from the parent's FULL log subsumes seed history; without
one, the whole log — seeded switches ARE the replayed inherited truth. The
permission preset fold scopes the same way. Red-first: generic-fork
seed-carried override tests in both policy suites.
2026-07-26 23:06:15 +08:00

9.6 KiB

Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides

Status: implemented

English | 中文

Problem

Session policy overrides are per-session log folds: the effective sandbox mode is fold(session's sandbox/mode events) ?? deployment default (the sandbox Agent Note), and the approval policy folds approval/policy the same way. In-process subagent children get a NEW session, so no override crossed the delegation boundary: a spawn child of a read-only-switched parent ran under the (possibly wider) deployment default — delegation was a bypass channel for a user's tightening — and a fork child inherited only whatever switch happened to sit inside its completed-turn seed, missing exactly the most common timing (the user switches while the agent is idle, so the switch lands after the last turn/end and outside the seed). A 'never' (headless/CI) approval parent likewise minted children that fell back to a prompting default. The escalation hint a denied child sees ("the approval prompt asks the user") also promised a prompt no answerer would ever deliver.

Decision

The shared in-process driver (startInProcessRun in packages/subagent/subagent-inprocess) captures the parent's policy overrides synchronously at delegation and carries them into the child's IMMUTABLE session header as creation meta — the delegationDepth precedent:

  • Capture synchronously at delegation, persist in the creation-time header. The driver reads overrideOf(parent.session) for both knobs BEFORE its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future, not the child — and stamps the captured values into the child's creation meta (sandboxMode/approvalPolicy on SessionHeader). The baseline is durable from the moment the session exists: no listener ordering can starve it (a denying UserPromptSubmit hook that vetoes the first prompt changes nothing), and no crash window can lose it — the decisive case being an idle SessionStart-style injection that persists a complete turn before any prompt turn opens, after which a first-turn event would not yet exist while the session already looks resumable.
  • Only the override chain is copied, and the owners validate on read. overrideOf(session) — the pure sandboxOverrideOf/approvalOverrideOf exports, surfaced as service methods — resolves fold(events past the seed boundary) ?? header baseline, never the deployment/configured default: an unswitched parent writes no baseline, so its children keep following the LIVE default across restarts. The header fields are neutral strings at the session boundary; each policy owner validates against its closed vocabulary UNCONDITIONALLY on every read (a corrupt header fails loud even when an own switch would shadow it) and throws on foreign values. EVERY knob consumer resolves through the same chain — enforcement (resolve(), pty-local) and the permission presets (current/set), so a child inheriting a wider baseline gets real knob switches when a narrower preset is selected instead of a silent no-op. The driver consumes both services opportunistically (ctx.get, type-only imports, peerDependenciesMeta.optional): compositions without them delegate policy-free, unchanged.
  • Fork stale-seed precedence falls out of the seed boundary — scoped to delegation children. A fork seed may carry the parent's OLD switch events; when a header baseline exists, overrideOf folds only events past header.seedLength — the baseline was captured from the parent's FULL log at delegation, so seed-carried history is subsumed by it while a switch the child makes ITSELF still outranks it. Without a baseline (a top-level session, or a generic SessionStore.fork child that captured no policy meta) the fold covers the whole log: there, seeded switches ARE the replayed inherited truth, and slicing them away would silently widen the child to the deployment default. The log stays free of synthetic events — the header is the baseline's one home, and the canonical setSandboxMode/setApprovalPolicy write paths remain reserved for real runtime switches.
  • Nesting composes by construction. A grandchild's capture resolves its parent-the-child's chain (own fold ?? baseline), so the chain collapses one level per delegation, at any depth. One-shot allowed-once escalation grants never enter a log or header, so they can never leak down the chain.

What a blocked child experiences

A confined child that hits the wall gets the ordinary denial marker; an escalation retry resolves through the real approval waterfall, where no answerer owns an in-process child, to the distinct fail-closed reason (no approval channel is available). The recovery path is reporting the denial upward: the parent — owned by a controller that can answer — escalates in its own session or re-delegates after the user widens the mode. An inherited 'never' skips even that wasted retry: the child's first system prompt already says not to request escalation.

Alternatives considered

  • Stamping the inherited override as sandbox/mode/approval/policy events inside the child's first turn (the shipped first iteration) — superseded: it kept the log-as-store idiom with zero format changes, but review surfaced a durability hole the turn-enclosure contract cannot patch — an idle SessionStart-style injection persists a complete one-shot turn BEFORE any prompt turn opens, so a crash in that window leaves a resumable-looking child with no inherited policy, and no event anchor exists earlier (creation-time appends are crash-tail garbage, injection turns dispatch no waterfall, session/event listeners cannot re-append). The header baseline closes every timing window at once and deletes the listener/prepend/dedup machinery the event approach needed.
  • Stamping at child creation (outside any turn) — rejected: the persistence contract commits at turn boundaries, so a pre-turn bare event is truncated as a torn tail on reload; the session invariant suite fails such an append outright.
  • Live resolution walking parentSession at each call — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening.
  • Forcing approvalPolicy: 'never' onto every in-process child — rejected: true today (no answerer owns them) but it forecloses a future child-capable answerer silently and muddies inheritance semantics; inheriting only the parent's override keeps the fail-closed outcome with honest per-request reasons.
  • Routing a child's approval asks to the root session's controller — deferred, unchanged from the approval-seam Agent Note: the ACP prompt must attach to a streamed tool call, a background child's originating call has already returned, and the bridge would need parent-chain ownership plus the spawning callId on the start request. Recorded here so the obstacles are not re-derived.

Consequences

  • 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.
  • 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).