Skip to content

fix: the checksum check proves the archive it downloaded was checked - #50

Merged
REPPL merged 9 commits into
mainfrom
fix/update-verify-scope
Sep 12, 2026
Merged

REPPL merged 9 commits into
mainfrom
fix/update-verify-scope

Conversation

@REPPL

@REPPL REPPL commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

One page that says what is on (itd-2609081718534201, spc-2609081750377336 closed → intent shipped).

What this adds

  • A Posture tab in the control panel: one page that states, in the present tense and as fact rather than warning, who can reach this server and on which addresses; whether a request from another machine has to carry the API key and, on its own line because the answer differs, that a request from this Mac — another account included — is served without it; whether Gropius is announcing the server over Bonjour and what the announcement carries; what the request log writes and at which level; and what request statistics record, where, and for how long. Where an address is on a private network the page says which, and says what the mark cannot see. The exposed-server warning is untouched.
  • Every line is a pure function over the state snapshot, evaluated under node by the tests, and names the snapshot fields it read; a reflection test binds those names to the Go types.
  • The snapshot's bind state now publishes what the running bind is (mode in force, bound address, wildcard, reach, port) and the Bonjour decision made at start, from the plan and the configuration the process started under. The page reads those and never the stored settings, which a save moves before a restart applies them; a test forbids the page from reading any startup-only setting. The advert rule has one spelling, app.Advertises, read by cmd/gropius at start and by the control plane.
  • docs/posture-reference.md, pointers from the mesh-VPN, bind and statistics pages, the README feature line and the changelog entry.
  • Captured: iss-2609091751184914 (a live advertise/port save changes the stored configuration while the advert and listeners run on; no restart notice for advertise, no panel control).

Reviews

Ruthless review and adversarial security review, each run twice; every finding either fixed or captured. Second-pass verdicts: APPROVE.

Gates

make test (race), gofmt -l ., go vet ./..., abcd docs lint green; branch scanned clean with gitleaks.

https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo

An adversarial review found the one input that exited 0 with the download
unverified, and it is the worst possible one: `shasum -c --ignore-missing`
answers for the files the checksums file NAMES. Absent names are skipped;
names that are present and irrelevant are verified and reported as a pass. A
checksums file naming any readable file with known content — /dev/null will
do — therefore passed, and the unverified archive went on to be unpacked,
executed to read its version, and installed. Reproduced against this Mac's
own /usr/bin/shasum.

Nothing in the invocation asserted that the artefact it protects was in
scope. The file is now narrowed to the archive's own line before shasum sees
it, and --ignore-missing goes with the narrowing: one name, the file just
downloaded, so a pass is a statement about that file. A line naming a PATH
is refused rather than matched, because "/somewhere/Gropius.app.zip" would
verify something never downloaded and make shasum print a success line that
contains the archive's own — so the pass is read line by line, not as a
substring.

Narrowing the input is not making the verdict. The digest is still computed
and compared by shasum, which stays the only thing in this product that
decides whether bytes match.

Red first: both new rows passed verification before the change. One existing
row moved cause — a checksums file naming only the client bundle is now
reported as not covering the archive, which is the thing that matters about
it.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
(cherry picked from commit bf01e58224c45dd293348d83524a6e7e558d04be)
The serving version was rendered straight from whatever holds the loopback
port. The challenge that lets a responder in proves a shared DATA ROOT, not
an identity — deliberately, at mode 0640, so a peer account can answer it
under the shared-cache mode this product documents — so that string is a
string a peer account chose. A newline in it forged report lines: the review
predicted it and the test reproduced it, printing "The firewall grant was
re-made for this build." as the report's own second line. Escape sequences
and carriage returns reached the terminal too.

It is checked where it becomes a fact rather than at the seam that read it,
so every path into the report goes through one switch. What this cannot
check is whether a plausible version is a true one: the report's provenance
for that line is the control plane, and a report that asks can be told
something false. The comment says so rather than implying more.

Also: stagedVersion runs the downloaded binary inside the directory that was
verified (the rule the step is written under, and cmd.Dir was never set),
with an empty environment and a capped read — the timeout bounded how long a
hostile build could take and said nothing about how much it could send. The
control-plane read refuses redirects, because loopback is a property of the
request and a redirect is how a request stops being the one that was made.
The one quit warning that skipped redact now applies it, by the rule this
repository adopted after the last leak.

And one claim is corrected rather than hardened, which is what the record
said to expect. "No environment variable can point the download" was false:
-q suppresses .curlrc only, and curl still honours https_proxy and
CURL_CA_BUNDLE — both measured. The claim is now about what Gropius reads,
with the limit written down. Neutralising them was rejected: the bootstrap
honours the same variables, so the verb would fail where the documented
install works, and it buys nothing against another account on this Mac,
which cannot set this account's environment.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
(cherry picked from commit 4b5b00c0b0d527d98e78e63fc8f5880ef51ec7c9)
Four decisions: the scoped verification and why narrowing the input is not
making the verdict; validating the control plane's version where it becomes
a fact, and what validation cannot establish; the curl-environment claim
corrected rather than hardened, with the reason neutralising was rejected;
and the branch deletion and recovery.

