From 0ff98dba1fddc532e0f61803a07a45d3b81475e0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 03:11:33 +0000 Subject: [PATCH 1/3] fix(redact): pii.phone skips bare numeric constants; pii.email skips package@version filenames Fixes #3577 Fixes #3576 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/redact-patterns.ts | 55 +++++++++++- test/redact-constant-and-at-filename.test.ts | 92 ++++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 test/redact-constant-and-at-filename.test.ts diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 9c820900c..90d91e071 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean { ); } +/** + * Bare integers that programs print as constants: 2^30 through 2^49 and each + * one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers, + * offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash, + * the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a, + * MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164 + * reads each as a bare 10-15 digit number. Only the exact separator-free value + * is exempt: the same digits with a `+`, spaces, dashes or dots still report, + * so an E.164 number, which carries its `+`, is never exempt. + */ +const NUMERIC_CONSTANTS: ReadonlySet = new Set([ + ...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]), + "2654435761", + "2654435769", + "1103515245", + "1013904223", + "2166136261", + "1540483477", + "3432918353", + "2246822507", + "3266489909", + "1812433253", + "4294967291", +]); + +export function isNumericConstant(span: string): boolean { + return NUMERIC_CONSTANTS.has(span); +} + /** Context window for pairing a normalized parcel ID with its punctuated form. */ const PARCEL_CONTEXT_CHARS = 400; @@ -446,6 +475,25 @@ export function isDotenvFilename(match: RegExpExecArray): boolean { return spanStart > 0 && input[spanStart - 1] === "."; } +/** File extensions that are not top-level domains (checked against the IANA + * root zone list, 2026-10-28). Extensions that are also TLDs (`.md`, `.py`, + * `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on purpose. */ +const NON_TLD_EXTENSION = /\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff)$/i; + +/** + * True when a pii.email span is a filename with an `@` in it rather than an + * address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`, + * `eslint-config@next.yaml`. The package@version convention puts a version + * after the `@`, so either sign decides: the "domain" ends in a file extension + * no TLD uses, or it is a dotted version (two or more all-digit labels) with + * one label after it. `user@163.com` (one numeric label) and an address at a + * TLD that doubles as an extension (`dev@acme.sh`, `ops@acme.md`) still report. + */ +export function looksLikeAtFilename(span: string): boolean { + const domain = span.slice(span.lastIndexOf("@") + 1); + return NON_TLD_EXTENSION.test(domain) || /^\d+(?:\.\d+)+\.[A-Za-z]{2,}$/.test(domain); +} + /** Extensions that mark a `.local.` span as a file. Closed on * purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front * of a public one, so after `.local` it can only be an extension. */ @@ -955,6 +1003,7 @@ export const PATTERNS: RedactPattern[] = [ regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/, autoRedactable: true, redactToken: "", + validate: (span) => !looksLikeAtFilename(span), // Engine layers the email allowlist (example.com, noreply@, user's own, // repo-public authors) on top of this — see redact-engine.ts. }, @@ -969,12 +1018,14 @@ export const PATTERNS: RedactPattern[] = [ // A digit-only UUID's hyphen groups read as national phone formatting, and // so do a county tax-map parcel ID (see looksLikeParcelId), vector // coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON - // values (isNumericMetadataValue), and GitHub Actions run and job ids in - // their id positions (isCiRunIdentifier). + // values (isNumericMetadataValue), GitHub Actions run and job ids in + // their id positions (isCiRunIdentifier), and bare powers of two and + // hash/PRNG constants (isNumericConstant). validate: (span, match) => !insideUuid(match) && span.replace(/\D/g, "").length >= 10 && !looksLikeCompactTimestamp(span) && + !isNumericConstant(span) && !looksLikeParcelId(span, match) && !looksLikeDecimalCoordinates(span) && !isNumericMetadataValue(span, match) && diff --git a/test/redact-constant-and-at-filename.test.ts b/test/redact-constant-and-at-filename.test.ts new file mode 100644 index 000000000..0658a0043 --- /dev/null +++ b/test/redact-constant-and-at-filename.test.ts @@ -0,0 +1,92 @@ +/** + * Two MEDIUM false positives from the gstack-evals runs (gstack 1.91.299-301): + * pii.phone.e164 on `4294967296` (2^32, a PRNG constant) and pii.email on + * `terminal-bench@2.0.json` (a package@version filename). Each guard is + * pinned in both directions: the false positive is clean, and the real shape + * next to it still reports. + */ +import { describe, test, expect } from "bun:test"; +import { scan } from "../lib/redact-engine"; +import { isNumericConstant, looksLikeAtFilename } from "../lib/redact-patterns"; + +const flags = (id: string, s: string): boolean => + scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === id); + +describe("pii.phone.e164 and bare numeric constants", () => { + // Value: protects=a bare power of two (or one less) and the common 32-bit hash/PRNG constants raise no phone finding; fails_when=the guard is + // removed and `4294967296` reports pii.phone.e164 again; why_new=no constant guard existed; seam=none (scan); no_claim=other constants not listed + test("powers of two, their max values and hash/PRNG constants are not phone numbers", () => { + for (const s of [ + "const RANGE = 4294967296;", + "mask = 4294967295", + "INT32_MAX 2147483647", + "x % 2147483648", + "1073741824 bytes", + "2^49 is 562949953421312", + "h *= 2654435761", + "seed ^= 2654435769", + "a = 1103515245", + "basis 2166136261", + "mt init 1812433253", + "p = 4294967291", + ]) { + expect([s, flags("pii.phone.e164", s)]).toEqual([s, false]); + } + expect(isNumericConstant("4294967296")).toBe(true); + expect(isNumericConstant("4294967297")).toBe(false); + }); + + // Value: protects=the same digits written as a phone number (a +, spaces, dashes, parens) and ordinary bare numbers still report; fails_when=the + // guard exempts by digits alone or by length, so +14294967296 or 429-496-7296 goes quiet; why_new=pins the narrowness of the new guard; + // seam=none (scan); no_claim=international formats beyond the ones listed + test("real phone numbers still report, including the constant's digits in phone form", () => { + for (const s of [ + "tel: +14294967296", + "call 429-496-7296", + "reach +1 429 496 7296", + "phone (429) 496-7296", + "p 429.496.7296", + "call 4294967297 now", + "ring +44 20 7946 0958", + "office 4155550123", + ]) { + expect([s, flags("pii.phone.e164", s)]).toEqual([s, true]); + } + }); +}); + +describe("pii.email and package@version filenames", () => { + // Value: protects=a filename with an @ (package@version, or a name ending in a file extension that is not a TLD) raises no email finding; + // fails_when=the guard is removed and terminal-bench@2.0.json reports pii.email again; why_new=no filename guard existed; seam=none (scan); + // no_claim=extensions outside the closed list + test("package@version and @-named files are not email addresses", () => { + for (const s of [ + "results in terminal-bench@2.0.json", + "npm pack wrote lodash@4.17.21.tgz", + "config eslint-config@next.yaml", + "cache react-dom@18.2.0.tar.gz", + "pin left-pad@1.3.0.lock", + "notes a@1.2.md", + ]) { + expect([s, flags("pii.email", s)]).toEqual([s, false]); + } + expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true); + }); + + // Value: protects=real addresses still report, including a numeric domain label (163.com) and TLDs that double as file extensions (.sh, .md, .py, + // .zip); fails_when=the guard treats any digit label or any extension-like TLD as a filename; why_new=pins the narrowness of the new guard; + // seam=none (scan); no_claim=every IANA TLD + test("real addresses still report, numeric domains and extension-shaped TLDs included", () => { + for (const s of [ + "mail alex@acme-corp.com", + "reach li.wei@163.com", + "ops dev@acme.sh", + "docs ops@acme.md", + "py team@acme.py", + "files sales@acme.zip", + "contact jo@mail.acme.io", + ]) { + expect([s, flags("pii.email", s)]).toEqual([s, true]); + } + }); +}); From 566641d44f0907c20c6dceedab16a6d574134fc3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 03:29:05 +0000 Subject: [PATCH 2/3] fix(redact): pii.email reports a file named after an address looksLikeAtFilename exempted any span whose domain ended in a non-TLD extension, so an address inside a filename (an export named after the address plus .json) went unreported. Strip the trailing extension run and exempt only when what precedes it cannot be a domain (one dotless label or a dotted version). The dotted-version branch now requires an extension that doubles as a TLD, so an address at an all-digit subdomain still reports. Regression rows pin both. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/redact-patterns.ts | 26 ++++++++++++-------- test/redact-constant-and-at-filename.test.ts | 11 ++++++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 90d91e071..330db6130 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -475,23 +475,29 @@ export function isDotenvFilename(match: RegExpExecArray): boolean { return spanStart > 0 && input[spanStart - 1] === "."; } -/** File extensions that are not top-level domains (checked against the IANA - * root zone list, 2026-10-28). Extensions that are also TLDs (`.md`, `.py`, - * `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on purpose. */ -const NON_TLD_EXTENSION = /\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff)$/i; +/** A trailing run of file extensions that are not top-level domains (checked + * against the IANA root zone list, 2026-10-28). Extensions that are also TLDs + * (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on + * purpose. */ +const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i; /** * True when a pii.email span is a filename with an `@` in it rather than an * address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`, - * `eslint-config@next.yaml`. The package@version convention puts a version - * after the `@`, so either sign decides: the "domain" ends in a file extension - * no TLD uses, or it is a dotted version (two or more all-digit labels) with - * one label after it. `user@163.com` (one numeric label) and an address at a - * TLD that doubles as an extension (`dev@acme.sh`, `ops@acme.md`) still report. + * `eslint-config@next.yaml`. The package@version convention puts a version or + * tag after the `@`, so either sign decides: the "domain" ends in file + * extensions no TLD uses and what precedes them cannot be a domain (one dotless + * label or a dotted version), or it is a dotted version (two or more all-digit + * labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file + * named after an address (`jane@acme.com.json`), `user@163.com`, + * `alice@1.2.com` and an address at a TLD that doubles as an extension + * (`dev@acme.sh`, `ops@acme.md`) still report. */ export function looksLikeAtFilename(span: string): boolean { const domain = span.slice(span.lastIndexOf("@") + 1); - return NON_TLD_EXTENSION.test(domain) || /^\d+(?:\.\d+)+\.[A-Za-z]{2,}$/.test(domain); + const stem = domain.replace(NON_TLD_EXTENSIONS, ""); + if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem); + return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain); } /** Extensions that mark a `.local.` span as a file. Closed on diff --git a/test/redact-constant-and-at-filename.test.ts b/test/redact-constant-and-at-filename.test.ts index 0658a0043..794e6c778 100644 --- a/test/redact-constant-and-at-filename.test.ts +++ b/test/redact-constant-and-at-filename.test.ts @@ -73,9 +73,9 @@ describe("pii.email and package@version filenames", () => { expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true); }); - // Value: protects=real addresses still report, including a numeric domain label (163.com) and TLDs that double as file extensions (.sh, .md, .py, - // .zip); fails_when=the guard treats any digit label or any extension-like TLD as a filename; why_new=pins the narrowness of the new guard; - // seam=none (scan); no_claim=every IANA TLD + // Value: protects=real addresses still report, including numeric domain labels (163.com, 1.2.com), TLDs that double as file extensions (.sh, .md, + // .py, .zip) and a file named after an address; fails_when=the guard treats any digit label, any extension-like TLD or any trailing extension as a + // filename; why_new=pins the narrowness of the new guard; seam=none (scan); no_claim=every IANA TLD test("real addresses still report, numeric domains and extension-shaped TLDs included", () => { for (const s of [ "mail alex@acme-corp.com", @@ -85,6 +85,11 @@ describe("pii.email and package@version filenames", () => { "py team@acme.py", "files sales@acme.zip", "contact jo@mail.acme.io", + "reach alice@1.2.com", + "export jane.doe@acme-corp.com.json", + "log at ops-team@acme.io.log", + "uploaded jdoe@acme-corp.com.PDF", + "archive li.wei@163.com.tar.gz", ]) { expect([s, flags("pii.email", s)]).toEqual([s, true]); } From bb5b6c665d6265e681cc6350c32112bfa57904d4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 03:33:31 +0000 Subject: [PATCH 3/3] docs: note the constant and @-filename redact exemptions; CHANGELOG under Unreleased Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ docs/troubleshooting.md | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cfab56f..4c3139c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +**The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report. + +### Fixed +- **`lib/redact-patterns.ts`**: `pii.phone.e164` skips a bare number that is exactly a power of two from 2^30 through 2^49, one less than one, or one of eleven well-known 32-bit hash and PRNG constants. The same digits written with a `+`, spaces, dashes, dots or parentheses still report (#3577). +- **`lib/redact-patterns.ts`**: `pii.email` skips a filename whose part after the `@` is a version or tag followed by file extensions no top-level domain uses, or a dotted version followed by an extension that doubles as one. A file named after a real address, an address at a numeric domain such as 163.com, and an address under a top-level domain that doubles as an extension (`.sh`, `.md`) still report (#3576). + +### Tests +- `test/redact-constant-and-at-filename.test.ts`: pins both exemptions in both directions, including files named after a real address. + ## [1.91.304.0] - 2026-10-29 **Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..15fec720a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2388,6 +2388,14 @@ and an E.164 `+14155551234` still report, so the finding means the id stands without a run word (`the id is 40269992077`, `40269992077 failed`) or follows a word that is not one (`see 40323989742`). +Separately, the scanner skips a bare number that is exactly a power of two +from 2^30 through 2^49, one less than such a power, or one of eleven 32-bit +hash and PRNG constants (Knuth's multiplicative hash, the golden-ratio +constant, the glibc and Numerical Recipes LCGs, FNV-1a, MurmurHash2 and 3, the +MT19937 seeder and the largest 32-bit prime; `isNumericConstant` in +`lib/redact-patterns.ts`). Only the exact digits with no separator are exempt: +the same digits with a `+`, spaces, dashes, dots or parentheses still report. + **Fix.** Write the id next to a run word (`run 40269992077`) or as an `/actions/runs/` link. For anything else, rerun with `--confirm ` using the `TOKEN:` line the refusal printed; MEDIUM findings do not block @@ -2428,6 +2436,15 @@ An address only another remote knows still reports. If the hook also printed `existing-email suppression was limited for this push`, it could not read the history in 5 seconds, so known authors may be listed too. +A filename with an `@` in the package@version style is not an address and +never reports: the part after the `@` is a version or tag (one dotless label +or a dotted version, never a domain name) followed by file extensions no +top-level domain uses, or a dotted all-digit version followed by +an extension that doubles as a top-level domain (`looksLikeAtFilename` in +`lib/redact-patterns.ts`). A file named after a real address, an address at an +all-digit domain and an address whose top-level domain doubles as a file +extension still report. + **Fix.** Remove the address, or allow it for this repo: `git config --add gstack.redact.allowEmail
`. If it is your own address, check `git config user.email`. The allowlist only covers `pii.email`;