Agent 0.159.1, the catalog allow-list (controller 0.328.0), break-glass rotation: report, register, design
gates / gates (push) Successful in 8m49s

Closed R-971, R-954, R-1124, R-1012 (174 -> 175 rows); opened R-1139..R-1143; R-895 READY with the design
and hub slice 1; R-972 waits on the vouch. [DESIGN] "The catalog allow-list" in 02; the catalog trust line in
01; FELHOM_THINCHECK and unit-forget in 03. Evidence audits/agent-allowlist-rotation-2026-10-17/.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
admin committed 2026-10-17 18:00:05 +02:00
1 parent d968f8490e
commit 5a5486104a
36 files changed
+816 -16

No files matched your search

@@ -169,6 +169,12 @@ second layer. **How it is checked:** `scripts/https_only_check.py` lists every p
routes and demands 301/308 to the same https URL (negative control: a stub answering plain 200 must be convicted).
Evidence: `audits/https-only-2026-10-14/`.
**What the catalog may ask of the box (controller v0.328.0, R-971, decision D2 = A).** The catalog repository is trusted
for WHICH apps exist and how they are configured — not for container RIGHTS. A template that asks for a right not on the
allow-list compiled into the controller (privileged mode, capabilities, devices, host namespaces, the Docker socket,
binds outside the box's data roots, …) is refused on the box before its compose is written, and the operator is told
(`02-controller-module-map.md` „The catalog allow-list"). Signed catalog releases remain the later step R-971 names.
**Who may reach an app, and through what (recorded 2026-09-29 — no document said it before; spike finding F1).**
Every app is reached only through the box's traefik (no catalog app publishes a host port except crafty-controller's
game ports; none uses host networking — read from the catalog 2026-09-29). traefik routes by host name: the tunnel's
@@ -727,6 +727,46 @@ Cloudflare edge served 0.315.0's first stylesheet under the address its third bu
---
## [DESIGN] The catalog allow-list — the container rights the box gives (controller v0.328.0, R-971, operator decision D2 = A, 2026-10-17)
**What.** Before ANY app compose is written — the install, a settings change, the 15-minute correction, a guarded update
or ladder step, a unit or off-site restore — the controller renders the compose with the app's own values (exactly as
`compose up` will see them) and asks `internal/privpolicy.Admit`. A setting in one of these classes is refused unless
this app's service has EXACTLY that setting on the allow-list: `privileged`, `cap_add`, `devices`,
`device_cgroup_rules`, `gpus`, deploy device reservations, `network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`/
`cgroup_parent`/`runtime`/`isolation`, `security_opt` other than `no-new-privileges`, `sysctls`, `volumes_from`, `build`,
`include`, ANY `extends`, a `provider` service, `post_start`/`pre_stop` hooks, an `env_file`/`configs`/`secrets` file
outside the stack dir, a named volume with `driver`/`driver_opts`/`external`/`name`, a `${VAR}` in any of those keys,
and binds: the Docker socket anywhere, mount propagation, a `${VAR}` in a bind's options, and any source outside the
data roots the box itself gives — `${HDD_PATH}/appdata/`, `${USERDATA_PATH}`, `${IMPORT_PATH}` (a bind source may use
only those variables, as its leading token; each root must lie under `/mnt/felhom-drives/` or the system data path; on
the box a source is judged where it really points, through its nearest existing ancestor, so a symlink cannot carry it
out).
**Where, and why in code.** The list is `internal/privpolicy/allowlist.json`, compiled in with `go:embed`: a change in
Gitea — the threat R-971 names (whoever can push to the catalog can take every guest) — cannot add to it; a new entry
needs a controller release. An entry is app + service + exact setting, and holds only while that service runs the
entry's image REPOSITORY (a literal reference), with the image's own entrypoint and command, and the exact
`healthcheck.test` the entry names — so a push cannot keep the allowed right and change what runs with it. Today: one
entry, homepage's read-only Docker socket (the survey of all 123 catalog files found nothing else,
`audits/agent-allowlist-rotation-2026-10-17/P2/survey.md`). The catalog's CI gate `check-privileges.py` mirrors the
check with a byte-identical copy of the list (pinned from both sides); the BOX is the authority.
**What a refusal does.** Nothing is written. A refused correction or update leaves the app on its last good compose,
running; the controller raises the operator-only hub event `app_definition_refused` once per refused definition (hub ≥
v0.157.0) and the app page carries one line (`app_info.definition_refused`). A refused install, settings change or
restore is refused with a plain message („Semmi nem változott"); a restore asks BEFORE it stops the app. A check
that cannot read the compose refuses. The sync never copies a catalog template named like a controller infra stack.
**Out of scope, stated.** The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override
files (setup gate, family gate, install hold, media library) are rendered by the controller, not the catalog. Published
host ports are not a class (crafty-controller's game ports). Known limit: under `${HDD_PATH}/appdata/` one app can
name another app's folder (a per-app rule needs a name map — paperless-ngx keeps `appdata/paperless`), R-1142.
**Pinned by** `internal/privpolicy/privpolicy_test.go` (each class refused, with positive controls),
`stacks.TestAllowList_IsWiredIntoEveryComposeWriter` (AST: every compose writer and every env decision point asks
first), `sync.TestR971_*` (a refused correction writes neither file and is not reported updated).
## The app-definition seam — what happens to a deployed app when its compose file is rewritten
> **STATUS: MEASURED BEHAVIOUR, NOT A RECORDED DESIGN.** This section describes what the system was
@@ -113,6 +113,8 @@ fixed file, delivered by the signed config bundle):
| `FELHOM_CONTROLLERSWAP` | the managed controller update | exact vmid; image ref pinned to `gitea.dooplex.hu/admin/felhom-controller:X.Y.Z` for the image check; the inspect template stays free text; **the write goes through `felhom-priv-apply controller-image <vmid>`** (the ref on stdin, re-checked as root against the same pattern — R-861 (a) A1, `09` §3 decision 165, agent after v0.150.0) | guest-scoped: a compromised agent can restart the guest's bootstrap and choose an OLDER version of OUR controller image (a downgrade); it can no longer hand the guest any other image. Until the A1 bundle reached a box, the agent's in-guest `tee` let it write any ref (sudo cannot see stdin — the 2026-10-06 night correction, `audits/night-burndown-2026-10-06/design-R-861.md`) |
| `FELHOM_STALELOCK` / `FELHOM_SCRATCH_TEARDOWN` | stale-lock clear; failed restore-test scratch | exact vmid; the scratch band `99000[0-9]` was already exact | — |
| `FELHOM_VZPARTIAL` | remove a dead half-written vzdump (R-1006, agent v0.156.1) | ONE line: `felhom-vzdump-partial-clean <storage> <vmid> <half-written name of that vmid>`; the root helper (a config-bundle file) re-checks: a `dir` storage from `storage.cfg`, the name of that vmid (`.tar.dat`, `.vma.dat`, `.tmp`), not a symlink, directly in `<path>/dump`, no vzdump on the host, nothing holds it open, dead (before this boot or quiet 120 s) | a compromised agent can delete a dead partial — never a finished archive, never a live one |
| `FELHOM_THINCHECK` | name an ORPHAN thin device around a whole-guest backup (R-1012, agent v0.159.0) | ONE line: `felhom-thin-check <vg> <pool>`; the root helper (a config-bundle file) lists LVM's thin ids before and after and the pool's ids through a metadata snapshot it reserves and releases; it refuses (exit 3) when someone else holds a metadata snapshot and sends no other pool message. The agent reports an id in the pool and in neither LVM list (two reads) in the backup failure's text; the repair (`runbooks/whole-guest-backup-orphan-thin.md`) stays human | a compromised agent can READ the pool's device ids — it cannot delete, create or change a thin device |
| `FELHOM_MOUNT` `unit-forget` | forget a decommissioned drive that is gone (R-1124, agent v0.159.0) | `felhom-priv-apply unit-forget mnt-<…>.mount` removes ONE unit file, only a felhom by-UUID drive unit (not a network unit) that is disabled, not mounted, and whose `/dev/disk/by-uuid` link is absent; the agent calls it at start and on the 20-s tick for drives whose intent is `decommissioned` | a compromised agent can delete the unit of an ABSENT, unmounted drive — the same effect as unplugging it; a present drive keeps its unit |
| `FELHOM_WG` | the off-site tunnel | conf via `felhom-priv-apply wg` (only the keys `renderConf` writes; no `PostUp`/`PreUp`/`DNS`/`Table`; `/32` only) | — |
| `FELHOM_SELFUPDATE` | commit / rollback of the A/B flip | **`apply` removed**: the flip runs only inside `felhom-os-apply agent_update`, after the operator signature, host, window and nonce are checked as root and the staged bytes are hashed ONCE and copied to a root-owned dir (`/var/lib/felhom-os-apply/agent-update/`); the wrapper accepts only that dir | — |
| `FELHOM_SSHD` | the out-of-band operator sshd | config via `felhom-priv-apply sshd-config` (the ONE template, only the Port varies, never 22); the felhom-op key via `sshd-key` (one plain key, no `command=`/`from=` options) | felhom-op's key itself is hub-delivered, not signed: a compromised agent can install its own key for **felhom-op** — whose sudo is scoped (`felhom-op.sudoers`: fixed repair verbs and `pct start` / `stop` / `unlock` of ONE numeric vmid, anchored since R-861 (b) B2), not root — accepted (`09` §3 decision 165, B3) |