Skip to content

feat(session-control): let a cron dispatch the sessions it creates - #8335

Open
chenmingwei23 wants to merge 1 commit into
mainfrom
feat/cron-session-control-8332
Open

feat(session-control): let a cron dispatch the sessions it creates#8335
chenmingwei23 wants to merge 1 commit into
mainfrom
feat/cron-session-control-8332

Conversation

@chenmingwei23

Copy link
Copy Markdown
Contributor

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 no fs_write and no code, 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:

gate code
create_session unattended_caller (UNATTENDED_SLOT_PREFIXES)
_refuse_ineligible_creator linked_session_caller, because inject_cron_result_to_dashboard sets linked_session_key = cron:<job_id>
authorize_target unattended_caller, the one that gates session_send / read / stop

agent.session_control did not reach any of them: the config gate sits above the prefix check. "View last result" was not a way around it either, because api_cron_to_chat reuses the same cron-<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_create plus session_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-dashboard is 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_message as the supported alternative. But send_message(session="origin") resolves the originating dashboard slot and, when it is idle, calls spawn_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_target accepts 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_session at 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_target refuses a crew member on any slot it did not create (_created_by, 403), and that fence is precisely why _MEMBER_DASHBOARD_GRANTS may auto-approve the write verbs while _CONDUCTOR_DASHBOARD_GRANTS withholds them; the tuple comments state the reasoning. So:

  1. create_session and authorize_target admit a cron- 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.
  2. workflow- stays refused. It is minted only once its originating tab is gone, so there is no owning session to fence it to. Membership of UNATTENDED_SLOT_PREFIXES is 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.
  3. A 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_creator an exact mirror of authorize_target's caller half as its docstring requires. The TARGET-side refusal is untouched.
  4. unattended_target stands. A cron drives its own children, never another job's tab.
  5. The global switch still gates a cron. Unlike a member it gets no bypass: the switch is the user's statement that agents may open and drive sessions at all, and a job running while they are asleep is the last caller that should be exempt from it.
  6. A session a cron creates is tagged SlotOrigin.CRON, not USER. This is the one place the change would otherwise open something. A cron's own slot is tagged CRON so its output stays outside the slots:user WS scope ("a USER label would expose it to any app holding slots:user"), and the trust model states the same rule from the other side: inferring USER for a background caller "put cron output inside slots: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_client returns 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, and creator_slot_cap_reached keyed 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 read linked_session_key / _created_by / _origin off the production class and a permissive double would let a dead guard look alive):

  • admission: a cron caller passes the unattended refusal and creates a session; a workflow- caller still gets unattended_caller; the global switch still refuses a cron with session_control_disabled.
  • the fence: a cron reaches a session it created, gets not_creator on one it did not, gets unattended_target on another job's tab, and fails closed on an unowned slot.
  • the link exemption: a cron link passes, a slack: link on the same slot still gets linked_session_caller, and an app-scoped cron tab still gets app_scoped_caller, so the exemption widens one refusal rather than the set.
  • origin: a cron caller's child is SlotOrigin.CRON; an ordinary caller's child is still SlotOrigin.USER.

Updated test_scheduled_caller_cannot_control_anyone, which asserted the old contract, to assert the fence instead, and added test_workflow_caller_cannot_control_anyone beside 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.py and test_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_key walks live slots, and the only creator site for a cron-<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 refused caller_unidentified: the capability lands from its second run onward. Jobs with persistent_session=False or hide_in_chat=True never 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 move hydrate_slot_from_history along with it, because the injection hydrates under if not slot.linked_session_key and 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_GRANTS withholds session_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's allowedTools. 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

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of ba3fac0506f194e396313a644663e8ec706b0281 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Ownership-fenced admission is the right shape, but the new _created_by fence retroactively restricts existing non-cron callers — a compat change riding a feature PR.

Watch

  • _caller_is_ownership_fenced's third population ("anything either of them created") tightens behavior for sessions that exist today: with agent.session_control on, an agent-created chat- session could previously control any same-workspace session and now gets not_creator, including when a human is driving it ("a person working in an agent-created session keeps that session's reach rather than their own"). The tightening is necessary — without it the cron admission hands out an unfenced deputy — but it is an authorization regression for pre-existing switch-on workflows with no grandfathering, and humans should sign off on that trade explicitly.
  • First-run gap (caller_unidentified until the tab is minted after run one) means the feature a user tests immediately after creating a job fails; acknowledged and deferred, but expect it to be reported as "the PR doesn't work" until the follow-up lands.

