Skip to content

ci: harden review gate and CI enforcement - #2

Merged
akminx merged 8 commits into
mainfrom
chore/workflow-hardening
Jul 24, 2026
Merged

ci: harden review gate and CI enforcement#2
akminx merged 8 commits into
mainfrom
chore/workflow-hardening

Conversation

@akminx

@akminx akminx commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Risk tier

Path-derived minimum (from review-policy.yaml): trust-critical — this PR modifies INVARIANTS.md (trust-critical) and evals/budgets.json (semantic).

  • routine
  • semantic
  • trust-critical

What Changed

  • Added a path-derived risk-tier gate (scripts/check-tier.mjs + review-policy.yaml): trust-critical paths (including the redaction module) require a conscious ack and CODEOWNERS/human-judge review, semantic tier is advisory locally but fails in CI, and the check fails loudly on an unresolvable GILLY_TIER_RANGE instead of passing on an empty diff.
  • Reworked scripts/check-all.mjs and scripts/check-swift.sh: npm-audit network failures FAIL in CI but only WARN locally (offline detection scoped to the error channel so real CVEs still fail), semgrep findings are advisory-only, and the CLAUDE.md-alignment check skips owner/repo reference tokens.
  • Wired the review harness into CI and repo config: new .github/workflows/check-all.yml jobs, CODEOWNERS, PR template, branch-protection doc, .captain/review adversary/fix-verify prompts, and .cursor review/model-routing rules.

Risk Assessment

✅ Low: The latest commit is a well-bounded gate-hardening change that correctly and completely resolves the prior round's findings (offline-audit false-negative scoped to npm's error channel, semgrep findings made truly non-gating), with no new bugs or behavior regressions found on a full pass.

Testing

Exercised the branch's core deliverable — the machine-checked risk-tier gate (scripts/check-tier.mjs) — through its actual CLI, the same way CI and the pre-commit hook call it, across nine scenarios covering both local and --ci modes in an isolated temp git repo (worktree left clean). It correctly enforces the path-derived floor (trust-critical cannot self-declare routine or under-declare semantic), requires a conscious ack for trust-critical locally, treats semantic as advisory locally but blocking in CI, fails loudly on an unresolvable GILLY_TIER_RANGE instead of passing on an empty diff, and maintains a de-duped durable audit log. Full command transcript captured as reviewer-visible evidence; all behaviors passed. This is a CLI/gate change with no rendered UI surface, so the appropriate product artifact is the CLI transcript rather than a screenshot.

Evidence: check-tier gate CLI transcript (9 scenarios: tier enforcement, ack audit log, unresolvable-range fail-loud)
========================================================================
 check-tier.mjs — path-derived risk-tier gate (end-to-end CLI transcript)
 policy: review-policy.yaml (trust-critical: redaction, grants, policy,
         eventlog, protocol, INVARIANTS.md; semantic: client, scheduler, evals)
========================================================================

### S1  routine change (README) -> OK, no declaration needed
$ node scripts/check-tier.mjs
check-tier: 1 changed file(s)
  path-derived minimum: routine
check-tier OK (routine)
   [exit=0]

### S2  trust-critical change (core/src/surface/redaction.ts) declared 'routine'
###     -> MUST FAIL: trust-critical path cannot self-declare routine
$ node scripts/check-tier.mjs --declare=routine
check-tier: 1 changed file(s)
  path-derived minimum: trust-critical
  trust-critical hits:
    - core/src/surface/redaction.ts
   [exit=1]

### S3  trust-critical change, no declaration, no ack -> FAIL (must consciously ack)
$ node scripts/check-tier.mjs
check-tier: 1 changed file(s)
  path-derived minimum: trust-critical
  trust-critical hits:
    - core/src/policy/decide.ts
   [exit=1]

### S4  same trust-critical change WITH GILLY_ACK_TRUST=1 -> OK + durable audit log
$ GILLY_ACK_TRUST=1 node scripts/check-tier.mjs
check-tier: 1 changed file(s)
  path-derived minimum: trust-critical
  trust-critical hits:
    - core/src/policy/decide.ts
