diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 173fc6f5cf..7c59eeec09 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -1,6 +1,23 @@ -import { sanitizePublicComment } from "../github/commands"; +import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction"; import type { ScoreGateDelta, ScorePreviewResult } from "../scoring/preview"; +// This endpoint (POST /v1/scoring/explain-breakdown, gated by requireContributorAccess) is authenticated and +// scoped to the requesting contributor's OWN score -- not a public GitHub comment surface -- so "score", +// "credibility", and "leverage" are its legitimate core vocabulary, not a leak. Reusing the shared +// sanitizePublicComment (src/github/commands.ts, tuned for genuinely public GitHub comment surfaces) mangled +// this feature's own output ("saturated near the score cap" -> "saturated near the private context cap", +// "Contributor credibility evidence..." -> "Contributor private context evidence..."). Mirrors the +// established, documented pattern in agent-action-explanation-card.ts / miner-dashboard-recommendations.ts +// (see src/signals/redaction.ts's note above PUBLIC_UNSAFE_TERMS): this endpoint's output is entirely +// computed, structured score data (numbers and gate deltas), so the only genuine residual risk is an +// accidentally-embedded token or local filesystem path -- keep just that minimal safety net rather than the +// full gittensor-economic-vocabulary substitution. +const TOKEN_OR_PATH_PATTERN = new RegExp(`\\bgithub_pat_[A-Za-z0-9_]+|\\bgh[pousr]_[A-Za-z0-9_]+|(?:${PUBLIC_LOCAL_PATH_INLINE})\\S+`, "gi"); + +function sanitizeScoreBreakdownText(value: string): string { + return value.replace(TOKEN_OR_PATH_PATTERN, ""); +} + export type ScoreMultiplierBand = "full" | "reduced" | "neutral" | "blocked"; export type ScoreMultiplierBreakdown = { @@ -359,7 +376,7 @@ function roundBand(value: number): string { function gateHighlightsFor(preview: ScorePreviewResult): ScoreBreakdownExplanation["gateHighlights"] { return preview.gateDeltas.map((delta) => ({ gate: delta.gate, - explanation: sanitizePublicComment(delta.explanation), + explanation: sanitizeScoreBreakdownText(delta.explanation), })); } @@ -383,7 +400,7 @@ function pickHighestLeverage(components: ScoreMultiplierBreakdown[]): ScoreBreak component: top.component, lever: top.lever, tiedLeverageComponents: tied, - reason: sanitizePublicComment(reason), + reason: sanitizeScoreBreakdownText(reason), }; } @@ -408,8 +425,8 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo nonCodeCapBreakdown(preview), ].map((entry) => ({ ...entry, - summary: sanitizePublicComment(entry.summary), - lever: sanitizePublicComment(entry.lever), + summary: sanitizeScoreBreakdownText(entry.summary), + lever: sanitizeScoreBreakdownText(entry.lever), })); return { diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index a116da0681..92a60ce838 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -237,7 +237,10 @@ describe("explainScoreBreakdown", () => { const breakdown = explainScoreBreakdown(preview); expect(breakdown.gateHighlights.length).toBeGreaterThan(0); - expect(breakdown.gateHighlights[0]?.explanation).toMatch(/private context|estimated score/i); + // score-breakdown.ts now uses its own curated vocabulary (not the shared public-comment sanitizer), so + // "estimated score" is the only possible outcome here -- the "private context" alternative was pinned to + // the old shared-sanitizer mangling and is no longer reachable. + expect(breakdown.gateHighlights[0]?.explanation).toMatch(/estimated score/i); expect(JSON.stringify(breakdown.gateHighlights)).not.toMatch(FORBIDDEN); }); @@ -728,4 +731,35 @@ describe("explainScoreBreakdown", () => { expect(top.tiedLeverageComponents).toEqual([]); expect(top.reason).not.toMatch(/ties with/); }); + + // REGRESSION (score-breakdown-own-vocabulary): explainScoreBreakdown previously piped its own copy through + // the shared public-comment sanitizer (src/github/commands.ts's sanitizePublicComment), which mangles bare + // "score"/"credibility" into "private context" -- fine for a genuinely public GitHub comment, but this + // endpoint is authenticated and per-contributor, and "score"/"credibility" are its entire point. Asserts + // the feature's own core vocabulary survives byte-for-byte, not just "no forbidden wallet/hotkey word leaked". + it("REGRESSION: never mangles this feature's own core vocabulary (score/credibility/leverage) into private context", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 100, + totalTokenScore: 100, + sourceLines: 100, + openPrCount: 0, + openIssueCount: 0, + existingContributorTokenScore: 5000, + credibility: 1, + linkedIssueMode: "none", + }, + }); + const breakdown = explainScoreBreakdown(preview); + const serialized = JSON.stringify(breakdown); + expect(serialized).not.toMatch(/private context/i); + const baseScore = breakdown.components.find((c) => c.component === "baseScore"); + expect(baseScore?.summary).toMatch(/score cap|score pipeline/i); + const credibilityComponent = breakdown.components.find((c) => c.component === "credibilityMultiplier"); + expect(credibilityComponent?.summary).toMatch(/credibility/i); + expect(serialized).not.toMatch(FORBIDDEN); + }); });