From eb8fc0f8c4c28b292e59a4bd5f2395f8ab4f680a Mon Sep 17 00:00:00 2001 From: GildardoDev <267998055+GildardoDev@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:53:22 -0500 Subject: [PATCH 1/2] fix(signals): centralize public local-path redaction across drifted surfaces --- src/db/repositories.ts | 5 ++- src/services/agent-action-explanation-card.ts | 5 ++- src/services/control-panel-roles.ts | 3 +- .../miner-dashboard-recommendations.ts | 5 ++- src/services/weekly-value-report.ts | 3 +- src/signals/focus-manifest.ts | 14 +++++--- src/signals/local-branch.ts | 4 +-- src/signals/redaction.ts | 19 +++++++++- test/unit/agent-orchestrator.test.ts | 15 ++++++++ test/unit/control-panel-roles.test.ts | 13 ++++++- test/unit/focus-manifest.test.ts | 2 ++ test/unit/local-branch.test.ts | 26 ++++++++++++++ .../miner-dashboard-recommendations.test.ts | 22 ++++++++++++ test/unit/redaction.test.ts | 35 +++++++++++++++++- test/unit/weekly-value-report.test.ts | 36 +++++++++++++++++++ 15 files changed, 193 insertions(+), 14 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 5b791437c1..5d226d8640 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -164,6 +164,7 @@ import { normalizeContributorBlacklist } from "../settings/contributor-blacklist import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy"; import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto"; import { jsonString, nowIso, parseJson, repoParts } from "../utils/json"; +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; const MAX_STORED_BODY_CHARS = 4000; const SIGNAL_FRESHNESS_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000; @@ -5308,7 +5309,9 @@ const PRODUCT_USAGE_SENSITIVE_KEY = /authorization|cookie|token|secret|password|private[_-]?key|source|body|diff|patch|prompt|raw[_-]?trust|trust[_-]?score|wallet|hotkey|coldkey|seed|mnemonic|local[_-]?path|repo[_-]?root|cwd|scoreability|reviewability|farming/i; const PRODUCT_USAGE_SENSITIVE_VALUE = /\b(seed phrase|mnemonic|private key|raw trust|trust score|wallet|hotkey|coldkey|scoreability|reviewability|farming|reward estimate|payout)\b/i; -const PRODUCT_USAGE_LOCAL_PATH = /(?:\/Users|\/home|\/root|\/var|\/tmp)\/[^\s"',;)]*|[A-Za-z]:\\Users\\[^\s"',;)]*/g; +// Compose from the canonical scrubber in redaction.ts so this surface cannot drift from the boundary; +// it already covered /root/ and /var/, and now unifies the Windows form (also accepts `C:/Users/`). +const PRODUCT_USAGE_LOCAL_PATH = PUBLIC_LOCAL_PATH_SCRUB_PATTERN; const PRODUCT_USAGE_TOKEN_VALUE = /\b(?:ghp_|github_pat_|gts_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g; const PRODUCT_USAGE_BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi; diff --git a/src/services/agent-action-explanation-card.ts b/src/services/agent-action-explanation-card.ts index dea2261fda..89246efd4f 100644 --- a/src/services/agent-action-explanation-card.ts +++ b/src/services/agent-action-explanation-card.ts @@ -1,4 +1,5 @@ import type { AgentActionBlockerCategory, AgentActionExplanationCard, AgentActionRecord } from "../types"; +import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction"; type AgentActionExplanationInput = Pick< AgentActionRecord, @@ -9,7 +10,9 @@ const BLOCKER_CATEGORY_ORDER: AgentActionBlockerCategory[] = ["branch", "account const PUBLIC_FORBIDDEN_PATTERN = /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|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; -const TOKEN_OR_PATH_PATTERN = /\bgithub_pat_[A-Za-z0-9_]+|\bgh[pousr]_[A-Za-z0-9_]+|\/Users\/\S+|\/home\/\S+|\/tmp\/\S+|[A-Z]:\\Users\\\S+/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"); export function withAgentActionExplanationCard(action: AgentActionRecord): AgentActionRecord { return { ...action, explanationCard: buildAgentActionExplanationCard(action) }; diff --git a/src/services/control-panel-roles.ts b/src/services/control-panel-roles.ts index a793860791..3063d350c7 100644 --- a/src/services/control-panel-roles.ts +++ b/src/services/control-panel-roles.ts @@ -2,6 +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"; export type RoleSummaryInputs = { login: string; @@ -290,7 +291,7 @@ function isMaintainerAssociation(value: string | null | undefined): boolean { export function sanitizeRoleText(value: string): string { const redacted = value - .replace(/(?:\/Users|\/home|\/tmp)\/[^\s"',;)]*|[A-Za-z]:\\Users\\[^\s"',;)]*/g, "") + .replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "") .replace(/\b(?:ghp_|github_pat_|gts_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g, "") .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer "); 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 ""; diff --git a/src/services/miner-dashboard-recommendations.ts b/src/services/miner-dashboard-recommendations.ts index c2fd49f503..a1adaccad5 100644 --- a/src/services/miner-dashboard-recommendations.ts +++ b/src/services/miner-dashboard-recommendations.ts @@ -1,5 +1,6 @@ import type { ContributorDecisionPack } from "./decision-pack"; import type { SignalSnapshotRecord } from "../types"; +import { PUBLIC_LOCAL_PATH_INLINE } from "../signals/redaction"; export type MinerDashboardSignalGroup = "repo_state" | "contributor_state" | "validation_state" | "policy_context"; export type MinerDashboardChangeStatus = "new" | "changed" | "unchanged"; @@ -42,7 +43,9 @@ const CHANGE_LABEL_LIMIT = 6; const REASON_LIMIT = 3; const FORBIDDEN_PUBLIC_TEXT = /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|private keys?|raw[-_\s]?trust(?: scores?)?|trust[-_\s]?scores?|reward(?:[-_\s]?(?:estimate|prediction|claim|score))?s?|payouts?|farming(?:[-_\s]?language)?|private[-_\s]?reviewability|private[-_\s]?scoreability|scoreability|public[-_\s]?score[-_\s]?(?:estimate|prediction)|estimated[-_\s]?score|score[-_\s]?estimate)\b/gi; -const LOCAL_PATH = /(?:\/(?:Users|home|root|tmp|var)\/[^\s,;:)]+|[A-Za-z]:\\Users\\[^\s,;:)]+)/g; +// 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_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g; export function previousDecisionPackFromSnapshots(currentPack: ContributorDecisionPack, snapshots: SignalSnapshotRecord[]): ContributorDecisionPack | undefined { diff --git a/src/services/weekly-value-report.ts b/src/services/weekly-value-report.ts index ed39b43296..2f831ef562 100644 --- a/src/services/weekly-value-report.ts +++ b/src/services/weekly-value-report.ts @@ -28,6 +28,7 @@ import type { WeeklyValueReportVariant, } from "../types"; import { nowIso } from "../utils/json"; +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../signals/redaction"; type WeeklyValueReportInputs = { generatedAt: string; @@ -409,7 +410,7 @@ function normalizeReportDays(value: number | null | undefined): number { function sanitizeReportText(value: string): string { const redacted = value - .replace(/(?:\/Users|\/home|\/tmp)\/[^\s"',;)]*|[A-Za-z]:\\Users\\[^\s"',;)]*/g, "") + .replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "") .replace(/\b(?:ghp_|github_pat_|gts_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g, "") .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer "); if ( diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 1638f365f4..a453aa8616 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -2,6 +2,7 @@ import { parse as parseYaml } from "yaml"; import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types"; import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy"; import { mergeContributorBlacklists, normalizeContributorBlacklist } from "../settings/contributor-blacklist"; +import { PUBLIC_LOCAL_PATH_INLINE } from "./redaction"; export type FocusManifestSource = "repo_file" | "api_record" | "none"; export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; @@ -284,15 +285,20 @@ const EMPTY_MANIFEST: FocusManifest = { warnings: [], }; +// This surface's economic/identity term vocabulary is intentionally richer than the canonical +// PUBLIC_UNSAFE_TERMS (extra phrases like "public score estimate"), so it stays a local literal. The local +// filesystem paths, however, compose from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (which also +// covers `/var/`, previously missed here, plus `/root/` and the forward-slash Windows form `C:/Users/`) so this +// guard cannot drift from the canonical boundary on a leaking root. +const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i; +const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(PUBLIC_LOCAL_PATH_INLINE, "i"); + /** * Public-safe redaction guard shared with the local-branch packet renderer. Public manifest * text must not leak reward, wallet/key, ranking, or local filesystem path material. */ export function isFocusManifestPublicSafe(text: string): boolean { - // Local filesystem path alternatives mirror the canonical PUBLIC_UNSAFE_PATTERN in redaction.ts: include - // `/root/` (container/CI home) and accept the forward-slash Windows form (`C:/Users/`), not only the - // backslash one — otherwise a `/root/...` or `C:/Users/...` path leaks through this public-safe guard. - return !/\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b|\/Users\/|\/home\/|\/root\/|\/tmp\/|[A-Z]:[\\/]Users[\\/]/i.test(text); + return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text); } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index a417babbaf..a1837633db 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -21,7 +21,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 { isPublicSafeText, PUBLIC_LOCAL_PATH_PREFIX_PATTERN } from "./redaction"; import { deriveEligibilityPlan } from "../services/eligibility-plan"; import { scenarioInputFromLocalBranchMetadata } from "../scenarios/input-model"; import { renderPublicScenarioSummary, type PublicScenarioSummary, type ScenarioSummaryInput } from "../scenarios/scenario-summary"; @@ -1233,7 +1233,7 @@ function firstCommitTitle(messages: string[] | undefined): string | undefined { 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\/|\/root\/|\/tmp\/|[A-Z]:\/Users\/)/i.test(String(path).replace(/\\/g, "/")) ? "[local path hidden]" : String(path || "(unknown path)").replace(/\\/g, "/"); + return PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test(String(path).replace(/\\/g, "/")) ? "[local path hidden]" : String(path || "(unknown path)").replace(/\\/g, "/"); } export function isTestFile(file: string): boolean { diff --git a/src/signals/redaction.ts b/src/signals/redaction.ts index 929f7e15d9..0f857a7c3d 100644 --- a/src/signals/redaction.ts +++ b/src/signals/redaction.ts @@ -22,7 +22,24 @@ // intentionally NOT collapsed onto `PUBLIC_UNSAFE_TERMS`. export const PUBLIC_UNSAFE_TERMS = String.raw`(?:reward|score|wallet|hotkey|coldkey|mnemonic|payout|ranking)\w*|farming|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability`; -export const PUBLIC_UNSAFE_PATTERN = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b|/Users/|/home/|/root/|/var/|/tmp/|[A-Z]:[\\/]Users[\\/]`, "i"); +// `PUBLIC_LOCAL_PATH_INLINE` is the canonical local-filesystem-root vocabulary (alternation source only — +// no flags, no anchors), the path analogue of `PUBLIC_UNSAFE_TERMS`. Public surfaces that detect or scrub +// absolute local paths compose from this one source instead of re-typing the root list, so a surface cannot +// drift and miss a root (e.g. `/root/` for container/CI homes, `/var/` for service paths) the canonical +// boundary blocks. It accepts both the back- and forward-slash Windows form (`C:\Users\`, `C:/Users/`). +export const PUBLIC_LOCAL_PATH_INLINE = String.raw`/Users/|/home/|/root/|/var/|/tmp/|[A-Z]:[\\/]Users[\\/]`; + +// Global scrubber for `.replace()` surfaces that swap an absolute local path for a placeholder: matches a +// root from `PUBLIC_LOCAL_PATH_INLINE` plus the rest of the path segment (stopping at whitespace or a common +// delimiter). Sharing one `/g` constant across modules is safe because `String.prototype.replace` resets +// `lastIndex` after each call (unlike `.test()`, which is why the boundary patterns below stay non-global). +export const PUBLIC_LOCAL_PATH_SCRUB_PATTERN = new RegExp(String.raw`(?:${PUBLIC_LOCAL_PATH_INLINE})[^\s"',;)]*`, "gi"); + +// Anchored, non-global guard for surfaces that test whether a single path STARTS at a local root (e.g. the +// local-branch repo-path renderer). Non-global so `.test()` stays stateless across calls. +export const PUBLIC_LOCAL_PATH_PREFIX_PATTERN = new RegExp(String.raw`^(?:${PUBLIC_LOCAL_PATH_INLINE})`, "i"); + +export const PUBLIC_UNSAFE_PATTERN = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b|${PUBLIC_LOCAL_PATH_INLINE}`, "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 { diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 5f0d305d3b..914bc19e9c 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -776,6 +776,21 @@ describe("agent orchestrator", () => { expect(publicPacket.publicSafe.rerunWhen).toMatch(/private context/); }); + it("redacts /root/, /var/, and forward-slash Windows local paths from the public-safe card (#1418)", () => { + const card = buildAgentActionExplanationCard({ + actionType: "prepare_pr_packet", + status: "ready", + why: ["A concise packet keeps public context focused on linked work."], + blockedBy: [], + // /root/ and /var/ were previously missed by this card's local path regex; C:/Users/ (forward-slash) is also now covered. + publicSafeSummary: "Built at /root/work/repo and /var/log/app.log on C:/Users/alice/repo.", + safetyClass: "public_safe", + }); + + expect(card.publicSafe.summary).toContain(""); + expect(card.publicSafe.summary).not.toMatch(/\/root\/work|\/var\/log|C:\/Users\/alice/); + }); + it("covers local action ready and blocker-free branches from prepared metadata", () => { const run = __agentOrchestratorInternals.buildRunRecord({ objective: "local ready branch", diff --git a/test/unit/control-panel-roles.test.ts b/test/unit/control-panel-roles.test.ts index 8bca463095..cf8ea3e6ff 100644 --- a/test/unit/control-panel-roles.test.ts +++ b/test/unit/control-panel-roles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildControlPanelAccessScope, buildControlPanelRoleSummary, loadControlPanelRoleSummary } from "../../src/services/control-panel-roles"; +import { __controlPanelRolesInternals, buildControlPanelAccessScope, buildControlPanelRoleSummary, loadControlPanelRoleSummary } from "../../src/services/control-panel-roles"; import type { InstallationRecord, PullRequestRecord, RepositoryRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -69,6 +69,17 @@ describe("control panel role summaries", () => { expect(JSON.stringify(summary)).not.toMatch(/\/Users|github_pat|1234567890abcdef|wallet|hotkey/); }); + it("redacts all local-path roots including /root/, /var/, and forward-slash Windows paths (#1418)", () => { + const { sanitizeRoleText } = __controlPanelRolesInternals; + // /root/ and /var/ were previously missed by this surface's local copy of the path regex. + expect(sanitizeRoleText("clone at /root/work/repo/src")).toBe("clone at "); + expect(sanitizeRoleText("log at /var/log/app/run.log")).toBe("log at "); + expect(sanitizeRoleText("checkout C:/Users/alice/repo")).toBe("checkout "); + // Already-covered roots stay redacted (no regression). + expect(sanitizeRoleText("see /Users/me/repo")).toBe("see "); + expect(sanitizeRoleText("see C:\\Users\\me\\repo")).toBe("see "); + }); + it("recognizes account installations even before an owned repo is cached", () => { const summary = buildControlPanelRoleSummary({ login: "repo-owner", diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index f13c37225a..78fe9ae87d 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -718,6 +718,8 @@ describe("public-safe invariant", () => { expect(isFocusManifestPublicSafe("see /Users/me/repo/src")).toBe(false); expect(isFocusManifestPublicSafe("see /home/dev/repo/src")).toBe(false); expect(isFocusManifestPublicSafe("see /root/repo/src")).toBe(false); + // #1418: `/var/` was previously missed by this guard's local copy; it now composes from the canonical source. + expect(isFocusManifestPublicSafe("see /var/folders/me/work/repo")).toBe(false); expect(isFocusManifestPublicSafe("see /tmp/build/out")).toBe(false); // Windows, both backslash and forward-slash forms. expect(isFocusManifestPublicSafe("see C:\\Users\\me\\repo")).toBe(false); diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index ec7667d7da..36cd85c12b 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -1236,6 +1236,32 @@ describe("local branch analysis", () => { expect(analysis.prPacket.markdown).not.toContain("/root/work"); }); + it("hides /var/ service paths and forward-slash Windows paths from public PR packet changed paths (#1418)", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [ + { path: "/var/folders/work/src/cache.ts", additions: 12, deletions: 2, status: "modified" }, + { path: "C:/Users/alice/work/src/util.ts", additions: 3, deletions: 1, status: "modified" }, + ], + validation: [{ command: "npm test -- cache", status: "passed" }], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.prPacket.markdown).toContain("[local path hidden]"); + expect(analysis.prPacket.markdown).not.toContain("/var/folders"); + expect(analysis.prPacket.markdown).not.toContain("C:/Users/alice"); + }); + it("removes snake_case private signals from public PR packet markdown", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/miner-dashboard-recommendations.test.ts b/test/unit/miner-dashboard-recommendations.test.ts index 2a8e17446f..a9352f3893 100644 --- a/test/unit/miner-dashboard-recommendations.test.ts +++ b/test/unit/miner-dashboard-recommendations.test.ts @@ -319,6 +319,28 @@ describe("miner dashboard recommendation metadata", () => { expect(priorityChange).not.toHaveProperty("after"); }); + it("redacts /root/, /var/, and forward-slash Windows local paths from rerun reasons (#1418)", () => { + const current = decisionPack({ + generatedAt: "2026-06-02T00:00:00.000Z", + topActions: [action()], + actionPortfolio: { + topActions: [ + { + repoFullName: "JSONbored/gittensory", + actionKind: "open_new_direct_pr", + // /root/ and /var/ were already covered here; C:/Users/ (forward-slash) is now covered via the shared source. + rerunWhen: "Rerun when PRs change at /root/work/repo, /var/log/app.log, and C:/Users/alice/repo.", + }, + ], + }, + }); + + const [enriched] = buildMinerDashboardNextActions(current); + const repoStateReasons = enriched?.rerunReasons.find((group) => group.group === "repo_state")?.reasons.join(" ") ?? ""; + expect(repoStateReasons).toContain("[local path]"); + expect(JSON.stringify(enriched?.rerunReasons)).not.toMatch(/\/root\/work|\/var\/log|C:\/Users\/alice/); + }); + 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" }); diff --git a/test/unit/redaction.test.ts b/test/unit/redaction.test.ts index 72efa0952b..9a1fe1ceb0 100644 --- a/test/unit/redaction.test.ts +++ b/test/unit/redaction.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isPublicSafeText, PUBLIC_UNSAFE_PATTERN } from "../../src/signals/redaction"; +import { + isPublicSafeText, + PUBLIC_LOCAL_PATH_PREFIX_PATTERN, + PUBLIC_LOCAL_PATH_SCRUB_PATTERN, + PUBLIC_UNSAFE_PATTERN, +} from "../../src/signals/redaction"; describe("isPublicSafeText (#542 shared public/private boundary)", () => { it("accepts text with no private signals", () => { @@ -61,3 +66,31 @@ describe("isPublicSafeText (#542 shared public/private boundary)", () => { expect(isPublicSafeText("clean line")).toBe(true); }); }); + +describe("shared local-path constants (#1418 drift fix)", () => { + it("scrubs every local root, including /root/ and /var/, plus both Windows forms", () => { + expect("clone at /Users/me/repo/src done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("clone at