check-tier OK (trust-critical locally acked via GILLY_ACK_TRUST=1; logged to /private/tmp/tier-lab.Qn548r/.captain/review/loop-log.md)
   [exit=0]

### S5  semantic change (client/App.swift) LOCAL -> WARN, non-blocking (advisory locally)
$ node scripts/check-tier.mjs
check-tier: 2 changed file(s)
  path-derived minimum: semantic
  semantic hits:
    - client/App.swift
check-tier WARN: path-derived minimum is 'semantic' — not blocking locally; declare 'semantic' on the PR (CI enforces it).
   [exit=0]

### S6  same semantic change in CI (--ci) with NO declaration -> FAIL (CI enforces)
$ node scripts/check-tier.mjs --ci   (GILLY_TIER_RANGE=HEAD~1...HEAD, empty PR body)
     check-tier: 2 changed file(s)
       path-derived minimum: semantic
       semantic hits:
         - client/App.swift
     check-tier FAIL: path minimum is 'semantic' but no tier was declared.
       Check the matching risk-tier box in the PR template (≥ path minimum).


### S7  CI with an UNRESOLVABLE GILLY_TIER_RANGE -> FAIL loudly (never pass on empty diff)
###     (hardening: 95d7eb9 — a bad range must not silently green-light a PR)
$ GILLY_TIER_RANGE=deadbeef...HEAD node scripts/check-tier.mjs --ci
     check-tier FAIL: cannot resolve GILLY_TIER_RANGE 'deadbeef...HEAD' (git diff exited 128). Refusing to pass on an empty diff.
       fatal: ambiguous argument 'deadbeef...HEAD': unknown revision or path not in the working tree.
     Use '--' to separate paths from revisions, like this:
     'git <command> [<revision>...] -- [<file>...]'
     [exit=1  <- verified directly; the shown 0 was a pipe-status artifact of the sed indent]

### S8  CI trust-critical change WITH correct PR declaration -> OK
$ GILLY_TIER_RANGE=HEAD~1...HEAD  --declare=trust-critical
     check-tier: 1 changed file(s)
       path-derived minimum: trust-critical
       trust-critical hits:
         - specs/grants.md
     check-tier OK (declared trust-critical ≥ minimum trust-critical)

### S8b same, but PR under-declares 'semantic' -> FAIL (below path minimum)
$ GILLY_TIER_RANGE=HEAD~1...HEAD  --declare=semantic
     check-tier: 1 changed file(s)
       path-derived minimum: trust-critical
       trust-critical hits:
         - specs/grants.md
     check-tier FAIL: declared 'semantic' is below path-derived minimum 'trust-critical'.
       Raise the PR risk tier (or split the trust-critical files out).

========================================================================
### Durable audit trail — .captain/review/loop-log.md after two local acks
###   (hardening 644292a: header written ONCE, one row per ack, no dup headers)
========================================================================
---- rendered .captain/review/loop-log.md ----
# Review loop log

| Date | Unit / PR | Tier | Reviewers used | Escaped bug? | Fixer regression? | Notes / policy change |
|---|---|---|---|---|---|---|
| 2026-07-24 | local ack | trust-critical | GILLY_ACK_TRUST=1 | no | no | ack for: core/src/policy/decide.ts |
| 2026-07-24 | local ack | trust-critical | GILLY_ACK_TRUST=1 | no | no | ack for: core/src/policy/decide.ts |

Pipeline

Updates from git push no-mistakes

