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)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ The driver follows this sequence:
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
The child also inherits the parent's session POLICY overrides: a one-shot `agent/prompt-submit` listener installed during setup stamps the parent's effective `sandbox/mode` and `approval/policy` overrides onto the child through `ctx.sandboxPolicy.inheritOverride` / `ctx.approval.inheritOverride` (both consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request, and its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing; only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each stamp folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously 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 — and a one-shot PREPENDED `agent/prompt-submit` listener stamps the captured values through `stampOverride` (both services consumed opportunistically — compositions without them delegate policy-free). Anchoring inside the child's first turn keeps the stamp turn-enclosed (durable) and ahead of the first request; prepending puts it before veto-capable listeners, so a denying UserPromptSubmit hook cannot close the first turn without the stamp; its log position after any fork-seed switch lets the ordinary last-event-wins fold resolve stale-seed timing. Only the override chain is copied, so an unswitched parent stamps nothing and the child follows the live deployment default. Nesting composes: each capture folds the delegating session's already-stamped log ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
|
||||
@@ -100,6 +100,17 @@ export async function startInProcessRun(
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// Policy inheritance, read half: capture the parent's sandbox/approval
|
||||
// OVERRIDES synchronously, before the first await — the delegation moment
|
||||
// is the semantic snapshot point, and a parent switch racing the child's
|
||||
// asynchronous creation must belong to the parent's future, not the child.
|
||||
// Both services are consumed opportunistically — without them, delegation
|
||||
// stays policy-free.
|
||||
const sandboxPolicy = parent.ctx.get('sandboxPolicy')
|
||||
const approval = parent.ctx.get('approval')
|
||||
const inheritedMode = sandboxPolicy?.overrideOf(parent.session)
|
||||
const inheritedPolicy = approval?.overrideOf(parent.session)
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (request.persona !== undefined) {
|
||||
@@ -109,20 +120,23 @@ export async function startInProcessRun(
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
// Policy inheritance: stamp the parent's sandbox/approval OVERRIDES onto
|
||||
// the child once, anchored inside the child's FIRST turn (prompt-submit
|
||||
// runs after turn/start, before prompt assembly) — a bare between-turn
|
||||
// append would be crash-tail garbage on reload, and stamping here also
|
||||
// orders the override after any stale switch a fork seed carried, so the
|
||||
// ordinary last-event-wins fold resolves it. One-shot: later turns must
|
||||
// not re-stamp over a switch the child made itself. Both services are
|
||||
// consumed opportunistically — without them, delegation stays policy-free.
|
||||
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
|
||||
disposeInherit()
|
||||
parent.ctx.get('sandboxPolicy')?.inheritOverride(parent.session, childAgent.session)
|
||||
parent.ctx.get('approval')?.inheritOverride(parent.session, childAgent.session)
|
||||
return next()
|
||||
})
|
||||
// Write half: stamp the captured overrides once, anchored inside the
|
||||
// child's FIRST turn (prompt-submit runs after turn/start, before prompt
|
||||
// assembly) — a bare between-turn append would be crash-tail garbage on
|
||||
// reload, and stamping here also orders the override after any stale
|
||||
// switch a fork seed carried, so the ordinary last-event-wins fold
|
||||
// resolves it. PREPENDED so a veto-capable listener (a denying
|
||||
// UserPromptSubmit hook) cannot close the first turn without the stamp —
|
||||
// the stamp must be durable even for a blocked first prompt. One-shot:
|
||||
// later turns must not re-stamp over a switch the child made itself.
|
||||
if (inheritedMode !== undefined || inheritedPolicy !== undefined) {
|
||||
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
|
||||
disposeInherit()
|
||||
if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode)
|
||||
if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy)
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
}
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
|
||||
@@ -102,9 +102,11 @@ async function setupBare(script: Script) {
|
||||
* "user switched while idle, model delegates in the very next turn" fork
|
||||
* timing constructible (the post-seed switch lives in the still-open turn).
|
||||
* `fork: true` seeds the child with the caller's completed-turn prefix,
|
||||
* mirroring the fork provider's slice.
|
||||
* mirroring the fork provider's slice. `raceSwitch` flips the CALLER's mode
|
||||
* synchronously after `startInProcessRun`'s synchronous prologue but before
|
||||
* its creation transaction resolves — the delegation-vs-late-switch race.
|
||||
*/
|
||||
function registerDelegate(ctx: Context, captured: Agent[]): void {
|
||||
function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-full-access'): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'delegate',
|
||||
description: 'delegate a task to an in-process child (test scaffold)',
|
||||
@@ -115,10 +117,15 @@ function registerDelegate(ctx: Context, captured: Agent[]): void {
|
||||
const events = caller.session.events
|
||||
const lastEnd = events.findLast(e => e.type === 'turn/end')
|
||||
const seed = lastEnd === undefined ? [] : events.slice(0, lastEnd.seq + 1)
|
||||
const run = await startInProcessRun(
|
||||
const starting = startInProcessRun(
|
||||
{ prompt: [{ type: 'text', text: 'delegated task' }], parent: caller, signal: exec.signal },
|
||||
args.fork === true && seed.length > 0 ? { seed } : {},
|
||||
)
|
||||
// The caller's turn is still open, so this switch is legal — and it lands
|
||||
// while the child's creation transaction is pending, strictly before the
|
||||
// child's first prompt-submit could ever run.
|
||||
if (raceSwitch !== undefined) setSandboxMode(caller.session, raceSwitch)
|
||||
const run = await starting
|
||||
captured.push(run.localAgent as Agent)
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
@@ -264,6 +271,38 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
expect(overrideEvents(child).sandbox).toBe(1)
|
||||
})
|
||||
|
||||
it('inherits the mode AT delegation, not a parent switch racing child creation', async () => {
|
||||
const script: Script = []
|
||||
const captured: Agent[] = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
// The delegate scaffold flips the parent to danger-full-access AFTER
|
||||
// startInProcessRun's synchronous prologue, while the child's creation
|
||||
// transaction is still pending — the value at delegation is read-only.
|
||||
registerDelegate(ctx, captured, 'danger-full-access')
|
||||
const blocked = join(workspace, 'race-blocked.txt')
|
||||
script.push(
|
||||
() => {
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
return textResponse('staged')
|
||||
},
|
||||
toolCallResponse('d-race', 'delegate', { fork: false }),
|
||||
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
|
||||
textResponse('race child done'),
|
||||
textResponse('turn two done'),
|
||||
)
|
||||
parent.send([{ type: 'text', text: 'stage' }])
|
||||
await parent.whenIdle()
|
||||
parent.send([{ type: 'text', text: 'delegate' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const child = captured[0] as Agent
|
||||
// The child runs under the snapshot taken at delegation — the racing
|
||||
// wider switch belongs to the parent's own future, not to the child.
|
||||
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
|
||||
})
|
||||
|
||||
it('a GRANDCHILD inherits through the chain (child delegates again)', async () => {
|
||||
const script: Script = []
|
||||
const captured: Agent[] = []
|
||||
@@ -297,6 +336,43 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritance survives prompt vetoes', () => {
|
||||
it('stamps the child even when an earlier-registered prompt-submit listener vetoes without next()', async () => {
|
||||
const script: Script = []
|
||||
const { ctx, parent } = await setupWalled(script)
|
||||
// A veto-capable listener registered BEFORE the child exists — the
|
||||
// Claude/Codex UserPromptSubmit hook shape: it blocks the child's prompt
|
||||
// and never delegates. Inheritance must still run for the first turn.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
|
||||
if (agent.session.header.parentSession !== undefined) {
|
||||
return Promise.resolve({ kind: 'block' as const, reason: 'vetoed by test hook' })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
script.push(
|
||||
() => {
|
||||
setSandboxMode(parent.session, 'read-only')
|
||||
return textResponse('staged')
|
||||
},
|
||||
// No child model entries: the blocked prompt closes a zero-step turn.
|
||||
)
|
||||
parent.send([{ type: 'text', text: 'stage' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
const child = run.localAgent as Agent
|
||||
|
||||
// The veto closed the first turn promptless, but the stamp is inside that
|
||||
// turn regardless — a later resume must not fall back to the deployment
|
||||
// default just because the first prompt was blocked.
|
||||
expect(overrideEvents(child)).toEqual({ sandbox: 1, approval: 0 })
|
||||
expect(ctx.sandboxPolicy.resolve({ session: child.session }).mode).toBe('read-only')
|
||||
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritance guards (must hold before AND after the fix)', () => {
|
||||
it('a child of an unswitched parent runs under the live deployment default, with ZERO stamped events', async () => {
|
||||
const script: Script = []
|
||||
|
||||
@@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap
|
||||
|
||||
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
|
||||
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.inheritOverride(parent, child)` stamps a parent session's override (never the configured default) onto a child session through that write path — the in-process subagent driver calls it inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise. `ctx.approval.overrideOf(session)` / `ctx.approval.stampOverride(child, policy)` are the two halves of delegation inheritance — the fold alone (never the configured default), and the write of a captured override through that write path; the in-process subagent driver captures at delegation and stamps inside the child's first turn so a `'never'` parent cannot mint prompting children ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
|
||||
@@ -327,21 +327,31 @@ export class ApprovalService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the parent's approval-policy OVERRIDE onto a child session through
|
||||
* the canonical write path — the delegation-inheritance step: a `'never'`
|
||||
* A session's approval-policy OVERRIDE — the fold alone, never the
|
||||
* configured 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 policy, or `undefined` for a never-switched session.
|
||||
*/
|
||||
overrideOf(session: Session): ApprovalPolicy | undefined {
|
||||
return effectiveApprovalPolicy(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a captured override onto a child session through the canonical
|
||||
* write path — the write half of delegation inheritance: a `'never'`
|
||||
* (headless/CI) parent must not mint children that fall back to a prompting
|
||||
* default. Only the override chain is copied: an unswitched parent stamps
|
||||
* nothing, so the child keeps following the LIVE configured default. A
|
||||
* child whose log (e.g. a fork seed) already folds to the inherited policy
|
||||
* default. A child whose log (e.g. a fork seed) already folds to the policy
|
||||
* 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.
|
||||
* @param policy - the captured {@link overrideOf} value to stamp.
|
||||
*/
|
||||
inheritOverride(parent: Session, child: Session): void {
|
||||
const inherited = effectiveApprovalPolicy(parent.events)
|
||||
if (inherited === undefined || effectiveApprovalPolicy(child.events) === inherited) return
|
||||
setApprovalPolicy(child, inherited)
|
||||
stampOverride(child: Session, policy: ApprovalPolicy): void {
|
||||
if (effectiveApprovalPolicy(child.events) === policy) return
|
||||
setApprovalPolicy(child, policy)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -577,44 +577,39 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('inheritOverride (parent → child stamping)', () => {
|
||||
describe('delegation inheritance (overrideOf + stampOverride)', () => {
|
||||
const policyEvents = (session: Session) => session.events.filter(e => e.type === 'approval/policy')
|
||||
|
||||
function bareSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
|
||||
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
|
||||
it('overrideOf folds to the LAST override and never falls back to the configured default', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-inherit-parent')
|
||||
const child = bareSession('sess-appr-inherit-child')
|
||||
setApprovalPolicy(parent, 'never')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
expect(ctx.approval.overrideOf(parent)).toBe('never')
|
||||
expect(ctx.approval.overrideOf(bareSession('sess-appr-unswitched'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stampOverride appends the captured policy through the canonical write path', async () => {
|
||||
const ctx = await mounted()
|
||||
const child = bareSession('sess-appr-inherit-child')
|
||||
|
||||
ctx.approval.stampOverride(child, 'never')
|
||||
|
||||
const stamped = policyEvents(child)
|
||||
expect(stamped).toHaveLength(1)
|
||||
expect(stamped[0]?.data).toEqual({ policy: 'never' })
|
||||
})
|
||||
|
||||
it('appends NOTHING when the parent never switched (the configured default must stay live)', async () => {
|
||||
it('stampOverride skips a child already folding to the policy (fork-seed dedup)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-default-parent')
|
||||
const child = bareSession('sess-appr-default-child')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
|
||||
expect(child.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips the append when the child already folds to the inherited policy (fork-seed dedup)', async () => {
|
||||
const ctx = await mounted()
|
||||
const parent = bareSession('sess-appr-dedup-parent')
|
||||
const child = bareSession('sess-appr-dedup-child')
|
||||
setApprovalPolicy(parent, 'never')
|
||||
setApprovalPolicy(child, 'never')
|
||||
|
||||
ctx.approval.inheritOverride(parent, child)
|
||||
ctx.approval.stampOverride(child, 'never')
|
||||
|
||||
expect(policyEvents(child)).toHaveLength(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user