iss-2609120417422598 carries the finding that is NOT this branch's to fix:
install.sh runs the same shasum invocation with the same missing scope
assertion, and it is the command the README tells people to pipe into bash.
The issue carries the measured reproduction and the three moves the Go verb
took, and says plainly that a verb with "update" in its name has not closed
it.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
(cherry picked from commit 4cfba4a786740298fb4b0faa62e28bb231bb0dcf)
install.sh verifies the download with `shasum -c --ignore-missing`, which
answers for the files the checksums file NAMES: absent names are skipped, and
names that are present and irrelevant are verified and reported as a pass.
Nothing asserts that the archive it protects was in scope
(iss-2609120417422598).

Five rows against the real script, a real /usr/bin/shasum and a real archive.
Red before the fix, and red in the two ways that matter: a checksums file
naming /dev/null with its own digest — the capture's own reproduction — exits 0
and goes on to unpack the archive it never looked at, and so does a line naming
the archive by an absolute PATH, which verifies a file that was never
downloaded and prints a success line CONTAINING the one the script looks for. A
checksums file naming only the other bundle already failed, but as "corrupt or
tampered", which sends a user to look at their network for a file that is
intact and answers about something else.

The two rows that must not move are here too: the honest bare-name line still
passes through to the unpacking, and a wrong digest still fails as a mismatch.

The pass is read as a LINE. The script's own CI-seam warning quotes the words
"Checksum OK." in prose, so a substring match reported every refused run as a
pass — which is the same shape of mistake as the defect under test.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
`shasum -c --ignore-missing SHA256SUMS.txt` answers for the files the checksums
file NAMES. Absent names are skipped; names that are present and irrelevant are
verified and reported as a pass. So a checksums file naming any other readable
file with a correct digest — /dev/null did it — exited 0 with the download
never looked at, and install.sh went on to print "Checksum OK.", unpack the
archive, clear its quarantine and hand the binary inside it the install, as
root, on the command the README tells people to pipe into bash
(iss-2609120417422598).

The same three moves the update verb took (d7e31ab, internal/lifecycle/
updatefetch.go), for the one asset each invocation downloads:

  - the checksums file is narrowed to the archive's OWN line before shasum sees
    it — an exact bare name, a 64-character hex digest, either separator;
  - --ignore-missing goes with the narrowing, because there is then exactly one
    name and it is the file just downloaded;
  - a line naming a PATH is refused rather than matched, and the pass is read as
    a LINE, because "/somewhere/Gropius.app.zip: OK" ends in the same characters
    as the line this script looks for.

Narrowing the input is not making the verdict: the digest is still computed and
compared by /usr/bin/shasum, which stays the only thing here that decides
whether bytes match. An empty file, a page that is not a checksums file, and a
file that answers about a different asset now fail with a message that says the
checksums carry no line for this archive, instead of "corrupt or tampered".

No new tool to name by absolute path: the scan is bash's own `read`, fed by a
redirect from the file, so the standard input `curl … | bash` feeds the script
from is untouched.

TestTheInstallerVerifiesBeforeItHandsOver anchored on the flag that is gone; it
now anchors on the invocation and refuses --ignore-missing coming back in any
line that is not a comment.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
iss-2609120417422598 resolved against 9ecd1ad, with the decision line the
bootstrap's half of the fix was missing: the same three moves as the Go verb,
the scan written with bash's own `read` rather than a new PATH-resolved tool,
and a refusal of its own for a checksums file that is intact and answers about
something else.

Assisted-by: Claude:claude-opus-5
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
The changelog carries a Security entry for each of the two user-facing
changes on this branch: the bootstrap and the update verb both proved
only that shasum had checked something, not that it had checked the
archive; and the update report printed the serving version as the control
plane handed it back. Written under [Unreleased] on the same terms as the
0.5.0 entries.

Assisted-by: Claude:claude-fable-5-1
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
…ect refusal are held

The security review of this branch found three hardening moves with no
test that would go red without them: the staged build's version query
runs with an empty environment and inside the verified directory, its
answer is read only up to a cap, and the control-plane read stops at a
redirect. Each now has a row that was watched to fail against the line
removed: a build that echoes a variable from this process's environment,
one that reports its working directory, one that pads its answer past
the cap, and a server that answers 302 towards a second server that
counts whether it was asked.

The quit warning's comment claimed the redaction rule was applied
everywhere; the install verb's warnings still print error text as it
comes. The comment now says what this line does, and the install
verb's five lines are captured as iss-2609120438396694.

Assisted-by: Claude:claude-fable-5-1
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
@REPPL
REPPL enabled auto-merge September 12, 2026 04:39
…of its listing

In the merge queue, the tripwire that asserts install.sh changed nothing
in the real /Applications reported a .doctor-*.tmp file that was there
for one snapshot and not the other. That is the lifecycle package's
writability probe: the test that proves the live-environment guard can
be opened builds the live install environment, which asks whether this
account can write /Applications by creating and removing that file, and
go test runs the two packages side by side. It is not something the
bootstrap writes, and the bundle fingerprints beside the listing are
what catch an install, so the one name is left out of the comparison —
exactly that name, held by a test that was watched to fail with the
exemption widened to nothing.

The race itself is recorded as iss-2609120444017291: a unit test still
creates a file in the machine's /Applications.

Assisted-by: Claude:claude-fable-5-1
Claude-Session: https://claude.ai/code/session_01RooZgUESQnVnievtn48pHo
@REPPL
REPPL added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 525d874 Sep 12, 2026
6 checks passed
@REPPL
REPPL deleted the fix/update-verify-scope branch September 12, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant