diff --git a/CHANGELOG.md b/CHANGELOG.md index 2353e83..deb7ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## docs — REUSE.md introduced (2026-07-03) + +Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New `REUSE.md` at the +repo root: curated map of canonical helpers (48 rows — exec/sudoers surface, format-safety guards, +durable-id seams, stores, local-API plumbing), patterns, dangerous lookalikes (req.Device TOCTOU, +raw mkfs, uuid:-vs-byid: scheme confusion, MemoryNonceStore, pool-blind stale-lock scan…), test +seams, extension points, and observed duplication (7 clusters, NOT fixed). Every entry code-verified +at file+symbol; cited paths machine-checked by `felhom.eu/scripts/reuse_refs_check.py` (green). +CLAUDE.md gains the "See REUSE.md before writing new code" pointer + the same-commit maintenance rule. + ## v0.61.0 — blast-radius audit fixes B1 + D1 + D2 + D3 (2026-07-03) Four LOW/INFO fixes from `AUDIT-blast-radius-hostroot-localapi-2026-07-02.md` — the "second gate must diff --git a/CLAUDE.md b/CLAUDE.md index 6c07a50..0131672 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ internal/pbs/ PBS-API client (fingerprint-pinned) + verify maintenance lo - Design doc: `felhom.eu/documentation/architecture/03-host-agent.md` (locked). - Platform facts: `felhom.eu/documentation/proxmox-platform.md` + `tests/phase{0,1-2,3,4}-findings.md`. +- See `REUSE.md` before writing new code. ## Current state @@ -95,6 +96,7 @@ All shippable work commits **directly to `main`**; `main` is always equal to wha > **Never write secrets** — tokens, passwords, private keys, API keys — into `CHANGELOG.md`, `REPORT.md`, or any committed file. Reference them as "stored out-of-band" instead. - Code quality: verify generated code for bugs/edge cases; add debug logging; **ask rather than guess** when you'd otherwise invent input/output. +- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit). ### Live validation diff --git a/CONTEXT.md b/CONTEXT.md index e7299fe..da2d32b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,6 +5,7 @@ ## Current +- **2026-07-03 — `REUSE.md` exists at the repo root** (canonical helpers / format-safety guards / traps / seams, code-verified); maintenance rule active: update it in the same commit that changes a shared helper. - **v0.61.0** (2026-07-03) — blast-radius audit fixes **B1 + D1 + D2 + D3** from `felhom.eu/documentation/audits/AUDIT-blast-radius-hostroot-localapi-2026-07-02.md`: random temp staging for root-installed scripts (+ sudoers/manifest glob updates), mkfs-wrapper member/RO diff --git a/REPORT.md b/REPORT.md index 2548178..dc46cef 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,78 +1,44 @@ -# REPORT — blast-radius audit fixes B1 + D1 + D2 + D3 (v0.61.0) +# REPORT — docs: REUSE.md introduced (cross-repo reuse map) -**Date:** 2026-07-03 · **Repo:** `felhom-agent` · **Class:** Implementation (destructive-op guard -surfaces D1/D3 proven in unit-land + a loop-device recorder harness — NO real mkfs, no live /dev race, -per the §13 STOP). Source of truth: -`felhom.eu/documentation/audits/AUDIT-blast-radius-hostroot-localapi-2026-07-02.md`. +**Date:** 2026-07-03 · **Class:** docs-only (no code change, no version bump, no deploy) +**Baseline:** `main` @ `0db09bf34fe2eff4b80ac77ed19afa18aef7675f` (v0.61.0) -## 1. Baseline → target +## What was done -agent **v0.60.0** (`cc93dae`, confirmed before starting) → **v0.61.0**. Commits on `main`: -- **`3f382bf`** — all four fixes + tests + harness + version bump + CHANGELOG/CONTEXT/README. -- (this REPORT is the follow-up docs commit) +- **`REUSE.md`** created at the repo root (160 lines): canonical helpers (48 rows — allowlisted + exec/sudoers surface, disk/format safety with each guard named, durable-id seams, atomic stores, + local-API plumbing, proxmox/hub/PBS/provisioning), canonical patterns (12), dangerous lookalikes + (10), test seams + cross-repo edges, extension points, observed duplication (7 clusters, NOT fixed). +- Sourced from a full code sweep (702 non-test `func` declarations scanned; mandatory trap sweep). + Trap-sweep result: the only `--delete` in Go code is `pct set --delete mpN` (DetachBind, itself a + §3 trap vs `DetachDrive`); `os.RemoveAll` only on escrow temp workdirs; no wipefs/sfdisk/dd. +- **`CLAUDE.md`**: two hook lines — "See `REUSE.md` before writing new code" and the same-commit + maintenance rule. No other CLAUDE.md edits. +- `CHANGELOG.md` entry (no version number) + `CONTEXT.md` note. -## 2. What shipped +## Verification -| Fix | Files | What changed | -|---|---|---| -| **B1** random temp staging | `internal/guesthook/install.go`, `internal/localapi/intermediary.go` (new `stageTemp`), `configs/felhom-agent.sudoers`, `internal/capability/manifest.go` | Fixed, predictable `/tmp` names (local TOCTOU into root-executed hookscript/boot script) → `os.CreateTemp` random names (the `lanresolver` pattern). Sudoers install-SOURCE grants became globs (`/tmp/felhom-guest-hook-*.sh`, `/tmp/felhom-shared-parent-*.{sh,service}`); destinations + modes unchanged. Manifest repr vectors updated to match. | -| **D1** wrapper asymmetry | `configs/felhom-mkfs-guarded.sh`, new `scripts/mkfs-guarded-harness.sh` | Additive re-checks: `/sys/block//ro`==1 → die; lsblk-FSTYPE loop refusing exactly `claim.go`'s `memberFSTypes` (LVM2_member/zfs_member/linux_raid_member/crypto_LUKS/swap); pvs via absolute candidates (`/usr/sbin/pvs`, `/sbin/pvs`) with the FSTYPE loop as the independent LVM catch when pvs is absent. Nothing removed/reordered; a blank partitioned disk still formats. | -| **D2** empty-lsblk fail-safe | `internal/storage/claim.go` | `classifyClaim` refuses when the node tree is EMPTY or the target whole-disk is absent from it (previously: empty-but-successful lsblk ⇒ `unclaimed`). Placed after the member/mount loop; the `gatherErr`/`wholeDiskOK`/`isSystem`/`readonly`/`lvmPV`/`zfsMember` ordering untouched. | -| **D3** blank-format anti-retarget | `internal/localapi/wipe_reresolve.go` (shared `antiRetargetResolveExpect` core + new `antiRetargetResolveBlank`), `disks.go`, `formatjob.go`, `server.go` (new `reresolveBlank` seam) | The blank branch now derives the device's durable id (**no durable id ⇒ 409 refuse** — path-only formats not permitted), re-resolves it (resolve → re-derive exact match → re-inspect **STILL !DataBearing**) and formats the RE-RESOLVED device, never `req.Device`. The format-job record carries `blank`; restart recovery re-checks blank jobs with the blank variant. Confirmed (data-bearing) branch untouched. | +`python felhom.eu/scripts/reuse_refs_check.py e:/git/felhom-agent` → +`OK [felhom-agent]: 57 cited paths, all exist`. -**Not in this batch (per spec §0/§12):** A1 (stale-lock pool-membership — a SPIKE: the role lacks -`Pool.Audit`, no cheap membership read exists); C1, C2, A2, B2–B5, E1, E2 (deferred). B2's `..` guard -intentionally skipped — `DriveNameFromRaw` already rejects `/`, so `..` alone can't traverse. +## Notable traps recorded (§3 highlights) -## 3. Tests + red-proofs (each new test shown FAILING on the pre-fix impl, then green on the fix) +Acting on `req.Device` after inspection (AGENT-001 TOCTOU) vs the re-resolve seams; raw `mkfs.*` +(no longer sudoers-allowlisted) vs `SudoHostOps.Format`; `uuid:` vs `byid:`/`byuuid:` durable-id +scheme confusion (F20-BUG2); `MemoryNonceStore` on a real host; adding methods to +`proxmox.Privileged` (breaks the 3-exception fence); mutating outside gate/queue/journal; copying +the pool-blind stale-lock scan (open audit A1). -| Fix | Tests | Red-proof result | -|---|---|---| -| B1 | `TestInstallSnippet_RandomTempName`, `TestInstallSharedParent_RandomTempName` (fake runner records the install source; random pattern + two-calls-differ + content + cleanup asserted) | Pre-fix (HEAD files restored): both FAIL — fixed name, identical across calls. Fix restored → green. | -| D2 | `TestClassifyClaim_EmptyNodesRefused`, `TestClassifyClaim_TargetAbsentFromTree` | Pre-fix `claim.go`: both FAIL — `(true,"unclaimed")` returned. Fix restored → green. | -| D3 | Pure: `TestFormatBlankPath_AntiRetarget_{ReassignedDataBearingRefused,ReassignedDifferentDiskRefused,UnresolvableRefused,SameBlankProceeds}`. Handler: `TestFormat_Blank_{FormatsReresolvedDeviceNotCallerPath,ReresolveRefusalNoMkfs,NoDurableIDRefused}` | Pre-fix blank branch (formats `req.Device`, no binding): all 3 handler tests FAIL — mkfs ran on the caller path with HTTP 200 and no durable binding. Fix restored → green. | -| D1 | `scripts/mkfs-guarded-harness.sh` on felhom-pve (root): throwaway loop devices (`losetup`, one `-r` read-only), PATH-shimmed `lsblk` FSTYPE fixture, `/dev/null` bound over pvs for the pvs-absent case, and a RECORDER bind-mounted over `/usr/sbin/mkfs.ext4` in a private mount ns — **no real mkfs possible**; "formats" is proven by the recorder argv, refusal by its absence | **Fixed wrapper: 8/8 PASS** (5 member FSTYPEs refused, read-only refused, pvs-hidden LVM still refused, plain blank disk still reaches mkfs). **Pre-fix wrapper: 7/8 FAIL** — every member/RO/pvs-hidden fixture reached mkfs. The loop-device `-b` check ran for real (no stub). | +## Observations (NOT acted on) -Full gates green on Windows AND on the build server (go1.26.0): -`go build ./... && go vet ./... && go test ./...` — **0 failures**. Test count **472 → 483** (+11). +- **CLAUDE.md is badly stale**: says "Current: v0.31.0" and its Layout omits `localapi`, + `signedjobs`, `escrow`, `capability`, `guesthook`, `lanresolver`, `desired`, `provision` — all + live at v0.61.0. A CLAUDE.md refresh is a separate task. +- Dead config knobs: `storage.Binaries.MkfsExt4/MkfsXfs` defined+defaulted but never executed by + non-test code (`Format` only execs `MkfsGuarded`). +- Acknowledged mirror: `antiRetargetResolveExpect` re-implements `WipeExecutor.Execute` steps 1–3 + ("mirrors" comments in both) — a change to one must touch both. +- Duplication clusters (2× lsblk parsers, 2× smartctl paths, 3× atomic JSON store, 4× run() helper, + several /proc mount readers) recorded in REUSE.md §6. -## 4. Build / publish / deploy / verify - -- Built on 180 from `3f382bf`: `CGO_ENABLED=0 go build -ldflags "-X main.version=0.61.0"`; - `--version` → `felhom-agent 0.61.0`. -- **Published to Gitea** (generic package, delete-then-put + round-trip GET verified): - `AGENT_VERSION=0.61.0`, `AGENT_SHA256=d5f2f283501dac9daac1c197c9665c98f4577e11d155f5da831b26048c736ed8`. - Credentials sourced out-of-band (`/root/.golden-secrets.env` on the host; not committed anywhere). -- **Deployed to felhom-pve** (backups kept: `felhom-agent.bak-0.60.0`, `felhom-mkfs-guarded.bak-0.60.0`): - binary → `/usr/local/bin/felhom-agent`; **new wrapper** → `/usr/local/sbin/felhom-mkfs-guarded` - (grep-confirmed the D1 checks in the installed copy); **new sudoers** → `/etc/sudoers.d/felhom-agent` - (`visudo -cf` passed both staged and installed). -- **Verify after restart:** service `active`, `--version` 0.61.0, **0 ERROR lines** in the startup - journal; **capability self-check 46/46 ok, 0 degraded** (proves the new sudoers globs cover the new - manifest vectors live); `ReassertGuestBinds`/reconcile re-bound exactly the three enrolled drives - (felhom-flash, felhom-usb, teszt_enroll) — nothing else; stale-lock recovery ran with nothing to reap - (no stale locks, no false action); local API listening; hub desired-state pulled. -- **B1 live-proven end-to-end:** made the installed shared-parent script stale (appended a marker) → - the next reconcile self-healed it through the REAL sudo path with random-named temps - (`install -m 0755 -- /tmp/felhom-shared-parent-281383295.sh …`, - `install -m 0644 -- /tmp/felhom-shared-parent-2841067761.service …`) — authorized by the new globs. -- Cleanup: all /tmp staging removed from felhom-pve and 180; harness loop devices/workdir self-cleaned. - -## 5. NOT yet live-validated — awaiting a supervised step - -- **D1 destructive proof:** a real `mkfs` attempt against a crafted ZFS/mdraid/LUKS/swap member disk. -- **D3 race proof:** a live /dev re-enumeration race during a real format. -Both are proven in unit-land + the recorder harness only, per the task's hard STOP. - -## 6. Observations / follow-ups (recorded, not acted on) - -- **Operator follow-up:** the hub Day-0 artifact manifest still vouches agent **0.60.0** — record - `0.61.0` / sha `d5f2f283…` in the password-gated operator UI (Configs → Day-0 artifacts) so Day-0 - self-installs fetch the new build. (CC cannot do this; see hub-artifact-manifest gotcha.) -- **A1 (LOW, pending SPIKE):** stale-lock recovery reaps by a pool-blind `ListLXC` scan — contained by - the pool-scoped token; MUST be fixed before any broad/root-token deployment. Open design question: - which pool-membership read the role can authorize (`Pool.Audit` absent today). -- Deferred audit items: C1 (controller-swap version floor), C2 (NAS server allowlist), A2 (gate - journal cross-check), B3/B4/B5 housekeeping, E1/E2 comment fixes. -- Known pre-existing: the non-root agent cannot read the PBS key (`felhom-pbs.pw` permission denied at - startup — the BUNDLE leftover, unchanged by this batch). +Cross-repo summary + full sweep counts: `felhom.eu/REPORT.md`. diff --git a/REUSE.md b/REUSE.md new file mode 100644 index 0000000..55e358d --- /dev/null +++ b/REUSE.md @@ -0,0 +1,160 @@ +# REUSE.md — felhom-agent + +> Before writing new code, check here. Canonical helpers, patterns to copy, traps to avoid. +> Maintenance: update in the SAME commit that adds/changes/deprecates a shared helper. +> Entries cite file + symbol. Line numbers are landmarks only — reconfirm before editing. + +## 1. Canonical helpers (MUST reuse — do not reinvent) + +### Allowlisted exec / privileged surface (sudoers) + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `Runner` / `ExecRunner.Run`, `RunStdin` | internal/proxmox/privileged.go | `Run(ctx, name, args...) (stdout, stderr []byte, err)` | ALL host command exec (direct or `sudo -n` prefix) | Arg vectors only, never a shell string; `capBuf` caps output at 1 MiB | +| `Privileged` (CreateGoldenLXC/MountUSBByUUID/SMART/Sensors) | internal/proxmox/privileged.go | methods on `*Privileged` | the 3 fenced root-CLI exceptions ONLY | Do NOT add methods — fence is structural (`routing_test.go` asserts it) | +| `SudoHostOps.run` | internal/storage/hostops.go | `run(ctx, name, args...) error` | allowlisted exec with stderr-wrapped error | Every arg pre-validated via validate.go before this is called | +| `Prober.Probe` | internal/capability/probe.go | `Probe(ctx) []Status` | live sudo-policy capability check (`sudo -n -l --`) | Needs a DIRECT runner (never the sudo-prefixing one — double-sudo); never executes probed cmds | +| `stageTemp` | internal/localapi/intermediary.go | `stageTemp(pattern, content) (path, err)` | random-named temp before a root `install` (audit B1) | Fixed /tmp names are a TOCTOU — sudoers globs expect `/tmp/felhom-*-*.ext` | +| `guesthook.InstallSnippet` / `Register` | internal/guesthook/install.go | `InstallSnippet(ctx, runner) error` | pre-start self-heal hook install (C1 net) | Same random-temp+install pattern; snippet delegates to the agent binary (no shell logic) | + +### Disk / format safety (role gates, durable IDs, format guards) + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `SudoHostOps.Format` | internal/storage/hostops.go | `Format(ctx, device, fstype) error` | THE only mkfs path | Guards, in order: `ValidateBlockDevice` + `ValidateFSType` → mandatory `deviceUnclaimed` (claim.go) → exec `felhom-mkfs-guarded` (sudoers allowlists ONLY the wrapper, not raw mkfs) | +| `SudoHostOps.InspectDevice` + `DeviceProbe.DataBearing` | internal/storage/hostops.go | `InspectDevice(ctx, device) (DeviceProbe, error)` | data-bearing verdict from the AGENT's own read | Fail-safe: `Probed=false` ⇒ DataBearing=true; blkid output is evidence, lsblk is read-success authority | +| `classifyClaim` / `SudoHostOps.deviceUnclaimed` | internal/storage/claim.go | `classifyClaim(claimFacts) (unclaimed bool, reason string)` | "is this disk provably free to format" | Pure function of `gatherClaimFacts`; ANY read error/ambiguity/empty-lsblk ⇒ CLAIMED (audit D2); Felhom's own `/mnt/felhom-drives` mounts are not a foreign claim | +| `SudoHostOps.ListCandidateDisks` | internal/storage/candidates.go | `ListCandidateDisks(ctx) ([]CandidateDisk, error)` | enroll-candidate discovery | Fail-safe: omits anything not provably unclaimed | +| `antiRetargetResolveExpect` (+ `antiRetargetResolve`, `antiRetargetResolveBlank`) | internal/localapi/wipe_reresolve.go | `(durableID, expectDataBearing, resolve, derive, inspect) (device, err)` | pre-mkfs anti-retarget: resolve durable id → re-derive+match → re-inspect | AGENT-001 + audit D3; refuses path-only bindings; wired via `Server.reresolveWipe`/`reresolveBlank` (test-injectable) | +| `signedjobs.WipeExecutor.Execute` | internal/signedjobs/wipe.go | `Execute(ctx, op, params) error` | operator-signed data-bearing wipe | Durable-id bound; nonce burned by gate BEFORE execute; refuses no-longer-data-bearing targets | +| `Gate.AuthorizeStorageWipe` | internal/reconcile/gate.go | `AuthorizeStorageWipe(StorageWipeAuthz, *SignedOp) Decision` | tiered wipe authz | user-data ⇒ customer confirm bound to agent's DeviceDurableID; system/backup ⇒ operator signature only, `Confirmed` IGNORED by role | +| `Gate.Authorize` | internal/reconcile/gate.go | `Authorize(Intent, *SignedOp) Decision` | every destructive intent | role-scoping (`roleAuthorizes`) + op-to-action binding; benign passes unsigned; audits every decision | +| `storage.DeviceDurableID` / `ResolveDurableDevice` | internal/storage/durable_device.go | `DeviceDurableID(device) (string, error)` | WIPE-binding ids (`byid:`/`byuuid:`) | Single seam for /disks list AND gate (F20-BUG2); `ResolveDurableDevice` refuses bare paths | +| `storage.ResolveStorageDevice` | internal/storage/durable_device.go | `ResolveStorageDevice("uuid:") (dev, err)` | re-resolve enrolled STORAGE drives (remount) | `uuid:` scheme ONLY — distinct from the wipe schemes; never trust a remembered /dev node | +| `deriveDurableID` | internal/storage/durableid.go | `deriveDurableID(typ, s, backingDevice, uuid) string` | storage-target durable id (DR re-attach key) | Deterministic per type; `uuid:` for usb/local-dir; PBS id includes `#` | +| `SystemDisks` / `isSystemBacked` / `RoleForStorage` / `RoleForRawDevice` | internal/storage/role.go | `RoleForRawDevice(device, sysDisks, sysKnown) DeviceRole` | protection-tier classification | Fails safe to `system` (most protected) on any ambiguity; role is AGENT-derived, never caller-supplied | +| `ValidateUUID/MountPath/BlockDevice/FSType/SMARTDevice/LVMName`, `UnitNameForMount` | internal/storage/validate.go | `Validate*(v) error` | EVERY arg that reaches a root shell-out | The security boundary; strict whitelists (no by-* symlinks, no dm, no traversal); `systemdEscapePath` computed in-process | +| `ValidateNetworkMountSpec` | internal/storage/netmount.go | `ValidateNetworkMountSpec(spec) error` | NAS mount input boundary | Same discipline as validate.go; SMB requires a creds ref; mountpoint confined under `NetworkMountRoot` | + +### Mount lifecycle (host + guest binds) + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `SudoHostOps.EnsureMount` | internal/storage/hostops.go | `EnsureMount(ctx, MountSpec) error` | persistent by-UUID systemd .mount | Validate→render→stage→`install`→`enable --now`; idempotent | +| `SudoHostOps.Unmount` | internal/storage/hostops.go | `Unmount(ctx, where) error` | detach a mount unit | DESTRUCTIVE — caller MUST have gated it; does not self-authorize | +| `SudoHostOps.ReassertEnrolledMounts` | internal/storage/hostops.go | `ReassertEnrolledMounts(ctx)` | reboot remount (re-resolve by UUID) | Re-asserts unless mounted AND enabled (`shouldReassertMount`); skips absent UUIDs | +| `GuestBinder.AttachDrive` / `DetachDrive` | internal/localapi/intermediary.go | `AttachDrive(ctx, vmid, where) (guestPath, err)` | live drive hot-swap under `/mnt/felhom-drives` | Normalizes to EXACTLY ONE bind via `countHostMounts` (converges double-binds); force re-bind when guest can't see it | +| `GuestBinder.EnsureSharedParent` | internal/localapi/intermediary.go | `EnsureSharedParent(ctx) error` | shared-parent bind + boot unit | make-private+make-shared ONLY on first bind — re-running orphans the guest's slave; F2-a: compares script AND unit for staleness | +| `StablePathForRaw` / `DriveNameFromRaw` | internal/localapi/intermediary.go | `StablePathForRaw("/mnt/") string` | raw host mount → stable guest path | Single source of truth both repos derive from | +| `GuestBinder.GuestSeesMount` / `GuestBootID` | internal/localapi/intermediary.go | `GuestSeesMount(ctx, vmid, path) bool` | guest-visible (usable) signal; reboot detection | Host bind present ≠ guest sees it (non-recursive parent bind) | +| `SudoHostOps.EnsureNetworkMount` / `RemoveNetworkMount` / `ListNetworkMounts` | internal/storage/netmount.go | `EnsureNetworkMount(ctx, spec) error` | NAS automount pair | rm glob confined to `mnt-felhom*` units; NAS ≠ drive (no durable-id/SMART/wipe) | + +### Durable stores (atomic state) + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `IntentStore` (`Get/SetEnrolled/SetEjected/SetDecommissioned/OnAbsent`) | internal/storage/intent.go | `OpenIntentStore(path)` | drive intent (4-state self-heal) | Keyed by durable-id only; `OnAbsent` is the ONLY ejected→enrolled path; refuses empty ids | +| `GuestBindStore` (`Record/Remove/Guests`) | internal/localapi/guestbindstore.go | `OpenGuestBindStore(path)` | per-guest enrolled binds (F9 re-assert) | Same tmp+rename 0600 pattern as IntentStore | +| `FormatJobStore` + `startFormatDetached` + `RecoverFormatJob` | internal/localapi/formatjob.go | `startFormatDetached(device, durableID, fstype, blank) <-chan error` | detached, restart-surviving mkfs (F20-BUG3) | Runs off `s.baseCtx` (60-min bound) so a request deadline can't SIGKILL mkfs; recovery re-resolves by durable id; blank jobs re-check STILL-blank | +| `TokenStore.Mint` / `Lookup` | internal/localapi/tokenstore.go | `Mint(vmid) (plaintext, error)` | per-guest local-API tokens | Only the SHA-256 hash persists (fsync'd append log); constant-time compare on lookup; plaintext returned exactly once | +| `FileNonceStore.SeenOrRecord` | internal/authz/noncestore.go | `SeenOrRecord(nonce, exp) bool` | durable anti-replay | fsync'd before returning false; prune only after exp | +| `Journal` (`Append/Latest/InFlight/AlreadyApplied`) | internal/reconcile/journal.go | `OpenJournal(path)` | op journal + idempotency + crash recovery | `Recover` consumes `InFlight()`; scratch entries special-cased | + +### Local-API plumbing + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `Server.withGuest` | internal/localapi/server.go | `withGuest(fn(w, r, vmid)) http.HandlerFunc` | bearer auth + self-scoping for EVERY route | Token→vmid is the authority; explicit `?vmid=` only a consistency check (403 on mismatch) | +| `Server.scopedFromBody` | internal/localapi/server.go | `scopedFromBody(w, bodyVMID, tokenVMID, path) bool` | POST-body vmid self-scope check | Call right after decode; false = already 403'd | +| `decodeBody` / `writeOK` / `writeErr` / `writeStatus` | internal/localapi/server.go | `writeStatus(w, code, ok, data, errMsg)` | ALL local-API JSON I/O | Do not hand-roll response envelopes | +| `EnsureLeaf` | internal/localapi/cert.go | `EnsureLeaf(certPath, keyPath, host) (cert, fingerprint, generated, err)` | pinned self-signed leaf | `generated=true` invalidates every issued bootstrap pin — log LOUD (B.1) | +| `Server.RecoverStaleLockedGuests` | internal/localapi/stalelock.go | `RecoverStaleLockedGuests(ctx)` | startup stale vzdump-lock heal (F2-b) | Clears ONLY `backup`/`snapshot-delete`, only when no vzdump in-flight; A1 OPEN: pool-blind `ListLXC` scan | +| `ControllerSwapper.Swap` + `ValidControllerImage` | internal/localapi/controllerswap.go | `Swap(ctx, vmid, target) *ControllerSwapState` | agent-owned controller image swap + rollback | Strict image regex (repo + 3-part semver); state file written BEFORE swap; no-healthcheck images need `verifyDwell` | + +### Proxmox client / hub / PBS / provisioning + +| Symbol | File | Short signature | Use for | Gotchas | +|---|---|---|---|---| +| `Client.WaitTask` | internal/proxmox/task.go | `WaitTask(ctx, upid, opts) (TaskStatus, error)` | asserting EVERY mutating op | POST 200 ≠ success; authz can fail at task exec; `AllowWarnings` opt-in | +| `Client` mutate wrappers (`RestoreLXC/Vzdump/DestroyLXC/Snapshot/Rollback/SetConfig/ResizeLXC/Start/Stop`) | internal/proxmox/mutate.go | return `(upid, error)` | all API mutations | Async → always pair with WaitTask; route via gate/queue, not ad-hoc | +| `TLSConfig.build` / `normalizeFingerprint` | internal/proxmox/tls.go | `build() (*tls.Config, error)` | PVE leaf-cert SHA-256 pinning | No insecure default | +| `pinnedTLS` | internal/pbs/pin.go | `pinnedTLS(fingerprint) (*tls.Config, error)` | PBS leaf pinning | Same model as PVE; 64-hex fingerprint normalized | +| `hub.Client.Report` | internal/hub/client.go | `Report(ctx, *HostReport) (*ControlEnvelope, error)` | the heartbeat | Typed `TransportError`/`HTTPError`, never contain the bearer token | +| `hub.Loop` + `MultiObserver` | internal/hub/loop.go | `NewLoop(...)`; `MultiObserver(obs...)` | resilient report loop + envelope fan-out | Errors logged, loop continues; interval clamped 60–3600 s | +| `provision.BackHalf.Provision` | internal/provision/backhalf.go | `Provision(ctx, Input) (Result, error)` | guest bootstrap back-half | mint→render→0600 write→chown 100000:100000→`pct set` ro bind→onboot; token NEVER logged/returned | +| `reconcile.Queue.Submit` | internal/reconcile/queue.go | `Submit(vmid, fn) <-chan error` | per-guest serialization of ALL mutations | Same vmid strictly FIFO; lanes parallel across guests | +| `Engine.RunSignedJob` | internal/reconcile/job.go | `RunSignedJob(ctx, intent, signed, exec) JobResult` | executing a gated destructive job | Idempotency by nonce; journaled | +| `escrow.Create` | internal/escrow/escrow.go | `Create(ctx, CreateOptions) (CreateResult, R, error)` | PBS-key escrow (zero-knowledge) | Recovery code returned SEPARATELY from the result (anti-log); self-verifies recoverability | + +## 2. Canonical patterns (copy structure from THE named file) + +| Pattern | Canonical file | Key traits | +|---|---|---| +| Validate-then-exec privileged op | internal/storage/hostops.go (`EnsureMount`) | validate EVERY arg (validate.go) → render → stage in agent dir → root `install` → `systemctl`; refuse before any command exists | +| Fail-safe pure classifier over gathered facts | internal/storage/claim.go (`classifyClaim` over `claimFacts`) | pure function ⇒ fixture-testable; any error/ambiguity refuses; gather separated from verdict | +| Anti-retarget durable-id binding | internal/localapi/wipe_reresolve.go | resolve id → re-derive + exact match → re-inspect expected state → act on RE-RESOLVED device only | +| Atomic single-file JSON store | internal/storage/intent.go | `Open*` loads (missing=empty, corrupt=fail-loud), mutex, tmp+rename 0600, idempotent set | +| Durable append-only log + index | internal/authz/noncestore.go (`FileNonceStore`) | fsync before returning "new"; replay into index on open; expiry-only compaction | +| Injectable seam funcs on Server | internal/localapi/server.go (`reresolveWipe`, `deviceDurableID`, `boundCheck`) | prod default wired in `NewServer`; tests override — no real /dev in tests | +| Optional dependency degradation | internal/localapi/server.go (`Options`) | nil dep ⇒ endpoint answers "not configured" (503), never a crash | +| Root-file install via random temp | internal/localapi/intermediary.go (`installSharedParentUnit`) | `stageTemp` (os.CreateTemp) → sudoers-globbed `install -m` → pinned destination | +| Detached destructive job + restart recovery | internal/localapi/formatjob.go | persist `running` → run off baseCtx → record outcome; recovery re-resolves durable id, never a path | +| Signed-op verify pipeline | internal/authz/verifier.go (`Verify`) | armor→namespace→key-material allowlist→crypto over RAW bytes→blob→target→window→nonce LAST | +| Resilient daemon loop | internal/hub/loop.go (`Loop.Run`) | ctx-cancel returns nil; errors logged and loop continues | +| Guarded-below-the-agent wrapper | configs/felhom-mkfs-guarded.sh | root re-checks catastrophic cases (system disk/LVM PV/foreign mount/RO/member FS) even against an agent bug | + +## 3. Dangerous lookalikes — do NOT reuse + +| Trap | Why it bites | Use instead | +|---|---|---| +| Acting on the caller's `req.Device` (or any remembered /dev path) after inspection | classify→mkfs TOCTOU (AGENT-001): /dev re-enumeration retargets the node to a different physical disk | `Server.reresolveWipe`/`reresolveBlank` → format the RE-RESOLVED device | +| Exec'ing raw `mkfs.*` (incl. `Binaries.MkfsExt4/MkfsXfs`) | sudoers no longer allowlists raw mkfs; bypasses the claim filter + wrapper re-checks | `SudoHostOps.Format` (→ `felhom-mkfs-guarded`) | +| `DiskInfo.DurableID` (`uuid:`) as a wipe-confirmation id | wipe gate resolves `byid:`/`byuuid:` — a `uuid:` id is a `binding_mismatch` (F20-BUG2) | `DiskInfo.WipeDurableID` / `storage.DeviceDurableID` | +| `ResolveDurableDevice` for enrolled-storage remounts (or vice versa) | schemes differ: wipe = `byid:`/`byuuid:`, storage = `uuid:` — each refuses the other | `ResolveStorageDevice` for mounts; `ResolveDurableDevice` for wipes | +| `authz.MemoryNonceStore` on a real host | replay protection dies on restart | `authz.FileNonceStore` | +| Adding methods to `proxmox.Privileged` | breaks the 3-exception root-CLI fence (`routing_test.go`) | `proxmox.Runner` + a new sudoers Cmnd_Alias + validate.go-style checks | +| Calling `Client.DestroyLXC`/`Vzdump`/`SetConfig` outside the gate/queue/journal | skips classification, signature, per-guest serialization, crash recovery | `reconcile.Engine` paths / `RunSignedJob`; queue via `Queue.Submit` | +| `GuestBinder.AttachBind`/`DetachBind` (per-drive `pct set -mpN`) | legacy model; a missing bind source can brick guest boot (C1) | `AttachDrive`/`DetachDrive` (intermediary model) | +| `isHostMountpoint` to reconcile bind state | boolean can't converge stacked double-binds (the /mnt doubling bug) | `countHostMounts` normalization inside `AttachDrive` | +| Copying `RecoverStaleLockedGuests`' pool-blind `ListLXC` scan | audit A1: acts on guests the agent doesn't own — contained ONLY by today's pool-scoped token | don't extend it; A1 spike must land before any broad-token feature builds on that scan | + +## 4. Seams & interfaces (testing + cross-repo) + +| Interface | Defined in | Implemented by | Fakes/tests at | +|---|---|---|---| +| `proxmox.Runner` | internal/proxmox/privileged.go | `*ExecRunner` (direct/sudo) | `mockRunner` internal/proxmox/mock_test.go; runner fakes in storage tests | +| `storage.HostOps` | internal/storage/hostops.go | `*SudoHostOps` (prod), `NoopHostOps` (degraded) | fakes in internal/storage/observe_test.go, watchdog_test.go | +| `storage.HostReader` | internal/storage/hostread.go | `*ProcHostReader` | `fakeHostReader` internal/localapi/disks_test.go; internal/storage/role_test.go | +| `localapi.DiskOps` / `StorageGate` / `GuestAttacher` / `GuestLister` | internal/localapi/disks.go | `*storage.SudoHostOps`; `storageGateAdapter` (cmd/felhom-agent/main.go); `*GuestBinder`; `*proxmox.Client` | `fakeDiskOps`/`fakeGate`/`fakeGuestAttacher`/`fakeGuestList` internal/localapi/disks_test.go | +| `localapi.GuestAPI` / `BackupService` / `BackupStore` / `TokenAuthority` | internal/localapi/server.go | `*proxmox.Client`, `*backup.BackupRunner`, `*backup.Store`, `*TokenStore` | `fakeGuests`/`fakeBackups`/`fakeStore` internal/localapi/server_test.go | +| `localapi.StaleLockController` | internal/localapi/stalelock.go | `*staleLockController` (Client + Runner) | `fakeStaleLock` internal/localapi/stalelock_test.go | +| `localapi.GuestExecutor` | internal/localapi/controllerswap.go | `*GuestBinder` (pct exec) | `fakeGuestExec` internal/localapi/controllerswap_test.go | +| `reconcile.OpVerifier` | internal/reconcile/gate.go | `*authz.Verifier` | fake verifier in internal/reconcile gate tests | +| `signedjobs.WipeOps` / `Executor` (`ExecutorChain`) | internal/signedjobs/wipe.go + runner.go | `*storage.SudoHostOps`; `WipeExecutor`+`DecommissionExecutor` | internal/signedjobs wipe/runner/decommission tests | +| `hub.reporter` / `collectorIface` / `EnvelopeObserver` | internal/hub/loop.go | `*hub.Client`, `*hub.Collector`; `desired.Syncer` + `signedjobs.Runner` | `fakeReporter`/`fakeCollector` internal/hub/loop_test.go | +| `capability.Runner` | internal/capability/probe.go | `*proxmox.ExecRunner` (RunnerDirect) | `fakeRunner` internal/capability/probe_test.go | +| Cross-repo: local API ↔ controller | internal/localapi/server.go routes; contract seeded by internal/provision/doc.go (`bootstrap.json`: endpoint + leaf fingerprint + token) | felhom-controller's agentapi client | pin = served leaf cert (memory gotcha) | +| Cross-repo: agent ↔ hub | internal/hub/report.go (`HostReport`), `ControlEnvelope`; POST `/api/v1/host-report` | hub mirrors structs field-for-field | new event/report fields need hub-side ingest changes | +| Cross-repo: shipped host artifacts | configs/felhom-agent.sudoers, configs/felhom-mkfs-guarded.sh, shared-parent script/unit (inline in internal/localapi/intermediary.go) | deployed WITH the binary | sudoers globs must match `stageTemp` patterns + staging dirs exactly | +| Operator signing | internal/authz (OpBlob, SSHSIG) | cmd/felhom-opsign (offline CLI) | blob/verify tests in internal/authz | + +## 5. Extension points (where new features plug in) + +- **Local-API route**: add to `Server.Handler` (internal/localapi/server.go) wrapped in `s.withGuest`; new deps go into `Options` as OPTIONAL fields degrading to "not configured". +- **New signed-job verb**: implement `signedjobs.Executor` (return `ErrNoExecutor` for foreign ops) and append to the `signedjobs.ExecutorChain` in cmd/felhom-agent/main.go; add the op class + role scoping in internal/reconcile (classify.go, gate.go `roleAuthorizes`). +- **New privileged host op**: validate args (internal/storage/validate.go style) → exec via `Runner` → add a `Cmnd_Alias` to configs/felhom-agent.sudoers → add a probe vector to internal/capability/manifest.go (so degradation is visible) → ship sudoers with the binary. +- **New reconcile action**: `ActionKind` + `classOfAction` (internal/reconcile/classify.go), plan emission in internal/reconcile/plan.go; destructive ⇒ gate handles it automatically. +- **Hub-report field**: extend `hub.HostReport` (internal/hub/report.go) + `Collector` — hub side must mirror + allowlist it (cross-repo). +- **Envelope-driven behavior**: implement `hub.EnvelopeObserver`, add to the `MultiObserver` in cmd/felhom-agent/main.go. +- **Selftest mode**: `selftestFlag` + `runSelftest*` in cmd/felhom-agent/main.go. +- **Config**: internal/config/config.go (`Load` + `applyEnv` `FELHOM_AGENT_*` overlay; keep secrets out of `Redacted()` output). + +## 6. Known duplication (observed — NOT fixed) + +- Two lsblk `-J` parsers with near-identical structs: `parseLsblkDevice`/`lsblkDevice` (internal/storage/hostops.go) vs `parseLsblkNodes`/`lsblkDev` (internal/storage/claim.go). +- Two smartctl `-a -j` paths: `SudoHostOps.SMART` (internal/storage/hostops.go, parsed `hub.SmartSummary`) vs `Privileged.SMART` (internal/proxmox/privileged.go, raw map). +- Atomic tmp+rename JSON store implemented 3×: `IntentStore.saveLocked` (internal/storage/intent.go), `FormatJobStore.save` (internal/localapi/formatjob.go), `GuestBindStore.saveLocked` (internal/localapi/guestbindstore.go) — comments say "mirrors", no shared helper. +- `run(ctx, name, args...) error` stderr-wrapping helper duplicated 4×: `SudoHostOps.run`, `Privileged.run`, `BackHalf.run` (internal/provision/backhalf.go), `GuestBinder.run` (internal/localapi/guestbind.go). +- Several independent /proc mount-table readers: `SudoHostOps.mountedSet` (internal/storage/hostops.go), `ProcHostReader.Mounts` (internal/storage/hostread.go), `isHostMountpoint` + `countHostMounts` (internal/localapi/intermediary.go). +- Deliberate mirror: `antiRetargetResolveExpect` (internal/localapi/wipe_reresolve.go) duplicates `WipeExecutor.Execute` steps 1–3 (internal/signedjobs/wipe.go) across packages. +- `stableParentDir` literal duplicated in internal/provision/backhalf.go to avoid a provision→localapi import edge (commented as intentional); `trim` (internal/storage/hostops.go) vs `trimBody` (internal/proxmox/errors.go) output-truncation twins.