diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..49b3523 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,29 @@ +# ──────────────────────────────────────────────────────────────────────────── +# gitleaks config — maple-brief commit gate (secret stage) +# +# Policy (fail-closed): the FULL default gitleaks ruleset (every known +# provider / secret shape) hard-blocks at commit and pre-push. The ONLY +# suppression is a tight, content-regexed allowlist scoped to a single file: +# .env.example (the committed placeholder template). It suppresses ONLY those +# exact placeholder lines. A real composite key pasted there (or anywhere) +# STILL fires — verified on gitleaks 8.30.1 (linux x64). +# +# Gotchas found while validating (do not regress): +# • allowlist must be a MAP [allowlist] — a slice [[allowlist]] fails +# with "expected a map, got slice" +# • an embedded single quote inside a single-quoted (literal) TOML string +# silently closes it — keep regex strings double-quoted (basic) +# • paths= scopes the suppression to the file; regexes= to the lines +# ──────────────────────────────────────────────────────────────────────────── +[extend] +useDefault = true + +[allowlist] +description = "Suppress ONLY empty double-quoted placeholder values and the change-me example, in the .env template" +paths = ['\.env\.example$'] +regexes = [ + '^OPENAI_API_KEY=("")$', + '^ANTHROPIC_API_KEY=("")$', + '^NEXT_PUBLIC_ADSENSE_[A-Z_]+=("")$', + "^ADMIN_API_KEY=\"change-me[^\"]*\"$" +] diff --git a/README.md b/README.md index 69ab47d..5dd7cec 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,35 @@ npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma npm run seed # manual ingest + synthesis pass ``` +## Commit gate (secret → build → test, fail-closed) + +Every commit and push runs a three-stage gate (`scripts/gate/gate.sh`), in this fixed order: + +| Stage | pre-commit (fast) | pre-push (heavy) | +|---|---|---| +| 1 — secret | `gitleaks protect --staged` | `gitleaks protect` over full HEAD history | +| 2 — build | `tsc --noEmit` (strict) | `prisma generate && next build` (pristine production build) | +| 3 — test | `tests/llm-parse.test.ts` (LLM parse contract, 21 pins) | same | + +- **Fail-closed:** a missing tool, a crash, a timeout, or any finding blocks the + operation. Only a deliberate `git commit --no-verify` / `git push --no-verify` + bypasses it (auditable in the terminal scrollback). +- **Secret stage:** full gitleaks default ruleset, hard-blocks. The one allow + exception is a file- and content-scoped suppress of *empty/placeholder* + values in `.env.example` (see `[allowlist]` in `.gitleaks.toml`) — a real key + written into that file still fires. +- **Test stage:** `tests/llm-parse.test.ts` pins the synthesis parse contract + (6-paragraph cap, 160-char headline, ≤5 takeaways, sanitized tags, + JSON-fence recovery, determinism). It imports only `src/lib/llm/parse.ts` — + pure, zero-dep, zero-network — so it runs under `tsx` with no framework. +- **Install (idempotent, per clone):** + ```bash + bash scripts/install/install-hooks.sh + # → bootstraps gitleaks (pinned 8.30.1) into ~/.local/bin if missing, + # copies scripts/gate/hooks/* into .git/hooks/ + ``` +- Walk away from a fresh machine: `npm ci && bash scripts/install/install-hooks.sh`. + ## Configuration All configuration is in `.env` (local, gitignored — only `.env.example` is committed): diff --git a/package.json b/package.json index 178b1af..818b236 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "prisma generate && next build", "start": "next start", "typecheck": "tsc --noEmit", + "test": "tsx tests/llm-parse.test.ts", "db:generate": "prisma generate", "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", diff --git a/scripts/gate/gate.sh b/scripts/gate/gate.sh new file mode 100755 index 0000000..ddde7c7 --- /dev/null +++ b/scripts/gate/gate.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# ──────────────────────────────────────────────────────────────────────────── +# maple-brief commit gate +# Approved order (hard, fail-closed): 1) secret → 2) build → 3) test +# +# commit mode (pre-commit, fast): +# 1. gitleaks on STAGED changes 2. tsc --noEmit 3. LLM parse unit tests +# push mode (pre-push, heavy): +# 1. gitleaks on full HEAD history 2. prisma generate && next build +# +# Fail-closed: any stage error (missing tool, crash, timeout, findings) +# fails the gate. Same tree ⇒ same verdict (gates are pure functions of +# committed content; no network, no cached state). Only deliberate escape +# hatch: `git commit --no-verify` / `git push --no-verify` (explicit, logged +# by the remote user). +# +# Usage: gate.sh commit | push +# ──────────────────────────────────────────────────────────────────────────── +set -u + +MODE="${1:-commit}" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" || { echo "gate: cannot cd to repo root" >&2; exit 1; } + +TS0=$(date +%s) +FAILURES=0 + +say() { printf '\n█ gate[%s] %s\n' "$MODE" "$1"; } +ok() { printf '\x1b[32m PASS\x1b[0m %s\n' "$1"; } +bad() { printf '\x1b[31m FAIL\x1b[0m %s\n' "$1"; FAILURES=$((FAILURES + 1)); } + +# --- setup (fail-closed: never proceed without the gate components) --------- +command -v gitleaks >/dev/null 2>&1 || { + say "setup" + bad "gitleaks not found — run: bash scripts/install/install-hooks.sh" + printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } +[ -d node_modules ] || { + say "setup" + bad "node_modules missing — run: npm ci" + printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } +[ -f .gitleaks.toml ] || { + say "setup"; bad ".gitleaks.toml missing (gate config)"; + printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } +[ -x node_modules/.bin/tsx ] || { + say "setup"; bad "tsx not in node_modules — run: npm ci"; + printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } + +# verdict key: what content this verdict is a function of +case "$MODE" in + commit) TREEKEY="$(git write-tree 2>/dev/null || echo no-staged-tree)" ;; + push) TREEKEY="$(git rev-parse HEAD 2>/dev/null || echo no-head)" ;; + *) say "usage: gate.sh commit|push"; exit 2 ;; +esac + +# --- stage 1: secret -------------------------------------------------------- +# $1 = "staged" | "history"; gitleaks 8.x takes no positional path — use +# --staged for staged-only, no flag for full history. Exit 0 = clean, +# 1 = findings, anything else = scan error (all fail-closed). +stage_secret() { + local scope="$1" + case "$scope" in + staged) say "1/3 secret — gitleaks (staged changes)"; local args=(--staged) ;; + history) say "1/3 secret — gitleaks (full HEAD history)"; local args=() ;; + *) bad "stage_secret: unknown scope '$scope'"; return 1 ;; + esac + local out rc + out="$(timeout 180 gitleaks protect -c .gitleaks.toml --no-banner --no-color "${args[@]+"${args[@]}"}" 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then + bad "secret scan failed (exit ${rc:-ERR})" + printf '%s\n' "$out" | grep -vE '^\s*$' | tail -12 | sed 's/^/ /' + else + ok "no secret matches in $scope" + fi +} + +# --- stage 2: build --------------------------------------------------------- +stage_build_commit() { + say "2/3 build — tsc --noEmit (strict TS)" + local out rc + out="$(timeout 240 ./node_modules/.bin/tsc --noEmit 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then bad "typecheck failed (exit $rc)"; printf '%s\n' "$out" | tail -15 | sed 's/^/ /'; + else ok "typecheck clean"; fi +} +stage_build_push() { + say "2/3 build — prisma generate && next build (pristine, full)" + local out rc + out="$(timeout 900 npm run build 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then bad "build failed (exit $rc)"; printf '%s\n' "$out" | tail -20 | sed 's/^/ /'; + else ok "production build clean"; fi +} + +# --- stage 3: test ---------------------------------------------------------- +stage_test() { + say "3/3 test — LLM parse contract (tests/llm-parse.test.ts)" + local out rc + out="$(timeout 120 ./node_modules/.bin/tsx tests/llm-parse.test.ts 2>&1)"; rc=$? + if [ "$rc" -ne 0 ]; then bad "unit test failed (exit $rc)"; printf '%s\n' "$out" | grep -E '^(not ok|ok|fail)' | tail -10 | sed 's/^/ /'; + else ok "LLM parse contract holds ($(printf '%s\n' "$out" | grep -c '^ok ') checks)"; fi +} + +# --- run (all stages always execute; verdict = OR of failures) -------------- +case "$MODE" in + commit) + stage_secret staged + stage_build_commit + stage_test + ;; + push) + stage_secret history + stage_build_push + stage_test + ;; +esac + +ELAPSED=$(( $(date +%s) - TS0 )) +case "$MODE" in + commit) OBJECT="staged-tree $TREEKEY" ;; + push) OBJECT="HEAD $TREEKEY" ;; +esac +if [ "$FAILURES" -eq 0 ]; then + printf '\n════════════════════════════════════════\n GATE PASS [%s] %s in %ss\n════════════════════════════════════════\n' "$MODE" "$OBJECT" "$ELAPSED" + exit 0 +else + printf '\n════════════════════════════════════════\n GATE FAIL [%s] %s — %s stage(s) failed, in %ss\n bypass (auditable): git %s --no-verify\n════════════════════════════════════════\n' "$MODE" "$OBJECT" "$FAILURES" "$ELAPSED" "$MODE" + exit 1 +fi diff --git a/scripts/gate/hooks/pre-commit b/scripts/gate/hooks/pre-commit new file mode 100755 index 0000000..473763e --- /dev/null +++ b/scripts/gate/hooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# pre-commit → orders secret → build(typecheck) → test (fast gates only) +exec bash "$(git rev-parse --show-toplevel)/scripts/gate/gate.sh" commit diff --git a/scripts/gate/hooks/pre-push b/scripts/gate/hooks/pre-push new file mode 100755 index 0000000..21c90ba --- /dev/null +++ b/scripts/gate/hooks/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# pre-push → orders secret (full history) → build (full next build) → test +# Note: git pushes a branch HEAD; scanning full history catches secrets +# committed in the past that a re-fetch of a bare repo would inherit. +exec bash "$(git rev-parse --show-toplevel)/scripts/gate/gate.sh" push diff --git a/scripts/install/bootstrap-gitleaks.sh b/scripts/install/bootstrap-gitleaks.sh new file mode 100755 index 0000000..8c32ea8 --- /dev/null +++ b/scripts/install/bootstrap-gitleaks.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# One-shot bootstrap of the gitleaks binary into ~/.local/bin (pinned version). +# Reproducible: fixed tag, not "latest". Bump GL_VER deliberately to upgrade. +set -eu +GL_VER="8.30.1" +DEST="${HOME}/.local/bin" +mkdir -p "$DEST" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +URL="https://github.com/gitleaks/gitleaks/releases/download/v${GL_VER}/gitleaks_${GL_VER}_linux_x64.tar.gz" +echo "[bootstrap] fetching gitleaks v${GL_VER}" +curl -sSL -m 180 -o "$tmp/gl.tgz" "$URL" +tar xzf "$tmp/gl.tgz" -C "$tmp" gitleaks +install -m0755 "$tmp/gitleaks" "${DEST}/gitleaks" +echo "[bootstrap] installed: $("$DEST/gitleaks" version) → ${DEST}/gitleaks" +echo "[bootstrap] ensure PATH includes ${DEST}, e.g. in your shell rc: + export PATH=\"${DEST}:\$PATH\"" diff --git a/scripts/install/install-hooks.sh b/scripts/install/install-hooks.sh new file mode 100755 index 0000000..54fd84d --- /dev/null +++ b/scripts/install/install-hooks.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Install the repo commit gate into this clone (idempotent, re-runnable): +# 1. bootstrap gitleaks binary if missing → ~/.local/bin/gitleaks (pinned) +# 2. copy scripts/gate/hooks/* → .git/hooks/* (repo-local, NOT in repo) +# 3-9 show what the gate will do on every commit/push. +# Nothing here is in the committed history; hooks live in .git/hooks/. +set -eu +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +[ -f "$ROOT/scripts/install/install-hooks.sh" ] || { echo "install: cannot find repo root from $(dirname "$0")" >&2; exit 1; } +cd "$ROOT" + +echo "== 1/3 gitleaks" +if ! command -v gitleaks >/dev/null 2>&1; then + if [ -x "$HOME/.local/bin/gitleaks" ]; then + echo "[install] gitleaks at ~/.local/bin (not on PATH) — will be used via absolute path" + else + bash scripts/install/bootstrap-gitleaks.sh + fi +else + gitleaks version | sed 's/^/[install] gitleaks /' +fi + +echo "== 2/3 hooks" +install -m0755 scripts/gate/hooks/pre-commit .git/hooks/pre-commit +install -m0755 scripts/gate/hooks/pre-push .git/hooks/pre-push +echo "[install] wrote .git/hooks/pre-commit, .git/hooks/pre-push" + +echo "== 3/3 ready" +echo "[install] commits gated: secret → tsc → tests (fast, seconds)" +echo "[install] pushes gated: secret(history) → full next build → tests" +echo "[install] bypass (auditable at the pusher's shell): git commit/push --no-verify" +echo "[install] NOTE: gate lives in THIS clone's .git/hooks — after git clone," +echo "[install] the same-branch pull on another machine re-installs by re-running:" +echo "[install] bash scripts/install/install-hooks.sh" diff --git a/tests/llm-parse.test.ts b/tests/llm-parse.test.ts new file mode 100644 index 0000000..bf5dafa --- /dev/null +++ b/tests/llm-parse.test.ts @@ -0,0 +1,164 @@ +/** + * LLM synthesis parse-contract unit test (gate stage 3 / Flux 3). + * + * Pinned contract — if a change breaks any of these, the secret→build→test + * gate hard-blocks the commit. Covers the pipeline choke point that EVERY + * provider funnels through: src/lib/llm/parse.ts (pure, zero-dep, zero-net). + * + * Notable pins (2026-08-15 "redo it" cut): + * - body survives up to 6 paragraphs (3→6 cut in the prompt) + * - headline capped at 160 chars + * - takeaways ≤5, tags ≤6, tags sanitized to [a-z0-9-] + * - determinism: same input ⇒ same output (the gate's own principle) + * + * Runner: no framework. `tsx tests/llm-parse.test.ts` → TAP-style, exit 1 on + * any failure. Keeps node deps at zero so the gate never needs a test litany. + */ +import assert from 'node:assert/strict'; + +process.env.TS_NODE_PROJECT = process.cwd() + '/tsconfig.json'; + +import { + extractJson, + parseSynthesis, + primaryTag, + safeParse, + type ParsedSynthesis, +} from '@/lib/llm/parse'; + +let passed = 0; +let failed = 0; + +function check(name: string, fn: () => void): void { + try { + fn(); + passed += 1; + console.log(`ok ${passed + failed} ${name}`); + } catch (err) { + failed += 1; + console.log(`not ok ${passed + failed} ${name}`); + console.log(` ${(err as Error).message}`); + } +} + +const GOOD_JSON = JSON.stringify({ + headline: 'Toronto MSTP extension debate heats up', + body: ['P1 fact.', 'P2 context.', 'P3 detail.', 'P4 quote.', 'P5 view.', 'P6 outlook.'] + .join('\n\n'), + takeaways: ['a', 'b', 'c', 'd', 'e', 'f'], + tags: ['Transit / Big City!', 'CanAdA', 'overly-long-tag-that-exceeds-character-limit'], +}); + +check('extractJson: passthrough of strict JSON', () => { + assert.equal(extractJson(GOOD_JSON), GOOD_JSON); +}); + +check('extractJson: strips ```json fences', () => { + assert.equal(extractJson('```json\n{"headline":"H","body":"B"}\n```'), '{"headline":"H","body":"B"}'); +}); + +check('extractJson: recovers object from verbose prose', () => { + const raw = 'Sure! Here is the synthesis you requested:\n{"headline":"H","body":"B"}\nLet me know if you need anything else!'; + assert.equal(extractJson(raw), '{"headline":"H","body":"B"}'); +}); + +check('parseSynthesis: canonical 6-paragraph shape preserved', () => { + const r: ParsedSynthesis = parseSynthesis(GOOD_JSON); + assert.equal(r.headline, 'Toronto MSTP extension debate heats up'); + assert.equal(r.body.split(/\n{2,}/).length, 6); +}); + +check('parseSynthesis: 7 paragraphs trimmed to 6 (cut contract)', () => { + const seven = JSON.stringify({ + headline: 'H', + body: Array.from({ length: 7 }, (_, i) => `paragraph ${i}`).join('\n\n'), + }); + const r = parseSynthesis(seven); + assert.equal(r.body.split(/\n{2,}/).length, 6, 'body must cap at 6 paragraphs'); + assert.ok(!r.body.includes('paragraph 6'), 'seventh paragraph dropped'); +}); + +check('parseSynthesis: takeaways capped at 5', () => { + assert.equal(parseSynthesis(GOOD_JSON).takeaways.length, 5); +}); + +check('parseSynthesis: tags sanitized, capped at 6, no junk', () => { + const tags = parseSynthesis(GOOD_JSON).tags; + assert.ok(tags.length <= 6); + for (const t of tags) { + assert.match(t, /^[a-z0-9-]{1,24}$/); + } + assert.ok(tags.includes('transit-big-city')); + assert.ok(tags.includes('canada')); +}); + +check('parseSynthesis: headline capped at 160 chars', () => { + const long = JSON.stringify({ headline: 'x'.repeat(400), body: 'b' }); + assert.equal(parseSynthesis(long).headline.length, 160); +}); + +check('parseSynthesis: falls back to primaryTag when no tags', () => { + const r = parseSynthesis(JSON.stringify({ headline: 'Ottawa Auto Recall Notice', body: 'b' })); + assert.deepEqual(r.tags, ['ottawa']); +}); + +check('parseSynthesis: missing headline throws (fail before persist)', () => { + assert.throws(() => parseSynthesis(JSON.stringify({ body: 'b' })), /missing headline or body/i); +}); + +check('parseSynthesis: missing body throws', () => { + assert.throws(() => parseSynthesis(JSON.stringify({ headline: 'h' })), /missing headline or body/i); +}); + +check('parseSynthesis: array input rejected (array ≠ synthesis object)', () => { + // JSON.parse of "[1,2,3]" is an array (typeof === 'object' in JS), so it + // flows past the object guard and is rejected as missing headline/body. + // Either way it must THROW — garbage in, article marked failed. + assert.throws(() => parseSynthesis('[1, 2, 3]')); +}); + +check('parseSynthesis: scalar (not-object) JSON throws', () => { + // "42" parses to a number → hits the explicit not-an-object guard. + assert.throws(() => parseSynthesis('42'), /not an object/i); +}); + +check('parseSynthesis: unparseable JSON throws (caller marks failed)', () => { + assert.throws(() => parseSynthesis('definitely not json'), /Unparseable synthesis JSON/i); +}); + +check('parseSynthesis: null/empty-string headline treated as missing', () => { + assert.throws(() => parseSynthesis(JSON.stringify({ headline: null, body: 'b' }))); +}); + +check('primaryTag: strips stopwords, keeps first content word', () => { + assert.equal(primaryTag('The announcement of a big fuel increase'), 'announcement'); +}); + +check('primaryTag: all-stopwords/short headline falls back to canada', () => { + // every token is either a stopword or ≤3 chars → no content word → canada + assert.equal(primaryTag('The of and'), 'canada'); +}); + +check('safeParse: valid JSON passes through', () => { + assert.deepEqual(safeParse('[1, 2, 3]'), [1, 2, 3]); +}); + +check('safeParse: malformed JSON returns fallback', () => { + const fb = { default: 'x' }; + assert.equal(safeParse('nope', fb), fb); +}); + +check('determinism: same input ⇒ byte-identical output (gate principle)', () => { + const a = parseSynthesis(GOOD_JSON); + const c = parseSynthesis(GOOD_JSON); + assert.deepEqual(a, c); +}); + +check('fenced+prose end-to-end still parses (small-model degradation path)', () => { + const r = parseSynthesis(extractJson('```json\n' + GOOD_JSON + '\n```')); + assert.equal(r.body.split(/\n{2,}/).length, 6); +}); + +const total = passed + failed; +console.log(`\n# tests ${total}, pass ${passed}, fail ${failed}`); +process.exit(failed === 0 ? 0 : 1);