diff --git a/src/services/ai-summaries.ts b/src/services/ai-summaries.ts index cce2ce06d2..ac4eb7acde 100644 --- a/src/services/ai-summaries.ts +++ b/src/services/ai-summaries.ts @@ -4,6 +4,12 @@ import type { AgentRunBundle } from "./agent-orchestrator"; type AiSummaryVisibility = "private" | "public"; +const PRIVATE_CONTEXT_PATTERN = + /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|raw trust scores?|trust scores?|private reviewability|private scoreability|public score estimates?)\b/gi; +const PRIVATE_OUTCOME_PATTERN = /\b(payouts?|farming|reward estimates?|reward optimization)\b/gi; +const PUBLIC_FORBIDDEN_TEXT_PATTERN = + /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|raw trust scores?|trust scores?|estimated scores?|score estimates?|public score estimates?|reward estimates?|payouts?|farming|private reviewability|private scoreability|reward optimization)\b/i; + export type AiSummaryResult = | { status: "disabled"; reason: string } | { status: "unavailable"; reason: string } @@ -90,6 +96,7 @@ export async function summarizeAgentBundleWithAi(env: Env, bundle: AgentRunBundl } function compactAgentSignalBundle(bundle: AgentRunBundle, visibility: AiSummaryVisibility): Record { + const publicMode = visibility === "public"; return { run: { id: bundle.run.id, @@ -99,22 +106,35 @@ function compactAgentSignalBundle(bundle: AgentRunBundle, visibility: AiSummaryV status: bundle.run.status, dataQualityStatus: bundle.run.dataQualityStatus, }, - actions: bundle.actions.slice(0, 5).map((action) => ({ - actionType: action.actionType, - status: action.status, - recommendation: visibility === "public" ? action.publicSafeSummary : action.recommendation, - publicSafeSummary: action.publicSafeSummary, - why: action.why.slice(0, 4), - blockedBy: action.blockedBy.slice(0, 4), - scoreabilityImpact: visibility === "public" ? undefined : action.scoreabilityImpact, - riskImpact: visibility === "public" ? undefined : action.riskImpact, - maintainerImpact: action.maintainerImpact, - rerunWhen: action.rerunWhen, - })), + actions: bundle.actions.slice(0, 5).map((action) => { + const publicSafeSummary = publicMode ? sanitizePublicPromptText(action.publicSafeSummary) : action.publicSafeSummary; + return { + actionType: action.actionType, + status: action.status, + recommendation: publicMode ? publicSafeSummary : action.recommendation, + publicSafeSummary, + why: sanitizePromptList(action.why, visibility), + blockedBy: sanitizePromptList(action.blockedBy, visibility), + scoreabilityImpact: publicMode ? undefined : action.scoreabilityImpact, + riskImpact: publicMode ? undefined : action.riskImpact, + maintainerImpact: publicMode && action.maintainerImpact ? sanitizePublicPromptText(action.maintainerImpact) : action.maintainerImpact, + rerunWhen: action.rerunWhen, + }; + }), freshnessWarnings: bundle.contextSnapshots.flatMap((snapshot) => snapshot.freshnessWarnings).slice(0, 8), } as Record; } +function sanitizePromptList(values: string[], visibility: AiSummaryVisibility): string[] { + const selected = values.slice(0, 4); + if (visibility !== "public") return selected; + return selected.map((value) => sanitizePublicPromptText(value)).filter(Boolean); +} + +function sanitizePublicPromptText(value: string): string { + return sanitizeAiText(value, "public"); +} + function buildPrompt(signalBundle: Record, visibility: AiSummaryVisibility): string { return [ `Visibility: ${visibility}`, @@ -141,16 +161,16 @@ function extractAiText(response: unknown): string { function sanitizeAiText(value: string, visibility: AiSummaryVisibility): string { const sanitized = value - .replace(/\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|trust score)\b/gi, "private context") - .replace(/\b(payout|farming)\b/gi, "private outcome"); + .replace(PRIVATE_CONTEXT_PATTERN, "private context") + .replace(PRIVATE_OUTCOME_PATTERN, "private outcome"); if (visibility === "public") { - return sanitized.replace(/\b(estimated score|score estimate|reward estimate|reward optimization)\b/gi, "private context"); + return sanitized.replace(/\b(estimated scores?|score estimates?)\b/gi, "private context").trim(); } return sanitized.trim(); } function containsPublicForbiddenText(value: string): boolean { - return /\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|estimated score|score estimate|reward estimate|payout|farming)\b/i.test(value); + return PUBLIC_FORBIDDEN_TEXT_PATTERN.test(value); } function isEnabled(value: string | undefined): boolean { diff --git a/test/unit/ai-summaries.test.ts b/test/unit/ai-summaries.test.ts index 8ef18e1c92..948a3b5710 100644 --- a/test/unit/ai-summaries.test.ts +++ b/test/unit/ai-summaries.test.ts @@ -3,6 +3,10 @@ import { __aiSummaryInternals, summarizeAgentBundleWithAi } from "../../src/serv import type { AgentRunBundle } from "../../src/services/agent-orchestrator"; import { createTestEnv } from "../helpers/d1"; +const PUBLIC_FORBIDDEN_TEXT = + /\b(wallets?|hotkeys?|raw trust scores?|trust scores?|payouts?|reward estimates?|farming|private reviewability|private scoreability|public score estimates?)\b/i; +type AiRunRequest = { messages: Array<{ role: string; content: string }> }; + describe("Workers AI summaries", () => { it("stays disabled by default and does not call Workers AI", async () => { const run = vi.fn(); @@ -122,6 +126,56 @@ describe("Workers AI summaries", () => { expect(unsafe).toMatchObject({ status: "unsafe", reason: "public summary failed sanitizer" }); }); + it.each(["wallet", "hotkey", "raw trust score", "payout", "reward estimate", "farming", "private reviewability", "private scoreability", "public score estimate"])( + "rejects unsafe public AI output containing %s", + async (unsafeText) => { + const run = vi.fn(async () => ({ response: `Do the next action because ${unsafeText} changed.` })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + }); + + const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "public"); + + expect(result).toMatchObject({ status: "unsafe", reason: "public summary failed sanitizer" }); + }, + ); + + it("keeps private action facts out of public AI prompt context", async () => { + const run = vi.fn(async () => ({ response: "Public-safe queue summary." })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + }); + + const publicResult = await summarizeAgentBundleWithAi(env, unsafeBundleFixture(), "public"); + + expect(publicResult).toMatchObject({ status: "ok" }); + const publicRequest = (run.mock.calls as unknown as Array<[string, AiRunRequest]>)[0]?.[1]; + const publicPrompt = publicRequest?.messages.find((message) => message.role === "user")?.content ?? ""; + expect(publicPrompt).not.toMatch(PUBLIC_FORBIDDEN_TEXT); + + const privateRun = vi.fn(async () => ({ response: "Private summary with authenticated context." })); + await expect( + summarizeAgentBundleWithAi( + { + ...env, + AI: { run: privateRun } as unknown as Ai, + }, + unsafeBundleFixture(), + "private", + ), + ).resolves.toMatchObject({ status: "ok" }); + + const privateRequest = (privateRun.mock.calls as unknown as Array<[string, AiRunRequest]>)[0]?.[1]; + const privatePrompt = privateRequest?.messages.find((message) => message.role === "user")?.content ?? ""; + expect(privatePrompt).toMatch(PUBLIC_FORBIDDEN_TEXT); + }); + it("falls back when Workers AI returns malformed output or throws non-Error values", async () => { const malformed = createTestEnv({ AI: { run: vi.fn(async () => ({ unknown: "shape" })) } as unknown as Ai, @@ -210,3 +264,24 @@ function bundleFixture(): AgentRunBundle { summary: "done", }; } + +function unsafeBundleFixture(): AgentRunBundle { + const bundle = bundleFixture(); + const action = bundle.actions[0]; + if (!action) throw new Error("missing fixture action"); + return { + ...bundle, + actions: [ + { + ...action, + recommendation: "Review wallet and hotkey evidence before discussing payout projections.", + why: ["raw trust score, farming language, and private reviewability are private context."], + blockedBy: ["private scoreability context and public score estimate are not public-safe."], + scoreabilityImpact: "Authenticated scoreability can include reward estimate details.", + riskImpact: "Private users may inspect payout evidence without public rendering.", + maintainerImpact: "Avoid publishing wallet, hotkey, or reward estimate language.", + publicSafeSummary: "Public score estimate and private reviewability should stay private.", + }, + ], + }; +}