# SPIKE — Narrow-grant controller-swap mechanics (stdin `tee` write + sudoers wildcard matching)

- **Date:** 2026-06-29
- **Class:** Spike / empirical, read-only / non-destructive (scratch sudoers drop-in + `/tmp` scratch
  file only; both removed in teardown). No production code, no change to the real
  `/etc/sudoers.d/felhom-agent`, `/etc/felhom-controller-image`, `controllerswap.go`, or the bootstrap
  unit.
- **Host:** felhom-pve (`demo-felhom`, 192.168.0.162), guest LXC 9201; agent runs non-root as
  `felhom-agent`. Live controller image at spike time: `…/felhom-controller:0.89.0`.
- **Verdict: GO** — Option A (narrow-grant controller-swap) is mechanically sound as specced. All
  three questions PASS; no vector needs reshaping.

---

## Q1 — stdin round-trip through `sudo -n → pct exec → tee` — **PASS**

Run as `felhom-agent`:
```
printf '%s\n' 'gitea.dooplex.hu/admin/felhom-controller:0.89.0' \
  | sudo -u felhom-agent sudo -n pct exec 9201 -- tee /tmp/felhom-swap-spike
→ tee-exit=0
```
The bytes written into the guest, `od -c` tail:
```
…  c   o   n   t   r   o   l   l   e   r   :   0   .   8   9   .   0  \n
```
Exact image ref **plus the trailing `\n`** — byte-identical to the golden's `printf '%s\n'` write.
The `$(cat)` parse the bootstrap uses:
```
IMG=$(cat /tmp/felhom-swap-spike) → parsed=[gitea.dooplex.hu/admin/felhom-controller:0.89.0]
```
stdin forwards cleanly through `sudo -n` and `pct exec` into the in-guest `tee`; the result is what
the reader expects. **The `bash -c "printf … >"` vector can be replaced by stdin-fed `tee`.**

---

## Q2 — sudoers wildcard matching — **PASS (all 5 grants)**

Scratch grants installed (validated `visudo -cf`):
```
/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image
/usr/sbin/pct exec [0-9]* -- docker image inspect *
/usr/sbin/pct exec [0-9]* -- docker inspect -f *
/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service
/usr/sbin/pct exec [0-9]* -- tee /tmp/felhom-swap-spike   (scratch path; real grant → /etc/felhom-controller-image)
```
`sudo -n -l -- <real invocation>` exit codes (0 = matched/permitted):

| Real invocation | exit |
|---|---|
| `pct exec 9201 -- docker inspect -f '{{json .State}}' felhom-controller` | **0** |
| `pct exec 9201 -- docker inspect -f '{{.State.Running}}|{{if .State.Health}}…{{end}}|{{.Config.Image}}' felhom-controller` (the REAL `controllerHealthy` template) | **0** |
| `pct exec 9201 -- docker image inspect gitea.dooplex.hu/admin/felhom-controller:0.89.0` | **0** |
| `pct exec 9201 -- cat /etc/felhom-controller-image` | **0** |
| `pct exec 9201 -- systemctl restart felhom-controller-bootstrap.service` | **0** |
| `pct exec 9201 -- tee /tmp/felhom-swap-spike` | **0** |

**The critical question — does `*` span spaces? YES.** `docker inspect -f *` matches the full
`-f '<template-with-spaces-and-braces>' <container>` line, the `*` spanning the format string AND the
container across the space. Same for `docker image inspect *`. (sudo matches the trailing argument
string with `fnmatch`, and an unanchored `*` covers whitespace.) **No grant needs reshaping** — the
`-f` template can stay the rich multi-field string; it does not need to be flattened to a fixed
template.

### Negative controls — the grants stay SCOPED (not a blanket `pct exec`) — **PASS**

| Non-granted invocation | exit (want 1) |
|---|---|
| `pct exec 9201 -- docker rm -f felhom-controller` | **1** |
| `pct exec 9201 -- rm -rf /etc` | **1** |
| `pct exec 9201 -- bash -c 'echo pwned'` | **1** |
| `pct exec 9201 -- tee /etc/felhom-controller-image` (real file; scratch only granted `/tmp`) | **1** |

Arbitrary in-guest execution (`bash -c`, `rm`, `docker rm`) and writing the real image file are all
**denied**. The five narrow grants do NOT widen back into the general `pct exec * ` surface that this
work exists to avoid. (In the real grant, `tee /etc/felhom-controller-image` is a FIXED path — no
wildcard — so it is even tighter than the scratch `/tmp` form tested here.)

---

## Q3 — read compatibility on the live guest — **PASS**

Deployed unit `felhom-controller-bootstrap.service` →
`ExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh`. Its read line (verbatim):
```
9:  IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true)
10: [ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: …image missing"; exit 1; }
```
`$(cat)` strips trailing newlines, so the read is newline-agnostic. The currently-deployed file is
48 bytes ending `…0.89.0\n` (od-confirmed) — i.e. the live golden already stores image+`\n`, exactly
what a `tee`-written `image\n` produces. **A `tee`-written file is consumed identically to today's
`printf` write** — no bootstrap change needed.

---

## Go / No-go for Option A — **GO**

All three unproven mechanisms hold on the live host:
1. stdin → `sudo -n` → `pct exec` → `tee` writes exact bytes incl. the trailing newline (Q1).
2. The 5 narrow sudoers grants match every real invocation, `*` spans spaces, and they remain scoped
   — arbitrary exec stays denied (Q2 + negatives).
3. The deployed bootstrap's `$(cat)` read consumes a `tee`-written file identically (Q3).

**No vector needs reshaping before implementation.** Implementation notes for the impl task (not this
spike):
- `GuestExec` (`guestbind.go:119`) has no stdin path today — the impl needs a stdin-carrying variant
  (e.g. `GuestExecStdin(ctx, vmid, stdin io.Reader, args…)`) that sets `cmd.Stdin`; `writeImage`
  switches from `bash -c "printf…>"` to that variant piping the image into `tee <file>`.
- The 5 grants go into the real `/etc/sudoers.d/felhom-agent` (a new `FELHOM_CONTROLLERSWAP` alias) —
  with `tee /etc/felhom-controller-image` as a FIXED path (no wildcard). Add them to the v0.44.0
  capability manifest so the build-test covers them.
- The image ref is already strict-validated (`controllerImageRe`) before write — the stdin/`tee` path
  removes the shell entirely, so even that is now defence-in-depth rather than the only guard.

## Teardown — confirmed clean

`/etc/sudoers.d/felhom-agent-spike` removed; `/tmp/felhom-swap-spike` removed; `visudo -cf
/etc/sudoers` parses OK; remaining drop-ins `felhom-agent`, `README`, `zfs`; the real
`felhom-agent` grant is untouched (lxc-info grant still present). No secrets recorded (the image ref
is not a secret; no tokens were handled).

---

## IMPLEMENTED 2026-06-29 — felhom-agent v0.45.0

Option A shipped per this spike's GO. `writeImage` now pipes the image on stdin into an in-guest
`tee` (no `bash -c`); the 5 narrow grants live in the `FELHOM_CONTROLLERSWAP` sudoers alias + the
capability manifest (Critical). Live: agent reports `capabilities self-check ok=45/45`; all 5 grants
match (`sudo -n -l` exit 0) and the negative controls (`bash -c`, `tee /etc/passwd`, `docker rm -f`,
`rm -rf`) are denied on the live host. **Live swap (Scenario D) awaiting supervised run** (restarts the
controller). See `felhom-agent/REPORT.md` + CHANGELOG v0.45.0.
