[object Object]

← back to Cli Printing Press

feat(ci): mirror supply-chain hardening from printing-press-library (#1619)

f671b7ba9a572a2540f10931b2d794eebc16cb53 · 2026-05-18 01:26:36 -0700 · Trevin Chow

* feat(ci): mirror supply-chain hardening from printing-press-library

Adds a layered PR-time gate against the workflow-trust and Go-module env
attack shapes observed in May 2026 (TanStack mini-Shai-Hulud,
BufferZoneCorp, node-ipc).

Two layers, applied to PRs touching .github/workflows/**:

1. Deterministic Python scan in .github/scripts/verify-supply-chain/,
   vendored from mvanhorn/printing-press-library (source dated 2026-05-17).
   scan.py is verbatim; signals.py is adapted to the generator-repo signal
   set — R3 (replace directives in library/**/go.mod), R5 (npm lifecycle),
   and R6 (module-path drift) are omitted because they protect surfaces
   that don't exist here. R1 (pull_request_target + PR-head checkout),
   R2 (id-token outside allowlist; empty allowlist here), and R4
   (GOPROXY/GOFLAGS/GONOSUMCHECK overrides) apply with minor adaptation.
   15 unit + integration tests pass.

2. Four Greptile rules covering the same signals plus a judgment-only
   credential-path read rule for internal/**/*.go and cmd/**/*.go.

Companion PR in mvanhorn/printing-press-library:
https://github.com/mvanhorn/printing-press-library/pull/665

Canonical incident timeline and primary-source citations live in the
published-library repo's docs/solutions/security/2026-05-supply-chain-hardening.md
— single source of truth across both repos.

Informational on landing — promote to required check after a one-week
green window confirms no false-positive miscalibration on real PRs.

* fix(ci): close scanner self-bypass gap in supply-chain workflow

Mirrors the workflow fix from mvanhorn/printing-press-library#665.

Original posture: workflow checked out PR head, so the scan.py
executing the gate was whatever the PR shipped. A malicious PR
could weaken scan.py and bypass detection of its own payload.

New posture: check out the base branch from the base repo (the
trusted scanner version runs), fetch the PR head SHA as a
detached ref, and pass it to scan.py via --head-ref.

Forward-looking concern — this check is informational on
landing but matters once promoted to a required gate.

(R3 / block-form replace evasion does not apply here — the
generator-repo mirror omits R3 entirely; see signals.py header.)

* fix(ci): bootstrap fallback when base has no scanner yet

Mirrors the printing-press-library fix. The previous workflow rewrite
runs scan.py from the base branch, but this PR is what introduces
scan.py, so base has nothing to run. Adds an if-not-exists fallback
that restores .github/scripts/verify-supply-chain/ from the PR head
SHA. No-op after merge.

* fix(ci): R1 flow-sequence trigger + diff-aware R1/R2/R4

Mirrors the printing-press-library fix. Two gaps Greptile flagged
on PR #665 of the published-library repo apply equally here:

1. R1 trigger regex missed YAML flow-sequence form
   `on: [pull_request_target, push]`. Added a second regex
   (_PR_TARGET_TRIGGER_FLOW) and unified via _has_pr_target_trigger().

2. R1/R2/R4 scanned full head_content rather than diff additions.
   Refactored via a shared _lines_to_scan() helper: full scan for
   new files, added_lines only for existing files. Prevents false
   positives if main ever drifts to contain a pattern these rules
   cover.

19 tests pass (was 15).

* chore(ci): mirror scan.py signature change from printing-press-library

PR #665 added rename-aware diff handling to scan.py: changed_files()
returns old_path for renames/copies, and build_change() fetches
base_content from old_path on renames. The signature change is benign
in the generator-repo mirror (R3/R5/R6 are omitted here, so no signal
currently consumes the rename information), but kept in lock-step
with the source to make future cherry-picks of signal changes
mechanical.

Mirror commit only. Tests unchanged (19 pass).

* fix(ci): deletion-guard + id-token regex edge case (mirror)

Mirrors the printing-press-library fix:

1. Workflow gains a deletion-guard step that hard-fails any PR which
   removes files under .github/scripts/verify-supply-chain/. Closes the
   staged delete-then-replace attack against the bootstrap fallback.

2. _ID_TOKEN_WRITE regex now accepts an optional trailing YAML comment
   (`id-token: write  # justification`) so attackers can't evade the
   strict end-of-line match.

20 tests pass (was 19).

* docs(agents): trim supply-chain section to operational guidance

Mirror of the printing-press-library trim. AGENTS.md no longer narrates
the incident set or enumerates signals; greptile.json is authoritative
for rule coverage, and the published-library solutions doc carries the
incident timeline + primary sources.

* fix(ci): YAML-parse R1/R2/R4 to close block-scalar bypass

Greptile flagged on PR 1619 (4/5): a workflow using YAML block-scalar
notation evades the single-line regex.

  ref: >-
    \${{ github.event.pull_request.head.sha }}

Both the regex `_DANGEROUS_REF` and `_ID_TOKEN_WRITE` required the
sensitive value on the key's line; valid YAML with the value folded
onto the next line silently passes. Each new YAML quirk (block-form
replace, flow-sequence trigger, now block-scalar values) needed its
own regex patch — that's a brittle approach.

Switched R1/R2/R4 to parse the workflow with pyyaml (pre-installed on
ubuntu-latest runners) and walk the parsed dict tree:

- R1 walks jobs → steps → uses=actions/checkout* → with.ref, and
  matches the loaded ref value against a (still-regex) pattern for
  dangerous expressions. YAML normalises block-scalars before the
  match runs, so all forms collapse to the same string.
- R2 walks workflow-level and job-level `permissions.id-token`. Also
  catches `permissions: write-all` which grants id-token implicitly.
- R4 walks env blocks at workflow, job, and step level.

Diff-awareness is now STRUCTURAL: parse both base and head, fire only
if head has the dangerous pattern AND base didn't. Replaces the
line-level _lines_to_scan approach for these signals — more robust
because reformatting (reindent, change YAML style) of an unchanged
dangerous pattern no longer false-fires.

Two new tests cover the folded (`>-`) and literal (`|-`) block-scalar
ref forms. The refs/pull regex also fixed to use [^\\n] instead of
[^\\s] so spaces inside \${{ ... }} don't break the match.

22 tests pass (was 19).

* fix(ci): R1 catches github.head_ref + merge_commit_sha shortcuts

