fix(chat): authorize History resume against the conversation it loads - #2842
fix(chat): authorize History resume against the conversation it loads#2842leonlaiyc wants to merge 1 commit into
Conversation
250deaf to
326a0d4
Compare
`POST /api/chat/slots/{slot}/resume` authorizes on only one of its two
branches. When a live slot exists it applies the App Kit 5.2 check to that
slot; when none does — the ordinary History case — it created the slot
from `request["app"]` and then read `body["key"]` straight off disk.
That key is caller-supplied and need not match the slot name in the URL,
so it names an arbitrary conversation, and the only slot in scope is the
one the request just created carrying the caller's own identity: checking
it lets the claim stand as its own evidence. An app token could therefore
hydrate any persisted conversation — an ordinary dashboard user's, another
app's, or a channel thread — into a slot it owns, taking that
conversation's messages, title, agent, model and folder with it and
clearing its closed flag on the way in.
Authorize against the transcript instead, before any slot exists.
`meta["app"]` is the durable owner: the slot save writes it, it is one of
`SLOT_OWNED_META_KEYS`, and both restore paths already rebuild
`slot._app` from it. Absence is an answer rather than a gap — an unscoped
conversation's save omits the key by that same contract — so an app caller
is denied by default. The metadata read moves above slot creation and is
the one snapshot both authorization and hydration use.
A transcript that does not exist has no owner either, so it takes the same
branch and is answered identically; the two existing refusals on this
route gain the same machine-readable code so all three bodies stay byte
for byte the same and the refusal cannot be used to probe which history
keys exist.
326a0d4 to
b017222
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. |
Design Review (Fable 5, fork) — ✅ PASSAdvisory design-level review of Verified the base handler ( Design-Verdict: PASS Real authorization bypass, fixed at the durable owner ( Suggestions
[DESIGN-REVIEWED] b017222 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/dashboard/chat_handlers.py:3540 -- persisted owner is app-rewritable |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSAdvisory premise-level review of I've read the contract, the intent file, the authoritative patch, and the relevant base sources ( First-Principles-Verdict: CONCERNS The fix is real and cause-level, but "resume was the only reader that ignored What this change shipsIntent: stop an app token from reading and adopting any persisted conversation by naming its key in a resume body — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] b017222 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate concerns whether To ground this I'd need a concrete reachable input where two session files share a The added guard itself is sound: it fails closed ( No findings. [OPUS-REVIEWED] b017222 |
|
🤖 Kiro Crew [operator: bolichen97]: This PR has been inactive for 7+ days. I reviewed the blockers but they require your input:
When you've addressed these, the pipeline will re-assess on its next cycle. |
Audit note — part of this has already landed; the rest has notThis PR is not a duplicate and is not finished by anything on Which parts
|
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, one overlap to flag from an audit of the open PR set. #6813 (@rnoack1, "fix(chat): persist undrained pending context across a close") edits the same window of Where they collide: your ownership gate ( What differs: this PR decides which persisted conversation a caller may adopt, while #6813 decides whether queued pending context survives a close. There is no behavioural contradiction, so both can land. Which side is further along: #6813 spans 46 files and ships census tests that pin the new spelling. Suggestion: land this PR first. It is 3 files, it closes an authorization bypass, and re-anchoring one gate afterwards is cheaper than re-deriving #6813's tests. If #6813 goes first instead, please re-place the gate inside the folded, offloaded read rather than rebasing the hunk mechanically, and keep it after the member-binding await. 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
POST /api/chat/slots/{slot}/resumehas two branches, and only one of them authorizes.When a live slot already exists, the App Kit §5.2 check runs against that slot before anything is returned. When it does not — which is the ordinary History case, a conversation with no open tab — the handler went straight to:
history_keyisbody["key"]— caller-supplied, and not required to match the slot name in the URL. So it names an arbitrary conversation, and nothing on that path compares the caller against that conversation's owner. The only slot in scope is the one the request just created carrying the caller's own identity, so authorizing against it would let the claim stand as its own evidence.On
24a6f8ee5, an app token can read any persisted conversation this way:dashboard:chat-7-…— an ordinary dashboard user's conversation200, full transcriptdashboard:spec-builder-99—meta["app"] == "spec-builder"200, full transcriptslack:1785370133.085469— a Slack thread200, full transcriptIn every case the URL slot was
app-a-scratch, unrelated to the key — so this is not about channel-shaped names. It is also not read-only: the transcript is hydrated into a new app-owned slot, which then owns that conversation's messages, its title, agent, model and folder, and clears itsclosedflag on the way in.Why it matters
This is an authorization bypass, not a cosmetic gap. An app token that owns a
scratch slot can name any conversation key in the request body and receive the
full transcript back: a dashboard user's private chat, another app's conversation,
or a Slack thread. The slot in the URL never has to be related to the key that is
read, so no naming convention limits the blast radius.
/resumeis the only reader of a persisted conversation that ignoredmeta["app"]; every other restore path already treats it as authoritative.What changed
Authorize against the persisted conversation, before a slot exists:
meta["app"]is the durable owner. It is written by the slot save (chat_persistence.py:1669), it is one ofhistory.SLOT_OWNED_META_KEYS, and both restore paths already rebuildslot._appfrom it (chat_persistence.py:485/:559and:869/:917). Resume was the only reader of a persisted conversation that ignored it.Absence is an answer, not a gap. Because
appis a slot-owned key, an unscoped conversation's save deliberately omits it — that states "no app owns this", not "unknown". Deny-by-default for app callers therefore follows the writer's own contract rather than guessing about legacy rows.One metadata read. It moved above
get_or_create_slotand the same dict already fed the title/agent/model/folder restoration below, so authorization and hydration decide from one snapshot rather than two reads.A refusal is not an existence oracle. A transcript that does not exist also has no
app, so a foreign conversation and a missing one take the same branch and are answered identically — no probing the history namespace for keys that exist. The two pre-existing §5.2 denials on this route gained the samecodeso all three bodies stay byte-identical; per AGENTS.md a new non-2xx body must carry a machine-readablecode, and giving it only to the new one would itself have been the oracle.error-code-baseline.jsonregenerated for those three (dashboard/chat_handlers.py76 → 74).Dashboard callers have no app scope and are untouched.
Relationship to #2783 — adjacent, not overlapping
linked_session_key).They are independent: even with no session binding at all, an app that can hydrate another conversation's messages has already crossed the isolation boundary. Neither fix makes the other unnecessary.
Tests —
test/test_resume_history_ownership.pyBehaviour-level, through the real handler with the auth middleware's app stamp. 9 failed / 5 passed against
24a6f8ee5; 14 passed after.Refused — an ordinary dashboard conversation, another app's conversation, a channel conversation, and a conversation with no ownership metadata (deny-by-default).
Not an oracle — a foreign conversation answers with the same status and the same JSON body as a nonexistent one; and the create-branch refusal is identical to the existing-slot branch's.
No side effects on refusal — no slot is created; no existing slot is mutated (title, agent, model, pin,
linked_session_key,channel_origin, message count, dirty flag, the slot table and the restricted-key set are all snapshot-compared); and the foreign transcript is not rewritten, which theclosed-flag clear would otherwise do.Preserved, all passing before the change — an app resumes its own conversation; a dashboard caller resumes a channel conversation, an app-owned one, and an ordinary one; and the existing-slot branch still serves its owner.
Verification
Windows 11 26200 / py3.10.6.
test_resume_history_ownership.py— 9 failed / 5 passed before, 14 passed after.sets rather than counts:
test_error_code_contract.py,test_dashboard_chat.py,test_open_slots_persistence.py,test_session_restore.py,test_rehydrate_async.py,test_channel_slots.py,test_one_conversation_one_session.py,test_trusted_apps_api.py,test_slot_detail_full_history.pyplus the new file — base: 9 failed, 860passed, 2 skipped; branch: 869 passed, 2 skipped, 0 failed. Every one of the
9 base failures is in the new file, so the delta across 869 tests is exactly
the security coverage this PR adds and nothing else moved.
flake8,isortandgit diff --checkclean;mypyreports nothing inchat_handlers.py.docs/app-kit/documents no resume contract, and the route's own §5.2 comments are the existing statement of the rule this extends.