diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e3ec949298..04f5498387 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -929,8 +929,8 @@ 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`, confirms the contributor, and reuses the already-fetched changed + * files. Fail-safe: any AI error is swallowed so the gate still finalizes. */ export async function runAiSlopForAdvisory( env: Env, @@ -939,11 +939,12 @@ export async function runAiSlopForAdvisory( repoFullName: string; pr: { number: number; title: string; body?: string | null | undefined }; author: string | null; + confirmedContributor: boolean; files: Awaited>; deterministicBand: SlopBand; }, ): Promise { - if (!args.advisory.headSha) return; + if (!args.confirmedContributor || !args.advisory.headSha) return; try { const result = await runGittensoryAiSlopAdvisory(env, { repoFullName: args.repoFullName, @@ -1140,9 +1141,22 @@ 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. + // threshold). Loads files lazily so disabled repos pay nothing. The AI advisory is additionally limited + // to confirmed contributors so untrusted PR authors cannot spend the shared Workers AI budget. let slopRisk: number | null = null; if (settings.slopGateMode !== "off") { const slopFiles = await listPullRequestFiles(env, repoFullName, pr.number); @@ -1155,22 +1169,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, { advisory, repoFullName, pr, author, files: slopFiles, deterministicBand: slop.band }); + await runAiSlopForAdvisory(env, { advisory, repoFullName, pr, author, confirmedContributor, files: slopFiles, deterministicBand: slop.band }); } } - 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/src/services/ai-slop.ts b/src/services/ai-slop.ts index 1f69cf9f9f..9d5287370d 100644 --- a/src/services/ai-slop.ts +++ b/src/services/ai-slop.ts @@ -12,7 +12,7 @@ // • Fail-safe on every path: AI off / no binding / over-budget / unparseable / unsafe text → no finding. // • Opt-in: only runs when the repo set `gate.slop.aiAdvisory: true` on top of `gate.slop.mode != off`. // -// Free Cloudflare Workers AI only (one call, metered against the shared daily neuron budget). BYOK is a +// Free Cloudflare Workers AI only (bounded and metered against the shared daily neuron budget). BYOK is a // possible later enhancement; slop assessment does not need a frontier model. Every public string is // forced through `toPublicSafe`; anything tripping the public/private boundary is dropped, not published. import type { SignalFinding } from "../signals/engine"; @@ -70,6 +70,7 @@ export type AiSlopResult = type SlopOpinion = { band: SlopBand; rationale: string; signals: string[] }; const SLOP_BANDS: readonly SlopBand[] = ["clean", "low", "elevated", "high"]; +const SLOP_AI_MAX_MODEL_CALLS = 6; function isSlopBand(value: unknown): value is SlopBand { return typeof value === "string" && (SLOP_BANDS as readonly string[]).includes(value); @@ -173,7 +174,7 @@ export async function runGittensoryAiSlopAdvisory(env: Env, input: AiSlopInput): const maxTokens = clampNumber(Number(env.AI_MAX_OUTPUT_TOKENS || 256), 256, 1024); const user = buildUserPrompt(input); - const estimatedNeurons = estimateNeurons(SLOP_SYSTEM_PROMPT.length + user.length, maxTokens, 1); + const estimatedNeurons = estimateNeurons(SLOP_SYSTEM_PROMPT.length + user.length, maxTokens, SLOP_AI_MAX_MODEL_CALLS); const budget = clampNumber(Number(env.AI_DAILY_NEURON_BUDGET || 10000), 0, 1_000_000); const used = await sumAiEstimatedNeuronsSince(env, utcDayStartIso()); const remainingBudget = Math.max(0, budget - used); diff --git a/test/unit/ai-slop.test.ts b/test/unit/ai-slop.test.ts index 784e0c9dc5..5107a28391 100644 --- a/test/unit/ai-slop.test.ts +++ b/test/unit/ai-slop.test.ts @@ -256,17 +256,35 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { repoFullName: "acme/widgets", pr, author: "alice", + confirmedContributor: true, files, deterministicBand: "elevated", }); expect(adv.findings.map((f) => f.code)).toEqual([AI_SLOP_FINDING_CODE]); }); + + it("no-ops for unconfirmed contributors so untrusted PRs cannot spend AI budget", async () => { + const adv = advisory(); + const run = vi.fn(async () => ({ response: slopJson({ band: "high" }) })); + await runAiSlopForAdvisory(enabledEnv(run), { + advisory: adv, + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: false, + files, + deterministicBand: "elevated", + }); + expect(adv.findings).toEqual([]); + expect(run).not.toHaveBeenCalled(); + }); + it("no-ops when the advisory has no head SHA", async () => { const noSha = advisory(); delete (noSha as Partial).headSha; const run = vi.fn(); - await runAiSlopForAdvisory(enabledEnv(run), { advisory: noSha, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "low" }); + await runAiSlopForAdvisory(enabledEnv(run), { advisory: noSha, repoFullName: "acme/widgets", pr, author: "alice", confirmedContributor: true, files, deterministicBand: "low" }); expect(noSha.findings).toEqual([]); expect(run).not.toHaveBeenCalled(); }); @@ -278,6 +296,7 @@ describe("runAiSlopForAdvisory (processor wiring)", () => { repoFullName: "acme/widgets", pr, author: "alice", + confirmedContributor: true, files, deterministicBand: "clean", }); @@ -287,7 +306,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, { advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", files, deterministicBand: "high" })).resolves.toBeUndefined(); + await expect(runAiSlopForAdvisory(env, { advisory: adv, repoFullName: "acme/widgets", pr, author: "alice", confirmedContributor: true, files, deterministicBand: "high" })).resolves.toBeUndefined(); expect(adv.findings).toEqual([]); }); });