Installer step 4b: vault first, then set, then a PVE login (R-1145); host key off curl's argv (review F4); harness green and a gate (R-1146); hub 0.158.0 deploy evidence; independent review + verdicts
Live on demo-hp (--rotate-recovery-only): vault time moved, new value 200, old 401. Harness 52/0;
the R1145 cases fail against HEAD. Publishes only with the next installer tag.
== live redaction test 2026-10-18T10:28:49Z: a fresh fake token (48 hex) sent in a query, a /bind/ path, a session query, a Bearer header, a Cookie, a POST body
### F1 (medium) `scripts/felhom-host-install.sh` ~1767–1783 (the `--rotate-recovery-only` healthz pre-check) + `step_break_glass` ~1657
**The pre-flight that is supposed to stop a rotation that cannot be vaulted uses the wrong credential, so a stale/invalid host key leaves `root@pam` rotated-but-unvaulted.**
Scenario: operator runs `--rotate-recovery-only` on an appliance box whose `hub.api_key` in `agent.json` is stale (host deleted/re-enrolled at the hub, or a copied/old config). The mode's only gate before the destructive `chpasswd` is `http_code "$HUB_URL/healthz"` — an **unauthenticated** endpoint. healthz returns 200, so the script proceeds; `step_break_glass` generates a new random password, sets it with `chpasswd`, then `PUT /hosts/$HOST_ID/recovery-credential` with `Bearer $HOST_API_KEY` returns 401/403 → `die`. At that point `root@pam` has already been changed to a random string that was never vaulted; the hub still holds the *old* credential, which no longer works. The PVE web console (:8006) — the layer-3 break-glass path this mode exists to maintain — is now unreachable with any known password.
The code comment claims the healthz check covers "a vault that fails AFTER chpasswd leaves a password nobody holds", but healthz exercises **no** auth, whereas the vault PUT needs the host key — a different channel, so the pre-check cannot catch the failure it is meant to prevent (the same class as the R-286 "control from a different channel" rule). In the full install this is masked because `step_enroll` validates the host key before `step_break_glass`; the new standalone mode has no enroll, so nothing confirms the key is good before the irreversible `chpasswd`.
Fix direction: before `chpasswd`, make one **host-key-authenticated** call (e.g. `GET /api/v1/hosts/$HOST_ID/desired-state` with the Bearer key) and refuse the rotation if it is not 2xx. Confidence: confirmed by reading.
**The "unexplained snapshot" (fake-injection) alarm trusts the box's own self-reported run times, so an attacker holding the box's key can suppress it.**
`offsite_snapshots_unexplained` fires only when a newly-appeared snapshot's upload time is **not** near any `offsite.last_run` the box reported. But `OffsiteRunTimesSince` reads those run times from the customer's `report_json` — data produced by the same guest an attacker who holds the append-only key typically already controls. An attacker who uploads a fabricated snapshot through the box's add-only key can also have the box report a `last_run` whose time brackets the injected file's mtime, so `explainedByRun` returns true and no warning is raised. The injection signature the alarm is built to catch is defeatable by the very actor it targets.
This is partly acknowledged in the design ("Fakes added with the box's key would look like this; a crashed run would too"), and the custody-critical half — `offsite_snapshots_vanished` — is **not** affected: it is judged on the hub's own name listing against hub-side window credits (`WindowCreditsBetween`), a channel the box cannot forge, and correctly uses the full listing `l.Entries` (not the settle-filtered `next`) for the "still present" test, so a too-new file is not mistaken for a deletion. Reported because the brief explicitly asks about faking the off-site alarms; the deletion alarm is the one that matters most and it is sound. Confidence: confirmed by reading.
**Decommission-anyway stops the drive's apps before the agent is asked; on an agent policy refusal the apps are left stopped with nothing retired (half-state).**
The handler calls `StopStack` on every app whose `HDD_PATH` is the target drive, then `finalizeDecommission`. With the R-1063 reorder the agent is now asked first and can refuse with 403 (protected role) or 409 (whole-guest backup target); on that refusal the function returns and nothing is marked — but the apps were already stopped and are not restarted, so a household that presses "decommission anyway" on a drive that is also the backup target ends with its apps down and a 409. This is **not newly introduced** (the stop always preceded `finalizeDecommission`), and the reorder is a net improvement for the mark/agent consistency the comment describes (previously the registry was marked decommissioned while the agent kept serving). Noted as a residual half-state the retirement change did not close. Confidence: confirmed by reading.
**The host API key is passed in `curl` argv (`-H "Authorization: Bearer $HOST_API_KEY"`) during the vault PUT, visible in process args while the call runs.** The password is handled correctly (stdin→chpasswd, stdin→`curl -d @-`, scrubbed), but the host key rides on the command line. On a PVE host this is root-only and the code is moved verbatim from the original step, so it is pre-existing, not a regression. Worth a line because `--rotate-recovery-only` makes this a standalone, operator-run path. Confidence: confirmed by reading.
---
## Checked and found sound
- **Per-app appdata root (R-1142).** `AppdataFolder` narrows `${HDD_PATH}` to `appdata/<the app's own folder>`; a template named like another app's mapped folder (e.g. `paperless` vs `paperless-ngx` → `paperless`) gets **no** root (reverse-lookup over `appdataFolders` values), an app with no usable name gets no root (fail-closed), `oneSegment` rejects `/ \ $ . .. ""`, and a duplicate folder mapping panics at load. Another app's folder/sub-folder/the whole `appdata`/a prefix-collision name/a literal path to another app are all refused; tests pin each case.
- **Catalog CI parity.** `scripts/privilege-allowlist.json` is byte-identical to the compiled `allowlist.json` (both v2, empty entries, `paperless-ngx→paperless`); `check-privileges.py`'s `one_segment`/`appdata_folder`/reverse-lookup/duplicate-folder checks mirror the Go, and `app` = the template's top-level dir name, matching the box's `appName`.
- **USERDATA_PATH can no longer be named by a template.** `policyEnv` deletes any app-env `USERDATA_PATH` and re-derives it; the **runtime** converges on the same value — Go `exec` dedups `cmd.Env` to the last occurrence and `envLookup`/`ensureUserdataMounts`/`renderMediaOverride` all read the last match, which is the `withPathVars`-appended derivation, so an injected `app.yaml USERDATA_PATH` wins neither the check nor the bind. `IMPORT_PATH` is likewise always controller-derived or unset.
- **Docker socket is refused everywhere.** Empty allow-list ⇒ any `docker.sock` bind (short/long syntax, `/run` or `/var/run`, `.sock` suffix, or a symlink whose `EvalSymlinks` target leaves the root) is a violation with no allowed entry; homepage's template no longer mounts it and `TestAllowList_HasNoExceptionAndRefusesHomepagesSocket` red-proofs the removal. Controller-infra stack names are refused in `copyTemplates`.
- **Agent-only vouch.** The R-120 gate is skipped only when the submitted golden equals the vouched one in **both** version and sha256; a changed version *or* a same-version re-push (different sha, freshly resolved from the registry) is still judged and refused if behind the fleet (test-pinned). The R-273 tag+package installability gate, the bundle-sha resolve, and operator-only `RequireAuth`/CSRF are all intact; MinAgent floor logic is untouched. Nothing was loosened that lets a stale/wrong golden be saved.
- **Access log.** Query string dropped entirely; path segments ≥32 chars and the `/bind/<token>` capability redacted; `cred`/`auth` record only the kind, never a value (`Authorization`, session cookie, `X-Retrieval-Password` → names only). Recovery-credential PUT keeps the password to body + hub log prints only user+length. A logging failure never fails/delays the request (non-blocking channel, rate-limited error); prune removes only exact `access-YYYY-MM-DD.log` names. No request shape observed that lands a secret in a line.
- **Agent orphan thin-check reorder.** `thinCheck` now runs only from `explainFailure` (vzdump start/task failure), bounded by a 2-minute `context.WithoutCancel` timeout, read-only; the success path no longer snapshots the live pool. Capability manifest description updated to match.
- **build.sh.** `:latest` moves only for a release tag matching `^[0-9]+\.[0-9]+\.[0-9]+$`; rc/dev tags push only their own tag.
- **agentapi typed refusals.** `ErrNoDurableID` (409 + "has no durable id") and `DecommissionRefusedError` (403→protected, 409→backup-target) carry only the agent's truncated reason text, never bodies/secrets; the agent-side role gate fails safe to protected on an unresolvable mount.
# Verdicts on the independent review (2026-10-18) — each checked against source by the attending session
The report: `independent-review.md` (this folder; written by a fresh reviewer with no context from the night; read back
here — 54 lines, 4 findings).
| # | Finding | Verdict | What was done |
|---|---|---|---|
| F1 (medium) | `--rotate-recovery-only` checks only an unauthenticated `/healthz`; a stale host key → chpasswd runs → the vault PUT 401s → root holds an unvaulted password | **REAL in the night's code** (`felhom-host-install.sh` step 4b set the password first). | **Fixed by R-1145 (today):** the vault comes FIRST, so a stale key stops at the PUT and the password is not touched. Harness `R1145-e` (vault 401 → no chpasswd); red-proof: it FAILS against HEAD (`P5/redproof-r1145.txt`). |
| F2 (low–medium) | `offsite_snapshots_unexplained` is explained by the box's OWN reported `offsite.last_run` — a box (or whoever holds its key) can report a matching run and silence the warning | **REAL, by design today** (decision 68 accepts a box-trusted run time; the custody-critical `offsite_snapshots_vanished` is judged on the hub's own listing and window credits and is not affected — the reviewer confirms). | Recorded in R-895's row (its design §3.1/§3.2 remainder is where an independent upload-time check belongs); not a new row. |
| F3 (low) | „anyway" stops the drive's apps before the agent is asked; a refusal leaves them stopped | **REAL, already filed** (R-1144). | **Fixed today** (controller): the agent's own disk list is asked first; a refusal after the stops starts exactly the apps that were running. `TestR1144_*`, red-proofed (`P3/`). |
| F4 (low) | step 4b passes the host api_key in curl's argv (`-H "Authorization: Bearer …"`), visible in `ps` | **REAL**, pre-existing (moved verbatim). | **Fixed today:** the header rides a 0600 temp file written by the printf builtin (`-H @file`), removed after. The harness asserts the key is in no fake's argv (all R1145 cases). |
Nothing else was reported. The reviewer's „checked and found sound" list is in its report.
== RED-PROOF R-1145: the new harness cases against the installer at HEAD (set first, then vault)
=== hostinstall-mode-harness — script under test: /tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/649cb06d-b4ca-49f2-9e2c-16c8bc36531f/scratchpad/old-install.sh ===
FAIL R1145-a vault fails → the password is NOT touched
FAIL R1145-b vault, then set, then the PVE login — in that order
FAIL R1145-c set fails twice after the vault → the state is said, root unchanged
FAIL R1145-d the PVE login refuses the new value → die, the state is said
=== 46 passed, 5 failed, 1 skipped ===
== RED-PROOF review F1/F4 against HEAD (set before vault; key in argv)
=== hostinstall-mode-harness — script under test: /tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/649cb06d-b4ca-49f2-9e2c-16c8bc36531f/scratchpad/old-install.sh ===
FAIL R1145-e a stale host key (vault 401) → the password is NOT touched (review F1)
=== 46 passed, 6 failed, 1 skipped ===
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.