Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e06ed97fa8 | |||
| e4b5cf9693 | |||
| faa3cad92e |
@@ -1,3 +1,35 @@
|
||||
## 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`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user