R-812 option A: the Proxmox package lane (layer pve) + the /etc/pve write gate

The wrapper gains layer "pve" (slow lane): the host's Proxmox userspace
packages only — origin "Proxmox Debian Repository", never a kernel / boot /
firmware / microcode name (R14), no removal, no undo, a new package only from
an allow-list; authority = a signed os_pve_step or the root-owned ring-0 mark.
The night leg runs it in ring 0 after a healthy host step; ring 1 only by a
signed job (PVEStepExecutor). While it runs, the agent's own /etc/pve writes
(every non-GET API call, pct config verbs, pvesm, pveum, felhom-pbs-apply)
wait on internal/pvegate. Health = the host rule + unchanged container ids +
pveversion reads the installed pve-manager.

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-07 10:07:41 +02:00
parent ac90169a5d
commit ce1a4b4758
17 changed files with 990 additions and 41 deletions
+66 -16
View File
@@ -21,6 +21,11 @@
# or, for an unsigned ring-0 step, the root-owned TRUST_FILE saying `"ring0_slow_lane": true` (set by hand on the demo
# boxes only). The agent's own config is NOT trusted for either: the agent can write it. A Docker step also needs
# `live-restore` ON (R15) — without it every container restarts.
# Agent v0.151.0 (R-812 option A, `09` §3 decision 163) adds the layer "pve": the HOST's Proxmox USERSPACE packages,
# lane "slow" only, origin "Proxmox Debian Repository" only, never a kernel / boot / firmware / microcode name (R14 —
# the kernel is R-836's lane), no removal, no undo, a NEW package only from PVE_NEW_ALLOW, an appliance only (R12), and
# the same authority as the Docker step (R3: a signed `os_pve_step`, or the root-owned ring-0 mark). Select
# "pending-pve" (ring 0): every installed Proxmox-origin package with a pending upgrade, minus HOST_SLOW_RE.
#
# Modes (plan field "mode"):
# inventory `apt-get update`, then report what is installed (with origin), what is pending, and health.
@@ -100,6 +105,12 @@ HOST_SERVICES = ["pveproxy", "pvedaemon", "pvestatd", "pve-cluster", "felhom-age
DOCKER_NAMES = ("containerd.io", "docker-buildx-plugin", "docker-ce", "docker-ce-cli", "docker-ce-rootless-extras",
"docker-compose-plugin")
DOCKER_ORIGIN = "Docker CE"
# The Proxmox package lane (R-812 option A): the origin apt prints for download.proxmox.com, and the ONLY new packages
# a pve step may add (measured on demo-felhom 2026-10-07: a full upgrade adds proxmox-firewall-data and the kernel;
# the kernel is refused by R14 whatever this list says).
PVE_ORIGIN = "Proxmox Debian Repository"
PVE_NEW_ALLOW = ("proxmox-firewall-data",)
PVE_SIGNED_OP = "os_pve_step"
# ROOT-OWNED trust anchors (the installer writes them; the demo boxes got them by hand, R-840). Never the agent's config.
TRUST_FILE = "/etc/felhom/os-trust.json" # {"host_id": "...", "ring0_slow_lane": false}
TRUST_SIGNERS = "/etc/felhom/operator-signers" # ssh allowed_signers: <key_id> namespaces="felhom-op-v1" <key>
@@ -434,12 +445,14 @@ class Apply:
raise Refused("R11", "bundle is a host-layer mode")
return mode, "host", 0, "bundle"
layer = plan.get("layer")
if layer not in ("guest", "host", "docker"):
raise Refused("R12", f"layer {layer!r} is not guest, host or docker")
if layer not in ("guest", "host", "docker", "pve"):
raise Refused("R12", f"layer {layer!r} is not guest, host, docker or pve")
lane = plan.get("lane", "fast")
if layer == "docker" and lane != "slow":
raise Refused("R3", "the Docker engine is the slow lane (`11` §5.8); a fast-lane Docker plan is refused")
if layer != "docker" and lane != "fast":
if layer == "pve" and lane != "slow":
raise Refused("R3", "the Proxmox packages are the slow lane (`11` §5.10); a fast-lane pve plan is refused")
if layer not in ("docker", "pve") and lane != "fast":
raise Refused("R3", f"the {layer} layer has no slow lane in this release (kernel, Proxmox: `11` §8 step 6)")
if mode == "facts" and layer != "host":
raise Refused("R11", "facts is a host-layer mode (it reads the host and the guest)")
@@ -447,7 +460,7 @@ class Apply:
raise Refused("R11", "live-restore-on is a guest-layer mode")
if mode == "oom-check" and layer != "docker":
raise Refused("R11", "oom-check is a docker-layer mode (it checks the guest's Docker engine)")
if plan.get("undo") and layer != "docker":
if plan.get("undo") and layer != "docker": # the pve layer has no undo in this release (R-812 option A)
raise Refused("R5", "an undo (downgrade) exists only for the Docker layer, inside a signed job")
vmid = plan.get("vmid")
if not isinstance(vmid, int) or isinstance(vmid, bool) or vmid <= 0:
@@ -458,17 +471,19 @@ class Apply:
if plan.get("allow_new"):
raise Refused("R6", "allow_new is a slow-lane field; the fast lane never adds a package")
select = plan.get("select", "listed")
if select not in ("listed", "pending-fast", "pending-docker"):
if select not in ("listed", "pending-fast", "pending-docker", "pending-pve"):
raise Refused("R11", f"unknown select {select!r}")
if (select == "pending-docker") != (layer == "docker" and select != "listed"):
if select == "pending-docker" or layer == "docker":
raise Refused("R11", f"select {select!r} does not fit layer {layer!r}")
if (select == "pending-pve") != (layer == "pve" and select != "listed"):
raise Refused("R11", f"select {select!r} does not fit layer {layer!r}")
pk = plan.get("packages", [])
if not isinstance(pk, list):
raise Refused("R11", "packages must be a list")
if mode == "apply" and select == "listed" and not pk:
raise Refused("R11", "packages must be a non-empty list in apply mode (select listed)")
if select in ("pending-fast", "pending-docker") and pk:
if select in ("pending-fast", "pending-docker", "pending-pve") and pk:
raise Refused("R11", f"select {select} takes no package list")
seen = set()
for e in pk:
@@ -488,6 +503,12 @@ class Apply:
continue
if n in DOCKER_NAMES:
raise Refused("R2", f"{n} is a Docker package — the slow lane (`11` §5.8), never in a {layer} plan")
if layer == "pve":
if o != PVE_ORIGIN:
raise Refused("R2", f"{n}: origin {o!r} is not {PVE_ORIGIN!r} (the pve layer)")
if HOST_SLOW_RE.match(n):
raise Refused("R14", f"{n} is a kernel / boot / firmware package — never the pve lane (R-836)")
continue
if o not in FAST_ORIGINS:
raise Refused("R2", f"{n}: origin {o!r} is not Debian / Debian-Security (the fast lane, `11` C3)")
if layer == "host" and HOST_SLOW_RE.match(n):
@@ -630,12 +651,13 @@ class Apply:
now = self.r.now()
self.r.write_nonces({k: v for k, v in seen.items() if v > now})
def docker_authority(self, plan):
"""R3 for the docker layer: returns (who, undo). A signed job binds the EXACT package list and the undo flag."""
def docker_authority(self, plan, op_name=SIGNED_OP):
"""R3 for the docker (and, op_name os_pve_step, the pve) layer: returns (who, undo). A signed job binds the EXACT
package list and the undo flag."""
trust = self.load_trust()
signed = plan.get("signed")
if signed:
params = self.verify_signed(signed, trust)
params = self.verify_signed(signed, trust, op_name=op_name)
want = sorted(f"{e.get('name')}={e.get('version')}" for e in params.get("packages") or [])
got = sorted(f"{e['name']}={e['version']}" for e in plan.get("packages", []))
if not want or want != got:
@@ -649,7 +671,7 @@ class Apply:
raise Refused("R3", "an undo (downgrade) needs a signed operator job")
if trust.get("ring0_slow_lane") is True:
return "ring0", False
raise Refused("R3", "a Docker step needs a signed operator job (ring 1) or this box's root-owned ring-0 mark")
raise Refused("R3", f"a {self.layer} slow-lane step needs a signed operator job (ring 1) or this box's root-owned ring-0 mark")
def live_restore(self):
rc, out, _ = self.g(["docker", "info", "--format", "{{.LiveRestoreEnabled}}"], timeout=60)
@@ -795,8 +817,8 @@ class Apply:
# ---------- target helpers ----------
def x(self, argv, timeout=1800):
"""Run in the TARGET layer: the guest via pct exec, or the host directly."""
if self.layer == "host":
"""Run in the TARGET layer: the guest via pct exec, or the host directly (host and pve)."""
if self.layer in ("host", "pve"):
return self.r.host(argv, timeout)
return self.r.guest(self.vmid, argv, timeout) # guest and docker both live in the customer guest
@@ -891,7 +913,7 @@ class Apply:
def restart_needed(self):
"""Processes still mapping deleted files, OUTSIDE containers (C11). Guest: outside docker; host: outside the
LXC guests (the host's /proc shows guest processes too)."""
skip = RESTART_SKIP_CGROUP["host" if self.layer == "host" else "guest"]
skip = RESTART_SKIP_CGROUP["host" if self.layer in ("host", "pve") else "guest"]
script = ('for p in /proc/[0-9]*; do grep -q "(deleted)" $p/maps 2>/dev/null || continue; '
'grep -q "%s" $p/cgroup 2>/dev/null && continue; echo "${p#/proc/} $(cat $p/comm 2>/dev/null)"; done' % skip)
rc, out, _ = self.x(["sh", "-c", script], timeout=120)
@@ -965,7 +987,7 @@ class Apply:
return Bundle(self).from_plan(plan)
if self.mode == "agent_update":
return self.agent_update(plan)
if self.layer == "host":
if self.layer in ("host", "pve"):
self.check_appliance()
self.check_guest(self.vmid)
log = self.r.log
@@ -976,6 +998,9 @@ class Apply:
self.report["oom_check"] = self.oom_check()
return 0
self.who, self.allow_downgrade = ("fast", False)
if self.layer == "pve" and self.mode == "apply":
self.who, self.allow_downgrade = self.docker_authority(plan, op_name=PVE_SIGNED_OP)
self.report["authority"] = self.who
if self.layer == "docker" and self.mode == "apply":
self.who, self.allow_downgrade = self.docker_authority(plan)
if self.live_restore() != "true":
@@ -988,7 +1013,7 @@ class Apply:
log(f"os-apply: START release={plan.get('release_id')} layer={self.layer}" +
(f":{self.vmid}" if self.layer != "host" else "") +
f" lane={plan.get('lane', 'fast')} mode={self.mode} select={self.select} packages={len(plan.get('packages', []))}" +
(f" authority={self.who}{' UNDO' if self.allow_downgrade else ''}" if self.layer == "docker" else ""))
(f" authority={self.who}{' UNDO' if self.allow_downgrade else ''}" if self.layer in ("docker", "pve") else ""))
if self.apt_lock_held():
raise Refused("R9", f"another apt/dpkg holds the lock on the {self.layer}")
self.report["health_before"] = self.health()
@@ -1012,6 +1037,8 @@ class Apply:
if self.layer == "docker":
rc_v, out_v, _ = self.g(["docker", "version", "--format", "{{.Server.Version}}"], timeout=60)
self.report["docker_engine"] = out_v.strip() if rc_v == 0 and out_v.strip() else "unknown"
if self.layer == "pve":
self.report["pve_manager"] = self.pve_manager()
self.report["reboot_scanned"] = "reboot_needed" in self.report
self.report["health_after"] = self.health()
if self.layer == "docker" and self.mode == "apply":
@@ -1161,10 +1188,26 @@ class Apply:
return [{"name": p["name"], "version": p["to"], "origin": DOCKER_ORIGIN} for p in pend
if p["from"] is not None and p["name"] in DOCKER_NAMES and self.origin_name(p["origin"]) == {DOCKER_ORIGIN}]
def pending_pve(self):
"""Ring 0 (select pending-pve): the newest pending version of each INSTALLED Proxmox-origin package, never a
kernel / boot / firmware / microcode name (HOST_SLOW_RE, R-836's lane), never another origin."""
rc, pend, remv, _ = self.simulate(["dist-upgrade"])
return [{"name": p["name"], "version": p["to"], "origin": PVE_ORIGIN} for p in pend
if p["from"] is not None and not HOST_SLOW_RE.match(p["name"]) and p["name"] not in DOCKER_NAMES
and self.origin_name(p["origin"]) == {PVE_ORIGIN}]
def pve_manager(self):
"""pveversion's pve-manager version ("unknown" when it cannot be read)."""
rc, out, _ = self.r.host(["pveversion"], 60)
m = re.match(r"^pve-manager/([^/\s]+)", out.strip()) if rc == 0 else None
return m.group(1) if m else "unknown"
def origin_ok(self, origin):
o = self.origin_name(origin)
if self.layer == "docker":
return o == {DOCKER_ORIGIN}
if self.layer == "pve":
return o == {PVE_ORIGIN}
return bool(o & set(FAST_ORIGINS))
def apply(self, plan):
@@ -1173,6 +1216,8 @@ class Apply:
packages = plan["packages"]
elif self.select == "pending-docker":
packages = self.pending_docker()
elif self.select == "pending-pve":
packages = self.pending_pve()
else:
packages = self.pending_fast()
cmp_op = "ne" if self.allow_downgrade else "gt"
@@ -1221,6 +1266,11 @@ class Apply:
raise Refused("R4", f"the plan would remove {', '.join(remv[:5])}")
want = dict(upgrade)
for p in sim:
if p["from"] is None and self.layer == "pve" and p["name"] in PVE_NEW_ALLOW and self.origin_ok(p["origin"]) \
and not HOST_SLOW_RE.match(p["name"]):
self.r.log(f"os-apply: NEW {p['name']}={p['to']} (on the pve lane's allow-list)")
self.report.setdefault("added", []).append({"name": p["name"], "version": p["to"]})
continue
if p["from"] is None:
raise Refused("R6", f"the plan would add a package that is not installed: {p['name']}")
if p["name"] not in want:
@@ -1231,7 +1281,7 @@ class Apply:
raise Refused("R5", f"{p['name']} would be downgraded {p['from']} -> {p['to']}")
if not self.origin_ok(p["origin"]):
raise Refused("R2", f"{p['name']} would come from {p['origin']}, not the {self.layer} layer's origin")
if self.layer == "host" and HOST_SLOW_RE.match(p["name"]):
if self.layer in ("host", "pve") and HOST_SLOW_RE.match(p["name"]):
raise Refused("R14", f"{p['name']} is a kernel / boot / firmware package — the host's slow lane")
need = self.download_bytes(args)
free = self.free_bytes()
+153 -1
View File
@@ -232,6 +232,8 @@ class Fake:
return (0, self.daemon_json, "") if self.daemon_json is not None else (1, "", "No such file")
if cmd == "cat" and a[1] == "/etc/debian_version":
return 0, "13.7\n", ""
if cmd == "pveversion":
return 0, f"pve-manager/{self.installed.get('pve-manager', '9.2.2')}/abcdef (running kernel: 7.0.14-20-pve)\n", ""
if cmd == "uname":
return 0, "7.0.14-20-pve\n", ""
if cmd == "apt-mark":
@@ -284,7 +286,7 @@ class Fake:
n, v = x.split("=", 1)
if v not in self.avail(n):
return 100, "", f"E: Version '{v}' for '{n}' was not found"
origin = "Docker CE:trixie" if n in osapply.DOCKER_NAMES else SEC if n == "openssl" else DEB
origin = getattr(self, "origins", {}).get(n) or ("Docker CE:trixie" if n in osapply.DOCKER_NAMES else SEC if n == "openssl" else DEB)
out += f"Inst {n} [{self.installed[n]}] ({v} {origin} [amd64])\n"
out += "".join(l + "\n" for l in self.extra_sim)
return 0, out, ""
@@ -1324,5 +1326,155 @@ class OOMCheck(unittest.TestCase):
self.assertEqual((rc, rep["refused"]["code"]), (2, "R11"), rep)
PVE = "Proxmox Debian Repository:stable"
PVE_SET = [{"name": "pve-manager", "version": "9.2.21", "origin": "Proxmox Debian Repository"},
{"name": "libpve-common-perl", "version": "9.1.9", "origin": "Proxmox Debian Repository"}]
def pve_fake(signed=None, ring0=False):
f = Fake()
f.installed.update({"pve-manager": "9.2.2", "libpve-common-perl": "9.1.1", "proxmox-kernel-helper": "9.0.4"})
f.live["pve-manager"] = {"9.2.21", "9.2.2"}
f.live["libpve-common-perl"] = {"9.1.9", "9.1.1"}
f.origins = {"pve-manager": PVE, "libpve-common-perl": PVE, "proxmox-kernel-helper": PVE, "shim-signed": PVE,
"proxmox-firewall-data": PVE}
f.plan = {"release_id": "os-pve-t1", "layer": "pve", "lane": "slow", "vmid": 9201, "mode": "apply",
"packages": [dict(p) for p in PVE_SET]}
if ring0:
f.files[osapply.TRUST_FILE] = json.dumps({"host_id": "demo-hp-bb76ea", "ring0_slow_lane": True})
if signed is not None:
f.plan["signed"] = signed
return f
class PVELane(unittest.TestCase):
"""R-812 option A (`09` §3 decision 163): the host's Proxmox USERSPACE packages, slow lane, no kernel / boot /
firmware / microcode (R14), no removal, a new package only from PVE_NEW_ALLOW. Red-proof: audits/day-2026-10-07/B/."""
def refused(self, f, code):
rc, rep = run(f)
self.assertEqual(rc, 2, rep)
self.assertEqual(rep["refused"]["code"], code, rep)
self.assertEqual(f.installed["pve-manager"], "9.2.2", "nothing may be installed on a refusal")
return rep
# THE RED TEST (design-R-812 §5): before the pve layer existed this plan was refused R12.
def test_pve_manager_plan_is_installed_on_the_host(self):
f = pve_fake(signed=signed_job(packages=PVE_SET, op="os_pve_step"))
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
self.assertEqual(f.installed["pve-manager"], "9.2.21")
self.assertEqual(rep["authority"], "signed")
self.assertEqual(rep["pve_manager"], "9.2.21", "pveversion after the step is reported")
inst = [c for c in f.calls if c[0] == "host" and "install" in c[1] and "-s" not in c[1] and "-f" not in c[1]
and "--print-uris" not in c[1]]
self.assertTrue(inst, "the pve layer installs on the HOST")
self.assertFalse([c for c in f.calls if c[0] == "guest" and "install" in c[2]], "nothing installed in the guest")
self.assertEqual(sorted(rep["health_after"]["host_services"]), sorted(osapply.HOST_SERVICES))
def test_kernel_in_a_pve_plan_is_refused(self):
f = pve_fake(ring0=True)
f.plan["packages"].append({"name": "proxmox-kernel-7.0", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"})
self.refused(f, "R14")
def test_shim_in_a_pve_plan_is_refused(self):
f = pve_fake(ring0=True)
f.plan["packages"].append({"name": "shim-signed", "version": "1.47+pmx1", "origin": "Proxmox Debian Repository"})
self.refused(f, "R14")
def test_kernel_pulled_in_by_the_simulation_is_refused(self):
f = pve_fake(ring0=True)
f.installed["proxmox-kernel-helper"] = "9.0.4"
f.extra_sim = ["Inst proxmox-kernel-helper [9.0.4] (9.0.6 Proxmox Debian Repository:stable [all])"]
self.refused(f, "R6") # not in the plan — refused before the name check; R14 below when it IS in the plan
g = pve_fake(ring0=True)
g.live["proxmox-kernel-helper"] = {"9.0.6"}
g.plan["packages"] = [dict(PVE_SET[0])]
g.extra_sim = ["Inst proxmox-kernel-helper [9.0.4] (9.0.6 Proxmox Debian Repository:stable [all])"]
# a kernel-helper the plan did not name is R6; the R14 name check covers a listed one (test above)
self.refused(g, "R6")
def test_debian_package_in_a_pve_plan_is_refused(self):
f = pve_fake(ring0=True)
f.plan["packages"].append({"name": "libc6", "version": "2.41-12+deb13u4", "origin": "Debian"})
self.refused(f, "R2")
def test_debian_origin_in_the_pve_simulation_is_refused(self):
f = pve_fake(ring0=True)
f.origins["libpve-common-perl"] = DEB
self.refused(f, "R2")
def test_docker_package_in_a_pve_plan_is_refused(self):
f = pve_fake(ring0=True)
f.plan["packages"].append({"name": "docker-ce", "version": "5:29.8.2-1~debian.13~trixie", "origin": "Proxmox Debian Repository"})
self.refused(f, "R2")
def test_pve_in_the_fast_lane_is_refused(self):
f = pve_fake(ring0=True)
f.plan["lane"] = "fast"
self.refused(f, "R3")
def test_no_authority_is_refused(self):
self.refused(pve_fake(), "R3")
def test_docker_signed_op_does_not_authorize_a_pve_step(self):
self.refused(pve_fake(signed=signed_job(packages=PVE_SET, op="os_docker_step")), "R3")
def test_pve_on_a_byo_box_is_refused(self):
f = pve_fake(ring0=True)
f.files[osapply.INSTALL_STATE] = json.dumps({"mode": "byo"})
self.refused(f, "R12")
def test_unlisted_new_package_is_refused(self):
f = pve_fake(ring0=True)
f.extra_sim = ["Inst proxmox-new-thing (1.0 Proxmox Debian Repository:stable [all])"]
self.refused(f, "R6")
def test_allow_listed_new_package_is_accepted(self):
f = pve_fake(ring0=True)
f.extra_sim = ["Inst proxmox-firewall-data (0.1 Proxmox Debian Repository:stable [all])"]
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
self.assertEqual(f.installed["pve-manager"], "9.2.21")
def test_allow_new_is_never_an_open_door_in_the_fast_lane(self):
f = Fake()
f.plan["layer"] = "host"
f.extra_sim = ["Inst proxmox-firewall-data (0.1 Proxmox Debian Repository:stable [all])"]
rc, rep = run(f)
self.assertEqual((rc, rep["refused"]["code"]), (2, "R6"), rep)
def test_undo_is_refused(self):
f = pve_fake(signed=signed_job(packages=PVE_SET, op="os_pve_step"))
f.plan["undo"] = True
self.refused(f, "R5")
def test_ring0_pending_pve_takes_only_installed_proxmox_userspace(self):
f = pve_fake(ring0=True)
f.plan["select"], f.plan["packages"] = "pending-pve", []
f.installed.update({"linux-image-amd64": "6.12.1", "tailscale": "1.102.2"})
f.pending_sim = [
"Inst pve-manager [9.2.2] (9.2.21 Proxmox Debian Repository:stable [amd64])",
"Inst libpve-common-perl [9.1.1] (9.1.9 Proxmox Debian Repository:stable [all])",
"Inst proxmox-kernel-helper [9.0.4] (9.0.6 Proxmox Debian Repository:stable [all])",
"Inst proxmox-kernel-7.0.14-20-pve-signed (7.0.14-20 Proxmox Debian Repository:stable [amd64])",
"Inst proxmox-firewall-data (0.1 Proxmox Debian Repository:stable [all])",
"Inst libc6 [2.41-12+deb13u3] (2.41-12+deb13u4 Debian:13.7/stable [amd64])",
"Inst tailscale [1.102.2] (1.102.5 Tailscale:pkgs.tailscale.com [amd64])",
]
rc, rep = run(f)
self.assertEqual(rc, 0, rep)
self.assertEqual(rep["authority"], "ring0")
self.assertEqual(sorted(u["name"] for u in rep["upgraded"]), ["libpve-common-perl", "pve-manager"],
"pending-pve: installed, Proxmox-origin, never kernel/boot/firmware, never Debian or other origins")
self.assertEqual(f.installed["libc6"], "2.41-12+deb13u3")
def test_select_pending_pve_needs_the_pve_layer(self):
f = Fake()
f.plan["layer"], f.plan["select"], f.plan["packages"] = "host", "pending-pve", []
rc, rep = run(f)
self.assertEqual((rc, rep["refused"]["code"]), (2, "R11"), rep)
if __name__ == "__main__":
unittest.main()