Skip to content

[4.41/L0] Linear: validate comment-thread and agent-session issue ownership #231

Description

@patrick-chinchill

Summary

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.

Current Python behavior

  • src/chat_sdk/adapters/linear/adapter.py:1713-1719 (_fetch_agent_session_messages):
    • It reads session_issue_id from issue { id }.
    • 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:270 test_falls_back_to_thread_issue_id_when_session_issue_absent asserts the fallback.
    • :286 test_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.

Out of scope

Porting notes

  • 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.
  • it.each(["issue-private", null, "issue-public"]) "validates agent session ownership through the Linear SDK: %s". Port the reject cases here. The issue-public case with comment: null expects 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").
  • Replace test_falls_back_to_thread_issue_id_when_session_issue_absent; it asserts the vulnerable behavior. Update test_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.

Dependencies

None; this can start immediately. Blocks #232.

Metadata

  • Effort: S (~150–250 LOC including tests)
  • Consumer impact: low. Linear only, and only affects cross-issue or unresolvable ids. None for Slack/Teams.
  • Suggested branch: sync/4.41-l0

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions