From e4b5cf9693b20e43697409f72b9c1b927a5be5f3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 12:23:55 +0200 Subject: [PATCH] =?UTF-8?q?R-880:=20build-step-bundle.py=20=E2=80=94=20the?= =?UTF-8?q?=20transition=20bundle=20for=20a=20release=20whose=20bundle=20a?= =?UTF-8?q?dds=20paths=20(an=20installed=20felhom-os-apply=20refuses=20unk?= =?UTF-8?q?nown=20paths,=20R16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 8 ++++ configs/test_felhom_config_bundle.py | 62 ++++++++++++++++++++++++++++ scripts/build-step-bundle.py | 59 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 scripts/build-step-bundle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1c33e..b636914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ config bundle sha256 `42333e969028867ad8142335e6c1bc4040eec231de0d8d330c2d4b2cf7 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): diff --git a/configs/test_felhom_config_bundle.py b/configs/test_felhom_config_bundle.py index 4ac6ac9..9b7a38b 100644 --- a/configs/test_felhom_config_bundle.py +++ b/configs/test_felhom_config_bundle.py @@ -667,5 +667,67 @@ class SelfupdateWrapperConfinement(unittest.TestCase): 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() diff --git a/scripts/build-step-bundle.py b/scripts/build-step-bundle.py new file mode 100644 index 0000000..000708b --- /dev/null +++ b/scripts/build-step-bundle.py @@ -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 (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. 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 (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))