Skip to content

Commit a17d9a2

Browse files
authored
Merge pull request #646 from escoffier-labs/fix/636-checkpoint-export-privacy
fix(runs): strip private recovery-checkpoint bodies at export (#636)
2 parents 2d02241 + 397ac6c commit a17d9a2

5 files changed

Lines changed: 263 additions & 3 deletions

File tree

src/brigade/run_checkpoint.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,3 +1178,98 @@ def recover_from_checkpoint(
11781178
except run_projector.ProjectionError as exc:
11791179
raise CheckpointError(_bound("projection failed"), category="projection") from exc
11801180
return _restore_run_json_from_checkpoint(run_dir, restore_bytes, run_meta=run_meta)
1181+
1182+
1183+
# -- Export boundary (issue #636) ---------------------------------------------
1184+
1185+
_ARTIFACT_REFERENCE_KEYS = frozenset({"path", "sha256", "media_type", "byte_size", "privacy_class"})
1186+
1187+
1188+
def checkpoint_artifact_reference(*, sha256: str, byte_size: int) -> dict[str, Any]:
1189+
"""Return the closed artifact-reference shape used at export boundaries."""
1190+
if not isinstance(sha256, str) or not _HEX64.fullmatch(sha256):
1191+
raise CheckpointError(_bound("checkpoint export digest is invalid"), category="export-privacy")
1192+
if isinstance(byte_size, bool) or not isinstance(byte_size, int) or byte_size < 0:
1193+
raise CheckpointError(_bound("checkpoint export byte size is invalid"), category="export-privacy")
1194+
return {
1195+
"path": f"events/{CHECKPOINT_DIR_NAME}/{sha256}.json",
1196+
"sha256": sha256,
1197+
"media_type": CHECKPOINT_MEDIA_TYPE,
1198+
"byte_size": byte_size,
1199+
"privacy_class": CHECKPOINT_PRIVACY_CLASS,
1200+
}
1201+
1202+
1203+
def refuse_checkpoint_body_export(*, reason: str = "checkpoint body is private") -> None:
1204+
"""Refuse an export that would emit checkpoint body content."""
1205+
raise CheckpointError(
1206+
_bound(f"{reason} (privacy_class={CHECKPOINT_PRIVACY_CLASS})"),
1207+
category="export-privacy",
1208+
)
1209+
1210+
1211+
def is_checkpoint_artifact_reference(payload: Mapping[str, Any]) -> bool:
1212+
"""True when payload is exactly the closed artifact-reference shape."""
1213+
if set(payload) != _ARTIFACT_REFERENCE_KEYS:
1214+
return False
1215+
sha = payload.get("sha256")
1216+
path = payload.get("path")
1217+
byte_size = payload.get("byte_size")
1218+
return (
1219+
isinstance(sha, str)
1220+
and bool(_HEX64.fullmatch(sha))
1221+
and path == f"events/{CHECKPOINT_DIR_NAME}/{sha}.json"
1222+
and payload.get("media_type") == CHECKPOINT_MEDIA_TYPE
1223+
and payload.get("privacy_class") == CHECKPOINT_PRIVACY_CLASS
1224+
and not isinstance(byte_size, bool)
1225+
and isinstance(byte_size, int)
1226+
and byte_size >= 0
1227+
)
1228+
1229+
1230+
def strip_checkpoint_bodies_for_export(run_dir: Path) -> list[dict[str, Any]]:
1231+
"""Replace recovery-checkpoint file bodies with artifact references.
1232+
1233+
Local recovery continues to read the original private bodies under the run
1234+
directory. Call this only on a copy that is about to leave the run tree
1235+
(archive, bundle, sync, or similar export boundary).
1236+
"""
1237+
cp_dir = checkpoint_dir(run_dir)
1238+
if not cp_dir.is_dir():
1239+
return []
1240+
replaced: list[dict[str, Any]] = []
1241+
for path in sorted(cp_dir.glob("*.json")):
1242+
if not path.is_file() or path.is_symlink():
1243+
refuse_checkpoint_body_export(reason="checkpoint export path is not a regular file")
1244+
raw = path.read_bytes()
1245+
try:
1246+
parsed = json.loads(raw.decode("utf-8"))
1247+
except (UnicodeDecodeError, json.JSONDecodeError):
1248+
parsed = None
1249+
if isinstance(parsed, dict) and is_checkpoint_artifact_reference(parsed):
1250+
replaced.append(dict(parsed))
1251+
continue
1252+
sha = hashlib.sha256(raw).hexdigest()
1253+
if path.name != f"{sha}.json":
1254+
refuse_checkpoint_body_export(reason="checkpoint export filename digest mismatch")
1255+
reference = checkpoint_artifact_reference(sha256=sha, byte_size=len(raw))
1256+
path.write_text(json.dumps(reference, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1257+
os.chmod(path, 0o600)
1258+
replaced.append(reference)
1259+
return replaced
1260+
1261+
1262+
def assert_export_tree_has_no_checkpoint_bodies(root: Path) -> None:
1263+
"""Fail closed if any recovery-checkpoint file under root still holds a body."""
1264+
for path in sorted(Path(root).rglob(f"*/{CHECKPOINT_DIR_NAME}/*.json")):
1265+
if not path.is_file():
1266+
continue
1267+
try:
1268+
payload = json.loads(path.read_text(encoding="utf-8"))
1269+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
1270+
raise CheckpointError(
1271+
_bound("checkpoint export body is unreadable"),
1272+
category="export-privacy",
1273+
) from exc
1274+
if not isinstance(payload, dict) or not is_checkpoint_artifact_reference(payload):
1275+
refuse_checkpoint_body_export(reason="checkpoint body crossed an export boundary")

src/brigade/run_events.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@
144144
}
145145
)
146146

147+
# Recovery-checkpoint bodies under ``events/recovery-checkpoints/`` are
148+
# ``privacy_class: private`` (see ``run_checkpoint.CHECKPOINT_PRIVACY_CLASS``).
149+
# Journal event payloads only carry the closed artifact-reference shape
150+
# (relative path, sha256, media type, byte size, privacy class). Any exporter
151+
# or collector that copies a run directory across a boundary must strip those
152+
# bodies, replace them with that artifact-reference shape, or refuse with a
153+
# bounded error naming the privacy class. Local recovery may still read the
154+
# private bodies in place. Helpers: ``run_checkpoint.strip_checkpoint_bodies_for_export``,
155+
# ``run_checkpoint.refuse_checkpoint_body_export``.
156+
147157

148158
class CanonicalizationError(ValueError):
149159
"""Raised when a value cannot be canonicalized under the strict rules."""

src/brigade/work_cmd/verification.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,31 +551,68 @@ def _verify_archive_tree_manifest(root: Path) -> dict[str, tuple[str, str | None
551551
return manifest
552552

553553

554+
def _expected_verify_archive_manifest(
555+
run_dir: Path, source_manifest: dict[str, tuple[str, str | None]]
556+
) -> dict[str, tuple[str, str | None]]:
557+
"""Return the tree manifest after recovery-checkpoint export stripping (#636)."""
558+
from brigade import run_checkpoint
559+
560+
expected = dict(source_manifest)
561+
prefix = f"events/{run_checkpoint.CHECKPOINT_DIR_NAME}/"
562+
for relative, (kind, _digest) in list(expected.items()):
563+
if kind != "file" or not relative.startswith(prefix) or not relative.endswith(".json"):
564+
continue
565+
path = run_dir / relative
566+
raw = path.read_bytes()
567+
try:
568+
parsed = json.loads(raw.decode("utf-8"))
569+
except (UnicodeDecodeError, json.JSONDecodeError):
570+
parsed = None
571+
if isinstance(parsed, dict) and run_checkpoint.is_checkpoint_artifact_reference(parsed):
572+
continue
573+
sha = hashlib.sha256(raw).hexdigest()
574+
if path.name != f"{sha}.json":
575+
raise OSError(f"verify archive checkpoint export filename digest mismatch: {relative}")
576+
reference = run_checkpoint.checkpoint_artifact_reference(sha256=sha, byte_size=len(raw))
577+
ref_bytes = (json.dumps(reference, indent=2, sort_keys=True) + "\n").encode("utf-8")
578+
expected[relative] = ("file", hashlib.sha256(ref_bytes).hexdigest())
579+
return expected
580+
581+
554582
def _archive_verify_run(run_dir: Path, archive_root: Path) -> dict[str, Any]:
555583
"""Copy one run dir into the archive and append an index entry. Raises on failure.
556584
557585
Integrity is checked twice: the archived receipt bytes must match the source
558586
bytes, and a receipt that carries ``digests.receipt_sha256`` must still
559587
re-hash to that value after the copy. Callers must treat any exception as
560-
"do not delete the original".
588+
"do not delete the original". Recovery-checkpoint bodies are private and are
589+
replaced with the closed artifact-reference shape before the archive lands
590+
(issue #636); local source run dirs are left unchanged.
561591
"""
592+
from brigade import run_checkpoint
593+
562594
run_id = run_dir.name
563595
dest = archive_root / run_id
564596
source_manifest = _verify_archive_tree_manifest(run_dir)
597+
expected_manifest = _expected_verify_archive_manifest(run_dir, source_manifest)
565598
receipt_path = run_dir / "receipt.json"
566599
source_receipt_sha = localio.file_sha256(receipt_path) if receipt_path.is_file() else None
567600
if dest.is_symlink():
568601
raise OSError(f"verify archive conflict: {dest} is a symlink")
569602
if dest.exists():
570-
# Re-archive of the same run id is only safe when the evidence is identical.
571-
if _verify_archive_tree_manifest(dest) != source_manifest:
603+
# Re-archive is safe when evidence matches the source, or when the
604+
# existing archive is already the privacy-normalized export of source.
605+
dest_manifest = _verify_archive_tree_manifest(dest)
606+
if dest_manifest not in (source_manifest, expected_manifest):
572607
raise OSError(f"verify archive conflict: {dest} already exists with different evidence")
573608
dest_receipt = dest / "receipt.json"
574609
dest_sha = localio.file_sha256(dest_receipt) if dest_receipt.is_file() else None
575610
if dest_sha != source_receipt_sha:
576611
raise OSError(f"verify archive conflict: {dest} already exists with different evidence")
577612
if dest_sha is not None:
578613
_assert_archived_receipt_integrity(dest_receipt)
614+
run_checkpoint.strip_checkpoint_bodies_for_export(dest)
615+
run_checkpoint.assert_export_tree_has_no_checkpoint_bodies(dest)
579616
entry = _verify_archive_index_entry(run_dir, dest, dest_sha, already_archived=True)
580617
_append_verify_archive_index(archive_root, entry)
581618
return entry
@@ -590,6 +627,13 @@ def _archive_verify_run(run_dir: Path, archive_root: Path) -> dict[str, Any]:
590627
if copied_sha != source_receipt_sha:
591628
raise OSError(f"verify archive copy integrity check failed: {run_id}")
592629
_assert_archived_receipt_integrity(staging / "receipt.json")
630+
# Export boundary (#636): never archive private recovery-checkpoint
631+
# bodies. Replace them with the closed artifact-reference shape after
632+
# the byte-identical copy check so local recovery semantics stay intact.
633+
run_checkpoint.strip_checkpoint_bodies_for_export(staging)
634+
run_checkpoint.assert_export_tree_has_no_checkpoint_bodies(staging)
635+
if _verify_archive_tree_manifest(staging) != expected_manifest:
636+
raise OSError(f"verify archive export privacy normalize mismatch: {run_id}")
593637
os.rename(staging, dest)
594638
if _verify_archive_tree_manifest(run_dir) != source_manifest:
595639
raise OSError(f"verify archive source changed during copy: {run_id}")

tests/test_run_checkpoint.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import hashlib
1010
import json
1111
import os
12+
import shutil
1213
import signal
1314
import stat
1415
import subprocess
@@ -3266,3 +3267,80 @@ def test_write_text_atomic_sigkill_during_temp_window_preserves_one_valid_payloa
32663267
)
32673268
for run_dir in attempt_run_dirs:
32683269
assert _write_text_atomic_temp_paths(run_dir) == []
3270+
3271+
3272+
# -- Issue #636: checkpoint export privacy -----------------------------------
3273+
3274+
3275+
def test_strip_checkpoint_bodies_for_export_replaces_private_body(tmp_path):
3276+
run_dir = _run_dir(tmp_path)
3277+
private_task = "SECRET_TASK_PROMPT_do_not_export"
3278+
private_error = "SECRET_ERROR_TRACE_do_not_export"
3279+
body = _writer_bytes(
3280+
{
3281+
"schema": "brigade.run.v1",
3282+
"status": "failed",
3283+
"task": private_task,
3284+
"error": private_error,
3285+
}
3286+
)
3287+
placed = _place_checkpoint_file(run_dir, body)
3288+
assert private_task in placed.read_text(encoding="utf-8")
3289+
3290+
export_copy = tmp_path / "export-copy"
3291+
shutil.copytree(run_dir, export_copy)
3292+
3293+
refs = run_checkpoint.strip_checkpoint_bodies_for_export(export_copy)
3294+
assert len(refs) == 1
3295+
assert run_checkpoint.is_checkpoint_artifact_reference(refs[0])
3296+
assert refs[0]["privacy_class"] == "private"
3297+
assert refs[0]["sha256"] == hashlib.sha256(body).hexdigest()
3298+
assert refs[0]["byte_size"] == len(body)
3299+
3300+
exported = (export_copy / "events" / "recovery-checkpoints" / placed.name).read_text(encoding="utf-8")
3301+
assert private_task not in exported
3302+
assert private_error not in exported
3303+
assert '"task"' not in exported
3304+
assert json.loads(exported) == refs[0]
3305+
3306+
# Local recovery source is unchanged.
3307+
assert placed.read_bytes() == body
3308+
run_checkpoint.assert_export_tree_has_no_checkpoint_bodies(export_copy)
3309+
3310+
3311+
def test_assert_export_tree_refuses_checkpoint_body(tmp_path):
3312+
run_dir = _run_dir(tmp_path)
3313+
body = _writer_bytes({"schema": "brigade.run.v1", "status": "planning", "task": "keep-private"})
3314+
_place_checkpoint_file(run_dir, body)
3315+
3316+
with pytest.raises(run_checkpoint.CheckpointError, match="privacy_class=private") as exc_info:
3317+
run_checkpoint.assert_export_tree_has_no_checkpoint_bodies(run_dir)
3318+
assert exc_info.value.category == "export-privacy"
3319+
3320+
3321+
def test_refuse_checkpoint_body_export_names_privacy_class():
3322+
with pytest.raises(run_checkpoint.CheckpointError, match="privacy_class=private") as exc_info:
3323+
run_checkpoint.refuse_checkpoint_body_export()
3324+
assert exc_info.value.category == "export-privacy"
3325+
3326+
3327+
def test_local_recovery_round_trips_after_export_strip_of_copy(tmp_path):
3328+
"""Export stripping a copy must not change local recovery semantics."""
3329+
workspace = _workspace(tmp_path)
3330+
run_dir = _run_dir(tmp_path)
3331+
run_json_obj = {
3332+
"schema": "brigade.run.v1",
3333+
"status": "planning",
3334+
"task": "SECRET_TASK_for_local_recovery_only",
3335+
}
3336+
_activated_journal_with_checkpoint(workspace, run_dir, run_json_obj)
3337+
3338+
export_copy = tmp_path / "export-copy"
3339+
shutil.copytree(run_dir, export_copy)
3340+
run_checkpoint.strip_checkpoint_bodies_for_export(export_copy)
3341+
run_checkpoint.assert_export_tree_has_no_checkpoint_bodies(export_copy)
3342+
3343+
(run_dir / "run.json").unlink()
3344+
repaired = run_checkpoint.recover_from_checkpoint(run_dir, None)
3345+
assert repaired == run_json_obj
3346+
assert (run_dir / "run.json").read_bytes() == _writer_bytes(run_json_obj)

tests/test_work_cmd_verification.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2668,3 +2668,36 @@ def test_verify_receipt_identity_preserves_real_index(tmp_target, monkeypatch, c
26682668
text=True,
26692669
).stdout
26702670
assert after == before
2671+
2672+
2673+
def test_archive_verify_run_strips_recovery_checkpoint_bodies(tmp_path):
2674+
"""Export boundary: verify-archive must not emit private checkpoint bodies (#636)."""
2675+
from brigade import run_checkpoint
2676+
from brigade.work_cmd import helpers, verification
2677+
2678+
root = helpers._verify_runs_root(tmp_path)
2679+
root.mkdir(parents=True)
2680+
run_dir, _ = _write_verify_run_dir(root, "20260101-000001-a", sign=True)
2681+
_write_verify_run_dir(root, "20260101-000002-b")
2682+
2683+
private_task = "SECRET_CHECKPOINT_TASK_must_not_archive"
2684+
body_obj = {"schema": "brigade.run.v1", "status": "failed", "task": private_task, "error": "boom"}
2685+
body = (json.dumps(body_obj, indent=2, sort_keys=True) + "\n").encode("utf-8")
2686+
cp_dir = run_dir / "events" / "recovery-checkpoints"
2687+
cp_dir.mkdir(parents=True)
2688+
sha = hashlib.sha256(body).hexdigest()
2689+
(cp_dir / f"{sha}.json").write_bytes(body)
2690+
os.chmod(cp_dir / f"{sha}.json", 0o600)
2691+
2692+
archive_root = tmp_path / "verify-archive"
2693+
removed = verification._prune_verify_runs(tmp_path, keep=1, archive_root=archive_root)
2694+
2695+
assert removed == 1
2696+
archived_cp = archive_root / "20260101-000001-a" / "events" / "recovery-checkpoints" / f"{sha}.json"
2697+
assert archived_cp.is_file()
2698+
payload = json.loads(archived_cp.read_text(encoding="utf-8"))
2699+
assert run_checkpoint.is_checkpoint_artifact_reference(payload)
2700+
assert private_task not in archived_cp.read_text(encoding="utf-8")
2701+
assert '"task"' not in archived_cp.read_text(encoding="utf-8")
2702+
# Source verify-run is deleted after archive; the privacy rule is on the export.
2703+
assert not (root / "20260101-000001-a").exists()

0 commit comments

Comments
 (0)