feat(packaging): linux deb/rpm via nfpm#120
Conversation
Benchmark results
Budget: regex/cache < 2.5ms (0.5% of 500ms baseline), streaming < 20ms (4% of 500ms baseline) |
…rage gap (#121) ## Summary Decomposes `cmd/proxy/main.go` `func main()` into separately-testable startup helpers and adds a helper-process `TestMain` that exercises `main()` end-to-end. Closes the structural delta-coverage gap surfaced by #118 — every function in `cmd/proxy/` now meets the 95% per-function gate, so future PRs touching this file (including #120's `--generate-ca` flag addition) land without false coverage failures. ## Motivation - #118 closed a silent-skip bug in `.github/scripts/delta-coverage.sh`. Before that fix, PRs touching `cmd/proxy/main.go` quietly bypassed the per-function gate. - #120 is the first PR to actually hit the now-loud gate. `main()` has always been 0% covered (network setup + signal handling), so any change to the file fails 95%. - Rather than per-PR workarounds, this PR fixes the root cause on `main`: refactor `main()` into testable helpers + helper-process tests that exercise the real binary. ## What changed - New `cmd/proxy/startup.go` — `proxyHTTPServer`, `startManagementAPI`, `runManagementAPI`, `runHTTPServer`, `closeProxyServer` (extracted from `main()`). `closeProxyServer` takes `io.Closer` so the error-log branch is exercisable with a fake. - New `cmd/proxy/shutdown.go` — `installShutdownHandler` (extracted from `main()`). - New unit tests in `cmd/proxy/startup_test.go` and `cmd/proxy/shutdown_test.go` covering each helper at 100%. `TestCloseProxyServer_*` capture the default logger's output and assert the error message is/isn't emitted, not just that both branches execute. - `cmd/proxy/main_test.go` — `TestMain` dispatcher + four helper-process tests (lifecycle, zero-packs guard, proxy-port conflict, mgmt-port conflict) using the canonical Go helper-process pattern (`GO_WANT_HELPER_PROCESS=1` + re-exec of `os.Args[0]`). `TestPrintBanner_ZeroValueConfig_DoesNotPanic` preserves the zero-value-config guarantee previously owned by `TestMain_Smoke`. - `cmd/proxy/main.go` `func main()` reduced to orchestration; no behaviour change. ## Per-function coverage (`go tool cover -func`) ``` cmd/proxy/main.go:39: main 100.0% cmd/proxy/main.go:66: printBanner 100.0% cmd/proxy/shutdown.go:13: installShutdownHandler 100.0% cmd/proxy/startup.go:17: proxyHTTPServer 100.0% cmd/proxy/startup.go:28: startManagementAPI 100.0% cmd/proxy/startup.go:37: runManagementAPI 100.0% cmd/proxy/startup.go:45: runHTTPServer 100.0% cmd/proxy/startup.go:54: closeProxyServer 100.0% total: 100.0% ``` Local `delta-coverage.sh coverage.out 95.0 origin/main` exits 0: ``` === Delta Coverage Check (threshold: 95.0%) === Changed source files: cmd/proxy/main.go cmd/proxy/shutdown.go cmd/proxy/startup.go Checked 8 functions in 3 changed files. SUCCESS: All functions in changed files meet 95.0% coverage threshold. ``` ## Quality gates - [x] `go test -race ./...` green (all packages) — see `TestTokenFormatNonRetriggering` evidence below - [x] `bash .github/scripts/delta-coverage.sh coverage.out 95.0 origin/main` exits 0 with `SUCCESS` - [x] Coverage minimums hold: `internal/anonymizer` 95.6%, `internal/config` 100%, `internal/anonymizer/packs` 97.6%; overall well above 85% - [x] Lifecycle helper-process test stable across 5 consecutive local runs - [x] No `t.Skip()`, no `// coverage-ignore`, no hardcoded test-satisfying values. Four `//nolint:gosec` and two `// #nosec` directives carry substantive reason comments per the existing repo convention. - [ ] `make lint` / `make check` — `golangci-lint` in the local sandbox is built with go1.25 and refuses go1.26.3 modules (same failure on a clean `main` checkout). CI runs all four sub-gates against a fresher toolchain. ## Gate #2 — `TestTokenFormatNonRetriggering` for every PII type Both variants live in `internal/anonymizer/anonymizer_test.go` and ran green at head `c69911f`: ``` === RUN TestTokenFormatNonRetriggering [ANONYMIZER] loaded 41 patterns from 7 enabled packs: [SECRETS GLOBAL DE FR NL FINANCE_EU HEALTHCARE] --- PASS: TestTokenFormatNonRetriggering (0.04s) === RUN TestTokenFormatNonRetriggeringAllPacks [ANONYMIZER] loaded 47 patterns from 8 enabled packs: [SECRETS GLOBAL DE FR US NL FINANCE_EU HEALTHCARE] --- PASS: TestTokenFormatNonRetriggeringAllPacks (0.04s) ``` Each variant iterates over a static list of `PIIType` values and, for each, asserts the generated `[PII_*_<16hex>]` token does not match any loaded pack pattern. The PII types covered: | Pack | PII types asserted by the test | |---|---| | Generic / built-in | `PIIEmail`, `PIIPhone`, `PIISSN`, `PIICreditCard`, `PIIIPAddress`, `PIIAPIKey`, `PIIName`, `PIIAddress`, `PIIMedical`, `PIISalary`, `PIICompany`, `PIIJobTitle` | | DE | `PIISteuerID`, `PIISVNR`, `PIIKFZ` | | SECRETS | `PIISSHKey`, `PIIJWT`, `PIIBearer`, `PIIDBConn`, `PIIAWSKey`, `PIIGHToken` | | FR | `PIINIR`, `PIISIRET`, `PIISIREN` | | NL | `PIIBSN`, `PIIKVK` | | FINANCE_EU | `PIIIBAN`, `PIISWIFTBIC`, `PIIVATID` | | HEALTHCARE | `PIIMRN`, `PIIICD10`, `PIIInsuranceID` | That's 32 PII types in the default-packs variant. `TestTokenFormatNonRetriggeringAllPacks` adds the US pack and drops the four AI-only types (`PIIMedical/Salary/Company/JobTitle`), covering 28 types under the full pattern set (`PIIKFZ`/`PIIDBConn` are noted in the test as known low-confidence retriggers against the broad US phone pattern and routed through the AI gate — documented in the test, not silenced). The two variants together cross every PII type declared in `internal/anonymizer/anonymizer.go`. None of the changed files in this PR touch the anonymizer, the pack files, or the PII type list, so no per-type evidence shifted between baseline and head — but the gate is exercised end-to-end at head as shown above. ## §6 Test Inventory — baseline vs head Keyed by **pack** per `docs/test-plans/ai-proxy-test-method.md` §6. Captured by running `go test -count=1 -json ./internal/anonymizer ./internal/anonymizer/packs` on `origin/main` (`98511d7`) and on PR head (`c69911f`), then attributing each top-level Test event to its pack via the file list in §6. | Pack | Files (per §6) | Baseline PASS | Baseline FAIL | Head PASS | Head FAIL | Delta | |---|---|---:|---:|---:|---:|---:| | GLOBAL | `packs/global_test.go` | 6 | 0 | 6 | 0 | 0 | | DE | `packs/de_test.go` | 5 | 0 | 5 | 0 | 0 | | US | `packs/us_test.go` | 10 | 0 | 10 | 0 | 0 | | FR | `packs/fr_test.go` | 9 | 0 | 9 | 0 | 0 | | SECRETS | `packs/secrets_test.go` + `secrets_report_test.go` + `secrets_priority_report_test.go` | 31 | 0 | 31 | 0 | 0 | | NL | `packs/nl_test.go` + `nl_report_test.go` | 6 | 0 | 6 | 0 | 0 | | FINANCE_EU | `packs/finance_eu_test.go` + `finance_eu_report_test.go` | 7 | 0 | 7 | 0 | 0 | | HEALTHCARE | `packs/healthcare_test.go` + `healthcare_report_test.go` | 6 | 0 | 6 | 0 | 0 | | Cross-pack | all `*_report_test.go` | 9 | 0 | 9 | 0 | 0 | Counts are top-level `func Test*` events from the JSON stream, filtered by the test-name list extracted from each pack's `_test.go` file(s). The `secrets_priority_report_test.go` file lives alongside `secrets_report_test.go`, was added after §6 was written, and is included under SECRETS (its prefix matches the SECRETS pack convention). The Cross-pack row overlaps with the pack rows by design (it sums all `*_report_test.go` content, which the relevant per-pack rows already include). **Supplementary Go-package view** — `go test -count=1 -json ./...` gives 861 → 871 PASS / 0 → 0 FAIL across all 10 Go packages; the `cmd/proxy` row went 4 → 14, every other row unchanged. The per-pack table above is the gate-required view. ## §6 changed-files attribution None of the changed files in this PR (`cmd/proxy/main.go`, `cmd/proxy/startup.go`, `cmd/proxy/shutdown.go`, plus the corresponding `_test.go` files) live under any pack's file list, so the identical per-pack counts are the expected measured outcome, not a substitution for measurement. ## Test plan - [x] `go test -race ./cmd/proxy/...` — all green including new helper-process tests - [x] `go test -race -run '^TestTokenFormatNonRetriggering' ./internal/anonymizer/...` — both variants PASS at head - [x] `go tool cover -func` — every function in `cmd/proxy/` at 100% - [x] Local `delta-coverage.sh` simulation against `origin/main` — exits 0 - [x] Lifecycle test run 5x consecutively — 5/5 pass - [x] CI green (Lint, Test, Security Scan, Benchmark, CodeQL × 2, Build) at `c69911f` ## Out of scope - Any changes under `packaging/linux/` — Fedora `ca-certificates` and openSUSE trust-store path belong to #120 follow-up commits after rebase. - Any changes to `internal/*`, `.github/workflows/`, or `.github/scripts/delta-coverage.sh`. - Adding a second binary or the `--generate-ca` flag (#120's territory). --------- Co-authored-by: Claude <noreply@anthropic.com>
Exposes the existing internal/mitm.GenerateCA via a one-shot flag so package post-install scripts can generate the CA cert+key non-interactively before the service starts.
nfpm.yaml produces .deb and .rpm for amd64 and arm64 from a single
config. systemd unit includes hardening (NoNewPrivileges, ProtectSystem,
PrivateTmp, etc.). Env file template covers every env var consumed by
cmd/proxy and internal/config. Default proxy-config.json is shipped
as a conffile so user edits survive upgrade.
Adds make package-linux / package-linux-{amd64,arm64} targets.
…re integration postinstall.sh creates the ai-proxy system user, generates the CA if absent (idempotent across upgrades), copies it into the OS trust store (Debian-family /usr/local/share/ca-certificates, RHEL-family /etc/pki/ca-trust/source/anchors), then enables + starts the service. preremove.sh stops and disables the service and removes the CA from the trust store. Files under /etc/ai-proxy/ are preserved on remove (conffile semantics) and cleaned up on purge.
Builds .deb and .rpm packages for amd64 + arm64 on tag push (and on PRs touching packaging paths). Runs an install/verify/uninstall round-trip across Ubuntu 22.04/24.04, Debian 12, Alma 9, Fedora, and openSUSE Leap 15 in privileged systemd-capable containers. On tag pushes, signs each artifact (and SHA256/SHA512 sums) with Sigstore cosign keyless and uploads everything to the GitHub release.
…ninstall docs/packaging/linux.md covers per-family install commands, the env file variables, upgrade semantics (conffile preservation), uninstall, and cosign signature verification. docs/packaging/README.md is an index pointing at linux.md and noting macos/windows arrive in later UEM phases. README.md doc table gets a packaging row.
…path The Fedora and openSUSE install round-trips failed because: 1. ca-certificates is not in those minimal base images, so postinstall's update-ca-trust / update-ca-certificates command was absent and the trust-store install silently no-op'd. 2. openSUSE ships update-ca-certificates but reads anchors from /etc/pki/trust/anchors (Debian uses /usr/local/share/ca-certificates). The original postinstall blindly used the Debian path, so the CA never reached the trust store. Fixes: - Declare ca-certificates and systemd as package deps so dnf/apt/zypper pull them automatically. - Detect the right anchor directory in postinstall (Debian / openSUSE / RHEL) before copying the CA. - preremove now removes from all three possible anchor paths. - test-install.sh accepts the openSUSE path as a valid trust-store location during verification.
e33170f to
c933bb4
Compare
laplaque
left a comment
There was a problem hiding this comment.
PR Review: feat(packaging): linux deb/rpm via nfpm
Evidence block
- Head SHA:
c933bb4cf73284f4121b2b9954afe8a1aa49d665 - CI: 16/17 SUCCESS + 1 correctly-SKIPPED (
Sign + publish, gated onrefs/tags/v*) - Gate-defining document:
CLAUDE.md(read at head SHA) - Sibling docs read:
CONTRIBUTING.md,.github/PULL_REQUEST_TEMPLATE.md,.github/scripts/delta-coverage.sh
Pipeline status
PASS
Test coverage verdict
UNVERIFIABLE — author offered no per-package coverage numbers (gate 3), no delta-coverage report (gate 4), no §6 baseline-vs-head inventory (gate 5).
Quality gates audit
Each gate parsed into clauses; per-clause score based strictly on evidence offered in the PR body / commits / comments. CI green is external evidence the suite ran; it does not substitute for author-side documentation of the gate (per CLAUDE.md and the .github/PULL_REQUEST_TEMPLATE.md "Empty checkboxes block merge; vague 'passes' assertions without evidence count as empty").
| # | Clause (verbatim from CLAUDE.md / PR template) | Score |
|---|---|---|
| 1a | make check passes locally — aggregates lint, test, security, vulncheck; all four sub-gates exit 0 |
FAIL — author explicitly documents the lint sub-gate was not run locally ("golangci-lint was not run locally … CI runs golangci-lint v2.10.1 and will exercise the new code there"); no claim either way for the security (gosec) or vulncheck sub-gates |
| 1b | Last-line-of-output paste (template) | NEED-EVIDENCE |
| 2a | go test -race ./... passes |
NEED-EVIDENCE — -race flag not confirmed (PR body says go test ./...) |
| 2b | …including TestTokenFormatNonRetriggering for every PII type |
NEED-EVIDENCE — sub-clause silent (likely N/A since no new PII types, but the gate must still be addressed) |
| 3 | Coverage minimums: 95% on internal/anonymizer, internal/config, internal/anonymizer/packs; 85% overall |
NEED-EVIDENCE — no per-package coverage numbers offered |
| 4 | Delta coverage ≥90% on all changed/new files (per .github/scripts/delta-coverage.sh) |
NEED-EVIDENCE — template ## Delta Coverage Report section (raw script output paste + per-function table) is entirely absent from the PR body |
| 5 | Test-plan inventory baseline-vs-head; "Diff reasoning … does NOT satisfy this gate" | NEED-EVIDENCE — template ## §6 Test Inventory — baseline vs head section is entirely absent from the PR body |
| 6a | No //nolint without substantive reason |
PASS — three new directives at cmd/proxy/main_test.go:148, :320, :344 all carry substantive reasons |
| 6b | No new gosec exclusions (PR template clause) |
NEED-EVIDENCE / PARTIAL — three new //nolint:gosec inline directives added (same lines); clause not addressed by the author |
| 6c | No t.Skip() without linked issue / no // coverage-ignore / no test-satisfying hardcoded values |
PASS |
| 7 | All CI jobs green at PR head SHA | PASS |
Template-structure note (umbrella)
The PR body has none of the required template sections (## What, ## Changes, ## Quality Gates checklist, ## Delta Coverage Report, ## §6 Test Inventory — baseline vs head, ## Test Plan, ## Linked Issues). Custom sections (## What ships, ## Verified locally, ## Existing Issues, ## Out of scope) replaced the template wholesale. This is the root cause of the NEED-EVIDENCE clauses above.
Severity grading note
All five inline code findings (F2 – F6) touch security paths and are graded HIGH per reviewer policy: anything security-touching is HIGH, no exceptions. The release pipeline supply chain (F2), CA-trust-anchor with silent service-start failure (F3), security-linter inline suppressions (F4), over-broad CI permissions (F5), and user-facing CA-trust-anchor disclosure for UEM deployments (F6) all qualify.
Path to Approval (Action Plan)
- Re-state the PR body using the required template sections, with evidence per the gate: Quality Gates checklist (each checkbox with concrete evidence); Delta Coverage Report (raw
.github/scripts/delta-coverage.shoutput paste + per-function table); §6 Test Inventory baseline-vs-head (per-package PASS/FAIL onmainAND head); Test Plan (named test functions and what each one pins); Linked Issues. - Resolve the
lintsub-gate ofmake checklocally — or, if the toolchain mismatch is structural, declare the deviation and the compensating evidence explicitly under gate #1, including the currently-silentsecurity(gosec) andvulnchecksub-gates. - Address the five inline security findings (F2 supply-chain pin; F3 silent service-start suppression with CA already trust-anchored; F4 gosec inline exclusions; F5 over-broad workflow permissions; F6 UEM consent-grade CA disclosure).
- Re-run review at the new head SHA.
Verdict
REQUEST_CHANGES
|
Reviewer self-correction — gate #1a should be LOW, not FAIL. Per the standing review policy ([[claude/feedback/ai-proxy-quality-gate-severity-bump]] established during the PR #121 review on 2026-05-17), the exception to severity-bumping on gate #1 (
Both conditions hold here: the PR body's "Existing Issues" section discloses the Verdict remains REQUEST_CHANGES — driven by:
This is a reviewer-side correction only; no action needed from the author beyond what the original review already requested. |
Addresses two review threads on PR #120: - F2: replace 'go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest' with the same package pinned to v2.46.3. nfpm builds the release artifacts that cosign then signs; the build tool itself is in the supply-chain trust path, so its version must be reviewable. Bumps now require an explicit PR. - F5: move 'contents: write' and 'id-token: write' from workflow-level permissions into the release job only. The build and test-install jobs (the latter running --privileged docker containers) inherit 'contents: read' instead. A compromise in build or test-install can no longer mint OIDC tokens or push to the release.
… window) Addresses F3 on PR #120. The previous '|| true' on systemctl start swallowed every failure mode (port collision, bad config, AppArmor/ SELinux denial, missing runtime dep) while still leaving the CA installed into the OS trust store from earlier in postinstall. On a real host this would mean a package-manager SUCCESS with a CA that browsers and CLIs trust but no proxy running to intercept — exactly the failure mode CLAUDE.md Invariant #1 ("No PII leaves the process") exists to prevent. HTTPS LLM-API requests would succeed, trusted, and carry PII off the host unanonymized. Two changes: - Detect the chroot/container build context by the presence of /run/systemd/system (canonical 'systemd is PID 1 right now' marker) and only enforce service-start in real systemd-managed hosts. The container build case still just enables the unit. - On a real host, if 'systemctl start' fails, the trust-store anchor is removed and postinstall exits non-zero so the package manager surfaces the failure to the operator and any UEM rollout tooling.
…suppressions Addresses F4 on PR #120. The file previously carried five //nolint:gosec directives at exec.CommandContext call sites and one at os.ReadFile — each substantive but each also a per-line suppression of the security linter. - Centralises subprocess construction in helperCmd(t, args...). The gosec G204 suppression now lives in one place (the helper) next to the explanation of why the pattern is safe (the target is the test binary itself, not external input), and every helper-process test becomes shorter. - Removes the os.ReadFile + pem.Decode + x509.ParseCertificate block from TestRunGenerateCA. The cryptographic shape of the generated CA is covered by tests in internal/mitm; cmd/proxy/runGenerateCA's contract is path-arg validation + file-on-disk presence + key permission, and that's what the test now asserts. Drops the remaining gosec G304 suppression at the same time. Net: 7 //nolint:gosec directives in main_test.go reduced to 1.
… rollouts Addresses F6 on PR #120. The CA install was bullet-noted alongside 'creates the ai-proxy user', which under-disclaims for the audience that reads this doc — fleet admins approving a UEM rollout (Intune, JAMF, SCCM, Ansible). docs/tls-mitm.md covers the architectural MITM story; this doc is the operational one that gates rollout approval. Adds a 'Security posture' callout at the top of docs/packaging/linux.md spelling out: - After install, the host trusts an ai-proxy-controlled CA capable of MITM-ing any HTTPS connection from it. - The private key at /etc/ai-proxy/ca-key.pem must be protected with host-SSH-key rigour; anyone who can read it can mint trusted certs. - Per-distro trust-store anchor paths so security teams can audit. - Uninstall removes the anchor; failed service-start aborts install with the anchor rolled back (paired with the F3 postinstall fix).
CI's misspell linter (US-English mode) flagged 'Centralising' in the helperCmd comment added in 9394eb7.
laplaque
left a comment
There was a problem hiding this comment.
PR Review (round 2): feat(packaging): linux deb/rpm via nfpm
Evidence block
- Head SHA:
d78f66c5624e69db638ddd448b8f22295a6ec4e9 - Prior review:
4305992554(CHANGES_REQUESTED atc933bb4c…) plus#issuecomment-4471866528self-correction on gate #1a - New commits since prior review: 5 (
4c0cf6d,32c5359,9394eb7,b18b6b6,d78f66c) - CI: 16/17 SUCCESS + 1 correctly-SKIPPED (
Sign + publish) at new head SHA - Re-walked Step 3.7 (gate enumeration) in full at new head — every clause re-scored against the new state, not only previously-failing clauses
Inline findings — resolved at this head SHA
| # | Resolution | Verified at d78f66c |
|---|---|---|
| F2 | nfpm pinned @v2.46.3 (commit 4c0cf6d); paired with F5 narrowing so build-job compromise cannot mint OIDC tokens |
✓ .github/workflows/release-linux-packages.yml:44 |
| F3 | Distinguishes chroot/container (no /run/systemd/system → skip start, OK) from real systemd host (start failure → log + roll back CA from trust store + exit 1); package install now fails loudly on real-environment failure (commit 32c5359) |
✓ packaging/linux/postinstall.sh:56,91-96 |
| F4 | Refactored to drop the suppressions (commit 9394eb7 + spelling fix d78f66c). Consolidated all helper-process exec.CommandContext(os.Args[0], …) calls into a single helperCmd(t, args...) helper carrying one //nolint:gosec G204 with substantive reason; os.ReadFile G304 removed entirely by tightening the test's contract. Net gosec directives in file: 7 → 1 |
✓ cmd/proxy/main_test.go:38-41 |
| F5 | File-level permissions: contents: read; per-job blocks explicit on build + test-install (read only); contents: write + id-token: write confined to release job (commit 4c0cf6d) |
✓ .github/workflows/release-linux-packages.yml:15-16,22-23,60-61,97-99 |
| F6 | Added ## ⚠ Security posture — read before fleet deployment as the first content after the page title; verbatim consent-grade wording; enumerates key location/mode, per-distro anchor paths, upgrade/uninstall behaviour, and the F3 install-time guarantee (commit b18b6b6) |
✓ docs/packaging/linux.md:5-15 |
All five HIGH-security inline findings from the prior review are addressed at this head SHA. Thank you for the thorough fixes — F3 in particular (the rollback-on-real-failure path) closes the PII-leak window cleanly.
Quality gates audit at d78f66c
| # | Clause (verbatim) | Score |
|---|---|---|
| 1a | make check passes locally — aggregates lint, test, security, vulncheck; all four sub-gates exit 0 |
LOW — the carved exception applies (author disclosed toolchain mismatch + CI green at head; per reviewer policy this stays at original severity) |
| 1b | Last line of make check output pasted (PR template) |
HIGH — NEED-EVIDENCE |
| 2a | go test -race ./... passes (with -race) |
HIGH — NEED-EVIDENCE |
| 2b | …including TestTokenFormatNonRetriggering for every PII type |
HIGH — NEED-EVIDENCE |
| 3 | Coverage minimums: 95% on internal/anonymizer, internal/config, internal/anonymizer/packs; 85% overall |
HIGH — NEED-EVIDENCE |
| 4 | Delta coverage ≥90% on all changed/new files (per .github/scripts/delta-coverage.sh) |
HIGH — NEED-EVIDENCE |
| 5 | Test-plan inventory baseline-vs-head; "Diff reasoning … does NOT satisfy this gate" | HIGH — NEED-EVIDENCE |
| 6a | No //nolint without substantive reason |
PASS — single remaining directive in helperCmd carries substantive reason |
| 6b | No new gosec exclusions (PR template clause) |
PASS (with note) — net gosec directive count in cmd/proxy/main_test.go strictly decreased (7 → 1); the remaining directive is a consolidation of pre-existing helper-process suppressions, not a fresh exclusion |
| 6c | No t.Skip() without linked issue / no // coverage-ignore / no test-satisfying hardcoded values |
PASS |
| 7 | All CI jobs green at PR head SHA | PASS (16 SUCCESS + 1 SKIPPED) |
Severity bump on the NEED-EVIDENCE gates is per the standing reviewer policy for ai-proxy CLAUDE.md gates: findings against documented quality gates default one severity level higher than they would on a generic project, because the gates are a documented merge contract.
Template-structure observation (still applies)
The PR body still has none of the required template sections (## What, ## Changes, ## Quality Gates checklist, ## Delta Coverage Report, ## §6 Test Inventory — baseline vs head, ## Test Plan, ## Linked Issues). All five NEED-EVIDENCE clauses above sit downstream of this umbrella issue. The template preamble says: "Every section below is required. Empty checkboxes block merge; vague 'passes' assertions without evidence count as empty."
Path to Approval — concrete actions (the prior path was too narrow; each NEED-EVIDENCE clause is a separate action item)
Each item below is something to add to the PR body. None requires a new commit — they are documentation deliverables, addressable via a PR-body edit. The five inline findings from round 1 ARE addressed at this head SHA; this list is exclusively the gate-evidence gaps that the prior review's audit table flagged but the prior Path to Approval did not enumerate concretely.
- Gate 1b — last-line-of-
make checkpaste. Add aQuality Gatessection to the PR body using the template's checkbox layout. The gate-1 checkbox slot readsLast line of output: <paste>. Paste the last line of your localmake check(with thegolangci-lintcarve-out documented separately, as already in the PR body). - Gate 2a —
-raceflag confirmation. Under the gate-2 checkbox, state explicitly thatgo test -race ./...passes (with the-raceflag). Current PR body saysgo test ./.... - Gate 2b —
TestTokenFormatNonRetriggeringsub-clause. Under the gate-2 checkbox, address the "for every PII type" sub-clause. If this PR introduces no new PII types, state that explicitly so the sub-clause is addressed rather than silent. - Gate 3 — per-package coverage minimums. Under the gate-3 checkbox, confirm the four threshold packages are at their minimums at this head SHA:
internal/anonymizer≥95%,internal/config≥95%,internal/anonymizer/packs≥95%, overall ≥85%. The PR doesn't touch the three 95%-gated packages, so baseline should hold — but the gate requires explicit confirmation per package. - Gate 4 —
## Delta Coverage Reportsection. Add the template-specified section. Per the template at.github/PULL_REQUEST_TEMPLATE.mdand the script at.github/scripts/delta-coverage.sh, this section must contain:- Raw script output paste verbatim — including the
Changed source files:list, every per-FAIL line if any, and the finalSUCCESS:/ERROR:line, inside the code-block placeholder the template provides. - Per-function table — one row per function in every changed
.gosource file (cmd/proxy/main.goandcmd/proxy/main_test.goare excluded as_test.goper the script's filter;cmd/proxy/main.gois in scope) with columnsFile | Function | Coverage % | ≥90%. - CI green on the Test job is the script running; it is not author-side documentation of the result, which the gate text and template both require.
- Raw script output paste verbatim — including the
- Gate 5 —
## §6 Test Inventory — baseline vs headsection. Add the template-specified section. The CLAUDE.md gate text states: "Document per-package PASS/FAIL counts in the PR body under a## §6 Test Inventory — baseline vs headsection. Diff reasoning … does NOT satisfy this gate." The template renders the per-package table empty (./cmd/proxy/,./internal/anonymizer/,./internal/anonymizer/packs/,./internal/config/,./internal/logger/,./internal/management/,./internal/metrics/,./internal/mitm/,./internal/proxy/) — fill each cell withmainand head PASS/FAIL counts and a delta. The gate's referenced doc isdocs/test-plans/ai-proxy-test-method.md§6. - Template structure umbrella. Restructure the PR body to follow
.github/PULL_REQUEST_TEMPLATE.md. The current sections (## What ships,## Verified locally,## Existing Issues,## Out of scope) can be merged into the template's## What+## Changes+## Quality Gates+## Test Plan+## Linked Issuesshape; items 1–6 above are the contents of the missing template sections.
The gate text and the PR template both already specify the evidence shape and the commands that produce the evidence — items 1–6 cite those locations rather than prescribing new commands, per reviewer-scope discipline.
Verdict
REQUEST_CHANGES — driven by gates 1b, 2a, 2b, 3, 4, 5, and the template-structure umbrella. All inline security findings from the prior review are resolved at this head SHA.
laplaque
left a comment
There was a problem hiding this comment.
All round-2 Path-to-Approval bullets addressed via PR-body restructure (no new commits required for the documentation gates).
Inline findings F2–F6 remain closed at this head SHA. Gate-evidence summary:
- Gate 1:
make checklast-line + per-sub-gate detail (lint with documentedgolangci-lint v2.10.1workaround, test 10/10, security 0 issues, vulncheck no vulnerabilities) - Gate 2:
-raceper-package output (10/10 ok);TestTokenFormatNonRetriggeringsub-clause N/A statement (no new PII types) - Gate 3: per-package coverage all above minimums (
internal/anonymizer95.6%,internal/config100.0%,internal/anonymizer/packs97.6%, overall 94.4%) - Gate 4: Delta Coverage Report — raw
.github/scripts/delta-coverage.shoutput + per-function table (cmd/proxy/main.go3/3 functions at 100%) - Gate 5: §6 Test Inventory — per-package PASS/FAIL counts on
main (1ebc033)vshead (d78f66c);+3in./cmd/proxy/matching the 3 new test names; zero failures either side - Gate 6: net gosec directive count strictly decreased (7 → 1, consolidated into
helperCmd) - Gate 7: CI green at head SHA (16 SUCCESS + 1 correctly-SKIPPED)
- Template: all 7 required sections present and ordered
Dismissing both prior CHANGES_REQUESTED reviews (4305992554 at c933bb4c + 4306082414 at d78f66c5) per SKILL.md Step 9 GitHub re-review caveat so branch-protection sees a clean APPROVE.
Superseded by APPROVE review # at head d78f66c5 — all inline findings (F2–F6) and all gate-evidence (Quality Gates checklist, Delta Coverage Report, §6 Test Inventory, template structure) addressed.
Phase 2 of the UEM packaging workstream — macOS PKG installer (signed +
notarized + stapled on tag builds) and a JAMF-deployable `.mobileconfig`
profile.
## What ships
- `packaging/macos/pkg/` — `build.sh`, `distribution.xml`,
`scripts/postinstall`, `scripts/preuninstall`, `notarize.sh`, the
in-package LaunchDaemon plist, the conffile defaults. `build.sh` and
`mobileconfig/build.sh` both honor `SKIP_SIGN=1` for the PR-event
dry-run path.
- LaunchDaemon at
`/Library/LaunchDaemons/com.ai-anonymizing-proxy.plist`,
`RunAtLoad=true`, `KeepAlive={SuccessfulExit=false}`
- `_aiproxy` system user created in postinstall (UID auto-allocated
220–400, hidden, no shell)
- CA injection into `/Library/Keychains/System.keychain`. **Postinstall
rollback verifies actual keychain state** and exits with distinct codes
— `1` = "service down, CA cleaned", `2` = "service down, CA STILL
TRUSTED, manual remediation required" (H2).
- `packaging/macos/mobileconfig/ai-proxy.mobileconfig.tmpl` + `build.sh`
— CA trust + global HTTP proxy payloads, with **ExceptionsList** for
`127.0.0.1`, `localhost`, `*.local`, `169.254/16` (M3).
- `make package-macos`, `make package-macos-pkg`, `make
package-macos-mobileconfig` (refuse to run off Darwin)
- `.github/workflows/release-macos-pkg.yml`:
- **PR runs** do a `Validate build mechanism (PR dry-run, no signing)`
step (N1).
- **Tag runs** execute the full sign + notarize + staple + upload path.
- P12 cleanup via `trap EXIT` (H5). Explicit artifact upload list (H6).
`concurrency:` block (L2). YAML comment above `Notarize PKG` references
issue #125 (H3).
- `docs/packaging/macos.md` — install / configure / verify / upgrade /
uninstall, blast-radius callout (H7), per-route rotation (M4),
troubleshooting (L3), pre-tag verification ritual (H3).
- Issue #125 — pre-tag MDM-host HTTPS-interception verification ritual.
## Quality Gates
- [x] `make check` passed locally on `8e5c975`. lint: `0 issues.` /
test: all 10 `ok` / security: `Issues : 0` / vulncheck: `No
vulnerabilities found.`
- [x] `go test -race ./...` passed on `8e5c975` — every package `ok`, no
`--- FAIL`.
- [x] Coverage minimums met (table below). 95%+ on anonymizer / config /
packs; 94.4% total.
- [x] Delta coverage ≥90% — N/A by gate (zero `.go` files in PR diff;
script skip is documented behavior).
- [x] [§6 Test Inventory
baseline-vs-head](#6-test-inventory--baseline-vs-head) completed.
- [x] No hacks — diff grep at head returns empty for
`TODO|FIXME|HACK|XXX|//nolint|t\.Skip|coverage-ignore`.
- [x] All CI jobs green at the PR head SHA `8e5c975`.
### Coverage (per Go package)
| Package | Coverage | Gate |
|---|---|---|
| `./cmd/proxy/` | 100.0% | — |
| `./internal/anonymizer/` | 95.6% | ≥95% ✅ |
| `./internal/anonymizer/packs/` | 97.6% | ≥95% ✅ |
| `./internal/config/` | 100.0% | ≥95% ✅ |
| `./internal/domainmatch/` | 100.0% | — |
| `./internal/logger/` | 86.4% | — |
| `./internal/management/` | 89.8% | — |
| `./internal/metrics/` | 100.0% | — |
| `./internal/mitm/` | 85.7% | — |
| `./internal/proxy/` | 93.0% | — |
| **total** | **94.4%** | ≥85% ✅ |
## Delta Coverage Report
```bash
go test -race -coverprofile=coverage.out -covermode=atomic ./...
bash .github/scripts/delta-coverage.sh coverage.out 90.0 origin/main
```
Output:
```text
No changed Go source files — delta coverage check skipped.
```
PR diff contains zero `.go` files (`git diff --name-only
--diff-filter=ACMR origin/main...HEAD -- '*.go'` is empty). The script's
documented behavior on empty Go diffs is to skip with exit 0.
## §6 Test Inventory — baseline vs head
**Method per `docs/test-plans/ai-proxy-test-method.md`:**
1. Spec source: §6 table, organized **per PII pack**, mapping each pack
to its unit test file and (where present) report test file.
2. For each row, extracted top-level test names from the named file(s)
with `grep -oE '^func (Test[A-Za-z0-9_]+)'`, passed them to `go test
-race -count=1 -v -run "^(N1|N2|...)$" <pkg>`.
3. Counted `--- PASS` and `--- FAIL` lines **top-level + subtests** per
CLAUDE.md gate spec — `grep -cE '^[[:space:]]*--- PASS'` (the
leading-whitespace match captures indented subtest results, which the
prior round's `^--- PASS` missed; **all previously-posted numbers were
undercounted**).
4. Two independent worktrees (`git worktree add /tmp/main-tree 6436ce0`
and the PR head), separate `go test` invocations against separate trees.
Not derived from `git diff`.
| §6 row | File(s) | main (`6436ce0`) PASS / FAIL | head (`8e5c975`)
PASS / FAIL | Δ |
|---|---|---|---|---|
| GLOBAL | `packs/global_test.go` | 22 / 0 | 22 / 0 | 0 |
| DE | `packs/de_test.go` | 17 / 0 | 17 / 0 | 0 |
| US | `packs/us_test.go` | 38 / 0 | 38 / 0 | 0 |
| FR | `packs/fr_test.go` | 41 / 0 | 41 / 0 | 0 |
| SECRETS (unit) | `packs/secrets_test.go` | 28 / 0 | 28 / 0 | 0 |
| SECRETS (report) | `anonymizer/secrets_report_test.go` | 44 / 0 | 44 /
0 | 0 |
| NL (unit) | `packs/nl_test.go` | 12 / 0 | 12 / 0 | 0 |
| NL (report) | `anonymizer/nl_report_test.go` | 6 / 0 | 6 / 0 | 0 |
| FINANCE_EU (unit) | `packs/finance_eu_test.go` | 15 / 0 | 15 / 0 | 0 |
| FINANCE_EU (report) | `anonymizer/finance_eu_report_test.go` | 12 / 0
| 12 / 0 | 0 |
| HEALTHCARE (unit) | `packs/healthcare_test.go` | 4 / 0 | 4 / 0 | 0 |
| HEALTHCARE (report) | `anonymizer/healthcare_report_test.go` | 15 / 0
| 15 / 0 | 0 |
| Cross-pack (all report tests) |
`anonymizer/{secrets,nl,finance_eu,healthcare,secrets_priority}_report_test.go`
| 90 / 0 | 90 / 0 | 0 |
`diff /tmp/inv6-main.txt /tmp/inv6-head.txt` returns empty. Zero deltas
on every row.
### §5 Known-issues pinning tests
The method's §5 names specific tests as pins for known issues. Each was
executed independently by name on both worktrees:
| Pinning test | Issue | main (`6436ce0`) | head (`8e5c975`) | Δ |
|---|---|---|---|---|
| `TestDESteuerIDPattern` | #67 | 1 / 0 | 1 / 0 | 0 |
| `TestDESVNRPattern` | #67 | 1 / 0 | 1 / 0 | 0 |
| `TestUSAddressPattern` (German* subtests) | #68 | 7 / 0 | 7 / 0 | 0 |
| `TestSIREN_SSN_CrossPattern` | #69 | 4 / 0 | 4 / 0 | 0 |
| `TestFRSIRENPattern` | #69 | 1 / 0 | 1 / 0 | 0 |
| **`TestSecretsPriorityOverGLOBAL`** | **#70** | **12 / 0** | **12 /
0** | **0** |
| `TestNLKvKPattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestFINANCEEUSWIFTBICPattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestHEALTHCAREICD10Pattern` | (design) | 1 / 0 | 1 / 0 | 0 |
| `TestHEALTHCAREMRNPattern` | (design) | 1 / 0 | 1 / 0 | 0 |
Issue #70 / `TestSecretsPriorityOverGLOBAL` directly pins **CLAUDE.md
Invariant #3** ("SECRETS must precede GLOBAL"). Verified PASS at head by
name.
### §2.3 Report-test config compliance
Every report test under `internal/anonymizer/*_report_test.go` declares
the canonical configuration the method requires:
```bash
grep -n "EnabledPacks" internal/anonymizer/*_report_test.go
```
Returns `EnabledPacks: []string{"SECRETS", "GLOBAL", "US", "DE", "FR",
"NL", "FINANCE_EU", "HEALTHCARE"}` in every report test, with `UseAI:
false` and `PackDecayRate: 0.0` adjacent. SECRETS precedes GLOBAL —
Invariant #3 satisfied at the config level, in addition to the
named-test pin above.
### Supplemental: per-Go-package rollup
| Package | main (`6436ce0`) | head (`8e5c975`) | Δ |
|---|---|---|---|
| `./cmd/proxy/` | 17 / 0 | 17 / 0 | 0 |
| `./internal/anonymizer/` | 202 / 0 | 202 / 0 | 0 |
| `./internal/anonymizer/packs/` | 71 / 0 | 71 / 0 | 0 |
| `./internal/config/` | 34 / 0 | 34 / 0 | 0 |
| `./internal/domainmatch/` | 5 / 0 | 5 / 0 | 0 |
| `./internal/logger/` | 11 / 0 | 11 / 0 | 0 |
| `./internal/management/` | 38 / 0 | 38 / 0 | 0 |
| `./internal/metrics/` | 17 / 0 | 17 / 0 | 0 |
| `./internal/mitm/` | 30 / 0 | 30 / 0 | 0 |
| `./internal/proxy/` | 59 / 0 | 59 / 0 | 0 |
Note: these per-package counts use `go test -v -count=1 ./<pkg>`
(whole-package) with subtest-aware grep. They are a rollup, not the gate
artifact — the §6 per-pack table above is the artifact the spec demands.
## Review-feedback resolution
| ID | Round | Fix |
|---|---|---|
| H1 | R1 | PR body disclosure + checklist filled. |
| H2 | R1 (`00d341a`) | postinstall rollback verifies cert is actually
absent; distinct exit code 2 when CA STILL TRUSTED. |
| H3 | R1 → R2 reformulated | Trackable artifact triple: issue #125 +
workflow YAML comment + docs § Pre-tag verification ritual. |
| H4 | R1 (`79c409b`) | Secret-verify + signing/notarize gated on
`IS_RELEASE`. |
| H5 | R1 (`79c409b`) | P12 cleanup via `trap EXIT`. |
| H6 | R1 (`79c409b`) | Artifact upload explicit filenames. |
| H7 | R1 (`c219de1`) | Blast-radius callout in docs. |
| M1 | R1 (`b9ce7c6`) | macOS env file ports match plist. |
| M2 | R1 (`b9ce7c6`) | `distribution.xml` `pkg-ref version` rendered
from `$VERSION`. |
| M3 | R1 (`b9ce7c6`) | `.mobileconfig` ExceptionsList. |
| M4 | R1 (`c219de1`) | Rotation per route. |
| L1 | R1 (`00d341a`) | bootstrap stderr captured. |
| L2 | R1 (`79c409b`) | concurrency block. |
| L3 | R1 (`c219de1`) | Troubleshooting section. |
| N1 | R2 (`274e319`) | PR-event dry-run exercises build mechanism. |
| Gate 5 §6 inventory | R3 | Per-pack execution; previously was
per-Go-package. |
| Gate 5 subtest counting + §5 pins + §2.3 config | R4 | Subtest-aware
grep (`^[[:space:]]*--- PASS`); §5 named-test pin table; §2.3 config
compliance verified. |
## Verified
- `bash -n` clean on every shell script
- `xmllint --noout` clean on `distribution.xml`, the LaunchDaemon plist,
and the `.mobileconfig.tmpl`
- YAML parse clean on the workflow
- JSON parse clean on `proxy-config.json.default`
- `make check` clean on `8e5c975`
- `go test -race ./...` clean on `8e5c975`
- §6 inventory: two independent worktree executions, per-pack,
subtest-aware, zero deltas
- §5 known-issues pinning tests verified by name on both worktrees, zero
deltas
- §2.3 report-test config compliance grep'd from source
- `make -n package-macos-pkg` gates on Darwin
- PR-event macOS CI: dry-run path exercises payload staging + pkgbuild +
productbuild + mobileconfig render unsigned
**Pre-tag manual gate (issue #125):** `.mobileconfig` HTTPS-interception
verification on MDM-enrolled host — required before pushing any `v*`
tag.
## Out of scope
- Homebrew tap (Phase 5a)
- Windows MSI (Phase 3)
- Sparkle / auto-update integration
- JAMF policy templates wrapping the .mobileconfig (left to admins)
## Related
- Previous: Phase 1 Linux DEB/RPM (PR #120)
- Next: Phase 3 Windows MSI + ADMX
- Pre-tag gate: #125
## What
Phase 3 of the UEM packaging workstream — a per-machine `x64` Windows
MSI built via WiX Toolset (v4 schema, v7 CLI), plus an ADMX/ADML Group
Policy template for managed-fleet proxy configuration. The proxy binary
is now Windows-service-aware: SCM-launched processes register a service
handler within milliseconds, translate `Stop`/`Shutdown` into a bounded
graceful HTTP shutdown, and exit cleanly. Per-host CA generation mirrors
the Linux postinstall pattern (idempotent on upgrade); the CA is
imported into `Cert:\LocalMachine\Root` on install and removed on
uninstall via a Go helper rather than inline PowerShell.
## Changes
- **`internal/envfile/`** — new `KEY=VALUE` parser + `--env-file` CLI
flag so the MSI service can hand the binary a single config-file path
(no Windows-native `EnvironmentFile=` equivalent exists).
- **`internal/config/policy.go` + `policy_windows.go`** — Windows-only
read of `HKLM\SOFTWARE\Policies\laplaque\AiProxy`; `Address` overrides
`BindAddress` and `Port` overrides `ProxyPort`. Layering: defaults →
file → env → policy. **`applyPolicy` writes only to `BindAddress` /
`ProxyPort` — never to `EnabledPacks`, so the §1.2 pipeline-ordering
invariant is preserved structurally, not just empirically.** A
`registryGetter` interface plus a `fakeRegistry` in `policy_test.go`
make the apply logic testable on any platform.
- **`cmd/proxy/service_lifecycle.go` + `service_windows.go`** —
platform-neutral `runServiceLifecycle` implements the SCM contract
(`StartPending` → `Running` → bounded `srv.Shutdown` → exit) and is
exercised by `service_lifecycle_test.go` (graceful stop, interrogate,
shutdown-timeout, bind-failure). The Windows-only file is now a thin
SCM-channel bridge.
- **`cmd/proxy/caremove*.go`** — new `--remove-ca-from-store` flag.
Parses the PEM, computes the SHA-1 thumbprint, and shells out to
`certutil -delstore` from Go. Idempotent on "cannot find" so re-running
the uninstaller is safe.
- **`packaging/windows/wix/`** — `Product.wxs`, `Service.wxs`,
`CATrust.wxs`, `build.ps1`. Per-machine MSI, `MajorUpgrade`,
restart-on-failure (5s delay, 1-day reset), CA generated via
`ai-proxy.exe --generate-ca` (idempotent), imported with `certutil`,
removed on uninstall via `ai-proxy.exe --remove-ca-from-store`.
`<util:PermissionEx>` on the `CADir` component restricts
`C:\ProgramData\AiProxy` to `LocalSystem` + `Administrators`.
- **`packaging/windows/admx/`** — `ai-proxy.admx` +
`en-US/ai-proxy.adml` exposing Enable, Address, Port to GPO admins.
- **`make package-windows`** +
`.github/workflows/release-windows-msi.yml` — `windows-latest` build,
`wix eula accept wix7`, AzureSignTool with HSM-backed EV cert, `signtool
verify`, round-trip `msiexec /qn /i ... /x ...` test.
- **`docs/packaging/windows.md`** — install/configure/uninstall, GPO
deployment, Intune commands, signature verification.
## Quality Gates
- [x] `make check` passed locally (lint, test, security, vulncheck — all
four sub-gates exit 0). Last lines of output:
```
Running gosec security scanner...
...
Issues : 0
Running govulncheck...
Verifying packages...
Verifying modules...
No vulnerabilities found.
All checks passed.
```
(Locally `gosec` runs the same `-exclude=G104,G304,G703,G706` set as the
Makefile; the new SHA-1 thumbprint code uses gosec `#nosec G401`/`G505`
annotations with reason comments — no new global exclusions.)
- [x] `go test -race ./...` passed
- [x] `TestTokenFormatNonRetriggering` and
`TestTokenFormatNonRetriggeringAllPacks` both pass at head
- [x] Coverage minimums met: `internal/config` 100.0%,
`internal/anonymizer` 95.6%, `internal/anonymizer/packs` 97.6%; overall
well above 85%
- [x] Delta coverage ≥95% on all changed/new files
- [x] [§6 Test Inventory
baseline-vs-head](#6-test-inventory--baseline-vs-head) completed below —
full regression gate per `docs/test-plans/ai-proxy-test-method.md`
§§1–6: §5 anchors, §2.3 standard config, §4 invariants, §6 inventory all
verified
- [x] No hacks introduced (no `//nolint` without a substantive reason;
no `t.Skip()`; no `// coverage-ignore`; no hardcoded test-satisfying
values; no new gosec exclusions in the Makefile — only file-level
`#nosec` directives with reason comments)
- [ ] All CI jobs green at the PR head SHA — pipeline is being re-walked
at the latest head after the review fixes; will tick once CI confirms
## Delta Coverage Report
**Command:**
```bash
go test -race -coverprofile=coverage.out -covermode=atomic ./...
bash .github/scripts/delta-coverage.sh coverage.out 95.0 origin/main
```
**Raw script output:**
```text
=== Delta Coverage Check (threshold: 95.0%) ===
Changed source files:
cmd/proxy/caremove.go
cmd/proxy/caremove_other.go
cmd/proxy/caremove_windows.go
cmd/proxy/main.go
cmd/proxy/service_lifecycle.go
cmd/proxy/service_other.go
cmd/proxy/service_windows.go
internal/config/config.go
internal/config/policy.go
internal/config/policy_other.go
internal/config/policy_windows.go
internal/envfile/envfile.go
Checked 22 functions in 12 changed files.
SUCCESS: All functions in changed files meet 95.0% coverage threshold.
```
**Per-function table** (every changed file, ≥95% on the Linux runner):
| File | Function | Coverage % | ≥95% |
|---|---|---|---|
| cmd/proxy/caremove.go | `certThumbprint` | 100.0% | ✅ |
| cmd/proxy/caremove_other.go | `removeCAFromStore` | 100.0% | ✅ |
| cmd/proxy/caremove_windows.go | `removeCAFromStore` | (Windows-only) |
n/a on Linux runner |
| cmd/proxy/main.go | `main` | 96.6% | ✅ |
| cmd/proxy/main.go | `runServerOrService` | 100.0% | ✅ |
| cmd/proxy/main.go | `runGenerateCA` | 100.0% | ✅ |
| cmd/proxy/main.go | `printBanner` | 100.0% | ✅ |
| cmd/proxy/service_lifecycle.go | `runServiceLifecycle` | 95.7% | ✅ |
| cmd/proxy/service_other.go | `runAsServiceIfNeeded` | 100.0% | ✅ |
| cmd/proxy/service_windows.go | `runAsServiceIfNeeded` / `Execute` |
(Windows-only) | n/a on Linux runner |
| internal/config/config.go | `Load` | 100.0% | ✅ |
| internal/config/policy.go | `applyPolicy` | 100.0% | ✅ |
| internal/config/policy_other.go | `loadPolicy` | 0-of-0 statements |
n/a (empty stub) |
| internal/config/policy_windows.go | `loadPolicy` / `winRegistryGetter`
| (Windows-only) | n/a on Linux runner |
| internal/envfile/envfile.go | `Apply` | 96.0% | ✅ |
| internal/envfile/envfile.go | `unquote` | 100.0% | ✅ |
The two Windows-only paths (`policy_windows.go::loadPolicy`,
`service_windows.go::*`) are exercised by the round-trip `msiexec /qn
/i` test on the `windows-latest` CI runner; on the Linux runner their
build-tag stubs are tested instead, and the cross-platform `applyPolicy`
+ `runServiceLifecycle` paths cover the actual logic (see H4a/H4b in the
review fixes).
## §6 Test Inventory — baseline vs head
The full §§1–6 of `docs/test-plans/ai-proxy-test-method.md` is the
regression gate. Every part is exercised on both `main` (6436ce0) and
head (ace8487).
### §1.2 / §4 — Pipeline-ordering invariant
The methodology requires patterns to be evaluated in `EnabledPacks`
order (`SECRETS → GLOBAL → US → DE → FR → NL → FINANCE_EU →
HEALTHCARE`), so that specific secret patterns aren't consumed by
GLOBAL's broad `api_key` regex (issue #70).
This PR introduces a new config layer (Windows Group Policy).
`applyPolicy` writes only to `cfg.BindAddress` and `cfg.ProxyPort`:
```go
// internal/config/policy.go
if addr, ok := g.GetString("Address"); ok && addr != "" {
cfg.BindAddress = addr
}
if port, ok := g.GetUint64("Port"); ok && port > 0 && port <= 65535 {
cfg.ProxyPort = int(port)
}
```
`cfg.EnabledPacks` is never written by the new code path, so the
pipeline-ordering invariant is preserved structurally.
`TestSecretsPriorityOverGLOBAL` (12 subtests) is the empirical witness —
see §5 table below.
### §2.3 — Standard configuration intact
The report tests on both sides still pin:
```go
Options{
UseAI: false,
PackDecayRate: 0.0,
EnabledPacks: []string{"SECRETS", "GLOBAL", "US", "DE", "FR", "NL", "FINANCE_EU", "HEALTHCARE"},
}
```
Confirmed by `grep -rE "UseAI|PackDecayRate|EnabledPacks"
internal/anonymizer/*_report_test.go` returning identical entries on
both `main` and head across all five report-test files.
### §5 — Named regression-anchor tests (both sides identical)
Each anchor named in §5 ran on `main` and on head with `go test -race
-count=1 -v -run "^<Test>$" <pkg>`:
| Test (§5 reference) | Package | main PASS / FAIL | head PASS / FAIL |
Delta |
|---|---|---|---|---|
| `TestDESteuerIDPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0
| 0 |
| `TestDESVNRPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 | 0
|
| `TestUSAddressPattern` (subtests: `German_ist`, `German_ist_2`,
`German_Kunst`, `German_Durst`, `German_erst`, `German_Bist`) |
`internal/anonymizer/packs/` | 10 / 0 | 10 / 0 | 0 |
| `TestSIREN_SSN_CrossPattern` (issue #69) |
`internal/anonymizer/packs/` | 4 / 0 | 4 / 0 | 0 |
| `TestFRSIRENPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 |
0 |
| `TestSecretsPriorityOverGLOBAL` (issue #70 — empirical witness for
§1.2 ordering) | `internal/anonymizer/` | 12 / 0 | 12 / 0 | 0 |
| `TestNLKvKPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1 / 0 | 0
|
| `TestFINANCEEUSWIFTBICPattern` | `internal/anonymizer/packs/` | 1 / 0
| 1 / 0 | 0 |
| `TestHEALTHCAREICD10Pattern` | `internal/anonymizer/packs/` | 1 / 0 |
1 / 0 | 0 |
| `TestHEALTHCAREMRNPattern` | `internal/anonymizer/packs/` | 1 / 0 | 1
/ 0 | 0 |
| **§5 total** | | **33 / 0** | **33 / 0** | **0** |
### §6 — Inventory (per-file PASS/FAIL on both sides)
| Pack | File | main (6436ce0) PASS / FAIL | head (ace8487) PASS / FAIL
| Delta |
|---|---|---|---|---|
| GLOBAL (unit) | `internal/anonymizer/packs/global_test.go` | 6 / 0 | 6
/ 0 | 0 |
| DE (unit) | `internal/anonymizer/packs/de_test.go` | 5 / 0 | 5 / 0 | 0
|
| US (unit) | `internal/anonymizer/packs/us_test.go` | 10 / 0 | 10 / 0 |
0 |
| FR (unit) | `internal/anonymizer/packs/fr_test.go` | 9 / 0 | 9 / 0 | 0
|
| SECRETS (unit) | `internal/anonymizer/packs/secrets_test.go` | 28 / 0
| 28 / 0 | 0 |
| SECRETS (report) | `internal/anonymizer/secrets_report_test.go` | 1 /
0 | 1 / 0 | 0 |
| NL (unit) | `internal/anonymizer/packs/nl_test.go` | 4 / 0 | 4 / 0 | 0
|
| NL (report) | `internal/anonymizer/nl_report_test.go` | 2 / 0 | 2 / 0
| 0 |
| FINANCE_EU (unit) | `internal/anonymizer/packs/finance_eu_test.go` | 5
/ 0 | 5 / 0 | 0 |
| FINANCE_EU (report) | `internal/anonymizer/finance_eu_report_test.go`
| 2 / 0 | 2 / 0 | 0 |
| HEALTHCARE (unit) | `internal/anonymizer/packs/healthcare_test.go` | 4
/ 0 | 4 / 0 | 0 |
| HEALTHCARE (report) | `internal/anonymizer/healthcare_report_test.go`
| 2 / 0 | 2 / 0 | 0 |
| **Cross-pack (report)** |
`internal/anonymizer/secrets_priority_report_test.go` | 2 / 0 | 2 / 0 |
0 |
| **§6 total** | | **80 / 0** | **80 / 0** | **0** |
Zero failures on either side, zero net delta, every §5 anchor passes
identically, §2.3 standard config remains the pinned report-test
configuration, and the §1.2 / §4 pipeline-ordering invariant is
preserved structurally (the new policy layer never writes
`EnabledPacks`).
New tests added by this PR live outside the §6 anonymizer inventory (in
`./cmd/proxy/`, `./internal/config/`, `./internal/envfile/`) — see the
[Test Plan](#test-plan) section.
## Test Plan
New tests added by this PR (all run as part of `go test -race ./...`):
- `cmd/proxy/main_test.go`:
- `TestMain_HelperProcess_EnvFile_Loaded` / `_Fatal` — pins both
branches of the new `--env-file` dispatch path in `main()`.
- `TestMain_HelperProcess_RemoveCAFromStore_Fatal` — pins the
`--remove-ca-from-store` dispatch path (success branch is exercised on
Windows via the MSI round-trip test).
- `cmd/proxy/run_test.go`:
- `TestRunServerOrService_ServiceModeReturnsImmediately` — swaps the
`serviceDispatcher` package var with a fake that returns true; covers
the SCM early-return branch on any platform.
- `cmd/proxy/service_lifecycle_test.go`:
- `TestRunServiceLifecycle_StopGracefully` — pins the `StartPending` →
`Running` → `StopPending` transitions on a clean stop.
- `TestRunServiceLifecycle_InterrogateReEmits` — pins the SCM
Interrogate handling.
- `TestRunServiceLifecycle_ShutdownTimeoutLogged` — pins the
`srv.Shutdown` error path with a 50 ms test-only deadline and a held
request.
- `TestRunServiceLifecycle_BindFailureReturnsNonZero` — pins exit code 1
on listener collision.
- `cmd/proxy/caremove_test.go`:
- `TestCertThumbprint_MatchesSHA1OfDER` — pins the thumbprint format
Windows certutil expects.
- `TestCertThumbprint_MissingFile` / `_NotPEM` / `_InvalidCertBytes` —
pin every failure mode of the parser.
- `internal/envfile/envfile_test.go`:
- Table-driven tests for `Apply` covering simple pairs, comments, blank
lines, quoted values, embedded `=`, empty values, missing-`=` errors,
empty-key errors, missing-file errors, and oversized-line scanner
errors.
- `internal/config/policy_test.go`:
- `TestApplyPolicy_NilGetter_NoOp` / `OverridesAddressAndPort` /
`EmptyAddressIgnored` / `OutOfRangePortIgnored` /
`AbsentKeysLeaveDefaults` — pin every branch of the cross-platform GPO
apply logic via a `fakeRegistry` stub.
End-to-end coverage on the actual Windows service + MSI + cert-store
paths is delivered by the `.github/workflows/release-windows-msi.yml`
round-trip step on `windows-latest`: `msiexec /qn /i` → assert
`Get-Service ai-proxy` is `Running` and the CA is in
`Cert:\LocalMachine\Root` → `msiexec /qn /x` → assert both are gone.
## Linked Issues
- Implements UEM packaging Phase 3 (Windows MSI + ADMX).
- Previous: Phase 1 Linux DEB/RPM (#120).
- Next: Phase 4 unified silent installer.
## Tooling note (M3 from review)
The WiX XML schema this PR targets is the v4 namespace
(`http://wixtoolset.org/schemas/v4/wxs`), which is the stable schema
produced by every WiX release since v4 — the CLI invoking it is `wix
--version 7.0.0` (the current `dotnet tool install --global wix`
default). v7 introduced the OSMF EULA gate, which `build.ps1` accepts
via `wix eula accept wix7` before any other command. The CI workflow
pins the `actions/setup-dotnet` SHA but intentionally tracks the latest
`wix` CLI so security/bugfix releases are picked up.
What
First phase of the UEM packaging workstream. Ships
.deband.rpmpackages built reproducibly via nfpm so enterprise rollout tools (Intune, JAMF, SCCM, Ansible) can install the proxy non-interactively, register the CA in the OS trust store, and bring the service up under systemd with a single command — without baking config into the binary.Changes
cmd/proxy/main.go— adds a one-shot--generate-ca/--ca-cert/--ca-keyCLI dispatch that wraps the existinginternal/mitm.GenerateCA. The post-install script calls this to bootstrap the CA non-interactively without needingopensslor interactive prompts.packaging/linux/nfpm.yaml— single nfpm config producing.deband.rpmforamd64+arm64. Declaresca-certificates+systemdruntime deps so package managers pull what postinstall needs (fixed minimal-image gaps surfaced on Fedora / openSUSE in CI).packaging/linux/ai-proxy.service— systemd unit with hardening (NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp,PrivateDevices, etc.). Reads either/etc/default/ai-proxy(Debian) or/etc/sysconfig/ai-proxy(RHEL) viaEnvironmentFile=-.packaging/linux/postinstall.sh— createsai-proxysystem user, generates CA if absent (idempotent), copies into the correct trust-store anchor directory per distro family (Debian / openSUSE / RHEL all differ), enables the unit, then on a real systemd-managed host enforces thatsystemctl startsucceeds — on failure, rolls back the CA anchor and exits non-zero so the operator and any UEM tooling see the failure. Closes the PII-leak window from F3 in round 1.packaging/linux/preremove.sh— stops the service, removes the CA from all three possible anchor directories.packaging/linux/ai-proxy.env— env file template covering every env var consumed bycmd/proxy/internal/config.packaging/linux/proxy-config.json.default— default config shipped as a conffile (preserved on upgrade).Makefile—make package-linux(and per-arch variants)..github/workflows/release-linux-packages.yml— CI pipeline: builds packages for both arches, runs install / verify / uninstall round-trip in privileged systemd-capable Docker containers across Ubuntu 22.04, 24.04, Debian 12, Alma 9, Fedora latest, openSUSE Leap 15. On release tags only: generates SHA256/512 sums and signs each artifact via Sigstore cosign keyless. nfpm pinned tov2.46.3.contents: write+id-token: writescoped to thereleasejob only..github/scripts/test-install.sh— install round-trip verification mounted into each matrix container.cmd/proxy/main_test.go— addsTestRunGenerateCA(table-driven contract test) + two helper-process tests (TestMain_HelperProcess_GenerateCA,TestMain_HelperProcess_GenerateCA_Fatal) that re-exec the binary via theGO_WANT_HELPER_PROCESS=1pattern. Consolidates the existing per-callsite//nolint:gosec G204directives into a singlehelperCmd(t, args...)helper carrying one suppression with a substantive reason (net gosec directive count in the file: 7 → 1).docs/packaging/linux.md+docs/packaging/README.md— install / configure / verify / uninstall reference, plus a⚠ Security posture — read before fleet deploymentcallout at the top spelling out the CA-trust-anchor implication, key location/mode, per-distro anchor paths, upgrade behaviour, and the F3 install-time guarantee.README.md— adds a packaging row to the doc table.Quality Gates
make checkpassed locally (lint, test, security, vulncheck — all four sub-gates exit 0). Last line of output:All checks passed.Sub-gate detail at
d78f66c:golangci-lint v2.10.1(the exact CI version, downloaded locally; thegolangci-linton$PATHis older than the project's Go 1.26 toolchain so the documented carve-out applies):0 issues.ok(no-racehere; race is in the next gate).gosec -exclude=G104,G304,G703,G706):Files : 31,Lines : 5933,Nosec : 5,Issues : 0.govulncheck ./...):No vulnerabilities found.go test -race ./...passed — all 10 packagesokwith-race. Output:TestTokenFormatNonRetriggering— this PR introduces no new PII types or pack patterns, so the "for every PII type" sub-clause is N/A. The existing test continues to pass under-race(part ofinternal/anonymizer's 202 PASS / 0 FAIL above).Coverage minimums met:
internal/anonymizer→ 95.6% (≥95% ✓)internal/config→ 100.0% (≥95% ✓)internal/anonymizer/packs→ 97.6% (≥95% ✓)Delta coverage ≥90% on all changed/new files — see Delta Coverage Report below
§6 Test Inventory baseline-vs-head completed below
No hacks introduced — only
//nolint:gosec G204incmd/proxy/main_test.go:38-41(substantive reason: helper-process pattern;os.Args[0]is the test binary itself, not external input). Net gosec directives in that file went from 7 (5 pre-existing from chore(cmd/proxy): decompose main() for testability + close delta-coverage gap #121 + 2 added by this PR's initial draft) to 1 (consolidated into the helper). Not.Skip(), no// coverage-ignore, no test-satisfying hardcoded values.All CI jobs green at the PR head SHA (16 SUCCESS + 1 correctly-SKIPPED
Sign + publish, gated onrefs/tags/v*).Delta Coverage Report
Per
.github/scripts/delta-coverage.sh— every function in changed/new.gosource files (excluding_test.go,_generated.go,mock_*) must be ≥90%.Command:
go test -race -coverprofile=coverage.out -covermode=atomic ./... bash .github/scripts/delta-coverage.sh coverage.out 90.0 origin/mainRaw script output (verbatim from
d78f66cvsorigin/main):Per-function table — every function in every changed
.gosource file (test files filtered by the script; the only changed Go source file iscmd/proxy/main.go):cmd/proxy/main.gomaincmd/proxy/main.gorunGenerateCAcmd/proxy/main.goprintBannermain()'s 100% comes from the helper-process tests (TestMain_HelperProcess_*) introduced in #121 — they re-exec the test binary as the production binary, somain()itself runs under coverage instrumentation. This PR's only addition tomain.gois the--generate-caflag-dispatch +runGenerateCAhelper; both are exercised by the newTestMain_HelperProcess_GenerateCA(success path) andTestMain_HelperProcess_GenerateCA_Fatal([CA]log.Fatalf path), plusTestRunGenerateCA(in-process unit test for the helper's path-arg validation + file-on-disk + key-perm contract).§6 Test Inventory — baseline vs head
Method per
.github/PULL_REQUEST_TEMPLATE.md: checkoutmain,go test -race -count=1 -v <pkg>, count--- PASS/--- FAILlines. Repeat on PR head SHA. Both executions performed; no diff-reasoning.1ebc033) PASS / FAILd78f66c) PASS / FAIL./cmd/proxy/./internal/anonymizer/./internal/anonymizer/packs/./internal/config/./internal/logger/./internal/management/./internal/metrics/./internal/mitm/./internal/proxy/Zero failures on either side. Only positive delta is
+3in./cmd/proxy/covering the new--generate-capaths.Test Plan
New tests added in this PR (all in
cmd/proxy/main_test.go):TestRunGenerateCA— pins the contractcmd/proxy/runGenerateCAowns: rejects empty--ca-cert/--ca-keypaths; surfaces filesystem errors when the cert directory is unwritable; on success writes both files with the key at mode0600. The cryptographic shape of the generated CA is covered byinternal/mitmtests; this test only owns the cmd-layer contract.TestMain_HelperProcess_GenerateCA— exercisesmain()'s--generate-cadispatch end-to-end via the helper-process pattern (GO_WANT_HELPER_PROCESS=1). Re-execs the test binary with--generate-ca --ca-cert <tmp> --ca-key <tmp>, asserts non-zero exit isn't returned and both files land at the expected paths.TestMain_HelperProcess_GenerateCA_Fatal— exercisesmain()'s[CA]log.Fatalfbranch. Re-execs with--generate-ca --ca-cert= --ca-key <tmp>(empty cert path →runGenerateCAreturns error →log.Fatalf), asserts non-zero exit and[CA]in the captured stderr.CI-side test plan (executed by
.github/workflows/release-linux-packages.ymlon this PR):make package-linux-amd64+make package-linux-arm64produce 4 artifacts (.deb+.rpm× amd64 + arm64)..github/scripts/test-install.shruns inside privileged containers across Ubuntu 22.04, Ubuntu 24.04, Debian 12, Alma 9, Fedora latest, openSUSE Leap 15. Verifies: binary at/usr/bin/ai-proxy, conffiles at/etc/ai-proxy/proxy-config.jsonand/etc/default/ai-proxyor/etc/sysconfig/ai-proxy, CA anchored in one of the three trust-store directories, systemd unit enabled. Then uninstalls and verifies the CA is removed from the trust store and the binary is gone.Linked Issues
No linked GitHub issue. This is the first phase of the UEM packaging workstream tracked in the project wiki; subsequent phases (macOS PKG, Windows MSI, personal apt/yum repo, AUR) ship in their own PRs.
Out of scope (deferred to later UEM phases)
.pkg+.mobileconfig— Phase 2