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
This commit is contained in:
2026-10-05 12:13:42 +02:00
parent 0342c7bb57
commit fdd87178d2
8 changed files with 164 additions and 22 deletions
+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
+38 -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,21 @@ 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)
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")