Greptile P1: the dangerous-ref pattern missed github.head_ref (shorthand
alias for event.pull_request.head.ref) and event.pull_request.merge_commit_sha
(GitHub's synthesised test-merge commit). Both resolve to PR-author-controlled
content under pull_request_target — same elevated-context attack surface as
the previously-covered forms.

Same fix mirrored in mvanhorn/printing-press-library#667.

24 tests pass (was 22).

* chore(ci): gitignore __pycache__ and untrack stray .pyc files

verify-supply-chain Python tests were generating .pyc files that got committed because .gitignore had no Python rules.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit f671b7ba9a572a2540f10931b2d794eebc16cb53
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 18 01:26:36 2026 -0700

    feat(ci): mirror supply-chain hardening from printing-press-library (#1619)
    
    * feat(ci): mirror supply-chain hardening from printing-press-library
    
    Adds a layered PR-time gate against the workflow-trust and Go-module env
    attack shapes observed in May 2026 (TanStack mini-Shai-Hulud,
    BufferZoneCorp, node-ipc).
    
    Two layers, applied to PRs touching .github/workflows/**:
    
    1. Deterministic Python scan in .github/scripts/verify-supply-chain/,
       vendored from mvanhorn/printing-press-library (source dated 2026-05-17).
       scan.py is verbatim; signals.py is adapted to the generator-repo signal
       set — R3 (replace directives in library/**/go.mod), R5 (npm lifecycle),
       and R6 (module-path drift) are omitted because they protect surfaces
       that don't exist here. R1 (pull_request_target + PR-head checkout),
       R2 (id-token outside allowlist; empty allowlist here), and R4
       (GOPROXY/GOFLAGS/GONOSUMCHECK overrides) apply with minor adaptation.
       15 unit + integration tests pass.
    
    2. Four Greptile rules covering the same signals plus a judgment-only
       credential-path read rule for internal/**/*.go and cmd/**/*.go.
    
    Companion PR in mvanhorn/printing-press-library:
    https://github.com/mvanhorn/printing-press-library/pull/665
    
    Canonical incident timeline and primary-source citations live in the
    published-library repo's docs/solutions/security/2026-05-supply-chain-hardening.md
    — single source of truth across both repos.
    
    Informational on landing — promote to required check after a one-week
    green window confirms no false-positive miscalibration on real PRs.
    
    * fix(ci): close scanner self-bypass gap in supply-chain workflow
    
    Mirrors the workflow fix from mvanhorn/printing-press-library#665.
    
    Original posture: workflow checked out PR head, so the scan.py
    executing the gate was whatever the PR shipped. A malicious PR
    could weaken scan.py and bypass detection of its own payload.
    
    New posture: check out the base branch from the base repo (the
    trusted scanner version runs), fetch the PR head SHA as a
    detached ref, and pass it to scan.py via --head-ref.
    
    Forward-looking concern — this check is informational on
    landing but matters once promoted to a required gate.
    
    (R3 / block-form replace evasion does not apply here — the
    generator-repo mirror omits R3 entirely; see signals.py header.)
    
    * fix(ci): bootstrap fallback when base has no scanner yet
    
    Mirrors the printing-press-library fix. The previous workflow rewrite
    runs scan.py from the base branch, but this PR is what introduces
    scan.py, so base has nothing to run. Adds an if-not-exists fallback
    that restores .github/scripts/verify-supply-chain/ from the PR head
    SHA. No-op after merge.
    
    * fix(ci): R1 flow-sequence trigger + diff-aware R1/R2/R4
    
    Mirrors the printing-press-library fix. Two gaps Greptile flagged
    on PR #665 of the published-library repo apply equally here:
    
    1. R1 trigger regex missed YAML flow-sequence form
       `on: [pull_request_target, push]`. Added a second regex
       (_PR_TARGET_TRIGGER_FLOW) and unified via _has_pr_target_trigger().
    
    2. R1/R2/R4 scanned full head_content rather than diff additions.
       Refactored via a shared _lines_to_scan() helper: full scan for
       new files, added_lines only for existing files. Prevents false
       positives if main ever drifts to contain a pattern these rules
       cover.
    
    19 tests pass (was 15).
    
    * chore(ci): mirror scan.py signature change from printing-press-library
    
    PR #665 added rename-aware diff handling to scan.py: changed_files()
    returns old_path for renames/copies, and build_change() fetches
    base_content from old_path on renames. The signature change is benign
    in the generator-repo mirror (R3/R5/R6 are omitted here, so no signal
    currently consumes the rename information), but kept in lock-step
    with the source to make future cherry-picks of signal changes
    mechanical.
    
    Mirror commit only. Tests unchanged (19 pass).
    
    * fix(ci): deletion-guard + id-token regex edge case (mirror)
    
    Mirrors the printing-press-library fix:
    
    1. Workflow gains a deletion-guard step that hard-fails any PR which
       removes files under .github/scripts/verify-supply-chain/. Closes the
       staged delete-then-replace attack against the bootstrap fallback.
    
    2. _ID_TOKEN_WRITE regex now accepts an optional trailing YAML comment
       (`id-token: write  # justification`) so attackers can't evade the
       strict end-of-line match.
    
    20 tests pass (was 19).
    
    * docs(agents): trim supply-chain section to operational guidance
    
    Mirror of the printing-press-library trim. AGENTS.md no longer narrates
    the incident set or enumerates signals; greptile.json is authoritative
    for rule coverage, and the published-library solutions doc carries the
    incident timeline + primary sources.
    
    * fix(ci): YAML-parse R1/R2/R4 to close block-scalar bypass
    
    Greptile flagged on PR 1619 (4/5): a workflow using YAML block-scalar
    notation evades the single-line regex.
    
      ref: >-
        \${{ github.event.pull_request.head.sha }}
    
    Both the regex `_DANGEROUS_REF` and `_ID_TOKEN_WRITE` required the
    sensitive value on the key's line; valid YAML with the value folded
    onto the next line silently passes. Each new YAML quirk (block-form
    replace, flow-sequence trigger, now block-scalar values) needed its
    own regex patch — that's a brittle approach.
    
    Switched R1/R2/R4 to parse the workflow with pyyaml (pre-installed on
    ubuntu-latest runners) and walk the parsed dict tree:
    
    - R1 walks jobs → steps → uses=actions/checkout* → with.ref, and
      matches the loaded ref value against a (still-regex) pattern for
      dangerous expressions. YAML normalises block-scalars before the
      match runs, so all forms collapse to the same string.
    - R2 walks workflow-level and job-level `permissions.id-token`. Also
      catches `permissions: write-all` which grants id-token implicitly.
    - R4 walks env blocks at workflow, job, and step level.
    
    Diff-awareness is now STRUCTURAL: parse both base and head, fire only
    if head has the dangerous pattern AND base didn't. Replaces the
    line-level _lines_to_scan approach for these signals — more robust
    because reformatting (reindent, change YAML style) of an unchanged
    dangerous pattern no longer false-fires.
    
    Two new tests cover the folded (`>-`) and literal (`|-`) block-scalar
    ref forms. The refs/pull regex also fixed to use [^\\n] instead of
    [^\\s] so spaces inside \${{ ... }} don't break the match.
    
    22 tests pass (was 19).
    
    * fix(ci): R1 catches github.head_ref + merge_commit_sha shortcuts
    
    Greptile P1: the dangerous-ref pattern missed github.head_ref (shorthand
    alias for event.pull_request.head.ref) and event.pull_request.merge_commit_sha
    (GitHub's synthesised test-merge commit). Both resolve to PR-author-controlled
    content under pull_request_target — same elevated-context attack surface as
    the previously-covered forms.
    
    Same fix mirrored in mvanhorn/printing-press-library#667.
    
    24 tests pass (was 22).
    
    * chore(ci): gitignore __pycache__ and untrack stray .pyc files
    
    verify-supply-chain Python tests were generating .pyc files that got committed because .gitignore had no Python rules.
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 .github/scripts/verify-supply-chain/scan.py      | 218 ++++++++++++
 .github/scripts/verify-supply-chain/scan_test.py | 326 +++++++++++++++++
 .github/scripts/verify-supply-chain/signals.py   | 425 +++++++++++++++++++++++
 .github/workflows/verify-supply-chain.yml        |  85 +++++
 .gitignore                                       |   4 +
 AGENTS.md                                        |   6 +
 greptile.json                                    |  22 +-
 7 files changed, 1085 insertions(+), 1 deletion(-)

diff --git a/.github/scripts/verify-supply-chain/scan.py b/.github/scripts/verify-supply-chain/scan.py
new file mode 100644
index 00000000..0b39b749
--- /dev/null
+++ b/.github/scripts/verify-supply-chain/scan.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""PR-time supply-chain scan for cli-printing-press (generator repo).
+
+This file is vendored from mvanhorn/printing-press-library's
+.github/scripts/verify-supply-chain/scan.py (source dated 2026-05-17).
+The orchestration logic (diff walking, git plumbing, annotation
+emission, exit codes) is general-purpose and copied verbatim. Only
+is_scoped() is adapted to the smaller signal set used here — see
+signals.py for the scope rationale.
+
+Drift between the two repos' scan.py is surfaced by hand on PR review.
+A parity script is deferred (see the published-library plan's "Deferred
+to follow-up work" section).
+
+Exit code semantics:
+  0 — no block-severity findings.
+  1 — at least one block-severity finding.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path, PurePosixPath
+
+import signals
+
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+
+# ---------------------------------------------------------------------------
+# Git plumbing
+# ---------------------------------------------------------------------------
+
+
+def run_git(args: list[str]) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(
+        ["git", *args],
+        cwd=REPO_ROOT,
+        check=False,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+
+
+def git_show(ref: str, path: str) -> str | None:
+    result = run_git(["show", f"{ref}:{path}"])
+    return result.stdout if result.returncode == 0 else None
+
+
+def changed_files(base_ref: str, head_ref: str) -> list[tuple[str, str, str | None]]:
+    """Return [(status, path, old_path)] for files touched between base and head.
+    old_path is non-None for renames/copies; signals can use it to fetch the
+    file's pre-rename content from base.
+    """
+    result = run_git(["diff", "--name-status", "-z", f"{base_ref}...{head_ref}"])
+    if result.returncode != 0:
+        print(result.stderr, file=sys.stderr)
+        raise SystemExit(result.returncode)
+
+    fields = result.stdout.split("\0")
+    if fields and fields[-1] == "":
+        fields.pop()
+
+    entries: list[tuple[str, str, str | None]] = []
+    i = 0
+    while i < len(fields):
+        status = fields[i]
+        i += 1
+        if not status:
+            continue
+        if status.startswith(("R", "C")):
+            old_path = fields[i]
+            new_path = fields[i + 1]
+            i += 2
+            entries.append((status[0], new_path, old_path))
+        else:
+            path = fields[i]
+            i += 1
+            entries.append((status, path, None))
+    return entries
+
+
+def added_lines(base_ref: str, head_ref: str, path: str) -> list[tuple[int, str]]:
+    result = run_git(["diff", "--unified=0", "--no-color", f"{base_ref}...{head_ref}", "--", path])
+    if result.returncode != 0:
+        return []
+
+    added: list[tuple[int, str]] = []
+    head_line = 0
+    for raw in result.stdout.splitlines():
+        if raw.startswith("@@"):
+            try:
+                plus = raw.split(" ")[2]
+                head_line = int(plus[1:].split(",")[0])
+            except (IndexError, ValueError):
+                head_line = 0
+            continue
+        if raw.startswith("+++") or raw.startswith("---"):
+            continue
+        if raw.startswith("+"):
+            added.append((head_line, raw[1:]))
+            head_line += 1
+        elif raw.startswith(" "):
+            head_line += 1
+    return added
+
+
+# ---------------------------------------------------------------------------
+# Path scoping — adapted for the generator-repo signal set
+# ---------------------------------------------------------------------------
+
+
+def is_scoped(path: str) -> bool:
+    parts = PurePosixPath(path).parts
+    if len(parts) >= 3 and parts[0] == ".github" and parts[1] == "workflows":
+        return path.endswith((".yml", ".yaml"))
+    return False
+
+
+# ---------------------------------------------------------------------------
+# Annotation emission
+# ---------------------------------------------------------------------------
+
+
+def annotation_escape(value: str) -> str:
+    return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+
+
+def emit_annotation(f: signals.Finding) -> None:
+    kind = "error" if f.is_block() else "notice"
+    pieces = [f"file={f.path}"]
+    if f.line is not None:
+        pieces.append(f"line={f.line}")
+    pieces.append(f"title=supply-chain:{f.signal_id}")
+    head = ",".join(pieces)
+    body = annotation_escape(f"{f.message} | Fix: {f.remediation}")
+    print(f"::{kind} {head}::{body}")
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+
+def build_change(
+    base_ref: str, head_ref: str, status: str, path: str, old_path: str | None
+) -> signals.FileChange:
+    base_lookup_path = old_path if old_path else path
+    base_content = None if status == "A" else git_show(base_ref, base_lookup_path)
+    head_content = None if status == "D" else git_show(head_ref, path)
+    diff_added = added_lines(base_ref, head_ref, path) if head_content is not None else []
+    return signals.FileChange(
+        path=path,
+        base_content=base_content,
+        head_content=head_content,
+        added_lines=diff_added,
+    )
+
+
+def scan(base_ref: str, head_ref: str, strict: bool) -> list[signals.Finding]:
+    findings: list[signals.Finding] = []
+    for status, path, old_path in changed_files(base_ref, head_ref):
+        if not is_scoped(path):
+            continue
+        change = build_change(base_ref, head_ref, status, path, old_path)
+        for finding in signals.run_signals(change):
+            if strict and finding.severity == "advise":
+                finding = signals.Finding(
+                    path=finding.path,
+                    line=finding.line,
+                    severity="block",
+                    signal_id=finding.signal_id + ".strict",
+                    message=finding.message + " [strict mode: promoted to block]",
+                    remediation=finding.remediation,
+                )
+            findings.append(finding)
+    return findings
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--base-ref", required=True, help="git ref for the diff base")
+    parser.add_argument("--head-ref", default="HEAD", help="git ref for the diff head")
+    parser.add_argument(
+        "--strict",
+        action="store_true",
+        help="promote advisory findings to block-severity (off by default)",
+    )
+    args = parser.parse_args(argv)
+
+    findings = scan(args.base_ref, args.head_ref, args.strict)
+
+    block_count = 0
+    for f in findings:
+        emit_annotation(f)
+        if f.is_block():
+            block_count += 1
+
+    if block_count:
+        print(
+            f"::error::supply-chain scan: {block_count} block-severity finding(s); see annotations above."
+        )
+        return 1
+
+    advisory_count = sum(1 for f in findings if not f.is_block())
+    if advisory_count:
+        print(f"::notice::supply-chain scan: {advisory_count} advisory finding(s); see annotations.")
+    else:
+        print("supply-chain scan: no findings.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/.github/scripts/verify-supply-chain/scan_test.py b/.github/scripts/verify-supply-chain/scan_test.py
new file mode 100644
index 00000000..d19ea7d4
--- /dev/null
+++ b/.github/scripts/verify-supply-chain/scan_test.py
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""Unit tests for the supply-chain scan (cli-printing-press mirror).
+
+Vendored from mvanhorn/printing-press-library's scan_test.py (2026-05-17)
+with the test cases for omitted signals (R3 replace, R5 npm, R6 module
+path) stripped out. Tests for R1, R2, R4, and integration coverage are
+retained.
+
+Run from this directory:
+    python3 -m unittest scan_test
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+import scan
+import signals
+
+
+def _fc(
+    path: str,
+    *,
+    base: str | None = None,
+    head: str | None = None,
+    added: list[tuple[int, str]] | None = None,
+) -> signals.FileChange:
+    return signals.FileChange(
+        path=path,
+        base_content=base,
+        head_content=head,
+        added_lines=added or [],
+    )
+
+
+# ---------------------------------------------------------------------------
+# Signal-level unit tests
+# ---------------------------------------------------------------------------
+
+
+class WorkflowTrustSignalTest(unittest.TestCase):
+    def test_pull_request_target_with_head_sha_ref_blocks(self) -> None:
+        wf = textwrap.dedent(
+            """
+            name: bad
+            on:
+              pull_request_target:
+            jobs:
+              x:
+                runs-on: ubuntu-latest
+                steps:
+                  - uses: actions/checkout@v4
+                    with:
+                      ref: ${{ github.event.pull_request.head.sha }}
+            """
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_pull_request_target_with_refs_pull_merge_blocks(self) -> None:
+        wf = "on: pull_request_target\njobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: refs/pull/${{ github.event.number }}/merge\n"
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+
+    def test_safe_pull_request_target_no_checkout_does_not_block(self) -> None:
+        """The existing conversation-resolution-check.yml posture: pull_request_target,
+        no PR-head checkout, only API calls. Must NOT trigger."""
+        wf = textwrap.dedent(
+            """
+            on:
+              pull_request_target:
+            permissions:
+              contents: read
+              pull-requests: read
+            jobs:
+              gate:
+                runs-on: ubuntu-latest
+                steps:
+                  - run: gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}
+            """
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/policy.yml", head=wf))
+        self.assertEqual(findings, [])
+
+    def test_plain_pull_request_with_head_ref_does_not_block(self) -> None:
+        wf = "on: pull_request\njobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n"
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/ci.yml", head=wf))
+        self.assertEqual(findings, [])
+
+    def test_flow_sequence_trigger_blocks(self) -> None:
+        """Compact YAML flow-sequence trigger form `on: [pull_request_target, push]`."""
+        wf = "on: [pull_request_target, push]\njobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n"
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_preexisting_dangerous_ref_unchanged_does_not_fire(self) -> None:
+        """Diff-awareness: pre-existing pattern on base unchanged → no findings."""
+        wf = "on:\n  pull_request_target:\njobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n"
+        change = _fc(".github/workflows/legacy.yml", base=wf, head=wf, added=[])
+        findings = signals.signal_workflow_trust(change)
+        self.assertEqual(findings, [])
+
+    def test_block_scalar_folded_ref_blocks(self) -> None:
+        """Greptile-flagged bypass: YAML folded block-scalar `ref: >-` with
+        the dangerous expression on the next line evades single-line regex
+        but is semantically identical. Structural YAML parsing catches it."""
+        wf = (
+            "on:\n  pull_request_target:\n"
+            "jobs:\n  x:\n    steps:\n"
+            "      - uses: actions/checkout@v4\n"
+            "        with:\n"
+            "          ref: >-\n"
+            "            ${{ github.event.pull_request.head.sha }}\n"
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_block_scalar_literal_ref_blocks(self) -> None:
+        """Literal block-scalar form `|-` same as folded — must also block."""
+        wf = (
+            "on:\n  pull_request_target:\n"
+            "jobs:\n  x:\n    steps:\n"
+            "      - uses: actions/checkout@v4\n"
+            "        with:\n"
+            "          ref: |-\n"
+            "            refs/pull/123/merge\n"
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+
+    def test_github_head_ref_shorthand_blocks(self) -> None:
+        """Greptile-flagged: github.head_ref is the shorthand alias for
+        event.pull_request.head.ref — must block."""
+        wf = (
+            "on:\n  pull_request_target:\n"
+            "jobs:\n  x:\n    steps:\n"
+            "      - uses: actions/checkout@v4\n"
+            "        with:\n"
+            "          ref: ${{ github.head_ref }}\n"
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_merge_commit_sha_blocks(self) -> None:
+        """github.event.pull_request.merge_commit_sha = GitHub's test-merge
+        commit; contains PR-author code merged with base."""
+        wf = (
+            "on:\n  pull_request_target:\n"
+            "jobs:\n  x:\n    steps:\n"
+            "      - uses: actions/checkout@v4\n"
+            "        with:\n"
+            "          ref: ${{ github.event.pull_request.merge_commit_sha }}\n"
+        )
+        findings = signals.signal_workflow_trust(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+
+
+class IdTokenSignalTest(unittest.TestCase):
+    def test_id_token_in_any_workflow_blocks(self) -> None:
+        """In the generator repo the allowlist is empty — id-token: write
+        anywhere should block."""
+        wf = "permissions:\n  id-token: write\n  contents: read\n"
+        findings = signals.signal_id_token_outside_allowlist(
+            _fc(".github/workflows/release.yml", head=wf)
+        )
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_no_id_token_does_not_block(self) -> None:
+        wf = "permissions:\n  contents: read\n"
+        findings = signals.signal_id_token_outside_allowlist(
+            _fc(".github/workflows/anything.yml", head=wf)
+        )
+        self.assertEqual(findings, [])
+
+    def test_preexisting_id_token_unchanged_does_not_fire(self) -> None:
+        """Diff-aware: a pre-existing id-token grant shouldn't be re-flagged
+        when the diff doesn't touch it."""
+        wf = "permissions:\n  id-token: write\n  contents: read\n"
+        change = _fc(".github/workflows/legacy.yml", base=wf, head=wf, added=[])
+        findings = signals.signal_id_token_outside_allowlist(change)
+        self.assertEqual(findings, [])
+
+    def test_id_token_with_trailing_comment_blocks(self) -> None:
+        """Trailing comment must not evade the match."""
+        wf = "permissions:\n  id-token: write  # justification\n"
+        findings = signals.signal_id_token_outside_allowlist(
+            _fc(".github/workflows/sneaky.yml", head=wf)
+        )
+        self.assertEqual(len(findings), 1)
+
+
+class GoEnvOverrideSignalTest(unittest.TestCase):
+    def test_goproxy_blocks(self) -> None:
+        wf = "jobs:\n  x:\n    env:\n      GOPROXY: https://mirror.attacker.example\n"
+        findings = signals.signal_go_env_override(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+        self.assertTrue(findings[0].is_block())
+
+    def test_goflags_blocks(self) -> None:
+        wf = "env:\n  GOFLAGS: -insecure\n"
+        findings = signals.signal_go_env_override(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+
+    def test_gonosumcheck_blocks(self) -> None:
+        wf = "env:\n  GONOSUMCHECK: '*'\n"
+        findings = signals.signal_go_env_override(_fc(".github/workflows/bad.yml", head=wf))
+        self.assertEqual(len(findings), 1)
+
+    def test_unrelated_env_does_not_fire(self) -> None:
+        wf = "env:\n  GO_VERSION: 1.22\n  CGO_ENABLED: 0\n"
+        findings = signals.signal_go_env_override(_fc(".github/workflows/ok.yml", head=wf))
+        self.assertEqual(findings, [])
+
+    def test_preexisting_goproxy_unchanged_does_not_fire(self) -> None:
+        """Diff-aware: pre-existing GOPROXY on base shouldn't re-fire."""
+        wf = "env:\n  GOPROXY: https://corp.example/\n"
+        change = _fc(".github/workflows/legacy.yml", base=wf, head=wf, added=[])
+        findings = signals.signal_go_env_override(change)
+        self.assertEqual(findings, [])
+
+
+# ---------------------------------------------------------------------------
+# Integration test
+# ---------------------------------------------------------------------------
+
+
+class ScanIntegrationTest(unittest.TestCase):
+    def setUp(self) -> None:
+        self.tmp = Path(tempfile.mkdtemp(prefix="verify-supply-chain-"))
+        self.addCleanup(lambda: shutil.rmtree(self.tmp))
+        self.old_root = scan.REPO_ROOT
+        scan.REPO_ROOT = self.tmp
+        self._git("init", "-q", "-b", "main")
+        self._git("config", "user.email", "test@example.com")
+        self._git("config", "user.name", "Test")
+        self._git("commit", "--allow-empty", "-q", "-m", "init")
+
+    def tearDown(self) -> None:
+        scan.REPO_ROOT = self.old_root
+
+    def _git(self, *args: str) -> subprocess.CompletedProcess[str]:
+        return subprocess.run(
+            ["git", *args],
+            cwd=self.tmp,
+            check=True,
+            text=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+        )
+
+    def _write(self, rel: str, content: str) -> None:
+        p = self.tmp / rel
+        p.parent.mkdir(parents=True, exist_ok=True)
+        p.write_text(content)
+
+    def _commit(self, message: str) -> None:
+        self._git("add", "-A")
+        self._git("commit", "-q", "--allow-empty", "-m", message)
+
+    def _run_scan(self, base: str = "main") -> int:
+        return scan.main(["--base-ref", base])
+
+    def test_clean_pr_no_findings(self) -> None:
+        self._write("internal/generator/foo.go", "package generator\n")
+        self._commit("baseline")
+        self._git("checkout", "-q", "-b", "feat/x")
+        self._write("internal/generator/foo.go", "package generator\n// edit\n")
+        self._commit("tweak")
+        self.assertEqual(self._run_scan(), 0)
+
+    def test_pr_target_with_head_checkout_fails(self) -> None:
+        self._write(".github/workflows/existing.yml", "on: push\n")
+        self._commit("baseline")
+        self._git("checkout", "-q", "-b", "feat/x")
+        self._write(
+            ".github/workflows/new.yml",
+            "on:\n  pull_request_target:\njobs:\n  x:\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n",
+        )
+        self._commit("add bad workflow")
+        self.assertEqual(self._run_scan(), 1)
+
+    def test_id_token_in_any_workflow_fails(self) -> None:
+        """Generator repo allowlist is empty — id-token: write in ANY workflow blocks."""
+        self._write(".github/workflows/release.yml", "on: push\n")
+        self._commit("baseline")
+        self._git("checkout", "-q", "-b", "feat/x")
+        self._write(
+            ".github/workflows/release.yml",
+            "on: push\npermissions:\n  id-token: write\n",
+        )
+        self._commit("grant id-token in release")
+        self.assertEqual(self._run_scan(), 1)
+
+    def test_goproxy_in_workflow_env_fails(self) -> None:
+        self._write(".github/workflows/baseline.yml", "on: push\n")
+        self._commit("baseline")
+        self._git("checkout", "-q", "-b", "feat/x")
+        self._write(
+            ".github/workflows/baseline.yml",
+            "on: push\njobs:\n  x:\n    env:\n      GOPROXY: https://mirror.attacker.example\n",
+        )
+        self._commit("redirect GOPROXY")
+        self.assertEqual(self._run_scan(), 1)
+
+    def test_unrelated_changes_pass(self) -> None:
+        """Touching Go source under internal/ but not any workflow → no findings."""
+        self._write("internal/cli/foo.go", "package cli\n")
+        self._commit("baseline")
+        self._git("checkout", "-q", "-b", "feat/x")
+        self._write("internal/cli/foo.go", "package cli\nfunc Bar() {}\n")
+        self._commit("add func")
+        self.assertEqual(self._run_scan(), 0)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.github/scripts/verify-supply-chain/signals.py b/.github/scripts/verify-supply-chain/signals.py
new file mode 100644
index 00000000..b44ce156
--- /dev/null
+++ b/.github/scripts/verify-supply-chain/signals.py
@@ -0,0 +1,425 @@
+"""Signal catalog for the supply-chain scan (cli-printing-press mirror).
+
+This file is vendored from mvanhorn/printing-press-library's
+.github/scripts/verify-supply-chain/signals.py with scope adaptations for
+the generator repo:
+
+  - R1 (pull_request_target + PR-head checkout) — applied unchanged.
+  - R2 (id-token: write outside allowlist) — applied with an empty
+    allowlist. The generator repo does not currently use OIDC anywhere;
+    any addition trips the rule. If release.yml migrates to keyless
+    cosign with OIDC, allowlist that specific workflow at that time.
+  - R3 (replace directives in library/**/go.mod) — omitted.
+  - R4 (GOPROXY/GOFLAGS/GONOSUMCHECK in workflows) — applied unchanged.
+  - R5 (npm lifecycle scripts) — omitted.
+  - R6 (module-path drift) — omitted.
+
+Detection strategy for R1/R2/R4: parse the YAML structurally with pyyaml
+(pre-installed on ubuntu-latest runners) and walk the parsed dict tree.
+The earlier regex approach missed valid YAML forms (block-scalar values
+`ref: >-` with the value on the next line, etc.) and required a separate
+patch for each YAML quirk encountered. Structural parsing eliminates the
+whole class.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from typing import Any
+
+import yaml
+
+
+# ---------------------------------------------------------------------------
+# Types
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class Finding:
+    path: str
+    line: int | None
+    severity: str  # "block" | "advise"
+    signal_id: str
+    message: str
+    remediation: str
+
+    def is_block(self) -> bool:
+        return self.severity == "block"
+
+
+@dataclass(frozen=True)
+class FileChange:
+    path: str
+    base_content: str | None
+    head_content: str | None
+    added_lines: list[tuple[int, str]]
+
+
+# ---------------------------------------------------------------------------
+# Path-scope helpers
+# ---------------------------------------------------------------------------
+
+
+def is_workflow(path: str) -> bool:
+    parts = PurePosixPath(path).parts
+    return (
+        len(parts) >= 3
+        and parts[0] == ".github"
+        and parts[1] == "workflows"
+        and (path.endswith(".yml") or path.endswith(".yaml"))
+    )
+
+
+# Empty allowlist in the generator repo — id-token: write should not appear
+# in any current workflow.
+ID_TOKEN_ALLOWLIST: set[str] = set()
+
+
+# ---------------------------------------------------------------------------
+# YAML parsing helpers (shared by R1, R2, R4)
+# ---------------------------------------------------------------------------
+
+
+def _parse_workflow(content: str | None) -> Any:
+    """Parse a workflow YAML safely. Returns the loaded structure (typically
+    a dict) or None if the content is absent or unparseable. A malformed
+    workflow would also fail to load in GitHub Actions itself, so silently
+    skipping it is safe."""
+    if content is None:
+        return None
+    try:
+        return yaml.safe_load(content)
+    except (yaml.YAMLError, TypeError, ValueError):
+        return None
+
+
+def _workflow_on(parsed: Any) -> Any:
+    """Extract the workflow's `on:` section.
+
+    YAML 1.1 (pyyaml's default) interprets unquoted `on` as the boolean
+    literal True — so `on:` at the top of a workflow becomes the key True
+    after parsing. Check both forms; GitHub Actions accepts either."""
+    if not isinstance(parsed, dict):
+        return None
+    if "on" in parsed:
+        return parsed["on"]
+    if True in parsed:
+        return parsed[True]
+    return None
+
+
+def _has_pr_target_trigger(on_node: Any) -> bool:
+    """Detect pull_request_target in any of YAML's trigger declaration forms:
+    string (`on: pull_request_target`), list (`on: [...]` or
+    `on:\\n  - pull_request_target`), or mapping (`on:\\n  pull_request_target:`)."""
+    if on_node is None:
+        return False
+    if isinstance(on_node, str):
+        return on_node.strip() == "pull_request_target"
+    if isinstance(on_node, list):
+        return any(
+            isinstance(item, str) and item.strip() == "pull_request_target"
+            for item in on_node
+        )
+    if isinstance(on_node, dict):
+        return "pull_request_target" in on_node
+    return False
+
+
+_DANGEROUS_REF_VALUE = re.compile(
+    # All forms that resolve to PR-author-controlled content under
+    # pull_request_target. github.head_ref is the shorthand alias for
+    # event.pull_request.head.ref (Greptile-flagged); merge_commit_sha
+    # points at GitHub's synthesised merge commit which contains PR code.
+    r"github\.event\.pull_request\.head\.(sha|ref)"
+    r"|github\.event\.pull_request\.merge_commit_sha"
+    r"|github\.head_ref"
+    # [^\n] (not [^\s]) so the match survives spaces inside `${{ ... }}`
+    # expressions, e.g., refs/pull/${{ github.event.number }}/merge.
+    r"|refs/pull/[^\n]*?/(merge|head)"
+)
+
+
+def _is_dangerous_ref_value(value: Any) -> bool:
+    """Return True if a checkout step's `ref:` value references the PR head."""
+    if not isinstance(value, str):
+        return False
+    return bool(_DANGEROUS_REF_VALUE.search(value))
+
+
+def _walk_checkout_refs(parsed: Any) -> list[str]:
+    """Walk parsed workflow for actions/checkout steps with dangerous ref
+    values. Returns the list of dangerous ref values found (one per offending
+    step). Inspects every job and every step recursively but only flags the
+    `with.ref` field of `uses: actions/checkout*` steps."""
+    if not isinstance(parsed, dict):
+        return []
+    jobs = parsed.get("jobs")
+    if not isinstance(jobs, dict):
+        return []
+    findings: list[str] = []
+    for job in jobs.values():
+        if not isinstance(job, dict):
+            continue
+        steps = job.get("steps")
+        if not isinstance(steps, list):
+            continue
+        for step in steps:
+            if not isinstance(step, dict):
+                continue
+            uses = step.get("uses")
+            if not isinstance(uses, str) or not uses.startswith("actions/checkout"):
+                continue
+            with_block = step.get("with")
+            if not isinstance(with_block, dict):
+                continue
+            ref = with_block.get("ref")
+            if _is_dangerous_ref_value(ref):
+                findings.append(ref.strip())
+    return findings
+
+
+def _walk_id_token_grants(parsed: Any) -> bool:
+    """Return True if the parsed workflow grants `id-token: write` at workflow
+    or job level. (GitHub Actions doesn't honor step-level permissions, so we
+    skip those.)"""
+    if not isinstance(parsed, dict):
+        return False
+    if _permissions_grant_id_token(parsed.get("permissions")):
+        return True
+    jobs = parsed.get("jobs")
+    if isinstance(jobs, dict):
+        for job in jobs.values():
+            if isinstance(job, dict) and _permissions_grant_id_token(job.get("permissions")):
+                return True
+    return False
+
+
+def _permissions_grant_id_token(perm: Any) -> bool:
+    if isinstance(perm, dict):
+        value = perm.get("id-token")
+        return isinstance(value, str) and value.strip() == "write"
+    # A string value of "write-all" grants every permission, including id-token.
+    if isinstance(perm, str) and perm.strip() == "write-all":
+        return True
+    return False
+
+
+_GO_ENV_KEYS = ("GOPROXY", "GOFLAGS", "GONOSUMCHECK", "GOSUMDB", "GONOSUMDB")
+
+
+def _walk_go_env_overrides(parsed: Any) -> list[str]:
+    """Return the list of GOPROXY/GOFLAGS/etc env-variable names set anywhere
+    in the workflow's env blocks (workflow-level, job-level, or step-level)."""
+    found: list[str] = []
+    if not isinstance(parsed, dict):
+        return found
+    _collect_go_env_from(parsed.get("env"), found)
+    jobs = parsed.get("jobs")
+    if isinstance(jobs, dict):
+        for job in jobs.values():
+            if not isinstance(job, dict):
+                continue
+            _collect_go_env_from(job.get("env"), found)
+            steps = job.get("steps")
+            if isinstance(steps, list):
+                for step in steps:
+                    if isinstance(step, dict):
+                        _collect_go_env_from(step.get("env"), found)
+    return found
+
+
+def _collect_go_env_from(env_block: Any, out: list[str]) -> None:
+    if not isinstance(env_block, dict):
+        return
+    for key in env_block:
+        if isinstance(key, str) and key in _GO_ENV_KEYS:
+            out.append(key)
+
+
+def _find_line_in(content: str | None, needle: str) -> int | None:
+    """Best-effort line number lookup. Returns the 1-indexed line where the
+    substring first appears, or None. Used to annotate findings with a line
+    pointer even when the parsed YAML loses position info."""
+    if not content or not needle:
+        return None
+    for idx, line in enumerate(content.splitlines(), start=1):
+        if needle in line:
+            return idx
+    return None
+
+
+# ---------------------------------------------------------------------------
+# R1: pull_request_target + PR-head checkout (TanStack OIDC theft)
+# ---------------------------------------------------------------------------
+
+
+def signal_workflow_trust(change: FileChange) -> list[Finding]:
+    """R1. A workflow that combines pull_request_target with a checkout of
+    the PR head ref is the TanStack mini-Shai-Hulud attack shape — head
+    code runs with the elevated permissions of the base context, including
+    secrets and OIDC.
+
+    Structural diff: fires when head has the bad combo AND base didn't have
+    the same dangerous ref value. (Reformatting the same dangerous YAML in
+    a different style is not a new attack — only newly-introduced danger
+    fires.)"""
+    if not is_workflow(change.path) or change.head_content is None:
+        return []
+
+    head = _parse_workflow(change.head_content)
+    if not _has_pr_target_trigger(_workflow_on(head)):
+        return []
+
+    head_refs = _walk_checkout_refs(head)
+    if not head_refs:
+        return []
+
+    # Diff-aware: any dangerous ref present on base is pre-existing, not new.
+    base = _parse_workflow(change.base_content)
+    base_refs: set[str] = set()
+    if base is not None and _has_pr_target_trigger(_workflow_on(base)):
+        base_refs = set(_walk_checkout_refs(base))
+
+    new_dangerous = [r for r in head_refs if r not in base_refs]
+    if not new_dangerous:
+        return []
+
+    danger_text = new_dangerous[0]
+    line = _find_line_in(change.head_content, danger_text)
+    return [
+        Finding(
+            path=change.path,
+            line=line,
+            severity="block",
+            signal_id="workflow_trust_pr_head_checkout",
+            message=(
+                "pull_request_target workflow checks out PR head code "
+                "(matched: %r). This is the TanStack mini-Shai-Hulud attack "
+                "shape — head code runs with base-context secrets and OIDC." % danger_text
+            ),
+            remediation=(
+                "Use `pull_request` instead, or omit the `ref:` override on "
+                "actions/checkout so it stays on the base commit. Never run "
+                "PR head code under pull_request_target."
+            ),
+        )
+    ]
+
+
+# ---------------------------------------------------------------------------
+# R2: id-token: write outside the publishing allowlist
+# ---------------------------------------------------------------------------
+
+
+def signal_id_token_outside_allowlist(change: FileChange) -> list[Finding]:
+    """R2. id-token: write mints OIDC tokens. It must appear only in the
+    workflow(s) that actually publish. Generator repo's allowlist is empty.
+
+    Structural diff: fires only if the head workflow grants id-token: write
+    and the base didn't (or didn't exist)."""
+    if not is_workflow(change.path) or change.head_content is None:
+        return []
+    if change.path in ID_TOKEN_ALLOWLIST:
+        return []
+
+    head = _parse_workflow(change.head_content)
+    if not _walk_id_token_grants(head):
+        return []
+
+    base = _parse_workflow(change.base_content)
+    if base is not None and _walk_id_token_grants(base):
+        # Pre-existing grant; not introduced by this PR.
+        return []
+
+    line = _find_line_in(change.head_content, "id-token")
+    allowlist_label = ", ".join(sorted(ID_TOKEN_ALLOWLIST)) or "(none — generator repo has no OIDC workflows on origin/main)"
+    return [
+        Finding(
+            path=change.path,
+            line=line,
+            severity="block",
+            signal_id="id_token_outside_allowlist",
+            message=(
+                "id-token: write is granted in a workflow outside the "
+                "publishing allowlist (%s)." % allowlist_label
+            ),
+            remediation=(
+                "Remove the id-token permission. If a publishing workflow "
+                "with OIDC is being introduced, add it to ID_TOKEN_ALLOWLIST "
+                "in signals.py in the same PR with reviewer sign-off."
+            ),
+        )
+    ]
+
+
+# ---------------------------------------------------------------------------
+# R4: GOPROXY / GOFLAGS / GONOSUMCHECK overrides in workflows
+# ---------------------------------------------------------------------------
+
+
+def signal_go_env_override(change: FileChange) -> list[Finding]:
+    """R4. Setting GOPROXY / GOFLAGS / GONOSUMCHECK / GOSUMDB inside a
+    workflow env block lets an attacker redirect module resolution or
+    suppress checksum verification (BufferZoneCorp).
+
+    Structural diff: fires for each go-env key newly set in head that wasn't
+    set in base."""
+    if not is_workflow(change.path) or change.head_content is None:
+        return []
+
+    head_vars = set(_walk_go_env_overrides(_parse_workflow(change.head_content)))
+    if not head_vars:
+        return []
+
+    base_vars: set[str] = set()
+    if change.base_content is not None:
+        base_vars = set(_walk_go_env_overrides(_parse_workflow(change.base_content)))
+
+    new_vars = head_vars - base_vars
+    if not new_vars:
+        return []
+
+    findings: list[Finding] = []
+    for var in sorted(new_vars):
+        findings.append(
+            Finding(
+                path=change.path,
+                line=_find_line_in(change.head_content, var),
+                severity="block",
+                signal_id="go_env_override_in_workflow",
+                message=(
+                    "Workflow sets %s in an env block. This can redirect Go "
+                    "module resolution to an attacker proxy or suppress "
+                    "checksum verification (BufferZoneCorp attack shape)." % var
+                ),
+                remediation=(
+                    "Remove the env override. If a private GOPROXY is required, "
+                    "configure it at the org or runner level under operator review, "
+                    "not in a workflow file that PRs can modify."
+                ),
+            )
+        )
+    return findings
+
+
+# ---------------------------------------------------------------------------
+# Signal dispatch
+# ---------------------------------------------------------------------------
+
+
+ALL_SIGNALS = (
+    signal_workflow_trust,
+    signal_id_token_outside_allowlist,
+    signal_go_env_override,
+)
+
+
+def run_signals(change: FileChange) -> list[Finding]:
+    findings: list[Finding] = []
+    for sig in ALL_SIGNALS:
+        findings.extend(sig(change))
+    return findings
diff --git a/.github/workflows/verify-supply-chain.yml b/.github/workflows/verify-supply-chain.yml
new file mode 100644
index 00000000..f8472796
--- /dev/null
+++ b/.github/workflows/verify-supply-chain.yml
@@ -0,0 +1,85 @@
+name: Verify supply chain
+
+# Mirror of the supply-chain hardening gate from
+# mvanhorn/printing-press-library/.github/workflows/verify-supply-chain.yml
+# (2026-05-17). Catches the workflow-trust and Go-module-env attack shapes
+# in PRs against this generator repo.
+#
+# Scope adapted: this repo doesn't have a library/**/go.mod surface, an
+# npm wrapper, or published-CLI module paths, so signals for those are
+# omitted. See .github/scripts/verify-supply-chain/signals.py for the
+# rationale.
+#
+# IMPORTANT — scan integrity: this workflow checks out the BASE branch
+# (not the PR head) so the scan.py / signals.py that execute are the
+# trusted base versions, not whatever the PR author shipped. The PR head
+# is fetched separately and passed via --head-ref. Prevents a PR from
+# weakening the scanner in the same change that introduces an attack
+# payload — relevant once promoted to a required gate.
+#
+# Informational on landing — promote to a required check via branch
+# protection only after a one-week green window.
+
+on:
+  pull_request:
+    paths:
+      - '.github/workflows/**'
+      - '.github/scripts/verify-supply-chain/**'
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: verify-supply-chain-${{ github.event.pull_request.number || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  scan:
+    name: Scan
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      # Checkout the BASE branch from the base repo — never the PR head.
+      - uses: actions/checkout@v6
+        with:
+          repository: ${{ github.repository }}
+          ref: ${{ github.base_ref || github.ref }}
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Run supply-chain scan
+        if: github.event_name == 'pull_request'
+        env:
+          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+        run: |
+          set -euo pipefail
+
+          git remote add pr "https://github.com/${HEAD_REPO}.git" 2>/dev/null || git remote set-url pr "https://github.com/${HEAD_REPO}.git"
+          git fetch --no-tags --depth=200 pr "${HEAD_SHA}"
+
+          # Deletion guard: refuse to proceed if the PR deletes scanner
+          # files (closes the staged two-step attack: delete scanner →
+          # re-introduce a patched one that whitelists payload).
+          deleted_scanner=$(git diff --name-status --diff-filter=D HEAD "${HEAD_SHA}" -- .github/scripts/verify-supply-chain/ || true)
+          if [ -n "${deleted_scanner}" ]; then
+            echo "::error::PR deletes supply-chain scanner files. Removing or relocating the scanner requires explicit out-of-band review — automated gate refuses to proceed. Files: ${deleted_scanner}"
+            exit 1
+          fi
+
+          # Bootstrap case: base has no scanner yet (this PR introduces it).
+          # Restore scanner from PR head; no-op after merge. The deletion
+          # guard above ensures only the genuine first-introduction case
+          # reaches this branch.
+          if [ ! -f .github/scripts/verify-supply-chain/scan.py ]; then
+            echo "::notice::Base branch has no scanner — bootstrap mode, restoring scanner from PR head ${HEAD_SHA}."
+            mkdir -p .github/scripts/verify-supply-chain
+            git restore --source="${HEAD_SHA}" --worktree -- .github/scripts/verify-supply-chain/
+          fi
+
+          python3 .github/scripts/verify-supply-chain/scan.py --base-ref HEAD --head-ref "${HEAD_SHA}"
+
+      - name: Run supply-chain scan (manual dispatch)
+        if: github.event_name == 'workflow_dispatch'
+        run: python3 .github/scripts/verify-supply-chain/scan.py --base-ref origin/main
diff --git a/.gitignore b/.gitignore
index e465af32..825f24ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,10 @@ library/
 dist/
 claude-upgrade-progress.json
 
+# Python bytecode (verify-supply-chain scripts)
+__pycache__/
+*.pyc
+
 # Local AI agent instructions (not for sharing)
 CLAUDE.local.md
 AGENTS.override.md
diff --git a/AGENTS.md b/AGENTS.md
index 723ea5a2..92ec219e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -195,6 +195,12 @@ Run `go test ./...` before considering your work done.
 Generated CLIs must pass 8 gates: `go mod tidy`, `govulncheck`, `go vet`, `go build`, binary build, `--help`, `version`, and `doctor`.
 Run `govulncheck` in default mode only, scoped to the generated or publishing CLI module (`./...` from that CLI directory). Do not use `-show verbose` or a whole public-library scan as a blocking gate; the public library is a historical collection, so its blocking CI should scan only added or changed CLI modules and leave whole-library sweeps to scheduled/reporting workflows.
 
+## Supply-chain hardening
+
+PRs touching `.github/workflows/**` are gated by Greptile rules in [`greptile.json`](greptile.json) and a Python scan in [`.github/scripts/verify-supply-chain/`](.github/scripts/verify-supply-chain/) run by `verify-supply-chain.yml`. The signal set is the workflow-trust + Go-env subset of the published-library gate; see `signals.py` for scope adaptations (no library go.mod, no npm wrapper, no published-CLI module paths). Run locally with `python3 .github/scripts/verify-supply-chain/scan.py --base-ref origin/main`; tests are `python3 -m unittest scan_test` from that directory.
+
+Runs informationally on landing — promote to a required branch-protection check only after a one-week green window. Canonical incident background lives in the [published-library solutions doc](https://github.com/mvanhorn/printing-press-library/blob/main/docs/solutions/security/2026-05-supply-chain-hardening.md).
+
 ## Local Artifacts
 Generated artifacts live under `~/printing-press/`, not in this repo: `library/<api-slug>/`, `manuscripts/<api-slug>/`, and `.runstate/<scope>/`. The API slug is derived by the generator from the spec title (`cleanSpecName`), and the binary name is `<api-slug>-pp-cli`. Never hardcode an API slug when the generator can derive it. See [`docs/ARTIFACTS.md`](docs/ARTIFACTS.md) for local-vs-public flow and divergence rules.
 
diff --git a/greptile.json b/greptile.json
index 3daaceca..6e7c68c3 100644
--- a/greptile.json
+++ b/greptile.json
@@ -1,4 +1,24 @@
 {
   "statusCheck": true,
-  "triggerOnUpdates": true
+  "triggerOnUpdates": true,
+  "customContext": {
+    "rules": [
+      {
+        "scope": [".github/workflows/**"],
+        "rule": "P0. A workflow that combines a pull_request_target trigger with a checkout step whose ref points at PR-head code (github.event.pull_request.head.sha, github.event.pull_request.head.ref, or refs/pull/<n>/merge) is the TanStack mini-Shai-Hulud attack shape — head code runs with base-context secrets and OIDC tokens, which attackers exfiltrate from runner memory. The repo's existing pull_request_target workflow (conversation-resolution-check.yml) makes API calls only and does not check out PR head; new workflows must follow that posture. Flag any new pull_request_target workflow that overrides the checkout ref. Recommend switching to pull_request, or removing the ref override so checkout defaults to the base commit."
+      },
+      {
+        "scope": [".github/workflows/**"],
+        "rule": "P0. id-token: write mints OIDC tokens consumers use to publish to npm, Sigstore, AWS, etc. In this repo the allowlist is empty — release.yml uses keyed cosign signing and no other workflow needs OIDC. Any new workflow granting id-token: write is the TanStack OIDC-theft attack shape. Flag and recommend either removing the permission, or if a publishing workflow is genuinely being introduced, adding it to the allowlist in .github/scripts/verify-supply-chain/signals.py in the same PR with reviewer sign-off."
+      },
+      {
+        "scope": [".github/workflows/**"],
+        "rule": "P0. Setting GOPROXY, GOFLAGS, GONOSUMCHECK, GOSUMDB, or GONOSUMDB inside a workflow env block (workflow, job, or step level) lets PR authors redirect Go module resolution to an attacker proxy or suppress checksum verification (BufferZoneCorp attack shape). The repo does not use any of these overrides on origin/main as of 2026-05-17; new ones are forward-looking attack vectors. Flag any addition and recommend configuring private proxies at the org/runner level under operator review instead."
+      },
+      {
+        "scope": ["internal/**/*.go", "cmd/**/*.go"],
+        "rule": "Judge whether the generator's purpose plausibly explains any credential-path read. Patterns to weigh: literal strings or os.Getenv calls for ~/.aws, ~/.ssh, ~/.kube, ~/.claude, ~/.vscode (the node-ipc and TanStack persistence/exfil paths), or env vars AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL, GITHUB_TOKEN, NPM_TOKEN. The generator's own code reads specs and writes generated CLIs; it does not normally read user credentials. Flag P0 when a credential-path read is added unless the PR clearly justifies it. The generator must never write to ~/.claude/ or ~/.vscode/ — those were the TanStack persistence sinks."
+      }
+    ]
+  }
 }

← 1dfbf795 chore(main): release 4.9.0 (#1495)  ·  back to Cli Printing Press  ·  feat(cli): default mcp.transport=[stdio, http] for small API 14bc18a4 →