Suggestions

  • Pair the cron: link exemption with _cron_caller(caller_key): the PR's own rationale keys cron identity on the immutable slot key because linked_session_key is "a field a later write could alter," yet the exemption trusts that mutable field alone.

[DESIGN-REVIEWED] ba3fac0

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of ba3fac0506f194e396313a644663e8ec706b0281 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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 ships

Intent: let a scheduled cron job create and drive its own worker sessions — an ADDITION, honestly labelled feat.

  1. A cron job can now create sessions — justified (bounded by the existing created_by fence).
  2. A cron can drive only sessions it created; others answer not_creator — justified.
  3. workflow- callers stay refused; future prefixes fail closed — justified.
  4. A cron's own transcript link no longer trips the caller-side channel-link refusals — justified.
  5. App-owned crons refused with two new codes, pre-resolution — justified; lookup hand-rolled (see Subtractions).
  6. Cron-created sessions at any depth tagged SlotOrigin.CRON, kept out of slots:user — justified (trust-model boundary).
  7. Children created by ordinary sessions are now fenced too — undeclared, rides along.
  8. Global agent.session_control switch still gates crons — justified.
  9. Spec section and refusal-table rows — mandated (same-commit rule).

Watch

  • Item 7: old fence was _member_caller(caller_key) and …; new _caller_is_ownership_fenced returns true for ANY non-empty _created_by (session_control.py:198). A child a plain user session created could previously read/send/stop any same-workspace session; now it gets not_creator. The doc claims "three populations … a member DM slot, a cron slot, and anything either of them created" — the code cannot distinguish "either of them" from anyone, so a fourth population is silently tightened. The uniform fence may be the smallest robust closure of the deputy hole (depth ≥2 needs a lineage walk), but the behavior change must be owned, not framed away.
  • The spec's "(_caller_is_ownership_fenced is the single predicate both admissions and the fence read, so they cannot drift)" is contradicted by the diff: both admissions read _cron_caller (session_control.py:827, 1282); the predicate has exactly 1 consumer, the fence (1403). The member switch-bypass reads _member_caller — drift is possible exactly where the sentence says it is not.

Subtractions

  • _app_owned_cron_refusal's 6-line for job in state.crons.list_jobs(include_disabled=True) scan re-spells an existing mechanism: CronStore.get_job(job_id) (src/kiro_crew/cron.py:3033) reads the same unfiltered cache. Replace the loop with state.crons.get_job(job_id).

[FIRST-PRINCIPLES-REVIEWED] ba3fac0

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed ba3fac0506f194e396313a644663e8ec706b0281 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] ba3fac0

Verdict parsed from the review's SHA-scoped output markers for commit ba3fac0506f194e396313a644663e8ec706b0281.

False positive or not applicable? A repository writer can comment:
/ai-review override fable ba3fac0506f194e396313a644663e8ec706b0281: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging ba3fac0506f194e396313a644663e8ec706b0281.

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
owner = str(getattr(job, "created_by", "") or "")
App agent calls cron_add -> ownership exists only in job.session_key -> second run passes this check and creates an unscoped persistent session.
Anchor: backend-security-controls
Fix: Also reject app ownership derived from job.session_key.
[BLOCK-MERGE] ba3fac0
[GPT-REVIEWED] ba3fac0
False positive or not applicable? A repository writer can comment:
/ai-review override gpt ba3fac0506f194e396313a644663e8ec706b0281: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/cron-session-control-8332 branch from 8c5e3c2 to a0c88b5 Compare September 4, 2026 01:23
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

ai-review-disposition

GPT: App-owned crons bypass app isolation (session_control.py:732) -- ACCEPTED and FIXED in a0c88b5.

The finding is correct and the mechanism is exactly as stated. _app is how every other isolation decision in these files recognises an app, but inject_cron_result_to_dashboard mints the cron tab with origin=SlotOrigin.CRON and NO app= argument, so an app's own scheduled job reaches this surface with _app == "". The exemption I added let it past the app_scoped_caller check beside it, and the child create_session mints is a persistent, sidebar-visible session with no app tag -- which is the confinement escape that refusal exists to prevent, reached through the app's cron instead of its session. Verified against apps/cron_sdk.py, which tags an app's jobs created_by = "app:{app_name}" (line 117).

