subagent: capture overrides at delegation; stamp ahead of prompt vetoes
Review fixes (ds-review-bot on #623): - Capture-at-delegation: the driver now reads overrideOf(parent.session) for both knobs synchronously before its first await, and the prompt-submit listener stamps those captured values — a parent switch racing the child's asynchronous creation belongs to the parent's future, not the child. The inheritOverride(parent, child) service method is split into its two halves (overrideOf / stampOverride) accordingly. - Veto safety: the one-shot prompt-submit listener registers with prepend: true, so a veto-capable listener (a denying UserPromptSubmit hook) cannot close the child's first turn without the durable stamp. Both regressions are pinned red-first in inheritance.spec.ts: the delegation-vs-late-switch race (delegate tool flips the caller wider while the creation transaction is pending) and a blocking prompt-submit listener (stamp survives a promptless first turn). Service contract tests renamed to the split API; READMEs and the bilingual Agent Note updated.
This commit is contained in:
@@ -17,7 +17,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
|
||||
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
|
||||
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
|
||||
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
|
||||
- `ctx.sandboxPolicy.inheritOverride(parent, child)` — the delegation-inheritance step: stamps the parent session's effective override (never the deployment default) onto a child session through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver calls it inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
- `ctx.sandboxPolicy.overrideOf(session)` / `ctx.sandboxPolicy.stampOverride(child, mode)` — the two halves of delegation inheritance: the fold alone (never the deployment default), and the write of a captured override through `setSandboxMode`, skipping a child that already folds to it. The in-process subagent driver captures at delegation and stamps inside the child's first turn so a delegating parent's tightened mode binds its children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
|
||||
|
||||
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules.
|
||||
|
||||
@@ -106,21 +106,32 @@ export class SandboxPolicyService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the parent's sandbox-mode OVERRIDE onto a child session through the
|
||||
* canonical write path — the delegation-inheritance step: a child agent runs
|
||||
* under the policy its delegating parent was switched to, not under the
|
||||
* (possibly wider) deployment default. Only the override chain is copied: an
|
||||
* unswitched parent stamps nothing, so the child keeps following the LIVE
|
||||
* deployment default. A child whose log (e.g. a fork seed) already folds to
|
||||
* the inherited mode is left untouched. Callers must append inside an open
|
||||
* child turn — a bare between-turn event is crash-tail garbage on reload.
|
||||
* @param parent - the delegating session whose effective override is read.
|
||||
* @param child - the child session the override is appended to.
|
||||
* A session's sandbox-mode OVERRIDE — the fold alone, never the deployment
|
||||
* default. The read half of delegation inheritance: the subagent driver
|
||||
* captures this synchronously at delegation, so a parent switch racing the
|
||||
* child's asynchronous creation belongs to the parent's future, not to the
|
||||
* child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
* @param session - the session whose override chain to fold.
|
||||
* @returns the last switched mode, or `undefined` for a never-switched session.
|
||||
*/
|
||||
inheritOverride(parent: Session, child: Session): void {
|
||||
const inherited = effectiveSandboxMode(parent.events)
|
||||
if (inherited === undefined || effectiveSandboxMode(child.events) === inherited) return
|
||||
setSandboxMode(child, inherited)
|
||||
overrideOf(session: Session): SandboxMode | undefined {
|
||||
return effectiveSandboxMode(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a captured override onto a child session through the canonical
|
||||
* write path — the write half of delegation inheritance: a child agent runs
|
||||
* under the policy its delegating parent was switched to, not under the
|
||||
* (possibly wider) deployment default. A child whose log (e.g. a fork seed)
|
||||
* already folds to the mode is left untouched. Callers must append inside
|
||||
* an open child turn — a bare between-turn event is crash-tail garbage on
|
||||
* reload.
|
||||
* @param child - the child session the override is appended to.
|
||||
* @param mode - the captured {@link overrideOf} value to stamp.
|
||||
*/
|
||||
stampOverride(child: Session, mode: SandboxMode): void {
|
||||
if (effectiveSandboxMode(child.events) === mode) return
|
||||
setSandboxMode(child, mode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,45 +143,40 @@ describe('the sandbox/mode session kit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritOverride (parent → child stamping)', () => {
|
||||
describe('delegation inheritance (overrideOf + stampOverride)', () => {
|
||||
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
|
||||
|
||||
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
const parent = session('sess-inherit-parent')
|
||||
const child = session('sess-inherit-child')
|
||||
setSandboxMode(parent, 'workspace-write')
|
||||
setSandboxMode(parent, 'read-only')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only')
|
||||
// undefined, NOT the deployment default — a child stamped with the
|
||||
// default would stop following the LIVE default across resumes.
|
||||
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stampOverride appends the captured mode through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
const child = session('sess-inherit-child')
|
||||
|
||||
ctx.sandboxPolicy.stampOverride(child, 'read-only')
|
||||
|
||||
const stamped = modeEvents(child)
|
||||
expect(stamped).toHaveLength(1)
|
||||
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
|
||||
})
|
||||
|
||||
it('appends NOTHING when the parent never switched (the deployment default must stay live)', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write' })
|
||||
const parent = session('sess-inherit-default-parent')
|
||||
const child = session('sess-inherit-default-child')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
|
||||
// No event — a resumed child keeps following whatever the deployment
|
||||
// default is THEN, instead of a frozen copy of today's default.
|
||||
expect(child.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips the append when the child already folds to the inherited mode (fork-seed dedup)', async () => {
|
||||
it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = session('sess-inherit-dedup-parent')
|
||||
const child = session('sess-inherit-dedup-child')
|
||||
setSandboxMode(parent, 'read-only')
|
||||
// A fork seed can already carry the parent's switch; stamping again would
|
||||
// append a redundant event on every delegation.
|
||||
setSandboxMode(child, 'read-only')
|
||||
|
||||
ctx.sandboxPolicy.inheritOverride(parent, child)
|
||||
ctx.sandboxPolicy.stampOverride(child, 'read-only')
|
||||
|
||||
expect(modeEvents(child)).toHaveLength(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user