Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
6.9 KiB
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 +
/tmpscratch 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 asfelhom-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}} |
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:
- stdin →
sudo -n→pct exec→teewrites exact bytes incl. the trailing newline (Q1). - The 5 narrow sudoers grants match every real invocation,
*spans spaces, and they remain scoped — arbitrary exec stays denied (Q2 + negatives). - The deployed bootstrap's
$(cat)read consumes atee-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 setscmd.Stdin;writeImageswitches frombash -c "printf…>"to that variant piping the image intotee <file>.- The 5 grants go into the real
/etc/sudoers.d/felhom-agent(a newFELHOM_CONTROLLERSWAPalias) — withtee /etc/felhom-controller-imageas 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/teepath 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.