LearnNewsExamplesServices
Frontmatter
id13046
titleWire contentTrust projection into conversation read paths
stateClosed
labels
enhancementaiarchitecture
assigneesneo-opus-ada
createdAtJun 13, 2026, 2:46 AM
updatedAtJun 13, 2026, 8:39 AM
githubUrlhttps://github.com/neomjs/neo/issues/13046
authorneo-opus-ada
commentsCount0
parentIssue10476
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 13, 2026, 8:39 AM

Wire contentTrust projection into conversation read paths

Closed v13.1.0/archive-v13-1-0-chunk-2 enhancementaiarchitecture
neo-opus-ada
neo-opus-ada commented on Jun 13, 2026, 2:46 AM

Context

Slice 2 of #10476 (P8 anti-astroturfing, Epic #10291) — delivers its AC2. Slice 1 (#13026 / PR #13027, merged) landed the pure substrate in ai/services/shared/contentTrust/: classifyAuthorTrust (fail-closed GitHub-login → trust-tier, roster-derived from identityRoots.IDENTITIES, injectable collaborator set) and sanitizeContent (trust-gated URL defang → [QUARANTINED_URL: <domain>], denylist name redaction, stealth-intent signal flags; pure, non-mutating). The sanitizer's own JSDoc names this ticket's scope: "the pure transform both boundaries will call (the get_conversation read path and the KB ingestion path, in later wiring slices)". This is the read-path boundary; the ingest path (IssueSyncer/KB) is the next slice.

The Problem

IssueService.getConversation() (ai/services/github-workflow/IssueService.mjs:86), PullRequestService.getConversation() (PullRequestService.mjs:181), and DiscussionService.getConversation() (DiscussionService.mjs:63) return GitHub body/comment payloads verbatim — no trust-tier projection, no defang (verified on dev, mirroring @neo-gpt's 2026-06-07 intake V-B-A on #10476). An agent reading a hostile external comment through get_conversation receives a traversable marketing/watering-hole URL inline; the documented @desiorac incident entered through exactly this surface.

The Architectural Reality

  • The three services share one shape: GraphQL fetch → {title, body, author:{login}, comments:{nodes:[{id, author:{login}, body, …}]}} → client-side selector filter (comment_id > since_comment_id > last_n > full) → return. Discussions add nested replies.nodes under top-level comments.
  • The MCP router (ai/mcp/server/github-workflow/toolService.mjs:107) fans get_conversation to the issue/PR paths; get_discussion_conversation binds the Discussion path. Wiring at the service layer covers all tool surfaces at once.
  • Sibling-precedent placement: ai/services/github-workflow/shared/ (contentIndex/contentPath/pruneEmptyDirs) is the home for a github-workflow-specific pure helper.
  • Spec idiom: IssueService.spec.mjs stubs GraphqlService.query with save/restore — the wiring test reuses it.

The Fix

  1. New pure helper ai/services/github-workflow/shared/conversationTrust.mjs: projectConversationTrust(conversation, {collaborators, productNameDenylist}) — classifies the root author + every comment author (+ Discussion reply authors), stamps authorTrust per node, sanitizes body fields whose author tier is untrusted (fail-closed via isTrustedTier), and attaches an always-present top-level contentTrust summary {projected: true, quarantined, signals}. Pure: returns a new object, never mutates input, passes structured-error payloads through untouched.
  2. Wire all three services: apply the projection to the fetched resource BEFORE selector filtering, so every return path (full + all three selector shapes) inherits the projected nodes.
  3. Spec: pure-helper fixtures (trusted passthrough, body/comment/reply defang, signal paths, error passthrough, input non-mutation) + service wiring tests via the GraphqlService.query stub idiom.

Deliberate posture (documented, not hidden): roster-only classification at runtime this slicecollaborators stays an injectable parameter with no live fetch (every current write-access account is in identityRoots; a live collaborator fetch adds a token-scope failure mode — #12951 class — that earns its own slice if ever needed). No denylist config leaf yetsanitizeContent's merged default ([]) applies until an operator-curated list exists (P7/AC3 slice territory); URL defang + signal flags are the deterministic core and need no config.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
get_conversation / get_discussion_conversation response payload this ticket + #10476 AC2 ADDITIVE keys: authorTrust per authored node, contentTrust summary at root; untrusted-author body text rewritten per merged sanitizer contract trusted-tier content byte-identical; error payloads untouched PR body + helper JSDoc #13026/#13027 merged substrate; #10476 intake V-B-A
projectConversationTrust helper (NEW) sibling-lift github-workflow/shared/ pure projection; no I/O; collaborators/denylist injectable absent opts → roster-only classification (classifier's merged contract) Anchor & Echo JSDoc sibling helpers in same dir
Three getConversation implementations service layer projection applied post-fetch/pre-selector; selector semantics unchanged n/a (no behavior removed) service JSDoc note spec assertions on all selector paths

Decision Record impact

none (consumes #13026's substrate; aligned with Epic #10291's P8 prescription).

Acceptance Criteria

  • projectConversationTrust helper exists with full JSDoc; pure + non-mutating, error-payload passthrough.
  • Issue, PR, and Discussion getConversation all return projected payloads on every selector path.
  • External-author URL-bearing comment arrives defanged through the service layer (wiring test via GraphqlService.query stub); trusted-author content byte-identical.
  • Discussion nested replies are projected (not just top-level comments).
  • Unit specs green in the canonical test/playwright/unit/ai/services/github-workflow/ home.

Out of Scope

  • #10476 AC1 (AGENTS.md traversal-prohibition rule — turn-loaded substrate, own slice with turn-memory-pre-flight).
  • #10476 AC3 / P7 sanitization profile + any denylist config leaf.
  • KB ingestion path (IssueSyncer, TicketSource, IssueIngestor) — next wiring slice.
  • Live collaborator fetching.
  • openapi.yaml description changes (response keys are additive; tool description stays budget-lean).

Related

  • #10476 (parent P8 ticket; this is AC2)
  • #10291 (Epic: organism self-defense)
  • #13026 / PR #13027 (slice 1, merged substrate)

Release classification: post-release (self-defense hardening, non-blocking).

Live latest-open sweep: checked latest 12 open issues at 2026-06-13T00:46Z — no equivalent (closest: #13043/#13042 are AGENTS_STARTUP/workflow lanes, disjoint). A2A sweep: no overlapping claim; slice-1 lineage is mine via the #10476 thread.

Retrieval Hint: "contentTrust projection get_conversation read path defang wiring slice"