fix(cron): confine channel agents from scheduling a privileged agent (#8371) - #9049
fix(cron): confine channel agents from scheduling a privileged agent (#8371)#9049chenmingwei23 wants to merge 1 commit into
Conversation
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: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks complete. The fix is well-derived and correctly placed; the one real finding is that the new deny helper is a near-verbatim second copy of an existing one it explicitly "mirrors". Final review: First-Principles-Verdict: CONCERNS
What this change shipsIntent: stop a confined channel agent from scheduling durable cron work that runs as a more privileged agent (issue #8371). This is a FIX.
The placement claim checks out: Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 20ebc0e |
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/mcp_cron.py:1633 -- Descendants bypass channel-agent cron confinement Adjudication (Opus 4.8) — is blocking on each finding proportionate?I've verified the chain. Findings summary: The subagent session key is False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right boundary, right layer: the deny sits at MCP dispatch on the gateway-vouched identity, exactly where the auto-approve path can't skip it. Suggestions
[DESIGN-REVIEWED] 20ebc0e |
…8371) cron_add and cron_update accept an `agent` (cron_update maps it onto agent_id on an existing job) and pass it to add_job / update_job with no check against the caller's confinement. A channel agent -- confined to channel posts by CHANNEL_AGENT_BLOCKED_TOOLS -- could schedule agent="kirocrew", approval_mode="auto" and have a full-tool agent run on the gateway host on a timer, an escalation that outlives its turn and the channel. cron_update is a second door: it re-targets an existing job's agent without creating anything. The denial is placed in mcp_cron (_deny_channel_agent_cron), keyed on the STRICT, gateway-vouched caller session, and applied as the first check in both the cron_add and cron_update branches. It mirrors mcp_core._deny_channel_agent_messaging. Placing it at MCP dispatch rather than in CHANNEL_AGENT_BLOCKED_TOOLS is load-bearing: that list is consulted only in channel.py's permission-request gate, and an @kirocrew-cron/cron_add entry in a channel agent's allowedTools is translated to a KAS auto-approve permission (acp/kas_permissions.py; MCP tools are not in WITHHELD_FROM_AUTO_APPROVE). Auto-approval is the ABSENCE of a permission request, so the list guard never runs -- the containment has to hold at dispatch, on the verified identity. (This corrects the first revision of this PR, which added the two verbs to that list; a reviewer showed the auto-approve path bypasses it, and the change also broke the neighbouring non-shell-tool contract in test_channel_trusted_patterns.py, which uses cron_add as its canonical non-shell example.) Only the channel: orchestrator-agent namespace is confined. A slack:/discord: session is an allow-listed human scheduling their own work, and a dashboard session scheduling for another crew is the legitimate flow the issue is careful not to break -- neither consults this gate. Out of scope, noted for follow-up: the audit-trail half (created_by unwritten by mcp_cron, runs attributed to cron:<job_id>). Closes #8371
8102e76 to
20ebc0e
Compare
What is the problem?
cron_addandcron_updatelet a contained caller schedule a durable job that runs as a MORE privileged agent than the caller itself. Both take anagent(cron_updatemaps it ontoagent_idon an existing job) and pass it straight toadd_job/update_jobwith no check comparing the requested agent against the caller's confinement:Why this matters to the user
A channel agent's reach is deliberately bounded to channel posts --
CHANNEL_AGENT_BLOCKED_TOOLSalready holds backsend_message,send_notification, and everysession_*verb for exactly that reason. Neither cron verb was covered anywhere. So a channel agent could:and have a full-tool agent (shell, filesystem, everything the named agent carries) run on the gateway host on a timer, with its own restrictions nowhere in the picture. It is durable (outlives the turn and the channel conversation), and
cron_updateis a second door -- it re-targets an existing job'sagent_idwithout creating anything.How the fix solves it -- symptom to root cause
The root cause is that these verbs run agent-chosen work outside the channel, which is the exact shape
CHANNEL_AGENT_BLOCKED_TOOLSexists to hold back. The fix denies both verbs to a channel agent inmcp_cron(_deny_channel_agent_cron), keyed on the STRICT, gateway-vouched caller session (_authz_session_key, which refuses forgeable sources), applied as the first check in both thecron_addandcron_updatebranches. It mirrors the existingmcp_core._deny_channel_agent_messaging.The placement is the whole point, and it corrects the first revision of this PR. The first revision added
cron_add/cron_updatetoCHANNEL_AGENT_BLOCKED_TOOLS. That list is consulted only inchannel.py's permission-request gate -- but an@kirocrew-cron/cron_addentry in a channel agent'sallowedToolsis translated to a KAS auto-approve permission (acp/kas_permissions.py; MCP tools are not inWITHHELD_FROM_AUTO_APPROVE), and auto-approval is the absence of a permission request, so the list guard never runs. GPT 5.6's blocking review caught this and it is correct -- a named entry in that list is not a boundary for an auto-approved call. Moving the denial to MCP dispatch, on the verified identity, closes the auto-approve path. (The list approach also broke the neighbouring non-shell-tool contract intest_channel_trusted_patterns.py, which usescron_addas its canonical non-shell example -- another reason the list was the wrong home.)Only the
channel:orchestrator-agent namespace is confined. Aslack:/discord:session is an allow-listed HUMAN participant scheduling their own recurring work, and a dashboard session scheduling a job for a different crew is the legitimate flow the issue is careful not to break -- neither consults this gate, so both keep working.Scope note (from review): the in-code / earlier claim that
cron_updatecan re-point "a job an operator already created and reviewed" is narrowed._check_cron_job_ownershiprestricts updates to jobs keyed to the caller's own session, so the real reach is the channel agent's OWN-session jobs -- still a named channel-boundary harm (it re-targets the agent those jobs run as), so both verbs are confined, but the threat is own-session, not operator-owned.Out of scope (noted per the issue): the audit-trail half --
created_byis unwritten bymcp_cronand later runs are attributed tocron:<job_id>rather than the authoring principal.Known limitation -- descendant propagation (filed separately).
_deny_channel_agent_cronkeys on the IMMEDIATE caller's session (_authz_session_key), so it closes a channel agent callingcron_add/cron_updatedirectly. It does NOT propagate the channel agent's confinement to a session it spawns:spawn_runis not inCHANNEL_AGENT_BLOCKED_TOOLS, and a spawned subagent's key issubagent:<id>(notchannel:<...>), so a channel agent that spawns a subagent which then callscron_addevades this guard. Closing that requires threading channel-origin identity through the MCP caller context (mcp_cronis a separate stdio process and cannot see the spawn chain -- it has no reference to the subagent manager orparent_session_key), and the identical exposure exists for the mirroredmcp_core._deny_channel_agent_messaging, so it is a systemic channel-confinement-propagation gap rather than a cron-specific one. Tracked as a separate follow-up issue; deliberately not attempted here.What tests we did
Added to
test/test_mcp_cron_caller_identity.py(the existing home for caller-identity-gated cron behavior, which already drives_call_tool_innerwith a strict caller block):cron_addis refused and mints no job.cron_updateis refused before the ownership check._call_tool_inner-- the dispatch path a call reaches AFTER approval -- proving it does not depend on a permission event (the auto-approve bypass), and it denies even a plain self-scoped schedule with noagentoverride (all-or-nothing, matchingsend_*/session_*).slack:human session may still schedule cron (legitimate flow preserved).dashboard:session may schedule a job for a different crew (the flow the issue protects).Mutation-verified: neutering
_deny_channel_agent_cronto a no-op turns 3 of these red while the "still allowed" tests stay green. All 37 tests in the file pass (32 pre-existing caller-identity tests unaffected). The twotest_channel_trusted_patterns.pyfailures from the first revision are resolved (that file is untouched now). Ran targeted only,-n0.Lint run independently, all clean on both touched files:
black --target-version py310,isort,flake8,mypy(mcp_cron.py). Black baseline gate passes with no baseline change (the first revision's baseline-shrink was an artifact of touching a baselined file, now reverted).Pattern harvest
Rule candidate: When confining a tool that an existing block-list already governs, verify WHERE that list is enforced before adding to it.
CHANNEL_AGENT_BLOCKED_TOOLSis checked only atchannel.py's permission-request event; an MCP tool named in an agent'sallowedToolsis KAS auto-approved and fires no such event, so the list never sees it. A capability confinement for an MCP tool must live at MCP dispatch on the verified caller identity (the_deny_channel_agent_*pattern), not on a permission-event list -- a named entry in a list is not a boundary until something refuses the call on the path the call actually takes.Closes #8371