diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index eb12febc68..dc7ca7dba6 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -19,6 +19,7 @@ import { buildRepoRewardRisk, type RepoRewardRisk, type RewardRiskAction } from import { buildLocalWorkspaceIntelligence, type LocalWorkspaceIntelligence } from "./local-workspace-intelligence"; import { buildFocusManifestGuidance, parseFocusManifest, type FocusManifestGuidance } from "./focus-manifest"; import { sanitizeLocalScorerWarnings } from "./local-scorer-diagnostics"; +import { isPublicSafeText } from "./redaction"; import { deriveEligibilityPlan } from "../services/eligibility-plan"; import { scenarioInputFromLocalBranchMetadata } from "../scenarios/input-model"; import { renderPublicScenarioSummary, type PublicScenarioSummary, type ScenarioSummaryInput } from "../scenarios/scenario-summary"; @@ -1201,10 +1202,6 @@ function firstCommitTitle(messages: string[] | undefined): string | undefined { return messages?.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim() || undefined; } -function isPublicSafeText(text: string): boolean { - return !/\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Z]:[\\/]Users[\\/]/i.test(text); -} - function safeRepoPath(path: string): string { /* v8 ignore next -- Empty path fallback protects malformed local-git adapters; path redaction is covered by local branch tests. */ return /^(\/Users\/|\/home\/|\/tmp\/|[A-Z]:\/Users\/)/i.test(String(path).replace(/\\/g, "/")) ? "[local path hidden]" : String(path || "(unknown path)").replace(/\\/g, "/"); diff --git a/src/signals/redaction.ts b/src/signals/redaction.ts new file mode 100644 index 0000000000..c2b40c0f87 --- /dev/null +++ b/src/signals/redaction.ts @@ -0,0 +1,16 @@ +// #542: the canonical public/private boundary primitive. Any text destined for a PUBLIC surface — PR/issue +// comments, check annotations, notifications, badge, extension payloads, slop/advisory reasons — must pass +// `isPublicSafeText` first, so a single regex governs redaction and new surfaces cannot drift their own copy. +// +// It rejects gittensor economic/identity signals (rewards, raw/trust score, wallet/hotkey/coldkey/mnemonic, +// farming, payout, ranking, (private) reviewability) and local filesystem paths. +// +// The pattern is intentionally NON-GLOBAL so `.test()` stays stateless (no `lastIndex` carry-over between +// calls) and the exported constant can be reused safely across call sites and modules. +export const PUBLIC_UNSAFE_PATTERN = + /\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Z]:[\\/]Users[\\/]/i; + +/** True iff `text` contains nothing that must stay private — i.e. it is safe to surface on a public GitHub surface. */ +export function isPublicSafeText(text: string): boolean { + return !PUBLIC_UNSAFE_PATTERN.test(text); +} diff --git a/test/unit/redaction.test.ts b/test/unit/redaction.test.ts new file mode 100644 index 0000000000..6be22518b1 --- /dev/null +++ b/test/unit/redaction.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { isPublicSafeText, PUBLIC_UNSAFE_PATTERN } from "../../src/signals/redaction"; + +describe("isPublicSafeText (#542 shared public/private boundary)", () => { + it("accepts text with no private signals", () => { + expect(isPublicSafeText("Add a retry to the cache reconnect path.")).toBe(true); + expect(isPublicSafeText("- PR #12: changes requested.")).toBe(true); + expect(isPublicSafeText("")).toBe(true); + }); + + it("rejects gittensor economic / identity signals", () => { + for (const text of [ + "estimated reward is high", + "your score will rise", + "wallet 5F...", + "hotkey leaked", + "coldkey backup", + "mnemonic phrase", + "this looks like farming", + "payout pending", + "ranking change", + "raw trust value", + "raw-trust score", + "trust_score 0.8", + "private reviewability internals", + "reviewability breakdown", + ]) { + expect(isPublicSafeText(text)).toBe(false); + } + }); + + it("rejects local filesystem paths (posix and Windows)", () => { + expect(isPublicSafeText("/Users/alice/project")).toBe(false); + expect(isPublicSafeText("/home/bob/repo")).toBe(false); + expect(isPublicSafeText("/tmp/scratch")).toBe(false); + expect(isPublicSafeText("C:\\Users\\carol\\repo")).toBe(false); + expect(isPublicSafeText("C:/Users/carol/repo")).toBe(false); + }); + + it("is case-insensitive", () => { + expect(isPublicSafeText("WALLET")).toBe(false); + expect(isPublicSafeText("Payout")).toBe(false); + }); + + it("uses a NON-global pattern so .test() is stateless (no lastIndex carry-over)", () => { + expect(PUBLIC_UNSAFE_PATTERN.global).toBe(false); + // A global regex would alternate true/false across repeated .test() calls on the same input. + expect(PUBLIC_UNSAFE_PATTERN.test("wallet")).toBe(true); + expect(PUBLIC_UNSAFE_PATTERN.test("wallet")).toBe(true); + expect(isPublicSafeText("clean line")).toBe(true); + expect(isPublicSafeText("clean line")).toBe(true); + }); +});