|
|
@@ -6,33 +6,30 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md)
|
|
|
|
|
|
## 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](2026-07-06-sandbox.md)), 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.
|
|
|
+Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior.
|
|
|
|
|
|
## 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:
|
|
|
+The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants.
|
|
|
|
|
|
-- **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.
|
|
|
+Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event in the child's constructor seed. The driver places these events after any fork prefix while keeping `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's already-seeded log, so the rule composes without another inheritance mechanism.
|
|
|
+
|
|
|
+Constructor seeds are validated before publication and captured by persistence when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch.
|
|
|
|
|
|
### 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.
|
|
|
+A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt.
|
|
|
|
|
|
## 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.
|
|
|
+- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Constructor-seeded events have the required ordering and reuse the existing durable store.
|
|
|
+- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already accepts initial log events.
|
|
|
+- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment.
|
|
|
- **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](2026-07-06-approval-seam.md): 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.
|
|
|
+- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md).
|
|
|
|
|
|
## 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.
|
|
|
-- 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).
|
|
|
+- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, default omission, and context disposal.
|
|
|
+- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed.
|
|
|
+- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches.
|