v0.5.0: slice 5 Phase B — the host-root surface (mounts + SMART + grow + destructive gate)

The privileged write surface, isolated behind a narrow, arg-validated, adversarially-
tested seam (HostOps), the same discipline as the slice-4 gate. Completes slice 5.

- internal/storage: HostOps seam + SudoHostOps (systemd .mount units by fs-UUID, detach,
  SMART, lvs) via sudoers allowlist + fixed arg vectors, no shell; NoopHostOps fallback.
- validate.go: strict UUID/mount-path/device/LVM validators + in-process systemd-escape.
  Headline test: adversarial matrix (metacharacters/traversal/malformed) refused with
  zero exec.
- smart.go: smartctl SATA + NVMe parse, UNKNOWN-degrade; lvs thin-pool metadata fill.
- observer enrichment (Observe only): fills smart + thin-pool metadata.
- watchdog: benign re-mount response off the poll path (DevicePresent probe, rate-limited).
- reconcile: ActionResize (benign, grow-only) + proxmox.ResizeLXC; destructive storage ops
  (ClassStorageWipe/Decommission) through the slice-4 gate, target-scoped; built+tested,
  inert live.
- --selftest=storage [-watch] live harness; configs/felhom-agent.sudoers; privileged.* knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:53:38 +02:00
parent 27b68f043b
commit 9d6e49236c
25 changed files with 2074 additions and 182 deletions
+57
View File
@@ -3,6 +3,63 @@
All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed.
## v0.5.0 — slice 5 Phase B: the host-root surface (mounts + SMART + grow + destructive gate) (2026-06-09)
The write surface — the agent's first step outside its Proxmox API token into OS-root.
Isolated behind a narrow, argument-validated, adversarially-tested seam, exactly like the
slice-4 gate. Completes slice 5 (Phase A = read-only observe/report/watchdog at v0.5.0-rc1).
### Added
- **`HostOps` seam + `SudoHostOps`** (`internal/storage/hostops.go`) — the one privileged
host surface: persistent mounts via **systemd `.mount` units keyed by fs-UUID** (enabled to
survive reboot), detach (stop+disable), SMART, and thin-pool metadata. Shells out via the
fenced Runner (`sudo -n`, **fixed arg vectors, no shell**); a fake backs the tests (no real
root in the suite). `NoopHostOps` is the safe fallback when the surface is unavailable.
- **The argument validator** (`internal/storage/validate.go`) — the security boundary:
`ValidateUUID` (strict hex), `ValidateMountPath` (absolute, no traversal, no metacharacters),
`ValidateSMARTDevice` (raw-disk whitelist), `ValidateLVMName`, and an in-process
`systemdEscapePath` (no `systemd-escape` shell-out). **Every argument is validated BEFORE a
command is constructed.** Headline test (`validate_test.go`): an adversarial matrix of
shell metacharacters / `../` traversal / malformed inputs is rejected with **zero exec**.
- **SMART** (`internal/storage/smart.go`) — parses `smartctl -a -j` into `StorageTarget.smart`:
**SATA** (reallocated/pending/offline-uncorrectable, temp, power-on-hours) **and NVMe**
(critical_warning, media_errors, percentage_used, temp), degrading to `UNKNOWN` for devices
with no SMART (USB-SATA bridges). **`lvs`** fills the lvmthin thin-pool **metadata** fill
(the value Phase A left null). Wired into the Observer's enrichment (Observe only, not the
watchdog's fast Known path).
- **Watchdog re-mount response** (`internal/storage/watchdog.go`) — on a known mount-backed
target's device returning **unmounted** (a new `DevicePresent` liveness probe), the watchdog
**dispatches a benign by-UUID re-mount off the poll path** (a goroutine, never under the
lock), rate-limited per target to the debounce window. The mount is routed through the gate
as benign (`gateRemounter` in `main.go`, so `storage` stays decoupled from `reconcile`).
- **Disk-grow executor** (`internal/reconcile`) — `ActionResize` (benign `ClassResize`), planned
**grow-only** (desired DiskBytes > actual → `pct resize rootfs +<n>M`; a shrink is refused,
never silently grown) + a defensive executor guard (size must start with `+`). New
`proxmox.Client.ResizeLXC` (API; `VM.Config.Disk`+`Datastore.AllocateSpace`; async→UPID).
Built + fixture-tested; **unfed** live (no hub spec until slice 10).
- **Destructive storage ops through the slice-4 gate** (`internal/reconcile/storage_ops.go`) —
`IntentForStorageMount` (benign) and `IntentForStorageDestructive` (`ClassStorageWipe`/
`ClassDecommission`). Host/target-scoped: the op binds on the storage **target identity**
(carried in `target.guest_id`). Reuses the existing verifier/role-scoping/binding/audit — no
new gate, no new crypto. Storage cases added to the adversarial matrix (`storage_test.go`):
unsigned wipe → `pending_signature`; "wipe A" signature vs "wipe B" → `binding_mismatch`;
valid → accepted. **Inert** live.
- **`--selftest=storage` [`-watch <dur>`]** — the live USB-runbook harness: an observe pass
(full table incl. SMART + thin-pool data+metadata), and a bounded watchdog window with the
re-mount response live. Runs standalone on the Proxmox host (no hub).
- **`configs/felhom-agent.sudoers`** — the documented narrow allowlist (install unit / systemctl
manage / smartctl / lvs), with the agent-side fine validation noted.
- **Config**: `privileged.{unit_dir,stage_dir,systemctl,install,smartctl,lvs}` (paths must match
the sudoers entries).
### Notes
- Daemon still runs cleanly with no removable storage / no signers / no hub manifest, and a
missing/declined sudoers entry degrades with a warning (SMART→UNKNOWN, mount→logged error),
not a crash. `go test -race` passes (the watchdog re-mount dispatches off the poll path).
- Slice-3/4 + Phase-A exported surfaces, goldens, and adversarial tests intact. `authz`
untouched. The destructive-storage executor + grow are built/tested but unfed live until
slice 10.
## v0.5.0-rc1 — slice 5 Phase A: storage observe + report + watchdog (read-only, live) (2026-06-09)
Phase A of the storage slice (doc 03 §7). Read-only and live: the agent now observes every