Compare commits

...

5 Commits

Author SHA1 Message Date
admin e06ed97fa8 agent v0.146.1 REPORT
gates / gates (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 13:00:22 +02:00
admin e4b5cf9693 R-880: build-step-bundle.py — the transition bundle for a release whose bundle adds paths (an installed felhom-os-apply refuses unknown paths, R16)
gates / gates (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:23:55 +02:00
admin faa3cad92e agent v0.146.1 CHANGELOG (released)
gates / gates (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:14:07 +02:00
admin fdd87178d2 R-861 review fixes: the signed update hands the A/B wrapper a root-owned copy of the hashed bytes; mount units accept no Wants/Requires/Before and no continuation lines; the escrow read walks the path without following any symlink
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:13:42 +02:00
admin 0342c7bb57 agent v0.146.0 CHANGELOG (released)
gates / gates (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:01:25 +02:00
10 changed files with 408 additions and 32 deletions
+75
View File
@@ -1,3 +1,78 @@
## v0.146.1 — R-861 review fixes: the signed update flips a root-owned copy; no Wants=/continuations in mount units; the escrow read follows no symlink anywhere (2026-10-05)
Released by `scripts/release-agent.sh`: binary sha256 `badd6c9a2e40c8bfe856d2d1a203443b21b7eb92ecc35d6090ab44518d4d082a`,
config bundle sha256 `42333e969028867ad8142335e6c1bc4040eec231de0d8d330c2d4b2cf7bc3442`. **Supersedes v0.146.0, which
was released but never vouched or delivered to any box.** The same order applies: signed `agent_update` first, then the
signed `agent_config_update`.
**Delivery needs a STEP bundle (R-880, found while delivering).** An installed `felhom-os-apply` checks an incoming
bundle's paths against its OWN table (R16), so every box on the v0.145.0 bundle REFUSES the v0.146.1 bundle (it adds 4
paths). `scripts/build-step-bundle.py` builds the transition: the box's current bundle with ONLY `felhom-os-apply`
replaced (same paths — the old wrapper accepts it), published as bundle version `0.146.1-step1`; then the release's own
bundle. Order on a box: `agent_update` 0.146.1 → `agent_config_update` 0.146.1-step1 → `agent_config_update` 0.146.1.
Tests `StepBundle` (the R16 refusal reproduced; the step accepted; exactly one file changed). Tooling only — not in the
binary or the bundle.
A background security review of the v0.146.0 commit found three holes in the new code; each is fixed and red-proved
(`felhom.eu/documentation/audits/hub-safety-2026-10-05/partF/red-proof.txt`, S1–S3):
- **S1 — a race in the signed update.** `felhom-os-apply` hashed the agent's staged file and then let the A/B wrapper copy
it BY PATH; the agent owns that directory and could swap the file in between. Now the root step reads the file ONCE
(`read_staged_once`: O_NOFOLLOW, fstat, owner, size), hashes those bytes, writes them to a root-owned directory
(`/var/lib/felhom-os-apply/agent-update/`) and hands ONLY that copy to `felhom-selfupdate-guarded apply`, which now
refuses any other directory, a symlink, or a file not owned by root. Tests: `AgentUpdate` (+1),
`SelfupdateWrapperConfinement`.
- **S2 — an allowlist escape in `felhom-priv-apply`.** `[Unit]` accepted `Wants=`/`Requires=`/`Before=` naming any unit, so
a mount unit could start e.g. `reboot.target`. `[Unit]` now holds only `Description` and `After=local-fs-pre.target`
(what the renderers write), and any line ending in a backslash (a systemd continuation this parser would read
differently) is refused. Tests `test_U2_wants_starts_another_unit`, `test_U2_continuation_line`.
- **S3 — a path traversal in the escrow read.** `O_NOFOLLOW` guards only the last component; a symlinked DIRECTORY in the
agent's own state dir still redirected the root read. `readStagedNoFollow` now walks the path from `/` with
`openat(O_NOFOLLOW)` per component. Test `TestAttach_RefusesASymlinkedDirectory`.
## v0.146.0 — the agent's root grants narrowed: exact sudo patterns, a root content checker, fixed files from the bundle, the signed update checked as root (R-861) (2026-10-05)
Released by `scripts/release-agent.sh`: binary sha256 `b860af465076041e07f35fed1b12d64ae2b2985d8995f0ce167418d39c2b00d5`,
config bundle sha256 `161c737e523aa7910cf32ce41b83f989569bee55b8c5938e7211c92aef68548e`. **Order on a box: the signed
`agent_update` FIRST (the old bundle still grants the old flip), then the signed `agent_config_update`.** Between the
two (minutes) the new agent's checker calls are refused and retried; nothing is lost. After the bundle, an agent BELOW
0.146.0 cannot update itself on that box any more (the unsigned flip grant is gone) — deliver both together.
Design: `felhom.eu/documentation/architecture/03-host-agent.md` §3.1 (new). Measured before the change (real sudo
1.9.16, a throwaway container): the v0.145.0 sudoers let **23 of 29** attack command lines through; v0.146.0 lets
**0** through and still allows all **64** commands the agent's capability check uses.
- **Exact patterns.** A sudoers `*` in the arguments also matches spaces: `pct set [0-9]* -onboot 1` matched
`pct set 100 --dev0 /dev/sda -onboot 1` (a raw host disk for a guest), `mount --bind /mnt/*/felhom-data
/mnt/felhom-drives/*` matched a `..` path onto `/etc/sudoers.d`, `nft add element … *` took a chained `; flush
ruleset`. Every varying argument list is now a sudo regex (`^…$`): one value per slot, a fixed character set, no
`..`, no extra argument. `TestSudoersRefusesTheR861Injections` (29 attacks) + `TestManifestCoveredBySudoers`
(regex-aware now).
- **`felhom-priv-apply`** (new root wrapper, in the bundle). A systemd mount/automount unit, a dnsmasq drop-in, the
WireGuard config and the OOB sshd config + felhom-op key reach their root-read places only through it: fixed source,
fixed destination, CONTENT checked against what the agent's renderers write (no `[Service]`, `Where=` only
`/mnt/<name>` or `/mnt/felhom-drives/<name>` and equal to the unit name, no `bind`/`suid`; a network share must carry
`nosuid,nodev`; no `dhcp-script=`; no `PostUp=`; the sshd config only the one template with its Port). Its 30 tests
(`configs/test_felhom_priv_apply.py`) + Go contract tests feeding each renderer's real output
(`internal/privapplytest`). Pre-flight: every live file on both demo boxes reads OK.
- **NFS/SMB options gain `nosuid,nodev`** (a set-uid file on a server outside the box never acts on the host).
- **Fixed files from the bundle.** The guest pre-start hook (`/var/lib/vz/snippets/felhom-guest-hook.sh`, run as root at
every guest start) and the shared drive parent script + unit are bundle files now (byte-identical to the agent's
constants, pinned). The agent no longer installs them from `/tmp`; it checks them (`guesthook.SnippetReady`,
`ensureSharedParentBoot`) and only registers / enables.
- **The signed update is checked as root.** `felhom-os-apply` mode `agent_update` verifies the operator signature
(root-owned signers, this host, the window, the nonce), re-hashes the staged binary against the SIGNED sha, then runs
the A/B flip; `felhom-selfupdate-guarded apply` is no longer in the agent's sudoers. 7 tests (`AgentUpdate`).
- **The root escrow run reads no path from the agent's config.** As root it pins the PVE secret dir and the WireGuard
state dir to their defaults, refuses a storage id that is a path, and reads its two staged files without following a
symlink (`readStagedNoFollow`) — before, a symlink in the agent's own directory sealed any root file into the blob.
- **Not narrowed here (named in `03` §3.1):** `FELHOM_CONTROLLERSWAP` stays guest-scoped (a compromised agent can run a
chosen controller image in the guest — the household's data, not host root); `FELHOM_ESCROW` still hands the agent R
by design (the agent relays the ceremony); the mkfs / pbs-apply / backup-target wrappers keep a coarse argument and
their own checks.
- Red-proofs F1–F9: `felhom.eu/documentation/audits/hub-safety-2026-10-05/partF/red-proof.txt` (F1's first run did NOT
convict — the name rule masked it — and the test now uses the pair only the Where rule stops).
## v0.145.0 — the OS update repairs itself after a power cut; a short-session box gets restore-tested; "sent late" (R-876, R-874, R-875) (2026-10-05)
Released by `scripts/release-agent.sh`: binary sha256 `894da35c7b9e1ac78885690b78352b634e6831e7d99b321573c75d878db8886e`,
+49 -11
View File
@@ -1,14 +1,52 @@
# REPORT — agent v0.145.0 (2026-10-05, afternoon): the OS update repairs itself after a power cut
# REPORT — agent v0.146.0 / v0.146.1: the agent's root grants narrowed (R-861), the step bundle (R-880) — 2026-10-05
Brief: the 2026-10-05 catch-up brief (operator), Parts C (R-874, R-875) and D (R-876). Full session report:
`felhom.eu/REPORT-catchup-2026-10-05.md`. Architecture: `11-os-updates.md` §5.4.1, §8.4–8.5; `09` decisions 117–118.
Brief: "an open-items batch … and the agent permission fix (R-861) as its own Part" (Part F). Full session report:
`felhom.eu/REPORT-hub-safety-2026-10-05.md`. Design: `felhom.eu/documentation/architecture/03-host-agent.md` §3.1.
Evidence: `felhom.eu/documentation/audits/hub-safety-2026-10-05/partF/`, delivery `…/partH/`.
| Row | Fix | Proof |
|---|---|---|
| R-876 | the wrapper reads `dpkg --audit` AND the update journal in ONE call, repairs on either; belt: repair + retry once when apt says "interrupted" | 3 tests + 3 red-proofs; **live (operator's go): crash mid-unpack on demo-hp → the next pass `REPAIR … journal=1` → `DONE rc=0 upgraded=12`, nobody touched the box** |
| R-874 | the restore-test's first due-check 30 min after start | 2 tests + red-proof; live on demo-felhom: passed restore-test at start + 30 min |
| R-875 | a kept report's reason is neutral ("sent late …") | test + red-proof |
## Baseline and commits
Released by `scripts/release-agent.sh`: v0.145.0 (`894da35c…`, bundle `78c00adc…`), verified by download; signed
`agent_update` + `agent_config_update` to demo-hp, demo-felhom, tester-1 (71/71 after each bundle); vouched with
golden 0.295.0, min_agent 0.131.0. `go test ./...` rc 0, Python suites OK, `agent_gates.py` OK.
- Baseline `61345790ed` (v0.145.0).
- `6ab1e7c` R-861 narrowing → released **v0.146.0** (binary `b860af46…`, bundle `161c737e…`) — **never vouched, never
delivered**: a background security review of that commit found three holes.
- `fdd8717` the review fixes → released **v0.146.1** (binary `badd6c9a2e40c8bfe856d2d1a203443b21b7eb92ecc35d6090ab44518d4d082a`,
bundle `42333e969028867ad8142335e6c1bc4040eec231de0d8d330c2d4b2cf7bc3442`). Two releases in one session, against "one
release per repo" — the first one was unsafe to deliver.
- `e4b5cf9` `scripts/build-step-bundle.py` (R-880, tooling). Step bundle `0.146.1-step1`, sha `8482851e…`.
## What changed
- **Exact sudo patterns** for every varying argument list (sudo regex `^…$`). Measured with real sudo 1.9.16: the
v0.145.0 sudoers allowed **23 of 29** attack lines; v0.146.1 allows **0** and still allows all **64** commands the
capability check uses (container, and live on both demo boxes).
- **`felhom-priv-apply`** (new root wrapper in the bundle) installs mount units, dnsmasq drop-ins, the WireGuard config
and the OOB sshd config + key only after checking the CONTENT against the agent's own renderers.
- **Fixed bundle files:** the guest pre-start hook and the shared drive parent (script + unit); the agent only checks
and registers / enables.
- **The signed update is checked as root:** `felhom-os-apply` mode `agent_update` (signature, host, window, nonce; the
staged bytes read once, hashed, copied to a root-owned dir); `felhom-selfupdate-guarded apply` left the agent's
sudoers and accepts only that root-owned dir.
- **The root escrow run** pins its paths, refuses a storage id that is a path, and reads its staged files by walking
the path with `openat(O_NOFOLLOW)`.
- **NFS/SMB options** gain `nosuid,nodev`.
## Tests
`go build/vet/test ./...` green; `configs/test_felhom_priv_apply.py` 32 tests, `test_felhom_config_bundle.py` (incl.
`AgentUpdate`, `SelfupdateWrapperConfinement`, `StepBundle`), `test_felhom_os_apply.py` green; Go contract tests feed each
renderer's real output to the checker (`internal/privapplytest`); `TestSudoersRefusesTheR861Injections`,
`TestManifestCoveredBySudoers` (regex-aware). Red-proofs F1–F9 and S1–S3: `partF/red-proof.txt` (F1's first run did NOT
convict — masked by the name rule — and the test was strengthened; F3's first run errored rather than failed — the test
now asserts cleanly).
## Delivered (signed jobs, key felhom-op-1)
Per box: `agent_update` 0.146.1 → `agent_config_update` 0.146.1-step1 → `agent_config_update` 0.146.1. See the session
report §5 for each box's result. Tester 2: offline (DOWN), nothing sent.
## Not done / open
- R-861 stays open, narrowed to three named residuals (`03` §3.1): the controller-swap image ref is guest-scoped; the
felhom-op SSH key is hub-delivered, unsigned (felhom-op's sudo is scoped); the escrow ceremony hands the agent R by
design.
- R-881: the installer's uninstall does not remove `felhom-priv-apply`.
+43 -7
View File
@@ -113,6 +113,9 @@ CRASH_GUARD_STATE = "/var/lib/felhom-crash-guard/state.json"
SELFUPDATE_OP = "agent_update"
SELFUPDATE_DIR = "/var/lib/felhom-agent/selfupdate"
SELFUPDATE_WRAPPER = "/usr/local/sbin/felhom-selfupdate-guarded"
# The verified bytes are written HERE (a root-owned directory the agent cannot write) and only this copy reaches the A/B wrapper — never the agent's file.
SELFUPDATE_ROOT_DIR = "/var/lib/felhom-os-apply/agent-update"
SELFUPDATE_MAX_BYTES = 256 * 1024 * 1024
BUNDLE_FORMAT = 1
BUNDLE_OP = "agent_config_update"
BUNDLE_RECORD = "/etc/felhom/config-bundle.json" # what the box runs (0644 root; the non-root agent reports it)
@@ -289,6 +292,30 @@ class Runner:
except Exception:
pass
def read_staged_once(self, path, owner_uid, limit):
"""R-861: read a file the AGENT staged ONCE, as root, safely: O_NOFOLLOW (the last component may not be a
symlink), fstat on the opened fd (a regular file owned by owner_uid), at most `limit` bytes. The caller hashes
and uses exactly these bytes — never the path again (the agent owns the directory and could swap the file)."""
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != owner_uid:
raise Refused("R19", f"{path} is not a regular file owned by {AGENT_USER}")
if st.st_size > limit:
raise Refused("R19", f"{path} is larger than {limit} bytes")
chunks, n = [], 0
while True:
b = os.read(fd, 1 << 20)
if not b:
break
chunks.append(b)
n += len(b)
if n > limit:
raise Refused("R19", f"{path} grew past {limit} bytes while it was read")
return b"".join(chunks)
finally:
os.close(fd)
# ---------- host files, for the config bundle (R-840). Tests replace these with an in-memory tree. ----------
def read_bytes(self, path):
with open(path, "rb") as f:
@@ -547,17 +574,26 @@ class Apply:
staged = os.path.join(SELFUPDATE_DIR, "felhom-agent-" + ver)
if plan.get("staged") != staged:
raise Refused("R19", f"the staged binary must be {staged}, got {plan.get('staged')!r}")
# ONE read, then never the agent's path again: hash exactly these bytes and hand the A/B wrapper a ROOT-OWNED
# copy of them. Hashing the agent's file and then letting the wrapper copy it by path was a race — the agent owns
# that directory and could swap the file between the check and the copy (found by review 2026-10-05).
try:
st = self.r.stat(staged)
data = self.r.read_staged_once(staged, self.r.agent_uid(), SELFUPDATE_MAX_BYTES)
except OSError as e:
raise Refused("R19", f"cannot stat the staged binary: {e}")
if not stat.S_ISREG(st.st_mode) or st.st_uid != self.r.agent_uid():
raise Refused("R19", "the staged binary is not a regular file owned by the agent")
got = sha256_hex(self.r.read_bytes(staged))
raise Refused("R19", f"cannot read the staged binary: {e}")
got = sha256_hex(data)
if got != sha:
raise Refused("R19", f"the staged binary's sha256 {got[:16]}… is not the signed {sha[:16]}…")
self.r.log(f"os-apply: AGENT-UPDATE signed by the operator: version={ver} sha={sha[:16]} — handing to the A/B wrapper")
rc, out, err = self.r.host([SELFUPDATE_WRAPPER, "apply", staged, sha], 120)
root_copy = os.path.join(SELFUPDATE_ROOT_DIR, "felhom-agent-" + ver)
self.r.put_file(root_copy, data, 0o755)
self.r.log(f"os-apply: AGENT-UPDATE signed by the operator: version={ver} sha={sha[:16]} — handing the root copy to the A/B wrapper")
try:
rc, out, err = self.r.host([SELFUPDATE_WRAPPER, "apply", root_copy, sha], 120)
finally:
try:
self.r.remove(root_copy)
except OSError:
pass
self.report["agent_update"] = {"version": ver, "sha256": sha, "wrapper_rc": rc,
"wrapper": (out + err).strip()[-300:]}
if rc != 0:
+10 -5
View File
@@ -55,7 +55,6 @@ NET_TYPES = {"nfs", "nfs4", "cifs"}
# Options that turn a device mount into something else, or let set-uid/device files act on the host.
FORBIDDEN_OPTS = {"bind", "rbind", "move", "rmove", "remount", "suid", "dev", "user", "users", "owner", "group",
"x-mount.mkdir", "helper"}
UNIT_TOKEN_RE = re.compile(r"^[A-Za-z0-9@_.\\:-]+$")
DESC_RE = re.compile(r"^[^\x00-\x1f\x7f]{0,200}$")
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$")
KEY_LINE_RE = re.compile(r"^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com) "
@@ -164,6 +163,10 @@ def parse_ini(text, what):
sections, cur = {}, None
for n, raw in enumerate(text.split("\n"), 1):
line = raw.strip()
if line.endswith("\\"):
# systemd joins a line ending in a backslash with the next one; this parser does not. Refused, so the two
# can never read the same bytes differently (review 2026-10-05).
raise Refused("U2", f"{what}: line {n} ends with a backslash (a continuation)")
if not line or line.startswith("#") or line.startswith(";"):
continue
m = re.match(r"^\[([A-Za-z]+)\]$", line)
@@ -190,7 +193,10 @@ def check_unit(name, text):
kind = "automount" if name.endswith(".automount") else "mount"
s = parse_ini(text, name)
body = "Automount" if kind == "automount" else "Mount"
allowed = {"Unit": {"Description", "After", "Before", "Wants", "Requires"},
# [Unit] holds ONLY what the renderers write: Description, and After=local-fs-pre.target on a local mount. A
# Wants=/Requires=/Before= naming any unit would start it with the mount (Wants=reboot.target — found by review
# 2026-10-05), so none of them is accepted.
allowed = {"Unit": {"Description", "After"},
body: {"Where", "TimeoutIdleSec"} if kind == "automount" else {"What", "Where", "Type", "Options"},
"Install": {"WantedBy"}}
for sec, keys in s.items():
@@ -202,9 +208,8 @@ def check_unit(name, text):
u = s.get("Unit", {})
if not DESC_RE.match(u.get("Description", "")):
raise Refused("U2", f"{name}: Description has control characters")
for k in ("After", "Before", "Wants", "Requires"):
if k in u and not all(UNIT_TOKEN_RE.match(t) for t in u[k].split()):
raise Refused("U2", f"{name}: {k}= names something that is not a unit")
if "After" in u and u["After"] != "local-fs-pre.target":
raise Refused("U2", f"{name}: After= may only be local-fs-pre.target")
inst = s.get("Install", {})
if inst and inst.get("WantedBy") != "multi-user.target":
raise Refused("U2", f"{name}: WantedBy must be multi-user.target")
+11 -3
View File
@@ -24,6 +24,11 @@ set -u
BIN=/usr/local/bin/felhom-agent
PREV=$BIN.prev
STAGING=/var/lib/felhom-agent/selfupdate
# R-861 (agent v0.146.1): `apply` takes ONLY the root-owned copy felhom-os-apply writes after it has verified the
# operator's signature and hashed exactly those bytes (mode agent_update). The agent cannot call `apply` any more (it
# left the sudoers), and the agent's own staging dir is no longer accepted: a file in a directory the agent owns can be
# swapped between this script's sha check and its copy.
ROOT_STAGING=/var/lib/felhom-os-apply/agent-update
PENDING=$STAGING/pending.json
UNIT=felhom-agent.service
@@ -42,11 +47,14 @@ apply)
log "refusing apply: usage: apply <staged> <sha256>"
exit 2
fi
# Root-side path confinement: the staged binary MUST live in the agent's staging dir.
# Root-side path confinement: the staged binary MUST be felhom-os-apply's root-owned copy (R-861).
case "$staged" in
"$STAGING"/*) ;;
*) log "refusing apply: staged path outside $STAGING: $staged"; exit 1 ;;
"$ROOT_STAGING"/*) ;;
*) log "refusing apply: staged path outside $ROOT_STAGING: $staged"; exit 1 ;;
esac
if [ -L "$staged" ] || [ "$(stat -c %u "$staged" 2>/dev/null)" != "0" ]; then
log "refusing apply: $staged is a symlink or not root-owned"; exit 1
fi
case "$staged" in
*..*) log "refusing apply: staged path contains '..'"; exit 1 ;;
esac
+100 -1
View File
@@ -94,6 +94,14 @@ class Box:
raise OSError("no such file")
return self.files[p]
def read_staged_once(self, p, owner_uid, limit):
if p not in self.files:
raise OSError("no such file")
if self.uids[p] != owner_uid:
raise osapply.Refused("R19", f"{p} is not a regular file owned by felhom-agent")
self.staged_reads = getattr(self, "staged_reads", 0) + 1
return self.files[p]
def stat(self, p):
if p not in self.files:
raise OSError("no such file")
@@ -577,10 +585,23 @@ class AgentUpdate(unittest.TestCase):
box = update_box(update_job(sha))
rc, rep = run(box)
self.assertEqual(rc, 0, rep)
self.assertEqual(wrapper_calls(box), [[osapply.SELFUPDATE_WRAPPER, "apply", STAGED, sha]])
root_copy = osapply.SELFUPDATE_ROOT_DIR + "/felhom-agent-0.146.0"
# the wrapper gets the ROOT-OWNED copy of the bytes that were hashed — never the agent's path (review 2026-10-05)
self.assertEqual(wrapper_calls(box), [[osapply.SELFUPDATE_WRAPPER, "apply", root_copy, sha]])
self.assertIn(root_copy, box.writes)
self.assertNotIn(root_copy, box.files, "the root copy is removed after the flip")
self.assertEqual(box.staged_reads, 1, "the agent's file is read exactly once")
self.assertIn("u1", box.nonces)
self.assertEqual(rep["agent_update"]["version"], "0.146.0")
def test_a_staged_file_the_agent_does_not_own_is_refused(self):
sha = hashlib.sha256(NEW_BIN).hexdigest()
box = update_box(update_job(sha))
box.uids[STAGED] = 0
rc, rep = run(box)
self.assertEqual((rc, rep["refused"]["code"]), (2, "R19"), rep)
self.assertEqual(wrapper_calls(box), [])
def test_a_bad_signature_never_reaches_the_wrapper(self):
sha = hashlib.sha256(NEW_BIN).hexdigest()
box = update_box(update_job(sha))
@@ -630,5 +651,83 @@ class AgentUpdate(unittest.TestCase):
self.assertNotIn("u1", box.nonces)
class SelfupdateWrapperConfinement(unittest.TestCase):
"""R-861 (v0.146.1): the A/B wrapper takes only felhom-os-apply's root-owned copy, never the agent's staging dir
(a file there can be swapped between the wrapper's sha check and its copy). The path check runs before anything
is touched, so the real script can be run here unprivileged.
RED-PROOF: point ROOT_STAGING back at /var/lib/felhom-agent/selfupdate → this fails (the path is accepted and the
script goes on to `staged file missing`)."""
def test_the_agents_staging_dir_is_refused(self):
import subprocess
sha = "0" * 64
p = subprocess.run(["sh", str(HERE / "felhom-selfupdate-guarded"), "apply",
"/var/lib/felhom-agent/selfupdate/felhom-agent-0.146.1", sha], capture_output=True, text=True)
self.assertEqual(p.returncode, 1, p.stderr)
self.assertIn("outside /var/lib/felhom-os-apply/agent-update", p.stderr)
_sb = importlib.machinery.SourceFileLoader("stepbuild", str(REPO / "scripts" / "build-step-bundle.py"))
_ss = importlib.util.spec_from_loader("stepbuild", _sb)
stepbuild = importlib.util.module_from_spec(_ss)
_sb.exec_module(stepbuild)
NEW_IN_0146 = {"/usr/local/sbin/felhom-priv-apply", "/var/lib/vz/snippets/felhom-guest-hook.sh",
"/usr/local/sbin/felhom-shared-parent.sh", "/etc/systemd/system/felhom-shared-parent.service"}
class StepBundle(unittest.TestCase):
"""R-880 (agent v0.146.1): an INSTALLED wrapper checks an incoming bundle's paths against its OWN table (R16), so a
release that adds paths needs a step bundle: the boxes' current bundle with only felhom-os-apply replaced.
RED-PROOF: deliver the full bundle to the old table → R16 (test_the_full_bundle_is_refused_by_an_old_table)."""
def old_world(self):
"""The base bundle an older wrapper (no R-861 paths) installed, and that wrapper's table."""
full = json.loads(builder.build("0.145.0"))
full["files"] = [e for e in full["files"] if e["path"] not in NEW_IN_0146]
old_wrapper = b'# the v0.145.0 wrapper stands in here\nBUNDLE_OP = "agent_config_update"\n'
for e in full["files"]:
if e["path"] == "/usr/local/sbin/felhom-os-apply":
e["content_b64"], e["sha256"] = base64.b64encode(old_wrapper).decode(), hashlib.sha256(old_wrapper).hexdigest()
base = (json.dumps(full, indent=1, sort_keys=True) + "\n").encode()
old_dests = {k: v for k, v in osapply.BUNDLE_DESTS.items() if k not in NEW_IN_0146}
return base, old_dests
def parse_with_table(self, data, dests, version):
saved = osapply.BUNDLE_DESTS
osapply.BUNDLE_DESTS = dests
try:
return osapply.Bundle(osapply.Apply(Box(b"{}", None), "")).parse(data, hashlib.sha256(data).hexdigest(), version)
finally:
osapply.BUNDLE_DESTS = saved
def test_the_full_bundle_is_refused_by_an_old_table(self):
_, old_dests = self.old_world()
full = builder.build("0.146.1")
with self.assertRaises(osapply.Refused) as cm:
self.parse_with_table(full, old_dests, "0.146.1")
self.assertEqual(cm.exception.code, "R16")
def test_the_step_bundle_is_accepted_by_the_old_table_and_changes_only_the_wrapper(self):
base, old_dests = self.old_world()
new_wrapper = (HERE / "felhom-os-apply").read_bytes()
step = stepbuild.build_step(base, "0.146.1-step1", new_wrapper)
ver, files = self.parse_with_table(step, old_dests, "0.146.1-step1")
self.assertEqual(ver, "0.146.1-step1")
b, s_ = json.loads(base), json.loads(step)
self.assertEqual(sorted(e["path"] for e in b["files"]), sorted(e["path"] for e in s_["files"]), "the paths must not change")
changed = [e["path"] for e, f in zip(sorted(b["files"], key=lambda x: x["path"]), sorted(s_["files"], key=lambda x: x["path"]))
if e != f]
self.assertEqual(changed, ["/usr/local/sbin/felhom-os-apply"], "exactly the wrapper changes")
installed = dict((d, c) for d, c, *_ in files)
self.assertEqual(installed["/usr/local/sbin/felhom-os-apply"], new_wrapper)
# and the NEW wrapper (now installed) knows every path the release's full bundle names
self.assertTrue({e["path"] for e in json.loads(builder.build("0.146.1"))["files"]} <= set(osapply.BUNDLE_DESTS))
def test_a_step_version_must_carry_a_suffix(self):
base, _ = self.old_world()
with self.assertRaises(SystemExit):
stepbuild.build_step(base, "0.146.1", b"x")
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -204,6 +204,17 @@ class Refuses(unittest.TestCase):
def test_U2_service_section(self):
self.refused(*self.unit(LOCAL_UNIT + "\n[Service]\nExecStart=/bin/sh -c id\n"), "U2")
def test_U2_wants_starts_another_unit(self): # review 2026-10-05: Wants=reboot.target would reboot the host
for extra in ("Wants=reboot.target", "Requires=felhom-agent-rollback.service", "Before=pve-guests.service"):
self.refused(*self.unit(LOCAL_UNIT.replace("After=local-fs-pre.target", "After=local-fs-pre.target\n" + extra)), "U2")
self.refused(*self.unit(LOCAL_UNIT.replace("After=local-fs-pre.target", "After=poweroff.target")), "U2")
def test_U2_continuation_line(self):
t = LOCAL_UNIT.replace("Description=Felhom storage mount 91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae",
"Description=Felhom storage mount \\")
self.refused(*self.unit(t), "U2")
self.refused(*self.unit(LOCAL_UNIT.replace("# Managed by felhom-agent", "# comment \\\n# Managed by felhom-agent")), "U2")
def test_U2_unknown_key(self):
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=ext4\nDirectoryMode=0777")), "U2")
+25 -5
View File
@@ -184,15 +184,35 @@ func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string)
}
// readStagedNoFollow reads a file the AGENT staged, for the escrow ceremony that runs as ROOT (FELHOM_ESCROW). R-861
// (agent v0.146.0): both files live in the agent's own directory, so a compromised agent could put a SYMLINK there
// (to any root-only file) and the root ceremony would seal that file into the blob and hand the agent R — a root file
// read. So: no symlink (O_NOFOLLOW), a regular file, at most 4 KiB. A missing file keeps its os.IsNotExist meaning.
// Pinned by TestAttach_RefusesASymlink.
// (agent v0.146.0/0.146.1): both files live in the agent's own directory, so a compromised agent could put a SYMLINK
// there — at the file OR at any directory on the way (review 2026-10-05) — to a root-only file, and the root ceremony
// would seal that file into the blob and hand the agent R. So the path is walked from "/" one component at a time with
// openat(O_NOFOLLOW): no symlink anywhere, the last a regular file of at most 4 KiB. Once a directory is open, renaming
// it does not redirect the walk. A missing file keeps its os.IsNotExist meaning. Pinned by TestAttach_RefusesASymlink*.
func readStagedNoFollow(path string) ([]byte, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if !filepath.IsAbs(path) {
return nil, fmt.Errorf("%s is not an absolute path", path)
}
clean := filepath.Clean(path)
parts := strings.Split(strings.TrimPrefix(clean, "/"), "/")
dirfd, err := syscall.Open("/", syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
for i, part := range parts {
last := i == len(parts)-1
flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
if !last {
flags |= syscall.O_DIRECTORY
}
fd, err := syscall.Openat(dirfd, part, flags, 0)
syscall.Close(dirfd)
if err != nil {
return nil, &os.PathError{Op: "open", Path: clean, Err: err}
}
dirfd = fd
}
f := os.NewFile(uintptr(dirfd), clean)
defer f.Close()
fi, err := f.Stat()
if err != nil {
+25
View File
@@ -36,3 +36,28 @@ func TestAttach_RefusesASymlink(t *testing.T) {
t.Fatalf("control: a missing file must stay a clean no-attach: %v %v", ok, err)
}
}
// Review 2026-10-05: a symlinked DIRECTORY on the way must stop the read too (O_NOFOLLOW alone guards only the last
// component). RED-PROOF: open the full path with O_NOFOLLOW only → this fails.
func TestAttach_RefusesASymlinkedDirectory(t *testing.T) {
d := t.TempDir()
secretDir := filepath.Join(d, "root-only-dir")
_ = os.Mkdir(secretDir, 0o700)
_ = os.WriteFile(filepath.Join(secretDir, "private.key"), []byte("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n"), 0o600)
agentDir := filepath.Join(d, "agent")
_ = os.Mkdir(agentDir, 0o700)
if err := os.Symlink(secretDir, filepath.Join(agentDir, "wg")); err != nil {
t.Fatal(err)
}
var b IdentityBundle
if ok, err := AttachWGKey(&b, filepath.Join(agentDir, "wg", "private.key")); err == nil || ok || b.WGPrivateKey != "" {
t.Fatalf("a key behind a symlinked directory was read: ok=%v err=%v", ok, err)
}
// control: the same key under a REAL directory is read
_ = os.Remove(filepath.Join(agentDir, "wg"))
_ = os.Mkdir(filepath.Join(agentDir, "wg"), 0o700)
_ = os.WriteFile(filepath.Join(agentDir, "wg", "private.key"), []byte("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n"), 0o600)
if ok, err := AttachWGKey(&b, filepath.Join(agentDir, "wg", "private.key")); err != nil || !ok {
t.Fatalf("control: a key under a real directory was not read: %v %v", ok, err)
}
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""build-step-bundle.py — the TRANSITION bundle for a release whose bundle ADDS a path (R-880, `11` §5.4.2).
Usage: python3 scripts/build-step-bundle.py <base-bundle.json> <step-version> <out.json> (prints the sha256)
WHY. A box's INSTALLED felhom-os-apply checks every path of an incoming bundle against ITS OWN table (rule R16) — so a
bundle that adds a path (v0.146.1 adds felhom-priv-apply, the guest hook and the shared-parent files, R-861) is refused
by every box still running an older wrapper. The fix is a step: first a bundle the old wrapper accepts that brings ONLY
the new felhom-os-apply (the new table), then the release's own bundle, which the new wrapper accepts.
WHAT IT BUILDS. <base-bundle.json> is the bundle the boxes run now (download it from the package registry, e.g.
felhom-agent/0.145.0/felhom-config-bundle.json, and check its sha against the hub's record). The step bundle is that
bundle with EXACTLY ONE change: the /usr/local/sbin/felhom-os-apply entry's content is replaced by configs/felhom-os-apply
(this tree). Same paths, same modes, same checks, every other byte identical; agent_version is <step-version> (e.g.
0.146.1-step1). The old wrapper verifies it like any bundle (signature, sha, R16, content checks, self-check of the new
wrapper) — nothing about the trust route changes.
Pinned by configs/test_felhom_config_bundle.py (StepBundle): same paths as the base, only the wrapper differs, the
new wrapper's table is a superset of the base's paths.
"""
import base64
import hashlib
import json
import pathlib
import re
import sys
REPO = pathlib.Path(__file__).resolve().parent.parent
OSAPPLY_DEST = "/usr/local/sbin/felhom-os-apply"
def build_step(base_bytes, version, new_osapply_bytes):
if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.]+$", version):
raise SystemExit(f"build-step-bundle: {version!r} must be a semver with a step suffix, e.g. 0.146.1-step1")
base = json.loads(base_bytes)
files = base.get("files")
if base.get("format") != 1 or not isinstance(files, list):
raise SystemExit("build-step-bundle: the base is not a format-1 bundle")
hit = [e for e in files if e.get("path") == OSAPPLY_DEST]
if len(hit) != 1:
raise SystemExit(f"build-step-bundle: the base has {len(hit)} {OSAPPLY_DEST} entries, want exactly 1")
hit[0]["content_b64"] = base64.b64encode(new_osapply_bytes).decode()
hit[0]["sha256"] = hashlib.sha256(new_osapply_bytes).hexdigest()
base["agent_version"] = version
return (json.dumps(base, indent=1, sort_keys=True) + "\n").encode()
def main(argv):
if len(argv) != 4:
print(__doc__, file=sys.stderr)
return 2
data = build_step(pathlib.Path(argv[1]).read_bytes(), argv[2], (REPO / "configs" / "felhom-os-apply").read_bytes())
pathlib.Path(argv[3]).write_bytes(data)
print(hashlib.sha256(data).hexdigest())
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))