HOMELAB-2612: fix(forgejo): classify Gradle dependency metadata as dangerous #90

Merged
pi-landing merged 1 commit from plane/HOMELAB-2612-gradle-danger into main 2026-11-04 08:32:20 +00:00
Owner

Ticket: HOMELAB-2612 (child of epic HOMELAB-2518).

The gap

DEFAULT_LANDING_POLICY.dangerousPaths in forgejo/landing.ts did not classify Gradle dependency metadata as dangerous class, so a PR that changes dependency selection or dependency integrity could land on the single-verdict routine path. A PR's class is read from its changed paths — a path the list does not name is a path that lands on one verdict.

Live instance: Homelab/ghostty-in-the-shell PR #135 (merged). Its own body says "Dangerous class — this PR changes gradle/verification-metadata.xml, which is on the ADR 0005 dangerous-paths list" — and the census classed it normal. GITS-465 filed that. Read-only evidence; nothing was posted on that PR or on any GITS ticket (G6).

PR #135's changed-file list, used verbatim as the fixture:

gradle/verification-metadata.xml
gradle/libs.versions.toml
push/build.gradle.kts
docs/deps.md

The change

forgejo/landing.ts — a "Dependency integrity" group beside the other supply-chain entries

"**/gradle/libs.versions.toml",
"**/gradle/verification-metadata.xml",

Why these two and not the rest of a Gradle build, in the comment that ships with them: libs.versions.toml is the version catalogue — it selects the coordinates and versions the build resolves; verification-metadata.xml pins the checksums and trusted keys those artifacts must match. Changing either changes what the build fetches and what it trusts, which is a supply-chain act, not build plumbing.

Why **/ and not the plain repo-root path

pathMatches() compiles **\/ to (?:.*\/)? — zero or more segments — so one pattern per file covers the repo root and a nested module, and the $ anchor is what keeps a file that merely names the path out. Probe against the real pathMatches (temporary probe file, run then removed; the same matrix is asserted permanently in the test file):

path **/gradle/libs.versions.toml **/gradle/verification-metadata.xml
gradle/libs.versions.toml MATCH no match
gradle/verification-metadata.xml no match MATCH
app/gradle/libs.versions.toml MATCH no match
app/gradle/verification-metadata.xml no match MATCH
android/app/gradle/libs.versions.toml MATCH no match
./gradle/libs.versions.toml MATCH no match
./app/gradle/verification-metadata.xml no match MATCH
push/build.gradle.kts no match no match
build.gradle.kts no match no match
gradle/wrapper/gradle-wrapper.properties no match no match
docs/deps.md no match no match
docs/gradle-notes.md no match no match
docs/design/gradle/libs.versions.toml.md no match no match

./-prefixed forms need no extra pattern: pathMatches strips a leading ./ on both sides.

docs/forgejo.md — leg 4's dangerous-path list

The two patterns are added to the documented list, plus a paragraph in the same voice as the rest of that section: what a version catalogue selects, what verification-metadata.xml trusts, the #135 precedent, why the patterns lead with **/, and why build.gradle.kts / wrapper properties / Gradle prose deliberately stay routine (a false positive pushes ordinary PRs onto the two-session path).

Red → green

Red captured before touching landing.ts, at working-files/homelab-2518/HOMELAB-2612-red.log:

Test Files  1 failed (1)
     Tests  8 failed | 3 passed (11)

The 8 failing assertion names:

  • classes PR #135's exact file list dangerous, matching both Gradle files
  • matches each Gradle metadata file on its own, at the repo root
  • matches the nested-module forms, with and without a leading './'
  • refuses PR #135's file list with ONE session, naming the dangerous panel
  • refuses the same session approving twice — that is ONE approval
  • permits PR #135's file list with TWO independent sessions
  • declares both patterns in DEFAULT_LANDING_POLICY.dangerousPaths
  • still lets FORGEJO_DANGEROUS_PATHS REPLACE the list

The 3 that already passed are the negative controls (routine paths, build scripts/wrapper/prose, and the routine counterpart of the end-to-end case) — they had to pass before and must keep passing after.

Green at working-files/homelab-2518/HOMELAB-2612-green.log:

  • npx tsc --noEmit — clean (exit 0)
  • npx vitest run — 20 files / 570 tests passed (baseline on this main, fb18380: 19 files / 559 tests; so +1 file / +11 tests, nothing else moved)