⏭️ **intent** - skipped

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (4) ✅
  • ⚠️ scripts/check-tier.mjs:86 - changedFiles(): the range branch does return sh([&#34;diff&#34;,&#34;--name-only&#34;, range]) with no exit-status check. sh() discards git's non-zero status and returns [] on any failure. Because CI always sets GILLY_TIER_RANGE, an unresolvable base (origin/main not fetched as a remote-tracking ref, or github.event.before = all-zeros on a new-branch/force push) makes the diff error → zero changed files → path minimum 'routine' → the risk-tier gate passes vacuously. This reintroduces exactly the 'soft-lie green' the branch is closing everywhere else. The non-range CI branch (line 91) already guards on r.status === 0 and falls back to HEAD~1; the range branch should do the same — check the exit status and fail loudly (or fall through) instead of returning empty.
  • ℹ️ scripts/check-tier.mjs:110 - minTier(): hits.semantic.push is guarded by min !== &#34;trust-critical&#34;, so any semantic-tier file encountered after the first trust-critical file is dropped from the displayed hits list. Cosmetic only — the pass/fail decision uses min, not the hit lists — but the console diagnostic can under-report which files pushed the tier up.
  • ℹ️ scripts/check-all.mjs:302 - The new owner/repo heuristic (^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ && !existsSync) skips any two-segment token absent from disk to allow references like farzaa/clicky. Side effect: a genuinely-missing two-segment repo-relative path (e.g. scripts/foo) is now silently ignored by the CLAUDE.md-alignment check rather than flagged. Deliberate loosening per the comment, but it does narrow that check's coverage.

🔧 Fix: fail check-tier loudly on unresolvable GILLY_TIER_RANGE
3 issues (2 warnings, 1 info) still open:

  • ⚠️ review-policy.yaml:39 - trust_critical_paths uses **/redaction/**, which compiles to ^.*/redaction/.*$ and only matches a redaction/ directory. The real redaction module is a single file, core/src/surface/redaction.ts (confirmed: arch-checks allow-lists it as a file, and the glob returns false against it). No other trust-critical glob matches it, so a change touching only the redaction module is path-derived tier 'routine' and skips human_judge + codeowners — defeating INVARIANTS row 13, the invariant this same branch adds an arch-check for. The mirrored **/redaction/** lines in .github/CODEOWNERS have the identical gap. Fix: add core/src/surface/redaction.ts (or **/redaction*.ts) to trust_critical_paths and CODEOWNERS.
  • ⚠️ scripts/check-all.mjs:421 - checkRiskTier(false) is now part of the main check-all suite, and scripts/hooks/pre-commit:12 also runs check-tier.mjs standalone. In local mode check-tier FAILs when the path minimum is 'semantic' (or higher) and no tier is declared. Trust-critical has a lightweight ack (GILLY_ACK_TRUST=1) but semantic has none, and check-all passes no --declare through — so every commit touching client/** (the whole Swift client) or the semantic specs files fails pre-commit unless the developer exports GILLY_DECLARE_TIER=semantic each time. That friction pushes developers to permanently export the var, which hollows out the conscious-ack intent. Consider a GILLY_ACK_SEMANTIC-style escape, or scoping the local tier check to trust-critical only and leaving the full semantic/routine enforcement to CI PRs.
  • ℹ️ .github/workflows/check-all.yml:38 - The push-event tier step sets GILLY_TIER_RANGE to ${{ github.event.before }}...${{ github.sha }}. On the first push that creates a branch, github.event.before is all-zeros, the range is unresolvable, and check-tier now exits 1 (the round-1 fail-loud behavior). New branches' initial push will red until a PR is opened (pull_request uses base_ref, which resolves). This is the deliberate 'refuse to pass on an empty diff' tradeoff — noting it so the operational cost is understood.

🔧 Fix: classify redaction trust-critical; make local semantic tier advisory
2 warnings still open:

  • ⚠️ scripts/hooks/pre-commit:12 - The tier check runs twice per pre-commit: check-all.mjs:421 (checkRiskTier(false)) spawns check-tier.mjs, then pre-commit:12 runs it standalone. When GILLY_ACK_TRUST=1 on a trust-critical commit, check-tier.mjs:210 appendFileSync writes an audit row on EACH run — confirmed live: .captain/review/loop-log.md already contains two identical '2026-07-23 | local ack | ... ack for: INVARIANTS.md' rows from one commit. Two further problems: (a) the log is a tracked file, so every ack'd commit leaves the working tree dirty with rows not included in the commit; (b) a row is appended even if a later check aborts the commit, recording acks for commits that never landed. Fix: drop the redundant pre-commit:12 invocation (the comment already notes it is 'also embedded in check-all'), so the tier check — and its logging — runs once.
  • ⚠️ scripts/check-all.mjs:351 - checkDeps was changed SKIP→FAIL when npm audit hits a network error (enotfound/etimedout/offline/...). check-all runs at pre-commit (hook line 8), so any commit made without network connectivity now hard-fails the gate and blocks the commit. Unlike the missing-tool FAILs (biome/tsc/vitest — a one-time install), registry reachability is transient/environmental. This creates exactly the friction that pushes developers toward --no-verify, which this branch is otherwise closing off. Consider distinguishing local vs CI here (as was done for the semantic tier): treat a genuine registry-unreachable result as SKIP/WARN locally while keeping FAIL in CI, rather than blocking all offline commits.

🔧 Fix: de-dupe tier audit log; warn (not fail) on offline npm audit locally
2 issues (1 warning, 1 info) still open:

  • ⚠️ scripts/check-all.mjs:352 - checkDeps now downgrades a matched 'offline' result from FAIL to a non-blocking WARN locally. But the offline heuristic matches the token regex against blob = (r.stdout + r.stderr).toLowerCase() — i.e. the full npm audit --json REPORT, not just an error stream. When real high/critical vulns exist, npm exits non-zero (r.status truthy) AND emits the advisory JSON on stdout, whose via[].title/name/dependency-path fields commonly contain the word network (and possibly offline). Such a collision makes r.status &amp;&amp; /network|offline|.../ true, so the code treats a genuine-vulnerability run as 'registry unreachable' and returns WARN — silently unblocking a commit that has real CVEs. Before this change the same match returned FAIL, so the collision was harmless; the SKIP→WARN change turns it into a local security-gate false-negative (CI still FAILs, but with a misleading 'registry unreachable' message). Fix: only treat as offline when the audit produced no parseable report (e.g. JSON.parse(r.stdout).metadata?.vulnerabilities is absent) or scope the token regex to stderr / an {&#34;error&#34;:...} payload, so an advisory title containing 'network' can't suppress a real finding.
  • ℹ️ scripts/check-swift.sh:153 - The green path is relabeled semgrep (advisory) and the not-installed branch now says 'advisory only, not part of GATE PASS/FAIL', but the findings branch is still red &#34;semgrep findings&#34;, which increments FAIL and produces GATE FAIL. So when semgrep IS installed and reports findings, it is not advisory — it hard-fails the Swift gate, contradicting both the new label and CLAUDE.md's 'semgrep advisory-only'. Either make findings non-gating (echo/warn) to match the stated policy, or drop the 'advisory' framing.

🔧 Fix: scope offline audit detection to error channel; make semgrep findings advisory
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • node scripts/check-tier.mjs on a routine (README) change → OK, exit 0
  • node scripts/check-tier.mjs --declare=routine on a trust-critical redaction.ts change → FAIL (below path minimum), exit 1
  • node scripts/check-tier.mjs on trust-critical change, no ack → FAIL, exit 1
  • GILLY_ACK_TRUST=1 node scripts/check-tier.mjs on trust-critical change → OK + audit-log row written
  • node scripts/check-tier.mjs on a semantic (client/App.swift) change locally → WARN, non-blocking (exit 0)
  • GILLY_TIER_RANGE=HEAD node scripts/check-tier.mjs --ci semantic change, no PR declaration → FAIL, exit 1
  • GILLY_TIER_RANGE=deadbeef...HEAD node scripts/check-tier.mjs --ci → FAIL loudly, exit 1 (verified directly)
  • GILLY_TIER_RANGE=HEAD~1...HEAD node scripts/check-tier.mjs --ci --declare=trust-critical on specs/grants.md → OK; --declare=semantic → FAIL
  • Two consecutive GILLY_ACK_TRUST=1 runs → single header + two rows in .captain/review/loop-log.md (de-dup verified)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

akminx and others added 8 commits July 23, 2026 17:33
Make risk classification, coverage, Swift validation, and adversarial review requirements binding so protected PRs cannot pass on skipped or incomplete evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
The two GILLY_ACK_TRUST audit rows sat below the footer line in the
tracked loop-log template — leaked local acks, not real review
incidents. Restore the clean template state.

Risk-Tier: trust-critical
@akminx
akminx merged commit c2d1c7a into main Jul 24, 2026
4 checks passed
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