policy: resolve every knob consumer through the shared override chain

Review fixes (ds-review-bot warnings on #623):

- One chain, every consumer: the override resolution (own post-seed
  switches ?? header baseline, closed-vocabulary validated) moves into pure
  exports (sandboxOverrideOf / approvalOverrideOf); the services delegate,
  and the permission presets consume them — current(session) and set()
  now see inherited baselines, so a child inheriting danger-full-access
  gets REAL knob switches when workspace-write is selected instead of a
  silent no-op, and a seed-carried preset selection is subsumed by the
  baseline. current(events) becomes current(session) (pre-release; the
  only callers were tests).
- Unconditional durable validation: a malformed header baseline fails
  loud on every read, no longer shadowed by an own switch.
- The two policy peers are declared optional (peerDependenciesMeta), so a
  thin spawn/fork deployment without policy plugins can consume the
  driver; verify-runtime-closure honors the flag.

Red-first: inherited-preset derive/switch-away and seeded-selection tests
in the permission suite; malformed-baseline-with-own-switch tests in both
policy suites.
This commit is contained in:
kingwl
2026-07-26 22:02:02 +08:00
parent ffb6435a41
commit f505bd9258
23 changed files with 179 additions and 67 deletions
@@ -13,7 +13,7 @@ Session policy overrides are per-session log folds: the effective sandbox mode i
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)` — on `SandboxPolicyService` and `ApprovalService` — 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 on every read (durable-boundary validation) and throws on foreign values. The driver consumes both services opportunistically (`ctx.get`, type-only imports): compositions without them delegate policy-free, unchanged.
- **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.** A fork seed may carry the parent's OLD switch events; `overrideOf` folds only events past `header.seedLength`, so seed-carried history is subsumed by the delegation-time baseline while a switch the child makes ITSELF still outranks it. 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.