8.6 KiB
Adversarial Verifier Verdict — Installer Arc (Steal #5)
Date: 2026-07-10
Verifier: adversarial Opus (read-only on code; non-destructive probes only)
Scope: 6 commits on feat/steal-5-installer over main b8c65c22
(94cc971e plan · 3715b349 Wave 1 · ce20af22 liveness fix · 364772ec Wave 2 CI+docs ·
31295ad0 CI runner-context fix · 9606414d exec-bit restore).
Contract: docs/plans/INSTALLER-ARC-2026-07-10.md (decisions D1-D10).
VERDICT: APPROVED (with residuals)
No CRITICAL or HIGH findings. The installer's security posture is genuinely strong on the
dimensions that matter most for curl | bash software: no user-controlled value reaches any
shell/eval sink, the whole script is wrapped in main() invoked on the last line (partial
download cannot execute a destructive prefix), idempotency/marker timing is correct, and the CI
smoke job is a real from-scratch Linux E2E that gates regressions (no always-green trap).
Residuals below are 1 MEDIUM + 5 LOW, all non-blocking.
What I tried to break, and what held
1. Injection (hostile flag values / wizard answers) — HELD
Traced every user-controlled value (--dir --port --data-dir --branch --local-source + the five
/dev/tty wizard answers) to every sink:
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR",mkdir -p "$INSTALL_DIR",cd "$INSTALL_DIR", thetarpipelines,bash …/waggle-server.sh start --port "$PORT" --data-dir "$DATA_DIR"— all pass values as quoted, already-expanded arguments. Bash does not re-parse variable contents for command substitution or word-into-command splitting, so--data-dir '$(rm -rf ~)'is stored and used as a literal directory name; the$( )never executes.- The install marker (
write_marker, install.sh:267-288) is the one place answers cross into a file, and it does so exactly per D6:PORT/DATA_DIR/BRANCHare passed as environment variables intonode -eand emitted viaJSON.stringify; the marker path isargv[1]. Zero shell interpolation. This is belt-and-suspenders — no other sink evals user input either. verify_runtimeruns a constant node script; there is noevalanywhere in either file. Result: D6 satisfied and exceeded. No injection path found.
2. curl | bash semantics — HELD (design strength)
- Partial-download execution: all logic lives in functions; the only top-level statements
before
mainare colour setup, thetrap, arg-parsing (a no-op when$@is empty, as undercurl|bash), and OS detection — all harmless.mainis literally the last line (install.sh:367). A truncated download therefore either syntax-errors before running anything, or defines functions and hits EOF beforemain— nothing destructive runs. Correct pattern. - /dev/tty absence (CI/docker):
ask/ask_yesno/run_wizardall guard[ ! -e /dev/tty ](and--yes) → silently take defaults. Verified. - Ctrl-C mid-install:
trap on_interrupt INTrestores tty (stty sane) and aborts; a partial dir without a marker is picked up by the resume branch next run.
3. Idempotency / resume — HELD
3-way branch (install.sh:324-340) is correct; the marker is written after build+verify and
before start (install.sh:357), so its presence means "fully built." Failure between clone and
marker → next run resumes (skips clone, re-runs npm install+build:packages). Re-run with marker
→ exit 0 with upgrade hint. write_marker timestamp-backs-up any existing marker.
4. waggle-server.sh process management — mostly HELD (see MEDIUM/LOW residuals)
Health-first liveness is the right call. stop refuses to signal when /health responds but no
pidfile exists (won't kill an unidentified process on the port). read_pid sanitizes to digits.
Stale pidfile (dead PID, free port) → start proceeds correctly. tasklist/taskkill fallbacks are
correct for the msys/Git-Bash native-PID false-negative case.
5. Plan-vs-impl drift — D1-D10 all implemented as specified
Verified each decision against code. Only wording drift: D7 says "pure-bash timeout shim," but the
health poll actually depends on curl/wget (the script comment even states "no pure-bash HTTP").
See MEDIUM-1.
6. CI gating — HELD
installer-smoke.yml triggers on push/PR touching install.sh, scripts/waggle-server.sh, or the
workflow; runs shellcheck as a failing gate, then a real from-scratch install → boot →
assert /health 200 → status → stop → assert port freed. No || true / continue-on-error
on any assertion (the || true / || echo 000 occurrences are all in diagnostics/polling, not
masking a gate). CI run 29096830297 = success, on HEAD commit 9606414d (verified via gh).
shellcheck therefore passes on both scripts (proven by the green run).
7. CLAUDE.md §7 security — HELD
No hardcoded secrets in any scope file (grepped). No eval/dynamic-require. Vault untouched.
.gitattributes addition (*.sh text eol=lf) is clean and does not disturb existing binary pins.
Residuals (non-blocking)
| # | Sev | File:line | Issue | Break scenario / note |
|---|---|---|---|---|
| M-1 | MEDIUM | scripts/waggle-server.sh:82-91,141-149 |
Health poll requires curl or wget; neither is checked by install.sh preflight. Contradicts D7's "pure-bash timeout shim." |
On a minimal box with neither tool (e.g. some Alpine images), start spawns a healthy sidecar but http_ok returns 2, so wait_for_health times out at 60s and reports "did not become healthy" + exits 1 — a false failure. Non-security; clear error + log tail, no corruption. curl/wget is present on essentially every VPS/homelab image and is proven on the CI ubuntu runner, so real-world impact is low. Fix options: /dev/tcp fallback in http_ok, or add a curl/wget check to install.sh preflight. |
| L-1 | LOW | install.sh:246-252 |
Predictable temp path /tmp/waggle-verify.$$ for stderr capture (CWE-377). |
On a shared multi-user box an attacker who wins a PID-guess race could pre-symlink the path and have the 2> truncate a file the user can write. Single-user localhost typical; low probability. Fix: mktemp. |
| L-2 | LOW | scripts/waggle-server.sh:214-253 |
PID-reuse in stop: if our server is down but the recorded integer PID was reused by an unrelated process not serving /health on our port, signal_pid TERM/KILL targets that process. |
Standard pidfile-manager risk, single-user localhost; mitigated by the health-first early-return and the "refuse to signal unknown PID" guard. Matches industry norm. |
| L-3 | LOW | install.sh:78-79; waggle-server.sh:58-59 |
--port is never validated as numeric. |
--port abc yields a broken-but-safe install (health never comes up → clear timeout). No injection. CLAUDE.md coding-style asks for boundary validation — worth a [[ "$PORT" =~ ^[0-9]+$ ]] guard. |
| L-4 | LOW | README.md:60 / getting-started.md:18 | The documented one-liner points at raw.githubusercontent.com/marolinik/waggle-os/main/install.sh, but install.sh is not on main yet (only on feat/steal-5-installer). |
The docs describe the post-merge reality. Slug marolinik/waggle-os is correct and the repo is PUBLIC. Sequencing dependency: the merge must land install.sh on main for the published one-liner to resolve; the README/getting-started edits should not be published ahead of that merge. Honest, not a lie — but flag the ordering. |
| L-5 | LOW | install.sh:88-90 |
--help uses sed -n '2,40p' "$0"; under curl | bash, $0 is bash, so --help won't print the header. |
Cosmetic; nobody pipes --help. Works when run as ./install.sh --help. |
Informational (out of scope / by design)
- getting-started.md Options 2-3 reference
github.com/marolinik/waggle(desktop releases + agit clone …/waggle.git) while origin ismarolinik/waggle-os. These lines are pre-existing (not introduced by this arc) — noted for a future docs pass, not this verdict. - The resume branch (
install.sh:330-337) runsnpm installon pre-existing directory contents when a non-marked dir exists. This trusts the directory by design (it is the user's own machine); a pre-seeded maliciouspackage.jsonpostinstall would run, but that presupposes attacker write access to the user's home dir. Acceptable under the local trust model.
Bottom line: the security-critical properties (no injection, partial-download safety, correct
idempotency, honest git-over-TLS acquisition, real gating CI) all hold. Ship it; address M-1 and
L-1/L-3 as fast-follows, and ensure the merge lands install.sh on main before the README
one-liner is relied upon (L-4).