LearnNewsExamplesServices
Frontmatter
id17246
titleSync containment has an author-level lever for discussions only — and we get hit on issues
stateClosed
labels
bugaiarchitecturesecurityagent-os
assigneesneo-opus-ada
createdAtAug 16, 2026, 9:53 PM
updatedAtAug 17, 2026, 10:34 AM
githubUrlhttps://github.com/neomjs/neo/issues/17246
authorneo-opus-ada
commentsCount0
parentIssue10291
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 17, 2026, 10:34 AM

Sync containment has an author-level lever for discussions only — and we get hit on issues

Closed Backlog/active-chunk-16 bugaiarchitecturesecurityagent-os
neo-opus-ada
neo-opus-ada commented on Aug 16, 2026, 9:53 PM

Sync containment has an author-level lever for discussions only — and we get hit on issues

Context

Live incident, 2026-08-16. Issue #17236 was filed at 07:48Z by an authorAssociation: NONE account carrying the full hostile-content-quarantine §2 marker set: a vendor pitch under a helpful framing, a bare product-name drop, an integration sample in a language this repository does not use, and an engagement-bait closer. The swarm correctly held don't-engage for ~12h (0 reactions, 0 comments). It was neutralized at 19:45Z — title + body redacted, CLOSED not-planned, locked: SPAM.

By then it had already been ingested, at resources/content/issues/chunk-16/issue-17236.md, with the vendor name verbatim in the corpus title: field.

This ticket is not about that post. It is about the containment lever an agent reaches for during the response, and what it found.

The Problem

The hostile-content-quarantine §6 moderation matrix offers three dispositions, the third being:

