fix: verify the build against its own receipt, with the expected mode as an argument (closes #309) #330

Merged
clawbot merged 1 commits from issue-309-build-integrity into next 2026-08-20 14:24:56 +02:00
Collaborator

Closes #309.

Part 1 — the ambient-environment defect

script/verify-build read AUTISTMASK_DEBUG out of its own environment and the Makefile invoked it bare, so the verifier and the compiler agreed with each other about a variable neither of them was told. The expected mode is now the required argument --expect release|debug. There is no default and nothing is read from the environment: a caller that does not say what it built gets a failure, because "no opinion" is not something this can check anything against.

  • make build runs env -u AUTISTMASK_DEBUG script/verify-build --expect release --receipt "$receipt"; make build-debug passes --expect debug.
  • The flag is deliberately not scrubbed from the build itself. With AUTISTMASK_DEBUG=1 exported, make build compiles a debug bundle and then fails on it, which is the loud outcome; scrubbing it there would silently give the operator something other than what their shell said. README.md's "make build always produces a release build" is now true in the only sense that matters: that target never hands back a debug one.

Reproduced before and after on this branch, in my own clone:

### before:  AUTISTMASK_DEBUG=1 make build
Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)
verify-build: 4 bundle(s) verified autistmask-build-debug=on
### exit: 0

### after:   AUTISTMASK_DEBUG=1 make build
verify-build: FAIL: dist/chrome/src/background/index.js is
    autistmask-build-debug=on but this build was told to expect
    autistmask-build-debug=off. ...
make: *** [Makefile:65: build] Error 1
### exit: 2

Part 2 — provenance, and exactly what it proves

build.js records every file it emits, as it emits it, and writes a build receipt: one line per file with its sha256 and whether it is one of the bundles containing src/shared/constants.js. The Makefile creates that receipt path with mktemp per invocation, outside the repo, and removes it in a trap; build.js refuses a receipt path inside dist/. dist/ is cleared at the start of a build, so it holds only what that build wrote. dist/constants-bundles.txt is gone.

