From 213d360cc14a025e247603a1a0ac70563c617af7 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Sun, 12 Jul 2026 04:20:14 +0200 Subject: [PATCH] fix: harden toolchain and release integrity Pin CI and releases to Go 1.25.12 and x/sys 0.44.0, add a pinned govulncheck lane, and make release publication fail closed on missing or corrupt assets. Add four-target release and installer E2Es covering successful install, partial releases, missing checksums, and tampered binaries; refresh public toolchain and state docs. --- .github/workflows/ci.yml | 34 +++++++++++++++ .github/workflows/release.yml | 11 +++-- CHANGELOG.md | 2 + README.md | 4 +- STATE.md | 10 ++--- docs/PREREQUISITES.md | 2 +- go.mod | 4 +- go.sum | 4 +- scripts/install.sh | 15 ++++++- scripts/verify-release-assets.sh | 34 +++++++++++++++ test/install-e2e.sh | 74 ++++++++++++++++++++++++++++++++ test/release-assets-e2e.sh | 37 ++++++++++++++++ 12 files changed, 211 insertions(+), 20 deletions(-) create mode 100755 scripts/verify-release-assets.sh create mode 100755 test/install-e2e.sh create mode 100755 test/release-assets-e2e.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4bf752..92771c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,40 @@ permissions: contents: read jobs: + # The source can be correct while the compiler/runtime or a linked module carries a + # reachable vulnerability. Pin govulncheck and run it under the exact patch toolchain + # from go.mod so an old standard library cannot produce a fully-green release again. + # This is deliberately a separate lane: it consults the Go vulnerability database, + # while make verify remains fast and hermetic. + vuln: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Reachable vulnerability scan + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + + # Exercise the same curl/checksum/install path published in the README against a + # locally-built release asset. The script also proves missing and mismatched checksum + # records fail closed, so release-integrity regressions are caught before tagging. + installer: + name: release + installer integrity (local E2E) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build and verify the four-asset release set + run: bash test/release-assets-e2e.sh + - name: Verify and install a local release asset + run: bash test/install-e2e.sh + verify: name: make verify runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c1aee6..35b3174 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,16 +48,15 @@ jobs: if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - - name: Aggregate a signed-off checksums manifest - # One SHA256SUMS covering every binary, alongside the per-binary .sha256 files, - # so a consumer can verify the whole release set at once. - run: | - ( cd dist && sha256sum nilcore-* 2>/dev/null | grep -v '\.sha256$' > SHA256SUMS || true ) - echo "--- SHA256SUMS ---"; cat dist/SHA256SUMS + - name: Verify assets and assemble the checksums manifest + # Refuse to publish a partial or corrupt release. The installer requires the + # per-binary records, and SHA256SUMS must cover exactly the four supported targets. + run: scripts/verify-release-assets.sh dist - name: Publish release env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 880a279..ccd0cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ On a release, the maintainer moves the accumulated `[Unreleased]` entries into a ## [Unreleased] +- **security-release-hardening** — Pin the build/release floor to patched Go 1.25.12 and `x/sys` 0.44.0, add a pinned reachable-vulnerability CI gate, make release publication refuse missing/corrupt platform assets or checksum records, and add real four-target release + installer E2Es proving valid install plus fail-closed partial-release, missing-checksum, and tampered-binary paths; refresh the public toolchain and state snapshot. _Owns:_ `go.mod`, `go.sum`, `.github/workflows/{ci,release}.yml`, `scripts/{install,verify-release-assets}.sh`, `test/{install,release-assets}-e2e.sh`, `README.md`, `STATE.md`, `docs/PREREQUISITES.md`. _(security / release hardening)_ + - **docs(state-of-the-project): reconcile every document with the code as it actually is.** A ground-truth pack was extracted from the tree (CLI surface, the full env-var inventory incl. the prefix-constructed `NILCORE_{CODEX,CLAUDE}_{MODEL,EFFORT}`, package inventory + metrics, emitted event kinds, invariant enforcement sites, shipped-vs-gated status), then all 30 docs were audited against it and each repair was **independently refuted** by a second reader; 15 surviving defects were re-fixed. Headline corrections: `router.Classify(goal)` returns `run|build|swarm` — **not** `|decompose` (`Decompose` is a fourth `Preset`, opt-in only, excluded from `All()`), a claim three contract files asserted wrongly; `docs/ARCHITECTURE.md` overstated the container egress control as SSRF-proof/IP-layer when `AllowEgressVia` only sets `--network bridge` + `HTTP(S)_PROXY` and no packet filter exists — the proxy is the only *sanctioned* way out, not a hard network wall; metrics refreshed everywhere (120 packages, 375 source + 406 test files, ~89.8K non-test LOC); the six `NILCORE_OPENROUTER_*` / `NILCORE_COMPAT_*` vars documented only in suffix shorthand are now spelled out; `docs/REFERENCE.md` gained an explicit upgrade note for the two settings whose MEANING changed at `573a4df` (`NILCORE_SELFIMPROVE_AUTOAPPROVE=1` was a no-op and now really merges; swarm shards now reach their preset's declared hosts). Roadmap/plan docs keep their plans but now carry accurate SHIPPED / PARTIALLY SHIPPED / NOT-BUILT status and no longer name pruned symbols as live. `CHANGELOG.md` history untouched (append-only). Markdown only — no `.go`, `Makefile` or CI file changed. _Owns:_ `*.md`, `docs/**`. _(Phase 16 — documentation)_ - **chore(features-review): reconcile shipped-but-inert subsystems, then a full docs pass over the assembled diff.** A features-review sweep found a cluster of built-but-silently-dead paths (several of which reported false green), fixed each with a discriminating test, and then reconciled the docs to the code as it now is. **Verifier integrity:** the `vcache` key now folds in the browser-verify command, `NILCORE_VERIFY_PACKS`, `NILCORE_EVIDENCE_VERIFY`, `NILCORE_EVIDENCE_MAX_AGE` and the sandbox image identity (a behavioral toggle can no longer serve a stale green), and the worktree content hash includes `.nilcore/artifacts` when evidence verification is on; evidence verifiers are now discovered **lazily at Check time** on run/chat/serve/resume (they were built eagerly and so never ran); tiered verify reads only the `go test` leg of a compound command and replicates `-run`/`-skip`; a new boot `validateVerifyEnv` exits 2 on a bad `NILCORE_VERIFY_PACKS`/`NILCORE_EVIDENCE_MAX_AGE`. **Artifact packs:** `code.test_passes` ran `go test -- ''` (which never tested the selected package — a false green) → `go test ''`; `code.build_passes` on an undetectable layout is now Unverifiable, not Pass; `schema_verify` events are actually emitted (the report's SchemaDefects section was permanently empty; `DefectMeta` gained `claim_id`/`reason` + json tags); `software.github_tag_exists` paginates; the audit pack rejects leading-dash patterns. **Swarm:** shards now actually receive their derived egress (an allowlist proxy is stood up and applied per shard box — previously every shard ran `--network none`, so the research preset could never verify green and `--egress-allow` was inert); `--resume` re-seeds skipped/queued/running shards (planned-DAG dependents were being dropped — a false green) and red budget-exhausted shards block a clean converge. **Graduated auto-approval (graapprove) was structurally unreachable:** `AllowBranches:["*"]` never matched slash-y branches (`path.Match`) and trust/rate keyed on per-run-unique scopes — now `*` matches any non-empty scope, and trust + the per-day rate window key on a stable scope **family** (`task/trig-123`→`task/*`, a bare sha→`#commit`); the protected-base floor (main/master/release/trunk/stable/prod*) and `DenyBranches` stay on the **concrete** branch; an empty scope never auto-approves. **Self-improve now really merges:** it had no merge step (it logged `self_edit_merged` and returned `merged=true` while nothing landed) — there is now a real `Flow.Merge` seam, `Run` returns the verified branch, `merged=true` means it actually landed, and new event kinds record the failure modes (`self_edit_merge_unwired`/`self_edit_no_branch`/`self_edit_merge_failed`). **Session / front door:** drive-terminal persist ran on an already-cancelled ctx so conversation state never wrote (now detached — `context.WithoutCancel` + 5s timeout); `Session.Checkpoint` (previously zero callers) is now called on chat exit and in serve's drainShutdown; steer markers (`!`/`/steer`) are stripped before the model sees the turn; memory/lessons (`MemoryContext`) and operator steering (`SteeringContext`) now reach chat/TUI and serve, not just run/watch; `-blast-radius` now fences chat and serve native drives (chat mints ONE shared blast budget for its proxy + sandboxes + gate; a serve-restart-resumed run gets the dollar ceiling); serve's `--webhook` no longer opens a second SQLite handle on the DB serve owns; the serve-embedded flywheel gate is deny-default (was bound to `os.Stdin`); a failed serve drive no longer renders "✓ not verified" over Telegram/Slack. **Security / robustness:** `browsersession.Observation.URL` is scrubbed for typed secrets (an I3 leak into the model prompt AND the append-only log); browseragent irreversible-signal, `router.Classify`, `trust.Classify` and `session.referencesGoal` moved from substring to word-boundary matching; Slack model text is escaped (was ``-injectable), evidence-rich gates clip to Slack's 3000-char cap instead of silently auto-denying, 429/Retry-After is honored, and ws frame length is sanity-checked; Telegram scrubs the token from transport errors and clips long messages; MCP stdio writes are ctx-cancellable (a deadlock), responses size-capped, children reaped, stale resource/prompt wrappers pruned; one malformed SKILL.md no longer disables ALL skills; codeintel + live walks skip symlinks (I4) and vendor dirs (node_modules/vendor/.venv/dist/build), and several tool reads moved to `O_NOFOLLOW`; the termui spinner stop/animate deadlock is fixed (TryLock). **Backends / providers:** an empty-content native reply no longer poisons history and kills the run; Anthropic — a zero-frame clean-EOF stream is a retryable error, server-tool-only (`pause_turn`) turns are preserved, all beta headers collected; OpenAI — web plugin deduped, tool_result error signalled, max_tokens splice fixed; codex/claude-code `digText` matches the real claude-code stream-json (`result` key, `message.content[]`). **Also:** experience projection is rotation-aware (`Rebuild` drops stale keys); autosrc no longer kills a source permanently on a full queue; emit neutralizes ANSI in gate evidence; integrate stops on a failed `merge --abort`; the eventlog torn-tail heal won't drop committed lines; the report writer error-lists as json. **Docs reconciled to the code:** restored the AGENTS.md backend set (a find/replace had corrupted it to "Codex, Codex"), corrected Go 1.25 + dropped the never-adopted `sqlc` in PREREQUISITES, purged dead-symbol references (`impact.Localize`/SBFL, `policy.Gate`/`EgressWith`, `internal/blackboard`, `emit.NopEmitter`, `objective.MarkRun`), corrected the phantom `NILCORE_BROWSER_AGENT`/`NILCORE_DESKTOP_DARWIN` gates to the shipped gating, documented the previously-undocumented env vars in `docs/REFERENCE.md`, refreshed the stale REFERENCE.md metrics/CLI/package inventory, and stated the true Phase-15 status (web search shipped; the provider-compat eval P15-T13 was never built). _Owns:_ repo-wide — `internal/*`, `cmd/nilcore/*`, `AGENTS.md`, `CHANGELOG.md`, `README.md`, `STATE.md`, `docs/*`. _(features review)_ diff --git a/README.md b/README.md index ca911ed..48fd641 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ NilCore borrows intelligence instead of re‑encoding it — so the whole agent [![CI](https://github.com/RNT56/NilCore/actions/workflows/ci.yml/badge.svg)](https://github.com/RNT56/NilCore/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/RNT56/NilCore?label=release&color=6f42c1)](https://github.com/RNT56/NilCore/releases/latest) -[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](go.mod) +[![Go](https://img.shields.io/badge/Go-1.25.12-00ADD8?logo=go&logoColor=white)](go.mod) [![Dependencies](https://img.shields.io/badge/dependencies-SQLite%20%2B%20x%2Fsys-2ea44f)](go.mod) [![Agent size](https://img.shields.io/badge/agent-~90k%20LOC-1f6feb)](#the-receipts) [![Sandboxed](https://img.shields.io/badge/model%20execution-sandboxed-2ea44f)](#the-seven-invariants-non-negotiable) @@ -136,7 +136,7 @@ A `NILCORE.md` / `AGENTS.md` steering file loads as trusted project instructions ## Quickstart -**Requires** Go 1.25+. On Linux with a Landlock‑capable kernel (5.13+) and unprivileged user namespaces, NilCore sandboxes the loop with **no container runtime at all** — the auto‑detected host‑native namespace backend. Otherwise (or with `-sandbox container`) it uses a container runtime (`podman` rootless preferred, or `docker`). +**Requires** Go 1.25.12+. On Linux with a Landlock‑capable kernel (5.13+) and unprivileged user namespaces, NilCore sandboxes the loop with **no container runtime at all** — the auto‑detected host‑native namespace backend. Otherwise (or with `-sandbox container`) it uses a container runtime (`podman` rootless preferred, or `docker`). ```sh # Install (or grab a binary from Releases) diff --git a/STATE.md b/STATE.md index 176f173..718aa0c 100644 --- a/STATE.md +++ b/STATE.md @@ -1,9 +1,9 @@ # STATE.md — NilCore state snapshot -> **Point-in-time snapshot, 2026-07-10** (HEAD `573a4df`, on branch -> `claude/nilcore-features-review-931610`; **unmerged** — the features-completeness -> review + remediation pass). This is a derived report, not a contract. The sources of -> truth remain `CLAUDE.md` (constitution), `docs/ARCHITECTURE.md` (technical law + +> **Point-in-time snapshot, 2026-07-12** (`origin/main` after the merged +> features-completeness review #101 and adversarial remediation #100). This is a +> derived report, not a contract. The sources of truth remain the mirrored +> `AGENTS.md` / `CLAUDE.md` constitutions, `docs/ARCHITECTURE.md` (technical law + > invariants), and `CHANGELOG.md` (the ledger). Where this file and those disagree, > **they win** — regenerate this one. @@ -31,7 +31,7 @@ the audit trail. | Go packages | **120** total (`go list ./...`); **111** under `internal/` | | Direct module deps | **3 sanctioned families** (5 direct requires) — `modernc.org/sqlite`, `golang.org/x/sys`, Charm TUI (`bubbletea`/`bubbles`/`lipgloss`, behind `//go:build tui`) | | `CGO_ENABLED` | **0** (pure-Go; cross-compiles cleanly) | -| Gate | `make verify` (build + vet + lint + test) — green. (`-race` + the `tui` build are separate lanes, not folded in.) | +| Gate | `make verify` (build + vet + lint + test) — green. Race, TUI, sandbox, browser E2E, `govulncheck`, and installer-integrity proofs are separate CI lanes. | --- diff --git a/docs/PREREQUISITES.md b/docs/PREREQUISITES.md index 014c4e6..768ef93 100644 --- a/docs/PREREQUISITES.md +++ b/docs/PREREQUISITES.md @@ -6,7 +6,7 @@ Everything you need to build, run, and contribute to NilCore. Source of truth fo | Tool | Version | Why | |---|---|---| -| Go | 1.25+ (matches the `go` directive in `go.mod`) | the entire core | +| Go | 1.25.12+ (matches the patched `go` directive in `go.mod`) | the entire core; older 1.25.x releases contain reachable standard-library vulnerabilities | | Container runtime | **Podman ≥ 4 (rootless, preferred)** or Docker | the sandbox | | git | ≥ 2.30 | worktree-per-task workflow | | make | any | `make verify` is the gate | diff --git a/go.mod b/go.mod index f883c68..cc2c79d 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module nilcore -go 1.25.0 +go 1.25.12 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.44.0 modernc.org/sqlite v1.52.0 ) diff --git a/go.sum b/go.sum index 14f36f6..0412986 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= diff --git a/scripts/install.sh b/scripts/install.sh index df02410..02be63b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,12 +4,14 @@ # # curl -fsSL https://raw.githubusercontent.com/RNT56/NilCore/main/scripts/install.sh | sh # -# Override with: NILCORE_VERSION=v0.2.0 NILCORE_BINDIR=$HOME/.local/bin sh install.sh +# Override with: NILCORE_VERSION=v1.2.0 NILCORE_BINDIR=$HOME/.local/bin sh install.sh +# Mirrors/tests may set NILCORE_RELEASE_BASE to a trusted directory or HTTPS base URL. set -eu REPO="RNT56/NilCore" BINDIR="${NILCORE_BINDIR:-/usr/local/bin}" VERSION="${NILCORE_VERSION:-latest}" +RELEASE_BASE="${NILCORE_RELEASE_BASE:-}" os="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$os" in @@ -26,7 +28,9 @@ case "$arch" in esac asset="nilcore-${os}-${arch}" -if [ "$VERSION" = "latest" ]; then +if [ -n "$RELEASE_BASE" ]; then + base="${RELEASE_BASE%/}" +elif [ "$VERSION" = "latest" ]; then base="https://github.com/${REPO}/releases/latest/download" else base="https://github.com/${REPO}/releases/download/${VERSION}" @@ -52,6 +56,13 @@ curl -fsSL "$url" -o "$tmp" # FAIL CLOSED rather than install unverified. if curl -fsSL "$sumurl" -o "$sumtmp"; then expected="$(awk '{print $1}' "$sumtmp" | head -n1)" + if [ "${#expected}" -ne 64 ]; then + echo "error: invalid SHA-256 record for ${asset}" >&2 + exit 1 + fi + case "$expected" in + *[!0-9a-fA-F]*) echo "error: invalid SHA-256 record for ${asset}" >&2; exit 1 ;; + esac if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$tmp" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then diff --git a/scripts/verify-release-assets.sh b/scripts/verify-release-assets.sh new file mode 100755 index 0000000..f3e3856 --- /dev/null +++ b/scripts/verify-release-assets.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Fail-closed release-set verifier shared by the release workflow and its local E2E. +# A publishable NilCore release is exactly four non-empty platform binaries, one valid +# per-binary SHA-256 record each, and a freshly-derived four-line SHA256SUMS manifest. +set -eu + +dist="${1:-dist}" +assets="nilcore-darwin-amd64 nilcore-darwin-arm64 nilcore-linux-amd64 nilcore-linux-arm64" + +if command -v sha256sum >/dev/null 2>&1; then + check_record() { sha256sum -c "$1"; } + write_manifest() { sha256sum $assets > SHA256SUMS; } +elif command -v shasum >/dev/null 2>&1; then + check_record() { shasum -a 256 -c "$1"; } + write_manifest() { shasum -a 256 $assets > SHA256SUMS; } +else + echo "error: no SHA-256 tool (sha256sum/shasum)" >&2 + exit 1 +fi + +for asset in $assets; do + test -s "$dist/$asset" || { echo "error: missing release asset $asset" >&2; exit 1; } + test -s "$dist/$asset.sha256" || { echo "error: missing checksum $asset.sha256" >&2; exit 1; } + ( cd "$dist" && check_record "$asset.sha256" ) +done + +( cd "$dist" && write_manifest ) +test "$(wc -l < "$dist/SHA256SUMS" | tr -d ' ')" = "4" || { + echo "error: SHA256SUMS must contain exactly four records" >&2 + exit 1 +} + +echo "--- SHA256SUMS ---" +cat "$dist/SHA256SUMS" diff --git a/test/install-e2e.sh b/test/install-e2e.sh new file mode 100755 index 0000000..a01a0f2 --- /dev/null +++ b/test/install-e2e.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# End-to-end release installer proof. It builds the host asset, publishes it into a +# temporary file:// release root, and drives scripts/install.sh exactly as a user does. +# The negative controls are load-bearing: no checksum and a tampered binary must both +# fail before anything is installed. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +case "$(uname -s | tr '[:upper:]' '[:lower:]')" in + darwin) os=darwin ;; + linux) os=linux ;; + *) echo "unsupported test OS" >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "unsupported test architecture" >&2; exit 1 ;; +esac + +asset="nilcore-${os}-${arch}" +release="$tmp/release" +mkdir -p "$release" "$tmp/bin-good" "$tmp/bin-missing" "$tmp/bin-tampered" + +( + cd "$repo_root" + CGO_ENABLED=0 go build -trimpath -o "$release/$asset" ./cmd/nilcore +) +cp "$release/$asset" "$tmp/pristine" + +write_checksum() { + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$release" && sha256sum "$asset" > "$asset.sha256" ) + else + ( cd "$release" && shasum -a 256 "$asset" > "$asset.sha256" ) + fi +} + +run_installer() { + bindir="$1" + NILCORE_RELEASE_BASE="file://$release" \ + NILCORE_BINDIR="$bindir" \ + sh "$repo_root/scripts/install.sh" +} + +# Positive proof: a matching binary + checksum installs and runs. +write_checksum +run_installer "$tmp/bin-good" +test -x "$tmp/bin-good/nilcore" +"$tmp/bin-good/nilcore" version >/dev/null + +# Negative proof 1: the installer must fail closed when the checksum is missing. +rm "$release/$asset.sha256" +if run_installer "$tmp/bin-missing" >"$tmp/missing.out" 2>&1; then + echo "installer accepted an asset with no checksum" >&2 + exit 1 +fi +test ! -e "$tmp/bin-missing/nilcore" +grep -q "refusing to install unverified binary" "$tmp/missing.out" + +# Negative proof 2: a checksum for the pristine asset cannot authorize tampered bytes. +cp "$tmp/pristine" "$release/$asset" +write_checksum +printf 'tampered' >> "$release/$asset" +if run_installer "$tmp/bin-tampered" >"$tmp/tampered.out" 2>&1; then + echo "installer accepted a tampered asset" >&2 + exit 1 +fi +test ! -e "$tmp/bin-tampered/nilcore" +grep -q "checksum mismatch" "$tmp/tampered.out" + +echo "installer E2E: valid asset installed; missing checksum and tampering refused" diff --git a/test/release-assets-e2e.sh b/test/release-assets-e2e.sh new file mode 100755 index 0000000..8b54f54 --- /dev/null +++ b/test/release-assets-e2e.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Cross-build the exact release matrix and drive the same fail-closed asset verifier +# used by .github/workflows/release.yml. A missing checksum is the negative control. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +dist="$tmp/dist" +mkdir -p "$dist" + +for os in darwin linux; do + for arch in amd64 arm64; do + asset="nilcore-${os}-${arch}" + ( + cd "$repo_root" + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "$dist/$asset" ./cmd/nilcore + ) + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$dist" && sha256sum "$asset" > "$asset.sha256" ) + else + ( cd "$dist" && shasum -a 256 "$asset" > "$asset.sha256" ) + fi + done +done + +"$repo_root/scripts/verify-release-assets.sh" "$dist" +test "$(wc -l < "$dist/SHA256SUMS" | tr -d ' ')" = "4" + +rm "$dist/nilcore-linux-arm64.sha256" +if "$repo_root/scripts/verify-release-assets.sh" "$dist" >"$tmp/missing.out" 2>&1; then + echo "release verifier accepted a missing per-asset checksum" >&2 + exit 1 +fi +grep -q "missing checksum nilcore-linux-arm64.sha256" "$tmp/missing.out" + +echo "release assets E2E: four targets verified; partial release refused"