diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index cc065464a..8e1864d1a 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -320,6 +320,26 @@ export function isNumericMetadataValue(span: string, match: RegExpExecArray): bo return NUMERIC_METADATA_KEY.test(input.slice(Math.max(lineStart, start - 80), start)); } +/** + * GitHub Actions run and job ids are bare 10-12 digit integers, so + * pii.phone.e164 reads them as phone numbers. A published eval report prints + * the run id inside the command the reader copies + * (`bun run eval:pass-rates --run 37235771700`), and redacting it breaks that + * command. Only the position decides: a run/job flag (`--run 37235771700`, + * `--job=111534615007`), a `gh run view|watch|rerun|download|cancel` + * argument, an `actions/runs/` or `/job/` URL segment, or a + * `run_id` / `job_id` key. The same digits anywhere else still report. + */ +const CI_ID_POSITION_BEFORE = + /(?:--(?:run|job)(?:-id)?(?:[ \t]+|[ \t]*=[ \t]*)|\bgh[ \t]+run[ \t]+(?:view|watch|rerun|download|cancel)[ \t]+|\/actions\/runs\/|\/jobs?\/|(?:run|job)_id["']?[ \t]*[:=][ \t]*["']?)$/i; +export function isCiRunIdentifier(span: string, match: RegExpExecArray): boolean { + if (!/^\d+$/.test(span)) return false; + const input = match.input ?? ""; + const { start } = spanBounds(match); + const lineStart = input.lastIndexOf("\n", start - 1) + 1; + return CI_ID_POSITION_BEFORE.test(input.slice(Math.max(lineStart, start - 80), start)); +} + /** * A four-part version (MAJOR.MINOR.PATCH.BUILD: .NET assembly versions, * gstack's own VERSION) is byte-for-byte a dotted quad, and `1.128.1.0` is a @@ -892,14 +912,16 @@ 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). + // values (isNumericMetadataValue), and GitHub Actions run and job ids in + // their id positions (isCiRunIdentifier). validate: (span, match) => !insideUuid(match) && span.replace(/\D/g, "").length >= 10 && !looksLikeCompactTimestamp(span) && !looksLikeParcelId(span, match) && !looksLikeDecimalCoordinates(span) && - !isNumericMetadataValue(span, match), + !isNumericMetadataValue(span, match) && + !isCiRunIdentifier(span, match), }, { id: "pii.ssn", diff --git a/test/published-text.test.ts b/test/published-text.test.ts index 8cf6fda30..a929f5f69 100644 --- a/test/published-text.test.ts +++ b/test/published-text.test.ts @@ -75,3 +75,14 @@ describe('sanitizeFixedFenceLine (lines a workflow wraps in a fixed fence)', () expect(sanitizeFixedFenceLine('z'.repeat(10 * 1024))).toHaveLength(PUBLISHED_TEXT_MAX); }); }); + +describe('id positions survive redaction', () => { + test('the eval report fetch command keeps its run id (PR #3033 comment)', () => { + const line = '✗ context-recovery-artifacts rule FAIL assertion — expect(received).toBeGreaterThanOrEqual(expected) · cause assertion Expected: >= 1 · Received: 0 [slice 10, attempt 1] evidence: paid-slice-10-a1/shards/skill-e2e-session-intelligence (fetch: bun run eval:pass-rates --run 37235771700 --case context-recovery-artifacts) after a repair: bun run scripts/test-paid-shards.ts --tier gate --case context-recovery-artifacts'; + expect(sanitizeFixedFenceLine(line)).toBe(line); + expect(publishedFence([line])[1]).toBe(line); + }); + test('a phone number in published text is still redacted', () => { + expect(sanitizeFixedFenceLine('call +1 415 555 0123 now')).not.toContain('415 555 0123'); + }); +}); diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 449355442..5215943d9 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -191,3 +191,26 @@ describe("db.url_with_password: postgres:postgres only on a loopback host (#2913 ], ); }); + +describe("pii.phone.e164: GitHub Actions run and job ids in their id positions", () => { + table( + "pii.phone.e164", + "MEDIUM", + [ + "bun run eval:pass-rates --run 37235771700 --case context-recovery-artifacts", + "bun run eval:pass-rates --run=37235771700", + "gh run view 37235771700 --job 111534615007 --log", + "gh run download 37235771700 -n paid-slice-10-a1", + "https://github.com/garrytan/gstack/actions/runs/37235771700/job/111534615007", + "GITHUB_RUN_ID=37235771700", + '{"run_id": 37235771700}', + ], + [ + "call 37235771700 tomorrow", + "--phone 37235771700", + "https://example.com/contact/37235771700", + '{"phone_id": 37235771700}', + "run 4155550123 laps", + ], + ); +}); diff --git a/test/skill-e2e-session-intelligence.test.ts b/test/skill-e2e-session-intelligence.test.ts index 39053a1e2..f7c058265 100644 --- a/test/skill-e2e-session-intelligence.test.ts +++ b/test/skill-e2e-session-intelligence.test.ts @@ -40,19 +40,10 @@ describeIfSelected('Session Intelligence E2E', [ run('git', ['add', '.']); run('git', ['commit', '-m', 'initial']); - // Copy bin scripts needed by timeline and checkpoint - const binDir = path.join(workDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - for (const script of [ - 'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh', - 'gstack-learnings-log', 'gstack-learnings-search', - ]) { - const src = path.join(ROOT, 'bin', script); - if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(binDir, script)); - fs.chmodSync(path.join(binDir, script), 0o755); - } - } + // The prompts send every bin call to ./bin/, and the skill preambles run + // gstack-skill-start and gstack-context-recovery, which call further bin + // scripts, so ./bin/ is the whole runtime bin directory. + fs.symlinkSync(path.join(ROOT, 'bin'), path.join(workDir, 'bin'), 'dir'); // Compute slug (same logic as gstack-slug without git remote) slug = path.basename(workDir).replace(/[^a-zA-Z0-9._-]/g, '');