Fixed rather than reverted, because the derived-but-ignored identity is the defect, not the exemption: _app_owned_cron_refusal reads created_by off the JOB, which is where app ownership is actually recorded, and refuses an app: owner with app_owned_cron_caller. A distinct code rather than reusing app_scoped_caller because mcp_dashboard renders that one with app-SESSION wording that would misdescribe a cron.

Two details worth flagging for the re-review:

  • Fail-closed on an unverifiable owner. A job the registry cannot produce, or a registry that raises, refuses with cron_owner_unverifiable. "Could not verify the owner" must not read as "has no owner", the same direction agent_unverifiable takes on its own unreadable input. Nothing legitimate is refused by it: a cron whose job is gone is not running.
  • Applied at BOTH caller-side sites (_refuse_ineligible_creator and authorize_target's caller half), so the two halves stay the exact mirrors _refuse_ineligible_creator's docstring requires, and scoped to cron callers so no other caller pays for the lookup (asserted by a test that makes the registry raise if consulted).

Tests: 6 new cases in test/test_cron_session_control.py covering app-owned refused on both paths, a user-owned (created_by = Slack user id) cron unaffected, an unfindable job, an unreadable registry, and the no-lookup-for-ordinary-callers scope. 244 green across the six related suites; black / isort / flake8 / mypy clean. The spec's refusal table and its "Cron callers" section carry the new rule.


Dependency Audit / Audit Production Dependencies is an infrastructure timeout, not a finding. The log reads production dependency audit failed closed: npm audit timed out after 120s for website/package-lock.json. This diff touches four files (one Python module, one spec, two test files) and no lockfile or manifest, so there is nothing here for the audit to have changed. Re-dispatched by this push rather than papered over with a .vulnerability-exceptions.json entry.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/cron-session-control-8332 branch from a0c88b5 to 552ab5b Compare September 4, 2026 02:24
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Sep 4, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

ai-review-disposition

GPT: 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:

  1. create_session mints the child through get_or_create_slot(None, ...), so its key is a plain chat-N.
  2. The child inherits the caller's agent (agent.strip() or caller_slot.agent), which for a dispatching cron is a session-control-capable agent -- one that mounts @kirocrew-dashboard.
  3. _caller_is_ownership_fenced keyed on the prefix, so chat-N read as an ORDINARY caller.
  4. An ordinary caller with the switch on reaches any same-workspace persistent session. So: cron creates child, seeds it via session_send (permitted, it created it), child reads the user's sessions, cron reads the child's transcript via session_read_message (also permitted). The fence was bypassable in one hop.

Fixed by making the fence follow AUTHORITY rather than spelling. _caller_is_ownership_fenced now covers three populations: a member DM slot, a cron slot, and anything either of them created.

Two properties worth checking in the re-review, because they are what make this a fix rather than a patch:

  • No lineage walk, so it cannot fail open at depth or on a closed ancestor. _created_by is written at exactly ONE site (create_session, line 1042 -- verified by grep across dashboard/; the two other mentions are the rehydrate paths restoring it). A person's own tab and a fork reach get_or_create_slot directly and stay unattributed. So a non-empty _created_by means "an agent made this session" at ANY depth: a grandchild carries its parent's chat- key there and is fenced by the same test. A chain whose middle slot has been closed is not a fail-open hole, because no chain is consulted.
  • _human_seen releases a session a person is actually driving, which is the same escape hatch _ChatSlot.unattended uses. It is set only by a dashboard-user route with an empty request_app, so neither an app nor a cron can forge it. Without it, a human who opens a cron-created tab and asks it to stop a stuck session would be refused for lineage they had nothing to do with.

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 chat- key. Ordinary human-created conductors are unaffected (_created_by empty), which is pinned by a test.

Tests: 7 new cases in test/test_cron_session_control.py -- the child cannot reach a session it did not create, the child still reaches its own children, a grandchild is fenced, a human-driven agent session is released, and a human-created caller is not fenced. 249 green across the six related suites; black / isort / flake8 / mypy clean. The spec carries a new "The fence propagates to what a fenced caller creates" section.

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.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/cron-session-control-8332 branch from 552ab5b to f0bbdc6 Compare September 4, 2026 03:13
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

ai-review-disposition

Both findings on 552ab5b ACCEPTED and FIXED in f0bbdc6.

1. Sticky attendance disables the ownership fence (session_control.py:194)

Correct, and the chain is exactly as stated. _human_seen is monotonic and persisted, and it answers "has a human EVER driven this slot" -- which is the right question for an approval WINDOW (is someone around to click) and the wrong one for "whose authority is this turn". So: cron creates the child, the user types into it once out of curiosity, and from then on every cron-AUTHORED turn in that child runs unfenced. The creator gets its deputy back for the price of a glance at the tab.

Taken as offered: the exemption is removed, no substitute. There is no cheap correct version of it either -- knowing whether the CURRENT turn was human-authored is not something this predicate has access to, and the regression it was protecting (a person working in a cron-created tab keeps that session's reach rather than their own) is much smaller than the escalation. A test now pins the opposite of what I had written: _human_seen = True on an agent-created slot stays fenced.

My original reasoning for adding it was wrong in a specific way worth naming, since it is the kind of mistake that recurs: I reached for _ChatSlot.unattended's hatch because it looked like the same question, without checking that the two predicates are asked at different times. unattended is consulted when a tool is waiting for approval, where "a human has been here" really does predict "a human can answer". This one is consulted per turn, where it predicts nothing.

2. App-owned cron refused after target resolution (session_control.py:1328)

Also correct, and it is an existence oracle rather than merely untidy ordering: a caller allowed to touch NOTHING got target_not_found (404) for a session that does not exist and app_owned_cron_caller (403) for one that does, so it could enumerate the user's session keys and titles by the shape of the error.

Moved above _resolve_slot, where the unattended prefix gate already sits for the same reason. The refusal needs only state and caller_key, so nothing else had to move with it. Pinned by a test that asserts the SAME code for an existing and a nonexistent target -- its mutation guard is direct: put the refusal back below the resolution and the nonexistent case answers target_not_found again.

One thing I did NOT change, flagged rather than silently left: the pre-existing caller-side block below the resolution (app_scoped_caller, ephemeral_caller, linked_session_caller, mirrored_caller) has the same shape and the same oracle. Moving those alters refusal precedence for callers that exist today, which is a behaviour change this PR should not be making, so it is recorded in the spec next to the new ordering note instead. Happy to file it separately if you would rather it were tracked.

Unrelated red

CI / Backend Tests (Windows) (4) is a pre-existing timing flake, not this diff. test_snapshot_absent_vs_wrong_type::test_a_second_allocation_in_the_same_second_gets_its_own_directory needs both allocations inside one second; they landed on pre-restore-20260904T023330Z and ...023331Z, so the premise the assertion depends on did not hold. Nothing in this change touches snapshot or rollback directories. Dependency Audit remains the npm audit 120s timeout dispositioned earlier -- still no lockfile in this diff.

250 green across the six related suites; black / isort / flake8 / mypy clean. Spec updated for both changes.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
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.
@chenmingwei23
chenmingwei23 force-pushed the feat/cron-session-control-8332 branch from f0bbdc6 to ba3fac0 Compare September 4, 2026 03:31
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

ai-review-disposition

GPT 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 create_session, and at that point caller_key is a plain chat- key. _cron_caller returns False, the grandchild is minted SlotOrigin.USER, and ws_event_scope.py:684 delivers it to any app holding slots:user. The fenced child may then send cron-authored content into it, because it created it. Two hops, and the result is exactly the "create a session and write there instead" route the comment above that line says must be denied.

Fixed as both of you suggested, by reading the caller slot's own _origin alongside the prefix. That closes it transitively rather than at one more hop: the child carries CRON, so its children do too, at any depth. Same shape as the fence fix in the previous round, and for the same reason -- both tests have to follow authority, not spelling.

One deviation from the suggested patch, worth flagging because it moves the line: I compute child_origin from live_caller after the re-gate instead of from caller_slot at line 832. The origin is a decision input to the allocation, and everything read before the folder gate was read before the coroutine suspended -- the module's own rule for this span is "after the LAST suspension point, re-read EVERY decision input, no await before the act". _origin is stable in practice, so this is not a live bug being fixed; it keeps the new read on the correct side of the boundary rather than adding a second span for a future change to get wrong.

Mutation-verified rather than asserted. I dropped the _origin half of the condition and re-ran the new test: it fails with AssertionError: cron output would enter slots:user. Restored, green. The test also pins the negative direction, that an ordinary caller's child is still SlotOrigin.USER.

251 green across the six related suites; black / isort / flake8 / mypy clean. Spec updated.

Standing dispositions, unchanged: Dependency Audit is the npm audit 120s timeout (no lockfile in this diff), and the earlier Backend Tests (Windows) (4) red was the test_snapshot_absent_vs_wrong_type same-second timing flake.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cron cannot use session control, regardless of which agent the job runs as

1 participant