diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c63a33b522..6cd4685e51 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -940,8 +940,10 @@ export async function runAiReviewForAdvisory( /** * AI-assisted slop advisory (opt-in `slopAiAdvisory`). Appends at most one ADVISORY-only `ai_slop_advisory` * finding to the advisory; NEVER touches slopRisk or the gate (only the deterministic core can block). The - * caller gates on `settings.slopAiAdvisory` and reuses the already-fetched changed files. Fail-safe: any AI - * error is swallowed so the gate still finalizes. + * caller gates on `settings.slopAiAdvisory` and reuses the already-fetched changed files. Like the AI review + * path, it runs ONLY for confirmed contributors so an unconfirmed/untrusted PR author cannot spend either the + * shared Workers AI budget or the maintainer-paid BYOK quota. Fail-safe: any AI error is swallowed so the + * gate still finalizes. */ export async function runAiSlopForAdvisory( env: Env, @@ -953,13 +955,17 @@ export async function runAiSlopForAdvisory( author: string | null; files: Awaited>; deterministicBand: SlopBand; + confirmedContributor: boolean; }, ): Promise { - if (!args.advisory.headSha) return; + // Confirmed-contributor gate (matches runAiReviewForAdvisory): no AI spend — free OR BYOK — on a PR from + // an unconfirmed author. The deterministic slop core still ran for everyone; only the AI layer is gated. + if (!args.confirmedContributor || !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. + // fallback). The contributor is already confirmed (early return above), so BYOK billing is authorized. + // The slop advisory stays advisory-only regardless of which model writes it. const storedKey = args.settings.aiReviewByok ? await getDecryptedRepositoryAiKey(env, args.repoFullName) : null; const providerKey = storedKey && (!args.settings.aiReviewProvider || args.settings.aiReviewProvider === storedKey.provider) @@ -1161,6 +1167,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. @@ -1179,22 +1197,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..1b2ff87aa5 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("no-ops entirely for unconfirmed contributors — neither the maintainer BYOK key nor free Workers AI is spent", 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, + }); + + // Matches the AI review path: an unconfirmed author triggers no AI spend at all, so no finding lands. + expect(adv.findings).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + }); });