diff --git a/src/scenarios/input-model.ts b/src/scenarios/input-model.ts index 6677223dc8..20d2f87542 100644 --- a/src/scenarios/input-model.ts +++ b/src/scenarios/input-model.ts @@ -29,7 +29,8 @@ export const scenarioSignalSources = [ ] as const; export type ScenarioSignalSource = (typeof scenarioSignalSources)[number]; -const FORBIDDEN_PUBLIC_LANGUAGE = +/** Shared public-safety term set for scenario input + public summary guards (#8885 / #913). */ +export const FORBIDDEN_PUBLIC_LANGUAGE = /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|estimated[-\s]?rewards?|rewards?|reward[-\s]?estimate|rankings?|farming|raw trust|trust[-\s]?score|scoreability|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)/i; const FORBIDDEN_SOURCE_UPLOAD_KEYS = diff --git a/src/scenarios/scenario-summary.ts b/src/scenarios/scenario-summary.ts index 2ccdffab69..fb322e98b2 100644 --- a/src/scenarios/scenario-summary.ts +++ b/src/scenarios/scenario-summary.ts @@ -4,7 +4,7 @@ import type { OpenPrPressureSimulation, OpenPrStrategyOption } from "../services import type { ScoreGateBlocker } from "../scoring/preview"; import type { PendingPrScenarioDetection, OpenPrPendingClass } from "../scoring/pending-pr-scenarios"; import type { AgentScenarioInput } from "./input-model"; -import { serializeScenarioInputPublic } from "./input-model"; +import { FORBIDDEN_PUBLIC_LANGUAGE, serializeScenarioInputPublic } from "./input-model"; /** * Public-safe rendering of scenario simulator outputs for MCP/API clients and @@ -92,9 +92,6 @@ const PUBLIC_BLOCKER_TEXT: Partial> = { stale_work: "Stale open PR(s) detected; consider closing stale work before opening more.", }; -const FORBIDDEN_PUBLIC_LANGUAGE = - /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|reward[-\s]?estimate|farming|raw trust|trust[-\s]?score|scoreability|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)/i; - function renderOptions(simulation: OpenPrPressureSimulation): RenderedScenarioOption[] { return simulation.scenarios.map((s) => { const rationaleParts = [...s.facts.slice(0, 1), ...s.tradeoffs.slice(0, 1)]; @@ -180,17 +177,16 @@ function extractDataClassification(scenarioInput: AgentScenarioInput | undefined }; } -function assertPublicSummaryClean(summary: PublicScenarioSummary): void { +/** Defensive final guard: catches forbidden terms that slipped past per-field sanitization (#8885). */ +export function assertPublicSummaryClean(summary: PublicScenarioSummary): void { // Scan only rendered free-text fields. repoFullName/generatedAt are structural identifiers (the repo // the summary is about), not sanitized content -- a legitimately named repo (e.g. "owner/hotkey-vault") // must not make this guard throw and fail the whole summary. const { repoFullName: _repoFullName, generatedAt: _generatedAt, ...renderedContent } = summary; const serialized = JSON.stringify(renderedContent); - /* v8 ignore start -- Defensive: every rendered field is individually sanitized; this guards a future unsanitized field. */ if (FORBIDDEN_PUBLIC_LANGUAGE.test(serialized)) { throw new Error("Public scenario summary still contains forbidden language."); } - /* v8 ignore end */ } /** diff --git a/test/unit/scenario-summary.test.ts b/test/unit/scenario-summary.test.ts index 89293507be..3c5420fd0c 100644 --- a/test/unit/scenario-summary.test.ts +++ b/test/unit/scenario-summary.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; import { sanitizePublicComment } from "../../src/github/commands"; -import { buildScenarioInput, createScenarioSignalEntry } from "../../src/scenarios/input-model"; -import { renderPublicScenarioSummary } from "../../src/scenarios/scenario-summary"; +import { + buildScenarioInput, + createScenarioSignalEntry, + FORBIDDEN_PUBLIC_LANGUAGE, +} from "../../src/scenarios/input-model"; +import { + assertPublicSummaryClean, + renderPublicScenarioSummary, + type PublicScenarioSummary, +} from "../../src/scenarios/scenario-summary"; import { deriveEligibilityPlan } from "../../src/services/eligibility-plan"; import { simulateOpenPrPressure } from "../../src/services/open-pr-pressure-scenarios"; import type { PendingPrScenarioDetection } from "../../src/scoring/pending-pr-scenarios"; @@ -9,9 +17,6 @@ import { buildScorePreview, type ScoreGateBlocker } from "../../src/scoring/prev import type { QueueHealth, RoleContext } from "../../src/signals/engine"; import type { ScoringModelSnapshotRecord } from "../../src/types"; -const FORBIDDEN_PUBLIC_LANGUAGE = - /wallet|hotkey|coldkey|mnemonic|seed phrase|payout|reward[-\s]?estimate|farming|raw trust|trust[-\s]?score|scoreability|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)/i; - const snapshot: ScoringModelSnapshotRecord = { id: "scenario-summary-model", sourceKind: "test", @@ -411,4 +416,31 @@ describe("renderPublicScenarioSummary", () => { expect(summary.pendingScenarioNotes.join(" ")).not.toMatch(/Projected open PR count after pending cleanup/i); expect(summary.pendingPullRequests[0]?.classification).toBe("custom pending class"); }); + + it("assertPublicSummaryClean rejects bare rewards/rankings like the input-model guard (#8885)", () => { + const base: PublicScenarioSummary = { + repoFullName: "octo/demo", + generatedAt: "2026-06-03T00:00:00.000Z", + advisoryOnly: true, + notAutonomousPrBot: true, + notPublicScoring: true, + headline: "Advisory scenario summary generated from available repo signals.", + options: [], + eligibilityNotes: [], + blockerNotes: [], + pendingScenarioNotes: [], + pendingPullRequests: [], + dataClassification: { facts: [], assumptions: [], unavailableSignals: [] }, + }; + + expect(() => assertPublicSummaryClean({ ...base, headline: "Projected rewards look strong." })).toThrow( + /forbidden language/i, + ); + expect(() => assertPublicSummaryClean({ ...base, eligibilityNotes: ["Private rankings leaked."] })).toThrow( + /forbidden language/i, + ); + // Shared constant must stay identical to the input-model #913 term set. + expect(FORBIDDEN_PUBLIC_LANGUAGE.test("rewards")).toBe(true); + expect(FORBIDDEN_PUBLIC_LANGUAGE.test("rankings")).toBe(true); + }); });