From 904dcb3ce32c446fe4236d3e2c5e42b409a5fa7b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 12:44:59 +0100 Subject: [PATCH] Keep future Coulomb release evidence directories private Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 1 + tests/test_coulomb_image_release.py | 44 +++++++++++++++++++ tools/coulomb_image_release_node.py | 33 +++++++++++++- ...P-0015-coulomb-personal-content-release.md | 23 ++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c255f81..1a75a6c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -42,6 +42,7 @@ | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py index e733c02..703326e 100644 --- a/tests/test_coulomb_image_release.py +++ b/tests/test_coulomb_image_release.py @@ -2,6 +2,8 @@ import copy import importlib.util import json +import os +import stat from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -66,6 +68,48 @@ def test_mutable_staging_is_frozen_before_live_work(tmp_path, monkeypatch): assert (instance.journal / "receipt.json").is_file() +def test_nested_capsule_and_release_root_stay_private_under_normal_umask(tmp_path, monkeypatch): + root, _ = capsule(tmp_path) + nested = root / "charts/coulomb/templates/deployment.yaml" + nested.parent.mkdir(parents=True) + nested.write_bytes(b"synthetic chart") + plan = {"files": {str(p.relative_to(root)): release.sha(p.read_bytes()) + for p in root.rglob("*") if p.is_file() and p.name != "manifest.json"}} + (root / "manifest.json").write_text(json.dumps(plan)) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + previous = os.umask(0o022) + try: + instance = release.Release(root, "sha256:" + release.sha(release.canonical(plan))) + finally: + os.umask(previous) + for path in [tmp_path / "journal", *(tmp_path / "journal").rglob("*")]: + assert stat.S_IMODE(path.stat().st_mode) == (0o700 if path.is_dir() else 0o600) + assert (instance.root / nested.relative_to(root)).read_bytes() == b"synthetic chart" + + +@pytest.mark.parametrize("problem", ["broad", "link", "ancestor_link", "file"]) +def test_unsafe_release_parent_is_refused_without_changing_it(tmp_path, monkeypatch, problem): + root, digest = capsule(tmp_path) + journal = tmp_path / "journal" + if problem == "broad": + journal.mkdir(mode=0o755) + elif problem == "file": + journal.write_text("untouched") + elif problem == "link": + journal.symlink_to(root, target_is_directory=True) + else: + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + journal = alias / "journal" + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", journal) + before = {str(p): p.lstat() for p in tmp_path.rglob("*")} + with pytest.raises((release.Refused, OSError)): + release.Release(root, digest) + assert before == {str(p): p.lstat() for p in tmp_path.rglob("*")} + + @pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"]) def test_dataset_drift_is_not_accepted(field): instance = object.__new__(release.Release) diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py index d84b525..10381ea 100644 --- a/tools/coulomb_image_release_node.py +++ b/tools/coulomb_image_release_node.py @@ -16,6 +16,7 @@ import subprocess import tempfile import time import urllib.request +from contextlib import ExitStack from datetime import UTC, datetime from pathlib import Path @@ -35,6 +36,33 @@ class Refused(RuntimeError): pass +def private_directories(root, relative): + """Create every component privately; never repair existing unsafe state.""" + if not root.is_absolute() or relative.is_absolute() or ".." in relative.parts: + raise Refused("private_directory_path_required") + with ExitStack() as stack: + def opened(name, parent=None): + fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + stack.callback(os.close, fd) + return fd + + fd = opened("/") + for component in root.parts[1:]: + fd = opened(component, fd) + for component in (*relative.parts, None): + info = os.fstat(fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise Refused("private_owned_directory_required") + if component is None: + break + try: + os.mkdir(component, mode=0o700, dir_fd=fd) + os.fsync(fd) + except FileExistsError: + pass + fd = opened(component, fd) + + class Release: def __init__(self, capsule, expected, read_only=False): self.root = capsule @@ -73,12 +101,13 @@ class Release: return if self.journal.exists(): raise Refused("prior_execution_requires_review") - self.journal.mkdir(parents=True, mode=0o700) + private_directories(JOURNAL_ROOT.parent, Path(JOURNAL_ROOT.name)) + self.journal.mkdir(mode=0o700) self.root = self.journal / "capsule" self.root.mkdir(mode=0o700) for name, data in frozen.items(): target = self.root / name - target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + private_directories(self.root, Path(name).parent) fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(data) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 119f326..d91b2c4 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -399,3 +399,26 @@ packaged client export verifies85files without source mutation. Full make check 441passes, zero skips. Release decision75b94bb6 is resolved only for that exact execution; RAPPS-WP-0015-T05 is complete. Productive flow and service/offboarding acceptance remain open. Return: docs/release/identity-export-release-return-2026-10-31.md. + +## T06 — Preserve private custody when freezing future release evidence + +```task +id: RAPPS-WP-0015-T06 +status: done +priority: high +needs_human: false +``` + +Source correction for CSOC-WP-0012-T13 after backup installation preflight exposed +0755 intermediate capsule directories from completed releases. The current image +controller creates each component with mode0700 through no-follow directory +file descriptors, checks private ownership/mode and refuses unsafe existing state. +It does not chmod historical directories or replay the completed release. Files +remain0600. The old storage capsule remains frozen; it is not a current release path. + +Validation: 24 focused release safety cases pass, including a nested chart under +umask022 and refusal of broad, symlinked, ancestor-symlinked and non-directory +parents without mutation. Ruff and git diff --check pass. Product receipt: +coulomb-social/docs/release/backup-producer-custody-validation-2026-10-31.json. +The historical eight-entry repair and recurring service acceptance remain product +T04/T09. No cluster operation occurred; future release proposals bind the new source.