diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c30a8c8fd0..3b75939805 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -949,14 +949,17 @@ export async function runAiSlopForAdvisory( author: string | null; files: Awaited>; deterministicBand: SlopBand; + confirmedContributor: boolean; }, ): Promise { if (!args.advisory.headSha) return; try { // BYOK (opt-in): reuse the repo's encrypted key + aiReviewByok flag — one BYOK key serves both AI // features. A declared provider must match the stored key's provider, else skip BYOK (Workers-AI - // fallback). The slop advisory stays advisory-only regardless of which model writes it. - const storedKey = args.settings.aiReviewByok ? await getDecryptedRepositoryAiKey(env, args.repoFullName) : null; + // fallback). Because BYOK bills the maintainer, only confirmed contributors may use it; + // unconfirmed PRs fall back to Workers AI. The slop advisory stays advisory-only regardless of + // which model writes it. + const storedKey = args.settings.aiReviewByok && args.confirmedContributor ? await getDecryptedRepositoryAiKey(env, args.repoFullName) : null; const providerKey = storedKey && (!args.settings.aiReviewProvider || args.settings.aiReviewProvider === storedKey.provider) ? { provider: storedKey.provider, key: storedKey.key, model: args.settings.aiReviewModel ?? storedKey.model } @@ -1157,6 +1160,18 @@ async function maybePublishPrPublicSurface( scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length, }); + if (gateEnabled && author && !publicSurfaceSkipped && !official) { + official = await getCachedOfficialMinerDetection(env, author, { + targetKey: `${repoFullName}#${pr.number}`, + deliveryId: webhook.deliveryId, + }); + } + + // Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable + // detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before + // evaluating blockers so confirmed contributors cannot bypass a required Gate check. + const confirmedContributor = official?.status === "confirmed"; + // Anti-slop (#530/#532): only when opted in (slopGateMode !== "off"). Surface the deterministic slop // findings as advisory context, and feed the score to the gate (it only blocks under slop: block + the // threshold). Loads files lazily so disabled repos pay nothing. @@ -1175,22 +1190,10 @@ async function maybePublishPrPublicSurface( // AI-assisted slop advisory (#533, opt-in). Reuses the already-fetched files; appends at most one // advisory-only finding. Deliberately does NOT update slopRisk — only the deterministic core blocks. if (settings.slopAiAdvisory) { - await runAiSlopForAdvisory(env, { settings, advisory, repoFullName, pr, author, files: slopFiles, deterministicBand: slop.band }); + await runAiSlopForAdvisory(env, { settings, advisory, repoFullName, pr, author, files: slopFiles, deterministicBand: slop.band, confirmedContributor }); } } - if (gateEnabled && author && !publicSurfaceSkipped && !official) { - official = await getCachedOfficialMinerDetection(env, author, { - targetKey: `${repoFullName}#${pr.number}`, - deliveryId: webhook.deliveryId, - }); - } - - // Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable - // detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before - // evaluating blockers so confirmed contributors cannot bypass a required Gate check. - const confirmedContributor = official?.status === "confirmed"; - // AI maintainer review (opt-in via aiReviewMode). Mutates `advisory` with a consensus defect (if any) // BEFORE the gate evaluates, and returns advisory notes for the panel. Inside the try so any AI // failure is caught and the gate is still finalized (never left in_progress). diff --git a/test/unit/ai-slop.test.ts b/test/unit/ai-slop.test.ts index 7df1b63413..a8c61d88dc 100644 --- a/test/unit/ai-slop.test.ts +++ b/test/unit/ai-slop.test.ts @@ -275,6 +275,7 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { author: "alice", files, deterministicBand: "elevated", + confirmedContributor: true, }); expect(adv.findings.map((f) => f.code)).toEqual([AI_SLOP_FINDING_CODE]); }); @@ -283,7 +284,7 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { const noSha = advisory(); delete (noSha as Partial).headSha; const run = vi.fn(); - await runAiSlopForAdvisory(enabledEnv(run), { settings: noByok, advisory: noSha, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "low" }); + await runAiSlopForAdvisory(enabledEnv(run), { settings: noByok, advisory: noSha, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "low", confirmedContributor: true }); expect(noSha.findings).toEqual([]); expect(run).not.toHaveBeenCalled(); }); @@ -298,6 +299,7 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { author: "alice", files, deterministicBand: "clean", + confirmedContributor: true, }); expect(adv.findings).toEqual([]); }); @@ -305,7 +307,7 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { it("is fail-safe: a thrown error (broken DB) yields no finding and never throws", async () => { const adv = advisory(); const env = { ...enabledEnv(async () => ({ response: slopJson() })), DB: undefined } as unknown as Env; - await expect(runAiSlopForAdvisory(env, { settings: noByok, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "high" })).resolves.toBeUndefined(); + await expect(runAiSlopForAdvisory(env, { settings: noByok, advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "high", confirmedContributor: true })).resolves.toBeUndefined(); expect(adv.findings).toEqual([]); }); @@ -330,10 +332,41 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { author: "alice", files, deterministicBand: "elevated", + confirmedContributor: true, }); // The advisory came from the BYOK provider (high band → finding), and Workers AI was never called. expect(adv.findings.map((f) => f.code)).toEqual([AI_SLOP_FINDING_CODE]); expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.anthropic.com/v1/messages"); expect(run).not.toHaveBeenCalled(); }); + + it("does not use the maintainer BYOK key for unconfirmed contributors", async () => { + const run = vi.fn(async () => ({ response: slopJson({ band: "high" }) })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + TOKEN_ENCRYPTION_SECRET: "ai-slop-byok-test-encryption-secret-32b", + }); + await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-slop-9999", model: null }); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ content: [{ type: "text", text: slopJson({ band: "high" }) }] }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const adv = advisory(); + await runAiSlopForAdvisory(env, { + settings: { aiReviewByok: true } as RepositorySettings, + advisory: adv, + repoFullName: "acme/widgets", + pr, + author: "mallory", + files, + deterministicBand: "elevated", + confirmedContributor: false, + }); + + expect(adv.findings.map((f) => f.code)).toEqual([AI_SLOP_FINDING_CODE]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalled(); + }); + });