done"); + expect("clone at /home/me/repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("clone at

done"); + expect("clone at /root/work/repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("clone at

done"); + expect("log at /var/log/app.log done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("log at

done"); + expect("tmp at /tmp/build done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("tmp at

done"); + expect("win at C:\\Users\\me\\repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); + expect("win at C:/Users/me/repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); + }); + + it("scrub pattern is global (safe for .replace across modules) and prefix pattern is anchored + non-global", () => { + expect(PUBLIC_LOCAL_PATH_SCRUB_PATTERN.global).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.global).toBe(false); + }); + + it("prefix pattern matches a path that STARTS at a local root, not one merely containing it", () => { + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("/root/work/repo")).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("/var/folders/me/repo")).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("C:/Users/me/repo")).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("C:\\Users\\me\\repo")).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("src/signals/redaction.ts")).toBe(false); + // Non-global so .test() stays stateless across repeated calls on the same input. + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("/root/x")).toBe(true); + expect(PUBLIC_LOCAL_PATH_PREFIX_PATTERN.test("/root/x")).toBe(true); + }); +}); diff --git a/test/unit/weekly-value-report.test.ts b/test/unit/weekly-value-report.test.ts index 8c3889a49b..2a0b0514d8 100644 --- a/test/unit/weekly-value-report.test.ts +++ b/test/unit/weekly-value-report.test.ts @@ -175,6 +175,42 @@ describe("weekly value reports", () => { expect(markdown).not.toMatch(FORBIDDEN_EXPORT_TERMS); }); + it("redacts /root/ and /var/ local paths in operator rollup dimensions (#1418)", () => { + const report = buildWeeklyValueReport({ + generatedAt: "2026-06-01T12:00:00.000Z", + variant: "operator", + days: 7, + repositories: [repo("JSONbored/gittensory", true, true)], + installations: [installation(1)], + health: [health(1, "healthy")], + registry: registry([]), + scoring: scoring([]), + upstreamDrift: upstream({ status: "current", openReportCount: 0 }), + usageSummary: usageSummary({ totalEvents: 2, activeActors: 1 }), + usageRollups: [ + rollup("2026-05-31", { + totalEvents: 2, + activeActors: 1, + activeRepos: 2, + // /root/ (container/CI home) and /var/ (service paths) were previously missed by this surface's regex. + repos: [ + { key: "/root/work/private-repo", count: 1 }, + { key: "/var/folders/alice/private-repo", count: 1 }, + ], + events: [], + surfaces: [], + commands: [], + tools: [], + }), + ], + usageRollupStatus: rollupStatus({ status: "ready" }), + }); + + // Both keys collapse to the same placeholder, so they aggregate into a single redacted row. + expect(report.operatorDetails?.topRepos).toEqual(expect.arrayContaining([{ key: "", count: 2 }])); + expect(JSON.stringify(report)).not.toMatch(/\/root\/work|\/var\/folders/); + }); + it("keeps clean complete windows marked ready", () => { const report = buildWeeklyValueReport({ generatedAt: "2026-06-01T12:00:00.000Z", From 3c621d202006d325b20926554ddb467491d70e51 Mon Sep 17 00:00:00 2001 From: GildardoDev <267998055+GildardoDev@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:05:43 -0500 Subject: [PATCH 2/2] fix(signals): match the local-path drive letter case-insensitively at the source --- src/signals/redaction.ts | 7 +++++-- .../miner-dashboard-recommendations.test.ts | 8 +++++--- test/unit/redaction.test.ts | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/signals/redaction.ts b/src/signals/redaction.ts index 0f857a7c3d..b70d1220a3 100644 --- a/src/signals/redaction.ts +++ b/src/signals/redaction.ts @@ -26,8 +26,11 @@ export const PUBLIC_UNSAFE_TERMS = String.raw`(?:reward|score|wallet|hotkey|cold // no flags, no anchors), the path analogue of `PUBLIC_UNSAFE_TERMS`. Public surfaces that detect or scrub // absolute local paths compose from this one source instead of re-typing the root list, so a surface cannot // drift and miss a root (e.g. `/root/` for container/CI homes, `/var/` for service paths) the canonical -// boundary blocks. It accepts both the back- and forward-slash Windows form (`C:\Users\`, `C:/Users/`). -export const PUBLIC_LOCAL_PATH_INLINE = String.raw`/Users/|/home/|/root/|/var/|/tmp/|[A-Z]:[\\/]Users[\\/]`; +// boundary blocks. It accepts both the back- and forward-slash Windows form (`C:\Users\`, `C:/Users/`). The +// drive letter is matched case-insensitively at the source (`[A-Za-z]`, not `[A-Z]`) so a consumer that omits +// the `i` flag (e.g. the case-sensitive `/g` scrubber in miner-dashboard-recommendations.ts) still redacts a +// lower-case drive like `c:\Users\...`; the unix roots stay literal so case-sensitivity there is the caller's. +export const PUBLIC_LOCAL_PATH_INLINE = String.raw`/Users/|/home/|/root/|/var/|/tmp/|[A-Za-z]:[\\/]Users[\\/]`; // Global scrubber for `.replace()` surfaces that swap an absolute local path for a placeholder: matches a // root from `PUBLIC_LOCAL_PATH_INLINE` plus the rest of the path segment (stopping at whitespace or a common diff --git a/test/unit/miner-dashboard-recommendations.test.ts b/test/unit/miner-dashboard-recommendations.test.ts index a9352f3893..2efd5544d7 100644 --- a/test/unit/miner-dashboard-recommendations.test.ts +++ b/test/unit/miner-dashboard-recommendations.test.ts @@ -328,8 +328,10 @@ describe("miner dashboard recommendation metadata", () => { { repoFullName: "JSONbored/gittensory", actionKind: "open_new_direct_pr", - // /root/ and /var/ were already covered here; C:/Users/ (forward-slash) is now covered via the shared source. - rerunWhen: "Rerun when PRs change at /root/work/repo, /var/log/app.log, and C:/Users/alice/repo.", + // /root/ and /var/ were already covered here; C:/Users/ (forward-slash) and the lower-case drive + // form (c:\Users\...) are now covered via the shared source, which matches the drive letter + // case-insensitively even though this surface's scrubber omits the `i` flag. + rerunWhen: "Rerun when PRs change at /root/work/repo, /var/log/app.log, C:/Users/alice/repo, and c:\\Users\\bob\\secret.", }, ], }, @@ -338,7 +340,7 @@ describe("miner dashboard recommendation metadata", () => { const [enriched] = buildMinerDashboardNextActions(current); const repoStateReasons = enriched?.rerunReasons.find((group) => group.group === "repo_state")?.reasons.join(" ") ?? ""; expect(repoStateReasons).toContain("[local path]"); - expect(JSON.stringify(enriched?.rerunReasons)).not.toMatch(/\/root\/work|\/var\/log|C:\/Users\/alice/); + expect(JSON.stringify(enriched?.rerunReasons)).not.toMatch(/\/root\/work|\/var\/log|C:\/Users\/alice|c:\\Users\\bob/); }); it("selects the previous ready decision-pack snapshot", () => { diff --git a/test/unit/redaction.test.ts b/test/unit/redaction.test.ts index 9a1fe1ceb0..e85a6828d5 100644 --- a/test/unit/redaction.test.ts +++ b/test/unit/redaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { isPublicSafeText, + PUBLIC_LOCAL_PATH_INLINE, PUBLIC_LOCAL_PATH_PREFIX_PATTERN, PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN, @@ -76,6 +77,25 @@ describe("shared local-path constants (#1418 drift fix)", () => { expect("tmp at /tmp/build done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("tmp at

done"); expect("win at C:\\Users\\me\\repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); expect("win at C:/Users/me/repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); + // Lower-case drive letter: the source matches it case-insensitively, so a consumer that omits the `i` + // flag (the `/g`-only scrubber in miner-dashboard-recommendations.ts) still redacts it (#1418 regression). + expect("win at c:\\Users\\bob\\repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); + expect("win at c:/Users/bob/repo done".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

")).toBe("win at

done"); + }); + + it("the lower-case Windows drive is matched by the raw source even without the `i` flag", () => { + // miner-dashboard-recommendations.ts composes a `/g`-only (no `i`) scrubber from PUBLIC_LOCAL_PATH_INLINE, + // so the drive-letter class in the source must itself be case-insensitive ([A-Za-z], not [A-Z]). + const gOnly = new RegExp(`(?:${PUBLIC_LOCAL_PATH_INLINE})[^\\s]*`, "g"); + expect("at c:\\Users\\bob\\x".replace(gOnly, "

")).toBe("at

"); + expect("at C:\\Users\\bob\\x".replace(gOnly, "

")).toBe("at

"); + }); + + it("the shared `/g` scrubber resets lastIndex between .replace() calls (safe to share across modules)", () => { + const first = "a /tmp/one b".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

"); + const second = "a /tmp/one b".replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "

"); + expect(first).toBe("a

b"); + expect(second).toBe(first); }); it("scrub pattern is global (safe for .replace across modules) and prefix pattern is anchored + non-global", () => {