From fe9724fee5025186b9e7d92130b6dd89345107de Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:04:46 -0600 Subject: [PATCH 1/6] docs(specs): add dogfood scanners design --- .../2026-07-01-dogfood-scanners-design.md | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md diff --git a/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md b/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md new file mode 100644 index 0000000..4749123 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md @@ -0,0 +1,304 @@ +# Dogfood secret + SAST scanners (gitleaks + semgrep) — design (2026-07-01) + +## Goal + +Run the same secret-scanning + SAST gate the template ships (`enable_scanners`) +on the maintainer repo itself, adapted to the maintainer's actual surface (one +workflow with per-tool jobs; `tests/` the only Python; no `src/`; `template/` is +Jinja), green from the first commit. This closes gap #2 of the dogfooding-gap +audit (`docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md`) — the one +substantive *security* gap: a secret committed (even then deleted) to the +maintainer repo is currently uncaught by the very tool it ships. + +This design was produced from a multi-agent audit and adversarially reviewed; +the corrections are folded into the wiring below. The scan starts green today — +the only `eval(` token in `tests/` is a string literal in +`test_generation.py` (a `Constant` node), which semgrep's `eval(...)` Call +pattern does not match. + +**Scope:** gitleaks + semgrep only. pip-audit (gap #5) shares the template's +`scan.yml` but is a separate gap and a separate PR; it will add one step to the +`scan` job introduced here (see Out of scope). + +## Decisions + +Nine forks were resolved; all but two settle by "mirror, don't diverge." The two +open ones (pip-audit scope, pin-drift governance) were resolved with the +recommended options and remain revisitable at spec review. + +1. **gitleaks command: `gitleaks git . --redact --exit-code 1` (history), not + `gitleaks dir`.** The gap-audit plan doc claimed the template's local recipe + uses `gitleaks dir`; that is **stale** — `template/justfile.jinja:50` ships + `gitleaks git .`, and `test_generation.py` asserts `gitleaks git .` present / + `gitleaks dir` absent. History scan catches secrets committed then deleted; + it forces `fetch-depth: 0` on the CI job. +2. **CI shape: a new `scan` *job* inside the existing `test-template.yml`, not a + separate `scan.yml`.** The maintainer runs one workflow with per-tool jobs + (test / zizmor / typecheck / lint); the template's consolidation into + `scan.yml` is a downstream-only shape. A sibling job keeps the existing zizmor + job (which scans `.`) auditing the new job. +3. **zizmor stays its own job — not merged into `scan`.** The template folds + zizmor into `scan.yml` gated on `enable_sha_pin_policy`; the maintainer + already dogfoods zizmor standalone, so it must not be duplicated. +4. **Run mechanisms mirror the template:** gitleaks pinned once in `mise.toml` + and run in CI via `jdx/mise-action` + `mise exec -- gitleaks`; semgrep via + `uvx semgrep@1.167.0` (no pyproject dep — the "uvx-run tools skip the dep" + convention, matching zizmor). +5. **semgrep target: `.`** (verbatim mirror). The rule is `languages: [python]`, + so only `tests/*.py` is parsed regardless (`template/` Jinja and non-Python + trees are skipped); `.` preserves byte-parity and auto-covers any future + root-level Python. Hermetic flags `--config .semgrep.yml --metrics=off + --error` are non-negotiable — never `--config auto`. +6. **`scan` stays out-of-band** — not chained into any recipe. The template's + `ci` recipe omits scan ("scanners run in CI only"); the maintainer has no `ci` + recipe at all, and the pre-commit dogfood likewise kept `precommit` off any + aggregate. The CI `scan` job is the enforcer. +7. **`timeout-minutes: 15`** on the scan job — the template's budget for this + workload (a history scan is heavier than a workflow audit), vs the + maintainer's 10 on zizmor/typecheck/lint. +8. **Local `just scan` uses bare `gitleaks git .`** (byte-parity with + `template/justfile.jinja:50`). Verified safe: the maintainer shell activates + mise (`mise activate` in `config.fish`; tool install dirs on `$PATH`), so + `gitleaks` resolves after the pin is added — no `mise exec` wrapper needed + locally. (CI still uses `mise exec`, exactly as the template does.) +9. **No generation/policy self-test for the mirror** — the new files are at + ROOT, not under `template/`, so NEVER-rule #1 does not fire (same reasoning + the pre-commit dogfood used; it added none). Pin-drift governance is handled + by an AGENTS.md manual-sync note instead (see Decisions/governance below). + +**Pin-drift governance (resolved: AGENTS.md note).** The maintainer's semgrep +`1.167.0` and gitleaks `8.30.1` pins are **unguarded here**: the maintainer has +**no Renovate at all** (unlike downstream — verified: no root `renovate.json`), +and the generation drift test reads only the *rendered* downstream, never the +maintainer surfaces. Rather than add a parity test (that is really gap #4, +policy-tests — scope creep here), the new AGENTS.md section names the manual +sync obligation, mirroring the existing pre-commit "bump both `rev:` pins +together" precedent. + +## The configs (repo root, verbatim from the template) + +`.gitleaks.toml`: + +```toml +# gitleaks config — extend the default ruleset. +[extend] +useDefault = true +``` + +`.semgrep.yml`: + +```yaml +rules: + - id: no-eval + languages: [python] + severity: ERROR + message: Avoid eval(); it executes arbitrary code. + pattern: eval(...) +``` + +Both are plain files (not `.jinja`) and **unconditional** — there is no +`{% if enable_scanners %}` guard to reproduce at root. `.gitleaks.toml` is +auto-discovered at repo root (no `--config` flag is passed to gitleaks; renaming +or moving it silently reverts to gitleaks' embedded defaults). `.semgrep.yml` is +referenced by path in both the recipe and the CI step (`--config .semgrep.yml`). + +## Wiring + +### `mise.toml` + +Add one line under `[tools]`, byte-identical to `template/mise.toml.jinja:8` +(unconditional — this is a concrete file, not Jinja): + +```toml +gitleaks = "8.30.1" +``` + +This is the single source of truth the CI `scan` job installs gitleaks from via +`mise exec`. **Never** hardcode `8.30.1` or a `releases/download` URL in the +workflow. + +### `justfile` + +Add an out-of-band `scan` recipe. The two command lines and the inner comment +are byte-identical to `template/justfile.jinja:48-50`; a maintainer-style header +comment is added above `scan:` (the maintainer justfile puts `#` comments above +its recipes — a surface convention, not substance): + +```just +# Out-of-band secret + SAST scan (semgrep + gitleaks); enforced in CI by the `scan` job, not `ci`. +scan: + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. + gitleaks git . --redact --exit-code 1 +``` + +semgrep runs via `uvx` (not `uv run`), matching the template and the existing +zizmor invocation. `scan` is **not** added to any aggregate (there is no `ci` +recipe here; if gap #3's `just ci` later lands, scanners stay out of it, like +the template). + +### `.github/workflows/test-template.yml` + +Append a fifth job, `scan`, modeled on the existing `zizmor` job. Placement is +positional-only (all jobs run in parallel, no `needs`); appended after `lint` to +keep the diff a pure addition: + +```yaml + scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history so gitleaks can catch secrets committed then deleted. + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "0.11.23" + enable-cache: true + - name: semgrep + run: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # gitleaks is installed from the mise.toml pin (single source of truth); + # install: false brings up the mise CLI only. + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + install: false + - name: gitleaks + run: | + mise install gitleaks + mise exec -- gitleaks git . --redact --exit-code 1 +``` + +Load-bearing details, all verified against the audit + zizmor precedent: + +- **`jdx/mise-action@e6a8b39… # v4.2.0`** is a *new* action for this workflow + (which today uses only checkout / setup-uv / setup-just). It is SHA-pinned with + its exact-tag comment per NEVER-rule #2. `checkout@9c091bb… # v7.0.0` and + `setup-uv@fac544c… # v8.2.0` keep the SHA + comment byte-identical to every + other job. +- **No `GH_TOKEN` env** on the scan/gitleaks steps — gitleaks needs no GitHub + API (unlike zizmor). Do not copy zizmor's `env:` block blindly. +- **No `uv sync`** — uvx and mise are isolated, exactly like the zizmor job. +- The mise-action comment **drops the template's "kept fresh by Renovate's + native mise manager" clause** — the maintainer has no Renovate, so that + freshness story is template-only. +- **Do not touch the zizmor job or its `1.26.1` pin.** `test_generation.py` + asserts the maintainer `test-template.yml` zizmor version equals the rendered + `scan.yml` zizmor version (a set of size 1). The new `scan` job's `semgrep` + line contains no `zizmor` token, so it is safe; keep `zizmor` mentions in this + file to the single pinned run-line so the drift regex cannot inflate the set. + +### `AGENTS.md` + +Add a peer `## Scanning` H2 immediately after `## Pre-commit`. Unlike the +pre-commit section, it **can** use the "CI enforces it" refrain (the `scan` job +is a blocking gate). It documents the recipe, the configs + gitleaks mise pin, +the divergences from `scan.yml.jinja`, and the manual pin-sync obligation: + +````markdown +## Scanning + +```bash +just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full history) +``` + +`just scan` runs semgrep's `no-eval` rule over the tree (`.semgrep.yml`) and a +gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is +out-of-band (chained into no recipe), but CI enforces it: the `scan` job in +`.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned +in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` ++ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). The +semgrep rule is Python-only, so `tests/` is the effective target (`template/` is +Jinja; there is no `src/`); gitleaks scans the whole tree + history regardless of +language. Never pass semgrep `--config auto` (it drops the pinned rule and needs +metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). + +Deliberate divergences from the template's `scan.yml` +(`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning +into the existing `test-template.yml` as a sibling +`scan` job (the template consolidates into a standalone `scan.yml`), matching the +one-workflow / per-tool layout and letting the existing zizmor job audit it; +zizmor stays its own job here rather than a step in `scan` (already dogfooded +standalone). The CI `mise-action` comment drops the template's "kept fresh by +Renovate" note — **the maintainer has no Renovate**, so the pins are static. + +Because nothing here re-derives the pins (no Renovate; the generation drift test +reads only the *rendered* downstream), **bump every literal site by hand, against +the template.** gitleaks (`8.30.1`) has two maintainer sites — `mise.toml` and the +prose above — synced to `template/mise.toml.jinja` (CI installs via `mise exec`, +so there is no third gitleaks literal). semgrep (`1.167.0`) has three — the +`just scan` recipe, the `scan` job in `test-template.yml`, and the prose above — +synced to `template/justfile.jinja` and the template `scan.yml`. (Mirrors the +pre-commit "bump both `rev:` pins together" obligation.) +```` + +## Documented divergences (mirror-except-where-surface-differs) + +- **CI shape** — a `scan` *job* in `test-template.yml`, not a standalone + `scan.yml`. Surface: the maintainer's single-workflow / per-tool layout. +- **zizmor not merged into `scan`** — already dogfooded as its own job; the + template only folds it into `scan.yml` downstream. +- **mise-action comment drops the Renovate-freshness clause** — the maintainer + has no Renovate; the pin is static and manually synced (see the AGENTS.md + note). This is the audit critic's key correction. +- **`fetch-depth: 0` is per-job** on `scan` (split-job layout) rather than the + template's single conditional checkout; the `test` job already uses + `fetch-depth: 0` while zizmor/typecheck/lint stay shallow. +- **Unconditional root files** — no `{% if enable_scanners %}` guards to + reproduce (root files are not `.jinja`). +- **No copier toggle / no `copier.yml` change** — the dogfood mirrors what + ships; scanner versions are literals (mise pin / `uvx @X.Y.Z`), not copier + variables. + +## Acceptance + +Run the **history scan locally first** — it has never run on this repo, and a +real historical secret or false positive would turn the PR red. Install the pin, +then run both tools green before wiring CI: + +```bash +mise install gitleaks # realize the new mise.toml pin +just scan # semgrep (no-eval) + gitleaks full-history +``` + +Expected: semgrep `0 findings` / exit 0 (only `eval(` in `tests/` is a string +literal); gitleaks `no leaks found` / exit 0. + +Then confirm the tree is otherwise unchanged. No `test_generation.py` change is +owed (root files, not `template/`). **No CHANGELOG `[Unreleased]` entry and no +version tag** — this touches zero `template/` files, so it is a maintainer-harness +change, not a template release (per AGENTS.md's tag-only-on-`template/`-facing +releases discipline). + +## Out of scope / tracked follow-up + +- **pip-audit (gap #5)** — shares the template `scan.yml`; will add one + `pip-audit` step to the `scan` job created here plus a `just audit` recipe + (dropping `--no-dev`, since `package=false` makes the template's `--no-dev` + export vacuous). Separate PR. +- **Root-vs-template pin-parity test** — a real governance gap (semgrep/gitleaks + pins are unguarded here), but it belongs to gap #4 (policy-tests), which reads + the maintainer root and asserts config-literals cannot be silently weakened. + Deferred there; covered in the interim by the AGENTS.md manual-sync note. +- **Aggregate `just ci` (gap #3)** — if added later, keep `scan` off it (CI-only, + like the template). + +## Explicit no-touch (guard rails) + +- `copier.yml` — governs the generated template only; no scanner answers/defaults. +- `CHANGELOG.md` — no entry; this is not a template release (no tag either). +- `.pre-commit-config.yaml` — the template ships scanners CI-only (no scanner + pre-commit hook); do **not** add a gitleaks/semgrep hook. +- `pyproject.toml` — no dep (semgrep/gitleaks run via uvx/mise, like zizmor). +- The zizmor job / its `1.26.1` pin / the template `scan.yml` — untouched. + +## Endorsed decisions (verified in review — do not reopen) + +`.gitleaks.toml` / `.semgrep.yml` bytes match the template verbatim; SHA +`e6a8b39…` is authentic `jdx/mise-action` v4.2.0; the semgrep AST pattern does +not match the string-literal `eval(` in `test_generation.py`, so the scan is +green today; `fetch-depth: 0` is required for the history scan; gitleaks-via-mise +and semgrep-via-uvx match the template's run mechanisms; `scan` off any aggregate +mirrors the template; the maintainer shell activates mise so bare `gitleaks` +resolves locally; the mirror is ROOT-only, so no generation test is owed. From 266103100c7573459d13088b06564d8fe685d965 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:46:38 -0600 Subject: [PATCH 2/6] docs(plans): add dogfood scanners implementation plan --- .../plans/2026-07-01-dogfood-scanners.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-dogfood-scanners.md diff --git a/docs/superpowers/plans/2026-07-01-dogfood-scanners.md b/docs/superpowers/plans/2026-07-01-dogfood-scanners.md new file mode 100644 index 0000000..f38c413 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-dogfood-scanners.md @@ -0,0 +1,286 @@ +# Dogfood scanners (gitleaks + semgrep) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mirror the template's `enable_scanners` layer (gitleaks + semgrep) onto the maintainer repo at root, green from the first commit, closing gap #2 of the dogfooding-gap audit. + +**Architecture:** Five root files — two verbatim configs (`.gitleaks.toml`, `.semgrep.yml`), a `gitleaks` pin in `mise.toml`, an out-of-band `just scan` recipe, and a fifth `scan` job appended to the existing `.github/workflows/test-template.yml` (sibling to `zizmor`, not a separate `scan.yml`). Plus an `AGENTS.md` `## Scanning` section. No `template/` file is touched; no code, no dependency, no copier toggle. + +**Tech Stack:** gitleaks 8.30.1 (via mise), semgrep 1.167.0 (via uvx), GitHub Actions, just, mise, uv. + +**Design spec:** `docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md` (approved; adversarially reviewed). + +## Global Constraints + +Copied verbatim from the spec; every task's requirements implicitly include these. + +- **Exact pins (byte-identical to the template):** gitleaks `8.30.1`, semgrep `1.167.0`, setup-uv `0.11.23`. Action SHAs + exact-tag comments: `actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0`, `astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0`, `jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0`. +- **NEVER** bump/introduce an Action SHA without its exact-tag comment (AGENTS.md NEVER rule #2). +- **NEVER** add a file under `template/` (AGENTS.md NEVER rule #1). All new files are ROOT — this rule does not fire, but do not touch the tested `template/` scanner files. +- **Do not touch** the `zizmor` job or its `1.26.1` pin; keep `zizmor` mentions in `test-template.yml` to its single pinned run-line (the drift-test regex `_versions_near('zizmor', …)` collects every `X.Y.Z` on any line containing `zizmor`). +- **Hermetic semgrep flags** `--config .semgrep.yml --metrics=off --error` — never `--config auto`. +- **gitleaks in CI** installs via `mise exec` from the `mise.toml` pin — never hardcode the version or a `releases/download` URL in the workflow. +- **Commits:** Conventional Commits; GPG-signed; **NO** Co-Authored-By / AI-attribution trailer; author `Ashlen `. +- **No release:** touches zero `template/` files → **no `CHANGELOG.md` entry, no version tag.** +- **No-touch:** `copier.yml`, `CHANGELOG.md`, `.pre-commit-config.yaml`, `pyproject.toml`. +- **Branch:** `chore/dogfood-scanners` (already created; spec already committed there as `fe9724f`). +- **Full-suite validation** (finishing): `TMPDIR=/home/user/.cache/kickstarter-test-tmp just test` (the 4G tmpfs `/tmp` is too small); ensure no untracked nested git repo under `.claude/worktrees/` first (copier `vcs_ref=HEAD` render hard-fails otherwise). + +--- + +### Task 0: Commit the plan doc + +**Files:** +- Commit: `docs/superpowers/plans/2026-07-01-dogfood-scanners.md` + +The spec is already committed (`fe9724f`); commit this plan next so history reads spec → plan → feature work (matching the pre-commit precedent `0957475` → `a220409`). Inherits the Global Constraints (GPG-signed; NO AI trailer; author `Ashlen `). + +- [ ] **Step 1: Commit the plan** + +```bash +git add docs/superpowers/plans/2026-07-01-dogfood-scanners.md +git commit -m "docs(plans): add dogfood scanners implementation plan" +``` + +--- + +### Task 1: Scanner configs + mise pin + local `just scan` recipe + +**Files:** +- Create: `.gitleaks.toml` +- Create: `.semgrep.yml` +- Modify: `mise.toml` (add gitleaks pin under `[tools]`) +- Modify: `justfile` (append `scan` recipe) + +**Interfaces:** +- Produces: a working local `just scan` (semgrep no-eval over the tree + gitleaks full-history secret scan), and the `mise.toml` gitleaks pin the CI job (Task 2) installs from. + +- [ ] **Step 1: Create `.gitleaks.toml`** (verbatim from `template/{% if enable_scanners %}.gitleaks.toml{% endif %}.jinja`) + +```toml +# gitleaks config — extend the default ruleset. +[extend] +useDefault = true +``` + +- [ ] **Step 2: Create `.semgrep.yml`** (verbatim from the template) + +```yaml +rules: + - id: no-eval + languages: [python] + severity: ERROR + message: Avoid eval(); it executes arbitrary code. + pattern: eval(...) +``` + +- [ ] **Step 3: Add the gitleaks pin to `mise.toml`** + +Append under `[tools]` (after `copier = "9.15.2"`), byte-identical to `template/mise.toml.jinja:8`: + +```toml +gitleaks = "8.30.1" +``` + +Resulting `mise.toml`: + +```toml +[tools] +python = "3.13" +uv = "0.11.23" +just = "1.50.0" +copier = "9.15.2" +gitleaks = "8.30.1" +``` + +- [ ] **Step 4: Append the `scan` recipe to `justfile`** + +Add at end of file. The two command lines + inner comment are byte-identical to `template/justfile.jinja:48-50`; the header comment matches the maintainer's recipe-comment convention: + +```just +# Out-of-band secret + SAST scan (semgrep + gitleaks); enforced in CI by the `scan` job, not `ci`. +scan: + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. + gitleaks git . --redact --exit-code 1 +``` + +- [ ] **Step 5: Realize the mise pin** + +Run: `mise install gitleaks` +Expected: gitleaks 8.30.1 installed. Verify with `mise exec -- gitleaks version` (prints `8.30.1`). **Note:** `mise install` puts gitleaks on disk but does **not** add it to a non-interactive shell's PATH; a bare `gitleaks` resolves only in a mise-activated (interactive fish) shell, so verify and scan via `mise exec` below. + +- [ ] **Step 6: Run the scan locally to verify it is green** + +Run the scan in a mise-active way so the recipe's bare `gitleaks` resolves under the non-interactive executor shell: +Run: `mise exec -- just scan` +(Equivalently: `uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .` then `mise exec -- gitleaks git . --redact --exit-code 1`.) +Expected: semgrep prints `0 findings` (or no blocking findings) and exits 0 (the only `eval(` in `tests/` is a string literal at `tests/test_generation.py`, which the `eval(...)` Call pattern does not match); gitleaks prints `no leaks found` and exits 0. +**Failure taxonomy — classify before reacting:** +- `gitleaks: command not found` (exit 127) → PATH/activation issue, NOT a leak and NOT a network problem; re-run via `mise exec -- gitleaks …`. +- A real historical secret or a false-positive leak → **STOP and report**; do not suppress — this is exactly what the local-first run exists to catch. +- Network blocks `uvx`/`mise` downloads → note it, run whichever tool is reachable, defer the rest to the CI `scan` job (Task 2). Do not fake a pass. + +- [ ] **Step 7: Verify pre-commit hygiene on the new files** + +Run: `git add .gitleaks.toml .semgrep.yml mise.toml justfile && uv run pre-commit run --all-files` +Expected: all commit-stage hooks pass (the new YAML/TOML have final newlines and no trailing whitespace; `just`/`toml`/`yaml` are not python so ruff skips them). Stage only these four files — do **not** `git add -A` (the untracked spec/plan docs are committed separately). + +- [ ] **Step 8: Commit** + +```bash +git add .gitleaks.toml .semgrep.yml mise.toml justfile +git commit -m "feat(scan): dogfood gitleaks + semgrep configs, mise pin, just scan recipe" +``` + +--- + +### Task 2: CI `scan` job + +**Files:** +- Modify: `.github/workflows/test-template.yml` (append a 5th job) + +**Interfaces:** +- Consumes: the `mise.toml` gitleaks pin from Task 1 (installed via `mise exec`), and `.gitleaks.toml` / `.semgrep.yml`. +- Produces: a blocking PR gate mirroring the template's `scan.yml` steps, adapted to the maintainer's single-workflow layout. + +- [ ] **Step 1: Append the `scan` job** + +Add after the `lint` job (end of file), indented as a sibling under `jobs:`: + +```yaml + + scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history so gitleaks can catch secrets committed then deleted. + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "0.11.23" + enable-cache: true + - name: semgrep + run: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # gitleaks is installed from the mise.toml pin (single source of truth); + # install: false brings up the mise CLI only. + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + install: false + - name: gitleaks + run: | + mise install gitleaks + mise exec -- gitleaks git . --redact --exit-code 1 +``` + +- [ ] **Step 2: Verify the zizmor-version drift test still passes** + +Run: `uv run pytest tests/test_generation.py::test_tool_version_pins_have_no_drift -q` +Expected: PASS — the new `scan` job introduces a `semgrep` line but no `zizmor` token, so the zizmor version set stays `{1.26.1}` on both the maintainer file and the rendered `scan.yml`. (This test regex-scans the file as text; it does **not** parse the YAML — see Step 2b.) + +- [ ] **Step 2b: Verify the workflow still parses as YAML (network-free)** + +Run: `uv run python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('.github/workflows/test-template.yml').read_text())"` +Expected: exits 0 with no output — the appended `scan` job is well-formed YAML at the correct 2-space job indentation. A paste/indentation error fails here immediately, without needing the network (unlike the zizmor gate in Step 3). + +- [ ] **Step 3: Verify the new job is zizmor-clean (the same audit CI's existing zizmor job runs)** + +Run: `uvx zizmor@1.26.1 --persona=regular .` +Expected: PASS (no findings). The scan job is fully SHA-pinned, sets `persist-credentials: false`, has no injectable `${{ … }}` in `run:` blocks, and adds no elevated permissions. +**If network blocks `uvx`:** note it; the existing CI `zizmor` job will run this check on the PR. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/test-template.yml +git commit -m "ci(scan): add gitleaks + semgrep scan job to test-template workflow" +``` + +--- + +### Task 3: `AGENTS.md` `## Scanning` section + +**Files:** +- Modify: `AGENTS.md` (insert a new H2 after `## Pre-commit`, before `## Add a guardrail layer`) + +**Interfaces:** +- Consumes: nothing runtime; documents Tasks 1–2 and records the manual pin-sync obligation (the sole drift guard — there is no Renovate here). + +- [ ] **Step 1: Insert the `## Scanning` section** + +Immediately after the `## Pre-commit` section's last paragraph (the "bump both `rev:` pins together" line) and before `## Add a guardrail layer`, insert the following (match AGENTS.md's house style — **one unwrapped line per prose paragraph**, as the existing sections do; the hard-wrapping shown here is only for plan readability): + +````markdown +## Scanning + +```bash +just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full history) +``` + +`just scan` runs semgrep's `no-eval` rule over the tree (`.semgrep.yml`) and a +gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is +out-of-band (chained into no recipe), but CI enforces it: the `scan` job in +`.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned +in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` ++ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). The +semgrep rule is Python-only, so `tests/` is the effective target (`template/` is +Jinja; there is no `src/`); gitleaks scans the whole tree + history regardless of +language. Never pass semgrep `--config auto` (it drops the pinned rule and needs +metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). + +Deliberate divergences from the template's `scan.yml` +(`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning +into the existing `test-template.yml` as a sibling `scan` job (the template +consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool +layout and letting the existing zizmor job audit it; zizmor stays its own job +here rather than a step in `scan` (already dogfooded standalone). The CI +`mise-action` comment drops the template's "kept fresh by Renovate" note — **the +maintainer has no Renovate**, so the pins are static. + +Because nothing here re-derives the pins (no Renovate; the generation drift test +reads only the *rendered* downstream), **bump every literal site by hand, against +the template.** gitleaks (`8.30.1`) has two maintainer sites — `mise.toml` and the +prose above — synced to `template/mise.toml.jinja` (CI installs via `mise exec`, +so there is no third gitleaks literal). semgrep (`1.167.0`) has three — the +`just scan` recipe, the `scan` job in `test-template.yml`, and the prose above — +synced to `template/justfile.jinja` and the template `scan.yml`. (Mirrors the +pre-commit "bump both `rev:` pins together" obligation.) +```` + +- [ ] **Step 2: Verify pre-commit hygiene** + +Run: `git add AGENTS.md && uv run pre-commit run --all-files` +Expected: PASS (final newline present, no trailing whitespace; the embedded fenced ```` ```bash ```` block is fine — `AGENTS.md` is not under `template/`, so the eof/trailing-whitespace hooks apply and must be clean). + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md +git commit -m "docs(agents): document the scan gate and pin-sync obligation" +``` + +--- + +### Finishing + +- [ ] **Step 1: Run the full generation matrix** (unaffected by root-only changes, but confirm no regression) + +Ensure no untracked `.claude/worktrees/` nested repo exists, then: +Run: `TMPDIR=/home/user/.cache/kickstarter-test-tmp just test` +Expected: all tests pass (root scanner files are outside `template/`, so the matrix is untouched; this confirms the `test-template.yml` edit didn't break the drift test). + +- [ ] **Step 1b: Confirm the working tree is clean** + +Run: `git status --porcelain` +Expected: empty — all feature files committed (Tasks 1–3), the plan doc committed (Task 0), no stray/untracked files. Do not open the PR with anything unexpected dangling. + +- [ ] **Step 2: Finish the branch** — REQUIRED SUB-SKILL: superpowers:finishing-a-development-branch. Present push + PR options (operator-gated: pushing and opening the PR is the user's call). PR body via the pr-descriptions skill. + +## Self-Review + +- **Spec coverage:** configs (T1 s1–2), mise pin (T1 s3), recipe (T1 s4), local-green acceptance (T1 s5–6), CI job (T2), AGENTS.md incl. the enumerated pin-sync note (T3), no-tag/no-CHANGELOG (Global Constraints), no-touch guards (Global Constraints). All covered. +- **Placeholders:** none — every file's exact content is inline. +- **Type/name consistency:** SHAs, versions, and flags are identical across Global Constraints, T1, T2, and T3, and match the spec and the template ground truth. From 07d82ba92fcab7fb7a5758867d16180c467ccebe Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:55:04 -0600 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20correct=20semgrep=20coverage=20?= =?UTF-8?q?=E2=80=94=20scans=200=20files=20on=20maintainer=20(tests/=20def?= =?UTF-8?q?ault-ignored)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-01-dogfood-scanners.md | 15 +++-- .../2026-07-01-dogfood-scanners-design.md | 66 +++++++++++++------ 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/plans/2026-07-01-dogfood-scanners.md b/docs/superpowers/plans/2026-07-01-dogfood-scanners.md index f38c413..14e743a 100644 --- a/docs/superpowers/plans/2026-07-01-dogfood-scanners.md +++ b/docs/superpowers/plans/2026-07-01-dogfood-scanners.md @@ -115,7 +115,7 @@ Expected: gitleaks 8.30.1 installed. Verify with `mise exec -- gitleaks version` Run the scan in a mise-active way so the recipe's bare `gitleaks` resolves under the non-interactive executor shell: Run: `mise exec -- just scan` (Equivalently: `uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error .` then `mise exec -- gitleaks git . --redact --exit-code 1`.) -Expected: semgrep prints `0 findings` (or no blocking findings) and exits 0 (the only `eval(` in `tests/` is a string literal at `tests/test_generation.py`, which the `eval(...)` Call pattern does not match); gitleaks prints `no leaks found` and exits 0. +Expected: semgrep prints `Targets scanned: 0` / `0 findings` and exits 0 — `tests/` is excluded by semgrep's built-in `.semgrepignore` and there is no `src/`, so semgrep scans 0 files (expected, not a misconfiguration; it is a forward guard for any future non-test Python); gitleaks prints `161 commits scanned` / `no leaks found` and exits 0. **Failure taxonomy — classify before reacting:** - `gitleaks: command not found` (exit 127) → PATH/activation issue, NOT a leak and NOT a network problem; re-run via `mise exec -- gitleaks …`. - A real historical secret or a false-positive leak → **STOP and report**; do not suppress — this is exactly what the local-first run exists to catch. @@ -225,11 +225,14 @@ gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It i out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` -+ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). The -semgrep rule is Python-only, so `tests/` is the effective target (`template/` is -Jinja; there is no `src/`); gitleaks scans the whole tree + history regardless of -language. Never pass semgrep `--config auto` (it drops the pinned rule and needs -metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). ++ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). +semgrep scans non-test Python only — its built-in `.semgrepignore` excludes +`tests/`, and there is no `src/`, so on this repo it currently scans **0 files** +(it mirrors the shipped gate and fires if any non-test Python is added at root). +gitleaks scans the whole tree + full history regardless of language, and is the +substantive gate here. Never pass semgrep `--config auto` (it drops the pinned +rule and needs metrics on); never hardcode the gitleaks version in CI (install +via `mise exec`). Deliberate divergences from the template's `scan.yml` (`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning diff --git a/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md b/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md index 4749123..03593fd 100644 --- a/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md +++ b/docs/superpowers/specs/2026-07-01-dogfood-scanners-design.md @@ -10,11 +10,21 @@ audit (`docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md`) — the one substantive *security* gap: a secret committed (even then deleted) to the maintainer repo is currently uncaught by the very tool it ships. -This design was produced from a multi-agent audit and adversarially reviewed; -the corrections are folded into the wiring below. The scan starts green today — -the only `eval(` token in `tests/` is a string literal in -`test_generation.py` (a `Constant` node), which semgrep's `eval(...)` Call -pattern does not match. +This design was produced from a multi-agent audit and adversarially reviewed, +then corrected during execution by an empirical finding (below). Both scanners +run green today: gitleaks scanned all 161 commits with no leaks, and semgrep +exits clean. + +**Empirical finding — semgrep is currently inert on the maintainer.** semgrep's +built-in `.semgrepignore` excludes `tests/` (and `test/`), and there is no +`src/`, so semgrep scans **0 files** on the maintainer today (verified: +`Targets scanned: 0`). This is faithful to the template, which runs the identical +command against downstream **product code** (`src/PKG`) and ignores its `tests/` +too — the maintainer simply has no product Python. semgrep is therefore kept as a +byte-parity mirror and **forward guard**: it validates the shipped config on the +real repo and fires the moment any non-test Python is added at root. gitleaks is +the substantive gate here. (Alternative considered: drop semgrep as +not-applicable, like mutmut/coverage — see Out of scope.) **Scope:** gitleaks + semgrep only. pip-audit (gap #5) shares the template's `scan.yml` but is a separate gap and a separate PR; it will add one step to the @@ -45,10 +55,12 @@ recommended options and remain revisitable at spec review. `uvx semgrep@1.167.0` (no pyproject dep — the "uvx-run tools skip the dep" convention, matching zizmor). 5. **semgrep target: `.`** (verbatim mirror). The rule is `languages: [python]`, - so only `tests/*.py` is parsed regardless (`template/` Jinja and non-Python - trees are skipped); `.` preserves byte-parity and auto-covers any future - root-level Python. Hermetic flags `--config .semgrep.yml --metrics=off - --error` are non-negotiable — never `--config auto`. + and semgrep's built-in `.semgrepignore` excludes `tests/`; with no `src/`, the + maintainer has **0 scannable files today** (see the empirical finding in Goal). + `.` is kept for byte-parity with the template (which scans downstream `src/`) + and auto-covers any future non-test Python added at root. Hermetic flags + `--config .semgrep.yml --metrics=off --error` are non-negotiable — never + `--config auto`. 6. **`scan` stays out-of-band** — not chained into any recipe. The template's `ci` recipe omits scan ("scanners run in CI only"); the maintainer has no `ci` recipe at all, and the pre-commit dogfood likewise kept `precommit` off any @@ -208,11 +220,14 @@ gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It i out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` -+ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). The -semgrep rule is Python-only, so `tests/` is the effective target (`template/` is -Jinja; there is no `src/`); gitleaks scans the whole tree + history regardless of -language. Never pass semgrep `--config auto` (it drops the pinned rule and needs -metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). ++ `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). +semgrep scans non-test Python only — its built-in `.semgrepignore` excludes +`tests/`, and there is no `src/`, so on this repo it currently scans **0 files** +(it mirrors the shipped gate and fires if any non-test Python is added at root). +gitleaks scans the whole tree + full history regardless of language, and is the +substantive gate here. Never pass semgrep `--config auto` (it drops the pinned +rule and needs metrics on); never hardcode the gitleaks version in CI (install +via `mise exec`). Deliberate divergences from the template's `scan.yml` (`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning @@ -258,12 +273,14 @@ real historical secret or false positive would turn the PR red. Install the pin, then run both tools green before wiring CI: ```bash -mise install gitleaks # realize the new mise.toml pin -just scan # semgrep (no-eval) + gitleaks full-history +mise install gitleaks # realize the new mise.toml pin +mise exec -- just scan # semgrep (no-eval) + gitleaks full-history ``` -Expected: semgrep `0 findings` / exit 0 (only `eval(` in `tests/` is a string -literal); gitleaks `no leaks found` / exit 0. +Expected: semgrep `Targets scanned: 0` / `0 findings` / exit 0 (`tests/` is +semgrep-default-ignored and there is no `src/`); gitleaks `161 commits scanned` / +`no leaks found` / exit 0. Run via `mise exec` so the recipe's bare `gitleaks` +resolves in a non-mise-activated shell. Then confirm the tree is otherwise unchanged. No `test_generation.py` change is owed (root files, not `template/`). **No CHANGELOG `[Unreleased]` entry and no @@ -283,6 +300,12 @@ releases discipline). Deferred there; covered in the interim by the AGENTS.md manual-sync note. - **Aggregate `just ci` (gap #3)** — if added later, keep `scan` off it (CI-only, like the template). +- **Drop semgrep as not-applicable** — a defensible alternative: semgrep scans 0 + files here, the same reasoning the audit used to skip mutmut/coverage (no + product code). Kept instead as a byte-parity forward guard to preserve the + single `enable_scanners` mirror; revisit at PR review if an inert gate is + unwanted (trivial reversal: delete `.semgrep.yml`, the semgrep recipe line, and + the CI semgrep step). ## Explicit no-touch (guard rails) @@ -296,9 +319,10 @@ releases discipline). ## Endorsed decisions (verified in review — do not reopen) `.gitleaks.toml` / `.semgrep.yml` bytes match the template verbatim; SHA -`e6a8b39…` is authentic `jdx/mise-action` v4.2.0; the semgrep AST pattern does -not match the string-literal `eval(` in `test_generation.py`, so the scan is -green today; `fetch-depth: 0` is required for the history scan; gitleaks-via-mise +`e6a8b39…` is authentic `jdx/mise-action` v4.2.0; semgrep scans 0 files today +(`tests/` default-ignored, no `src/`) so it is green vacuously — kept as a +byte-parity forward guard; gitleaks scanned 161 commits clean; `fetch-depth: 0` +is required for the history scan; gitleaks-via-mise and semgrep-via-uvx match the template's run mechanisms; `scan` off any aggregate mirrors the template; the maintainer shell activates mise so bare `gitleaks` resolves locally; the mirror is ROOT-only, so no generation test is owed. From 00eed6f2e24a7b4cf77a735e713e79f4335ce94d Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:55:19 -0600 Subject: [PATCH 4/6] feat(scan): dogfood gitleaks + semgrep configs, mise pin, just scan recipe --- .gitleaks.toml | 3 +++ .semgrep.yml | 6 ++++++ justfile | 6 ++++++ mise.toml | 1 + 4 files changed, 16 insertions(+) create mode 100644 .gitleaks.toml create mode 100644 .semgrep.yml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f9fab4a --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,3 @@ +# gitleaks config — extend the default ruleset. +[extend] +useDefault = true diff --git a/.semgrep.yml b/.semgrep.yml new file mode 100644 index 0000000..15d233d --- /dev/null +++ b/.semgrep.yml @@ -0,0 +1,6 @@ +rules: + - id: no-eval + languages: [python] + severity: ERROR + message: Avoid eval(); it executes arbitrary code. + pattern: eval(...) diff --git a/justfile b/justfile index 084fbfa..d7f9e7e 100644 --- a/justfile +++ b/justfile @@ -35,3 +35,9 @@ setup: precommit: uv run pre-commit run --all-files uv run pre-commit run --all-files --hook-stage pre-push + +# Out-of-band secret + SAST scan (semgrep + gitleaks); enforced in CI by the `scan` job, not `ci`. +scan: + uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. + gitleaks git . --redact --exit-code 1 diff --git a/mise.toml b/mise.toml index 4131e20..926159f 100644 --- a/mise.toml +++ b/mise.toml @@ -3,3 +3,4 @@ python = "3.13" uv = "0.11.23" just = "1.50.0" copier = "9.15.2" +gitleaks = "8.30.1" From 2df628010ef2071577fe08fd2724c35ca34502ed Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:56:37 -0600 Subject: [PATCH 5/6] ci(scan): add gitleaks + semgrep scan job to test-template workflow --- .github/workflows/test-template.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index b7fec94..ddde591 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -86,3 +86,28 @@ jobs: run: uv run ruff check . - name: Format check (ruff format --check) run: uv run ruff format --check . + + scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history so gitleaks can catch secrets committed then deleted. + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: "0.11.23" + enable-cache: true + - name: semgrep + run: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . + # gitleaks is installed from the mise.toml pin (single source of truth); + # install: false brings up the mise CLI only. + - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 + with: + install: false + - name: gitleaks + run: | + mise install gitleaks + mise exec -- gitleaks git . --redact --exit-code 1 From 4db84fbb872077193160ebb176666e4af162decc Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 22:57:23 -0600 Subject: [PATCH 6/6] docs(agents): document the scan gate and pin-sync obligation --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c09051f..04f65fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,18 @@ This is a **local-only** gate: there is no pre-commit CI job, matching the templ Deliberate divergences from `template/.pre-commit-config.yaml.jinja`: ruff runs via local `uv run ruff` hooks (locked 0.15.19) instead of the `astral-sh/ruff-pre-commit` repo (pinned 0.15.18) — the venv is always synced here, so there is no bootstrap reason to keep the isolated-env repo hook; the pytest hook is dropped (the maintainer's only suite is the heavy generation matrix — CI-only). The two configs share the SHA-pinned `pre-commit-hooks` block: **bump both `rev:` pins together** (v6.0.0 = `3e8a8703…`). +## Scanning + +```bash +just scan # out-of-band secret + SAST scan: semgrep (no-eval) + gitleaks (full history) +``` + +`just scan` runs semgrep's `no-eval` rule and a gitleaks **full-history** secret scan (`.gitleaks.toml` = default ruleset). It is out-of-band (chained into no recipe), but CI enforces it: the `scan` job in `.github/workflows/test-template.yml` is a blocking PR gate. gitleaks is pinned in `mise.toml` (`gitleaks = "8.30.1"`) and installed in CI via `jdx/mise-action` + `mise exec`; semgrep runs via `uvx semgrep@1.167.0` (no dep, like zizmor). **semgrep scans non-test Python only** — its built-in `.semgrepignore` excludes `tests/`, and there is no `src/`, so on this repo it currently scans **0 files** (a forward guard that mirrors the shipped gate and fires the moment any non-test Python is added at root); gitleaks scans the whole tree + full history regardless of language and is the substantive gate here. Never pass semgrep `--config auto` (it drops the pinned rule and needs metrics on); never hardcode the gitleaks version in CI (install via `mise exec`). + +Deliberate divergences from the template's `scan.yml` (`template/.github/workflows/…scan.yml….jinja`): the maintainer folds scanning into the existing `test-template.yml` as a sibling `scan` job (the template consolidates into a standalone `scan.yml`), matching the one-workflow / per-tool layout and letting the existing zizmor job audit it; zizmor stays its own job here rather than a step in `scan` (already dogfooded standalone). The CI `mise-action` comment drops the template's "kept fresh by Renovate" note — **the maintainer has no Renovate**, so the pins are static. + +Because nothing here re-derives the pins (no Renovate; the generation drift test reads only the *rendered* downstream), **bump every literal site by hand, against the template.** gitleaks (`8.30.1`) has two maintainer sites — `mise.toml` and the prose above — synced to `template/mise.toml.jinja` (CI installs via `mise exec`, so there is no third gitleaks literal). semgrep (`1.167.0`) has three — the `just scan` recipe, the `scan` job in `test-template.yml`, and the prose above — synced to `template/justfile.jinja` and the template `scan.yml`. (Mirrors the pre-commit "bump both `rev:` pins together" obligation.) + ## Add a guardrail layer 1. Add an `enable_*` toggle to `copier.yml`.