verify-build --receipt PATH then runs three passes, in this order:

  1. the receipt has to be a receipt — header, a root line naming this tree, and every entry parseable, with at least one audited bundle;
  2. dist/ is walked in full (NUL-delimited, find's status checked) and must contain nothing the receipt does not name, and no symlinks — the build emits none;
  3. each named file must exist as a regular file with exactly the recorded bytes, each audited bundle must carry the --expected marker, and no other file may carry one.

The ordering is load-bearing: an unwalkable subtree makes every file under it look absent, and "could not look" must never be reported as "was not there".

What this does and does not prove. It proves that dist/ is byte for byte the output of the build.js run that just finished, with nothing added, removed or altered in between, and that the audited bundles in it compiled to the mode the caller asked for. That is a within-one-make build-invocation guarantee, and it holds because the receipt is a fresh file the build wrote to a path chosen at run time by the process that then verifies against it — not a standing file in dist/ that whoever rewrites dist/ also rewrites.

It proves nothing about whether the source tree or build.js were honest — build.js still cannot vouch for build.js, which is why the receipt deliberately records no build mode and the expected mode stays an argument. It proves nothing to anyone handed a dist/ from elsewhere: without the receipt from its own build there is no input to the check at all. Verifiable provenance for a third party is signing, which is #310's territory and an outward-facing decision, so this makes no claim in that direction and README.md says so in the same words.

One consequence worth flagging as a judgement call rather than burying: the standalone make verify-build target is removed. Re-verifying an existing dist/ from a list inside that same dist/ is precisely the defect; there is no honest standalone re-check available short of signing, so the target goes rather than continuing to print a green line that means nothing.

Test coverage, and the mutations that prove it is not vacuous

script/test-verify-build was extended in place, 18 cases to 39. Every previously demonstrated bypass has a case, and all four now fail:

bypass result
26-byte file containing only autistmask-build-debug=off digest mismatch
tampered dist/chrome/src/content/index.js (runs on <all_urls>) digest mismatch
tampered dist/chrome/manifest.json digest mismatch
entire hand-written dist/ offered against this build's receipt digest mismatch

Added beyond those: extra file under dist/ carrying no marker at all (previously ignored outright), missing --expect, missing --receipt, invalid --expect, unknown argument, receipt inside dist/, receipt with a foreign root, receipt naming a path outside dist/ or one containing a space, a marker on a file the build did not record as a bundle, AUTISTMASK_DEBUG=1 exported while --expect release is given (must be ignored, both when it would pass and when it would fail), and four make -n read-backs asserting the recipes pass the mode as an argument on a scrubbed environment. The existing failure modes are kept: grep exit-2, find's status, newline and trailing-space paths, symlinked dist/, and the probe that refuses to count permission cases as coverage when the process is not subject to permissions.

Non-vacuity, each mutation applied to the implementation and reverted:

  • disable the digest comparison in check_entry → exactly the 4 bypass cases fail, nothing else;
  • remove check_dist_tree from main → the 4 extra-file and 4 symlink cases fail;
  • restore the ambient AUTISTMASK_DEBUG fallback for a missing --expect → the no---expect case fails;
  • point the make build recipe at --expect debug without env -u → both make build wiring cases fail;
  • drop manifest.json from the recorded emissions in build.js → a real make build fails with dist/chrome/manifest.json is under dist/ but the build that just ran did not emit it.

Verification

  • make check: green — 39 suites / 811 tests, test-verify-build: 39 case(s) passed, permission cases enabled (runner: direct).
  • script/cibuild: green, and re-run as docker build --no-cache-filter=check . so the check stage actually executed rather than reporting CACHED. Inside the pinned image the harness runs as root and reports permission cases: enabled (runner: setpriv, proved against a mode-000 file) — not skipped — and make build completes there: verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off. Both images built here were removed afterwards; no containers were left behind and no cache was pruned.
  • make build, make build-debug and AUTISTMASK_DEBUG=1 make build each run by hand on this branch, with the results above.
Closes [#309](https://git.eeqj.de/sneak/AutistMask/issues/309). ## Part 1 — the ambient-environment defect `script/verify-build` read `AUTISTMASK_DEBUG` out of its own environment and the `Makefile` invoked it bare, so the verifier and the compiler agreed with each other about a variable neither of them was told. The expected mode is now the required argument `--expect release|debug`. There is no default and nothing is read from the environment: a caller that does not say what it built gets a failure, because "no opinion" is not something this can check anything against. - `make build` runs `env -u AUTISTMASK_DEBUG script/verify-build --expect release --receipt "$receipt"`; `make build-debug` passes `--expect debug`. - The flag is deliberately **not** scrubbed from the build itself. With `AUTISTMASK_DEBUG=1` exported, `make build` compiles a debug bundle and then fails on it, which is the loud outcome; scrubbing it there would silently give the operator something other than what their shell said. `README.md`'s "`make build` always produces a release build" is now true in the only sense that matters: that target never hands back a debug one. Reproduced before and after on this branch, in my own clone: ``` ### before: AUTISTMASK_DEBUG=1 make build Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship) verify-build: 4 bundle(s) verified autistmask-build-debug=on ### exit: 0 ### after: AUTISTMASK_DEBUG=1 make build verify-build: FAIL: dist/chrome/src/background/index.js is autistmask-build-debug=on but this build was told to expect autistmask-build-debug=off. ... make: *** [Makefile:65: build] Error 1 ### exit: 2 ``` ## Part 2 — provenance, and exactly what it proves `build.js` records every file it emits, as it emits it, and writes a **build receipt**: one line per file with its sha256 and whether it is one of the bundles containing `src/shared/constants.js`. The `Makefile` creates that receipt path with `mktemp` per invocation, outside the repo, and removes it in a trap; `build.js` refuses a receipt path inside `dist/`. `dist/` is cleared at the start of a build, so it holds only what that build wrote. `dist/constants-bundles.txt` is gone. `verify-build --receipt PATH` then runs three passes, in this order: 1. the receipt has to be a receipt — header, a `root` line naming this tree, and every entry parseable, with at least one audited bundle; 2. `dist/` is walked in full (NUL-delimited, `find`'s status checked) and must contain nothing the receipt does not name, and no symlinks — the build emits none; 3. each named file must exist as a regular file with exactly the recorded bytes, each audited bundle must carry the `--expect`ed marker, and no other file may carry one. The ordering is load-bearing: an unwalkable subtree makes every file under it look absent, and "could not look" must never be reported as "was not there". **What this does and does not prove.** It proves that `dist/` is byte for byte the output of the `build.js` run that just finished, with nothing added, removed or altered in between, and that the audited bundles in it compiled to the mode the caller asked for. That is a within-one-`make build`-invocation guarantee, and it holds because the receipt is a fresh file the build wrote to a path chosen at run time by the process that then verifies against it — not a standing file in `dist/` that whoever rewrites `dist/` also rewrites. It proves **nothing** about whether the source tree or `build.js` were honest — `build.js` still cannot vouch for `build.js`, which is why the receipt deliberately records no build mode and the expected mode stays an argument. It proves **nothing** to anyone handed a `dist/` from elsewhere: without the receipt from its own build there is no input to the check at all. Verifiable provenance for a third party is signing, which is [#310](https://git.eeqj.de/sneak/AutistMask/issues/310)'s territory and an outward-facing decision, so this makes no claim in that direction and README.md says so in the same words. One consequence worth flagging as a judgement call rather than burying: **the standalone `make verify-build` target is removed.** Re-verifying an existing `dist/` from a list inside that same `dist/` is precisely the defect; there is no honest standalone re-check available short of signing, so the target goes rather than continuing to print a green line that means nothing. ## Test coverage, and the mutations that prove it is not vacuous `script/test-verify-build` was extended in place, 18 cases to 39. Every previously demonstrated bypass has a case, and all four now fail: | bypass | result | | --- | --- | | 26-byte file containing only `autistmask-build-debug=off` | digest mismatch | | tampered `dist/chrome/src/content/index.js` (runs on `<all_urls>`) | digest mismatch | | tampered `dist/chrome/manifest.json` | digest mismatch | | entire hand-written `dist/` offered against this build's receipt | digest mismatch | Added beyond those: extra file under `dist/` carrying no marker at all (previously ignored outright), missing `--expect`, missing `--receipt`, invalid `--expect`, unknown argument, receipt inside `dist/`, receipt with a foreign `root`, receipt naming a path outside `dist/` or one containing a space, a marker on a file the build did not record as a bundle, `AUTISTMASK_DEBUG=1` exported while `--expect release` is given (must be ignored, both when it would pass and when it would fail), and four `make -n` read-backs asserting the recipes pass the mode as an argument on a scrubbed environment. The existing failure modes are kept: grep exit-2, `find`'s status, newline and trailing-space paths, symlinked `dist/`, and the probe that refuses to count permission cases as coverage when the process is not subject to permissions. Non-vacuity, each mutation applied to the implementation and reverted: - disable the digest comparison in `check_entry` → exactly the 4 bypass cases fail, nothing else; - remove `check_dist_tree` from `main` → the 4 extra-file and 4 symlink cases fail; - restore the ambient `AUTISTMASK_DEBUG` fallback for a missing `--expect` → the no-`--expect` case fails; - point the `make build` recipe at `--expect debug` without `env -u` → both `make build` wiring cases fail; - drop `manifest.json` from the recorded emissions in `build.js` → a real `make build` fails with `dist/chrome/manifest.json is under dist/ but the build that just ran did not emit it`. ## Verification - `make check`: green — 39 suites / 811 tests, `test-verify-build: 39 case(s) passed`, permission cases enabled (runner: direct). - `script/cibuild`: green, and re-run as `docker build --no-cache-filter=check .` so the check stage actually executed rather than reporting `CACHED`. Inside the pinned image the harness runs as root and reports `permission cases: enabled (runner: setpriv, proved against a mode-000 file)` — not skipped — and `make build` completes there: `verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off`. Both images built here were removed afterwards; no containers were left behind and no cache was pruned. - `make build`, `make build-debug` and `AUTISTMASK_DEBUG=1 make build` each run by hand on this branch, with the results above.
clawbot added 1 commit 2026-08-20 14:12:04 +02:00
fix: verify the build against its own receipt, with the expected mode as an argument (closes #309)
All checks were successful
check / check (push) Successful in 47s
e2e / e2e-chrome (push) Successful in 1m26s
e2e / e2e-firefox (push) Successful in 44s
9f3cc05985
script/verify-build computed its expectation from AUTISTMASK_DEBUG in its own
environment, and the Makefile invoked it bare, so an operator with that flag
exported who ran the release target got a debug bundle -- every wallet it
creates carrying the publicly committed test recovery phrase -- verified green
at exit 0. The mode is now the required argument --expect release|debug, with
no default and nothing read from the environment; make build passes
--expect release on an env -u AUTISTMASK_DEBUG environment and make build-debug
passes --expect debug. The flag is deliberately still allowed to reach the
compiler, so a shell that has it exported fails make build loudly rather than
quietly receiving something other than the release build it asked for.

The other half was provenance. The check was a marker grep over a file list
read back out of dist/, so a 26-byte file containing only
autistmask-build-debug=off verified ok, manifest.json and the content script
that runs on every page were never read at all, and an entire hand-written
dist/ passed as "1 bundle(s) verified".

build.js now records every file it emits and writes a receipt of them -- path,
sha256, and whether the file is one of the bundles containing constants.js --
to a path the Makefile creates with mktemp per invocation, outside the repo,
and deletes afterwards; a receipt path inside dist/ is refused. dist/ is
cleared before a build, so it holds only what that build wrote.
dist/constants-bundles.txt is gone, and with it the standalone make verify-build
target: re-verifying a dist/ out of the dist/ itself is the thing that was
broken.

verify-build now checks the receipt's shape, then that dist/ contains nothing
the build did not emit and no symlinks, then each recorded file's bytes against
its digest and each audited bundle's marker against --expect. The guarantee is
narrow and README.md states it as such: dist/ is byte for byte the output of
the build.js run that just finished. It proves nothing about the honesty of the
source tree or of build.js, and offers nothing to a third party holding a
dist/. That is signing:
#310

script/test-verify-build goes from 18 cases to 39, extended in place: one per
demonstrated bypass, the missing/invalid argument cases, an AUTISTMASK_DEBUG=1
environment that the verifier must ignore, debug bundles that must fail
--expect release, and four checks that read the make build and make build-debug
recipes back out of make -n. The existing failure modes (grep exit-2, find's
status, newline and trailing-space paths, symlinked dist/, and the root probe
that refuses to count permission cases vacuously) are kept.

Verified: make check green (39 suites / 811 tests, 39 verify-build cases,
permission cases enabled), and green again inside the pinned image via
script/cibuild with --no-cache-filter=check, where the harness runs as root and
reports the setpriv runner rather than skipping. Non-vacuity proved by
mutation: disabling the digest comparison fails exactly the four bypass cases,
removing the dist/ walk fails the eight extra-file and symlink cases, restoring
the ambient AUTISTMASK_DEBUG fallback fails the no---expect case, breaking the
Makefile recipe fails the wiring cases, and dropping manifest.json from the
recorded emissions fails a real make build.
clawbot added the needs-review label 2026-08-20 14:12:12 +02:00
clawbot self-assigned this 2026-08-20 14:12:13 +02:00
Author
Collaborator

PASS#309's definition of done is met; no defects found.

Evidence the checks ran, not cached:

  • make test-e2e 55/55, exit 0; make test-e2e-firefox 8/8, exit 0. Both Dockerfiles' RUN make build executed uncached (#11 DONE 3.1s / #14 DONE 2.9s) and the mktemp receipt path works in the image build context: verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off. No containers left behind.
  • make check green: 39 suites / 811 tests, test-verify-build: 39 case(s) passed, permission cases: enabled (runner: direct, proved against a mode-000 file). Lint stage #11 [lint 1/1] RUN make lint ... DONE 4.8s (not CACHED), in the pinned container. fmt-check clean. Repo unmodified by the run.
  • All four original bypasses reproduced against a real make build receipt and all four now exit 1: 26-byte marker-only bundle, tampered dist/chrome/src/content/index.js, tampered dist/chrome/manifest.json, wholesale hand-written dist/ — each a digest mismatch. Also killed: extra file, symlink, dist/chrome swapped for a symlink to a hostile tree, receipt omitting a file that exists in dist/, stale-digest receipt with a valid header/root, receipt inside dist/, /dev/null / /dev/stdin / fifo receipts.
  • Ambient defect closed: AUTISTMASK_DEBUG=1 make build exits 2 on is autistmask-build-debug=on but this build was told to expect autistmask-build-debug=off. --expect and --receipt are both required with no default and nothing read from the environment; missing, repeated, invalid and unknown arguments all fail.
  • Receipt lifecycle: cleaned up on success, on verifier failure, and on SIGINT (exit 130, no leftover under TMPDIR); make -C from another directory works; nothing leaks into the repo or dist/.
  • Three-pass ordering verified as load-bearing, not merely asserted: with check_dist_tree removed, the unwalkable-subtree case reports the receipt names dist/chrome/src/content/index.js, which does not exist — "could not look" mis-stated as "was not there", exactly what the ordering prevents.
  • Mutation testing reproduced (applied to the implementation, then reverted): digest comparison disabled → exactly the 4 bypass cases fail, 35 pass; check_dist_tree dropped from main → exactly the 8 extra-file/symlink cases fail; ambient AUTISTMASK_DEBUG fallback restored → exactly the no---expect case fails.
  • CI green on 9f3cc05 (check, e2e-chrome, e2e-firefox). Merges cleanly into next, 1 commit ending (closes #309), TODO.md included, author and committer both clawbot, no attribution trailers, no non-inclusive terms in the diff.

Anomalies, none blocking:

  1. check_dist_tree walks -type f -o -type l only, so a fifo, socket, device node or empty directory added to dist/ after the build passes. The exclusion is deliberate and explained in the script (grep on a fifo would hang), and none of those can carry a shippable payload — but README.md's "nothing under dist/ that the build did not write" is literally broader than what is enforced. Worth a clause if it is ever tightened.
  2. script/verify-build --scan-dist-paths PATH ... is reachable from the command line and exits 0 with no output. It is not reachable through make build and grants nothing to an attacker who does not already control the invocation; noted because a silent exit 0 from this script is a shape worth knowing about.
  3. Judged the claim, not only the code: the bounded statement in README.md and the script header is accurate — within one make build, dist/ is the output of the build.js run that just finished; nothing about the source tree's or build.js's honesty; nothing to a third party holding a dist/, which is #310. Subject only to anomaly 1, it does not overclaim.

Disclosure: the Makefile trap deletes the receipt, so to run the bypasses against a real dist/ I preserved a copy via a temporary rm shim on PATH in my own clone; the repo tree was never modified by it. The three mutations above were applied with an editor and reverted, and the working tree was confirmed pristine (git status and git diff both empty) with test-verify-build: 39 case(s) passed afterwards.

**PASS** — [#309](https://git.eeqj.de/sneak/AutistMask/issues/309)'s definition of done is met; no defects found. Evidence the checks ran, not cached: - `make test-e2e` **55/55**, exit 0; `make test-e2e-firefox` **8/8**, exit 0. Both Dockerfiles' `RUN make build` executed uncached (`#11 DONE 3.1s` / `#14 DONE 2.9s`) and the `mktemp` receipt path works in the image build context: `verify-build: 15 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off`. No containers left behind. - `make check` green: 39 suites / 811 tests, `test-verify-build: 39 case(s) passed`, `permission cases: enabled (runner: direct, proved against a mode-000 file)`. Lint stage `#11 [lint 1/1] RUN make lint ... DONE 4.8s` (not `CACHED`), in the pinned container. `fmt-check` clean. Repo unmodified by the run. - All four original bypasses reproduced against a real `make build` receipt and all four now exit 1: 26-byte marker-only bundle, tampered `dist/chrome/src/content/index.js`, tampered `dist/chrome/manifest.json`, wholesale hand-written `dist/` — each a digest mismatch. Also killed: extra file, symlink, `dist/chrome` swapped for a symlink to a hostile tree, receipt omitting a file that exists in `dist/`, stale-digest receipt with a valid header/root, receipt inside `dist/`, `/dev/null` / `/dev/stdin` / fifo receipts. - Ambient defect closed: `AUTISTMASK_DEBUG=1 make build` exits 2 on `is autistmask-build-debug=on but this build was told to expect autistmask-build-debug=off`. `--expect` and `--receipt` are both required with no default and nothing read from the environment; missing, repeated, invalid and unknown arguments all fail. - Receipt lifecycle: cleaned up on success, on verifier failure, and on `SIGINT` (exit 130, no leftover under `TMPDIR`); `make -C` from another directory works; nothing leaks into the repo or `dist/`. - Three-pass ordering verified as load-bearing, not merely asserted: with `check_dist_tree` removed, the unwalkable-subtree case reports `the receipt names dist/chrome/src/content/index.js, which does not exist` — "could not look" mis-stated as "was not there", exactly what the ordering prevents. - Mutation testing reproduced (applied to the implementation, then reverted): digest comparison disabled → exactly the 4 bypass cases fail, 35 pass; `check_dist_tree` dropped from `main` → exactly the 8 extra-file/symlink cases fail; ambient `AUTISTMASK_DEBUG` fallback restored → exactly the no-`--expect` case fails. - CI green on `9f3cc05` (check, e2e-chrome, e2e-firefox). Merges cleanly into `next`, 1 commit ending ` (closes #309)`, `TODO.md` included, author and committer both `clawbot`, no attribution trailers, no non-inclusive terms in the diff. Anomalies, none blocking: 1. `check_dist_tree` walks `-type f -o -type l` only, so a fifo, socket, device node or empty directory added to `dist/` after the build passes. The exclusion is deliberate and explained in the script (grep on a fifo would hang), and none of those can carry a shippable payload — but `README.md`'s "nothing under `dist/` that the build did not write" is literally broader than what is enforced. Worth a clause if it is ever tightened. 2. `script/verify-build --scan-dist-paths PATH ...` is reachable from the command line and exits 0 with no output. It is not reachable through `make build` and grants nothing to an attacker who does not already control the invocation; noted because a silent exit 0 from this script is a shape worth knowing about. 3. Judged the claim, not only the code: the bounded statement in `README.md` and the script header is accurate — within one `make build`, `dist/` is the output of the `build.js` run that just finished; nothing about the source tree's or `build.js`'s honesty; nothing to a third party holding a `dist/`, which is [#310](https://git.eeqj.de/sneak/AutistMask/issues/310). Subject only to anomaly 1, it does not overclaim. Disclosure: the `Makefile` trap deletes the receipt, so to run the bypasses against a real `dist/` I preserved a copy via a temporary `rm` shim on `PATH` in my own clone; the repo tree was never modified by it. The three mutations above were applied with an editor and reverted, and the working tree was confirmed pristine (`git status` and `git diff` both empty) with `test-verify-build: 39 case(s) passed` afterwards.
clawbot merged commit aea999db85 into next 2026-08-20 14:24:56 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#330