feat(ops): verify release provenance evidence pairs

This commit is contained in:
Hermes Agent
2026-09-20 11:47:05 +09:00
parent cd2342f2c6
commit db2bdd7d4a
15 changed files with 1054 additions and 67 deletions
+382 -26
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
@@ -68,10 +69,39 @@ _SENSITIVE_VALUE = re.compile(
)
_FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}")
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
_SHA256 = re.compile(r"[0-9a-f]{64}")
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
_REQUIRED_CAPTURE_KEYS = frozenset(
{"pending_slots", "reconciliation_slots", "queue_consumers"}
)
_OBSERVED_EVIDENCE_KEYS = frozenset(
{
"schema_version",
"target_id",
"target_kind",
"image_id",
"image_digest",
"oci_revision",
"observed_at",
}
)
_RECORD_KEYS = frozenset(
{
"schema_version",
"timestamp_utc",
"actor",
"reason",
"instance",
"command_class",
"operation_id",
"phase",
"observed_evidence",
"checkout",
"runtime_file_verification",
"state_capture",
"gate",
}
)
def _run_git(checkout_dir: Path, *args: str) -> str:
@@ -153,6 +183,80 @@ def _sanitize_json(value: object) -> object:
return value
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key}")
result[key] = value
return result
def _load_strict_json(path: str | Path) -> dict[str, object]:
try:
value = json.loads(
Path(path).read_text(encoding="utf-8"),
object_pairs_hook=_reject_duplicate_keys,
)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid JSON input") from exc
if not isinstance(value, dict):
raise TypeError("JSON input must be an object")
return value
def _is_utc_timestamp(value: object) -> bool:
if not isinstance(value, str):
return False
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return False
return parsed.tzinfo is not None and parsed.utcoffset() == datetime.now(tz=UTC).utcoffset()
def _contains_sensitive_material(value: object) -> bool:
if isinstance(value, dict):
return any(
_SENSITIVE_KEY.search(str(key)) or _contains_sensitive_material(item)
for key, item in value.items()
)
if isinstance(value, list):
return any(_contains_sensitive_material(item) for item in value)
return isinstance(value, str) and _sanitize_text(value) != value
def _validate_observed_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != _OBSERVED_EVIDENCE_KEYS:
raise ValueError("observed evidence keys must match schema version 1")
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
raise ValueError("observed evidence schema_version must be integer 1")
if not all(
isinstance(value[key], str) and bool(value[key])
for key in ("target_id", "target_kind")
):
raise ValueError("observed target fields must be non-empty strings")
if not all(
isinstance(value[key], str) and _IMAGE_DIGEST.fullmatch(value[key])
for key in ("image_id", "image_digest")
):
raise ValueError("observed image identity must use lowercase sha256 digests")
if not isinstance(value["oci_revision"], str) or not _FULL_GIT_REVISION.fullmatch(
value["oci_revision"]
):
raise ValueError("observed oci_revision must be a full lowercase git SHA")
if not _is_utc_timestamp(value["observed_at"]):
raise ValueError("observed_at must be a UTC RFC 3339 timestamp")
if _contains_sensitive_material(value):
raise ValueError("observed evidence must not contain sensitive material")
return dict(value)
def load_observed_evidence(path: str | Path) -> dict[str, object]:
"""Load strict caller-observed provenance without claiming runtime observation."""
return _validate_observed_evidence(_load_strict_json(path))
def _validate_capture_commands(
capture_commands: dict[str, list[str]], *, instance: str
) -> None:
@@ -297,8 +401,7 @@ def collect_release_provenance(
command_class: str,
operation_id: str,
phase: str,
expected_oci_revision: str,
image_digest: str,
observed_evidence: dict[str, object],
runtime_files: list[str],
capture_commands: dict[str, list[str]],
checkout_dir: str | Path = ".",
@@ -313,10 +416,8 @@ def collect_release_provenance(
raise ValueError("instance and operation_id must be safe identifiers")
if phase not in {"pre", "post"}:
raise ValueError("phase must be pre or post")
if not _FULL_GIT_REVISION.fullmatch(expected_oci_revision):
raise ValueError("expected_oci_revision must be a full lowercase git SHA")
if not _IMAGE_DIGEST.fullmatch(image_digest):
raise ValueError("image_digest must be sha256:<64 lowercase hex>")
observed = _validate_observed_evidence(observed_evidence)
expected_oci_revision = str(observed["oci_revision"])
if not runtime_files:
raise ValueError("at least one runtime file is required")
_validate_capture_commands(capture_commands, instance=instance)
@@ -378,6 +479,7 @@ def collect_release_provenance(
and broker_logs_ok
)
return {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"actor": actor,
"reason": reason,
@@ -393,11 +495,7 @@ def collect_release_provenance(
"git_clean": git_clean,
"dirty_paths": dirty_paths,
},
"artifact_identity": {
"expected_oci_revision": expected_oci_revision,
"oci_revision_matches_head": oci_revision_matches_head,
"image_digest": image_digest,
},
"observed_evidence": {**observed, "source": "operator_observed"},
"runtime_file_verification": runtime_reports,
"state_capture": {
"command_outputs": command_outputs,
@@ -413,38 +511,296 @@ def collect_release_provenance(
}
def _exact_object(value: object, keys: set[str] | frozenset[str], label: str) -> dict:
if not isinstance(value, dict) or set(value) != set(keys):
raise ValueError(f"{label} keys do not match schema version 1")
return value
def _validate_success_record(record: dict[str, object], *, phase: str) -> dict[str, object]:
_exact_object(record, _RECORD_KEYS, "gate record")
if type(record["schema_version"]) is not int or record["schema_version"] != 1:
raise ValueError("unsupported gate record schema")
if record["phase"] != phase:
raise ValueError(f"record phase must be {phase}")
if not _is_utc_timestamp(record["timestamp_utc"]):
raise ValueError("record timestamp must be UTC RFC 3339")
for key in ("actor", "reason", "instance", "command_class", "operation_id"):
if not isinstance(record[key], str) or not record[key]:
raise ValueError(f"record {key} must be a non-empty string")
observed = _exact_object(
record["observed_evidence"], _OBSERVED_EVIDENCE_KEYS | {"source"}, "observed evidence"
)
if observed["source"] != "operator_observed":
raise ValueError("observed evidence source must be operator_observed")
_validate_observed_evidence({key: observed[key] for key in _OBSERVED_EVIDENCE_KEYS})
checkout = _exact_object(
record["checkout"],
{
"path",
"head_revision",
"detached_head",
"git_status_porcelain",
"git_clean",
"dirty_paths",
},
"checkout",
)
if not (
checkout["detached_head"] is True
and checkout["git_clean"] is True
and checkout["dirty_paths"] == []
and checkout["head_revision"] == observed["oci_revision"]
):
raise ValueError("checkout evidence does not satisfy pair success invariants")
runtime_reports = record["runtime_file_verification"]
runtime_keys = {
"path",
"path_within_checkout",
"exists",
"current_sha256",
"expected_revision",
"expected_sha256",
"matches_expected_revision",
}
if not isinstance(runtime_reports, list) or not runtime_reports:
raise ValueError("runtime verification must be a non-empty list")
for item in runtime_reports:
report = _exact_object(item, runtime_keys, "runtime verification")
if not (
isinstance(report["path"], str)
and report["path"]
and report["path_within_checkout"] is True
and report["exists"] is True
and report["matches_expected_revision"] is True
and report["expected_revision"] == observed["oci_revision"]
and isinstance(report["current_sha256"], str)
and _SHA256.fullmatch(report["current_sha256"])
and report["current_sha256"] == report["expected_sha256"]
):
raise ValueError("runtime verification does not satisfy pair success invariants")
state_capture = _exact_object(
record["state_capture"], {"command_outputs", "broker_side_effect_logs"}, "state capture"
)
command_outputs = _exact_object(
state_capture["command_outputs"], _REQUIRED_CAPTURE_KEYS, "command outputs"
)
output_keys = {"command", "returncode", "stdout", "stdout_format", "stderr"}
for output in command_outputs.values():
captured = _exact_object(output, output_keys, "command output")
if captured["returncode"] != 0 or type(captured["returncode"]) is not int:
raise ValueError("state capture command did not succeed")
broker = state_capture["broker_side_effect_logs"]
broker_success_keys = {
"path",
"exists",
"capture_status",
"file_sha256",
"scanned_lines",
"matches",
}
broker = _exact_object(broker, broker_success_keys, "broker capture")
if broker["capture_status"] not in {"captured", "captured_empty"}:
raise ValueError("broker capture did not succeed")
gate = _exact_object(
record["gate"],
{
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
},
"gate",
)
if [
gate[key]
for key in (
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
)
] != [True, False, True, True, True]:
raise ValueError("gate summary does not satisfy pair success invariants")
return record
def _record_digest(path: Path, expected: str) -> str:
if not _SHA256.fullmatch(expected):
raise ValueError("expected record digest must be lowercase SHA-256")
try:
payload = path.read_bytes()
except OSError as exc:
raise ValueError("record path is not readable") from exc
actual = _sha256_bytes(payload)
if actual != expected:
raise ValueError("record bytes do not match expected SHA-256")
return actual
def verify_release_provenance_pair(
*,
pre_path: str | Path,
pre_sha256: str,
post_path: str | Path,
post_sha256: str,
output_path: str | Path,
) -> dict[str, object]:
"""Verify one explicit PRE/POST pair and write an immutable manifest."""
pre = Path(pre_path)
post = Path(post_path)
if not pre.is_absolute() or not post.is_absolute():
raise ValueError("record paths must be canonical absolute paths")
pre = pre.resolve()
post = post.resolve()
if pre == post:
raise ValueError("PRE and POST record paths must differ")
pre_digest = _record_digest(pre, pre_sha256)
post_digest = _record_digest(post, post_sha256)
pre_record = _validate_success_record(_load_strict_json(pre), phase="pre")
post_record = _validate_success_record(_load_strict_json(post), phase="post")
for key in ("schema_version", "operation_id", "instance"):
if pre_record[key] != post_record[key]:
raise ValueError(f"PRE/POST {key} mismatch")
pre_observed = pre_record["observed_evidence"]
post_observed = post_record["observed_evidence"]
for key in ("target_kind", "target_id"):
if pre_observed[key] != post_observed[key]:
raise ValueError(f"PRE/POST {key} mismatch")
destination = Path(output_path).resolve()
for checkout_path in (pre_record["checkout"]["path"], post_record["checkout"]["path"]):
checkout = Path(checkout_path).resolve()
try:
destination.relative_to(checkout)
except ValueError:
continue
raise ValueError("pair manifest must be outside the audit checkout")
def manifest_record(
path: Path, digest: str, record: dict[str, object], observed: dict[str, object]
) -> dict[str, object]:
return {
"record_path": str(path),
"record_sha256": digest,
"phase": record["phase"],
"command_class": record["command_class"],
"image_id": observed["image_id"],
"image_digest": observed["image_digest"],
"oci_revision": observed["oci_revision"],
"observed_at": observed["observed_at"],
}
manifest = {
"schema_version": 1,
"verified_at": datetime.now(tz=UTC).isoformat(),
"operation_id": pre_record["operation_id"],
"instance": pre_record["instance"],
"target_kind": pre_observed["target_kind"],
"target_id": pre_observed["target_id"],
"pre": manifest_record(pre, pre_digest, pre_record, pre_observed),
"post": manifest_record(post, post_digest, post_record, post_observed),
}
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
destination.chmod(0o444)
return manifest
def isolate_dirty_checkout_files(
*,
checkout_dir: str | Path,
isolate_dir: str | Path,
) -> dict:
"""Copy dirty/untracked checkout files to an isolation directory."""
"""Copy dirty files without mutating the checkout and verify copy integrity."""
checkout = Path(checkout_dir).resolve()
dst_root = Path(isolate_dir).resolve()
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
dst = dst_root / f"{stamp}--{uuid.uuid4().hex[:12]}"
_, dirty_paths = _git_status(checkout)
copied: list[str] = []
entries: list[dict[str, object]] = []
for rel in dirty_paths:
src = _resolve_under(checkout, rel)
if src is None or not src.exists() or src.is_dir():
continue
target = (dst / rel).resolve()
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
raise ValueError(f"unsafe dirty path: {rel}")
src = checkout / rel_path
try:
target.relative_to(dst)
except ValueError:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
copied.append(rel)
src.parent.resolve().relative_to(checkout)
except ValueError as exc:
raise ValueError(f"dirty path parent escapes checkout: {rel}") from exc
if src.is_symlink():
source_type = "symlink"
source_target = os.readlink(src)
source_target_path = Path(source_target)
if source_target_path.is_absolute():
raise ValueError(f"symlink target escapes checkout: {rel}")
resolved_source_target = (src.parent / source_target_path).resolve(strict=False)
try:
resolved_source_target.relative_to(checkout)
except ValueError as exc:
raise ValueError(f"symlink target escapes checkout: {rel}") from exc
source_bytes = source_target.encode("utf-8")
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
os.symlink(source_target, target)
copied_type = "symlink" if target.is_symlink() else "unsupported"
copied_bytes = os.readlink(target).encode("utf-8") if target.is_symlink() else b""
elif src.is_file():
source_type = "regular"
source_bytes = src.read_bytes()
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target, follow_symlinks=False)
copied_type = "regular" if target.is_file() and not target.is_symlink() else "unsupported"
copied_bytes = target.read_bytes() if copied_type == "regular" else b""
else:
raise ValueError(f"unsupported dirty path type: {rel}")
source_sha = _sha256_bytes(source_bytes)
copied_sha = _sha256_bytes(copied_bytes)
matches = (
copied_type == source_type
and len(copied_bytes) == len(source_bytes)
and copied_sha == source_sha
)
if not matches:
raise ValueError(f"isolated copy integrity mismatch: {rel}")
entries.append(
{
"path": rel,
"source_type": source_type,
"source_size": len(source_bytes),
"source_sha256": source_sha,
"copied_path": str(target.resolve(strict=False)) if source_type == "regular" else str(target),
"copied_type": copied_type,
"copied_size": len(copied_bytes),
"copied_sha256": copied_sha,
"matches": True,
}
)
manifest = {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"checkout_dir": str(checkout),
"isolate_dir": str(dst),
"dirty_paths": dirty_paths,
"copied_paths": copied,
"entries": entries,
}
dst.mkdir(parents=True, exist_ok=True)
with (dst / "manifest.json").open("x", encoding="utf-8") as fp:
manifest_path = dst / "manifest.json"
with manifest_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
manifest_path.chmod(0o444)
return manifest