feat(session-control): let a cron dispatch the sessions it creates - #8335
feat(session-control): let a cron dispatch the sessions it creates#8335chenmingwei23 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Ownership-fenced admission is the right shape, but the new Watch
Suggestions
[DESIGN-REVIEWED] ba3fac0 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. Producing the review. First-Principles-Verdict: CONCERNS Solid, derived work — but the fence quietly tightens ordinary agent-created sessions, a fourth population the "three populations" doc denies exists. What this change shipsIntent: let a scheduled cron job create and drive its own worker sessions — an ADDITION, honestly labelled
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] ba3fac0 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/dashboard/session_control.py:233 -- Generic app-created crons are treated as user-owned |
8c5e3c2 to
a0c88b5
Compare
ai-review-dispositionGPT: App-owned crons bypass app isolation ( The finding is correct and the mechanism is exactly as stated. Fixed rather than reverted, because the derived-but-ignored identity is the defect, not the exemption: Two details worth flagging for the re-review:
Tests: 6 new cases in
|
a0c88b5 to
552ab5b
Compare
ai-review-dispositionGPT: cron authority is identified only by slot-key prefix / created child is an unfenced deputy -- ACCEPTED and FIXED in 552ab5b. The escalation chain is exactly as described, and I reproduced each link against the source rather than taking it on trust:
Fixed by making the fence follow AUTHORITY rather than spelling. Two properties worth checking in the re-review, because they are what make this a fix rather than a patch:
This tightens the MEMBER path too, and deliberately so: the same deputy hole existed there before this PR, since a member's worker also carries a Tests: 7 new cases in I did not take the alternative you offered (revert cron admission), because the deputy hole is a property of the fence's identity test rather than of admitting a cron: it was already reachable through a member, and reverting would have left it. |
552ab5b to
f0bbdc6
Compare
ai-review-dispositionBoth findings on 552ab5b ACCEPTED and FIXED in f0bbdc6. 1. Sticky attendance disables the ownership fence (
|
A cron job's own slot was refused as a session-control caller by slot-key prefix, so a scheduled run could not create or drive a session no matter what its agent was allowed to do. Replace the prefix refusal with the created_by ownership fence a crew member already uses: a cron reaches the sessions it created and nothing else. A workflow result slot stays refused - it is minted only once its originating tab is gone, so it has no owner to fence it to. The fence covers three populations, not two: a member slot, a cron slot, and anything either created. A child is minted with a plain chat- key and inherits its creator's agent, so without the third a fenced caller running a session-control agent gets an unfenced deputy for free. _created_by is the marker and needs no lineage walk, since create_session is its only writer; _human_seen releases a session a person is actually driving. A cron:<job_id> link is exempted from the caller-side channel-link refusals, which exist for links that republish to a channel audience; a cron link names the job's own run transcript. The target-side refusal is unchanged, so a cron still cannot reach another job's tab. An APP-owned cron is refused outright. _app is how every other isolation decision recognises an app, but the cron tab is minted without app=, so an app's own job would arrive with _app empty and pass - and could then create a persistent, non-app-scoped session, the confinement escape that refusal exists to prevent. Ownership is read from the job's created_by instead, and a job the registry cannot produce fails closed. A session a cron creates is tagged SlotOrigin.CRON. Minting USER would hand a cron the slots:user exposure its own slot is deliberately denied, by the route of creating a session and writing there instead.
f0bbdc6 to
ba3fac0
Compare
ai-review-dispositionGPT and OPUS independently found the same defect on f0bbdc6, and it is real. ACCEPTED and FIXED in ba3fac0. The origin tag stopped at hop 1 while the ownership fence propagated, and the gap between those two is the bug. Opus's trace is exact, so restating only the load-bearing step: a child INHERITS its creator's agent, so a cron's child can itself call Fixed as both of you suggested, by reading the caller slot's own One deviation from the suggested patch, worth flagging because it moves the line: I compute Mutation-verified rather than asserted. I dropped the 251 green across the six related suites; black / isort / flake8 / mypy clean. Spec updated. Standing dispositions, unchanged: |
What is the problem?
A cron job cannot use session control, and the refusal does not depend on which agent the job runs as. A job mapped to
kirocrew-conductor, an agent installed specifically for session control with nofs_writeand nocode, was refused exactly like a job running as the default agent, because the gate reads the slot's NAME.Three refusals fired, all keyed on the
cron-slot-key prefix or on a field a cron borrowed:create_sessionunattended_caller(UNATTENDED_SLOT_PREFIXES)_refuse_ineligible_creatorlinked_session_caller, becauseinject_cron_result_to_dashboardsetslinked_session_key = cron:<job_id>authorize_targetunattended_caller, the one that gatessession_send/read/stopagent.session_controldid not reach any of them: the config gate sits above the prefix check. "View last result" was not a way around it either, becauseapi_cron_to_chatreuses the samecron-<job_id>slot.Why this issue matters to the user
The blocked workflow is a morning dispatch: a 06:30 job enumerates the tasks due today, the user replies "work on 1, 2 and 6", and each task should get its own session so the three run in parallel with separate context. The fan-out needs
session_createplussession_send, and both were refused.Beyond that one workflow, the gate was checking the wrong property and its stated reason was already contradicted.
Wrong property. Capability is bounded per agent already:
@kirocrew-dashboardis an opt-in per-agent MCP server, deliberately absent from the default agent's spec, and_install_conductor_agent()mounting it IS the explicit assignment. That layer is fail-closed by construction, since an agent without the mount never sees the verbs. The prefix check added a gate on top that cannot tell a session-control agent from a general-purpose one.Contradicted reason. The comment said a cron must not "type into the user's live conversations unattended", and named
send_messageas the supported alternative. Butsend_message(session="origin")resolves the originating dashboard slot and, when it is idle, callsspawn_guarded_turn->_run_chat. That is an unattended scheduled job starting a turn in the user's live conversation, through a documented path. What actually separates the two is SCOPE:_resolve_session_targetaccepts only the literal"origin"and rejects arbitrary slot keys, so a cron can talk back to its owner and nothing else.Scope is a defensible line. It was not the line the code stated, and it does not justify refusing
create_sessionat all: a session the cron just created is empty, so there is no third party's turn to interrupt and nothing to clobber.How our fix solves it
The chain runs symptom, then stated reason, then real invariant, then the mechanism that already expresses it.
The real invariant is "a scheduled job must not reach the user's own sessions", and this repo already has a fence for exactly that shape.
authorize_targetrefuses a crew member on any slot it did not create (_created_by, 403), and that fence is precisely why_MEMBER_DASHBOARD_GRANTSmay auto-approve the write verbs while_CONDUCTOR_DASHBOARD_GRANTSwithholds them; the tuple comments state the reasoning. So:create_sessionandauthorize_targetadmit acron-caller, and the fence binds it. Both admissions and the fence read one predicate (_caller_is_ownership_fenced) so they cannot drift apart. Fail-closed on an unowned slot, which is what an ownerless rehydrate looks like.workflow-stays refused. It is minted only once its originating tab is gone, so there is no owning session to fence it to. Membership ofUNATTENDED_SLOT_PREFIXESis now the fail direction for any prefix added later: a new unattended surface is refused as a source until it is given a fence of its own.cron:<job_id>link is exempt from the caller-side channel-link refusals. Those exist for links that republish to a Slack or Telegram audience; a cron link names the job's own run transcript and republishes to nobody. Both caller-side sites are exempted together, keeping_refuse_ineligible_creatoran exact mirror ofauthorize_target's caller half as its docstring requires. The TARGET-side refusal is untouched.unattended_targetstands. A cron drives its own children, never another job's tab.SlotOrigin.CRON, notUSER. This is the one place the change would otherwise open something. A cron's own slot is tagged CRON so its output stays outside theslots:userWS scope ("a USER label would expose it to any app holdingslots:user"), and the trust model states the same rule from the other side: inferring USER for a background caller "put cron output insideslots:user". A USER-labelled child would hand a cron that exposure by the route of creating a session and writing there. Nothing is lost, because only app tokens are filtered by origin (_serialize_for_clientreturns the unfiltered payload to a dashboard user), so the child stays in the sidebar exactly as a cron tab does.Runaway creation needed no new work. The existing guards were written for this caller:
create_rate_limited(5-minute window),slot_cap_reached, andcreator_slot_cap_reachedkeyed on the caller so each job gets its own share, whose comment already reasons about "an automated creator looping on it".What tests we did
New
test/test_cron_session_control.py, 19 tests against REAL slot objects (the suite's own doctrine, since the guards readlinked_session_key/_created_by/_originoff the production class and a permissive double would let a dead guard look alive):workflow-caller still getsunattended_caller; the global switch still refuses a cron withsession_control_disabled.not_creatoron one it did not, getsunattended_targeton another job's tab, and fails closed on an unowned slot.slack:link on the same slot still getslinked_session_caller, and an app-scoped cron tab still getsapp_scoped_caller, so the exemption widens one refusal rather than the set.SlotOrigin.CRON; an ordinary caller's child is stillSlotOrigin.USER.Updated
test_scheduled_caller_cannot_control_anyone, which asserted the old contract, to assert the fence instead, and addedtest_workflow_caller_cannot_control_anyonebeside it so the surviving refusal keeps a test of its own.238 tests green across
test_cron_session_control.py,test_session_control.py,test_member_session_control.py,test_session_control_boundaries.py,test_queue_drain_revalidation.pyandtest_session_pulse_session_count.py. black, isort, flake8 and mypy clean on the changed files.The spec (
docs/system-specs/modules/session-control.md) moves with the code: two refusal-table rows and a new "Cron callers" section stating the admission, the fence, the link exemption, the origin rule, and the per-agent capability layer the prefix could not see.Any other suggestions on the work
Two things I found while doing this and deliberately did not fold in.
A cron's caller identity resolves only through its live tab, and that tab is minted after the first run's result is injected.
caller_slot_keywalks live slots, and the only creator site for acron-<job_id>slot is the post-run injection path. So a brand-new job's FIRST run has no tab, resolves to no caller, and is refusedcaller_unidentified: the capability lands from its second run onward. Jobs withpersistent_session=Falseorhide_in_chat=Truenever get a tab and so never become eligible, which is coherent and fail-closed, but the first-run gap is a bad first impression for exactly the person testing a new job. Fixing it means ensuring the tab exists at run start, which has to movehydrate_slot_from_historyalong with it, because the injection hydrates underif not slot.linked_session_keyand pre-linking the slot without moving the hydration would silently skip it. That belongs in its own PR against the cron delivery path, not bundled with an authorization change. Filed separately.Approval, not authorization, is the remaining step for a hands-off fan-out.
_CONDUCTOR_DASHBOARD_GRANTSwithholdssession_send, correctly, because a conductor agent also runs in dashboard sessions where no ownership fence applies. A cron whose dispatch must run without an approval prompt needs the write verbs in its own agent'sallowedTools. That is the existing per-agent extension point and needs no code change, so it is documented in the spec rather than widened here.Closes #8332