fix(session-control): let a dispatched session inherit its creator's trust - #8571
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Aligns dispatched sessions with the The one real risk — transitive inheritance with no cascade revoke — is disclosed in the spec, bounded by in-memory trust plus the global picker, and already tracked (#8589), so it is a follow-up, not a defect in this shape. The child-with-a-named-agent case adds nothing new: existing code ( [DESIGN-REVIEWED] 8c25d43 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims in the description verify against the repository: First-Principles-Verdict: PASS A trusted operator's dispatched worker no longer stalls unattended; every item is derived from that defect or a documented invariant, exclusions included. What this change shipsIntent: make a dispatched worker session inherit the supervision posture its creator already held, so unattended dispatch stops stalling on approval prompts — a FIX.
Verification notes: the two audit [FIRST-PRINCIPLES-REVIEWED] 8c25d43 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the code at Falsifying CANDIDATE 1: the "widening" it describes is the deliberate, documented delegation posture — trust following dispatched work, mirroring the existing No findings. [OPUS-REVIEWED] 8c25d43 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
2fd9599 to
277f98e
Compare
|
Accepted as written. The child is now born with What convinced me this was right rather than merely arguable is that inheriting the patterns could not pay for itself in either direction. Where it would have been safe it was dead weight: with The underlying distinction, now recorded in the code comment: Two tests pin the exclusion, both red against the previous revision |
|
Design Review 🟡 CONCERNS — "the grant now outlives and outspreads its session" —
Both watch items are legitimate, and the verdict is right that neither blocks: they are tracked together in #8589 ( Taking the transitive-grant item honestly rather than shrugging at it: this PR does introduce the gap. Before it a child was never trusted, so there was nothing to fail to revoke. What keeps it out of this PR is not that it is small but that the fix needs a ruling I should not make for you. On the copy item: the scope change is real and the fix is a string, but it lands in the English catalog plus ten locale bundles and turns a two-file backend diff into a UI change carrying a screenshot obligation. It also cannot be written correctly until Task 1 decides whether the click implies a cascading revoke, so writing it now risks shipping copy that the follow-up immediately contradicts. Same issue, stated as its own task with its own acceptance criteria. One point of agreement worth recording, since it was the part most likely to be wrong: the verdict confirms Separately, the pattern-inheritance concern that the GPT 5.6 lane raised as BLOCKING on this same head is fixed in |
…trust
A conductor running with "trust this session" dispatched workers that were
born interactive. Every one of them then blocked on its first tool call with
nobody watching -- which is the same failure `parent_trusted` already closes
for `spawn_run` subagents, one layer up: a subagent reads the parent's stored
policy and starts auto-approved, while a `session_create` child started from
`_ChatSlot.__init__`'s empty defaults. The dashboard's global Trust only ever
writes the slots that exist when it is clicked, so a session dispatched
afterwards was never covered by it either.
`create_session` now carries the creator's session POSTURE to the child, and
only that: `_trust`, plus `_trust_reads` so that the setting a cautious
operator picks is not the one whose own workers still stall. The session-store
half needs no write here -- the child has no ACP session yet, where
`set_approval_policy` silently no-ops on a missing session, and `chat_runner`
already assigns the persistable policy from `_trust` on every session
create/resume, so the subagent spawn gate sees it from the child's first turn.
Two exclusions carry the safety, and they are the reason this is not simply
"copy the trust fields":
`_trusted_patterns` is NOT inherited. These are per-command grants ("`npm
test` is fine"), not a posture, and the distinction decides it: a pattern is
judged against the session the operator was LOOKING at, while a dispatched
worker runs model-authored work they have not seen, so the same glob can admit
a command the grant was never asked about. Inheriting them also buys nothing
where it would be safe -- with `_trust` set the child already auto-approves via
`_slot_is_trusted`, so the pattern list is dead weight -- and changes the
outcome ONLY when the operator withheld session trust and approved single
commands instead, which is exactly the case that must keep asking, because
`chat_runner` matches patterns independently of `_trust`.
`_trust_scope` is NOT inherited. It names a TTL-bounded, SEL-audited
`SafetyOverride` scope whose whole value is being re-checked on every approval;
forking the key would hand a second session a credential whose revocation this
path cannot observe, so the child would keep auto-approving after the scope
that justified it is gone. An unattended worker that needs one gets its own,
from whatever owns its lifecycle.
The value that transfers is read off `live_caller`, in the synchronous window
after the last gate -- not off the entry-time slot. `create_session` suspends
three times before the slot exists (project dir, config load, folder
confirmation), and an operator picking `normal` in any of those windows would
otherwise have a revoked grant resurrected by a create already in flight.
Revoking mid-call now yields an untrusted child, which is the direction that
fails safe.
Nothing is persisted at birth: trust is in-memory by construction, so a
restart returns the child to interactive along with its creator. The blast
radius stays bounded as before by `allow_create`'s per-caller rate window and
`MAX_SLOTS_PER_CREATOR`.
The create audit records what the child was born with (`inherited_trust`,
`inherited_trust_reads`) on both outcomes, so an auto-approved tool call in a
dispatched session is traceable to the creator's posture instead of appearing
unexplained, and "false" is positive evidence it did not transfer.
Tests: 8 in test_session_control.py. Three fail against pristine main (trust
inherited, trust_reads inherited, audit fields present); two more fail against
the first revision of this branch, pinning the pattern exclusion (no command
grant transfers even under full trust, and a pattern-only creator's child still
asks); three are negative controls -- an untrusted creator produces an
untrusted child, a scoped grant never forks, and a posture revoked inside the
project-dir resolution is not inherited.
Pattern harvest
Rule candidate: review-prompt
Pattern: a control-plane verb that creates a child copies identity-shaped state
(workspace, agent, project) and must decide separately about authority-shaped
state. Split that authority by whether it is a POSTURE ("auto-approve while I
supervise") or a per-object GRANT ("this command is safe"): a posture describes
the supervision relationship and can follow the delegation, while a grant was
judged against content the child does not share and must not. Anything
revocable or TTL-bounded is excluded from the copy rather than forked.
277f98e to
8c25d43
Compare
|
Design Review 🟡 CONCERNS — "silently widens what the click grants, with no spec or UI update" —
Correct on both counts, and the spec half was a genuine hole in this diff rather than a follow-up: AGENTS.md line 30 requires the module doc to be updated in the same commit as what it documents, and
It also records the transitive-grant gap as a Known gap in the spec rather than leaving it only in a tracker, with the three things that bound it (slot caps, in-memory-only trust, and the global Trust picker clearing every live slot) and a pointer to #8589. A gap that is real but accepted should be legible to the next reader of the spec, not just to whoever finds the issue. The UI disclosure half stays deferred to #8589 ( On the suggestion — a follow-up "untrust this session and its workers" that walks |
Problem / Motivation
A conductor session running with "trust this session" dispatches worker sessions via
session_create, and every one of them is born interactive. The worker then blocks on its first tool-approval prompt with nobody watching it — which is precisely the case trust was enabled for. The dashboard's global Trust does not cover it either: that path iterates the slots that exist at click time, so a session dispatched a minute later is untouched.spawn_runsubagents already do the right thing here.SubagentManager's admission gate readssessions.get_approval_policy(parent)(parent_trusted) and starts the subagent auto-approved. Asession_createchild took its trust state from_ChatSlot.__init__'s empty defaults instead, so the same delegation behaved differently depending only on whether it got a sidebar tab.Why it matters
It breaks unattended dispatch. Conductor patrol is autonudge-driven: it wakes itself, finds a worker parked on an approval nobody answered, and the goal makes no progress until a human opens the tab and clicks. The operator already granted the authority; the grant simply did not reach the session doing the work.
What changed (motivation → approach → change)
Root cause:
create_sessioninherits workspace, agent, project dir and folder, and nothing about approval posture — trust is per-slot state with no transfer path.create_sessionnow carries the creator's session posture to the child, and only that:_trust— the "trust this session" grant._trust_reads— the same posture narrowed to read-only bash. It has to carry too, or the setting a cautious operator picks is the one whose own workers still stall. It is bounded by construction: what it admits has no side effects.The session-store half needs no write here. The child has no ACP session yet, where
set_approval_policysilently no-ops on a missing session, andchat_runneralready assigns the persistable policy from_truston every session create/resume — so the subagent spawn gate sees it from the child's first turn, through the existing seam rather than a second one.Two exclusions carry the safety, and they are why this is not just "copy the trust fields":
_trusted_patternsis not inherited. These are per-command grants ("npm testis fine"), not a posture, and that distinction decides it: a pattern is judged against the session the operator was looking at, while a dispatched worker runs model-authored work they have not seen, so the same glob can admit a command the grant was never asked about. Inheriting them could not pay for itself in either direction — with_trustset the child already auto-approves through_slot_is_trusted, so the list was dead weight; and becausechat_runnermatches patterns independently of_trust, it changed an outcome only when the operator had withheld session trust and approved single commands instead, which is exactly the case that must keep asking. Raised as BLOCKING by the GPT 5.6 lane on2fd95999and fixed in277f98eb; dispositioned in-thread._trust_scopeis not inherited. It names a TTL-bounded, SEL-auditedSafetyOverridescope whose entire value is being re-checked on every approval. Forking the key would hand a second session a credential whose revocation this path cannot observe, so the child would keep auto-approving after the scope that justified it is gone. An unattended worker that needs one gets its own, from whatever owns its lifecycle.The value transferred is read at allocation, not at entry.
create_sessionsuspends three times before the slot exists (project dir, config load, folder confirmation). Reading the entry-time slot would let an operator picknormalmid-call and still have the revoked posture resurrected by a create already in flight. It readslive_callerin the synchronous window after the last re-gate, so revoking mid-call yields an untrusted child — the direction that fails safe.Nothing is persisted at birth: trust is in-memory by construction, so a restart returns the child to interactive along with its creator. The blast radius is bounded as before —
allow_create's per-caller rate window andMAX_SLOTS_PER_CREATOR(50) already exist becausesession_createis auto-approved for the conductor (#6109).The create audit records what the child was born with (
inherited_trust,inherited_trust_reads) on both outcomes, so an auto-approved tool call in a dispatched session is traceable to the creator's posture instead of appearing unexplained, and"false"is positive evidence it did not transfer.Tests
8 tests in
test/test_session_control.py— five that fail without the change, three negative controls that make those five mean something.test_created_session_inherits_the_callers_trust_trusttransferstest_created_session_inherits_trust_readstest_the_create_audit_records_what_the_child_was_born_withtest_command_grants_never_transfer_even_under_full_trust_trusted_patternsstays empty on the child even with_trust=True, and the caller keeps its own2fd95999test_a_pattern_only_creator_produces_a_child_that_still_asks_trust=False+ patterns case from the GPT finding: child born with nothing2fd95999test_an_untrusted_creator_makes_an_untrusted_childTruepasses the first three)test_the_scoped_safety_override_grant_is_never_inherited_trust_scopedoes not fork, and is not laundered into_trusttest_trust_revoked_mid_create_is_not_inheritedRed-before verified by reverting
session_control.pyalone with the tests in place: 3 failed against pristine main, and 2 failed against this branch's first revision.Manual verification
N/A — unit coverage sufficient. The transfer is two synchronous assignments inside
create_session; both consumers of the flags (_slot_is_trustedper approval,_persistable_session_policyfor the stored policy) are already covered, and the mid-call revoke window is exercised deterministically by monkeypatching the suspension rather than by timing.Related Issues
no linked issue: reported directly from a conductor run, so there is no tracked issue for this to close.
Docs
docs/system-specs/modules/session-control.mdgains a "What a created child inherits" section — the identity-versus-posture split, the two exclusions and why each is excluded, the allocation-time read, the birth metadata (showing no trust field is persisted), the two auditdetailfields, and the transitive-grant gap recorded as a Known gap pointing at #8589. Required in the same commit by AGENTS.md, and raised by the Design Review lane.Pattern harvest
Rule candidate:
review-promptPattern: a control-plane verb that creates a child copies identity-shaped state (workspace, agent, project) and must decide separately about authority-shaped state. Split that authority by whether it is a posture ("auto-approve while I supervise this") or a per-object grant ("this specific command is safe"): a posture describes the supervision relationship and can follow the delegation, while a grant was judged against content the child does not share and must not. Anything revocable or TTL-bounded is excluded from the copy rather than forked. The tell that a copy is wrong: it is redundant in the case where it would be safe, and load-bearing only in the case where it is not.