Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/services/agent-action-explanation-card.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentActionBlockerCategory, AgentActionExplanationCard, AgentActionRecord } from "../types";
import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction";
import { PUBLIC_LOCAL_PATH_INLINE, PUBLIC_TOKEN_INLINE } from "../signals/redaction";

type AgentActionExplanationInput = Pick<
AgentActionRecord,
Expand All @@ -10,9 +10,11 @@ const BLOCKER_CATEGORY_ORDER: AgentActionBlockerCategory[] = ["branch", "account
const PUBLIC_FORBIDDEN_PATTERN =
/\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|private keys?|raw[-_\s]?trust scores?|trust scores?|private reviewability|reviewability internals?|private scoreability|scoreability|projected scores?|score(?:d|s|ability)?|public score estimates?|estimated scores?|score estimates?|score previews?|reward estimates?|payouts?|farming|reward optimization|private rankings?)\b/gi;
const PUBLIC_SCORE_DELTA_PATTERN = /\b(?:projected\s+)?score\w*(?:\s+\w+){0,4}\s+[-+]?\d+(?:\.\d+)?\s*->\s*[-+]?\d+(?:\.\d+)?\b/gi;
// Token alternatives stay local; the local-path alternatives compose from the canonical PUBLIC_LOCAL_PATH_INLINE
// in redaction.ts (adds the previously-missed /root/ and /var/, plus the forward-slash Windows form C:/Users/).
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");
// #9697: token alternatives compose from the canonical PUBLIC_TOKEN_INLINE — one shared source across all five
// public surfaces so none can drift (this surface's old hand-written list missed the
// gts_/orbenr_/orbsec_/glpat-/sk-/xox prefixes the sibling surfaces caught); the local-path alternatives compose
// from PUBLIC_LOCAL_PATH_INLINE.
const TOKEN_OR_PATH_PATTERN = new RegExp(`\\b(?:${PUBLIC_TOKEN_INLINE})[A-Za-z0-9_]+|(?:${PUBLIC_LOCAL_PATH_INLINE})\\S+`, "gi");

export function withAgentActionExplanationCard(action: AgentActionRecord): AgentActionRecord {
return { ...action, explanationCard: buildAgentActionExplanationCard(action) };
Expand Down Expand Up @@ -119,6 +121,9 @@ function categorizeBlocker(blocker: string): AgentActionBlockerCategory {
return "unknown";
}

/** Test-only handle on the surface's public-text sanitizer (mirrors control-panel-roles' internals export). */
export const __agentActionExplanationCardInternals = { sanitizePublicCardText };

function sanitizePublicCardText(value: string): string {
return compactText(value)
.replace(TOKEN_OR_PATH_PATTERN, "<redacted>")
Expand Down
4 changes: 2 additions & 2 deletions src/services/control-panel-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isAuthorizedGitHubSessionLogin } from "../auth/security";
import { getFreshOfficialMinerDetection, getRepository, listAllPullRequests, listInstallations, listRepositories } from "../db/repositories";
import type { ControlPanelRoleCard, ControlPanelRoleName, ControlPanelRoleSummary, InstallationRecord, PullRequestRecord, RepositoryRecord } from "../types";
import { nowIso } from "../utils/json";
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction";
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, publicTokenPattern } from "../signals/redaction";

