fix(chat): refuse a linked session binding on an app-scoped slot - #2783
fix(chat): refuse a linked session binding on an app-scoped slot#2783leonlaiyc wants to merge 1 commit into
Conversation
dfd83de to
7a3afdb
Compare
7a3afdb to
bf28b5e
Compare
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
bf28b5e to
f014465
Compare
|
Hi @leonlaiyc, a maintainer nudge on this one: it is carrying the Current state:
Could you rebase onto the latest git fetch upstream # or: git fetch origin, if this branch lives here
git rebase upstream/main
# resolve any conflicts, run the local gates below, then update the branch with
# a force-with-lease so the rebase lands without clobbering anyone else's workThe local gates to run before updating the branch: black src/kiro_crew test && isort src/kiro_crew test
flake8 src/kiro_crew test && mypy src/kiro_crew
python -m pytest
cd website && npm run build && npm run testA good part of this branch's redness is likely stale rather than a real defect: it predates a lot of what is now on |
The self-heal sweep rescues a `pending` verdict on age and a `failure` verdict on later check evidence. Two freeze modes fell outside both tests and had no other recovery path, because the sweep is the only thing that recomputes an unchanged commit once its `workflow_run` events are spent. An UNPUBLISHED verdict was the live one. `pr-readiness.yml` deliberately does not retry its status POST -- a retry can overwrite a concurrent run's newer verdict -- and instead instructs a human to re-run the workflow. When that POST fails the SHA carries no readiness status at all, so there is no `pending` to age out, and the sweep skipped the PR on the assumption that its own run was still coming. The one case the publisher delegates to a re-run was the one case nothing re-ran. PR #2783 proves it: both readiness runs for f014465 failed on `gh: HTTP 503` from the POST on 2026-08-17, and eight days later the PR was still MERGEABLE with no readiness verdict of any kind. Today's #5282 hit the same POST with a 502 and survived only because an unrelated later event happened to republish. Rescued now on the PR's own last-activity age, which a push bumps, so the grace window restarts on every new revision and a genuinely queueing run is left alone. A STALE GREEN was the unsafe-direction gap. The re-run mechanism behind mode 2 is direction-blind: a job re-run that flips a lane red after a green verdict emits no `workflow_run: completed` either, leaving the required aggregate green over a now-red revision -- which PERMITS a merge, where a stale red only blocks one. The `*)` arm skipped it as "success needs no rescue". `success` and `error` now share mode 2's evidence test, narrowed to failure-class conclusions: housekeeping check-runs (`Strip stale workflow-change override`, `Fork workflow-change guard`) legitimately complete days after a verdict, so an unnarrowed "any check completed later" test would re-fire most green PRs every sweep while proving nothing. No live poisoned green was found in the current 250 open PRs -- the one candidate, #4272, turned out to be exactly that housekeeping false positive -- so this arm closes the mechanism, not an observed incident. `--paginate` WITHOUT `--slurp` was a latent defect in the pre-existing `failure` arm, found in review and fixed in both arms. gh concatenates one JSON document per page and jq evaluates each separately, so `max` emits one timestamp PER PAGE; `date -d` then rejects the multi-line string, the epoch reads 0, and the PR is skipped. That silently exempted every PR with more than 100 check-runs, which on this repo is any PR whose lanes have been re-run -- #4718 alone carries ~90. Verified against the live API: three pages produced three `max` lines unslurped and one slurped. The statuses read is now checked for FAILURE before its filter runs. A 503 and a genuinely absent status both yield empty jq output, and the new unpublished arm dispatches on the latter -- so without this, transient GitHub trouble would re-fire arbitrary old PRs every 15 minutes, on a shared token budget, which is the same exhaustion that causes the missing verdict in the first place. `--limit 300` on the open-PR listing is raised to 900 with a loud warning when the ceiling is hit, here and in the two sibling sweeps that share the defect (fork-pr-label.yml, pr-merge-conflict-label.yml, both found in review). `gh pr list` returns newest-first and truncates silently, so a ceiling near the real count drops the OLDEST PRs -- precisely the frozen or unlabelled ones each sweep exists to catch. At 250 open the old value was already at 83% of its ceiling. Same truncation class as the `pr_status_report` limit bug. `gh label list --limit` in those files counts labels, not PRs, and is untouched. The unpublished case is carried as a sentinel `state` rather than an early record write, so it reuses the single staleness comparison and the single stale-file record writer instead of restating either. GitHub's status states are error/failure/pending/success, so the sentinel cannot collide. Not addressed here, because the fix is a policy decision rather than a defect: a required lane that did not exist when the head SHA was pushed can never be satisfied, and a recompute re-derives `not started` forever. #3089 and #3037 are frozen on First Principles Review, which became blocking in #4253 but only exists from #3436. Auto-skipping the lane would let an unreviewed PR pass, so it wants a rebase or a deliberate eligibility rule. Filed as #5957. Test plan: - test/test_pr_readiness_sweep.py: 31 passed (18 pre-existing, 13 added). - unpublished: rescued past the window, left alone inside it, respects MAX_DISPATCH, fails closed on an unparseable timestamp, still reads as unpublished when only a foreign-context status exists, and is NOT triggered by a failed statuses read. - stale green: rescued for all six failure-class conclusions, never rescued by a later pass, self-terminating on republish, untouched with no evidence. - pagination: evidence on a SECOND page is found by both arms, which is what an unslurped `--paginate` cannot do. - truncation: the ceiling is a variable, and a hit is reported. - the context-isolation test now proves a foreign status cannot DRIVE the decision, rather than relying on the absent-status shape it used to assert. - the gh stub models `--paginate --slurp` (outer array of pages) and can fail a statuses read on demand, so both fixes are exercised rather than assumed. - Revert-verify: undoing each of the seven properties independently (unpublished arm, green arm, ceiling, failure-class narrowing, slurp on each arm, statuses transport guard) fails a named test; tree restored to 31 green. - test_workflows_conformance / test_workflow_permissions / test_github_workflow_security / test_pr_quality_gates / test_ai_review_workflows + the sweep suite: 289 passed. - check_black_formatting.py and flake8 clean; all three workflows parse as YAML. - test_pr_readiness_publish.py has 4 pre-existing failures on this host (local jq rejects `label` as an identifier); identical on pristine main, untouched here.
…#5955) The self-heal sweep rescues a `pending` verdict on age and a `failure` verdict on later check evidence. Two freeze modes fell outside both tests and had no other recovery path, because the sweep is the only thing that recomputes an unchanged commit once its `workflow_run` events are spent. An UNPUBLISHED verdict was the live one. `pr-readiness.yml` deliberately does not retry its status POST -- a retry can overwrite a concurrent run's newer verdict -- and instead instructs a human to re-run the workflow. When that POST fails the SHA carries no readiness status at all, so there is no `pending` to age out, and the sweep skipped the PR on the assumption that its own run was still coming. The one case the publisher delegates to a re-run was the one case nothing re-ran. PR #2783 proves it: both readiness runs for f014465 failed on `gh: HTTP 503` from the POST on 2026-08-17, and eight days later the PR was still MERGEABLE with no readiness verdict of any kind. Today's #5282 hit the same POST with a 502 and survived only because an unrelated later event happened to republish. Rescued now on the PR's own last-activity age, which a push bumps, so the grace window restarts on every new revision and a genuinely queueing run is left alone. A STALE GREEN was the unsafe-direction gap. The re-run mechanism behind mode 2 is direction-blind: a job re-run that flips a lane red after a green verdict emits no `workflow_run: completed` either, leaving the required aggregate green over a now-red revision -- which PERMITS a merge, where a stale red only blocks one. The `*)` arm skipped it as "success needs no rescue". `success` and `error` now share mode 2's evidence test, narrowed to failure-class conclusions: housekeeping check-runs (`Strip stale workflow-change override`, `Fork workflow-change guard`) legitimately complete days after a verdict, so an unnarrowed "any check completed later" test would re-fire most green PRs every sweep while proving nothing. No live poisoned green was found in the current 250 open PRs -- the one candidate, #4272, turned out to be exactly that housekeeping false positive -- so this arm closes the mechanism, not an observed incident. `--paginate` WITHOUT `--slurp` was a latent defect in the pre-existing `failure` arm, found in review and fixed in both arms. gh concatenates one JSON document per page and jq evaluates each separately, so `max` emits one timestamp PER PAGE; `date -d` then rejects the multi-line string, the epoch reads 0, and the PR is skipped. That silently exempted every PR with more than 100 check-runs, which on this repo is any PR whose lanes have been re-run -- #4718 alone carries ~90. Verified against the live API: three pages produced three `max` lines unslurped and one slurped. The statuses read is now checked for FAILURE before its filter runs. A 503 and a genuinely absent status both yield empty jq output, and the new unpublished arm dispatches on the latter -- so without this, transient GitHub trouble would re-fire arbitrary old PRs every 15 minutes, on a shared token budget, which is the same exhaustion that causes the missing verdict in the first place. `--limit 300` on the open-PR listing is raised to 900 with a loud warning when the ceiling is hit, here and in the two sibling sweeps that share the defect (fork-pr-label.yml, pr-merge-conflict-label.yml, both found in review). `gh pr list` returns newest-first and truncates silently, so a ceiling near the real count drops the OLDEST PRs -- precisely the frozen or unlabelled ones each sweep exists to catch. At 250 open the old value was already at 83% of its ceiling. Same truncation class as the `pr_status_report` limit bug. `gh label list --limit` in those files counts labels, not PRs, and is untouched. The unpublished case is carried as a sentinel `state` rather than an early record write, so it reuses the single staleness comparison and the single stale-file record writer instead of restating either. GitHub's status states are error/failure/pending/success, so the sentinel cannot collide. Not addressed here, because the fix is a policy decision rather than a defect: a required lane that did not exist when the head SHA was pushed can never be satisfied, and a recompute re-derives `not started` forever. #3089 and #3037 are frozen on First Principles Review, which became blocking in #4253 but only exists from #3436. Auto-skipping the lane would let an unreviewed PR pass, so it wants a rebase or a deliberate eligibility rule. Filed as #5957. Test plan: - test/test_pr_readiness_sweep.py: 31 passed (18 pre-existing, 13 added). - unpublished: rescued past the window, left alone inside it, respects MAX_DISPATCH, fails closed on an unparseable timestamp, still reads as unpublished when only a foreign-context status exists, and is NOT triggered by a failed statuses read. - stale green: rescued for all six failure-class conclusions, never rescued by a later pass, self-terminating on republish, untouched with no evidence. - pagination: evidence on a SECOND page is found by both arms, which is what an unslurped `--paginate` cannot do. - truncation: the ceiling is a variable, and a hit is reported. - the context-isolation test now proves a foreign status cannot DRIVE the decision, rather than relying on the absent-status shape it used to assert. - the gh stub models `--paginate --slurp` (outer array of pages) and can fail a statuses read on demand, so both fixes are exercised rather than assumed. - Revert-verify: undoing each of the seven properties independently (unpublished arm, green arm, ceiling, failure-class narrowing, slurp on each arm, statuses transport guard) fails a named test; tree restored to 31 green. - test_workflows_conformance / test_workflow_permissions / test_github_workflow_security / test_pr_quality_gates / test_ai_review_workflows + the sweep suite: 289 passed. - check_black_formatting.py and flake8 clean; all three workflows parse as YAML. - test_pr_readiness_publish.py has 4 pre-existing failures on this host (local jq rejects `label` as an identifier); identical on pristine main, untouched here. Co-authored-by: Joe Guo <zejiangg@amazon.com>
|
🤖 Kiro Crew [operator: bolichen97#bb3ad1ca]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: Reds are Backend Tests (3.12, shard 4) — If you'd prefer I don't touch this PR, add the |
|
This PR shares Suggested coordination order is to land/replay this identity-binding floor first, then #3139's worktree metadata, then #4904's parent/fork merge metadata. At each step, round-trip persistence/rehydration tests should prove app/channel identity is not overwritten while worktree and parent-session fields survive. |
f014465 to
b96c4e6
Compare
Treat a slot binding as session authority and reject it for app-owned slots at the shared attribute boundary. This covers name inference, explicit and late binding, and legacy persistence restore while leaving dashboard/channel flows unchanged. Do not infer channel provenance from a refused legacy app binding, document the invariant, and pin creation, restoration, serialization, SEL, and route behavior with focused tests.
b96c4e6 to
752de48
Compare
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All current Design-Verdict: PASS Enforcing the invariant at the single shared mutation boundary, with a retained refused claim failing closed, is the right shape for a cross-surface authorization fix. Suggestions
[DESIGN-REVIEWED] 752de48 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/dashboard/state.py:3531 -- Refused legacy bindings still restore foreign transcripts |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of Analysis complete. Key facts from the base tree: notes are stamped with First-Principles-Verdict: CONCERNS The note-seam claim comparison guards against "delivery to a different session" that the setter itself makes unreachable, and What this change shipsIntent: stop an app-owned chat slot from ever holding authority over another surface's session — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 752de48 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe binding-assignment sites (restore, cron inject, workflow inject, factory) all route foreign channel/cron sessions — exactly what app-slot isolation is meant to refuse. No legitimate app-slot routing depends on Assessing CANDIDATE 1 against the survival bar:
The mismatch between the (unchanged) No cross-session leak is introduced anywhere in the diff: every changed gate ( No findings. [OPUS-REVIEWED] 752de48 |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
@leonlaiyc Thanks for this. The invariant is still absent from The two PRs collide on Suggested path: agree on one refusal mechanism first, ideally as its own small PR that both then rebase onto, rather than merging both records. This PR should keep the parts #6813 does not touch, notably the app guard on Also still open here: rebase off the drift (the PR is currently conflicted), the GPT BLOCK-MERGE finding on refusing rather than skipping an already-hydrated poisoned row, and the missing Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
A slot's
linked_session_keyis session authority. App routes authorize againstslot._app, then dispatch oneffective_session_key(slot). Before this change, an app could request a channel-shaped slot name and receive an app-owned slot bound to a channel session it did not own.The invariant established here is stronger than blocking name inference: an app-scoped slot cannot carry a linked-session binding at all. That covers explicit arguments, bindings applied after creation, poisoned metadata written by an older build, and authorization rechecks after an await.
Why it matters
This closes a cross-surface authorization path. Slot ownership must not imply authority over a Slack, Discord, cron, or other session merely because the app chose a matching slot name or restored stale metadata.
What changed
linked_session_keyis a property with a shared mutation boundary. A non-empty binding on an app-owned slot is refused and audited without aborting slot creation or restore.linked_session_claim; it never becomes the effective route, transcript target, provider key, or serialized live binding.channel_origin, but no longer infers channel provenance from a refused legacy linked key on an app-owned row.docs/system-specs/modules/session.md.The rule lives at the attribute boundary because bindings are assigned by the factory, restore paths, cron/workflow injectors, and other late-binding callers. A per-call-site guard would leave the next assignment path as a bypass.
Verification
state,gateway app-kit, and chat-handler files: 168 passed.main, the focused 18 tests and static gates passed again.No flaky failure was hidden with a retry, sleep, or longer timeout. A separate Windows SEL test failure observed in CI is being fixed at its own shared lock boundary and is not mixed into this security PR.
Screenshots / video
Why no screenshot: This is a backend authorization, routing, persistence, and test change; it does not alter rendered UI.