fix(cron): resolve the calling session from the injected caller block - #4632
fix(cron): resolve the calling session from the injected caller block#4632chenmingwei23 wants to merge 1 commit into
Conversation
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: |
4145763 to
eb21680
Compare
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound fail-closed redesign at the right layer; the one-way scope rule strands every pre-existing chat-created cron with no in-band recovery. Watch
[DESIGN-REVIEWED] 5772433 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I've read the contract, the intent file, the patch, and verified the load-bearing claims against the repo ( First-Principles-Verdict: CONCERNS The fix adopts the mechanism that already existed and lands at cause level — but two of four managed servers share the exact root cause and stay unfixed. What this change shipsIntent: make cron's per-session ownership gate actually enforce, by giving the pooled cron server the calling session's identity. A FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 5772433 |
eb21680 to
e7b8c4b
Compare
|
Round 2: GPT's blocking finding is ACCEPTED and fixed forward on Its diagnosis was right and my justification was wrong. I had written that the The finding also caught an inconsistency in my own reasoning. I used one fact -- What changed:
Verification: 5 new mutation probes, all killed -- gate back to allowing an The PR body's now-falsified "the set cannot grow" paragraph has been corrected in |
e7b8c4b to
cd630b4
Compare
|
Round 3 on GPT was right and my round-2 reasoning was too narrow. I had kept ownerless So reads and writes now share ONE scope function, Consequence, stated rather than buried: a cron created with A pre-existing enumeration oracle, found by one of the new tests. The gate's Design Review Watch items:
First Principles Review returned "could not complete" (model error, advisory, Verification: 6 new mutation probes, all killed -- scope widening back to |
cd630b4 to
41750c9
Compare
|
Round 4 on GPT: the
Design Review: the description documented a withdrawn contract. Also correct,
The identity-less non-gateway launch remains unverified end-to-end and is called Verification: 3 new mutation probes, all killed -- the audit call removed |
41750c9 to
481b548
Compare
|
Round 5 on The gap is real; its location is not where the finding points. GPT asks for an So the fix is one line at the wrapper, not a third event: Emitting the suggested extra event would have double-logged every Verification: 2 mutation probes, both killed after one was found MIS-TARGETED. Scoped suite (cron/mcp/gateway/audit): 7928 passed, 1 failed -- the known host |
kirocrew-cron read its calling session from process environment. On a pooled backend one process serves many sessions, and gatewayd forwards no session-identifying variable to a shared backend, so what it read there was empty -- and every session-scoped path took its empty branch. Those branches disagreed: the per-job ownership gate allowed, cron_list skipped its filter, cron_add stored an ownerless row, and only cron_remove_all refused. Two of those are fail-open, which made the ownership gate dead code for exactly the callers it exists to separate. The mechanism for doing this correctly already shipped. mcp_cron even imports mcp_core's resolver, whose first source IS the caller block, and the stdio shim binds that block unconditionally -- but gatewayd strips any block from every forwarded request and re-injects its own only for a backend that advertised kirocrew.caller-identity, which this server never did. Nothing declines to POOL an unadvertised backend (rewriter.UNPOOLABLE_SERVERS is empty and says so), so the unadvertised state was not per-session spawn; it was pooled and identity-blind. Advertise the capability, and route every authorization decision through one strict resolver so a forgeable per-pid file can no longer decide who may delete whose job. Unify the empty-identity branches on one rule: reads still work, writes refuse, the CLI keeps its admin bypass. An unidentified caller can still share a pooled backend with identified ones -- gatewayd forwards caller=None when a stub registers without a key and peer resolution fails -- so empty identity does not imply a 1:1 transport and must not carry authority over stored rows. Rows written before this change keep the access they have today; cron_add refusing an unidentified caller is what keeps that set from growing. cron_add's channel default reads the block's channelId, which had the same defect as the session key. Fixes #4622
481b548 to
5772433
Compare
|
Round 6 on Its sharpest point is right and I had half-fixed the thing I called the root cause. The PR's own analysis is "nothing declines to pool an unadvertised backend", and I corrected the one comment asserting otherwise in
Both now state what the code does: the capability decides INJECTION, not pooling, so not advertising buys a shared process with no identity rather than a private one. Point-patching the comment in the file I happened to be editing, while the authoritative one kept the myth, would have left the next reader exactly the false safety property that let this bug live. Both rewrites point at Stale docstring, also right: Escalated rather than actioned: deleting the The remaining Watch -- the invariant gap for Verification: comment-and-docstring changes only, no behaviour touched; |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates were rated low-confidence by the discovery pass, and neither survives falsification: Candidate 1 requires that a pooled Candidate 2 is confirmed as ordering (script/command vetting in Step 2 turned up nothing grounded: the No findings. [OPUS-REVIEWED] 5772433 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Round 7 on Both advisory verdicts converge on three asks. Two are now filed rather than implied, which is what each lane specifically requested:
The third ask, deleting the One item to record rather than change, from First Principles: flipping Not verified locally, restated so it is not lost in the thread: the identity-less non-gateway launch (a kiro-cli with neither Five review rounds, and the reviewers were right about the defect in every one of them -- a fail-open ownership exemption, a disclosure path, a missing audit event, an unattributed successful authorization, and a false premise I had only half-corrected in the very comment I was citing as the root cause. What changed each time was my justification, not their diagnosis. Awaiting the author's approve. Not merging. |
What is the problem?
kirocrew-cronworked out which session was calling it by reading its ownprocess environment. On a pooled backend one process serves many sessions, and
process environment can only ever name one of them -- and gatewayd forwards no
session-identifying variable to a shared backend at all, so what this server
actually read there was EMPTY.
Every session-scoped path then took its empty branch, and those branches did not
agree with each other:
_check_cron_job_ownershipreturn None-- allowcron_listcron_addsession_key=""-- ownerless rowcron_remove_allTwo of those are fail-open. So the per-job ownership gate was dead code for
every caller arriving through the gateway -- which is every caller it exists to
separate:
cron_update,cron_remove,cron_pause,cron_resumeandcron_triggeraccepted any job id from any session.cron_addwas minting rowswith no recorded owner, which is also why
session="origin"delivery had nothingto route back to.
The mechanism for doing this correctly already shipped, and this server had not
adopted it.
Why this issue matters to the user
Two sessions cannot be kept apart. A user running a dashboard tab and a Slack
thread has two sessions whose scheduled jobs are supposed to be their own; today
either one can list, pause, retime or delete the other's, and nothing in the
audit trail can attribute which did.
Nothing crossed tenants BEFORE this change only because identity was uniformly
empty -- there was no other identity to cross into. That is not a property to
rely on: the moment any caller resolves a real key, the fail-open branches start
granting an unidentified one authority over that caller's rows.
How our fix solves it
Chain from the symptom to the root cause:
mcp_cronresolves identity throughmcp_core's resolver, whose FIRST sourceis the injected caller block, and the stdio shim in
mcp_sharedbinds thatblock on every
tools/callunconditionally. So the consumer side was alreadycorrect.
mcp_gateway/backend.pystrips any stub-supplied callerblock from every forwarded request and re-injects its own only when the
backend advertised
kirocrew.caller-identity. This server never advertised,so the block never arrived and the resolver had nothing to read.
declines to POOL an unadvertised backend.
rewriter.UNPOOLABLE_SERVERSisempty and its own comment records that the capability is read only to decide
injection. So cron was pooled AND identity-blind, and a comment in
mcp_coreasserting the opposite is corrected here.
The change:
Advertise the capability (
ADVERTISE_CALLER_IDENTITY = True, passed torun_mcp_stdio_loop) and addkirocrew-crontomcp_discovery._MANAGED_SERVERS_CALLER_AWARE. The existing ratchet intest/test_mcp_managed_caller_identity.pymakes those two impossible to drift:it drives each server's real serve entry and compares what the shim was handed
against the set.
One strict resolver for authorization.
_authz_session_key()uses theSTRICT resolver, so an ownership decision can no longer come from the lenient
resolver's
/procancestor walk over a per-pid file thatmcp_coreitselfdocuments as "agent-writable and therefore forgeable". Labelling an audit row
from it is tolerable; deciding who may delete whose scheduled job is not.
One scope for reads and writes.
_owned_byis the single function both gothrough: a caller reaches only the rows whose
session_keymatches its own, andan empty key reaches NOTHING. Two scopes were two chances to pick the wrong one,
and
cron_remove_allpicked the wider one once during this review. Every writeadditionally refuses with one shared message naming the CLI route. This follows
from one measurable fact: gatewayd forwards with
caller=Nonewhen a stub'sRegister carries no session key and peer-identity resolution fails
(
gatewayd.py, theSO_PEERCRED+/procpath), so an unidentified caller canbe sharing a pooled backend with identified ones. Empty identity does not imply a
1:1 transport, so such a caller gets neither their rows nor authority over them.
The CLI keeps its admin bypass -- note
KIROCREW_CLIis set nowhere insrc/,so that in-process bypass is dead code today; the CLI reaches
CronServicedirectly and never routes through this server.
A row with no recorded owner is outside every session's scope, for reading
and for writing alike. An earlier revision of this change kept such rows VISIBLE,
arguing a row with no owner has no owner's privacy to breach; that silently
assumed the only identified session is the operator's, and it is not -- an
allowlisted Slack or Telegram participant gets a session of their own, while a
cron's
messageis arbitrary prompt text withcommand/scriptpayloads besideit. Showing the admin surface's rows to one of those is a disclosure to a
different principal, so they are withheld.
cron_addstops this servercontributing new ownerless rows, but the set keeps growing regardless:
cli_commandsand the onboarding importer both create jobs with no session toname, which is why this is a permanent scope rule and not a time-boxed exemption
for legacy rows.
The consequence, stated plainly: a cron created with
kirocrew cron addnolonger appears in
cron_listfrom chat, and a job the old pooledcron_addminted with an empty owner becomes invisible and immutable from chat after
upgrade. No migration is possible -- the owner was never recorded -- and the CLI
remains the management surface for both. This belongs in the release notes, since
a user will otherwise read it as their crons vanishing.
The scoping decision is audited.
cron_listwithholding rows is anauthorization decision, and every other one in this module already landed on the
SEL trail; this one did not, and it was the least visible of them (an
unidentifiable caller has EVERYTHING withheld). It now emits
deniedwhennothing survives the filter and
scopedwhen some rows do -- and NO event whenthe caller owns everything it could see, because
cron_listis called often anda per-call event would bury the trail it is meant to serve.
The gate's three refusals are one string. A pre-existing enumeration oracle,
found by one of the new tests: the gate's own comment claimed anti-enumeration
while it answered
Job not found: <id>for an unknown id andError: job not found: <id>for another session's row -- two distinguishable strings, so acaller could tell an id that exists from one that does not. All three branches
now return one identical string. Post-gate messages still name the row: by then
the caller owns it.
cron_add's channel default had the same defect as the session key(
KIROCREW_CHANNEL_IDfrom process environment); it now readschannelIdoffthe same caller block, with the env var as the non-gateway fallback.
cron_triggerchecks the id's shape before ownership, so a malformed idkeeps its own message instead of the ownership gate's deliberately vague "job
not found". The enforcing check inside
trigger_cron_jobis unchanged; thisonly makes its reason reachable.
Scope note
The issue also proposes deleting the
session_bound_by_constructionreason codeonce nothing produces it. That is not yet possible and is deliberately left out:
kirocrew-computerandkirocrew-dashboardare the other two managed serversand neither advertises, so the input still has producers. Both already resolve
through the strict resolver, so each is the same small adoption -- worth its own
change rather than folding three servers' pooling behaviour into one review. The
verdict is now asserted explicitly for all four servers so the remaining two are
visible rather than implied.
What tests we did
The ratchet bites in both directions (checked before writing the fix, on the
untouched tree): adding cron to the caller-aware set without flipping the flag
fails
test_session_bound_is_the_inverse_of_advertising[kirocrew-cron], andflipping the flag without updating the set fails the same test. Neither
half-change can land silently.
New unit coverage (
test/test_mcp_cron_caller_identity.py, 15 tests):naming a DIFFERENT session, so a test that merely read the block back cannot
pass by accident;
regressed);
empty, proving it is not an authorization source;
row untouched;
cron_addrefuses and stores nothing;cron_liststill reads;the CLI bypass still works;
have held before -- both callers resolved empty);
neither listed nor mutable, and its refusal is byte-identical to the answer for
an id that does not exist;
_owned_by(jobs, "")is empty rather than everyownerless row; and the list-scoping decision lands on the SEL trail as
denied/scoped/ no-event.New integration coverage -- the half unit tests cannot reach. They prove cron
CONSUMES a block; they cannot prove gatewayd SENDS one, and the capability is
parsed off a REAL
initializeresponse, a step the in-process stub seam everyother gateway test uses does not go through. So
test_mcp_gateway_pool_integ.py::test_two_stubs_on_one_backend_are_told_apartdrives two REAL stub processes with different session keys onto one pooled
backend and reads back what the backend was actually handed, asserting it saw
both keys in order. The bundled fake server records the
sessionKeyof eachtools/call; advertising is bundled with recording deliberately, since arecorder that stayed silent would observe nothing and read as a broken gateway.
Mutation-verified, 9 probes, all killed. Each reverts one fixed line and
confirms the named test goes red: lenient resolver restored; per-job gate back to
allowing;
cron_addback to minting ownerless rows; channel default back toenv-only; legacy exemption dropped; visibility filter dropping ownerless rows;
authorization reading env directly; the backend not advertising; and gatewayd
injecting one hardcoded identity for every call.
Existing suites. 26 existing cron tests failed on the first run because they
exercised
cron_addas an anonymous caller -- fixture debt, not a design signal:they test cron's FIELD handling and always assumed a caller the gateway can name,
they simply never said so, because the unidentified state used to be allowed to
write. A shared
named_cron_callerfixture states that precondition; tests thatmock
CronServicenow stamp an owner on the fake row. Two tests pinned the oldcontract by name and were rewritten to the new one.
Full backend suite: 54194 passed, 15 failed. All 15 reproduce identically on
an unmodified checkout of this base -- host-environment failures (a
/local/homevs
/homelayout, CPU-count-derived worker budgets, flock holders on this box,stray bytecode). Zero attributable to this change.
isort,flake8,mypyandthe black-baseline gate are clean; the baseline lost 3 graduated entries it was
already failing on at this base (the graduated check is repo-wide, not
diff-scoped), and only shrinks.
Not verified locally, and stated as such: the non-gateway launch where neither
KIROCREW_SESSION_KEYnorKIROCREW_HOST_PIDexists (a GUI-launched kiro-cli)cannot be reproduced on this Linux host, so it is covered by the unit assertion
rather than end to end.
Any other suggestions on the work
rewriter.UNPOOLABLE_SERVERSis a trap while empty. A server that cannotsupport the extension has no way to say so: the intended signal -- refuse to
pool a backend that did not advertise -- is documented as unimplemented. That
is fine while every managed server is adopting the extension, and it is a real
hole the moment one cannot.
caller=Noneon a pooled connection deserves a name. It is currently animplicit state that each consumer re-interprets. Since it can coexist with
identified stubs on one backend, a first-class "unattributable caller" concept
would stop the next server from having to rediscover that empty is not the
same as alone.
governance-surface lookup. Both improve for free now that the block arrives,
and neither decides row ownership, so they are left alone -- but the
governance-surface read is the next candidate if the forgeable source is to be
removed from decisions entirely.
Why no screenshot: backend identity resolution, no UI surface.
Fixes #4622