Moderation deferred / record deliberately kept → Sync denylist (post-#12995) excludes it from ingestion

Following that instruction on an issue leads to discussionDenylist — and nothing else. An agent (this one) grepped the syncers, found a denylist gate in DiscussionSyncer and none in IssueSyncer, and concluded that issues have no containment at all. That conclusion was wrong, and being wrong took a V-B-A pass to discover: droppedLabels is the issue-side lever, and it drives the same file-unlink + content-index-removal machinery the discussion denylist does.

So there are two distinct defects, and only the second is a real capability gap:

  1. The playbook names a lever that does not exist on the surface we were attacked on. §6 reads as general; it is discussion-specific. The issue-side lever (droppedLabels) is documented nowhere in the moderation path.
  2. There is no author-level containment for issues or pull requests. discussionDenylist matches on number or author.login. The issue path matches on labels only. One hostile account posting ten discussions costs one config entry; the same account posting ten issues costs ten label applications, forever, reactively, one per artifact — and each one requires a human or agent to notice it first.

The Architectural Reality

Containment machinery is already at parity. The asymmetry is entirely in the trigger.

Surface Gate Trigger Already-emitted copy removed?
discussions DiscussionSyncer#isDenylisted (:265-268) discussionDenylist.numbers.authors yes — fs.unlink + quarantineRemovalsupdateContentIndex(remove:) (:639-655, :766)
issues IssueSyncer#getIssuePathnull (:573) droppedLabels only (:422) yes — fs.unlink + indexMutations.remove (:728-745, :883)
pull requests none

droppedLabels defaults to ['dropped', 'wontfix', 'duplicate'] (ai/mcp/server/github-workflow/configBase.mjs:148). None of those three carries "hostile" semantics: dropped is premise abandoned/superseded, wontfix is will not be worked on, duplicate is already exists. Containing spam today therefore requires mislabelling it as a disposition it is not — and the label that reads closest to the truth, invalid ("This doesn't seem right"), is not in the set.

The natural moderation acts — close as not-planned, lock as spam — are not labels, so neither triggers containment. A moderator who does the obviously-right thing gets no containment at all.

The Fix

Mirror the discussion gate onto the issue and pull-request syncers. The machinery exists; only the trigger is missing.

  1. ai/mcp/server/github-workflow/configBase.mjs — add an issueDenylist: leaf({numbers: [], authors: []}) sibling to discussionDenylist, with the same policy-free empty default and the same JSDoc contract (number-matching quarantines cached copies; author-matching is fetch-time exclusion, because the sync cache persists number and not author).
  2. ai/services/github-workflow/sync/IssueSyncer.mjs — a #isDenylisted(issue) private mirroring DiscussionSyncer:265-268, consulted inside #getIssuePath so a denylisted issue returns null and falls into the existing :728-745 quarantine branch. No new removal path is written; the gap is the predicate, not the plumbing.
  3. .agents/skills/hostile-content-quarantine/references/hostile-content-quarantine-workflow.md — correct the §6 matrix to name the real per-surface levers instead of a single "sync denylist", so the next responder does not repeat this ticket's own misdiagnosis.

Pull requests are deliberately excluded, and the reason is structural rather than effort. IssueSyncer can adopt the gate in three lines because the quarantine branch already exists at :728-745. PullRequestSyncer has no equivalent: at :631 it takes targetPath from #getPullRequestPath and goes straight to path.dirname(targetPath) at :653, so a null return throws rather than containing. Adding PR containment means authoring a drop branch — skip, unlink, index-removal, metadata deletion — not adding a predicate. That is a different change with a different risk profile and it belongs in its own ticket.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
issueSyncConfig.issueDenylist configBase.mjs:157 (discussionDenylist precedent) {numbers: [], authors: []}; empty default is a no-op absent/malformed → treated as empty, sync unchanged JSDoc leaf comment new leaf
IssueSyncer#isDenylisted DiscussionSyncer.mjs:265-268 number ∥ author match → #getIssuePath returns null no match → current behavior verbatim JSDoc IssueSyncer.mjs:573 is the existing null-return site
IssueSyncer quarantine branch IssueSyncer.mjs:728-745 unchanged — reused, not rewritten n/a existing fs.unlink + indexMutations.remove verified present
PullRequestSyncer gate PullRequestSyncer.mjs:631:653 not changed here — no drop branch exists to route into; null would throw at path.dirname n/a §6 table marks it a gap verified at :653; own ticket
§6 moderation matrix hostile-content-quarantine-workflow.md:52-58 per-surface lever named explicitly n/a the skill payload itself this ticket's misdiagnosis is the specimen

Decision Record impact

none — extends an existing containment pattern to sibling surfaces within the substrate that already owns it. No ADR authority is amended or challenged.

Acceptance Criteria

  • issueDenylist leaf exists in configBase.mjs with an empty, policy-free default, and its JSDoc states the number-vs-author asymmetry.
  • A denylisted-by-number issue that is already present in resources/content/issues/** is removed from disk and from the content index on the next sync — asserted against the existing quarantine branch, not a new one.
  • A denylisted-by-author issue is excluded at fetch time and never written, and a legitimate issue in the same fetched batch still syncs — a gate that suppresses the whole page would otherwise pass the first half of this AC.
  • With an empty denylist (the default), issue sync output is unchanged — proven by a spec, and by the two behavioral specs going red when the gate is disabled while the no-op control stays green.
  • The quarantine playbook §6 matrix names the real lever per surface (droppedLabels and issueDenylist for issues, discussionDenylist for discussions, none for pull requests) and no longer implies one general "sync denylist".
  • The pull-request containment gap is recorded in that same table as an explicit gap, with its own ticket, rather than left for the next responder to rediscover.
  • Specs live under test/playwright/unit/ai/services/github-workflow/sync/, matching the existing sibling layout.

Out of Scope

Two verified findings from the same investigation that this ticket deliberately does not fix — both belong to #10476, and bundling them would make this un-reviewable:

  • contentTrust.signals has zero consumers. A grep across ai/ finds no reader of the signals array outside the sanitizer and its own specs. The three STEALTH_SIGNALS patterns in astroturfSanitizer.mjs:49-72 fire into a field nothing acts on. #17236 produced signals: [] — a true negative for all three encoded incidents and a false all-clear for this one.

  • productNameDenylist cannot cover a first-seen name. It defaults to [] (configBase.mjs:164) and nothing populates it; by construction a denylist cannot contain a product name first observed today, which is precisely the link-free seeding variant the sanitizer's own module doc claims to cover.

  • Pull-request containment. Verified absent, and absent for a structural reason: PullRequestSyncer:653 calls path.dirname(targetPath) unconditionally, so there is no null branch to route a denylisted PR into. Closing it means authoring a drop path, not adding a predicate. Filed separately; the §6 table records it as a named gap in the meantime so the next responder inherits the finding instead of the surprise. A live specimen already exists (a FIRST_TIME_CONTRIBUTOR PR filed today claiming to fix a ticket it does not touch), so this is not hypothetical.

Also out of scope: automated hostility classification (that stays a human/agent judgment per §1's anti-trigger), and GitHub-side comment deletion (#10476 Out of Scope, unchanged).

Avoided Traps

  • Adding a fourth regex to STEALTH_SIGNALS. Tempting, and wrong: the signals have no consumer, so a new pattern would be pure diagnostics — it would have changed nothing about #17236's ingestion. Detector work belongs behind a consumer, in #10476.
  • Adding invalid to droppedLabels. It would retroactively drop every currently-invalid-labelled ticket from the corpus — a silent, wide blast radius disguised as a one-word config change.
  • Writing a new removal path. The V-B-A that produced this ticket first concluded issues had no containment at all. They do. The plumbing is reused verbatim; only the predicate is new.

Related

  • Parent Epic: #10291 (organism self-defense)
  • Sibling: #10476 (P8 link quarantine + stealth-intent detection) — owns the two Out-of-Scope findings
  • Precedent: #12995 (KB tier taint + sync denylist) — shipped the discussion-side lever this extends
  • Incident evidence: #17236 (redacted + locked), resources/content/issues/chunk-16/issue-17236.md
  • Playbook: .agents/skills/hostile-content-quarantine/

Origin Session ID: 3f264a19-c7d4-481e-bc80-5c288bca177f

Retrieval Hint: query_raw_memories("sync containment denylist issues discussions author-level astroturf #17236")

Live latest-open sweep: checked latest 20 open issues at 2026-08-16T19:52Z; A2A in-flight claim sweep over the last 30 messages at the same time; no equivalent ticket and no competing lane claim found.

tobiu referenced in commit 614676f - "feat(ai): containment gains an author-level lever on the surface we get attacked on (#17246) (#17250) on Aug 17, 2026, 10:34 AM
tobiu closed this issue on Aug 17, 2026, 10:34 AM