export type RoleSummaryInputs = {
login: string;
Expand Down Expand Up @@ -301,7 +301,7 @@ function isMaintainerAssociation(value: string | null | undefined): boolean {
export function sanitizeRoleText(value: string): string {
const redacted = value
.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "<redacted-path>")
.replace(/\b(?:ghp_|github_pat_|gts_|orbenr_|orbsec_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g, "<redacted-token>")
.replace(publicTokenPattern(), "<redacted-token>")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer <redacted-token>");
if (/\b(seed phrase|mnemonic|private key|raw trust|trust score|wallet|hotkey|coldkey|payout|reward estimate|farming|private reviewability|public score estimate)\b/i.test(redacted)) return "<redacted>";
return redacted.slice(0, 200);
Expand Down
5 changes: 2 additions & 3 deletions src/services/miner-dashboard-recommendations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ContributorDecisionPack } from "./decision-pack";
import type { SignalSnapshotRecord } from "../types";
import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction";
import { PUBLIC_LOCAL_PATH_INLINE, publicTokenPattern } from "../signals/redaction";

export type MinerDashboardSignalGroup = "repo_state" | "contributor_state" | "validation_state" | "policy_context";
export type MinerDashboardChangeStatus = "new" | "changed" | "unchanged";
Expand Down Expand Up @@ -46,7 +46,6 @@ const FORBIDDEN_PUBLIC_TEXT =
// Compose the roots from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (so this surface cannot drift)
// while preserving this surface's own trailing class and its case-sensitive `/g` (Windows form via `[A-Z]`).
const LOCAL_PATH = new RegExp(`(?:${PUBLIC_LOCAL_PATH_INLINE})[^\\s,;:)]+`, "g");
const FORBIDDEN_TOKEN = /\b(?:ghp_|github_pat_|gts_|orbenr_|orbsec_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g;

export function previousDecisionPackFromSnapshots(currentPack: ContributorDecisionPack, snapshots: SignalSnapshotRecord[]): ContributorDecisionPack | undefined {
const current = asRecord(currentPack);
Expand Down Expand Up @@ -386,7 +385,7 @@ function numberValue(record: DashboardRecord | undefined, key: string): number |
function sanitizePublicText(value: string): string {
return value
.replace(LOCAL_PATH, "[local path]")
.replace(FORBIDDEN_TOKEN, "private context")
.replace(publicTokenPattern(), "private context")
.replace(FORBIDDEN_PUBLIC_TEXT, "private context")
.replace(/\s+/g, " ")
.trim();
Expand Down
11 changes: 9 additions & 2 deletions src/services/score-breakdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction";
import { PUBLIC_LOCAL_PATH_INLINE, PUBLIC_TOKEN_INLINE } from "../signals/redaction";
import type { ScoreGateDelta, ScorePreviewResult } from "../scoring/preview";

// This endpoint (POST /v1/scoring/explain-breakdown, gated by requireContributorAccess) is authenticated and
Expand All @@ -12,12 +12,19 @@ import type { ScoreGateDelta, ScorePreviewResult } from "../scoring/preview";
// 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");
// #9697: token alternatives compose from the canonical PUBLIC_TOKEN_INLINE — one shared source across all five
// public surfaces, so none can drift and miss a prefix (this surface's old hand-written list also missed the
// gts_/orbenr_/orbsec_/glpat-/sk-/xox prefixes the sibling surfaces caught); the local-path alternatives compose
// from PUBLIC_LOCAL_PATH_INLINE. Module-local /g const is safe here — .replace resets lastIndex after each call.
const TOKEN_OR_PATH_PATTERN = new RegExp(`\\b(?:${PUBLIC_TOKEN_INLINE})[A-Za-z0-9_]+|(?:${PUBLIC_LOCAL_PATH_INLINE})\\S+`, "gi");

function sanitizeScoreBreakdownText(value: string): string {
return value.replace(TOKEN_OR_PATH_PATTERN, "<redacted>");
}

/** Test-only handle on the surface's token/path scrubber (mirrors control-panel-roles' internals export). */
export const __scoreBreakdownInternals = { sanitizeScoreBreakdownText };

export type ScoreMultiplierBand = "full" | "reduced" | "neutral" | "blocked";

export type ScoreMultiplierBreakdown = {
Expand Down
7 changes: 5 additions & 2 deletions src/services/weekly-value-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type {
WeeklyValueReportVariant,
} from "../types";
import { nowIso } from "../utils/json";
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction";
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, publicTokenPattern } from "../signals/redaction";

type WeeklyValueReportInputs = {
generatedAt: string;
Expand Down Expand Up @@ -411,7 +411,7 @@ function normalizeReportDays(value: number | null | undefined): number {
function sanitizeReportText(value: string): string {
const redacted = value
.replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "<redacted-path>")
.replace(/\b(?:ghp_|github_pat_|gts_|orbenr_|orbsec_|glpat-|sk-|xox[baprs]-)[A-Za-z0-9_=-]{8,}/g, "<redacted-token>")
.replace(publicTokenPattern(), "<redacted-token>")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer <redacted-token>");
if (
/\b(seed phrase|mnemonic|private key|raw[-\s]?trust|trust[-\s]?score|wallet|hotkey|coldkey|payout|reward(?:[-\s]?(?:estimate|prediction|claim|score|payout|risk))?|farming|private[-\s]?reviewability|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)|score[-\s]?(?:estimate|prediction|preview))\b/i.test(
Expand All @@ -421,3 +421,6 @@ function sanitizeReportText(value: string): string {
return "<redacted>";
return redacted.slice(0, 240);
}

/** Test-only handle on the report text sanitizer (mirrors control-panel-roles' internals export). */
export const __weeklyValueReportInternals = { sanitizeReportText };
19 changes: 19 additions & 0 deletions src/signals/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ export const PUBLIC_LOCAL_PATH_PREFIX_PATTERN = new RegExp(String.raw`^(?:${PUBL

export const PUBLIC_UNSAFE_PATTERN = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b|${PUBLIC_LOCAL_PATH_INLINE}`, "i");

// #9697: the canonical PUBLIC token-prefix vocabulary (alternation source only — no flags, no `\b` anchors, no
// trailing token-body class), the credential analogue of `PUBLIC_UNSAFE_TERMS` / `PUBLIC_LOCAL_PATH_INLINE`.
// Every public surface that scrubs a leaked credential composes from this one source, so a surface cannot drift
// and miss a prefix — the concrete gap that motivated this: `ghs_`, the GitHub App INSTALLATION token this
// Worker mints on every pass, was passing through three surfaces that only matched `ghp_`. `gh[pousr]_` is the
// correct GitHub class (the same one `src/review/secret-patterns.ts` uses), covering ghp_/gho_/ghu_/ghs_/ghr_
// at once. Adding a new provider's prefix here updates every surface at once.
export const PUBLIC_TOKEN_INLINE = String.raw`gh[pousr]_|github_pat_|gts_|orbenr_|orbsec_|glpat-|sk-|xox[baprs]-`;

/**
* A FRESH `/g` token scrubber for `.replace()` surfaces that swap a leaked credential for a placeholder: a prefix
* from `PUBLIC_TOKEN_INLINE` plus its opaque token body (`[A-Za-z0-9_=-]{8,}`, anchored on a word boundary).
* Returns a NEW `RegExp` on every call so no module-level `/g` object (and its `lastIndex`) is ever shared across
* call sites — a shared stateful global would carry `lastIndex` between surfaces and skip matches.
*/
export function publicTokenPattern(): RegExp {
return new RegExp(String.raw`\b(?:${PUBLIC_TOKEN_INLINE})[A-Za-z0-9_=-]{8,}`, "g");
}

/** 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);
Expand Down
29 changes: 29 additions & 0 deletions test/unit/agent-action-explanation-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { __agentActionExplanationCardInternals } from "../../src/services/agent-action-explanation-card";

const { sanitizePublicCardText } = __agentActionExplanationCardInternals;

describe("agent-action explanation-card public-text sanitizer — token redaction (#9697)", () => {
it.each([
["ghp_", `ghp_${"A".repeat(24)}`],
["gho_", `gho_${"A".repeat(24)}`],
["ghu_", `ghu_${"A".repeat(24)}`],
["ghs_", `ghs_${"A".repeat(24)}`],
["ghr_", `ghr_${"A".repeat(24)}`],
["github_pat_", `github_pat_${"A".repeat(24)}`],
["gts_", `gts_${"A".repeat(24)}`],
["orbenr_", `orbenr_${"A".repeat(24)}`],
["orbsec_", `orbsec_${"A".repeat(24)}`],
["glpat-", `glpat-${"A".repeat(24)}`],
["sk-", `sk-${"A".repeat(24)}`],
["xoxb-", `xoxb-${"A".repeat(24)}`],
])("REGRESSION (#9697): redacts a %s token (unified PUBLIC_TOKEN_INLINE)", (_prefix, token) => {
const out = sanitizePublicCardText(`blocked until ${token} rotates`);
expect(out).toContain("<redacted>");
expect(out).not.toContain(token);
});

it("leaves non-token card text unmodified by the token scrubber (#9697)", () => {
expect(sanitizePublicCardText("waiting on branch to be ready")).toBe("waiting on branch to be ready");
});
});
25 changes: 25 additions & 0 deletions test/unit/control-panel-roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ describe("control panel role summaries", () => {
expect(sanitizeRoleText("see C:\\Users\\me\\repo")).toBe("see <redacted-path>");
});

it.each([
["ghp_", `ghp_${"A".repeat(24)}`],
["gho_", `gho_${"A".repeat(24)}`],
["ghu_", `ghu_${"A".repeat(24)}`],
["ghs_", `ghs_${"A".repeat(24)}`],
["ghr_", `ghr_${"A".repeat(24)}`],
["github_pat_", `github_pat_${"A".repeat(24)}`],
["gts_", `gts_${"A".repeat(24)}`],
["orbenr_", `orbenr_${"A".repeat(24)}`],
["orbsec_", `orbsec_${"A".repeat(24)}`],
["glpat-", `glpat-${"A".repeat(24)}`],
["sk-", `sk-${"A".repeat(24)}`],
["xoxb-", `xoxb-${"A".repeat(24)}`],
])("REGRESSION (#9697): sanitizeRoleText redacts a %s token (unified PUBLIC_TOKEN_INLINE)", (_prefix, token) => {
const { sanitizeRoleText } = __controlPanelRolesInternals;
const out = sanitizeRoleText(`role evidence ${token} note`);
expect(out).toContain("<redacted-token>");
expect(out).not.toContain(token);
});

it("leaves non-token role evidence text unmodified (#9697)", () => {
const { sanitizeRoleText } = __controlPanelRolesInternals;
expect(sanitizeRoleText("collaborator on JSONbored/loopover since May")).toBe("collaborator on JSONbored/loopover since May");
});

it("recognizes account installations even before an owned repo is cached", () => {
const summary = buildControlPanelRoleSummary({
login: "repo-owner",
Expand Down
25 changes: 25 additions & 0 deletions test/unit/miner-dashboard-recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,31 @@ describe("miner dashboard recommendation metadata", () => {
expect(JSON.stringify(enriched?.rerunReasons)).not.toContain(fakeSecret);
});

// REGRESSION (#9697): ghs_ is the GitHub App INSTALLATION token this Worker mints on every pass. The old
// hand-written list matched only ghp_, so this exact prefix passed through verbatim to a public dashboard
// string. The unified PUBLIC_TOKEN_INLINE's `gh[pousr]_` class closes that gap.
it("REGRESSION (#9697): redacts a ghs_ installation token from rerun reasons", () => {
const installationToken = `ghs_${"a".repeat(24)}`;
const current = decisionPack({
generatedAt: "2026-06-02T00:00:00.000Z",
topActions: [action()],
actionPortfolio: {
topActions: [
{
repoFullName: "JSONbored/loopover",
actionKind: "open_new_direct_pr",
rerunWhen: `Rerun once the installation token ${installationToken} has rotated.`,
},
],
},
});

const [enriched] = buildMinerDashboardNextActions(current);
const repoStateReasons = enriched?.rerunReasons.find((group) => group.group === "repo_state")?.reasons.join(" ") ?? "";
expect(repoStateReasons).toContain("private context");
expect(JSON.stringify(enriched?.rerunReasons)).not.toContain(installationToken);
});

it("selects the previous ready decision-pack snapshot", () => {
const current = decisionPack({ generatedAt: "2026-06-02T00:00:00.000Z" });
const previous = decisionPack({ generatedAt: "2026-06-01T00:00:00.000Z" });
Expand Down
48 changes: 48 additions & 0 deletions test/unit/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,57 @@ import {
PUBLIC_LOCAL_PATH_INLINE,
PUBLIC_LOCAL_PATH_PREFIX_PATTERN,
PUBLIC_LOCAL_PATH_SCRUB_PATTERN,
PUBLIC_TOKEN_INLINE,
PUBLIC_UNSAFE_PATTERN,
publicTokenPattern,
} from "../../src/signals/redaction";

// Every prefix in PUBLIC_TOKEN_INLINE, as a concrete leaked-token sample (body is pure alphanumerics so it is
// fully consumed by both the `[A-Za-z0-9_=-]{8,}` and `[A-Za-z0-9_]+` body classes the call sites use).
const PUBLIC_TOKEN_SAMPLES: [string, string][] = [
["ghp_", `ghp_${"A".repeat(24)}`],
["gho_", `gho_${"A".repeat(24)}`],
["ghu_", `ghu_${"A".repeat(24)}`],
["ghs_", `ghs_${"A".repeat(24)}`],
["ghr_", `ghr_${"A".repeat(24)}`],
["github_pat_", `github_pat_${"A".repeat(24)}`],
["gts_", `gts_${"A".repeat(24)}`],
["orbenr_", `orbenr_${"A".repeat(24)}`],
["orbsec_", `orbsec_${"A".repeat(24)}`],
["glpat-", `glpat-${"A".repeat(24)}`],
["sk-", `sk-${"A".repeat(24)}`],
["xoxb-", `xoxb-${"A".repeat(24)}`],
];

describe("publicTokenPattern / PUBLIC_TOKEN_INLINE (#9697)", () => {
it("returns a FRESH /g RegExp on each call, never a shared stateful object", () => {
const a = publicTokenPattern();
const b = publicTokenPattern();
expect(a).not.toBe(b);
expect(a.global).toBe(true);
});

it("stays idempotent across repeated .replace() on the same input (no shared lastIndex carry-over)", () => {
const input = `leak ghs_${"A".repeat(24)} and gho_${"B".repeat(24)} here`;
const first = input.replace(publicTokenPattern(), "<redacted-token>");
const second = input.replace(publicTokenPattern(), "<redacted-token>");
expect(first).toBe(second);
expect(first).toContain("<redacted-token>");
expect(first).not.toMatch(/gh[so]_/);
});

it.each(PUBLIC_TOKEN_SAMPLES)("redacts a %s token via publicTokenPattern()", (_prefix, token) => {
const out = `x ${token} y`.replace(publicTokenPattern(), "<redacted-token>");
expect(out).toBe("x <redacted-token> y");
expect(out).not.toContain(token);
});

it("leaves a non-token string untouched", () => {
expect("plain reviewable summary text".replace(publicTokenPattern(), "<redacted-token>")).toBe("plain reviewable summary text");
expect(PUBLIC_TOKEN_INLINE).toContain("gh[pousr]_"); // ghs_/gho_/ghu_/ghr_ covered by the GitHub class
});
});

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);
Expand Down
25 changes: 24 additions & 1 deletion test/unit/score-breakdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildScorePreview } from "../../src/scoring/preview";
import { explainScoreBreakdown } from "../../src/services/score-breakdown";
import { __scoreBreakdownInternals, explainScoreBreakdown } from "../../src/services/score-breakdown";
import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types";

const FORBIDDEN = /\b(wallet|hotkey|coldkey|mnemonic|farming|payout|raw[-_\s]?trust)\b/i;
Expand Down Expand Up @@ -737,6 +737,29 @@ describe("explainScoreBreakdown", () => {
// "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.each([
["ghp_", `ghp_${"A".repeat(24)}`],
["gho_", `gho_${"A".repeat(24)}`],
["ghu_", `ghu_${"A".repeat(24)}`],
["ghs_", `ghs_${"A".repeat(24)}`],
["ghr_", `ghr_${"A".repeat(24)}`],
["github_pat_", `github_pat_${"A".repeat(24)}`],
["gts_", `gts_${"A".repeat(24)}`],
["orbenr_", `orbenr_${"A".repeat(24)}`],
["orbsec_", `orbsec_${"A".repeat(24)}`],
["glpat-", `glpat-${"A".repeat(24)}`],
["sk-", `sk-${"A".repeat(24)}`],
["xoxb-", `xoxb-${"A".repeat(24)}`],
])("REGRESSION (#9697): sanitizeScoreBreakdownText redacts a %s token (unified PUBLIC_TOKEN_INLINE)", (_prefix, token) => {
const out = __scoreBreakdownInternals.sanitizeScoreBreakdownText(`saturated near ${token} cap`);
expect(out).toContain("<redacted>");
expect(out).not.toContain(token);
});

it("leaves this feature's own computed score text unmodified by the token scrubber (#9697)", () => {
expect(__scoreBreakdownInternals.sanitizeScoreBreakdownText("credibility leverage saturated near the cap")).toBe("credibility leverage saturated near the cap");
});

it("REGRESSION: never mangles this feature's own core vocabulary (score/credibility/leverage) into private context", () => {
const preview = buildScorePreview({
repo,
Expand Down
Loading