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
- 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.
- 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.
- 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 slice — collaborators 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 yet — sanitizeContent'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
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"
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 fromidentityRoots.IDENTITIES, injectable collaborator set) andsanitizeContent(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 (theget_conversationread 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), andDiscussionService.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 throughget_conversationreceives a traversable marketing/watering-hole URL inline; the documented@desioracincident entered through exactly this surface.The Architectural Reality
{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 nestedreplies.nodesunder top-level comments.ai/mcp/server/github-workflow/toolService.mjs:107) fansget_conversationto the issue/PR paths;get_discussion_conversationbinds the Discussion path. Wiring at the service layer covers all tool surfaces at once.ai/services/github-workflow/shared/(contentIndex/contentPath/pruneEmptyDirs) is the home for a github-workflow-specific pure helper.IssueService.spec.mjsstubsGraphqlService.querywith save/restore — the wiring test reuses it.The Fix
ai/services/github-workflow/shared/conversationTrust.mjs:projectConversationTrust(conversation, {collaborators, productNameDenylist})— classifies the root author + every comment author (+ Discussion reply authors), stampsauthorTrustper node, sanitizesbodyfields whose author tier is untrusted (fail-closed viaisTrustedTier), and attaches an always-present top-levelcontentTrustsummary{projected: true, quarantined, signals}. Pure: returns a new object, never mutates input, passes structured-error payloads through untouched.GraphqlService.querystub idiom.Deliberate posture (documented, not hidden): roster-only classification at runtime this slice —
collaboratorsstays an injectable parameter with no live fetch (every current write-access account is inidentityRoots; a live collaborator fetch adds a token-scope failure mode —#12951class — that earns its own slice if ever needed). No denylist config leaf yet —sanitizeContent'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
get_conversation/get_discussion_conversationresponse payloadauthorTrustper authored node,contentTrustsummary at root; untrusted-authorbodytext rewritten per merged sanitizer contractprojectConversationTrusthelper (NEW)github-workflow/shared/getConversationimplementationsDecision Record impact
none(consumes #13026's substrate; aligned with Epic #10291's P8 prescription).Acceptance Criteria
projectConversationTrusthelper exists with full JSDoc; pure + non-mutating, error-payload passthrough.getConversationall return projected payloads on every selector path.GraphqlService.querystub); trusted-author content byte-identical.test/playwright/unit/ai/services/github-workflow/home.Out of Scope
AGENTS.mdtraversal-prohibition rule — turn-loaded substrate, own slice with turn-memory-pre-flight).IssueSyncer,TicketSource,IssueIngestor) — next wiring slice.openapi.yamldescription changes (response keys are additive; tool description stays budget-lean).Related
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"