chore: require live proof for hardware fix claims

This commit is contained in:
rcourtman 2026-08-04 23:57:44 +01:00
parent 1031034888
commit 8e25badea1
6 changed files with 853 additions and 35 deletions

2
.gitignore vendored
View file

@ -240,6 +240,8 @@ scripts/release_control/*
!scripts/release_control/format_staged_go_test.py
!scripts/release_control/governance_stage_guard.py
!scripts/release_control/governance_stage_guard_test.py
!scripts/release_control/live_runtime_proof.py
!scripts/release_control/live_runtime_proof_test.py
!scripts/release_control/mobile_relay_auth_approvals_proof.py
!scripts/release_control/mobile_release_gate.py
!scripts/release_control/mobile_release_gate_test.py

View file

@ -41,6 +41,62 @@ Cloud, and self-hosted production users.
line; release automation must resolve branch requirements from that file
instead of assuming `main`.
## Runtime Verification Claim Levels
Release status language is an evidence contract. Use these three levels and do
not promote a claim beyond the evidence that exists:
1. `implemented`
- The source change and targeted regression proof exist.
- This does not mean a release artifact contains the change or that a live
installation is running it.
2. `release-validated`
- The immutable release artifact containing the change passed its governed
build, artifact, install, and release-pipeline checks.
- Publication, a successful installer, and a displayed version number do
not by themselves prove the affected behavior on real hardware.
3. `live-verified`
- The exact release is running on the named target and a fresh,
machine-readable assertion against the affected live API passed.
- A saved receipt must be independently verified for the expected target,
exact version, assertion, and allowed age before anyone describes the
issue as fixed on hardware, fixed in production, or live verified.
For the Proxmox protection-posture persistence regression, the canonical
collector and verifier are `scripts/release_control/live_runtime_proof.py`.
The collector must query `/api/version` and every page of
`/api/recovery/postures`, require a non-empty successful-posture cohort, and
fail while any workload with `lastSuccessfulPointAt` remains `unknown`. It must
record the target origin, TLS-verification state, exact expected and observed
versions, packaged-versus-development runtime state, UTC collection time,
posture counts, failing resource IDs, and raw response SHA-256 values in a
sealed JSON receipt. Authentication values must be read from named environment
variables rather than command-line values or receipt content.
Example post-install proof:
```bash
export PULSE_LIVE_PROOF_AUTHORIZATION='Bearer <monitoring-read-token>'
python3 scripts/release_control/live_runtime_proof.py collect \
--base-url https://pulse.example.net \
--expected-version 6.2.0-rc.8 \
--authorization-env PULSE_LIVE_PROOF_AUTHORIZATION \
--minimum-postures 1 \
--minimum-successful-postures 1 \
--output /secure/release-evidence/pulse-6.2.0-rc.8-live.json
python3 scripts/release_control/live_runtime_proof.py verify \
--receipt /secure/release-evidence/pulse-6.2.0-rc.8-live.json \
--expected-origin https://pulse.example.net \
--expected-version 6.2.0-rc.8 \
--max-age-seconds 3600
```
The receipt contains operational target identity and may contain resource IDs,
so retain it in the restricted release-evidence location rather than a public
release body. Missing, stale, failed, edited, wrong-target, wrong-version, or
TLS-unverified receipts leave the claim at `implemented` or
`release-validated`; an operator statement cannot substitute for the receipt.
## Prerelease Rules
1. Every candidate intended for broad customer use must ship to `rc` before it

View file

@ -101,40 +101,42 @@ TLS floor in the dynamic config.
59. `scripts/release_control/resolve_release_promotion.py`
60. `scripts/release_control/mobile_release_gate.py`
61. `scripts/release_control/mobile_release_gate_test.py`
62. `scripts/release_candidate_manifest.py`
63. `scripts/release_control/validate_artifact_release_line.py`
63. `scripts/release_ldflags.sh`
64. `scripts/run_cloud_public_signup_smoke.sh`
65. `scripts/run_demo_public_browser_smoke.sh`
66. `scripts/demo_public_browser_smoke.cjs`
67. `scripts/run_hosted_staging_smoke.sh`
68. `scripts/trigger-release-dry-run.sh`
69. `scripts/trigger-release.sh`
70. `scripts/toggle-mock.sh`
71. `deploy/provider-msp/`
72. `deploy/helm/pulse/`
73. `tests/integration/playwright.config.ts`
74. `tests/integration/QUICK_START.md`
75. `tests/integration/README.md`
76. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
77. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
78. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
79. `tests/integration/scripts/hosted-tenant-runtime.mjs`
80. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
81. `tests/integration/scripts/managed-dev-runtime.mjs`
82. `tests/integration/scripts/relay-mobile-token-helper.go`
83. `tests/integration/tests/helpers.ts`
84. `tests/integration/tests/runtime-defaults.ts`
85. `docker-compose.yml`
86. `scripts/install-docker.sh`
87. `scripts/validate-published-release.sh`
88. `scripts/validate-release.sh`
89. `scripts/release_asset_common.sh`
90. `scripts/backfill-release-assets.sh`
91. `.github/workflows/backfill-release-assets.yml`
92. `.github/scripts/check-demo-reachability.sh`
93. `.github/scripts/setup-demo-ssh.sh`
94. `scripts/trigger-stable-patch.sh`
62. `scripts/release_control/live_runtime_proof.py`
63. `scripts/release_control/live_runtime_proof_test.py`
64. `scripts/release_candidate_manifest.py`
65. `scripts/release_control/validate_artifact_release_line.py`
66. `scripts/release_ldflags.sh`
67. `scripts/run_cloud_public_signup_smoke.sh`
68. `scripts/run_demo_public_browser_smoke.sh`
69. `scripts/demo_public_browser_smoke.cjs`
70. `scripts/run_hosted_staging_smoke.sh`
71. `scripts/trigger-release-dry-run.sh`
72. `scripts/trigger-release.sh`
73. `scripts/toggle-mock.sh`
74. `deploy/provider-msp/`
75. `deploy/helm/pulse/`
76. `tests/integration/playwright.config.ts`
77. `tests/integration/QUICK_START.md`
78. `tests/integration/README.md`
79. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
80. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
81. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
82. `tests/integration/scripts/hosted-tenant-runtime.mjs`
83. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
84. `tests/integration/scripts/managed-dev-runtime.mjs`
85. `tests/integration/scripts/relay-mobile-token-helper.go`
86. `tests/integration/tests/helpers.ts`
87. `tests/integration/tests/runtime-defaults.ts`
88. `docker-compose.yml`
89. `scripts/install-docker.sh`
90. `scripts/validate-published-release.sh`
91. `scripts/validate-release.sh`
92. `scripts/release_asset_common.sh`
93. `scripts/backfill-release-assets.sh`
94. `.github/workflows/backfill-release-assets.yml`
95. `.github/scripts/check-demo-reachability.sh`
96. `.github/scripts/setup-demo-ssh.sh`
97. `scripts/trigger-stable-patch.sh`
## Shared Boundaries
@ -389,7 +391,7 @@ upgrade, update, release, or artifact-selection behavior.
## Extension Points
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
Normal releases are single-build promotions. The exact pushed SHA must
produce one release candidate with the policy-required native signing lanes
through `.github/workflows/build-release-candidate.yml` while independent
@ -1603,6 +1605,22 @@ with build evidence; and `mobile-candidate-required` must fail closed until the
mobile candidate exists. This gate does not auto-submit App Store/TestFlight or
Play builds, but it prevents release packets from silently ignoring the mobile
track.
That same release-trust boundary owns the distinction between source proof,
release-artifact proof, and post-install live-runtime proof. Publication or
successful installation may establish `release-validated`; neither may be
described as `live-verified` or fixed on hardware without a fresh passing
receipt from `scripts/release_control/live_runtime_proof.py` for the named
target and exact running version. For the Proxmox protection-posture
persistence regression, collection must fail on an empty successful-posture
cohort or while any posture with `lastSuccessfulPointAt` remains `unknown`.
The receipt must bind the expected and observed versions, normalized target
origin, packaged-versus-development runtime state, TLS verification, UTC
collection time, aggregate posture results, failing resource IDs, and response
SHA-256 values. The verifier must reject failed, stale, edited, source-build,
development-build, TLS-unverified, wrong-target, and wrong-version receipts.
Credential values belong only in named environment variables and must never be
written to command arguments or receipts. A missing or failed receipt is an
enforced lower claim level, not an operator-waivable proof gap.
That same upload boundary must tolerate transient GitHub release-asset API
failures. `.github/workflows/create-release.yml` must retry every
`gh release upload` operation with bounded backoff before failing the release

View file

@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""Collect and verify machine-readable proof from a running Pulse release."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import ssl
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
PROOF_TYPE = "pulse-live-runtime"
ASSERTION = "successful-restore-points-have-evaluated-posture"
DEFAULT_TIMEOUT_SECONDS = 20.0
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class ProofError(RuntimeError):
"""Raised when live proof cannot be collected or verified."""
def _canonical_json(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def _sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _receipt_digest(receipt: dict[str, Any]) -> str:
unsigned = dict(receipt)
unsigned.pop("receiptSha256", None)
return _sha256(_canonical_json(unsigned))
def seal_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
sealed = dict(receipt)
sealed["receiptSha256"] = _receipt_digest(sealed)
return sealed
def _normalize_version(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith("v"):
normalized = normalized[1:]
return normalized
def normalize_origin(value: str) -> str:
parsed = urllib.parse.urlsplit(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ProofError("base URL must be an absolute http:// or https:// URL")
if parsed.username or parsed.password:
raise ProofError("base URL must not contain credentials")
if parsed.query or parsed.fragment:
raise ProofError("base URL must not contain a query or fragment")
if parsed.path not in {"", "/"}:
raise ProofError("base URL must contain only the target origin")
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
def _secret_header(env_name: str, header_name: str) -> dict[str, str]:
if not env_name:
return {}
if not ENV_NAME_PATTERN.fullmatch(env_name):
raise ProofError(f"{header_name} environment variable name is invalid")
value = os.environ.get(env_name, "").strip()
if not value:
raise ProofError(f"{header_name} environment variable {env_name!r} is empty")
return {header_name: value}
def build_headers(authorization_env: str, cookie_env: str) -> dict[str, str]:
headers = {"Accept": "application/json"}
headers.update(_secret_header(authorization_env, "Authorization"))
headers.update(_secret_header(cookie_env, "Cookie"))
return headers
def _read_json_response(
url: str,
*,
headers: dict[str, str],
timeout_seconds: float,
tls_verified: bool,
) -> tuple[dict[str, Any], str]:
request = urllib.request.Request(url, headers=headers, method="GET")
context = None
if urllib.parse.urlsplit(url).scheme == "https" and not tls_verified:
context = ssl._create_unverified_context() # noqa: SLF001 - explicit operator option
try:
with urllib.request.urlopen(request, timeout=timeout_seconds, context=context) as response:
body = response.read(MAX_RESPONSE_BYTES + 1)
if len(body) > MAX_RESPONSE_BYTES:
raise ProofError(f"response exceeded {MAX_RESPONSE_BYTES} bytes")
if response.status != 200:
raise ProofError(f"GET {urllib.parse.urlsplit(url).path} returned HTTP {response.status}")
except urllib.error.HTTPError as exc:
raise ProofError(
f"GET {urllib.parse.urlsplit(url).path} returned HTTP {exc.code}"
) from exc
except urllib.error.URLError as exc:
reason = str(exc.reason) if exc.reason else exc.__class__.__name__
raise ProofError(f"GET {urllib.parse.urlsplit(url).path} failed: {reason}") from exc
except TimeoutError as exc:
raise ProofError(f"GET {urllib.parse.urlsplit(url).path} timed out") from exc
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise ProofError(f"GET {urllib.parse.urlsplit(url).path} returned invalid JSON") from exc
if not isinstance(payload, dict):
raise ProofError(f"GET {urllib.parse.urlsplit(url).path} did not return a JSON object")
return payload, _sha256(body)
def _collect_postures(
origin: str,
*,
headers: dict[str, str],
timeout_seconds: float,
tls_verified: bool,
) -> tuple[list[dict[str, Any]], list[str], int]:
postures: list[dict[str, Any]] = []
response_hashes: list[str] = []
page = 1
expected_total: int | None = None
while True:
url = f"{origin}/api/recovery/postures?page={page}&limit=200"
payload, response_hash = _read_json_response(
url,
headers=headers,
timeout_seconds=timeout_seconds,
tls_verified=tls_verified,
)
data = payload.get("data")
meta = payload.get("meta")
if not isinstance(data, list) or not isinstance(meta, dict):
raise ProofError("posture response must contain data[] and meta{}")
if not all(isinstance(item, dict) for item in data):
raise ProofError("posture response data must contain JSON objects")
total = meta.get("total")
total_pages = meta.get("totalPages")
response_page = meta.get("page")
if not isinstance(total, int) or total < 0:
raise ProofError("posture response meta.total must be a non-negative integer")
if not isinstance(total_pages, int) or total_pages < 0:
raise ProofError("posture response meta.totalPages must be a non-negative integer")
if response_page != page:
raise ProofError(f"posture response returned page {response_page!r}, expected {page}")
if expected_total is None:
expected_total = total
elif total != expected_total:
raise ProofError("posture total changed while proof was being collected; retry")
postures.extend(data)
response_hashes.append(response_hash)
if page >= total_pages:
break
page += 1
if page > 1000:
raise ProofError("posture pagination exceeded 1000 pages")
if expected_total is None:
expected_total = 0
if len(postures) != expected_total:
raise ProofError(
f"posture pagination returned {len(postures)} rows, expected {expected_total}"
)
return postures, response_hashes, expected_total
def evaluate_live_runtime(
*,
expected_version: str,
observed_version: str,
postures: list[dict[str, Any]],
minimum_postures: int,
minimum_successful_postures: int,
) -> tuple[dict[str, Any], list[str]]:
failures: list[str] = []
expected_normalized = _normalize_version(expected_version)
observed_normalized = _normalize_version(observed_version)
if not observed_normalized:
failures.append("running version response was empty")
elif observed_normalized != expected_normalized:
failures.append(
f"running version {observed_version!r} does not match expected {expected_version!r}"
)
state_counts: Counter[str] = Counter()
successful_count = 0
unknown_with_success: list[str] = []
malformed_subjects: list[str] = []
for index, posture in enumerate(postures):
subject_id = posture.get("subjectResourceId")
state = posture.get("state")
if not isinstance(subject_id, str) or not subject_id.strip() or not isinstance(state, str):
malformed_subjects.append(str(index))
continue
state_counts[state] += 1
if posture.get("lastSuccessfulPointAt"):
successful_count += 1
if state == "unknown":
unknown_with_success.append(subject_id)
if malformed_subjects:
failures.append(
f"{len(malformed_subjects)} posture rows lacked a valid subjectResourceId or state"
)
if len(postures) < minimum_postures:
failures.append(
f"posture count {len(postures)} is below required minimum {minimum_postures}"
)
if successful_count < minimum_successful_postures:
failures.append(
"successful-posture count "
f"{successful_count} is below required minimum {minimum_successful_postures}"
)
if unknown_with_success:
failures.append(
f"{len(unknown_with_success)} workloads with successful restore points remain unknown"
)
observed = {
"version": observed_version,
"postureTotal": len(postures),
"stateCounts": dict(sorted(state_counts.items())),
"successfulPostureCount": successful_count,
"unknownWithSuccessfulPointCount": len(unknown_with_success),
"unknownWithSuccessfulPointResourceIds": sorted(unknown_with_success),
}
return observed, failures
def collect_receipt(args: argparse.Namespace) -> dict[str, Any]:
origin = normalize_origin(args.base_url)
headers = build_headers(args.authorization_env, args.cookie_env)
tls_verified = urllib.parse.urlsplit(origin).scheme == "https" and not args.insecure
version_payload, version_hash = _read_json_response(
f"{origin}/api/version",
headers=headers,
timeout_seconds=args.timeout_seconds,
tls_verified=tls_verified,
)
observed_version = version_payload.get("version")
if not isinstance(observed_version, str):
raise ProofError("version response must contain a string version")
postures, posture_hashes, posture_total = _collect_postures(
origin,
headers=headers,
timeout_seconds=args.timeout_seconds,
tls_verified=tls_verified,
)
observed, failures = evaluate_live_runtime(
expected_version=args.expected_version,
observed_version=observed_version,
postures=postures,
minimum_postures=args.minimum_postures,
minimum_successful_postures=args.minimum_successful_postures,
)
is_source_build = version_payload.get("isSourceBuild") is True
is_development = version_payload.get("isDevelopment") is True
if is_source_build:
failures.append("running target reports a source build, not a release artifact")
if is_development:
failures.append("running target reports a development build, not a release artifact")
observed.update(
{
"build": version_payload.get("build", ""),
"channel": version_payload.get("channel", ""),
"deploymentType": version_payload.get("deploymentType", ""),
"isSourceBuild": is_source_build,
"isDevelopment": is_development,
"versionResponseSha256": version_hash,
"postureResponseSha256": posture_hashes,
}
)
if posture_total != observed["postureTotal"]:
raise ProofError("posture response total changed during evaluation")
receipt = {
"schemaVersion": SCHEMA_VERSION,
"proofType": PROOF_TYPE,
"assertion": ASSERTION,
"result": "passed" if not failures else "failed",
"collectedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"target": {
"origin": origin,
"tlsVerified": tls_verified,
},
"expected": {
"version": args.expected_version,
"minimumPostures": args.minimum_postures,
"minimumSuccessfulPostures": args.minimum_successful_postures,
},
"observed": observed,
"failures": failures,
}
return seal_receipt(receipt)
def verify_receipt(
receipt: dict[str, Any],
*,
expected_version: str,
expected_origin: str,
max_age_seconds: int,
now: datetime | None = None,
) -> list[str]:
errors: list[str] = []
if receipt.get("schemaVersion") != SCHEMA_VERSION:
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
if receipt.get("proofType") != PROOF_TYPE:
errors.append(f"proofType must be {PROOF_TYPE!r}")
if receipt.get("assertion") != ASSERTION:
errors.append(f"assertion must be {ASSERTION!r}")
if receipt.get("receiptSha256") != _receipt_digest(receipt):
errors.append("receiptSha256 does not match receipt content")
if receipt.get("result") != "passed":
errors.append("receipt result is not passed")
if receipt.get("failures") != []:
errors.append("receipt contains assertion failures")
expected = receipt.get("expected")
observed = receipt.get("observed")
target = receipt.get("target")
if not isinstance(expected, dict) or not isinstance(observed, dict):
errors.append("receipt expected and observed fields must be objects")
else:
receipt_expected = expected.get("version")
observed_version = observed.get("version")
if _normalize_version(str(receipt_expected or "")) != _normalize_version(expected_version):
errors.append("receipt expected version does not match verifier expectation")
if _normalize_version(str(observed_version or "")) != _normalize_version(expected_version):
errors.append("receipt observed version does not match verifier expectation")
if observed.get("unknownWithSuccessfulPointCount") != 0:
errors.append("receipt reports unknown postures with successful restore points")
if observed.get("isSourceBuild") is not False:
errors.append("receipt does not prove a packaged release artifact")
if observed.get("isDevelopment") is not False:
errors.append("receipt reports a development runtime")
minimum_postures = expected.get("minimumPostures")
minimum_successful = expected.get("minimumSuccessfulPostures")
posture_total = observed.get("postureTotal")
successful_total = observed.get("successfulPostureCount")
state_counts = observed.get("stateCounts")
unknown_ids = observed.get("unknownWithSuccessfulPointResourceIds")
if not isinstance(minimum_postures, int) or minimum_postures < 1:
errors.append("receipt minimumPostures must be a positive integer")
elif not isinstance(posture_total, int) or posture_total < minimum_postures:
errors.append("receipt posture total is below its required minimum")
if not isinstance(minimum_successful, int) or minimum_successful < 1:
errors.append("receipt minimumSuccessfulPostures must be a positive integer")
elif not isinstance(successful_total, int) or successful_total < minimum_successful:
errors.append("receipt successful-posture total is below its required minimum")
if not isinstance(state_counts, dict) or not all(
isinstance(state, str) and isinstance(count, int) and count >= 0
for state, count in state_counts.items()
):
errors.append("receipt stateCounts must contain non-negative integer counts")
elif isinstance(posture_total, int) and sum(state_counts.values()) != posture_total:
errors.append("receipt stateCounts do not sum to postureTotal")
if unknown_ids != []:
errors.append("receipt contains unknown successful-point resource IDs")
version_hash = observed.get("versionResponseSha256")
posture_hashes = observed.get("postureResponseSha256")
sha256_pattern = re.compile(r"^[0-9a-f]{64}$")
if not isinstance(version_hash, str) or not sha256_pattern.fullmatch(version_hash):
errors.append("receipt version response SHA-256 is invalid")
if not isinstance(posture_hashes, list) or not posture_hashes or not all(
isinstance(value, str) and sha256_pattern.fullmatch(value)
for value in posture_hashes
):
errors.append("receipt posture response SHA-256 list is invalid")
try:
normalized_expected_origin = normalize_origin(expected_origin)
except ProofError as exc:
errors.append(str(exc))
normalized_expected_origin = ""
if not isinstance(target, dict):
errors.append("receipt target must be an object")
else:
if target.get("origin") != normalized_expected_origin:
errors.append("receipt target origin does not match verifier expectation")
if target.get("tlsVerified") is not True:
errors.append("receipt did not verify target TLS")
collected_at = receipt.get("collectedAt")
try:
collected = datetime.fromisoformat(str(collected_at).replace("Z", "+00:00"))
if collected.tzinfo is None:
raise ValueError("timestamp has no timezone")
reference_now = now or datetime.now(timezone.utc)
age_seconds = (reference_now - collected).total_seconds()
if age_seconds < -300:
errors.append("receipt timestamp is more than five minutes in the future")
if age_seconds > max_age_seconds:
errors.append(
f"receipt age {int(age_seconds)}s exceeds maximum {max_age_seconds}s"
)
except (TypeError, ValueError):
errors.append("receipt collectedAt is not a valid timezone-aware timestamp")
return errors
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
temporary = Path(handle.name)
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
os.replace(temporary, path)
def _load_receipt(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise ProofError(f"could not read receipt: {exc}") from exc
except json.JSONDecodeError as exc:
raise ProofError("receipt is not valid JSON") from exc
if not isinstance(payload, dict):
raise ProofError("receipt must be a JSON object")
return payload
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Collect or verify live hardware proof. A published or installed release is not "
"live-verified until this command produces a passing receipt."
)
)
subparsers = parser.add_subparsers(dest="command", required=True)
collect = subparsers.add_parser("collect", help="query a running Pulse target")
collect.add_argument("--base-url", required=True, help="target origin, without a path")
collect.add_argument("--expected-version", required=True)
collect.add_argument("--output", required=True, type=Path)
collect.add_argument("--authorization-env", default="")
collect.add_argument("--cookie-env", default="")
collect.add_argument("--minimum-postures", type=int, default=1)
collect.add_argument("--minimum-successful-postures", type=int, default=1)
collect.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS)
collect.add_argument(
"--insecure",
action="store_true",
help="disable TLS verification; receipts collected this way cannot be verified",
)
verify = subparsers.add_parser("verify", help="validate a saved proof receipt")
verify.add_argument("--receipt", required=True, type=Path)
verify.add_argument("--expected-version", required=True)
verify.add_argument("--expected-origin", required=True)
verify.add_argument("--max-age-seconds", required=True, type=int)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.command == "collect":
if args.minimum_postures < 1 or args.minimum_successful_postures < 1:
raise ProofError("minimum posture counts must be positive")
if args.timeout_seconds <= 0:
raise ProofError("timeout must be positive")
receipt = collect_receipt(args)
_write_json_atomic(args.output, receipt)
print(f"[{receipt['result'].upper()}] Live runtime proof: {args.output}")
for failure in receipt["failures"]:
print(f"ERROR: {failure}", file=sys.stderr)
return 0 if receipt["result"] == "passed" else 1
if args.max_age_seconds <= 0:
raise ProofError("max age must be positive")
receipt = _load_receipt(args.receipt)
errors = verify_receipt(
receipt,
expected_version=args.expected_version,
expected_origin=args.expected_origin,
max_age_seconds=args.max_age_seconds,
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"[PASSED] Verified live runtime proof: {args.receipt}")
return 0
except ProofError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Tests for live runtime release proof collection and verification."""
from __future__ import annotations
import argparse
import json
import threading
import unittest
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest import mock
from live_runtime_proof import collect_receipt, evaluate_live_runtime, seal_receipt, verify_receipt
class _ProofHandler(BaseHTTPRequestHandler):
authorization = "Bearer proof-token"
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
if self.headers.get("Authorization") != self.authorization:
self.send_response(401)
self.end_headers()
return
if self.path == "/api/version":
self._json(
{
"version": "6.2.0-rc.8",
"build": "proof-build",
"channel": "rc",
"isSourceBuild": False,
"isDevelopment": False,
}
)
return
if self.path == "/api/recovery/postures?page=1&limit=200":
self._json(
{
"data": [
{
"subjectResourceId": "vm-100",
"state": "protected",
"lastSuccessfulPointAt": "2026-08-04T10:00:00Z",
},
{
"subjectResourceId": "vm-101",
"state": "attention",
"lastSuccessfulPointAt": "2026-08-03T10:00:00Z",
},
],
"meta": {"page": 1, "limit": 200, "total": 2, "totalPages": 1},
}
)
return
self.send_response(404)
self.end_headers()
def _json(self, payload: object) -> None:
encoded = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, format: str, *args: object) -> None:
return
class LiveRuntimeProofTest(unittest.TestCase):
def test_evaluation_rejects_unknown_with_successful_point(self) -> None:
observed, failures = evaluate_live_runtime(
expected_version="6.2.0-rc.8",
observed_version="6.2.0-rc.8",
postures=[
{
"subjectResourceId": "vm-100",
"state": "unknown",
"lastSuccessfulPointAt": "2026-08-04T10:00:00Z",
}
],
minimum_postures=1,
minimum_successful_postures=1,
)
self.assertEqual(observed["unknownWithSuccessfulPointCount"], 1)
self.assertTrue(any("remain unknown" in failure for failure in failures))
def test_evaluation_rejects_version_mismatch_and_empty_dataset(self) -> None:
_, failures = evaluate_live_runtime(
expected_version="6.2.0-rc.8",
observed_version="6.2.0-rc.7",
postures=[],
minimum_postures=1,
minimum_successful_postures=1,
)
self.assertEqual(len(failures), 3)
self.assertTrue(any("does not match" in failure for failure in failures))
def test_collects_passing_receipt_from_running_target(self) -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), _ProofHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
with mock.patch.dict(
"os.environ", {"PULSE_PROOF_TEST_AUTH": _ProofHandler.authorization}
):
receipt = collect_receipt(
argparse.Namespace(
base_url=f"http://127.0.0.1:{server.server_port}",
expected_version="6.2.0-rc.8",
authorization_env="PULSE_PROOF_TEST_AUTH",
cookie_env="",
insecure=False,
timeout_seconds=2.0,
minimum_postures=2,
minimum_successful_postures=2,
)
)
self.assertEqual(receipt["result"], "passed")
self.assertEqual(receipt["observed"]["stateCounts"], {"attention": 1, "protected": 1})
self.assertEqual(receipt["observed"]["unknownWithSuccessfulPointCount"], 0)
self.assertEqual(len(receipt["observed"]["postureResponseSha256"]), 1)
def test_verifier_rejects_tampering(self) -> None:
receipt = self._passing_receipt()
receipt["observed"]["unknownWithSuccessfulPointCount"] = 3
errors = verify_receipt(
receipt,
expected_version="6.2.0-rc.8",
expected_origin="https://pulse.example.test",
max_age_seconds=3600,
now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc),
)
self.assertIn("receiptSha256 does not match receipt content", errors)
self.assertIn("receipt reports unknown postures with successful restore points", errors)
def test_verifier_rejects_wrong_target_and_stale_receipt(self) -> None:
receipt = self._passing_receipt()
errors = verify_receipt(
receipt,
expected_version="6.2.0-rc.8",
expected_origin="https://other.example.test",
max_age_seconds=60,
now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc),
)
self.assertIn("receipt target origin does not match verifier expectation", errors)
self.assertTrue(any("exceeds maximum" in error for error in errors))
def test_verifier_accepts_fresh_sealed_receipt(self) -> None:
errors = verify_receipt(
self._passing_receipt(),
expected_version="v6.2.0-rc.8",
expected_origin="https://pulse.example.test/",
max_age_seconds=3600,
now=datetime(2026, 8, 4, 12, 30, tzinfo=timezone.utc),
)
self.assertEqual(errors, [])
@staticmethod
def _passing_receipt() -> dict[str, object]:
return seal_receipt(
{
"schemaVersion": 1,
"proofType": "pulse-live-runtime",
"assertion": "successful-restore-points-have-evaluated-posture",
"result": "passed",
"collectedAt": "2026-08-04T12:00:00Z",
"target": {"origin": "https://pulse.example.test", "tlsVerified": True},
"expected": {
"version": "6.2.0-rc.8",
"minimumPostures": 1,
"minimumSuccessfulPostures": 1,
},
"observed": {
"version": "6.2.0-rc.8",
"postureTotal": 2,
"stateCounts": {"attention": 1, "protected": 1},
"successfulPostureCount": 2,
"unknownWithSuccessfulPointCount": 0,
"unknownWithSuccessfulPointResourceIds": [],
"isSourceBuild": False,
"isDevelopment": False,
"versionResponseSha256": "a" * 64,
"postureResponseSha256": ["b" * 64],
},
"failures": [],
}
)
if __name__ == "__main__":
unittest.main()

View file

@ -10,6 +10,7 @@ import unittest
import json
import record_rc_to_ga_blocked as blocked_record
from live_runtime_proof import evaluate_live_runtime
from release_promotion_policy_support import (
REQUIRED_STAGED_GOVERNANCE_INPUTS,
promotion_metadata_envelope,
@ -157,6 +158,28 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("public RC tag", content)
self.assertIn("license.pulserelay.pro/pulse-pro:6.0.0", content)
self.assertIn("moving branch", content)
self.assertIn("`implemented`", content)
self.assertIn("`release-validated`", content)
self.assertIn("`live-verified`", content)
self.assertIn("scripts/release_control/live_runtime_proof.py", content)
self.assertIn("an operator statement cannot substitute for the receipt", content)
def test_live_runtime_claim_rejects_unknown_successful_posture(self) -> None:
observed, failures = evaluate_live_runtime(
expected_version="6.2.0-rc.8",
observed_version="6.2.0-rc.8",
postures=[
{
"subjectResourceId": "vm-100",
"state": "unknown",
"lastSuccessfulPointAt": "2026-08-04T10:00:00Z",
}
],
minimum_postures=1,
minimum_successful_postures=1,
)
self.assertEqual(observed["unknownWithSuccessfulPointCount"], 1)
self.assertTrue(any("remain unknown" in failure for failure in failures))
def test_v6_ga_owner_risk_exception_is_bounded_and_packet_aligned(self) -> None:
policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md")