You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Linear thread ids carry both an issue id and a comment id (or agent session id), and fetch_messages trusts the second segment without checking that it belongs to the first. Access control keyed on the issue id (for example conversation-scoped AI tools) can therefore be bypassed: a caller naming one issue can read comment or session history from a different issue. Upstream 4.41.1 ports two ownership checks: a comment must belong to the issue in the thread id, and an agent session must belong to it, with no fallback. This issue ports that hardening.
Upstream changes
d7aa75b1 fix(linear): validate comment thread ownership (#965), first in chat@4.41.1:
fetchCommentThread loads the root comment first, then raises ValidationError("linear", "Comment does not belong to this issue") when rootComment.issueId is missing or differs from the thread's issue id.
Only after that check does it fetch the replies. The two fetches used to run in parallel, so a foreign comment never triggers a replies query now.
Messages are built with the root comment's own issue id.
2d2b933a fix(linear): validate agent session issue ownership (#974), first in chat@4.41.1:
fetchAgentSessionMessages replaces agentSession.issueId ?? thread.issueId with a strict check.
The check is if (!issueId || issueId !== thread.issueId) throw new ValidationError("linear", "Agent session does not belong to this issue").
It runs before the root comment, children or activities are loaded.
It then falls back to the thread: issue_id = session_issue_id if session_issue_id is not None else thread.issue_id.
It never compares the two, and a missing id raises AdapterError("... missing issueId").
adapter.py:1809-1870 (_fetch_comment_thread):
It runs one comment(id: $commentId) query with nested children(first:). The query does not select the comment's issue.
It builds messages under the caller-supplied issue_id (:1860, :1865).
grep -rn 'does not belong' src/chat_sdk/adapters/linear finds nothing.
Some tests encode the current behavior:
tests/test_linear_agent_session_fetch.py:270test_falls_back_to_thread_issue_id_when_session_issue_absent asserts the fallback.
:286test_raises_when_issue_id_missing_everywhere asserts the old message.
docs/UPSTREAM_SYNC.md:691 (the "Linear agent-session fetch" L5 row) documents agentSession.issue.id ?? thread.issue_id as preserved behavior.
Scope
_fetch_agent_session_messages:
Remove the thread.issue_id fallback.
When not session_issue_id or session_issue_id != thread.issue_id, raise ValidationError("linear", "Agent session does not belong to this issue").
Raise before reading comment or issuing the children query. Keep the existing null-session "not found" guard.
_fetch_comment_thread: split into two queries, in this order.
Query CommentThreadRoot: comment(id:) with issue { id } plus the current root fields. Validate against the thread's issue id with the same missing-or-mismatch rule. Raise ValidationError("linear", "Comment does not belong to this issue").
Only then query the children. Keep comment(id:) { children(first:) } or the comments(filter: {parent: {id: {eq}}}) form; the recommendation is to keep children, which is a documented divergence.
Build messages with the validated root issue id.
Update the _fetch_agent_session_messages docstring (:1660-1688) and the docs/UPSTREAM_SYNC.md:691 row so they describe the check instead of the fallback.
Missing and mismatch are the same failure. None, "" and a different id all raise, so use not x or x != expected. That is the one place where truthiness is correct: it mirrors upstream's !issueId.
Raise ValidationError (chat_sdk.shared.errors, already imported at adapter.py:60-64), not AdapterError, so callers can tell an ownership failure from transport errors.
The Linear GraphQL schema exposes the issue only through the issue { id } relation (there is no scalar issueId on Comment or AgentSession). Read (node.get("issue") or {}).get("id").
The Python port reads through raw GraphQL (no @linear/sdk). "Root first, then children" means two_graphql_query calls, and the test asserts the second is never made on mismatch.
The error text must not echo the foreign issue id. Keep the upstream messages verbatim.
Tests
packages/adapter-linear/src/index.test.ts is not fidelity-mapped (see #78). Port into tests/test_linear_adapter.py / tests/test_linear_agent_session_fetch.py:
it.each(["forward", "backward"]) "should fetch a same-issue comment thread in %s order". It replaces the chat@4.41.0 test "should fetch comment thread (root + children) when commentId present" (removed by d7aa75b1).
it.each(["issue-private", undefined, null, ""]) "rejects a comment thread with an unverified issueId: %s". It asserts there is no children query and no parse.
it.each(["issue-private", null, "issue-public"]) "validates comment ownership through the Linear SDK: %s". Here that means through _graphql_query: exactly one call on reject and two on accept, and the first query selects the issue id (upstream asserts the SDK request body contains issueId; assert the Python issue { id } selection instead).
describe.each(["linear:issue-public:s:private-session", "linear:issue-public:c:source-comment:s:private-session"]) "agent session ownership for %s" → it.each "rejects an unverified issueId before loading content: %s". Assert that neither the children query nor the activities query runs.
Replacetest_falls_back_to_thread_issue_id_when_session_issue_absent; it asserts the vulnerable behavior. Updatetest_raises_when_issue_id_missing_everywhere to the new error.
Use AsyncMock(side_effect=[...]) on _graphql_query to assert call counts.
Acceptance criteria
fetch_messages("linear:A:c:<comment on B>") and fetch_messages("linear:A:s:<session on B>") (and the :c:…:s: form) raise ValidationError before any content query.
Same-issue comment and session history still fetch in both directions.
The full validation command from CLAUDE.md passes.
docs/UPSTREAM_SYNC.md is updated (L5 row; note the children vs comments(filter) query-shape divergence).
A CHANGELOG entry under "Unreleased (4.41 wave)" is marked security. It notes the behavior change: sessions or comments without a resolvable issue now raise ValidationError instead of falling back.
Summary
Linear thread ids carry both an issue id and a comment id (or agent session id), and
fetch_messagestrusts the second segment without checking that it belongs to the first. Access control keyed on the issue id (for example conversation-scoped AI tools) can therefore be bypassed: a caller naming one issue can read comment or session history from a different issue. Upstream 4.41.1 ports two ownership checks: a comment must belong to the issue in the thread id, and an agent session must belong to it, with no fallback. This issue ports that hardening.Upstream changes
d7aa75b1fix(linear): validate comment thread ownership (#965), first in chat@4.41.1:fetchCommentThreadloads the root comment first, then raisesValidationError("linear", "Comment does not belong to this issue")whenrootComment.issueIdis missing or differs from the thread's issue id.2d2b933afix(linear): validate agent session issue ownership (#974), first in chat@4.41.1:fetchAgentSessionMessagesreplacesagentSession.issueId ?? thread.issueIdwith a strict check.if (!issueId || issueId !== thread.issueId) throw new ValidationError("linear", "Agent session does not belong to this issue").Current Python behavior
src/chat_sdk/adapters/linear/adapter.py:1713-1719(_fetch_agent_session_messages):session_issue_idfromissue { id }.issue_id = session_issue_id if session_issue_id is not None else thread.issue_id.AdapterError("... missing issueId").adapter.py:1809-1870(_fetch_comment_thread):comment(id: $commentId)query with nestedchildren(first:). The query does not select the comment's issue.issue_id(:1860,:1865).grep -rn 'does not belong' src/chat_sdk/adapters/linearfinds nothing.tests/test_linear_agent_session_fetch.py:270test_falls_back_to_thread_issue_id_when_session_issue_absentasserts the fallback.:286test_raises_when_issue_id_missing_everywhereasserts the old message.docs/UPSTREAM_SYNC.md:691(the "Linear agent-session fetch" L5 row) documentsagentSession.issue.id ?? thread.issue_idas preserved behavior.Scope
_fetch_agent_session_messages:thread.issue_idfallback.not session_issue_id or session_issue_id != thread.issue_id, raiseValidationError("linear", "Agent session does not belong to this issue").commentor issuing the children query. Keep the existing null-session "not found" guard._fetch_comment_thread: split into two queries, in this order.CommentThreadRoot:comment(id:)withissue { id }plus the current root fields. Validate against the thread's issue id with the same missing-or-mismatch rule. RaiseValidationError("linear", "Comment does not belong to this issue").comment(id:) { children(first:) }or thecomments(filter: {parent: {id: {eq}}})form; the recommendation is to keepchildren, which is a documented divergence._fetch_agent_session_messagesdocstring (:1660-1688) and thedocs/UPSTREAM_SYNC.md:691row so they describe the check instead of the fallback.Out of scope
linear:{issue}:s:{session}ids: [4.41/L1] Linear: stable agent-session thread ids, undetermined mentions for ordinary comments #232. With this issue a session with no root comment still raises "missing a root comment".webhook_verifier/ token function: [4.41/DEF1] Deferred upstream 4.32–4.41 features (demand-gated backlog) #189.Porting notes
None,""and a different id all raise, so usenot x or x != expected. That is the one place where truthiness is correct: it mirrors upstream's!issueId.ValidationError(chat_sdk.shared.errors, already imported atadapter.py:60-64), notAdapterError, so callers can tell an ownership failure from transport errors.issue { id }relation (there is no scalarissueIdonCommentorAgentSession). Read(node.get("issue") or {}).get("id").@linear/sdk). "Root first, then children" means two_graphql_querycalls, and the test asserts the second is never made on mismatch.Tests
packages/adapter-linear/src/index.test.tsis not fidelity-mapped (see #78). Port intotests/test_linear_adapter.py/tests/test_linear_agent_session_fetch.py:it.each(["forward", "backward"])"should fetch a same-issue comment thread in %s order". It replaces the chat@4.41.0 test "should fetch comment thread (root + children) when commentId present" (removed byd7aa75b1).it.each(["issue-private", undefined, null, ""])"rejects a comment thread with an unverified issueId: %s". It asserts there is no children query and no parse.it.each(["issue-private", null, "issue-public"])"validates comment ownership through the Linear SDK: %s". Here that means through_graphql_query: exactly one call on reject and two on accept, and the first query selects the issue id (upstream asserts the SDK request body containsissueId; assert the Pythonissue { id }selection instead).describe.each(["linear:issue-public:s:private-session", "linear:issue-public:c:source-comment:s:private-session"])"agent session ownership for %s" →it.each"rejects an unverified issueId before loading content: %s". Assert that neither the children query nor the activities query runs.it.each(["issue-private", null, "issue-public"])"validates agent session ownership through the Linear SDK: %s". Port the reject cases here. Theissue-publiccase withcomment: nullexpects the activities fallback, so port it in [4.41/L1] Linear: stable agent-session thread ids, undetermined mentions for ordinary comments #232 (here it still raises "missing a root comment").test_falls_back_to_thread_issue_id_when_session_issue_absent; it asserts the vulnerable behavior. Updatetest_raises_when_issue_id_missing_everywhereto the new error.AsyncMock(side_effect=[...])on_graphql_queryto assert call counts.Acceptance criteria
fetch_messages("linear:A:c:<comment on B>")andfetch_messages("linear:A:s:<session on B>")(and the:c:…:s:form) raiseValidationErrorbefore any content query.docs/UPSTREAM_SYNC.mdis updated (L5 row; note thechildrenvscomments(filter)query-shape divergence).ValidationErrorinstead of falling back.Dependencies
None; this can start immediately. Blocks #232.
Metadata
sync/4.41-l0Part of #184.