Skip to content

fix(ci): guard PR title/body — the one leak path no local hook can see - #418

Open
runyourempire wants to merge 1 commit into
mainfrom
fix/ci-pr-metadata-guard
Open

fix(ci): guard PR title/body — the one leak path no local hook can see#418
runyourempire wants to merge 1 commit into
mainfrom
fix/ci-pr-metadata-guard

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

.husky/commit-msg guards locally authored commit messages. .husky/pre-push guards outgoing commits. Neither runs when GitHub performs a squash merge — that commit message is composed server-side from the PR title and body.

That gap has already fired. Two commits on public maind5df9e34 (#409) and 053e5813 (#362) — carry the private external-verifier name in their messages. A scan of all 1,948 tracked files at origin/main returns zero flagged files, so the 2026-07-13 content scrub held; only this path regressed. Removing those two messages now needs a public-history rewrite. This PR stops the next one, which is the part still in our control.

What it does

Runs on pull_request, where the title and body are known before the merge button, and scans title + body + every commit message in the range using the existing hashed detector in scripts/private-asset-guard.cjs. No literal name is added anywhere in this change.

Wired into validate-success's needs, so it gates through the existing required check — no branch-protection change needed.

Two deliberate choices

Fail-closed. .husky/pre-push fails open on tool error, correctly — a broken guard must never brick the fleet's ability to push. CI is the opposite trade: it blocks a merge rather than a local loop, the failure is visible on the PR, and a re-run is cheap. A guard that silently cannot run is not a guard — and silent failure is precisely the bug class this check exists to stop.

Title/body reach the script through the environment, never ${{ }} inlined into the run: block. PR text is attacker-controlled; inlining it is a script-injection primitive.

Verification

Against the real detector, not a mock:

range containing d5df9e34  -> BLOCKED, naming the offending commit   (exit 1)
clean range (adfb68ff)     -> passes                                 (exit 0)
  • 7/7 unit tests (scripts/check-pr-metadata.test.cjs, added to test:scripts). They inject their own predicate, so no private literal lands in a tracked test file.
  • Workflow YAML parses; validate-success.needs confirmed to include pr-metadata.
  • This PR is itself gated by the new job.

Scope

Deliberately limited to the private-asset marker — the demonstrated leak, and the one with a proper shared module. Extending the same job to the secret-pattern set is a reasonable follow-up; those patterns currently live inline in the husky hook rather than in a shared module.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WR8YdADiaw1p8pD8CddncD

`.husky/commit-msg` guards locally authored messages; `.husky/pre-push` guards
outgoing commits. Neither runs when GitHub performs a SQUASH MERGE, because
that commit message is composed server-side from the PR title and body.

That gap has already fired. Two commits on public `main` — `d5df9e34` (#409)
and `053e5813` (#362) — carry the private external-verifier name in their
MESSAGES. A scan of all 1,948 tracked files at `origin/main` returns zero
flagged files, so the 2026-07-13 content scrub held; only this path regressed.
Removing those two messages now needs a public-history rewrite. This stops the
next one, which is the part still in our control.

The check runs on `pull_request`, where the title/body are known BEFORE the
merge button, and scans title + body + every commit message in the range via
the existing hashed detector in scripts/private-asset-guard.cjs (no literal
name is added anywhere).

Wired into `validate-success`'s `needs`, so it gates through the existing
required check — no branch-protection change required.

Two deliberate choices:

* FAIL-CLOSED. `.husky/pre-push` fails OPEN on tool error so a broken guard can
  never brick the fleet's ability to push. CI is the opposite trade: it blocks a
  merge, not a local loop, the failure is visible on the PR, and a re-run is
  cheap. A guard that silently cannot run is not a guard — and silent failure is
  the exact bug class this check exists to stop.
* Title and body reach the script through the ENVIRONMENT, never `${{ }}`
  inlined into the `run:` block. PR text is attacker-controlled; inlining it is
  a script-injection primitive.

Verified end-to-end against the real detector:
  * range containing `d5df9e34` -> BLOCKED, naming the offending commit (exit 1)
  * clean range (`adfb68ff`)    -> passes (exit 0)
  * 7/7 unit tests (injected predicate, so no private literal in a tracked test)
  * workflow YAML parses; `validate-success.needs` includes `pr-metadata`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR8YdADiaw1p8pD8CddncD
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
4da-home Ready Ready Preview Aug 11, 2026 6:12pm

runyourempire added a commit that referenced this pull request Aug 14, 2026
Today's outage made this concrete. #423 was a Rust-only PR, so the path filter
skipped its Frontend job — and `scripts/check-file-sizes.cjs --ci` ran ONLY
inside that job, even though it scans BOTH trees (SCAN_DIRS = ['src',
'src-tauri/src']). A 1032-line src-tauri/src/analysis_rerank.rs merged past the
1000-line hard error threshold with the gate never executing. check-file-sizes
then exited 1 on main, and because .husky/pre-commit treats that as blocking,
every developer in the fleet was unable to commit until #430 landed.

A gate that guards Rust files must not be reachable only through a filter that
excludes Rust.

Adds a `repo-guards` job with NO path filter and NO `needs:`, so it runs on
every pull request and dispatch whatever changed. It carries check-file-sizes,
check-no-window-spawns, check-release-channel and the guard self-tests, which
are removed from `frontend` — they were never frontend-specific. It needs no
pnpm install (all three guards use only node builtins) and runs hosted in ~40s.

It is in `validate-success`'s needs, so it actually gates the merge: since it
never skips, it is the only leg guaranteed to have run.

Also folds in `pnpm run test:scripts` — 53 tests across 6 files that verify the
guards still detect what they claim, and which previously executed in no hook
and no workflow. package.json is deliberately untouched: a peer holds a claim
on it and #418 edits the same line, and wiring it here achieves the same goal.

Rust job timeout 30 -> 45: that job now compiles the integration test targets,
and Swatinem's cache is only saved from main, so until this lands every PR run
pays a cold link for 5 extra binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
runyourempire added a commit that referenced this pull request Aug 14, 2026
…ncovered paths (#429)

## What this fixes

Four CI gates reported success without doing the work they claim. Each
was verified against live run data before anything was changed.

### 1. The integration tests had never run in CI — on any workflow

Every `cargo test` in `.github/workflows/` was `--lib`-scoped
(`validate.yml:303/308`, `hermetic.yml:167/176`). No `--tests`, no
`--all-targets`. So **all 154 real integration tests in
`src-tauri/tests/` had never executed in CI**, including all 12
migration tests and the repo's only forward-migration coverage — against
a `TARGET_VERSION = 103` migration chain that has no checksums and no
downgrade path.

**I measured before changing anything.** Full `cargo test --tests` run
on this branch's base (`a6ece843`), isolated data dir, exit code 0:

| Target | Result |
|---|---|
| lib unittests | **4,290 passed**, 0 failed, 10 ignored |
| `4da` (cli bin) | 13 passed, 0 failed |
| `fourda` / `fourda-engine` bins | 0 tests each |
| `migration_tests` | **12 passed**, 0 failed |
| `pipeline_integration` | **13 passed**, 0 failed |
| `source_resilience` | **5 passed**, 0 failed |
| `stack_simulation` | **124 passed**, 0 failed |
| `victauri_dogfood` | 157 passed, 3 ignored (self-skips without
`VICTAURI_E2E=1`) |
| **Total** | **4,614 passed across 9 binaries, 0 failed** |

**Nothing was broken and nothing had to be excluded.** They are also
hermetic by construction, which I verified rather than assumed:
`pipeline_integration` uses an in-memory DB (`test_utils::test_db()` →
`:memory:`) and `migration_tests` uses `tempfile::tempdir()`. Both
workflows now run `--tests` (lib + bins + integration) under the same
throwaway-data-dir isolation `hermetic.yml` already used.

Two follow-on fixes were required to avoid landing a red gate:
- The count floor took `tail -1` of the `test result:` lines. With
`--tests` there are **9** test binaries, so it would have read the
*last* binary's total (157) and tripped the 2000 floor on every run. It
now **sums** all binaries.
- A new assertion fails if fewer than 5 test binaries report — so if
this is ever re-scoped to `--lib`, it fails loudly instead of silently
dropping the integration suite again.

> ⚠️ **Non-obvious trap for reviewers:** the isolation directory name
must keep containing the substring `data`.
`src/state.rs::test_get_db_path_points_to_data_dir` asserts the resolved
DB path contains `"data"`. Pointing `FOURDA_DATA_DIR` at e.g.
`/tmp/4da-hermetic` makes that lib test fail; `…/4da-hermetic-data`
passes. I hit this during measurement. Both call sites are commented.

### 2. The hermetic fresh-clone canary never ran outside PRs

`fresh-clone` needs the PR-only `changes` job. GitHub skips any job
whose `needs` was skipped **unless its `if:` contains a status
function** — and `hermetic.yml:94` had none. So push-to-main, the
nightly cron and manual dispatch all skipped the clone and reported
success:

| Trigger | Duration | Result |
|---|---|---|
| push → main (08-14 03:52) | **9s** | "success" |
| push → main (08-13 16:42) | **7s** | "success" |
| schedule (08-13 08:21) | **7s** | "success" |
| pull_request (real work) | ~19min | success |

The file's own comment at `:56-60` claimed these paths "ALWAYS run the
full canary". **The nightly cron had never built a single fresh clone.**
Fixed with `!cancelled()` — not `always()`, so a cancelled run doesn't
spawn a 45-minute cold build.

The **identical defect** silently disabled `workflow_dispatch` for
Frontend, MCP Server and the entire Rust matrix in `validate.yml`: the
`github.event_name == 'workflow_dispatch'` clause on those three jobs
had never once fired, while `Validate Success` (`if: always()`) still
went green. Same fix.

### 3. A path-filter hole took the whole fleet down today

This stopped being hypothetical while this PR was being written:

- **#423 was a Rust-only PR.** Its `Frontend` job was skipped by the
path filter.
- `scripts/check-file-sizes.cjs --ci` ran **only inside the Frontend
job** — but it scans `SCAN_DIRS = ['src', 'src-tauri/src']`, i.e. it
guards Rust files too.
- So #423 merged a **1032-line `src-tauri/src/analysis_rerank.rs`** past
the 1000-line hard error threshold, with the gate never executing.
- `check-file-sizes.cjs` then exited 1 on `main`, and
`.husky/pre-commit:38-41` treats that as blocking — **every developer in
the fleet was unable to commit.**

A gate that guards Rust files must not be reachable only through a
filter that excludes Rust. This PR adds a **`repo-guards` job with no
path filter and no `needs:`** — it runs on every PR and dispatch, and
carries `check-file-sizes`, `check-no-window-spawns`,
`check-release-channel` and the guard self-tests. They are removed from
`Frontend` (they were never frontend-specific). Hosted, ~40s, no `pnpm
install` needed: all three guards use only node builtins.

It is also in `validate-success`'s `needs`, so it actually gates the
merge — `repo-guards` is now the only leg guaranteed to have run.

**Additionally, "no filter matched" now means RUN, not PASS.** `site/`,
`paddle-webhook/`, `mcp-memory-server/`, `editors/vscode/` and `.husky/`
matched no filter, while `Validate Success` is the only required check
and auto-merge is enabled repo-wide — so a Dependabot bump into the
payment webhook, or a PR weakening `.husky/` itself, could merge with
nothing run. Added `.github/**` and `.husky/**` explicitly, plus an
`uncovered` fail-safe filter that catches anything unrecognised
**including directories added in future**.

> The `uncovered` filter uses `predicate-quantifier: 'every'`, which is
**required** — the default `'some'` ORs the patterns, and a list of
negations OR'd together matches every file. Verified against the
action's source at the pinned SHA (`src/filter.ts:110-113` →
`patterns.every(...)`; `MatchOptions = {dot: true}`, so `.husky/**`
matches).

### 4. The guards' own self-tests ran nowhere

`pnpm run test:scripts` (53 tests across 6 files) executed in **no hook
and no workflow** — nothing verified the guards still detect what they
claim. `pnpm run validate` isn't run by CI either (the Frontend job runs
its steps individually), so wiring it into `package.json` alone would
not have gated it. It is now a step in `repo-guards`.

## Deliberately NOT done

- **No branch-protection or ruleset change.** Making `Hermetic Success`
required is the correct end state — currently `Validate Success` is the
*only* required check in the active `main-protection` ruleset (verified
via the API; classic branch protection is disabled). But with ~30 open
PRs and Hermetic historically failing on #421, flipping it now would
block the fleet. **Recommended as an explicit follow-up** once this
lands and Hermetic is observed green on push-to-main for a few days —
which, note, is the first time that signal will ever have existed.
- **`package.json` untouched.** `test:scripts` was going to be added to
the `validate` chain, but a peer worktree agent holds a claim on that
file and #418 also edits that exact line. Wiring it into `repo-guards`
achieves the real goal (it now runs in CI) without touching the claimed
file.
- **`analysis_rerank.rs` / `check-file-sizes.cjs` untouched** — a
separate agent owns the immediate unblock. This PR fixes the structural
cause only.
- **No per-package jobs for `site/`, `paddle-webhook/`,
`mcp-memory-server/`, `editors/vscode/`.** The `uncovered` fail-safe
means they now trigger the generic gate instead of passing silently, but
that gate does not *build* them. Dedicated jobs are the right follow-up
and belong in their own PR.
- **Rust job timeout raised 30 → 45 min.** Not cosmetic: this job now
compiles the integration test targets, and Swatinem's cache is only
saved from `main`, so until this lands there every PR run pays a cold
link for 5 extra binaries. 30 was too tight for that first window, and a
timeout on a required gate is a red gate.

## Conflicts with open PRs

Checked `gh pr diff --name-only` on every PR touching these files:

| PR | Overlap | Notes |
|---|---|---|
| #387, #350 | none | Dependabot `actions/checkout` SHA pins only —
different lines |
| #388 | none | `taiki-e/install-action` SHA pin only |
| #424 | none | Adds 3 matrix legs at `validate.yml:236-255`; my edits
are at 301+ and inside `steps:`. I deliberately did **not** add a matrix
key — an integration-floor key would have had to be added to its new
legs. Verified `cargo test --tests --features experimental` compiles
clean, so its `test-floor: 0` compile-gate legs are unaffected. |
| #418 | **1 line** | Both edit `validate-success`'s `needs:`. #418 adds
`pr-metadata`, this adds `repo-guards`. Resolution is a union of the two
lists — whoever merges second takes both. Flagged rather than
pre-empted. |

`uncovered` was also deliberately placed *before* the main filter step,
to stay clear of the end-of-job boundary #418 inserts a job into.

## Live CI evidence from this PR's own run

The first run of this branch already proves the fix, on both platforms:

| Check | Result |
|---|---|
| Fresh clone (ubuntu-22.04) | **pass**, 12m22s |
| Fresh clone (windows-latest) | **pass**, 18m21s |
| Hermetic Success | **pass** |
| Rust (default) | **pass**, 12m41s |
| Rust (experimental) | **pass**, 12m02s |

The hermetic job log shows **all 9 test binaries executing on both
legs** — `migration_tests` 12, `pipeline_integration` 13,
`source_resilience` 5, `stack_simulation` 124, `victauri_dogfood` 157,
plus lib (4,290 windows / 4,284 ubuntu — a 6-test platform delta, far
above the 2000 floor) and the 3 bin targets. **Zero failures.** That is
the first time any of those integration tests has run in CI.

Rust finished in ~12min against the old 30min cap, so the 45min bump is
headroom for the first cold-cache window rather than a response to an
observed timeout.

## Verification

- Both workflows parse as YAML; all `if:` expressions and filter blocks
inspected post-rebase.
- `cargo test --tests` measured green in full **before** any workflow
edit, and **re-run green after** rebasing onto current `main` (table
above) — #421 removed ~54k lines and #423 changed pipeline code between
those two runs.
- `dorny/paths-filter` negation + `every` semantics confirmed from
source at the pinned SHA, not assumed.
- `check-no-window-spawns`, `check-release-channel`, `test:scripts` all
verified exit 0 locally; `check-file-sizes` correctly exits 1 (the live
outage above).
- Rebased onto latest `main`; only the two workflow files differ.

The `analysis_rerank.rs` unblock (#430) has landed, so `repo-guards`
passes; this branch is rebased on top of it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant