diff --git a/docs/SANITIZED_ARTIFACTS.md b/docs/SANITIZED_ARTIFACTS.md index 232619d9..c0802cba 100644 --- a/docs/SANITIZED_ARTIFACTS.md +++ b/docs/SANITIZED_ARTIFACTS.md @@ -67,6 +67,13 @@ compression are fixed. Approval binds reviewer, time, policy, derivative tree, manifest, archive SHA-256, and archive byte size. `push` verifies all hashes and sends that exact ZIP without reconstructing it. +The approval record lands in `/.openadapt-approval.json`, outside the +archive, because it binds that archive's own SHA-256. Unzipping +`.approved.zip` somewhere else gives you the reviewed bytes without +the review, and `compile`, `validate-hosted`, and `push` all read that copy as +unapproved. To move an approved derivative, copy the whole directory, hidden +approval file included, next to its sibling ZIP. + ## Destination trust Execution lane and egress destination are independent: diff --git a/openadapt_flow/runtime_validation.py b/openadapt_flow/runtime_validation.py index 5c737d17..839e07ab 100644 --- a/openadapt_flow/runtime_validation.py +++ b/openadapt_flow/runtime_validation.py @@ -406,13 +406,22 @@ def create_runtime_validation_attestation( ) challenge = _validate_challenge(challenge) + # Both derivatives fail the same checks with the same wording. Name the + # flag that carried the failing path: an operator who has just approved one + # of the two otherwise reads the other one's refusal as that approval + # failing. try: recording_manifest = load_and_verify_derivative(recording_derivative) recording_approval = load_valid_approval(recording_derivative) + except SanitizationError as exc: + raise RuntimeValidationError( + f"--recording {recording_derivative}: {exc}" + ) from exc + try: bundle_manifest = load_and_verify_derivative(bundle_derivative) bundle_approval = load_valid_approval(bundle_derivative) except SanitizationError as exc: - raise RuntimeValidationError(str(exc)) from exc + raise RuntimeValidationError(f"--bundle {bundle_derivative}: {exc}") from exc if recording_manifest.get("kind") != "recording": raise RuntimeValidationError("Validation source must be a recording derivative") if bundle_manifest.get("kind") != "bundle": diff --git a/openadapt_flow/sanitized_artifact.py b/openadapt_flow/sanitized_artifact.py index 73a08df4..1229e48d 100644 --- a/openadapt_flow/sanitized_artifact.py +++ b/openadapt_flow/sanitized_artifact.py @@ -1159,8 +1159,22 @@ def load_valid_approval(destination: Path) -> dict[str, Any]: manifest = load_and_verify_derivative(destination) path = approval_path(destination) if not path.is_file(): + archive = approved_archive_path(destination) + if archive.is_file(): + # The approval binds the archive's SHA-256, so `approve-sanitized` + # never writes the approval into the archive it is hashing. + # Extracting that archive therefore rebuilds the reviewed bytes + # without the review. + raise SanitizationError( + f"{destination} has not been approved: {APPROVAL_NAME} is missing " + f"even though {archive.name} is beside it. Extracting an approved " + "archive does not restore its approval. Copy the reviewed " + f"directory with its {APPROVAL_NAME}, or review and approve this " + "copy here." + ) raise SanitizationError( - "Sanitized artifact has not been approved. Review it locally, then approve it." + f"{destination} has not been approved (no {APPROVAL_NAME}). Review it " + "locally, then approve it." ) try: approval = json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/test_runtime_validation.py b/tests/test_runtime_validation.py index fec62568..dd290459 100644 --- a/tests/test_runtime_validation.py +++ b/tests/test_runtime_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import zipfile from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -30,7 +31,14 @@ request_validation_challenge, verify_runtime_validation_attestation, ) -from openadapt_flow.sanitized_artifact import approve_derivative, sanitize_artifact +from openadapt_flow.sanitized_artifact import ( + APPROVAL_NAME, + approval_path, + approve_derivative, + approved_archive_path, + load_and_verify_derivative, + sanitize_artifact, +) _TARGET_URL = "https://mockmed.example.com/login" _TARGET_ORIGIN = "https://mockmed.example.com" @@ -830,3 +838,69 @@ def test_unrelated_recording_or_same_name_report_cannot_attest(tmp_path): if key != "bundle_derivative" }, ) + + +def test_recording_materialized_by_extracting_its_approved_archive_is_named(tmp_path): + """A hosted requalification runner extracts ``.approved.zip``. + + The approval record is deliberately absent from that archive, so the + extracted tree is unapproved. The refusal must name the recording role, the + exact path, and the extraction that produced it; otherwise the operator + reads a bundle they just approved successfully into a recording failure. + """ + recording, bundle, run_dir = _approved_artifacts(tmp_path) + + materialized = tmp_path / "runner" / "recording" + materialized.parent.mkdir(parents=True) + archive = approved_archive_path(recording) + with zipfile.ZipFile(archive) as source_archive: + assert APPROVAL_NAME not in source_archive.namelist() + source_archive.extractall(materialized) + approved_archive_path(materialized).write_bytes(archive.read_bytes()) + + # The extracted tree is a valid derivative; only its approval is absent. + assert load_and_verify_derivative(materialized)["kind"] == "recording" + assert not approval_path(materialized).is_file() + + with pytest.raises(RuntimeValidationError) as excinfo: + create_runtime_validation_attestation( + recording_derivative=materialized, + bundle_derivative=bundle, + run_dir=run_dir, + policy_source="permissive", + risk_class="low", + environment="local-test/mockmed-v1", + target_url=_TARGET_URL, + host=hosted.DEFAULT_HOST, + token="oai_ingest_test", + challenge=_challenge(), + ) + message = str(excinfo.value) + assert "--recording" in message + assert str(materialized) in message + assert APPROVAL_NAME in message + assert approved_archive_path(materialized).name in message + assert "has not been approved" in message + + +def test_bundle_approval_failure_names_the_bundle_flag(tmp_path): + recording, bundle, run_dir = _approved_artifacts(tmp_path) + approval_path(bundle).unlink() + + with pytest.raises(RuntimeValidationError) as excinfo: + create_runtime_validation_attestation( + recording_derivative=recording, + bundle_derivative=bundle, + run_dir=run_dir, + policy_source="permissive", + risk_class="low", + environment="local-test/mockmed-v1", + target_url=_TARGET_URL, + host=hosted.DEFAULT_HOST, + token="oai_ingest_test", + challenge=_challenge(), + ) + message = str(excinfo.value) + assert "--bundle" in message + assert str(bundle) in message + assert "--recording" not in message