diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 7f731ede..41de0830 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -17,3 +17,14 @@ updates:
- "dependencies"
commit-message:
prefix: "deps:"
+
+ # Python CLI tools (semgrep, ruff) live in
+ # `tools/setup/manifests/uv-tools`, installed via
+ # `common/python-tools.sh`. Dependabot does not support the
+ # uv-tools format, so pin drift is caught by the periodic
+ # uv-tools drift scan (BACKLOG P1) rather than by this file.
+ # Three-way-parity per GOVERNANCE §24 takes precedence over
+ # Dependabot-trackability — one canonical manifest, installed
+ # the same way on dev laptop / CI / devcontainer, beats a
+ # parallel `requirements.txt` that exists only to satisfy
+ # Dependabot's ecosystem list.
diff --git a/.github/workflows/resume-diff.yml b/.github/workflows/resume-diff.yml
new file mode 100644
index 00000000..f7787ffa
--- /dev/null
+++ b/.github/workflows/resume-diff.yml
@@ -0,0 +1,166 @@
+# Zeta resume-claim diff reviewer-helper
+#
+# Runs on every PR that touches `docs/FACTORY-RESUME.md` or
+# `docs/SHIPPED-VERIFICATION-CAPABILITIES.md` — the two files
+# that form the factory's "job-interview honesty" surface per
+# `memory/feedback_factory_resume_job_interview_honesty_only_direct_experience.md`.
+#
+# Purpose (BACKLOG row "Shipped-capabilities resume diff-based
+# CI check (round 44 follow-up, H-risk row 24)"; H-risk flagged
+# in `docs/research/imperfect-enforcement-hygiene-audit.md`):
+# emit a structured claim-level diff as a PR comment so a
+# reviewer can eyeball added / removed / modified claims for
+# substantive drift. Tighter than round-cadence; NOT a gate —
+# judgment stays with the reviewer and with Aaron (honesty-
+# floor owner).
+#
+# Security (reviewed 2026-04-22; complies with the pre-write
+# checklist in `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`,
+# which is in turn derived from the GitHub Security Lab guide at
+# https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/):
+# - All `${{ github.event.* }}` values are consumed ONLY via
+# `env:` blocks and referenced as shell variables in `run:`
+# scripts. No inline `${{ ... }}` interpolation inside any
+# `run:` command.
+# - Inputs are limited to commit SHAs (40-hex, injection-proof)
+# and the PR number (numeric). None of the risky inputs
+# (issue/PR titles, commit messages, body text, head refs,
+# branch names) appear anywhere in this workflow.
+# - Third-party actions SHA-pinned (only actions/checkout is
+# used). `gh pr comment` is pre-installed on GitHub-hosted
+# runners; uses the default workflow token.
+# - permissions: contents: read at the workflow level;
+# pull-requests: write only at the single job that posts
+# the comment.
+# - concurrency: workflow-scoped; cancel-in-progress for PR
+# events.
+# - Runner digest-pinned (ubuntu-22.04).
+# - Graceful no-change handling: if the diff has no claim-
+# bearing lines, posts a clarifying message and passes.
+# Does not fail the PR.
+
+name: resume-diff
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, ready_for_review]
+ paths:
+ - 'docs/FACTORY-RESUME.md'
+ - 'docs/SHIPPED-VERIFICATION-CAPABILITIES.md'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: resume-diff-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ resume-diff:
+ name: claim-level diff
+ runs-on: ubuntu-22.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout PR head with full history
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Compute claim-level diff
+ id: diff
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ set -euo pipefail
+
+ FILES=(
+ "docs/FACTORY-RESUME.md"
+ "docs/SHIPPED-VERIFICATION-CAPABILITIES.md"
+ )
+
+ OUTPUT_FILE="$(mktemp)"
+ HAS_CHANGES=0
+
+ {
+ echo "### Resume claim-level diff — reviewer attention requested"
+ echo
+ echo "This PR touches one or both of the factory's"
+ echo "**job-interview honesty** docs. Per the honesty"
+ echo "floor (\`memory/feedback_factory_resume_job_interview_honesty_only_direct_experience.md\`),"
+ echo "every resume claim must be backed by in-repo evidence"
+ echo "a reader can verify. Confirm each added claim has"
+ echo "evidence, each removed claim is intentionally retired,"
+ echo "each modified line preserves the honesty floor."
+ echo
+ echo "Base SHA: \`${BASE_SHA}\`"
+ echo "Head SHA: \`${HEAD_SHA}\`"
+ } > "$OUTPUT_FILE"
+
+ for FILE in "${FILES[@]}"; do
+ if ! git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- "$FILE"; then
+ HAS_CHANGES=1
+ {
+ echo
+ echo "#### \`$FILE\`"
+ echo
+
+ RAW_DIFF="$(git diff --no-color --unified=1 \
+ "$BASE_SHA" "$HEAD_SHA" -- "$FILE")"
+
+ CLAIM_LINES="$(printf '%s\n' "$RAW_DIFF" \
+ | grep -E '^[+-][^+-]' \
+ | grep -E '^[+-]\s*(- \*\*|\| |#{2,4} |.*\b(ships?|shipped|verified|proven|complete[ds]?|honest|already absorbed|implement(ed|s)?|in[- ]repo evidence)\b)' \
+ || true)"
+
+ if [ -n "$CLAIM_LINES" ]; then
+ echo "**Claim-bearing lines** (bullets, table rows, headers, honesty-keyword hits):"
+ echo
+ echo '```diff'
+ printf '%s\n' "$CLAIM_LINES"
+ echo '```'
+ else
+ echo "_No claim-bearing lines detected in this file's diff_"
+ echo "_(changes may be formatting / punctuation / whitespace — still worth a reviewer glance)._"
+ fi
+
+ echo
+ echo "Full unified diff
"
+ echo
+ echo '```diff'
+ printf '%s\n' "$RAW_DIFF"
+ echo '```'
+ echo
+ echo " "
+ } >> "$OUTPUT_FILE"
+ fi
+ done
+
+ if [ "$HAS_CHANGES" -eq 0 ]; then
+ {
+ echo
+ echo "_No changes detected in either resume file at claim level._"
+ echo "_(The \`paths\` filter matched one of the two files but"
+ echo "the diff resolved to zero lines — likely a rename or a"
+ echo "whitespace-only change.)_"
+ } >> "$OUTPUT_FILE"
+ fi
+
+ {
+ echo "diff_file=$OUTPUT_FILE"
+ echo "has_changes=$HAS_CHANGES"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Post PR comment
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ DIFF_FILE: ${{ steps.diff.outputs.diff_file }}
+ run: |
+ set -euo pipefail
+ gh pr comment "$PR_NUMBER" --body-file "$DIFF_FILE"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 00000000..dbab6e01
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,89 @@
+# OpenSSF Scorecard — weekly project-health audit.
+#
+# Scorecard runs ~20 heuristic checks that score the repo on
+# security-relevant posture: branch protection, signed releases,
+# dangerous workflows, pinned dependencies, CII best practices,
+# dependency-update tools, SAST coverage, token permissions,
+# maintained-ness, and so on. Results upload to GitHub
+# Security -> Code scanning as SARIF.
+#
+# Lane: factory. Orthogonal to the CVE scanners (Dependabot +
+# `dotnet list --vulnerable`) - Scorecard audits *configuration*,
+# not advisories. See `docs/research/vuln-and-dep-scanner-
+# landscape-2026-04-22.md` adopt-now item #3 for the rationale.
+#
+# SECURITY NOTE on expressions
+# ----------------------------
+# No attacker-controlled fields are interpolated into any `run:`
+# block or action input in this workflow. The only `${{ ... }}`
+# expansions are `github.workflow` and `github.ref` (trusted
+# contexts) in the concurrency group. Scorecard action inputs
+# (`results_format`, `results_file`, `publish_results`) are
+# static literals. `publish_results: true` opts in to the
+# OpenSSF public Scorecard dashboard - outbound publish of our
+# score; no inbound attack surface. Pre-write checklist:
+# `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`.
+#
+# Action-pin content-review (per `docs/security/SUPPLY-CHAIN-
+# SAFE-PATTERNS.md`): ossf/scorecard-action v2.4.3 tagged
+# 2025-09-30 by sschrock@google.com (OpenSSF maintainer),
+# SSH-signed, GitHub API reports verified=true reason=valid.
+# Owner org `ossf` is the OpenSSF foundation's GitHub org.
+# Commit SHA `4eaacf0543bb3f2c246792bd56e8cdeffafb205a` locks
+# the reviewed release.
+
+name: scorecard
+
+on:
+ schedule:
+ - cron: '0 7 * * 1'
+ push:
+ branches: [main]
+ branch_protection_rule:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ analysis:
+ name: scorecard analysis
+ runs-on: ubuntu-22.04
+ timeout-minutes: 10
+
+ permissions:
+ id-token: write
+ security-events: write
+ contents: read
+ actions: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard analysis
+ uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload SARIF as workflow artifact
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ with:
+ name: scorecard-sarif
+ path: results.sarif
+ retention-days: 7
+
+ - name: Upload SARIF to GitHub code scanning
+ # Same pin as `.github/workflows/codeql.yml` - one
+ # codeql-action version across the repo, bumped together.
+ uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ with:
+ sarif_file: results.sarif
diff --git a/.semgrep.yml b/.semgrep.yml
index 322e6380..a331ab42 100644
--- a/.semgrep.yml
+++ b/.semgrep.yml
@@ -340,3 +340,33 @@ rules:
# "file lacks any of these headings"; a bespoke diff-level lint
# (which round-30 spec calls the "safety-clause-diff lint") is
# the stronger signal. Tracked in docs/DEBT.md; target round-31.
+
+ # ────────────────────────────────────────────────────────────────
+ # Rule 17 — GitHub Actions workflow-injection: inline untrusted
+ # context on a `run:` line. The primary workflow-injection vector
+ # per https://github.blog/security/vulnerability-research/how-to-
+ # catch-github-actions-workflow-injections-before-attackers-do/.
+ # Matches single-line `run: ... ${{ github. }} ...`
+ # forms for the attacker-controlled contexts enumerated in
+ # docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md. Multi-line `run:
+ # |` blocks are covered by actionlint's YAML-aware parser.
+ # Fix: bind the value to an `env:` entry on the step and read it
+ # as `"$VAR"` in the shell. See the safe-patterns doc.
+ # ────────────────────────────────────────────────────────────────
+ - id: gha-untrusted-in-run-line
+ patterns:
+ - pattern-regex: '(?m)^\s*-?\s*run:.*\$\{\{\s*github\.(head_ref|event\.(issue\.(title|body)|pull_request\.(title|body|head_ref|head\.ref|head\.label)|comment\.body|review\.body|head_commit\.message|commits))'
+ paths:
+ include:
+ - ".github/workflows/*.yml"
+ - ".github/workflows/*.yaml"
+ message: >-
+ Attacker-controlled `github.event.*` / `github.head_ref` value
+ expanded directly into a `run:` shell command — classic GitHub
+ Actions workflow-injection sink. A PR title / issue body /
+ branch name containing shell metacharacters will execute on
+ the runner. Bind the value to the step's `env:` block and
+ reference it as `"$VAR"` in the shell instead. See
+ `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md` §Do / don't.
+ languages: [generic]
+ severity: ERROR
diff --git a/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md b/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md
new file mode 100644
index 00000000..ff4346da
--- /dev/null
+++ b/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md
@@ -0,0 +1,198 @@
+# GitHub Actions workflow injection — safe patterns
+
+**Purpose:** keep every `.github/workflows/*.yml` file in this
+repo secure-by-default against workflow-injection attacks, so
+new workflow authors (human or agent) don't have to rediscover
+the pattern every time. This doc is the **pre-write checklist**
+and the authoritative reference for reviewer gates on CI YAML.
+
+**Primary source:** GitHub Security Lab, *How to catch GitHub
+Actions workflow injections before attackers do*
+().
+Audit against the blog's current revision on the same cadence as
+the rest of the harness-surface audit (FACTORY-HYGIENE #38).
+
+## Threat model in one sentence
+
+Any `${{ ... }}` expression expanded directly into a `run:` shell
+script can execute attacker-controlled strings if the value
+originates from an untrusted context (PR title, issue body, branch
+name, commit message, …). The expression is expanded as raw text
+*before* the shell runs, so embedded backticks / `$( )` /
+newlines-plus-command are evaluated as shell even if the field
+looks like plain data.
+
+## The one rule you cannot break
+
+> **Never inline `${{ ... }}` for attacker-controllable data
+> inside a `run:` block. Bind it to an `env:` entry and read the
+> resulting shell variable (`"$VAR"`, always quoted) instead.**
+
+This is the only rule that is load-bearing for injection
+prevention. Everything else on this page is defence in depth.
+
+## Untrusted context — treat as attacker-controlled
+
+| Context | Notes |
+|---|---|
+| `github.event.issue.title` / `.body` | Comes from any GitHub user. |
+| `github.event.pull_request.title` / `.body` | Comes from the PR author (often a forker). |
+| `github.event.pull_request.head_ref`, `github.head_ref` | Branch name on the PR's head. Forks control it. |
+| `github.event.pull_request.base_ref` | Less risky but still user-influenced via PR retargeting. |
+| `github.event.head_commit.message` | Commit message. Author-controlled. |
+| `github.event.commits[*].message` | Same, for push events. |
+| `github.event.comment.body` | Issue / PR / review comments. |
+| `github.event.review.body` | PR review body. |
+| `github.event.pull_request.head.label`, `.repo.html_url` | Fork-controlled strings. |
+| `github.event.workflow_run.head_branch` | Cross-workflow triggers inherit the same risk. |
+| Tag names (`github.ref` on `push` tag events) | Tag authors may be external. |
+| `workflow_dispatch` / `workflow_call` inputs | Trusted only if the caller is trusted *and* inputs are typed+validated. |
+
+Anything reaching a `run:` from these contexts without the
+env-block buffer is an injection sink.
+
+## Trusted context — safe to inline
+
+- `github.workflow`, `github.run_id`, `github.run_number`
+- `github.repository`, `github.repository_owner`
+- `github.sha`, `github.ref` (for branch refs on push-to-branch),
+ `github.event.pull_request.number`
+- `github.event.pull_request.base.sha`,
+ `github.event.pull_request.head.sha` (SHAs are 40-hex, injection-proof)
+- `runner.*`, `matrix.*`, `hashFiles(...)`, `env.*` (when `env` was
+ set from a trusted literal), `secrets.*`
+
+"Trusted" here means GitHub sets the value, not a user.
+
+## Pre-write checklist
+
+Before committing any new workflow or editing an existing one, walk
+this list. Every item is reviewer-visible; CI enforcement catches
+most but not all.
+
+- [ ] **Trigger choice.** `pull_request` (default) grants no secrets
+ and write-permissions to fork PRs. `pull_request_target` runs
+ with repo-owner secrets on forked PRs — use **only** when
+ genuinely required and with extra scrutiny of every
+ interpolation.
+- [ ] **Permissions minimized.** Workflow-level `permissions:
+ contents: read`. Per-job elevations only where needed
+ (`pull-requests: write` for comment posters,
+ `security-events: write` for SARIF uploaders, …). No
+ `write-all`.
+- [ ] **Env-block buffer for every untrusted value.** Any
+ `github.event.*` value read in a `run:` step appears only as
+ an `env:` entry on that step, then as `"$VAR"` in the shell.
+ Never `${{ github.event.* }}` inline.
+- [ ] **Actions SHA-pinned.** Every `uses:` pins a full 40-char
+ commit SHA with a trailing `# vX.Y.Z` comment for humans.
+ Mutable tags (`@main`, `@v1`, `@latest`) are forbidden — they
+ are a supply-chain vector.
+- [ ] **Runners pinned.** `ubuntu-22.04` / `macos-14` — never
+ `-latest`.
+- [ ] **Concurrency group.** Declared at workflow level;
+ `cancel-in-progress: true` for PR events (never for main
+ pushes — every main commit deserves a record).
+- [ ] **Timeout set.** Every job declares a `timeout-minutes`. A
+ stuck job is a cost and availability risk.
+- [ ] **Header comment block.** Starts with a one-paragraph purpose
+ + a `SECURITY NOTE` section enumerating *which* contexts the
+ workflow reads and *where* they are consumed. This is the
+ reviewer's first stop.
+- [ ] **No secrets in `run:` strings.** `secrets.*` is fine in
+ `env:` blocks; never interpolated into a shell command.
+- [ ] **Linters will catch the rest.** Do not rely on that — author
+ correctly, then let lint confirm.
+
+## Factory tooling that enforces this
+
+Three layers; each covers a different failure mode. None catches
+everything — **the pre-write checklist is the primary defence**;
+the linters are the safety net.
+
+1. **`actionlint`** (`lint-workflows` job in `gate.yml`). Fires on
+ every PR. Catches unknown context refs, invalid runner labels,
+ shellcheck-style issues on `run:` blocks, and several well-known
+ injection patterns (e.g. `${{ github.head_ref }}` inside `run:`).
+ Hard-fails the build. Highest-signal layer for most authoring
+ mistakes.
+2. **CodeQL `actions` language** (`codeql.yml` matrix includes
+ `language: actions`, build-mode `none`). GitHub's late-2024
+ taint-tracking query for workflow injection runs here. Runs on
+ PRs-to-main and on a weekly schedule — **not every push to
+ feature branches**. Findings surface under Security → Code
+ scanning; triaged per GOVERNANCE.md §22. The most semantically
+ precise of the three, but also the slowest feedback loop.
+3. **Semgrep** (`lint (semgrep)` job). Fires on every PR via
+ `.semgrep.yml`. Two GitHub-Actions rules:
+ - `gha-action-mutable-tag` — catches `uses: foo@v1` / `@main`
+ instead of a 40-char SHA (supply-chain vector).
+ - `gha-untrusted-in-run-line` — catches single-line
+ `run: ... ${{ github. }} ...` forms for the
+ attacker-controlled context list enumerated above (PR
+ titles, issue bodies, branch refs, commit messages, etc.).
+ Runs ahead of CodeQL, so injection is caught at PR time even
+ when CodeQL is on its weekly cadence. Multi-line `run: |`
+ blocks are left to actionlint's YAML-aware parser.
+
+If all three pass, the workflow is compliant with the
+author-checkable slice of this document — the env-block buffer and
+permission-minimization items remain on the author and reviewer.
+When any fail, **fix the code, never the lint**.
+
+## Do / don't — minimal pair
+
+### Unsafe
+
+```yaml
+- name: Log PR title
+ run: echo "Processing PR ${{ github.event.pull_request.title }}"
+```
+
+A PR with title `` `rm -rf ~` `` executes the embedded command.
+
+### Safe
+
+```yaml
+- name: Log PR title
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ echo "Processing PR $PR_TITLE"
+```
+
+The expansion now happens inside the shell, which treats
+`$PR_TITLE` as a plain string. Always quote the variable (`"$PR_TITLE"`)
+in non-trivial uses.
+
+## Why this doc exists in-repo, not just as a link
+
+- **Offline readable** by every agent and reviewer, including ones
+ without web-fetch.
+- **Factory-specific cross-refs** — points at our actual lint jobs,
+ our actual CodeQL config, our actual SHA-pinning convention. The
+ blog is generic; this is ours.
+- **Cadenced audit target** — FACTORY-HYGIENE row 40 cadences a
+ re-read against the blog's current revision so drift is caught.
+- **Reviewer citation** — a CI YAML review can reject with "violates
+ §Pre-write checklist item N" rather than handwaving.
+
+## Related
+
+- `docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md` — sibling
+ checklist for the third-party-ingress class. The SHA-pinning
+ rule for `uses:` pins appears in both docs; the supply-chain
+ doc is the authoritative reference when a pin is being **added
+ or bumped** (evaluating the dependency itself), while this doc
+ is authoritative when a workflow is being **authored or edited**
+ (evaluating the shell commands inside).
+- `docs/security/INCIDENT-PLAYBOOK.md` Playbook A — the reactive
+ counterpart when a third-party action we pinned is discovered
+ to have been compromised.
+
+## Scope
+
+Factory-wide. Applies to every workflow in `.github/workflows/`,
+including future workflows for factory-reuse projects that inherit
+this CI shape. Inherits automatically via the factory CI discipline
+(`docs/research/ci-workflow-design.md`, FACTORY-HYGIENE row 40).
diff --git a/docs/security/INCIDENT-PLAYBOOK.md b/docs/security/INCIDENT-PLAYBOOK.md
index c3c69036..8ea79405 100644
--- a/docs/security/INCIDENT-PLAYBOOK.md
+++ b/docs/security/INCIDENT-PLAYBOOK.md
@@ -48,10 +48,26 @@ investigate before you fix.
malicious release, OR the upstream repo is compromised
and someone rewrites a tag to point at a malicious commit.
-**Canonical case:** tj-actions/changed-files cascade
-(CVE-2025-30066, March 2025). 4 hops deep, 3-4 month
-dwell, SpotBugs PAT → reviewdog → tj-actions → 23,000
-repos.
+**Canonical cases** (both mutable-tag class):
+
+- **tj-actions/changed-files cascade** (CVE-2025-30066,
+ March 2025). 4 hops deep, 3-4 month dwell, SpotBugs PAT
+ → reviewdog → tj-actions → 23,000 repos.
+- **Trivy TeamPCP attack** (2026-03-19). 76 of 77 version
+ tags force-pushed on `aquasecurity/trivy-action` plus 7
+ of 7 on `aquasecurity/setup-trivy`, malicious binary
+ `v0.69.4` published via the compromised `aqua-bot`
+ service account. Notable because (a) the target was a
+ *security scanner itself* — the very thing ecosystems use
+ to audit each other — and (b) even SHA-pinned consumers
+ were hit if they bumped during the compromise window,
+ which underscores why `docs/security/SUPPLY-CHAIN-SAFE-
+ PATTERNS.md` §"Content review is the load-bearing step"
+ matters: a SHA-256 of a compromised release is still a
+ valid SHA-256. We do not currently consume `trivy-action`;
+ scanner-adoption decisions are tracked in
+ `docs/research/vuln-and-dep-scanner-landscape-2026-04-
+ 22.md`, which defers Trivy pending rebuild-trust.
### Detect
diff --git a/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md b/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md
new file mode 100644
index 00000000..0eaa3772
--- /dev/null
+++ b/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md
@@ -0,0 +1,273 @@
+# Supply-chain attack surface — safe patterns
+
+**Purpose:** keep every third-party-code ingress point in this
+repo — GitHub Actions, NuGet packages, toolchain installers,
+MSBuild `.targets`, OS-package manifests — secure-by-default
+against tag-rewrite, dep-poisoning, and installer-hijack attacks.
+This doc is the **pre-add / pre-bump checklist** and the
+authoritative reference when an agent or contributor is about to
+introduce or upgrade a dependency.
+
+**Primary sources:**
+
+- OWASP Software Component Verification Standard (SCVS) —
+
+- NIST SP 800-218 *Secure Software Development Framework (SSDF)*
+ PW.4 (Reuse existing, well-secured software) —
+
+- SLSA supply-chain levels spec —
+- GitHub's dependency-review docs —
+
+- Canonical incidents (both mutable-tag class):
+ - **CVE-2025-30066** — tj-actions/changed-files tag-rewrite
+ cascade (March 2025), malicious commit landed on 23,000+
+ repos via a single mutable `@v1` tag.
+ - **Trivy TeamPCP attack** — 2026-03-19, Aqua Security's
+ Trivy scanner ecosystem compromised by force-push of 76 of
+ 77 version tags on `aquasecurity/trivy-action` + 7 of 7 on
+ `aquasecurity/setup-trivy`, plus a malicious binary
+ `v0.69.4` published via a compromised `aqua-bot` service
+ account. Even SHA-pinned consumers were hit if they bumped
+ during the window. This attack targets a *security
+ scanner itself* — it is the canonical case study for the
+ "content-review-is-load-bearing" policy above: a SHA-256
+ of a compromised release binary is still a valid SHA-256.
+ Referenced in Semgrep rule `gha-action-mutable-tag`,
+ Incident Playbook A, and the scanner-landscape research
+ doc (`docs/research/vuln-and-dep-scanner-landscape-2026-
+ 04-22.md`) — which defers Trivy adoption pending
+ rebuild-trust signals.
+
+Re-read against current revisions of these sources every 5-10
+rounds (FACTORY-HYGIENE row 41, same cadence as GHA safe-patterns).
+
+## Threat model in one sentence
+
+Any third-party code that executes in this repo's build, test, or
+runtime (including CI toolchain installers) can execute arbitrary
+commands with the permissions of that step — so every external
+pin is a trust decision that survives until it is re-audited.
+Mutable pins (tags, version ranges, moving URLs) turn a one-time
+trust decision into a standing capability for the upstream
+maintainer to compromise us later.
+
+## The one rule you cannot break
+
+> **Every third-party pin references an immutable identifier.
+> Tags, floating versions, and URLs-without-digests are not
+> pins — they are standing promises.**
+
+For actions this is a 40-char commit SHA. For NuGet it is a fully
+pinned version (no ranges) with a future `packages.lock.json`
+commitment. For toolchain installers it is a SHA-256 of the
+downloaded artefact. For OS packages it is the pinned version in
+the manifest plus repository signature verification.
+
+### Content review is the load-bearing step — the pin caches it
+
+The immutable identifier is *how* we lock a decision, but the
+decision itself is **content review at first pin**. A SHA-256 that
+points at malicious code is still malicious; a hand-verified
+script run `curl | bash`-style after a careful read is safe.
+
+In this factory, Aaron's standing policy (2026-04-22) is: *"never
+run a script you download without checking it for vulnerability,
+trojans and things of that nature even like gist and stuff, it's
+fine to download and run bash and things like that just validate
+them first."* So the actual author-time protocol is:
+
+1. **Download to disk**, do not execute.
+2. **Read the script in full** — check for sudo / privilege
+ escalation, data exfiltration (`curl -X POST`, `nc`, `scp` to
+ non-project hosts), shell-metacharacter injection, opaque
+ base64 blobs, cryptominers, calls to suspicious domains,
+ trojans masquerading as legitimate installers.
+3. **Execute if clean**; record the validation decision in the
+ commit message or manifest comment.
+4. **SHA-256-pin the validated content** — the pin is then a
+ cache of your review. Any bump invalidates the cache and
+ forces a re-read.
+
+The delivery mechanism (`curl | bash` vs `curl -o path && bash
+path`) is not the risk. The risk is unvalidated content.
+
+## Third-party-code ingress points
+
+Zeta has four classes of supply-chain surface. Each has a
+different current enforcement level — **which is itself the
+dominant residual risk** when an ingress is bumped.
+
+| Class | Immutable-pin enforced? | Author-time tooling | Reactive playbook |
+|---|---|---|---|
+| GitHub Actions (`.github/workflows/**`) | **Yes** — Semgrep `gha-action-mutable-tag` + convention of trailing `# vX.Y.Z` comment | Required by rule; lint hard-fails on mutable tags | INCIDENT-PLAYBOOK Playbook A (third-party action compromise) |
+| NuGet packages (`Directory.Packages.props`) | **Partial** — central version management + exact versions. `packages.lock.json` not yet adopted (SDL #7 deliverable). | `tools/audit-packages.sh` + `package-auditor` skill (manual) | INCIDENT-PLAYBOOK Playbook C (NuGet dep poisoning) |
+| Toolchain installers (`tools/setup/manifests/{brew,apt,dotnet-tools,uv-tools,verifiers}`) | **Partial** — versions declared per manifest; not all artefacts carry SHA-256. | `tools/setup/install.sh` is the single consumer; `ensure-*` scripts are opportunistic | INCIDENT-PLAYBOOK Playbook B (toolchain installer hijack mid-setup) |
+| MSBuild `.targets` auto-imported via NuGet | **No** — SDL #7 open deliverable; any package may ship executable MSBuild logic that runs during `dotnet build`. | Manual review; no allowlist yet | Playbook C covers the exploit path |
+
+If you bump or add something in a class marked Partial / No, the
+pre-add checklist is **load-bearing** — the lint won't save you.
+
+## Pre-add / pre-bump checklist — universal
+
+Before committing any third-party addition or bump, walk this
+list. Every item is reviewer-visible.
+
+- [ ] **Justify the dependency at all.** For a new add: does
+ something already in `Directory.Packages.props` / the toolchain
+ / the standard library cover this? Dependency deletion beats
+ dependency audit.
+- [ ] **Read the release notes since the current pin.** Not a
+ skim — specifically look for: breaking changes, new
+ maintainers, repository transfers, deprecation warnings,
+ scope expansions, new transitive dependencies.
+- [ ] **Confirm project health.** Recent commits, responsive
+ issue tracker, not a single-maintainer abandonware account.
+ For high-risk bumps, check the package registry for
+ maintainer account changes.
+- [ ] **Pin by immutable identifier.** SHA for actions; exact
+ version for NuGet; SHA-256 digest (where available) for
+ toolchain installers; pinned version + repo signature for
+ OS packages. Trailing human-readable comment (`# v1.2.3`)
+ so diffs are readable.
+- [ ] **Re-run the whole build gate.** `dotnet build -c Release`
+ plus `dotnet test Zeta.sln -c Release` plus the full CI
+ matrix on a PR — do not trust a one-OS bump.
+- [ ] **Document the bump rationale** in the commit message.
+ "Why this version, now?" Future you (or a future incident
+ responder) needs this.
+- [ ] **Check upstream attestations / SLSA level** if available.
+ SLSA-3+ packages carry build-provenance metadata GitHub can
+ verify automatically.
+
+## Pre-add / pre-bump checklist — per class
+
+### GitHub Actions
+
+- [ ] `uses:` points at a 40-char commit SHA, never a tag.
+- [ ] Trailing `# vX.Y.Z` comment for humans.
+- [ ] Action is from a **known maintainer** — GitHub-owned
+ (`actions/*`), a major-org account, or a single-author action
+ we've audited source for. Avoid newly-created third-party
+ actions unless the blast radius is trivial.
+- [ ] Semgrep `gha-action-mutable-tag` + `actionlint` both pass.
+- [ ] Also apply the injection checklist in
+ `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`.
+
+### NuGet packages
+
+- [ ] Added / bumped only in `Directory.Packages.props` (central
+ version management). No inline ``
+ in `.fsproj` / `.csproj`.
+- [ ] Version is exact, not a range (`1.2.3`, never `[1.0,2.0)`).
+- [ ] `tools/audit-packages.sh` was run locally before commit.
+- [ ] `package-auditor` skill workflow was followed for MAJOR
+ bumps — read CHANGELOG for Breaking / Removed bullets, grep
+ our source for any usage of removed surface, **test before
+ deferring**.
+- [ ] If the package ships MSBuild `.targets`, manually read the
+ targets file(s) before committing — they run at build time.
+- [ ] When `packages.lock.json` adoption lands (SDL #7), the lock
+ file gets updated and committed alongside the bump.
+
+### Toolchain installers (`tools/setup/`)
+
+- [ ] Addition or bump lands in the relevant manifest under
+ `tools/setup/manifests/`, not ad-hoc in a script.
+- [ ] Where the upstream publishes SHA-256 digests, include the
+ digest in the manifest; the install script verifies.
+- [ ] The three-way-parity invariant still holds (GOVERNANCE.md
+ §24) — dev laptop, CI, devcontainer all bootstrap from the
+ same manifest.
+- [ ] For new installer scripts, prefer Homebrew / apt / mise /
+ elan over hand-rolled `curl | bash`; when `curl | bash` is
+ unavoidable, pin the raw URL by SHA-256.
+
+### OS-package manifests (brew / apt)
+
+- [ ] Package listed in `tools/setup/manifests/{brew,apt}` with
+ pinned version.
+- [ ] For apt: repository GPG key is captured under
+ `tools/setup/apt-keys/` and verified during install, not
+ fetched from a keyserver at install-time.
+
+## Factory tooling that enforces this
+
+Three layers; none individually sufficient — the pre-add
+checklist is the primary defence.
+
+1. **Semgrep** — `.semgrep.yml` rule `gha-action-mutable-tag`
+ hard-fails any non-SHA action pin. Runs on every PR. One rule
+ today; expansion candidates tracked in SDL #7.
+2. **`package-auditor` skill + `tools/audit-packages.sh`** — the
+ manual NuGet-side audit. Paired with an agent; not yet
+ CI-gated (SDL #7 open deliverable to make the audit a gate).
+3. **Incident playbooks** (`docs/security/INCIDENT-PLAYBOOK.md`) —
+ reactive. Playbook A (action compromise), B (toolchain
+ installer hijack), C (NuGet dep poisoning), D (maintainer
+ account compromise). Triggered when prevention fails.
+
+When adding a third-party surface that none of the above covers,
+either extend the tooling in the same commit or flag a SECURITY-
+BACKLOG row naming the residual risk explicitly.
+
+## Do / don't — minimal pair
+
+### Unsafe
+
+```xml
+
+
+```
+
+```yaml
+# .github/workflows/foo.yml — one mutable tag is a trust delegation
+- uses: tj-actions/changed-files@v45
+```
+
+### Safe
+
+```xml
+
+
+```
+
+```yaml
+# Full SHA is the trust anchor; trailing comment keeps diffs readable.
+- uses: tj-actions/changed-files@0fe0c5a3b5ed3a1df2c6e8bab4a1a52f8e4c07d9 # v45.0.7
+```
+
+## Upstream signals to watch
+
+The supply-chain threat surface evolves fast. During the cadenced
+re-read (FACTORY-HYGIENE row 41), check:
+
+- **GitHub Security Advisories** for packages we depend on
+ (`dependabot` alerts on the repo; Security → Dependabot).
+- **SLSA progress tracker** — new attestation publishers; new
+ verification requirements.
+- **CVE feeds** filtered for NuGet ecosystem + GitHub Actions
+ ecosystem.
+- **Incident write-ups** for the canonical attack classes (the
+ `tj-actions/changed-files` post-mortem is the reference; newer
+ incidents get added here).
+
+## Why this doc exists in-repo
+
+- **Author-time reference.** Agents and contributors about to add
+ a dependency should not have to rediscover OWASP SCVS or NIST
+ SSDF — the relevant subset lives here, cross-referenced to our
+ actual tooling.
+- **Factory-specific cross-refs.** Points at our Semgrep rule,
+ our `audit-packages.sh`, our manifests, our playbooks. Generic
+ external guidance can't do that.
+- **Cadenced audit target.** FACTORY-HYGIENE row 41 re-reads
+ upstream guidance on cadence so drift is caught.
+- **Reviewer citation surface.** A PR review can reject with
+ "violates §Pre-add checklist item N" rather than handwaving.
+
+## Scope
+
+Factory-wide. Applies to every third-party ingress in this repo
+and — via factory reuse — to adopter projects. Inherits
+automatically via the factory CI discipline and the ships-to-
+project row in `docs/FACTORY-HYGIENE.md`.