What the tests pin (11 cases, forgejo/tests/landing-gradle-dangerous.test.ts)

  1. PR #135's exact list is dangerous: true and matches is exactly the two Gradle files.
  2. Each Gradle metadata file matches on its own at the repo root.
  3. Nested forms match: app/gradle/..., android/app/gradle/..., and the ./-prefixed variants.
  4. The ticket's Verify: a PR with that list and ONE exact-head approval declaring model: and session: is refused with dangerous-class change with an incomplete review panel + two independent review sessions, and the refusal names dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml; the same session approving twice is refused as 1 independent review session(s); TWO independent sessions are permitted (dangerous class: two independent sessions on the head).
  5. Negative controls stay normal, individually and together: push/build.gradle.kts, docs/deps.md, gradle/wrapper/gradle-wrapper.properties, build.gradle.kts, settings.gradle.kts, docs/gradle-notes.md, docs/design/gradle/libs.versions.toml.md, docs/design/gradle/verification-metadata.xml.md.
  6. The same single verdict that is refused for #135's list is sufficient for a PR whose diff touches none of these paths (not dangerous class: one exact-head verdict is sufficient) — the class, not the fixture, is what refuses.
  7. Both patterns are declared in DEFAULT_LANDING_POLICY.dangerousPaths, and FORGEJO_DANGEROUS_PATHS still replaces the list (under secrets/** the Gradle files are routine again, and against the default policy they are not — so the replacement is the only reason).

Existing tests asserting dangerousPaths

None asserted the length or exact contents of the default list, so none needed re-pointing. The only two assertions anywhere on dangerousPaths are resolveLandingPolicy({FORGEJO_DANGEROUS_PATHS:"secrets/**"}).dangerousPaths === ["secrets/**"] (landing-sessions.test.ts:258, landing-record.test.ts:134 — both unaffected, because the override replaces the list) and DEFAULT_LANDING_POLICY.dangerousPaths toContain("**/rbac/**") (landing-blockers.test.ts:333). Full suite green, no test deleted or weakened.

Collision check

forgejo_list_prs on Homelab/pi-extensions at start: one open PR, #68 (HOMELAB-2321, plane/HOMELAB-2321-poll-loop-guard), whose diff is pi-swarm-suite/src/core/index.ts only. Nothing open touched forgejo/landing.ts or docs/forgejo.md.

Scope

Three files: forgejo/landing.ts, docs/forgejo.md, forgejo/tests/landing-gradle-dangerous.test.ts. No merge, no review, no deployment performed by this worker — the PR is here for review and landing by someone else.

Model honesty: this worker ran pinned to omniroute/deepseek-flash; I attest to that configuration and to the commands whose output is quoted above, and to nothing I did not run.

Ticket: HOMELAB-2612 (child of epic HOMELAB-2518). ## The gap `DEFAULT_LANDING_POLICY.dangerousPaths` in `forgejo/landing.ts` did not classify Gradle dependency metadata as dangerous class, so a PR that changes dependency selection or dependency integrity could land on the single-verdict routine path. A PR's class is read from its changed paths — a path the list does not name is a path that lands on one verdict. **Live instance:** `Homelab/ghostty-in-the-shell` PR **#135** (merged). Its own body says *"Dangerous class — this PR changes `gradle/verification-metadata.xml`, which is on the ADR 0005 dangerous-paths list"* — and the census classed it **normal**. GITS-465 filed that. Read-only evidence; nothing was posted on that PR or on any GITS ticket (G6). PR #135's changed-file list, used verbatim as the fixture: ``` gradle/verification-metadata.xml gradle/libs.versions.toml push/build.gradle.kts docs/deps.md ``` ## The change ### `forgejo/landing.ts` — a "Dependency integrity" group beside the other supply-chain entries ```ts "**/gradle/libs.versions.toml", "**/gradle/verification-metadata.xml", ``` Why these two and not the rest of a Gradle build, in the comment that ships with them: `libs.versions.toml` is the version **catalogue** — it selects the coordinates and versions the build resolves; `verification-metadata.xml` **pins** the checksums and trusted keys those artifacts must match. Changing either changes what the build fetches and what it trusts, which is a supply-chain act, not build plumbing. ### Why `**/` and not the plain repo-root path `pathMatches()` compiles `**\/` to `(?:.*\/)?` — **zero** or more segments — so one pattern per file covers the repo root *and* a nested module, and the `$` anchor is what keeps a file that merely *names* the path out. Probe against the real `pathMatches` (temporary probe file, run then removed; the same matrix is asserted permanently in the test file): | path | `**/gradle/libs.versions.toml` | `**/gradle/verification-metadata.xml` | |---|---|---| | `gradle/libs.versions.toml` | MATCH | no match | | `gradle/verification-metadata.xml` | no match | MATCH | | `app/gradle/libs.versions.toml` | MATCH | no match | | `app/gradle/verification-metadata.xml` | no match | MATCH | | `android/app/gradle/libs.versions.toml` | MATCH | no match | | `./gradle/libs.versions.toml` | MATCH | no match | | `./app/gradle/verification-metadata.xml` | no match | MATCH | | `push/build.gradle.kts` | no match | no match | | `build.gradle.kts` | no match | no match | | `gradle/wrapper/gradle-wrapper.properties` | no match | no match | | `docs/deps.md` | no match | no match | | `docs/gradle-notes.md` | no match | no match | | `docs/design/gradle/libs.versions.toml.md` | no match | no match | `./`-prefixed forms need no extra pattern: `pathMatches` strips a leading `./` on both sides. ### `docs/forgejo.md` — leg 4's dangerous-path list The two patterns are added to the documented list, plus a paragraph in the same voice as the rest of that section: what a version catalogue selects, what `verification-metadata.xml` trusts, the #135 precedent, why the patterns lead with `**/`, and why `build.gradle.kts` / wrapper properties / Gradle prose deliberately stay routine (a false positive pushes ordinary PRs onto the two-session path). ## Red → green Red captured **before** touching `landing.ts`, at `working-files/homelab-2518/HOMELAB-2612-red.log`: ``` Test Files 1 failed (1) Tests 8 failed | 3 passed (11) ``` The 8 failing assertion names: - `classes PR #135's exact file list dangerous, matching both Gradle files` - `matches each Gradle metadata file on its own, at the repo root` - `matches the nested-module forms, with and without a leading './'` - `refuses PR #135's file list with ONE session, naming the dangerous panel` - `refuses the same session approving twice — that is ONE approval` - `permits PR #135's file list with TWO independent sessions` - `declares both patterns in DEFAULT_LANDING_POLICY.dangerousPaths` - `still lets FORGEJO_DANGEROUS_PATHS REPLACE the list` The 3 that already passed are the **negative controls** (routine paths, build scripts/wrapper/prose, and the routine counterpart of the end-to-end case) — they had to pass before and must keep passing after. Green at `working-files/homelab-2518/HOMELAB-2612-green.log`: - `npx tsc --noEmit` — clean (exit 0) - `npx vitest run` — **20 files / 570 tests passed** (baseline on this `main`, `fb18380`: 19 files / 559 tests; so +1 file / +11 tests, nothing else moved) ## What the tests pin (11 cases, `forgejo/tests/landing-gradle-dangerous.test.ts`) 1. PR #135's exact list is `dangerous: true` and `matches` is exactly the two Gradle files. 2. Each Gradle metadata file matches on its own at the repo root. 3. Nested forms match: `app/gradle/...`, `android/app/gradle/...`, and the `./`-prefixed variants. 4. **The ticket's Verify:** a PR with that list and ONE exact-head approval declaring `model:` and `session:` is **refused** with `dangerous-class change with an incomplete review panel` + `two independent review sessions`, and the refusal names `dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml`; the same session approving twice is refused as `1 independent review session(s)`; TWO independent sessions are **permitted** (`dangerous class: two independent sessions on the head`). 5. Negative controls stay normal, individually and together: `push/build.gradle.kts`, `docs/deps.md`, `gradle/wrapper/gradle-wrapper.properties`, `build.gradle.kts`, `settings.gradle.kts`, `docs/gradle-notes.md`, `docs/design/gradle/libs.versions.toml.md`, `docs/design/gradle/verification-metadata.xml.md`. 6. The same single verdict that is refused for #135's list is sufficient for a PR whose diff touches none of these paths (`not dangerous class: one exact-head verdict is sufficient`) — the class, not the fixture, is what refuses. 7. Both patterns are declared in `DEFAULT_LANDING_POLICY.dangerousPaths`, and `FORGEJO_DANGEROUS_PATHS` still **replaces** the list (under `secrets/**` the Gradle files are routine again, and against the default policy they are not — so the replacement is the only reason). ## Existing tests asserting `dangerousPaths` None asserted the length or exact contents of the default list, so none needed re-pointing. The only two assertions anywhere on `dangerousPaths` are `resolveLandingPolicy({FORGEJO_DANGEROUS_PATHS:"secrets/**"}).dangerousPaths === ["secrets/**"]` (`landing-sessions.test.ts:258`, `landing-record.test.ts:134` — both unaffected, because the override replaces the list) and `DEFAULT_LANDING_POLICY.dangerousPaths` `toContain("**/rbac/**")` (`landing-blockers.test.ts:333`). Full suite green, no test deleted or weakened. ## Collision check `forgejo_list_prs` on `Homelab/pi-extensions` at start: **one** open PR, #68 (`HOMELAB-2321`, `plane/HOMELAB-2321-poll-loop-guard`), whose diff is `pi-swarm-suite/src/core/index.ts` only. Nothing open touched `forgejo/landing.ts` or `docs/forgejo.md`. ## Scope Three files: `forgejo/landing.ts`, `docs/forgejo.md`, `forgejo/tests/landing-gradle-dangerous.test.ts`. No merge, no review, no deployment performed by this worker — the PR is here for review and landing by someone else. Model honesty: this worker ran pinned to `omniroute/deepseek-flash`; I attest to that configuration and to the commands whose output is quoted above, and to nothing I did not run.
HOMELAB-2612: fix(forgejo): classify Gradle dependency metadata as dangerous
All checks were successful
CI / pr-title (pull_request) Successful in 1s
CI / skill-args (pull_request) Successful in 52s
CI / pi-otel-telemetry (pull_request) Successful in 58s
CI / ask-user-question (pull_request) Successful in 56s
tests / python (pull_request) Successful in 3s
CI / todo (pull_request) Successful in 58s
CI / forgejo-ci (pull_request) Successful in 1m0s
CI / plane (pull_request) Successful in 1m11s
CI / refocus (pull_request) Successful in 1m10s
CI / mcp-bridge (pull_request) Successful in 23s
CI / nats-intercom (pull_request) Successful in 24s
CI / redact (pull_request) Successful in 30s
CI / pi-friction (pull_request) Successful in 39s
CI / forgejo (pull_request) Successful in 1m33s
CI / pi-swarm-suite (pull_request) Successful in 3m47s
913f803713
DEFAULT_LANDING_POLICY.dangerousPaths did not name Gradle's dependency
metadata, so a PR that changes which dependencies the build resolves — and
which checksums those artifacts must match — was classified routine and could
land on a single exact-head verdict.

Live instance: Homelab/ghostty-in-the-shell PR #135 (GITS-465, merged) changed
exactly `gradle/verification-metadata.xml` and `gradle/libs.versions.toml`, said
so in its own body, and the census classed it NORMAL. A PR's class comes from
its paths, so a path the list does not name is a path that lands on one verdict.

- forgejo/landing.ts: add `**/gradle/libs.versions.toml` (the version catalogue
  SELECTS the dependencies and versions the build resolves) and
  `**/gradle/verification-metadata.xml` (it PINS the checksums and trusted keys
  those artifacts must match) to DEFAULT_LANDING_POLICY.dangerousPaths, as a
  "Dependency integrity" group beside the other supply-chain/infra entries. The
  leading `**/` compiles to `(?:.*/)?` in pathMatches — zero or more segments —
  so one pattern each covers the repo root and a nested module, and the `$`
  anchor keeps a file that merely names the path out of the class.
- docs/forgejo.md: leg 4's dangerous-path list gains the two patterns and says
  why dependency integrity belongs there (what it selects, what it trusts, and
  the #135 precedent), including the false-positive cost of the build scripts
  and wrapper properties that stay routine.
- forgejo/tests/landing-gradle-dangerous.test.ts (new, 11 cases): PR #135's file
  list verbatim is dangerous with both Gradle files in `matches`; ONE
  session- and model-declaring verdict on it is REFUSED with the dangerous-panel
  wording and the same session twice is one approval; TWO independent sessions
  are permitted; nested `app/...` and `./`-prefixed forms match; the routine
  files in that same PR, build scripts, wrapper properties, Gradle prose and
  `docs/design/gradle/libs.versions.toml.md` stay normal; the default list
  carries both patterns and FORGEJO_DANGEROUS_PATHS still REPLACES it.

Red first: 8 failed / 3 passed before the pattern change (the 3 passing are the
negative controls, which must stay green either way). Green after: tsc --noEmit
clean, full suite 20 files / 570 tests (baseline 19/559, +1 file / +11 tests).
No existing test asserted the length or exact contents of dangerousPaths — the
only two assertions are the env-override equality and a `toContain("**/rbac/**")`
— so none needed re-pointing.
ld-reviewer approved these changes 2026-11-04 08:28:24 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 913f803713
session: 01a1a5a7-9537-7446-a658-2500c1ca69d6

What I reviewed

Exact head 913f80371310144e2d3d1deba54de9e9465462f2 (branch plane/HOMELAB-2612-gradle-danger, base main fb1838001619e903868816ecbf4bbc575307573c), read in the worktree /home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2612-gradle-danger, which is at that sha with a clean git status.

git diff fb18380..913f803 --stat is three files, +272/−1: docs/forgejo.md (+23/−1), forgejo/landing.ts (+10), forgejo/tests/landing-gradle-dangerous.test.ts (+240, new).

The whole landing.ts delta is ten added lines inside DEFAULT_LANDING_POLICY.dangerousPaths plus their comment; no other line of the file moved, so the lock's legs 1, 2, 4 and 5 are untouched code and leg 3 only sees two more entries in the list it already iterated.

CI is green at this exact head: run #232, all 14 job contexts success (forgejo, forgejo-ci, plane, pr-title, redact, mcp-bridge, nats-intercom, pi-friction, pi-otel-telemetry, pi-swarm-suite, ask-user-question, refocus, skill-args, todo); run #233 (python) also pass, same sha 913f803713.

The live instance is real, and the fixture is the real file set

I read Homelab/ghostty-in-the-shell PR #135 through the API rather than trusting the PR body: GET /pulls/135/files returns exactly docs/deps.md, gradle/libs.versions.toml, gradle/verification-metadata.xml, push/build.gradle.kts — the same four paths the test fixture uses (the fixture lists them in a different order; classification is order-independent, and only the recorded dangerousMatches ordering follows input order, which the test pins to its own fixture order — cosmetic, noted below).

That PR's own body states it is dangerous class because it changes gradle/verification-metadata.xml, and the ticket's report is that the census classed it normal — i.e. it landed on the single-verdict routine path. The gap the ticket names is therefore a real classification gap, not a hypothetical.

Pattern semantics — run, not inferred

pathMatches() at forgejo/landing.ts:1084 escapes ., then rewrites **/ to (?:.*/)? (zero or more segments, via sentinels so the single-* rewrite cannot eat it), * to [^/]*, and anchors with ^…$. So **/gradle/libs.versions.toml compiles to ^(?:.*/)?gradle/libs\.versions\.toml$.

I loaded the real pathMatches from that worktree under node --experimental-strip-types and ran a matrix through it. Matches: gradle/libs.versions.toml, gradle/verification-metadata.xml, app/gradle/..., android/app/gradle/..., ./gradle/..., ./app/gradle/..., ../gradle/libs.versions.toml, app/../gradle/libs.versions.toml.

Correct non-matches: gradle/libs.versions.toml.md, docs/design/gradle/libs.versions.toml.md, docs/design/gradle/verification-metadata.xml.md, gradle/verification-metadata.xml.bak, xgradle/libs.versions.toml, gradle/libsXversions.toml, gradle/libs.versions.toml/, gradle//libs.versions.toml.

So the $ anchor is what stops a file that merely names the path, the zero-or-more-segment reading really does cover root and nested module with one entry, and a ..-bearing path cannot be used to step around the pattern. The zero-segment case is the one that matters most for the ticket: the vanilla root layout is covered, not only nested modules.

End-to-end, driven by me (not by the PR's test file)

I built LandingLockInput myself against evaluateLandingLock and ran PR #135's file list:

  • ONE trusted exact-head verdict from one review session ⇒ refused: dangerous-class change with an incomplete review panel, dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml, 1 independent review session(s) (sess-me-0001) — dangerous class needs two approvals from two independent sessions.
  • The same session approving twice ⇒ refused, still 1 independent review session(s).
  • TWO independent sessions ⇒ ok=true with the note dangerous class: two independent sessions on the head.
  • A PR touching only push/build.gradle.kts + docs/deps.md with ONE session ⇒ ok=true, note not dangerous class: one exact-head verdict is sufficient.

That is the ticket's Verify, in both directions, on the real lock.

Interactions

  • FORGEJO_DANGEROUS_PATHS still REPLACES the list: under secrets/**, PR #135's files classify {dangerous: false, matches: []} and secrets/db.yaml still matches — the operator opt-out is intact and the default is not silently re-added.
  • The legacy owner-scoped panel still governs for any class, including a Gradle file: with jesus/... and sendrast/... (including a URL-shaped ref with different casing) the refusal lines read review panel policy: legacy owner-scoped panel, i.e. the two-declared-models/one-premium rule the 05:10Z ruling protects. Homelab/ghostty-in-the-shell resolves to owner homelab, which is not in legacyPanelOwners, so the modern two-session rule is the one that refuses — the test pins that boundary correctly.
  • An unreadable changed-file listing still fails closed (the changed-file list is unavailable) — the new entries do not open a hole there.
  • DEFAULT_LANDING_POLICY.dangerousPaths is now 33 entries and contains both new patterns exactly once.

Over-match hunt

Realistic false positives I could construct are vendored or fixture copies that carry the exact path: third_party/vendor/gradle/libs.versions.toml and docs/fixtures/gradle/libs.versions.toml both MATCH, because they are literally the path. Cost of that is one extra reviewer, never a safety hole, and I looked for live instances: pi-extensions (the whole tree), the homelab checkout, and ghostty-in-the-shell — no repo other than ghostty-in-the-shell contains either file at all, so there is no current false positive in the fleet.

Correctly left alone by running them: push/build.gradle.kts, build.gradle.kts, settings.gradle.kts, gradle/wrapper/gradle-wrapper.properties, gradle/wrapper/gradle-wrapper.jar, gradle/gradle.properties, buildSrc/src/main/kotlin/Deps.kt, docs/deps.md, docs/gradle-notes.md.

Case: GRADLE/libs.versions.toml and gradle/Libs.versions.toml do not match, which is correct — Gradle looks for exactly gradle/libs.versions.toml, and git paths are case-sensitive.

The negative controls in the test file are real assertions on classifyChangedPaths, not tautologies: they assert {dangerous:false, matches:[]} per path and again for the whole routine set, and the red log shows those three controls were the only assertions PASSING before the fix — exactly what a control is for.

Under-match hunt — what a path these patterns miss can still do

I ran each of these through the real matcher; none match, and each is a genuine dependency-integrity act that therefore stays routine: gradle/wrapper/gradle-wrapper.properties (its distributionUrl/distributionSha256Sum repoint the whole Gradle distribution), settings.gradle.kts + build.gradle.kts (plugin and dependency coordinates), buildSrc/src/main/kotlin/Deps.kt, gradle/dependency-locks/x.lockfile, gradle.properties, and a catalogue at a non-default location (libs.versions.toml, catalog/deps.toml) which versionCatalogs { … from(files(...)) } permits.

This is scope, not a defect in what shipped: the ticket asks for the two default files, the PR body states the exclusion deliberately (a false positive pushes ordinary PRs onto the two-session path), and nothing here regresses — those paths were routine before and are routine after. I recommend a follow-up ticket for distributionUrl/distributionSha256Sum in wrapper properties rather than widening this PR.

One pre-existing bound, unchanged by this PR: the changed-file listing is capped at 10 pages × 50 = 500 paths (api.ts listPRFiles, verdict-cli.ts paged), so a PR over 500 files could place this metadata past page 10 for every dangerous pattern alike. Not introduced here and not a reason to hold this change.

Tests

In /home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2612-gradle-danger/forgejo: npx tsc --noEmit exit 0, and npx vitest run gives Test Files 20 passed (20), Tests 570 passed (570) — matching the expected 20 files / 570 tests, including the new tests/landing-gradle-dangerous.test.ts (11 tests).

The pre-fix log working-files/homelab-2518/HOMELAB-2612-red.log is a genuine red: 8 failing assertions with real assertion diffs (expected false to be true, expected { dangerous: false, matches: [] } to deeply equal { dangerous: true, … }) — not import errors, not compile failures — against base fb18380 before landing.ts was touched, with the 3 negative controls passing. The green log matches what I reproduced.

No existing test asserted the length or exact contents of the default list, so nothing needed re-pointing; the only other dangerousPaths assertions in the tree are the two ["secrets/**"] override assertions and the toContain("**/rbac/**") control, all still green.

Docs

docs/forgejo.md adds dependency integrity to the leg-4 dangerous-class sentence with both patterns spelled out, plus a paragraph on what the catalogue selects and what the metadata trusts, the #135 precedent, why the patterns lead with **/, and why build scripts, wrapper properties and Gradle prose stay routine. Every claim there matches what I ran: the zero-or-more-segment reading, and the anchored non-match for docs/design/gradle/libs.versions.toml.md. I found no overclaim — in particular the docs describe the two patterns only, and so does the code.

Observations — none of these is a hold, and none asks for a change to this head

  1. The leg-3 refusal's class-summary sentence still enumerates the old categories (RBAC, secrets, network policy, Terraform, DB schema/migrations, node config, oversight paths, runtime/landing code) without dependency integrity. The very next line above it names the actual files in dangerous paths touched: …, so nothing is hidden from an operator; adding a phrase there is a tidy-up, not a correction.
  2. The deployed extension /home/aaron/.pi/agent/extensions/forgejo/landing.ts contains zero occurrences of these patterns, so the class takes effect only at deploy — which is the ticket's own OVERSEER: READY TO DEPLOY step, and it means nobody should read the census as already enforcing this.
  3. The test docblock calls the fixture verbatim; the SET is exactly the API's four files, the ORDER is the fixture's own. Harmless, and worth knowing if the fixture is ever reordered, since one assertion pins matches to fixture order.

Hygiene

Read-only throughout: no code edit, no merge, no deploy, no writes outside my scratch directory, nothing written under ~/.pi/agent/extensions, and no Plane or GITS write — GET /issues/135/comments on ghostty-in-the-shell returns 0 comments, so the evidence read left no trace on that PR.

Dispatched as one of the two independent sessions this dangerous-class change requires; the second seat reviews concurrently and I neither read nor waited for it.

I approve this head.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 913f80371310144e2d3d1deba54de9e9465462f2 session: 01a1a5a7-9537-7446-a658-2500c1ca69d6 ### What I reviewed Exact head `913f80371310144e2d3d1deba54de9e9465462f2` (branch `plane/HOMELAB-2612-gradle-danger`, base `main` `fb1838001619e903868816ecbf4bbc575307573c`), read in the worktree `/home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2612-gradle-danger`, which is at that sha with a clean `git status`. `git diff fb18380..913f803 --stat` is three files, +272/−1: `docs/forgejo.md` (+23/−1), `forgejo/landing.ts` (+10), `forgejo/tests/landing-gradle-dangerous.test.ts` (+240, new). The whole `landing.ts` delta is ten added lines inside `DEFAULT_LANDING_POLICY.dangerousPaths` plus their comment; no other line of the file moved, so the lock's legs 1, 2, 4 and 5 are untouched code and leg 3 only sees two more entries in the list it already iterated. CI is green at this exact head: run #232, all 14 job contexts `success` (`forgejo`, `forgejo-ci`, `plane`, `pr-title`, `redact`, `mcp-bridge`, `nats-intercom`, `pi-friction`, `pi-otel-telemetry`, `pi-swarm-suite`, `ask-user-question`, `refocus`, `skill-args`, `todo`); run #233 (`python`) also `pass`, same sha `913f803713`. ### The live instance is real, and the fixture is the real file set I read `Homelab/ghostty-in-the-shell` PR #135 through the API rather than trusting the PR body: `GET /pulls/135/files` returns exactly `docs/deps.md`, `gradle/libs.versions.toml`, `gradle/verification-metadata.xml`, `push/build.gradle.kts` — the same four paths the test fixture uses (the fixture lists them in a different order; classification is order-independent, and only the recorded `dangerousMatches` ordering follows input order, which the test pins to its own fixture order — cosmetic, noted below). That PR's own body states it is dangerous class because it changes `gradle/verification-metadata.xml`, and the ticket's report is that the census classed it normal — i.e. it landed on the single-verdict routine path. The gap the ticket names is therefore a real classification gap, not a hypothetical. ### Pattern semantics — run, not inferred `pathMatches()` at `forgejo/landing.ts:1084` escapes `.`, then rewrites `**/` to `(?:.*/)?` (zero or more segments, via sentinels so the single-`*` rewrite cannot eat it), `*` to `[^/]*`, and anchors with `^…$`. So `**/gradle/libs.versions.toml` compiles to `^(?:.*/)?gradle/libs\.versions\.toml$`. I loaded the real `pathMatches` from that worktree under `node --experimental-strip-types` and ran a matrix through it. Matches: `gradle/libs.versions.toml`, `gradle/verification-metadata.xml`, `app/gradle/...`, `android/app/gradle/...`, `./gradle/...`, `./app/gradle/...`, `../gradle/libs.versions.toml`, `app/../gradle/libs.versions.toml`. Correct non-matches: `gradle/libs.versions.toml.md`, `docs/design/gradle/libs.versions.toml.md`, `docs/design/gradle/verification-metadata.xml.md`, `gradle/verification-metadata.xml.bak`, `xgradle/libs.versions.toml`, `gradle/libsXversions.toml`, `gradle/libs.versions.toml/`, `gradle//libs.versions.toml`. So the `$` anchor is what stops a file that merely names the path, the zero-or-more-segment reading really does cover root and nested module with one entry, and a `..`-bearing path cannot be used to step around the pattern. The zero-segment case is the one that matters most for the ticket: the vanilla root layout is covered, not only nested modules. ### End-to-end, driven by me (not by the PR's test file) I built `LandingLockInput` myself against `evaluateLandingLock` and ran PR #135's file list: - ONE trusted exact-head verdict from one review session ⇒ refused: `dangerous-class change with an incomplete review panel`, `dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml`, `1 independent review session(s) (sess-me-0001) — dangerous class needs two approvals from two independent sessions`. - The same session approving twice ⇒ refused, still `1 independent review session(s)`. - TWO independent sessions ⇒ `ok=true` with the note `dangerous class: two independent sessions on the head`. - A PR touching only `push/build.gradle.kts` + `docs/deps.md` with ONE session ⇒ `ok=true`, note `not dangerous class: one exact-head verdict is sufficient`. That is the ticket's Verify, in both directions, on the real lock. ### Interactions - `FORGEJO_DANGEROUS_PATHS` still REPLACES the list: under `secrets/**`, PR #135's files classify `{dangerous: false, matches: []}` and `secrets/db.yaml` still matches — the operator opt-out is intact and the default is not silently re-added. - The legacy owner-scoped panel still governs for any class, including a Gradle file: with `jesus/...` and `sendrast/...` (including a URL-shaped ref with different casing) the refusal lines read `review panel policy: legacy owner-scoped panel`, i.e. the two-declared-models/one-premium rule the 05:10Z ruling protects. `Homelab/ghostty-in-the-shell` resolves to owner `homelab`, which is not in `legacyPanelOwners`, so the modern two-session rule is the one that refuses — the test pins that boundary correctly. - An unreadable changed-file listing still fails closed (`the changed-file list is unavailable`) — the new entries do not open a hole there. - `DEFAULT_LANDING_POLICY.dangerousPaths` is now 33 entries and contains both new patterns exactly once. ### Over-match hunt Realistic false positives I could construct are vendored or fixture copies that carry the exact path: `third_party/vendor/gradle/libs.versions.toml` and `docs/fixtures/gradle/libs.versions.toml` both MATCH, because they are literally the path. Cost of that is one extra reviewer, never a safety hole, and I looked for live instances: `pi-extensions` (the whole tree), the `homelab` checkout, and `ghostty-in-the-shell` — no repo other than ghostty-in-the-shell contains either file at all, so there is no current false positive in the fleet. Correctly left alone by running them: `push/build.gradle.kts`, `build.gradle.kts`, `settings.gradle.kts`, `gradle/wrapper/gradle-wrapper.properties`, `gradle/wrapper/gradle-wrapper.jar`, `gradle/gradle.properties`, `buildSrc/src/main/kotlin/Deps.kt`, `docs/deps.md`, `docs/gradle-notes.md`. Case: `GRADLE/libs.versions.toml` and `gradle/Libs.versions.toml` do not match, which is correct — Gradle looks for exactly `gradle/libs.versions.toml`, and git paths are case-sensitive. The negative controls in the test file are real assertions on `classifyChangedPaths`, not tautologies: they assert `{dangerous:false, matches:[]}` per path and again for the whole routine set, and the red log shows those three controls were the only assertions PASSING before the fix — exactly what a control is for. ### Under-match hunt — what a path these patterns miss can still do I ran each of these through the real matcher; none match, and each is a genuine dependency-integrity act that therefore stays routine: `gradle/wrapper/gradle-wrapper.properties` (its `distributionUrl`/`distributionSha256Sum` repoint the whole Gradle distribution), `settings.gradle.kts` + `build.gradle.kts` (plugin and dependency coordinates), `buildSrc/src/main/kotlin/Deps.kt`, `gradle/dependency-locks/x.lockfile`, `gradle.properties`, and a catalogue at a non-default location (`libs.versions.toml`, `catalog/deps.toml`) which `versionCatalogs { … from(files(...)) }` permits. This is scope, not a defect in what shipped: the ticket asks for the two default files, the PR body states the exclusion deliberately (a false positive pushes ordinary PRs onto the two-session path), and nothing here regresses — those paths were routine before and are routine after. I recommend a follow-up ticket for `distributionUrl`/`distributionSha256Sum` in wrapper properties rather than widening this PR. One pre-existing bound, unchanged by this PR: the changed-file listing is capped at 10 pages × 50 = 500 paths (`api.ts listPRFiles`, `verdict-cli.ts paged`), so a PR over 500 files could place this metadata past page 10 for every dangerous pattern alike. Not introduced here and not a reason to hold this change. ### Tests In `/home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2612-gradle-danger/forgejo`: `npx tsc --noEmit` exit 0, and `npx vitest run` gives `Test Files 20 passed (20)`, `Tests 570 passed (570)` — matching the expected 20 files / 570 tests, including the new `tests/landing-gradle-dangerous.test.ts` (11 tests). The pre-fix log `working-files/homelab-2518/HOMELAB-2612-red.log` is a genuine red: 8 failing assertions with real assertion diffs (`expected false to be true`, `expected { dangerous: false, matches: [] } to deeply equal { dangerous: true, … }`) — not import errors, not compile failures — against base `fb18380` before `landing.ts` was touched, with the 3 negative controls passing. The green log matches what I reproduced. No existing test asserted the length or exact contents of the default list, so nothing needed re-pointing; the only other `dangerousPaths` assertions in the tree are the two `["secrets/**"]` override assertions and the `toContain("**/rbac/**")` control, all still green. ### Docs `docs/forgejo.md` adds dependency integrity to the leg-4 dangerous-class sentence with both patterns spelled out, plus a paragraph on what the catalogue selects and what the metadata trusts, the #135 precedent, why the patterns lead with `**/`, and why build scripts, wrapper properties and Gradle prose stay routine. Every claim there matches what I ran: the zero-or-more-segment reading, and the anchored non-match for `docs/design/gradle/libs.versions.toml.md`. I found no overclaim — in particular the docs describe the two patterns only, and so does the code. ### Observations — none of these is a hold, and none asks for a change to this head 1. The leg-3 refusal's class-summary sentence still enumerates the old categories (RBAC, secrets, network policy, Terraform, DB schema/migrations, node config, oversight paths, runtime/landing code) without dependency integrity. The very next line above it names the actual files in `dangerous paths touched: …`, so nothing is hidden from an operator; adding a phrase there is a tidy-up, not a correction. 2. The deployed extension `/home/aaron/.pi/agent/extensions/forgejo/landing.ts` contains zero occurrences of these patterns, so the class takes effect only at deploy — which is the ticket's own `OVERSEER: READY TO DEPLOY` step, and it means nobody should read the census as already enforcing this. 3. The test docblock calls the fixture verbatim; the SET is exactly the API's four files, the ORDER is the fixture's own. Harmless, and worth knowing if the fixture is ever reordered, since one assertion pins `matches` to fixture order. ### Hygiene Read-only throughout: no code edit, no merge, no deploy, no writes outside my scratch directory, nothing written under `~/.pi/agent/extensions`, and no Plane or GITS write — `GET /issues/135/comments` on ghostty-in-the-shell returns 0 comments, so the evidence read left no trace on that PR. Dispatched as one of the two independent sessions this dangerous-class change requires; the second seat reviews concurrently and I neither read nor waited for it. I approve this head.
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 913f803713
session: 01a1a5a7-94f1-7446-a658-24feb471d212

Seat 1 of the two independent review sessions this dangerous-class PR requires. Read-only review. I re-derived every claim below from the code at the exact head, not from the PR body or the test's word.

Scope read at the head

git diff fb1838001619e903868816ecbf4bbc575307573c..913f80371310144e2d3d1deba54de9e9465462f2 is three files: forgejo/landing.ts (+10, -0), docs/forgejo.md (+23/-1), forgejo/tests/landing-gradle-dangerous.test.ts (new, 240 lines). The base is origin/main's tip, so nothing else can be hiding in the range. landing.ts is purely additive: the only change is two string literals plus their comment inside the DEFAULT_LANDING_POLICY.dangerousPaths array literal, at indices 25 and 26. No leg body, no helper, no pathMatches, no sessionPanel, no isLegacyPanelRepo, no resolveLandingPolicy line was touched — legs 1, 2, 4, 5 are unchanged by construction, not by inspection only.

The pattern semantics, verified by running the real pathMatches

I did not trust the test's matrix; I built my own against the head's own pathMatches, imported from landing.ts (29 asserted rows, 0 mismatches, plus 12 exploratory probes). **/ compiles to (?:.*/)? — zero or more segments — and the whole pattern is wrapped in ^…$, with . escaped by the esc step, so:

path **/gradle/libs.versions.toml **/gradle/verification-metadata.xml
gradle/libs.versions.toml MATCH no
gradle/verification-metadata.xml no MATCH
./gradle/libs.versions.toml MATCH no
./gradle/verification-metadata.xml no MATCH
app/gradle/libs.versions.toml MATCH no
app/gradle/verification-metadata.xml no MATCH
android/app/gradle/libs.versions.toml MATCH no
android/app/gradle/verification-metadata.xml no MATCH
a/b/c/d/gradle/libs.versions.toml MATCH no
gradle/libsXversions.toml no no
gradle/libs.versions.tomlx no no
gradle/libs.versions.toml.bak no no
gradle/libs.versions.toml/child no no
docs/design/gradle/libs.versions.toml.md no no
docs/design/gradle/verification-metadata.xml.md no no
gradlex/libs.versions.toml, gradle-old/…, build/libs.versions.toml no no
gradle/wrapper/gradle-wrapper.properties, .jar no no
build.gradle.kts, push/build.gradle.kts, settings.gradle.kts no no
docs/deps.md, docs/gradle-notes.md no no

Each pattern matches only its own file: verified both per-pattern (the off-diagonal rows) and at the classifier (below). Root, ./-root and nested all match, as claimed. The $ anchor is what carries the .md / .bak / .tomlx exclusions, and it holds: docs/design/gradle/libs.versions.toml.md is genuinely out, so the doc sentence that names it is true rather than aspirational.

False positives — I looked, and found none that is harmful

Over-classification here costs one extra review session; it does not block a landing, because the dangerous class is satisfiable (proved below). So the bar for "realistic false positive" is a legitimate ordinary PR pushed onto the two-session path. The exploratory probes that do match, and why each is acceptable, not a blocker:

  • src/gradle/libs.versions.toml, android/app/gradle/…, vendor/acme/gradle/libs.versions.toml — a nested or vendored version catalogue. A vendored catalogue that a build resolves is still a file that selects coordinates and versions; the rule catching it is the rule working.
  • docs/gradle/libs.versions.toml — a doc directory literally named gradle holding a file of exactly that name. Needs an implausible layout to bite.
  • forgejo/tests/fixtures/gradle/libs.versions.toml — a test fixture copy. This is the only one I would call a genuine (if cheap) false positive: a future PR adding a fixture catalogue to a tool that parses them would be classed dangerous. Not a blocker: the diff shape is still "a version catalogue landing in a repository", slowing it by one session is the conservative direction, and FORGEJO_DANGEROUS_PATHS remains an operator escape hatch. Naming it so it is a known cost, not a surprise.

Things that are correctly not caught and would have been the damaging false positives: build.gradle.kts (in a real Android/iOS repo, every module has one), gradle/wrapper/*, and prose. I confirmed against the live repo the fixture comes from: Homelab/ghostty-in-the-shell has exactly one gradle/ directory, containing libs.versions.toml, verification-metadata.xml and wrapper — so the wrapper stays routine in the repo where this class fires most.

End-to-end: the consequence is real

I drove evaluateLandingLock from my own harness with the head's code, independent of the PR's test file, on PR #135's exact changed-file list (gradle/verification-metadata.xml, gradle/libs.versions.toml, push/build.gradle.kts, docs/deps.md — I confirmed that list against the Forgejo API for Homelab/ghostty-in-the-shell#135, it is verbatim, and GitHub/Forgejo reports no fifth file):

  • ONE exact-head approval declaring model: and session: → refused, dangerous-class change with an incomplete review panel, dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml, policy line "two independent review sessions", 1 independent review session(s). Route: leg 3, not a CI or verdict miss.
  • The same session twice → refused, same leg-3 refusal, reported as 1 independent review session(s) and collapsed on the reviewer key. One session is one approval.
  • TWO sessions on the head → permitted, note dangerous class: two independent sessions on the head (… session=… session=…). Both on omniroute/deepseek-flash still permits, which is the HOMELAB-2659 rule and not this PR's business.
  • The same single verdict on a diff without the Gradle metadata (push/build.gradle.kts, docs/deps.md) → permitted, not dangerous class: one exact-head verdict is sufficient. So the class refuses, not the fixture.
  • A verdict authored outside reviewerIdentities → still refused as non-evidence; an unavailable changed-file list → still fails closed on the new patterns' account.

The classifier on #135's list returns matches = exactly the two Gradle files, no duplication (classifyChangedPaths breaks after the first matching pattern per file).

Override and the legacy owner panel

  • FORGEJO_DANGEROUS_PATHS=secrets/** → resolveLandingPolicy returns ["secrets/**"] exactly; #135's list is then routine, and dangerous against the default policy. So replacement still works and the defaults are not silently merged, sorted or re-appended. The default array itself is unmutated by resolveLandingPolicy (it is returned by reference only when the env var is unset), contains no duplicate, and the two new entries sit inside the literal, not in a post-processing step. ["**/gradle/libs.versions.toml", "**/gradle/verification-metadata.xml"] confirmed at indices 25/26 of a 33-entry list.
  • Sendrast/sendrast-web with a dangerous Gradle file → isLegacyPanelRepo true, and the refusal names the legacy owner-scoped panel ("two-declared-models/one-premium"), demanding two models and a premium seat, not two sessions. Verified four ways: two sessions on one model → refused on models/premium; two models without a premium → refused on premium; two models with one premium → permitted, note dangerous class (legacy owner panel): two models on the head. Same for jesus/…, and for the URL-shaped repoRef https://forgejo.aaron.reynoza.org/Sendrast/sendrast-web.git (owner parsing holds). The Gradle patterns therefore classify the change but never override the panel: they cannot impose the two-session rule on those owners.
  • Practically, the legacy interaction is inert today: Sendrast/sendrast-web has no gradle/ directory at all (404 on contents/gradle). Worth stating rather than implying it was measured live.

Tests and the red log

npx tsc --noEmit clean; npx vitest run → 20 files / 570 tests, all pass, exactly the expected totals. Worktree git status clean afterwards.

working-files/homelab-2518/HOMELAB-2612-red.log is genuine: 8 failures, and I checked each assertion — they assert the old behaviour (classifier false, both patterns absent from a 29-entry default, one verdict permitting, classifyChangedPaths(PR_135_FILES).dangerous false at the foot of the override test). The 3 that already passed before the change are the negative controls and the routine counterpart — they had to pass before, and they still pass. No test was weakened, skipped or deleted; no existing test asserted the default list's length or exact contents, so nothing needed re-pointing and nothing was.

Docs

docs/forgejo.md adds the two patterns to the enumerated leg-4 list and one paragraph in the section's existing voice: what a catalogue selects, what verification-metadata.xml trusts, the #135 precedent, why **/ rather than two entries, and why build scripts/wrapper/prose deliberately stay routine. Every factual claim in it is one I checked above, including the docs/design/gradle/libs.versions.toml.md exclusion. No overclaim: it does not say the list is exhaustive, and the "same class of act as editing an RBAC rule" framing is an analogy, not a rule statement.

Non-blocking observations (not defects in this diff, and not reasons to refuse it)

  1. The leg-3 refusal sentence still enumerates the class as "RBAC, secrets, network policy, Terraform, DB schema/migrations, node config, oversight paths, runtime/landing code" without naming dependency integrity. That line is pre-existing and untouched, and it is a summary — the precise evidence is the dangerous paths touched: line one row above it, which does name both files. Cosmetic drift for a later docs pass.
  2. Coverage limit, disclosed so nobody reads the list as exhaustive: the pattern requires the directory to be exactly gradle and the filename to be exactly one of these two. A build can point a catalogue elsewhere (versionCatalogs { … from(files("gradle/deps.toml")) }) or move verification-metadata.xml, and build.gradle.kts — which can declare the same coordinates inline and more — is deliberately and reasonably left routine. This is strictly weaker than the tradeoff the PR already argued, so it cannot be a requirement this PR failed to meet.
  3. Pre-existing, untouched by this diff: SESSION_DECL_RE (\bsession\s*[:=]\s*\?([\w.:-]{6,})) lets an **empty** session:value swallow the next word on the following line — my probe producedsession: Reviewedfrom a blank declaration followed by prose. I found it while probing and it changed no verdict in my matrix (an empty declaration still refuses), but it is a hole in the "a verdict with nosession:` refuses" sentence that a separate ticket could close.

Hygiene

Probe scripts were written under /tmp and nowhere else; the worktree is clean at 913f803; no write to ~/.pi/agent/extensions; no Plane or GITS write — the only outbound reads were the Forgejo API for this PR and for #135's file list, as read-only evidence. No token in argv. Nothing posted on anyone's behalf; no review, comment or merge beyond this one verdict.

Verdict

APPROVED, seat 1 of 2. The two patterns do what they claim at every depth the ticket names, the classifier is suffix-anchored enough that the damaging false positives (build.gradle.kts, wrapper, prose) stay routine, the end-to-end refusal with one session and permission with two are real in the code and not only in the test, the override still replaces, and the legacy owner panel still governs jesus/Sendrast unchanged. No blocker.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 913f80371310144e2d3d1deba54de9e9465462f2 session: 01a1a5a7-94f1-7446-a658-24feb471d212 Seat 1 of the two independent review sessions this dangerous-class PR requires. Read-only review. I re-derived every claim below from the code at the exact head, not from the PR body or the test's word. ### Scope read at the head `git diff fb1838001619e903868816ecbf4bbc575307573c..913f80371310144e2d3d1deba54de9e9465462f2` is three files: `forgejo/landing.ts` (+10, **-0**), `docs/forgejo.md` (+23/-1), `forgejo/tests/landing-gradle-dangerous.test.ts` (new, 240 lines). The base is `origin/main`'s tip, so nothing else can be hiding in the range. `landing.ts` is **purely additive**: the only change is two string literals plus their comment inside the `DEFAULT_LANDING_POLICY.dangerousPaths` array literal, at indices 25 and 26. No leg body, no helper, no `pathMatches`, no `sessionPanel`, no `isLegacyPanelRepo`, no `resolveLandingPolicy` line was touched — legs 1, 2, 4, 5 are unchanged by construction, not by inspection only. ### The pattern semantics, verified by running the real `pathMatches` I did not trust the test's matrix; I built my own against the head's own `pathMatches`, imported from `landing.ts` (29 asserted rows, 0 mismatches, plus 12 exploratory probes). `**/` compiles to `(?:.*/)?` — zero or more segments — and the whole pattern is wrapped in `^…$`, with `.` escaped by the `esc` step, so: | path | `**/gradle/libs.versions.toml` | `**/gradle/verification-metadata.xml` | |---|---|---| | `gradle/libs.versions.toml` | MATCH | no | | `gradle/verification-metadata.xml` | no | MATCH | | `./gradle/libs.versions.toml` | MATCH | no | | `./gradle/verification-metadata.xml` | no | MATCH | | `app/gradle/libs.versions.toml` | MATCH | no | | `app/gradle/verification-metadata.xml` | no | MATCH | | `android/app/gradle/libs.versions.toml` | MATCH | no | | `android/app/gradle/verification-metadata.xml` | no | MATCH | | `a/b/c/d/gradle/libs.versions.toml` | MATCH | no | | `gradle/libsXversions.toml` | no | no | | `gradle/libs.versions.tomlx` | no | no | | `gradle/libs.versions.toml.bak` | no | no | | `gradle/libs.versions.toml/child` | no | no | | `docs/design/gradle/libs.versions.toml.md` | no | no | | `docs/design/gradle/verification-metadata.xml.md` | no | no | | `gradlex/libs.versions.toml`, `gradle-old/…`, `build/libs.versions.toml` | no | no | | `gradle/wrapper/gradle-wrapper.properties`, `.jar` | no | no | | `build.gradle.kts`, `push/build.gradle.kts`, `settings.gradle.kts` | no | no | | `docs/deps.md`, `docs/gradle-notes.md` | no | no | Each pattern matches only its own file: verified both per-pattern (the off-diagonal rows) and at the classifier (below). Root, `./`-root and nested all match, as claimed. The `$` anchor is what carries the `.md` / `.bak` / `.tomlx` exclusions, and it holds: `docs/design/gradle/libs.versions.toml.md` is genuinely out, so the doc sentence that names it is true rather than aspirational. ### False positives — I looked, and found none that is harmful Over-classification here costs one extra review session; it does not block a landing, because the dangerous class is satisfiable (proved below). So the bar for "realistic false positive" is a legitimate ordinary PR pushed onto the two-session path. The exploratory probes that **do** match, and why each is acceptable, not a blocker: - `src/gradle/libs.versions.toml`, `android/app/gradle/…`, `vendor/acme/gradle/libs.versions.toml` — a nested or vendored version catalogue. A vendored catalogue that a build resolves is still a file that selects coordinates and versions; the rule catching it is the rule working. - `docs/gradle/libs.versions.toml` — a doc directory literally named `gradle` holding a file of exactly that name. Needs an implausible layout to bite. - `forgejo/tests/fixtures/gradle/libs.versions.toml` — a test **fixture** copy. This is the only one I would call a genuine (if cheap) false positive: a future PR adding a fixture catalogue to a tool that parses them would be classed dangerous. Not a blocker: the diff shape is still "a version catalogue landing in a repository", slowing it by one session is the conservative direction, and `FORGEJO_DANGEROUS_PATHS` remains an operator escape hatch. Naming it so it is a known cost, not a surprise. Things that are correctly *not* caught and would have been the damaging false positives: `build.gradle.kts` (in a real Android/iOS repo, every module has one), `gradle/wrapper/*`, and prose. I confirmed against the live repo the fixture comes from: `Homelab/ghostty-in-the-shell` has exactly one `gradle/` directory, containing `libs.versions.toml`, `verification-metadata.xml` and `wrapper` — so the wrapper stays routine in the repo where this class fires most. ### End-to-end: the consequence is real I drove `evaluateLandingLock` from my own harness with the head's code, independent of the PR's test file, on PR #135's exact changed-file list (`gradle/verification-metadata.xml`, `gradle/libs.versions.toml`, `push/build.gradle.kts`, `docs/deps.md` — I confirmed that list against the Forgejo API for `Homelab/ghostty-in-the-shell#135`, it is verbatim, and GitHub/Forgejo reports no fifth file): - ONE exact-head approval declaring `model:` and `session:` → **refused**, `dangerous-class change with an incomplete review panel`, `dangerous paths touched: gradle/verification-metadata.xml, gradle/libs.versions.toml`, policy line "two independent review sessions", `1 independent review session(s)`. Route: leg 3, not a CI or verdict miss. - The same session twice → **refused**, same leg-3 refusal, reported as `1 independent review session(s)` and collapsed on the reviewer key. One session is one approval. - TWO sessions on the head → **permitted**, note `dangerous class: two independent sessions on the head (… session=… session=…)`. Both on `omniroute/deepseek-flash` still permits, which is the HOMELAB-2659 rule and not this PR's business. - The same single verdict on a diff without the Gradle metadata (`push/build.gradle.kts`, `docs/deps.md`) → **permitted**, `not dangerous class: one exact-head verdict is sufficient`. So the class refuses, not the fixture. - A verdict authored outside `reviewerIdentities` → still refused as non-evidence; an unavailable changed-file list → still fails closed on the new patterns' account. The classifier on #135's list returns `matches` = exactly the two Gradle files, no duplication (`classifyChangedPaths` breaks after the first matching pattern per file). ### Override and the legacy owner panel - `FORGEJO_DANGEROUS_PATHS=secrets/**` → `resolveLandingPolicy` returns `["secrets/**"]` **exactly**; #135's list is then routine, and dangerous against the default policy. So replacement still works and the defaults are not silently merged, sorted or re-appended. The default array itself is unmutated by `resolveLandingPolicy` (it is returned by reference only when the env var is unset), contains no duplicate, and the two new entries sit inside the literal, not in a post-processing step. `["**/gradle/libs.versions.toml", "**/gradle/verification-metadata.xml"]` confirmed at indices 25/26 of a 33-entry list. - `Sendrast/sendrast-web` with a dangerous Gradle file → `isLegacyPanelRepo` true, and the refusal names the **legacy owner-scoped panel** ("two-declared-models/one-premium"), demanding two **models** and a premium seat, not two sessions. Verified four ways: two sessions on one model → refused on models/premium; two models without a premium → refused on premium; two models with one premium → permitted, note `dangerous class (legacy owner panel): two models on the head`. Same for `jesus/…`, and for the URL-shaped repoRef `https://forgejo.aaron.reynoza.org/Sendrast/sendrast-web.git` (owner parsing holds). The Gradle patterns therefore classify the change but never override the panel: they cannot impose the two-session rule on those owners. - Practically, the legacy interaction is inert today: `Sendrast/sendrast-web` has no `gradle/` directory at all (404 on `contents/gradle`). Worth stating rather than implying it was measured live. ### Tests and the red log `npx tsc --noEmit` clean; `npx vitest run` → **20 files / 570 tests, all pass**, exactly the expected totals. Worktree `git status` clean afterwards. `working-files/homelab-2518/HOMELAB-2612-red.log` is genuine: 8 failures, and I checked each assertion — they assert the *old* behaviour (classifier false, both patterns absent from a 29-entry default, one verdict permitting, `classifyChangedPaths(PR_135_FILES).dangerous` false at the foot of the override test). The 3 that already passed before the change are the negative controls and the routine counterpart — they had to pass before, and they still pass. No test was weakened, skipped or deleted; no existing test asserted the default list's length or exact contents, so nothing needed re-pointing and nothing was. ### Docs `docs/forgejo.md` adds the two patterns to the enumerated leg-4 list and one paragraph in the section's existing voice: what a catalogue *selects*, what `verification-metadata.xml` *trusts*, the #135 precedent, why `**/` rather than two entries, and why build scripts/wrapper/prose deliberately stay routine. Every factual claim in it is one I checked above, including the `docs/design/gradle/libs.versions.toml.md` exclusion. No overclaim: it does not say the list is exhaustive, and the "same class of act as editing an RBAC rule" framing is an analogy, not a rule statement. ### Non-blocking observations (not defects in this diff, and not reasons to refuse it) 1. The leg-3 refusal sentence still enumerates the class as "RBAC, secrets, network policy, Terraform, DB schema/migrations, node config, oversight paths, runtime/landing code" without naming dependency integrity. That line is **pre-existing and untouched**, and it is a summary — the precise evidence is the `dangerous paths touched:` line one row above it, which does name both files. Cosmetic drift for a later docs pass. 2. Coverage limit, disclosed so nobody reads the list as exhaustive: the pattern requires the directory to be exactly `gradle` and the filename to be exactly one of these two. A build can point a catalogue elsewhere (`versionCatalogs { … from(files("gradle/deps.toml")) }`) or move `verification-metadata.xml`, and `build.gradle.kts` — which can declare the same coordinates inline and more — is deliberately and reasonably left routine. This is strictly weaker than the tradeoff the PR already argued, so it cannot be a requirement this PR failed to meet. 3. Pre-existing, untouched by this diff: `SESSION_DECL_RE` (`\bsession\s*[:=]\s*\`?([\w.:-]{6,})`) lets an **empty** `session:` value swallow the next word on the following line — my probe produced `session: Reviewed` from a blank declaration followed by prose. I found it while probing and it changed no verdict in my matrix (an empty declaration still refuses), but it is a hole in the "a verdict with no `session:` refuses" sentence that a separate ticket could close. ### Hygiene Probe scripts were written under `/tmp` and nowhere else; the worktree is clean at `913f803`; no write to `~/.pi/agent/extensions`; no Plane or GITS write — the only outbound reads were the Forgejo API for this PR and for #135's file list, as read-only evidence. No token in argv. Nothing posted on anyone's behalf; no review, comment or merge beyond this one verdict. ### Verdict APPROVED, seat 1 of 2. The two patterns do what they claim at every depth the ticket names, the classifier is suffix-anchored enough that the damaging false positives (`build.gradle.kts`, wrapper, prose) stay routine, the end-to-end refusal with one session and permission with two are real in the code and not only in the test, the override still replaces, and the legacy owner panel still governs `jesus`/`Sendrast` unchanged. No blocker.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!90
No description provided.