diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b03c2..131e759 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,19 @@ +# Three jobs. +# +# `check` is the build gate: typecheck, lint, tests, build, and a smoke run of +# the shipped artifact under node. +# +# `secrets` greps the tracked tree for credential shapes — Solari keys, preview +# tokens, JWTs, Slack, Telegram and Discord tokens, and any tracked `.env` — and +# fails on a hit. Its allowlist is empty by design and the patterns are written +# so they do not match their own source, so "we never commit secrets" is checked +# rather than asserted. +# +# `generated` regenerates every generated file — the embedded relay server, the +# embedded test app, and the measured numbers in README.md and +# benchmarks/README.md — and then fails on `git diff --exit-code`. A stale embed +# or a benchmark figure typed by hand therefore cannot merge. + name: ci on: @@ -18,7 +34,7 @@ jobs: run: bunx playwright-core install --with-deps chromium - name: typecheck run: bun run typecheck - - name: lint (biome + oxlint + relay embed sync) + - name: lint (biome + oxlint + embed/number sync) run: bun run lint - name: unit + integration tests run: bun test src/ test-app/ e2e/ui.spec.ts @@ -38,3 +54,32 @@ jobs: # e2e (bun run test:e2e) is intentionally not run in CI: it needs a real # SOLARI_API_KEY and spends plan quota. It is the pre-publish gate, run # locally against the live API. + + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # A gate nobody has watched fire is a gate nobody has tested: this runs + # every pattern against a live sample and against known-good lines from + # this repo, and fails if a pattern has gone blind. + - name: secret scan can still fail + run: scripts/scan-secrets.sh --self-test + - name: no credential shapes in the tracked tree + run: scripts/scan-secrets.sh + + generated: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + # No `bun install`: all three generators read files and write files, and + # import nothing outside node: and bun: builtins. The job is ~15 s. + - name: regenerate every generated file + run: | + bun scripts/embed-guest.ts + bun test-app/embed-app.ts + bun scripts/render-measured.ts + - name: generated files are in sync with their sources + run: git diff --exit-code diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a7674..9550e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **CI fails on a committed secret.** `scripts/scan-secrets.sh` greps the + tracked tree for Solari live keys, port-preview tokens, JWTs, Slack, Telegram + and Discord tokens, and for any tracked `.env`. Its allowlist is empty and the + patterns do not match their own source, so nothing is exempt. It ships with a + `--self-test` that CI runs first, because a gate nobody has watched fire is a + gate nobody has tested. +- **CI fails on a stale generated file.** A `generated` job regenerates the + embedded relay server, the embedded test app and the measured numbers in + `README.md` and `benchmarks/README.md`, then runs `git diff --exit-code`. +- **The measured numbers are generated.** `scripts/render-measured.ts` renders + the tables and headline counts in `README.md` and `benchmarks/README.md` from + `benchmarks/*.json`, inside `` blocks. A figure typed by + hand no longer merges. `bun run lint` checks the same thing locally. + ## [0.7.0] - 2026-09-03 The handoff now knows whether anybody is on the other side, and the ending is diff --git a/README.md b/README.md index 64633ca..9fd7eeb 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ returns. Set `SOLARI_API_KEY`; handraise uses it to create the relay sandbox. Measured against the live API, method and raw data in [`benchmarks/`](benchmarks/README.md): + - **19/20 blocked workflows rescued** (baseline 0/20). - **30/30 handoffs resolved** in the latency benchmark. - **3.5 s median** from raise to live on the phone. + ## Approval mode @@ -465,20 +467,24 @@ Solari plan, one fresh relay sandbox each, a scripted human on the public WebSocket, measured from Germany against the default (us-west) endpoint. **30/30 resolved, zero reconnects, zero leaked sandboxes.** + | | p50 | p75 | worst of 30 | |---|---|---|---| | Agent raises its hand → the phone shows the live page | 3.5s | 3.6s | 3.7s | | — of which: relay sandbox cold start | 2.7s | 2.7s | 2.9s | | Input round trip through the relay (150 samples) | 186ms | 191ms | 286ms | + The number that matters more than any latency is what handraise does to workflows that would otherwise fail. `bun run bench:rescue`: 40 runs against a live portal with a real TOTP wall, interleaved arms, one completion test: + | | completed | median human time | |---|---|---| | baseline agent (no human available) | 0/20 | — | | with handraise | **19/20** | 5.5s | + The 0/20 baseline is the design fact, not a crippled agent: it tried, and a machine cannot know a TOTP code. The 5.5s is a scripted human — the machine @@ -491,10 +497,12 @@ side by side: 20 workflows against one live portal, interleaved takeover, approval, takeover, on 2026-09-02. A takeover needs the browser driven; an approval needs one decision, and every fourth one here was a denial. + | | completed | to visible | frames | bytes | relay sandbox | |---|---|---|---|---|---| | takeover — the human drives | 10/10 | 4718ms | 14 | 142 KB | 10.7s | | approval — the human decides | 10/10 | 4896ms | 1 | 25 KB | 5.3s | + Medians over the completed runs. A denied approval counts as completed: the decision was delivered, and the bench then loads the account page and requires diff --git a/benchmarks/README.md b/benchmarks/README.md index 3378273..21f041b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -32,13 +32,16 @@ MIXED_N=4 bun run bench:mixed # a short run Each writes its JSON back into this directory, overwriting the committed file. + ## Handoff latency — 30 handoffs, 30 resolved + 30 consecutive real handoffs, run one at a time, one fresh relay sandbox each, with a scripted human on the public WebSocket. Measured from Germany against the default (us-west) endpoint on 2026-09-01. **30/30 resolved, zero reconnects, zero failures, zero leaked sandboxes.** + | Metric | p50 | p75 | worst | |---|---|---|---| | `stuckToVisibleMs` — `raiseHand()` → first frame at the human | 3532 | 3594 | 3746 | @@ -46,6 +49,7 @@ reconnects, zero failures, zero leaked sandboxes.** | `firstFrameMs` — same frame, timed agent-side | 644 | 664 | 679 | | `inputRttMs` — human → relay → human, 150 samples | 186 | 191 | 286 | | `handoffDurationMs` — whole handoff live, scripted human | 2031 | 2065 | 2183 | + All values in milliseconds. `stuckToVisibleMs` is the honest end-to-end number: it is measured on the human's socket, not the agent's. Cold start is ~75% of @@ -57,17 +61,21 @@ input round trip sits on the network RTT floor from Germany to the us-west edge relay with your region. `handoffDurationMs` is the machine floor of a handoff, not a human's reading pace. + ## Rescue rate — 19 of 20 blocked workflows completed + One workflow run 2×20 times against one live portal with a real RFC 6238 TOTP wall: sign in, reach the account page. The arms are interleaved (baseline i, handraise i, baseline i+1, …) so both see the same browser ages, the same network minute and the same app state. + | | completed | median handoff | |---|---|---| | baseline — no human, no access to the shared secret | 0/20 | — | | with handraise | **19/20** | 5490 ms | + The 0/20 baseline is a design fact, not a crippled agent: it submits the form, scrapes the page for a code, reloads and retries, and it never touches the @@ -86,7 +94,9 @@ The counting is load-bearing rather than decorative, and that is testable: test, and the table must then read 20/20 for the baseline and 0/20 for handraise. + ## Mixed workload — 20 of 20 workflows completed, and what each mode cost + The first two benches ask about one mode. This one runs both against the same Aurora Bank instance, interleaved (takeover, approval, takeover, …), on @@ -100,10 +110,12 @@ Aurora Bank instance, interleaved (takeover, approval, takeover, …), on decision. A scripted human sees one screenshot and the action in words, and answers. Every fourth approval is denied (2 of 10 here). + | | completed | time to visible p50 / p75 | handoff p50 / p75 | frames | bytes | inputs | relay-sandbox s | |---|---|---|---|---|---|---|---| | takeover | 10/10 | 4718 / 4808 ms | 6927 / 7202 ms | 14 | 142 KB | 8 | 10.7 | | approval | 10/10 | 4896 / 4977 ms | 2063 / 2084 ms | 1 | 25 KB | 0 | 5.3 | + All per-handoff figures are medians over the runs that completed, and the two time columns measure different spans: diff --git a/package.json b/package.json index e461092..faaebec 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", - "lint": "biome check . && oxlint && bun scripts/embed-guest.ts --check", + "lint": "biome check . && oxlint && bun scripts/embed-guest.ts --check && bun test-app/embed-app.ts --check && bun scripts/render-measured.ts --check", "test": "bun test src/ test-app/ e2e/ui.spec.ts", "test:coverage": "bun test --coverage src/ test-app/", "test:e2e": "bun --env-file=.env e2e/handoff.e2e.ts", diff --git a/scripts/render-measured.ts b/scripts/render-measured.ts new file mode 100644 index 0000000..ebb8705 --- /dev/null +++ b/scripts/render-measured.ts @@ -0,0 +1,319 @@ +/** + * Render the measured numbers in README.md and benchmarks/README.md from the + * raw benchmark JSON, so no figure in the docs can be typed by hand. + * + * bun scripts/render-measured.ts # rewrite the generated blocks + * bun scripts/render-measured.ts --check # fail if a block is stale + * + * The benches in `e2e/` write `benchmarks/*.json`; this turns those files into + * the tables and headline counts the docs show. Every block it owns is fenced + * by `` … ``; prose between the + * blocks is written by hand and is not checked, so a claim that needs a number + * belongs inside a block. + * + * The output is a pure function of the JSON, so a second run is byte-identical + * to the first — that is what lets CI run it and then `git diff --exit-code`. + */ +import { readFileSync, writeFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const README = fileURLToPath(new URL("../README.md", import.meta.url)) +const BENCH_README = fileURLToPath( + new URL("../benchmarks/README.md", import.meta.url), +) + +interface LatencyStat { + metric: string + n: number + p50: number + p75: number + max: number +} + +interface LatencyReport { + meta: { completedN: number; successes: number } + stats: LatencyStat[] +} + +interface RescueSummary { + arm: string + runs: number + completed: number + medianHandoffMs: number | null +} + +interface RescueReport { + summaries: RescueSummary[] +} + +interface MixedMetric { + p50: number + p75: number +} + +interface MixedSummary { + mode: string + label: string + attempted: number + completed: number + stuckToVisibleMs: MixedMetric + handoffDurationMs: MixedMetric + framesSent: MixedMetric + bytesSent: MixedMetric + inputsApplied: MixedMetric + relaySandboxSeconds: MixedMetric +} + +interface MixedReport { + summaries: MixedSummary[] +} + +function read(path: string): string { + return readFileSync(fileURLToPath(new URL(path, import.meta.url)), "utf8") +} + +// SAFETY: these three files are written by e2e/bench.ts, e2e/rescue-bench.ts +// and e2e/mixed-bench.ts, which own the schema. Every field this script reads +// is fetched through `stat()`, `arm()` or `mode()` below, each of which throws +// on a missing entry — a renamed metric fails the run instead of rendering +// "undefined" into the README. +const latency = JSON.parse( + read("../benchmarks/handoff-latency.json"), +) as LatencyReport +// SAFETY: as above — written by e2e/rescue-bench.ts, arms looked up by name. +const rescue = JSON.parse( + read("../benchmarks/rescue-rate.json"), +) as RescueReport +// SAFETY: as above — written by e2e/mixed-bench.ts, modes looked up by name. +const mixed = JSON.parse( + read("../benchmarks/mixed-workload.json"), +) as MixedReport + +function stat(metric: string): LatencyStat { + const found = latency.stats.find((entry) => entry.metric === metric) + if (!found) { + throw new Error(`handoff-latency.json has no stat "${metric}"`) + } + return found +} + +function arm(name: string): RescueSummary { + const found = rescue.summaries.find((entry) => entry.arm === name) + if (!found) { + throw new Error(`rescue-rate.json has no arm "${name}"`) + } + return found +} + +function mode(name: string): MixedSummary { + const found = mixed.summaries.find((entry) => entry.mode === name) + if (!found) { + throw new Error(`mixed-workload.json has no mode "${name}"`) + } + return found +} + +/** 3532 → "3.5" — the README speaks in seconds, the JSON in milliseconds. */ +function s(ms: number): string { + return (ms / 1000).toFixed(1) +} + +/** 145104 → "142" — KB as the README writes it, 1024 bytes to the KB. */ +function kb(bytes: number): string { + return String(Math.round(bytes / 1024)) +} + +function readmeHeadline(): string { + const rescued = arm("handraise") + const baseline = arm("baseline") + const visible = stat("stuckToVisibleMs") + return [ + `- **${rescued.completed}/${rescued.runs} blocked workflows rescued** (baseline ${baseline.completed}/${baseline.runs}).`, + `- **${latency.meta.successes}/${latency.meta.completedN} handoffs resolved** in the latency benchmark.`, + `- **${s(visible.p50)} s median** from raise to live on the phone.`, + ].join("\n") +} + +function readmeLatencyTable(): string { + const visible = stat("stuckToVisibleMs") + const cold = stat("relayColdStartMs") + const rtt = stat("inputRttMs") + return [ + `| | p50 | p75 | worst of ${visible.n} |`, + "|---|---|---|---|", + `| Agent raises its hand → the phone shows the live page | ${s(visible.p50)}s | ${s(visible.p75)}s | ${s(visible.max)}s |`, + `| — of which: relay sandbox cold start | ${s(cold.p50)}s | ${s(cold.p75)}s | ${s(cold.max)}s |`, + `| Input round trip through the relay (${rtt.n} samples) | ${rtt.p50}ms | ${rtt.p75}ms | ${rtt.max}ms |`, + ].join("\n") +} + +function readmeRescueTable(): string { + const baseline = arm("baseline") + const rescued = arm("handraise") + const median = rescued.medianHandoffMs + return [ + "| | completed | median human time |", + "|---|---|---|", + `| baseline agent (no human available) | ${baseline.completed}/${baseline.runs} | — |`, + `| with handraise | **${rescued.completed}/${rescued.runs}** | ${median === null ? "—" : `${s(median)}s`} |`, + ].join("\n") +} + +function readmeMixedRow(summary: MixedSummary): string { + return `| ${summary.label} | ${summary.completed}/${summary.attempted} | ${summary.stuckToVisibleMs.p50}ms | ${summary.framesSent.p50} | ${kb(summary.bytesSent.p50)} KB | ${summary.relaySandboxSeconds.p50.toFixed(1)}s |` +} + +function readmeMixedTable(): string { + return [ + "| | completed | to visible | frames | bytes | relay sandbox |", + "|---|---|---|---|---|---|", + readmeMixedRow(mode("takeover")), + readmeMixedRow(mode("approval")), + ].join("\n") +} + +function benchLatencyHeading(): string { + return `## Handoff latency — ${latency.meta.completedN} handoffs, ${latency.meta.successes} resolved` +} + +function benchLatencyRow(metric: string, description: string): string { + const entry = stat(metric) + return `| \`${metric}\` — ${description} | ${entry.p50} | ${entry.p75} | ${entry.max} |` +} + +function benchLatencyTable(): string { + return [ + "| Metric | p50 | p75 | worst |", + "|---|---|---|---|", + benchLatencyRow( + "stuckToVisibleMs", + "`raiseHand()` → first frame at the human", + ), + benchLatencyRow( + "relayColdStartMs", + "sandbox create → public URL answering", + ), + benchLatencyRow("firstFrameMs", "same frame, timed agent-side"), + benchLatencyRow( + "inputRttMs", + `human → relay → human, ${stat("inputRttMs").n} samples`, + ), + benchLatencyRow("handoffDurationMs", "whole handoff live, scripted human"), + ].join("\n") +} + +function benchRescueHeading(): string { + const rescued = arm("handraise") + return `## Rescue rate — ${rescued.completed} of ${rescued.runs} blocked workflows completed` +} + +function benchRescueTable(): string { + const baseline = arm("baseline") + const rescued = arm("handraise") + const median = rescued.medianHandoffMs + return [ + "| | completed | median handoff |", + "|---|---|---|", + `| baseline — no human, no access to the shared secret | ${baseline.completed}/${baseline.runs} | — |`, + `| with handraise | **${rescued.completed}/${rescued.runs}** | ${median === null ? "—" : `${median} ms`} |`, + ].join("\n") +} + +function benchMixedHeading(): string { + const attempted = mixed.summaries.reduce( + (sum, entry) => sum + entry.attempted, + 0, + ) + const completed = mixed.summaries.reduce( + (sum, entry) => sum + entry.completed, + 0, + ) + return `## Mixed workload — ${completed} of ${attempted} workflows completed, and what each mode cost` +} + +function benchMixedRow(summary: MixedSummary): string { + const visible = `${summary.stuckToVisibleMs.p50} / ${summary.stuckToVisibleMs.p75} ms` + const handoff = `${summary.handoffDurationMs.p50} / ${summary.handoffDurationMs.p75} ms` + return `| ${summary.mode} | ${summary.completed}/${summary.attempted} | ${visible} | ${handoff} | ${summary.framesSent.p50} | ${kb(summary.bytesSent.p50)} KB | ${summary.inputsApplied.p50} | ${summary.relaySandboxSeconds.p50.toFixed(1)} |` +} + +function benchMixedTable(): string { + return [ + "| | completed | time to visible p50 / p75 | handoff p50 / p75 | frames | bytes | inputs | relay-sandbox s |", + "|---|---|---|---|---|---|---|---|", + benchMixedRow(mode("takeover")), + benchMixedRow(mode("approval")), + ].join("\n") +} + +const TARGETS: ReadonlyArray<{ + path: string + blocks: ReadonlyArray<{ name: string; render: () => string }> +}> = [ + { + path: README, + blocks: [ + { name: "readme-headline", render: readmeHeadline }, + { name: "readme-latency", render: readmeLatencyTable }, + { name: "readme-rescue", render: readmeRescueTable }, + { name: "readme-mixed", render: readmeMixedTable }, + ], + }, + { + path: BENCH_README, + blocks: [ + { name: "bench-latency-heading", render: benchLatencyHeading }, + { name: "bench-latency-table", render: benchLatencyTable }, + { name: "bench-rescue-heading", render: benchRescueHeading }, + { name: "bench-rescue-table", render: benchRescueTable }, + { name: "bench-mixed-heading", render: benchMixedHeading }, + { name: "bench-mixed-table", render: benchMixedTable }, + ], + }, +] + +function replaceBlock(text: string, name: string, body: string): string { + const open = `` + const close = `` + const start = text.indexOf(open) + const end = text.indexOf(close) + if (start === -1 || end === -1 || end < start) { + throw new Error(`no "${name}" block to fill — expected ${open} … ${close}`) + } + if (text.indexOf(open, start + 1) !== -1) { + throw new Error(`"${name}" block appears twice; block names must be unique`) + } + return `${text.slice(0, start + open.length)}\n${body}\n${text.slice(end)}` +} + +const checkOnly = process.argv.includes("--check") +const stale: string[] = [] +const written: string[] = [] +for (const target of TARGETS) { + const current = readFileSync(target.path, "utf8") + let next = current + for (const block of target.blocks) { + next = replaceBlock(next, block.name, block.render()) + } + if (next === current) { + continue + } + if (checkOnly) { + stale.push(target.path) + } else { + writeFileSync(target.path, next) + written.push(target.path) + } +} + +if (stale.length > 0) { + console.error( + `measured numbers are stale — run: bun scripts/render-measured.ts\n ${stale.join("\n ")}`, + ) + process.exit(1) +} +if (written.length > 0) { + console.log(`rewrote measured numbers in:\n ${written.join("\n ")}`) +} else { + console.log("measured numbers match benchmarks/*.json") +} diff --git a/scripts/scan-secrets.sh b/scripts/scan-secrets.sh new file mode 100755 index 0000000..c48d1af --- /dev/null +++ b/scripts/scan-secrets.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# Fail if the tracked tree contains anything shaped like a credential. +# +# scripts/scan-secrets.sh # scan every tracked file +# scripts/scan-secrets.sh --self-test # prove each pattern still fires +# +# Only tracked files are scanned, because only tracked files can be pushed. +# `.env` is in .gitignore, but .gitignore is a request, not a gate: `git add -f` +# and a fresh clone with a stale ignore file both get past it, so the tracked +# set is checked directly. +# +# The allowlist below is EMPTY and is meant to stay that way. Every pattern is +# written so that its own text does not match it — the prefix is always followed +# by a character class, never by a value — which is why this file needs no +# exemption from its own scan. If a fixture ever needs a fake token, give it a +# shape no issuer produces (`slr_live_x`, `pt_token=x`) rather than an entry +# here. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Paths exempt from the scan. Deliberately empty — see the header. +ALLOWLIST=() + +# name|regex, in ERE. Order is cosmetic; every pattern always runs. +PATTERN_NAMES=( + "Solari live API key" + "Solari port-preview token" + "JWT (>= 60 chars, two dots)" + "Slack bot/user token" + "Slack app-level token" + "Telegram bot token" + "Discord bot token" +) +PATTERN_REGEXES=( + 'slr_live_[A-Za-z0-9]{12,}' + 'pt_token=[A-Za-z0-9._-]{16,}' + 'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{20,}' + 'xox[bp]-[A-Za-z0-9-]{10,}' + 'xapp-[A-Za-z0-9-]{10,}' + '[0-9]{8,}:[A-Za-z0-9_-]{30,}' + '[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}' +) + +# Tracked files named `.env` or `.env.`. `.env.example` is the one +# name that is meant to be committed, and it holds names without values. +ENV_FILE_REGEX='(^|/)\.env($|\.)' +ENV_FILE_ALLOWED='(^|/)\.env\.(example|sample|template)$' + +self_test() { + # Samples are assembled at run time from two halves, so this file never + # itself contains a string matching its own patterns. That is what keeps the + # allowlist empty. A gate nobody has watched fire is a gate nobody has tested. + local blob="AbCdEfGhIjKlMnOpQrStUvWxYz0123456789" + local positives=( + "slr_live_${blob}" + "https://x-3000.preview.getsolari.com/?pt_token=${blob}.${blob}" + "eyJ${blob}.${blob}.${blob}" + "xoxb-${blob}" + "xapp-${blob}" + "12345678:${blob}" + "M${blob}.${blob:0:6}.${blob}" + ) + # Shapes that already live in this repo and must never trip the scan. + local negatives=( + 'url: "https://relay.example/?pt_token=x"' + 'says `Present it as ?pt_token=`' + '`https://example.preview.getsolari.com/?pt_token=${"x".repeat(240)}`' + 'sha256: 3b1f2c9d4e5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e' + ) + + local failed=0 i name regex sample + for i in "${!PATTERN_REGEXES[@]}"; do + name="${PATTERN_NAMES[$i]}" + regex="${PATTERN_REGEXES[$i]}" + sample="${positives[$i]}" + if ! printf '%s\n' "$sample" | grep -qE -- "$regex"; then + printf 'self-test: pattern "%s" no longer matches its own sample\n' "$name" >&2 + failed=1 + fi + for negative in "${negatives[@]}"; do + if printf '%s\n' "$negative" | grep -qE -- "$regex"; then + printf 'self-test: pattern "%s" matches a known-good line: %s\n' "$name" "$negative" >&2 + failed=1 + fi + done + done + + if [ "$failed" -ne 0 ]; then + printf 'self-test FAILED — the secret scan is not doing what it claims.\n' >&2 + exit 1 + fi + printf 'self-test: %d patterns fire on a live sample and stay quiet on %d known-good lines.\n' \ + "${#PATTERN_REGEXES[@]}" "${#negatives[@]}" +} + +scan() { + local pathspecs=(":(top)") + local path + for path in ${ALLOWLIST+"${ALLOWLIST[@]}"}; do + pathspecs+=(":(top,exclude)${path}") + done + + local failed=0 i name regex matches + for i in "${!PATTERN_REGEXES[@]}"; do + name="${PATTERN_NAMES[$i]}" + regex="${PATTERN_REGEXES[$i]}" + matches="$(git grep -nIE -e "$regex" -- "${pathspecs[@]}" || true)" + if [ -n "$matches" ]; then + printf '\ncredential shape found — %s:\n%s\n' "$name" "$matches" >&2 + failed=1 + fi + done + + local env_files + env_files="$(git ls-files | grep -E "$ENV_FILE_REGEX" | grep -vE "$ENV_FILE_ALLOWED" || true)" + if [ -n "$env_files" ]; then + printf '\nenv file is tracked — it must not be:\n%s\n' "$env_files" >&2 + failed=1 + fi + + if [ "$failed" -ne 0 ]; then + printf '\nsecret scan FAILED. Remove the value, rotate the credential, then rewrite the history that carried it.\n' >&2 + exit 1 + fi + printf 'secret scan: %d tracked files clean against %d credential shapes.\n' \ + "$(git ls-files | wc -l | tr -d ' ')" "${#PATTERN_REGEXES[@]}" +} + +case "${1-}" in + --self-test) self_test ;; + "") scan ;; + *) + printf 'usage: %s [--self-test]\n' "$0" >&2 + exit 2 + ;; +esac