diff --git a/.echo/certification.json b/.echo/certification.json new file mode 100644 index 0000000..b06c33e --- /dev/null +++ b/.echo/certification.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "journey": ["python3", "-B", "scripts/certforge_journey.py"] +} diff --git a/.echo/sdk.json b/.echo/sdk.json index 2d1fe37..aa7503b 100644 --- a/.echo/sdk.json +++ b/.echo/sdk.json @@ -46,9 +46,11 @@ "echo.certforge.projects.create", "echo.certforge.projects.list", "echo.certforge.public_verification", + "echo.certforge.publish", "echo.certforge.r5.status", "echo.certforge.r5.submit_async", "echo.certforge.run", + "echo.certforge.runtime_environment", "echo.certforge.service_status", "echo.certforge.status", "echo.certforge.submit", diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8b23b..4d2f4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0] - 2026-08-30 + +### Added + +- A pinned-key-signed production E2E gate required before any `PRODUCTION_READY` verdict. +- Exact E2E bindings for source, deployment, target, environment, accounts, and clients. + All notable changes to Echo Certification Forge are documented here. Versions follow Semantic Versioning; dates use ISO 8601. diff --git a/artifacts/p1_acceptance_report.json b/artifacts/p1_acceptance_report.json index 771a206..1e54769 100644 --- a/artifacts/p1_acceptance_report.json +++ b/artifacts/p1_acceptance_report.json @@ -1,10 +1,10 @@ { - "manifest_digest": "0ca325c3e6d39b7f22cdd3d01068c8f99dcd976c4422200b5b4d4cfe452f29f7", + "manifest_digest": "9e9f963377374e827ccc9c8b07ea82cc7966e25a2392838997d3c1371f5a4e5c", "manifest_id": "certforge.release-strict.v1", "passed": true, "phase": "P1", - "platform": "Windows-11-10.0.22631-SP0", - "python": "3.13.0 (tags/v3.13.0:60403a5, Oct 7 2024, 09:38:07) [MSC v.1941 64 bit (AMD64)]", + "platform": "Windows-11-10.0.26200-SP0", + "python": "3.12.10 (tags/v3.12.10:0cc8128, Apr 8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)]", "scenarios": { "default_block": { "passed": true, @@ -17,27 +17,30 @@ "mandatory_rule_missing:environment_identity", "mandatory_rule_missing:evidence_integrity", "mandatory_rule_missing:immutable_target_identity", + "mandatory_rule_missing:production_e2e", "mandatory_rule_missing:runner_control_channel", "mandatory_rule_missing:signing_authority_separation", "mandatory_rule_missing:tenant_isolation", + "production_e2e_attestation_missing", "run_outcome_inconclusive" ], "verdict": "NOT_READY" }, + "source_only_blocks": { + "deploy_gate_allowed": false, + "evidence_merkle_root": "41a4fc282a478767dc16f6c64128c93ab2535866e620a3f5b0ae922220ffb449", + "passed": true, + "payload_sha256": "a7255f5bae53fcd49b1e049b3edd1feb6c9b03f4520b0e2abb0e44f0da7138e7", + "signing_key_id": "ed25519:e222f1c31e85f1b8a6d8cfed30fd2300", + "verdict": "NOT_READY" + }, "tamper_blocks": { "deploy_gate_allowed": false, "passed": true, "reasons": [ - "current_evidence_invalid" + "current_evidence_invalid", + "verdict_not_production_ready" ] - }, - "verified_ready": { - "deploy_gate_allowed": true, - "evidence_merkle_root": "438efda29a515669d0db0472f6527c201c55d0d80afebf2963123d11c77a7d73", - "passed": true, - "payload_sha256": "db3fe179c7c950a9ffaf90cfef6d475fc915af87f0d1b10a85071c7d8411b975", - "signing_key_id": "ed25519:4592da038060823a957506d477e49114", - "verdict": "PRODUCTION_READY" } }, "schema_version": "1.0.0" diff --git a/artifacts/p6_acceptance.json b/artifacts/p6_acceptance.json index cfbd348..13c22df 100644 --- a/artifacts/p6_acceptance.json +++ b/artifacts/p6_acceptance.json @@ -1,8 +1,8 @@ { "phase": "P6", "title": "deployment enforcement and platform integration \u2014 live local acceptance", - "generated_at": "2026-07-26T02:18:04.411111Z", - "python": "3.13.0 (tags/v3.13.0:60403a5, Oct 7 2024, 09:38:07) [MSC v.1941 64 bit (AMD64)]", + "generated_at": "2026-08-30T12:40:31.960114Z", + "python": "3.12.10 (tags/v3.12.10:0cc8128, Apr 8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)]", "passed": true, "checks": [ { @@ -11,7 +11,7 @@ "passed": true, "detail": { "exit": 2, - "stdout": "{\"admission_id\": \"dep-3413cd5f5334d3b106b9ce81f7c7a26b\", \"allowed\": false, \"artifact_sha256\": \"cc9a65d96c25c8f96ba720e01628b734d6df4f5a1057238506ab6d504fb998fd\", \"chain_hash\": \"ab88eb7a583dc68353ca39d53cd4e37d9737ac4b6fa0e6c9b0ab8c659325a64d\", \"created_at\": \"2026-07-26T02:17:59.537952Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a1\", \"reasons\": [\"artifact_not_certified\", \"staging_acceptance_missing\"], \"rollback_candidate\": null, \"run_id\": null, \"tenant_id\": \"tenant-alpha\"}" + "stdout": "{\"admission_id\": \"dep-39149ed9c3643c355bbe8f4f09eec260\", \"allowed\": false, \"artifact_sha256\": \"cc9a65d96c25c8f96ba720e01628b734d6df4f5a1057238506ab6d504fb998fd\", \"chain_hash\": \"ff5f335a43355fdc85e2b64c11e7429ad3aa9070dca78655a74bf0d929da28b0\", \"created_at\": \"2026-08-30T12:40:27.987414Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a1\", \"reasons\": [\"artifact_not_certified\", \"staging_acceptance_missing\"], \"rollback_candidate\": null, \"run_id\": null, \"tenant_id\": \"tenant-alpha\"}" } }, { @@ -29,7 +29,7 @@ "passed": true, "detail": { "exit": 2, - "stdout": "{\"admission_id\": \"dep-f49980b48837ae58dec6fa3ffbc216e4\", \"allowed\": false, \"artifact_sha256\": \"11d7d626a19af7cf0b64c0862b3214a15fac77e3aa1400d70e6902f1a77fdb86\", \"chain_hash\": \"40c1d83374649745ee6fcd3c42c58ce5b6257e362aa6e22954f224c7284b61f5\", \"created_at\": \"2026-07-26T02:17:59.744469Z\", \"deployment_environment\": \"staging\", \"deployment_id\": \"deploy-a2\", \"reasons\": [\"artifact_not_certified\"], \"rollback_candidate\": null, \"run_id\": null, \"tenant_id\": \"tenant-alpha\"}" + "stdout": "{\"admission_id\": \"dep-de226c922ca25e4fe770043fa5cce489\", \"allowed\": false, \"artifact_sha256\": \"11d7d626a19af7cf0b64c0862b3214a15fac77e3aa1400d70e6902f1a77fdb86\", \"chain_hash\": \"1b08dbf5ba6527bbb6b743ab658e726bb487454d6ab95bd8ec1ec7889764501a\", \"created_at\": \"2026-08-30T12:40:28.129813Z\", \"deployment_environment\": \"staging\", \"deployment_id\": \"deploy-a2\", \"reasons\": [\"artifact_not_certified\"], \"rollback_candidate\": null, \"run_id\": null, \"tenant_id\": \"tenant-alpha\"}" } }, { @@ -38,7 +38,7 @@ "passed": true, "detail": { "exit": 2, - "stdout": "{\"admission_id\": \"dep-8ac38cbd2ade07ff1bb5c59d63d2582c\", \"allowed\": false, \"artifact_sha256\": \"67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0\", \"chain_hash\": \"5d31c6a43518753dc06259bee965f1e8fb91e4114f86c47bf61adde8a84ff4dd\", \"created_at\": \"2026-07-26T02:17:59.912909Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a4\", \"reasons\": [\"staging_acceptance_missing\"], \"rollback_candidate\": null, \"run_id\": \"cert-p6-v1\", \"tenant_id\": \"tenant-alpha\"}" + "stdout": "{\"admission_id\": \"dep-393b3f1c13aeaf883a659c67fe3a6062\", \"allowed\": false, \"artifact_sha256\": \"67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0\", \"chain_hash\": \"4adc5c6ca6c45d5664c517904d6b86304b4ae573fa525cef6397e6a315d30422\", \"created_at\": \"2026-08-30T12:40:28.260911Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a4\", \"reasons\": [\"staging_acceptance_missing\"], \"rollback_candidate\": null, \"run_id\": \"cert-p6-v1\", \"tenant_id\": \"tenant-alpha\"}" } }, { @@ -57,8 +57,8 @@ "passed": true, "detail": { "failure_candidate": { - "admission_id": "dep-c3f042d865f9fcd3c85e9d0a1c91484a", - "admitted_at": "2026-07-26T02:18:00.438507Z", + "admission_id": "dep-88177f711ac3ea76d71d3f5657753a8a", + "admitted_at": "2026-08-30T12:40:28.640308Z", "artifact_sha256": "67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0", "deployment_id": "deploy-a3-prd", "run_id": "cert-p6-v1" @@ -66,12 +66,12 @@ "rollback_target": { "artifact_sha256": "67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0", "run_id": "cert-p6-v1", - "admission_id": "dep-c3f042d865f9fcd3c85e9d0a1c91484a", + "admission_id": "dep-88177f711ac3ea76d71d3f5657753a8a", "deployment_id": "deploy-a3-prd", - "admitted_at": "2026-07-26T02:18:00.438507Z" + "admitted_at": "2026-08-30T12:40:28.640308Z" }, "rolled_back": { - "admission_id": "dep-d1f547a64a4ddb086ca7a521e0d1a4ce", + "admission_id": "dep-368a34d3be4daeb9870a2b93e41ebf45", "artifact_sha256": "6cf9eb2e75fbaf4037393188b98be82a05499dcfc0c45a6eeeb840bd7f775c20", "deployment_environment": "production", "detail": "restored v1", @@ -87,7 +87,7 @@ "passed": true, "detail": { "exit": 2, - "stdout": "{\"admission_id\": \"dep-4ec864d16bf5447abf4145975d62ed0a\", \"allowed\": false, \"artifact_sha256\": \"67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0\", \"chain_hash\": \"928bee7494d599c95422e4d3f6c8db2a7e80c2ec1ff5837056aead46be211115\", \"created_at\": \"2026-07-26T02:18:01.842335Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a5\", \"reasons\": [\"verdict_revoked\"], \"rollback_candidate\": null, \"run_id\": \"cert-p6-v1\", \"tenant_id\": \"tenant-alpha\"}" + "stdout": "{\"admission_id\": \"dep-1d051283684978484ff174d2f3cc5b7b\", \"allowed\": false, \"artifact_sha256\": \"67b7548d601f8ca9e8689fced7d40d8f407894ac7d6a2c09d2e524ee5514e7b0\", \"chain_hash\": \"8cbb0ce94045d583aaaaa7e150f5a997759f188f1de824d89f973711ef9a5754\", \"created_at\": \"2026-08-30T12:40:29.545903Z\", \"deployment_environment\": \"production\", \"deployment_id\": \"deploy-a5\", \"reasons\": [\"verdict_revoked\"], \"rollback_candidate\": null, \"run_id\": \"cert-p6-v1\", \"tenant_id\": \"tenant-alpha\"}" } }, { diff --git a/artifacts/p6_acceptance.summary.json b/artifacts/p6_acceptance.summary.json index 2c77777..ac231fd 100644 --- a/artifacts/p6_acceptance.summary.json +++ b/artifacts/p6_acceptance.summary.json @@ -3,5 +3,5 @@ "passed": true, "checks_total": 12, "checks_passed": 12, - "generated_at": "2026-07-26T02:18:04.411111Z" + "generated_at": "2026-08-30T12:40:31.960114Z" } diff --git a/contracts/certforge-capability-surface.v2.json b/contracts/certforge-capability-surface.v2.json index a1851fe..2ceef52 100644 --- a/contracts/certforge-capability-surface.v2.json +++ b/contracts/certforge-capability-surface.v2.json @@ -4,6 +4,7 @@ "capabilities": { "GET /healthz": "echo.certforge.health", "GET /v1/status": "echo.certforge.service_status", + "POST /v1/subscriber/runtime/environment": "echo.certforge.runtime_environment", "GET /v1/certifications": "echo.certforge.list_runs", "POST /v1/certifications": "echo.certforge.submit", "GET /v1/certifications/{run_id}": "echo.certforge.status", diff --git a/contracts/certforge-sdk-capabilities.v1.json b/contracts/certforge-sdk-capabilities.v1.json index e3522ea..7e009b6 100644 --- a/contracts/certforge-sdk-capabilities.v1.json +++ b/contracts/certforge-sdk-capabilities.v1.json @@ -1412,6 +1412,33 @@ "type": "object" } }, + "echo.certforge.publish": { + "input_schema": { + "additionalProperties": false, + "properties": { + "command": { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "run_id": { + "type": "string" + } + }, + "required": [ + "command", + "run_id" + ], + "type": "object" + }, + "operation": "POST /v1/subscriber/certifications/{run_id}/publish", + "output_schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, "echo.certforge.r5.status": { "input_schema": { "additionalProperties": false, @@ -1717,6 +1744,149 @@ "type": "object" } }, + "echo.certforge.runtime_environment": { + "input_schema": { + "additionalProperties": false, + "properties": { + "command": { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "journey": { + "anyOf": [ + { + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\x00]*$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "operation": "POST /v1/subscriber/runtime/environment", + "output_schema": { + "additionalProperties": false, + "properties": { + "environment": { + "additionalProperties": false, + "properties": { + "adapter_set_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "egress_policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "harness_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "model_route_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "os_runtime_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "prompt_set_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "runner_image_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "test_plan_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "runner_image_sha256", + "adapter_set_sha256", + "test_plan_sha256", + "policy_sha256", + "harness_sha256", + "prompt_set_sha256", + "model_route_sha256", + "os_runtime_sha256", + "egress_policy_sha256" + ], + "type": "object" + }, + "journey": { + "anyOf": [ + { + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\x00]*$", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "const": "certforge.runtime.v1", + "type": "string" + }, + "submit_environment": { + "additionalProperties": false, + "properties": { + "identity_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "runner_image_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "identity_digest" + ], + "type": "object" + } + }, + "required": [ + "journey", + "environment", + "submit_environment" + ], + "type": "object" + } + }, "echo.certforge.service_status": { "input_schema": { "additionalProperties": false, @@ -2295,7 +2465,7 @@ } } }, - "capability_count": 60, + "capability_count": 62, "policy": "Every Certification Forge SDK action has a command-bearing strict input schema and an output schema derived from its production route contract.", "schema_version": "1.0.0" } diff --git a/contracts/schemas/certification-run.v1.json b/contracts/schemas/certification-run.v1.json index 3e4f4b0..4797ffd 100644 --- a/contracts/schemas/certification-run.v1.json +++ b/contracts/schemas/certification-run.v1.json @@ -3,7 +3,7 @@ "$id": "https://cert.echoforge.com/contracts/certification-run.v1.json", "title": "CertificationRunV1", "type": "object", - "required": ["run_id", "tenant_id", "state", "run_outcome", "release_verdict", "target_identity_digest", "environment_identity_digest", "policy_version"], + "required": ["run_id", "tenant_id", "state", "run_outcome", "release_verdict", "target_identity_digest", "environment_identity_digest", "policy_version", "production_e2e"], "properties": { "run_id": {"type": "string", "minLength": 1, "maxLength": 128}, "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -14,6 +14,25 @@ "environment_identity_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, "policy_version": {"type": "string", "minLength": 1, "maxLength": 128}, "evidence_merkle_root": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "production_e2e": { + "type": "object", + "required": ["verified", "profile", "attestation_id", "source_commit", "deployment_sha", "target_identity_digest", "environment_identity_digest", "canonical_target", "tool_count", "signature_verified", "collector_key_id", "attestation_envelope_sha256"], + "properties": { + "verified": {"type": "boolean"}, + "profile": {"type": ["string", "null"]}, + "attestation_id": {"type": ["string", "null"]}, + "source_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}(?:[0-9a-f]{24})?$"}, + "deployment_sha": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}(?:[0-9a-f]{24})?$"}, + "target_identity_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "environment_identity_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "canonical_target": {"type": ["string", "null"]}, + "tool_count": {"type": ["integer", "null"], "minimum": 0}, + "signature_verified": {"type": "boolean"}, + "collector_key_id": {"type": ["string", "null"], "pattern": "^ed25519:[0-9a-f]{32}$"}, + "attestation_envelope_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + }, + "additionalProperties": false + }, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"} }, diff --git a/deploy/deploy_forge.sh b/deploy/deploy_forge.sh index a266eba..8617b9d 100644 --- a/deploy/deploy_forge.sh +++ b/deploy/deploy_forge.sh @@ -7,6 +7,10 @@ set -euo pipefail SOURCE_REPO="${CERTFORGE_SOURCE_REPO:-$(cd "$(dirname "$0")/.." && pwd)}" STAGING_PORT="${CERTFORGE_STAGING_PORT:-8311}" PROD_PORT="${CERTFORGE_PROD_PORT:-8309}" +# The governed Echo monorepo is roughly 1 GiB and can require more than five +# minutes to materialize under normal FORGE disk contention. Keep a finite, +# supported ceiling while avoiding false infrastructure failures at 300 seconds. +GIT_ACQUISITION_TIMEOUT_SECONDS="${ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS:-900}" SERVICE="echo-certforge" DISPATCH_SERVICE="echo-certforge-dispatcher" BRANCH="${CERTFORGE_BRANCH:-main}" @@ -14,6 +18,8 @@ EXPECTED_COMMIT_SHA="${CERTFORGE_EXPECTED_COMMIT_SHA:-}" RELEASE_ROOT="${CERTFORGE_RELEASE_ROOT:-/home/forge/echo-certification-forge-releases}" CURRENT_LINK="${CERTFORGE_CURRENT_LINK:-/home/forge/echo-certification-forge-current}" STATE_ROOT="${CERTFORGE_STATE_ROOT:-/home/forge/echo-certification-forge/var}" +PRODUCTION_E2E_ATTESTATION_DIR="${ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR:-$STATE_ROOT/production-e2e/attestations}" +PRODUCTION_E2E_TRUSTED_KEYS="${ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS:-$STATE_ROOT/production-e2e/trusted-public-keys}" # Readiness waits poll /healthz every 0.5s and return on the first healthy probe. # 20s proved too short for a cold uvicorn boot on a loaded FORGE (staging abort, # false rollback-restore failure, needless production rollback). @@ -21,13 +27,19 @@ READY_TIMEOUT_S="${CERTFORGE_READY_TIMEOUT_S:-90}" READY_POLLS=$(( READY_TIMEOUT_S * 2 )) ADAPTER_DIR="${ECHO_CERTFORGE_PROD_ADAPTER_DIR:-$STATE_ROOT/p5}" ADAPTER_MODE="${CERTFORGE_ADAPTER_MODE:-required}" -TRUSTED_MANIFEST_SHA256="${ECHO_CERTFORGE_TRUSTED_MANIFEST_SHA256:-7dc98e0e95e6dd2c000ec069a8c46c4d1d49a4fe869ad4eae25e059d103644f4}" +# Canonical-JSON digest of policies/mandatory-rules.v2.json (== run_worker._PRODUCTION_MANIFEST_SHA256). +# The old default (965106b0...) was the raw file-bytes digest, which run_worker rejects. +TRUSTED_MANIFEST_SHA256="${ECHO_CERTFORGE_TRUSTED_MANIFEST_SHA256:-08ba068ceb3e14bfed2690337edbb94c546e3e0a1a89b1321f7657653d8eea43}" UNIT_PATH="/etc/systemd/system/$SERVICE.service" DISPATCH_UNIT_PATH="/etc/systemd/system/$DISPATCH_SERVICE.service" RELEASE_DROPIN="/etc/systemd/system/$SERVICE.service.d/10-release.conf" DISPATCH_RELEASE_DROPIN="/etc/systemd/system/$DISPATCH_SERVICE.service.d/10-release.conf" ENV_FILE="${CERTFORGE_ENV_FILE:-/home/forge/.config/echo/certforge.env}" -GITC=(-c credential.helper= -c credential.helper="store --file=/home/forge/.config/echo/omega_git_creds") +# Source fetch credentials. Default: the FORGE credential store. Repositories on the +# echoomegaprime account are fetched with a repo-scoped GitHub App helper instead +# (CERTFORGE_GIT_CREDENTIAL_HELPER), because that store cannot see them. +GIT_CREDENTIAL_HELPER="${CERTFORGE_GIT_CREDENTIAL_HELPER:-store --file=/home/forge/.config/echo/omega_git_creds}" +GITC=(-c credential.helper= -c credential.helper="$GIT_CREDENTIAL_HELPER") LOCK_FILE="${CERTFORGE_DEPLOY_LOCK:-/run/lock/echo-certforge-deploy.lock}" exec 9>"$LOCK_FILE" @@ -67,6 +79,8 @@ mkdir -p \ "$STATE_ROOT/run-output" \ "$STATE_ROOT/dispatch-output" \ "$STATE_ROOT/deploy-scratch" +sudo install -d -o root -g root -m 0755 "$PRODUCTION_E2E_ATTESTATION_DIR" +sudo install -d -o root -g root -m 0755 "$PRODUCTION_E2E_TRUSTED_KEYS" trap 'rm -rf "$RELEASE_TMP"' EXIT mkdir "$RELEASE_TMP" git archive "$NEW_SHA" | tar -x -C "$RELEASE_TMP" @@ -95,6 +109,14 @@ case "$ADAPTER_MODE" in required|pending) ;; *) echo "!! CERTFORGE_ADAPTER_MODE must be required or pending"; exit 1 ;; esac +[[ "$GIT_ACQUISITION_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || { + echo "!! ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS must be an integer" + exit 1 +} +if (( GIT_ACQUISITION_TIMEOUT_SECONDS < 30 || GIT_ACQUISITION_TIMEOUT_SECONDS > 1800 )); then + echo "!! ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS must be between 30 and 1800" + exit 1 +fi ADAPTER_RESPONSE="" ADAPTER_POLICY="" ADAPTER_REGISTRY="" @@ -178,6 +200,28 @@ set -a set +a PROD_PEPPER="${ECHO_CERTFORGE_API_KEY_PEPPER:-}" STAGING_PEPPER="${ECHO_CERTFORGE_STAGING_API_KEY_PEPPER:-}" +PROD_DB_PATH="${ECHO_CERTFORGE_DB:-}" +if [ -z "$PROD_DB_PATH" ]; then + CURRENT_SERVICE_PID="$(systemctl show "$SERVICE.service" --property MainPID --value 2>/dev/null || true)" + if [ "${CURRENT_SERVICE_PID:-0}" -gt 1 ] 2>/dev/null && \ + [ -r "/proc/$CURRENT_SERVICE_PID/environ" ]; then + PROD_DB_PATH="$(python3 - "$CURRENT_SERVICE_PID" <<'PY' +from pathlib import Path +import sys + +for entry in Path(f"/proc/{sys.argv[1]}/environ").read_bytes().split(b"\0"): + if entry.startswith(b"ECHO_CERTFORGE_DB="): + print(entry.split(b"=", 1)[1].decode("utf-8")) + break +PY +)" + fi +fi +PROD_DB_PATH="${PROD_DB_PATH:-$STATE_ROOT/certforge.sqlite3}" +case "$(realpath -m "$PROD_DB_PATH")" in + "$STATE_ROOT"/*|/mnt/documentation/echo/certforge/*) ;; + *) echo "!! effective production database is outside an approved state root"; exit 1 ;; +esac test "${#PROD_PEPPER}" -ge 32 || { echo "!! production ECHO_CERTFORGE_API_KEY_PEPPER must be at least 32 bytes" exit 1 @@ -209,6 +253,7 @@ ECHO_CERTFORGE_EVIDENCE_ROOT="$STAGING_ROOT/evidence" \ ECHO_CERTFORGE_POLICY="$RELEASE_DIR/policies/mandatory-rules.v2.json" \ ECHO_CERTFORGE_TRUSTED_KEYS="$STATE_ROOT/trusted-public-keys" \ ECHO_CERTFORGE_TRANSPORT_KEYS="$STATE_ROOT/trusted-transport-keys" \ +ECHO_CERTFORGE_ADAPTER_MODE="$ADAPTER_MODE" \ ECHO_CERTFORGE_PROD_ADAPTER_RESPONSE="$ADAPTER_RESPONSE" \ ECHO_CERTFORGE_PROD_ADAPTER_POLICY="$ADAPTER_POLICY" \ ECHO_CERTFORGE_ADAPTER_REGISTRY="$ADAPTER_REGISTRY" \ @@ -261,6 +306,7 @@ ECHO_CERTFORGE_DB="$STAGING_ROOT/staging.sqlite3" \ ECHO_CERTFORGE_SUBSCRIBER_POLICY="$RELEASE_DIR/policies/subscriber-governance.v1.json" \ ECHO_CERTFORGE_API_KEY_PEPPER="$STAGING_PEPPER" \ ECHO_CERTFORGE_EXPECT_PRODUCT_READY=1 \ +ECHO_CERTFORGE_EXPECT_RUNTIME_PROFILE="$ADAPTER_MODE" \ "$RELEASE_DIR/.venv/bin/python" "$RELEASE_DIR/deploy/smoke_live.py" \ "http://127.0.0.1:$STAGING_PORT" || { echo "!! STAGING SMOKE RED - production untouched" @@ -317,10 +363,11 @@ if sudo test -f "$DISPATCH_RELEASE_DROPIN"; then sudo cat "$DISPATCH_RELEASE_DROPIN" >"$DISPATCH_RELEASE_DROPIN_BACKUP" HAD_DISPATCH_RELEASE_DROPIN=1 fi -DB_PATH="$STATE_ROOT/certforge.sqlite3" +DB_PATH="$PROD_DB_PATH" DB_BACKUP="$STATE_ROOT/deploy-scratch/echo-certforge-db.$RELEASE_ID" HAD_DB=0 DB_SNAPSHOT_READY=0 +PROD_API_STARTED=0 PROMOTION_ARMED=1 rollback_production() { @@ -333,11 +380,23 @@ rollback_production() { rollback_status=0 sudo systemctl stop "$DISPATCH_SERVICE.service" >/dev/null 2>&1 || true sudo systemctl stop "$SERVICE.service" >/dev/null 2>&1 || true - if systemctl is-active --quiet "$SERVICE.service"; then - rollback_status=1 + if systemctl is-active --quiet "$SERVICE.service" || + systemctl is-active --quiet "$DISPATCH_SERVICE.service"; then + echo "!! rollback cannot quiesce writers; database and recovery snapshots preserved" + return 1 fi sudo systemctl disable "$SERVICE.service" >/dev/null 2>&1 || true - if [ "$DB_SNAPSHOT_READY" = 1 ]; then + if [ "$DB_SNAPSHOT_READY" = 1 ] && [ "$PROD_API_STARTED" = 1 ]; then + # The API may have acknowledged customer writes during promotion. Retain + # them for the prior source and save an additional immutable recovery copy. + # If the old source is incompatible, its health check fails closed below; + # never silently discard acknowledged writes by restoring the old snapshot. + "$RELEASE_DIR/.venv/bin/python" "$RELEASE_DIR/deploy/preserve_rollback_database.py" \ + "$DB_PATH" "$DB_BACKUP.post-promotion" || { + echo "!! accepted-write snapshot failed; both services remain stopped" + return 1 + } + elif [ "$DB_SNAPSHOT_READY" = 1 ]; then rm -f "$DB_PATH-wal" "$DB_PATH-shm" if [ "$HAD_DB" = 1 ]; then DB_RESTORE="$DB_PATH.rollback.$$" @@ -460,7 +519,7 @@ rollback_production() { sudo systemctl daemon-reload || rollback_status=1 fi rm -f "$UNIT_BACKUP" "$DISPATCH_UNIT_BACKUP" \ - "$RELEASE_DROPIN_BACKUP" "$DISPATCH_RELEASE_DROPIN_BACKUP" "$DB_BACKUP" + "$RELEASE_DROPIN_BACKUP" "$DISPATCH_RELEASE_DROPIN_BACKUP" if [ "$rollback_status" = 0 ]; then echo "ROLLBACK COMPLETE - prior production state is healthy" else @@ -515,7 +574,7 @@ Type=simple User=forge WorkingDirectory=$CURRENT_LINK Environment=PYTHONUNBUFFERED=1 -Environment=ECHO_CERTFORGE_DB=$STATE_ROOT/certforge.sqlite3 +Environment=ECHO_CERTFORGE_DB=$DB_PATH Environment=ECHO_CERTFORGE_EVIDENCE_ROOT=$STATE_ROOT/evidence Environment=ECHO_CERTFORGE_POLICY=$CURRENT_LINK/policies/mandatory-rules.v2.json Environment=ECHO_CERTFORGE_SUBSCRIBER_POLICY=$CURRENT_LINK/policies/subscriber-governance.v1.json @@ -551,14 +610,16 @@ sudo tee "$DISPATCH_UNIT_PATH" >/dev/null </dev/null 2>&1 || true sudo systemctl reset-failed "$SERVICE.service" >/dev/null 2>&1 || true sudo systemctl reset-failed "$DISPATCH_SERVICE.service" >/dev/null 2>&1 || true sudo systemctl enable "$SERVICE.service" +PROD_API_STARTED=1 sudo systemctl restart "$SERVICE.service" sudo systemctl enable "$DISPATCH_SERVICE.service" @@ -605,10 +670,11 @@ for _ in $(seq 1 "$READY_POLLS"); do } sleep 0.5 done -if [ "$ready" != 1 ] || ! ECHO_CERTFORGE_DB="$STATE_ROOT/certforge.sqlite3" \ +if [ "$ready" != 1 ] || ! ECHO_CERTFORGE_DB="$DB_PATH" \ ECHO_CERTFORGE_SUBSCRIBER_POLICY="$RELEASE_DIR/policies/subscriber-governance.v1.json" \ ECHO_CERTFORGE_API_KEY_PEPPER="$PROD_PEPPER" \ ECHO_CERTFORGE_EXPECT_PRODUCT_READY=1 \ + ECHO_CERTFORGE_EXPECT_RUNTIME_PROFILE="$ADAPTER_MODE" \ "$RELEASE_DIR/.venv/bin/python" "$RELEASE_DIR/deploy/smoke_live.py" \ "http://127.0.0.1:$PROD_PORT"; then echo "!! PROD RED" @@ -633,7 +699,7 @@ if [ "$dispatcher_ready" != 1 ]; then exit 1 fi if [ "$ADAPTER_MODE" = required ]; then - ECHO_CERTFORGE_DB="$STATE_ROOT/certforge.sqlite3" \ + ECHO_CERTFORGE_DB="$DB_PATH" \ ECHO_CERTFORGE_SUBSCRIBER_POLICY="$RELEASE_DIR/policies/subscriber-governance.v1.json" \ ECHO_CERTFORGE_API_KEY_PEPPER="$PROD_PEPPER" \ "$RELEASE_DIR/.venv/bin/python" "$RELEASE_DIR/deploy/smoke_dispatch.py" \ @@ -654,7 +720,7 @@ cat \ PROMOTION_ARMED=0 trap - EXIT -rm -f "$UNIT_BACKUP" "$DISPATCH_UNIT_BACKUP" "$DB_BACKUP" +rm -f "$UNIT_BACKUP" "$DISPATCH_UNIT_BACKUP" echo "DEPLOY GREEN - $SERVICE live on :$PROD_PORT @ $NEW_SHA" sudo systemctl status "$SERVICE.service" --no-pager -l | head -6 || true sudo systemctl status "$DISPATCH_SERVICE.service" --no-pager -l | head -6 || true diff --git a/deploy/preserve_rollback_database.py b/deploy/preserve_rollback_database.py new file mode 100644 index 0000000..59a42cd --- /dev/null +++ b/deploy/preserve_rollback_database.py @@ -0,0 +1,36 @@ +"""Preserve the accepted-write database before a source-only rollback. + +The deployment owner must stop both API and dispatcher before calling this helper. +It never replaces the live database or deletes an earlier recovery snapshot. +""" +import hashlib +from contextlib import closing +import json +import os +from pathlib import Path +import sqlite3 +import sys + + +def preserve_database(source: Path, destination: Path) -> dict: + if not source.is_file() or source.is_symlink() or destination.exists(): + raise ValueError('rollback_snapshot_path_invalid') + fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(fd) + with closing(sqlite3.connect(source.resolve().as_uri() + '?mode=ro', uri=True)) as live: + with closing(sqlite3.connect(destination)) as snapshot: + live.backup(snapshot) + if snapshot.execute('PRAGMA integrity_check').fetchone() != ('ok',): + raise ValueError('rollback_snapshot_integrity_failed') + with destination.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + return {'preserved': True, 'live_database_replaced': False, + 'snapshot_sha256': digest, 'snapshot_bytes': destination.stat().st_size} + + +if __name__ == '__main__': + try: + print(json.dumps(preserve_database(Path(sys.argv[1]), Path(sys.argv[2])))) + except (OSError, ValueError, sqlite3.Error): + print(json.dumps({'preserved': False, 'error': 'rollback_snapshot_failed'})) + raise SystemExit(1) diff --git a/deploy/smoke_dispatch.py b/deploy/smoke_dispatch.py index b0410c1..5a9458d 100644 --- a/deploy/smoke_dispatch.py +++ b/deploy/smoke_dispatch.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Production subscriber-to-dispatcher certification smoke. +"""Production subscriber-to-dispatcher fail-closed certification smoke. This test runs only after the real API and dispatcher are active. It submits an exact-identity -local target through the customer HTTP contract, waits for the durable dispatcher, and verifies -the signed PRODUCTION_READY verdict and append-only evidence through the public API. +local target through the customer HTTP contract without a trusted production-E2E attestation, +waits for the durable dispatcher, and verifies that the service signs ``NOT_READY`` while keeping +the append-only evidence and public signature-verification paths healthy. A deploy smoke must not +manufacture the external evidence that the production gate is intended to require. """ from __future__ import annotations @@ -17,7 +19,8 @@ from pathlib import Path from echo_certification_forge.acquisition import acquire_target -from echo_certification_forge.models import TargetIdentity +from echo_certification_forge.models import EnvironmentIdentity, TargetIdentity +from echo_certification_forge.service import RuntimeEnvironmentResponse from echo_certification_forge.subscriber import ( OrganizationStatus, SubscriberGovernance, @@ -65,6 +68,39 @@ def _require(condition: bool, message: str, detail: object | None = None) -> Non print(f" [ok ] {message}") +def _require_attestation_gate_closed(terminal: dict) -> None: + """Prove an unattested run cannot be promoted by the production dispatcher.""" + + _require( + terminal.get("release_verdict") == "NOT_READY", + "unattested customer run is NOT_READY", + terminal.get("release_verdict"), + ) + production_e2e = terminal.get("production_e2e") + _require( + isinstance(production_e2e, dict) and production_e2e.get("verified") is False, + "unattested customer run has no verified production E2E", + production_e2e, + ) + + +def _resolve_environment(base: str, headers: dict, journey: list[str], image: str) -> dict: + code, payload = _request( + base, "POST", "/v1/subscriber/runtime/environment", **headers, + body={"journey": journey}, + ) + _require(code == 200, "smoke journey runtime resolved", code) + response = RuntimeEnvironmentResponse.model_validate(payload) + measured = EnvironmentIdentity(**response.environment.model_dump()) + _require(response.journey == journey, "resolved runtime binds the smoke journey") + _require(response.submit_environment.identity_digest == measured.identity_digest, + "resolved runtime component commitment verifies") + _require(response.submit_environment.runner_image_digest == image + and image == "sha256:" + measured.runner_image_sha256, + "resolved runtime retains the operator image") + return response.submit_environment.model_dump(exclude_none=True) + + def main() -> int: base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8309" db_path = os.environ.get("ECHO_CERTFORGE_DB") @@ -76,12 +112,7 @@ def main() -> int: status_code, status = _request(base, "GET", "/v1/status") _require(status_code == 200, "service status is reachable", status_code) _require(status.get("adapter_qualification") == "REQUIRED", "required adapter mode is active") - environment_digest = status.get("certification_environment_identity_digest") runner_image_digest = status.get("runner_image_digest") - _require( - isinstance(environment_digest, str) and len(environment_digest) == 64, - "customer certification environment is published", - ) _require( isinstance(runner_image_digest, str) and runner_image_digest.startswith("sha256:"), "customer runner image is published", @@ -105,6 +136,9 @@ def main() -> int: target_root = Path(db_path).parent / "deploy-scratch" / f"dispatch-smoke-{_NONCE}" target_root.mkdir(parents=True) try: + journey = ["python3", "certforge_smoke.py"] + environment = _resolve_environment(base, headers, journey, runner_image_digest) + environment_digest = environment["identity_digest"] (target_root / "certforge_smoke.py").write_text( "print('echo-certification-forge-production-smoke')\n", encoding="utf-8", @@ -152,13 +186,10 @@ def main() -> int: "reference": acquired.canonical_ref, "path": acquired.canonical_ref, }, - "environment": { - "identity_digest": environment_digest, - "runner_image_digest": runner_image_digest, - }, + "environment": environment, "policy_version": status["rule_manifest_id"], "idempotency_key": f"production-dispatch-smoke-{_NONCE}", - "journey": ["python3", "certforge_smoke.py"], + "journey": journey, }, ) _require(submit_status == 201, "customer certification was accepted", submitted) @@ -177,11 +208,7 @@ def main() -> int: time.sleep(1) _require(terminal.get("state") == "COMPLETE", "dispatcher completed the customer run", terminal) _require(terminal.get("run_outcome") == "COMPLETE", "customer run outcome is COMPLETE") - _require( - terminal.get("release_verdict") == "PRODUCTION_READY", - "customer received PRODUCTION_READY", - terminal.get("release_verdict"), - ) + _require_attestation_gate_closed(terminal) _require( terminal.get("environment_identity_digest") == environment_digest, "verdict retained the published environment identity", @@ -198,7 +225,12 @@ def main() -> int: ) _require(verdict_status == 200, "signed verdict is retrievable", verdict_status) payload = verdict.get("payload", {}) - _require(payload.get("release_verdict") == "PRODUCTION_READY", "signed verdict is PRODUCTION_READY") + _require(payload.get("release_verdict") == "NOT_READY", "signed verdict is NOT_READY") + _require( + "production_e2e_attestation_missing" in payload.get("reasons", []), + "signed verdict records the missing production E2E attestation", + payload.get("reasons"), + ) _require(payload.get("environment_identity_digest") == environment_digest, "signed verdict binds environment") verify_status, verified = _request( @@ -209,7 +241,7 @@ def main() -> int: "public verdict verification succeeds", verified, ) - print(f"PRODUCTION DISPATCH SMOKE GREEN - run_id={run_id}") + print(f"PRODUCTION DISPATCH FAIL-CLOSED SMOKE GREEN - run_id={run_id}") return 0 finally: shutil.rmtree(target_root, ignore_errors=True) diff --git a/deploy/smoke_live.py b/deploy/smoke_live.py index b96e2a2..180fdde 100644 --- a/deploy/smoke_live.py +++ b/deploy/smoke_live.py @@ -88,6 +88,34 @@ def _submit_body( } +def check_runtime_profile(base: str, owner, intruder, service_status: dict) -> bool: + from echo_certification_forge.models import EnvironmentIdentity + from echo_certification_forge.service import RuntimeEnvironmentResponse + journey = ["python3", "certforge_smoke.py"] + code, payload = _req(base, "POST", "/v1/subscriber/runtime/environment", + tenant=owner.organization_id, token=owner.bootstrap_api_key, + body={"journey": journey}) + check("authenticated runtime planning -> 200", code == 200, str(code)) + if code != 200: + return False + try: + response = RuntimeEnvironmentResponse.model_validate(payload) + components = EnvironmentIdentity(**response.environment.model_dump()) + valid = (response.journey == journey + and components.identity_digest == response.submit_environment.identity_digest + and response.submit_environment.runner_image_digest == "sha256:" + components.runner_image_sha256 + and response.submit_environment.runner_image_digest == service_status.get("runner_image_digest")) + except ValueError: + valid = False + check("runtime journey, components and operator image agree", valid) + code, _ = _req(base, "POST", "/v1/subscriber/runtime/environment", body={"journey": journey}) + check("runtime planning without authorization -> 401", code == 401, str(code)) + code, _ = _req(base, "POST", "/v1/subscriber/runtime/environment", + tenant=owner.organization_id, token=intruder.bootstrap_api_key, body={"journey": journey}) + check("cross-tenant runtime planning -> 404", code == 404, str(code)) + return valid and not FAILS + + def main() -> int: base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8311" print(f"== echo-certification-forge live smoke @ {base} ==") @@ -162,6 +190,9 @@ def main() -> int: check("smoke project id returned", bool(project_id), json.dumps(project)[:200]) if not project_id: return 1 + if os.environ.get("ECHO_CERTFORGE_EXPECT_RUNTIME_PROFILE") == "required": + if not check_runtime_profile(base, owner, intruder, svc): + return 1 # 2. no tenant header -> 401 (fail closed) status, _ = _req(base, "GET", "/v1/certifications") diff --git a/docs/JOURNEY_IMAGE_ADMISSION.md b/docs/JOURNEY_IMAGE_ADMISSION.md new file mode 100644 index 0000000..2ce9023 --- /dev/null +++ b/docs/JOURNEY_IMAGE_ADMISSION.md @@ -0,0 +1,109 @@ +# Admit a separate journey image through existing P4 authority + +The Node24/Python journey images have a different layout from the twelve P4 +role images. They must not be relabeled as a P4 role variant or replace their +manifest. `prepare_journey_image_admission.py` prepares a separate unsigned +`ImageAdmissionPolicy` using the existing public policy and attestation. It +preserves trusted keys, compromised-key exclusions and revoked image digests, +and binds only the candidate image, source, final base, recipe and dependency +commitment. The two-stage base provenance remains in the hashed input lock and +provenance. A source-integrated recipe does not relabel a previously built image; +retain that image's actual build source in `ImageIdentity`. + +The qualification contract accepts the original Node24.19.0/Python3.11.x pair +and the exact ECF security-update pair Node24.22.1/Python3.12.16. Cross-pairs and +other Python3.12 patch versions are rejected. This adds no serialized field, so +legacy request digests retain their existing representation. Both pairs still +require matching actual runtime packages in the hash-bound SPDX document, fresh +independent build and security evidence, current admitted owner authority and +the exact reviewed request digest. The ECF pair additionally requires exactly +one nodejs and one CPython entry in its SPDX package inventory; mixed or duplicate +runtime entries fail closed. Supporting a version pair is not image admission, +a clean-security assertion, or release permission. Reviewers must independently +reject outdated Node-core reports, including for legacy versions; the contract +does not authenticate a submitted report hash or its zero-count assertions. +The reviewer must also compare the referenced runtime probe and SPDX runtime +rows with the actual candidate image at its exact digest; matching declarations +alone do not prove which binary executes. The legacy pair retains its previous +inventory rules, including the absence of the new ECF-only uniqueness check. + +Preparation verifies bounded exact material bytes, recipe policy, source and +SPDX image/runtime-package binding, independent-build comparison commitments, +scanner/Node-core negative counts, runtime isolation assertions, and current +existing authority. Qualifications are strict typed, at most24 hours old, and +must bind the canonical identity digest. Distinct observed build receipt IDs +are required; the archive hashes may be identical for a reproducible build. +Distinct IDs alone do not prove independence. Public assertions and report hashes +are review inputs, not authenticated scanner attestations. Independently inspect +their referenced raw reports, tool provenance and two actual builds before +approving the request. Do not infer image qualification from operator-supplied +zero counts alone. + +```text +python scripts/prepare_journey_image_admission.py \ + --packet --qualification \ + --dockerfile \ + --prior-policy \ + --prior-attestation \ + --expected-source \ + --expected-image sha256: \ + --expected-policy-sha256 \ + --output +``` + +The packet contains `runner.identity.UNSIGNED.json`, `runner.spdx.json`, +`runner.provenance.json`, and `journey-image-inputs.lock.json`. All paths must be +regular files below non-reparse ancestors. Inputs are bounded to4MiB each. +Outputs use exclusive creation and a completion receipt written last. Existing +output paths are refused; an incomplete directory is not an admission. The +owner must serialize input custody while reading; path checks do not lock a +directory against another authorized writer. + +After independent review, the admitted control-plane owner may use the bounded +sealer with the **existing** P4 key in its original custody: + +```text +python scripts/seal_journey_image.py \ + --request \ + --approved-request-sha256 \ + --current-policy \ + --current-attestation \ + --attestation-private-key \ + --valid-days 7 --output-dir +``` + +This wrapper reuses `seal_p4_images.load_private_key`; it has no key generation, +trust bootstrap or policy installation mode. It requires POSIX same-owner0600 +regular-file custody, current exact prior public admission and the same public +key/key ID. The existing P4 loader's optional strict mode binds the checked +inode, owner/group, permissions, size and timestamps to an O_NOFOLLOW descriptor +before and after its bounded read. Legacy P4 callers retain their existing behavior. +The current owner policy and attestation must still exactly equal the +reviewed public snapshot and pass current admission. Both files are reread after +key loading immediately before the signing guard; changed revocations, trust or +signature state stop signing. Owner serialization must span those reads, signing +and output creation; a file snapshot alone is not an atomic trust transaction. +It never serializes the private key, passes it to a child or exposes +it to a worker. Signing is bounded by both30days and the existing authority's +remaining lifetime. All outputs are public; errors report a fixed category. +The review digest proves input integrity, **not permission or a Commander +signature**. Execute only through the admitted owner workflow with the actual +authorization and reviewed implementation. Synthetic test keys are not usable +as a production trust root. + +The result is `SEALED_NOT_INSTALLED`. Its canonical signature/admission must be +independently read back before owner selection. It does not install a policy, +modify any existing P4 role, update API/dispatcher configuration, mutate a queue, +restart a service, certify the CertForge source, or certify a target application. +Source release still requires its exact-SHA gates and rollback. Runtime selection +must use the shared measured profile in `RUNTIME_IDENTITY.md`; preserve the +original profile and old source/image, drain/reconcile leases through supported +governance, then independently verify API/worker identity agreement. No opaque +environment label or fixed-image default may bypass that measurement. + +This integration retains PR28's measured runtime identity and the local intake +fix that preserves optional artifact/source commitments. Artifact or acquired +source drift still fails before journey execution or signing. Image admission, +source certification, target certification and ordinary deployed bot acceptance +remain separate. A successful source-only journey cannot satisfy production E2E +or establish a95+bot grade. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 66e4b6f..350f576 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,5 +1,29 @@ # Operations +## Dispatcher recovery limits + +Operator sandbox and runtime measurement failures stop the dispatcher before +it claims another job. The unexecuted claim returns to pending with its retry count +unchanged. Service restarts use the configured supervisor delay. Inspect the +structured `dispatcher_runtime_circuit_open` event, repair the runtime through +supported governance, then restart. Tenant journey or environment mismatches use a +persisted 60-second delay between bounded retries; they cannot open the global +circuit or occupy the head of the queue while delayed. Do not consume the customer +backlog as a diagnostic probe. + +Intake recovery isolates validated request-local failures and malformed stored +intake payloads. Unexpected database integrity failures and corrupt materialized +run records deliberately remain fail-loud: stop and investigate the original +database with a preserved snapshot. Broad exception swallowing could conceal +cross-record corruption or an ownership failure. API rejection refunds are fenced +to the exact still-reserved request; recovery-owned or executing work is retained. + +The current post-promotion dispatch smoke shares the normal FIFO queue and has a +180-second deadline. A backlog can cause a false red and source rollback. Check +queue drain readiness before promotion; scoped canary dispatch remains a separate +availability improvement. This smoke is not a claim that the service supports +multiple independent dispatchers during long target acquisition. + ## Operator principles - Treat `NOT_READY` as the default and expected failure state. @@ -10,6 +34,50 @@ ## Verification sequence +Before a worker may issue `PRODUCTION_READY`, mount a collector envelope through +`--production-e2e-attestation` (or +`ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION`) and a directory containing only the +pinned collector public keys through `--trusted-production-e2e-keys` (or +`ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS`). The private collector key must not +be present on the worker. Absence, signature failure, expiration, or any exact +identity/E2E mismatch is a normal `NOT_READY` result, never a bypass. + +The durable subscriber dispatcher resolves attestations from +`ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR`. Each envelope is named +`..json`; both values are +the exact 64-character digests published for the queued run. The matching +collector public key is pinned independently under +`ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS`. Both directories are root-owned +and readable by the dispatcher but are not writable by its `forge` service +identity. Install a collector envelope atomically (temporary file followed by +rename), never place a private key in either directory, and never accept a +target-selected path or target-supplied public key. Missing, untrusted, stale, +or identity-mismatched envelopes remain `NOT_READY`. + +Exact Git acquisition uses the bounded dispatcher setting +`ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS` (production default: 900; +allowed range: 30–1800 seconds). This bounds network and checkout stalls while +allowing large repositories to materialize under normal disk contention. An +invalid value fails the acquisition before creating the target destination; +never remove the timeout to make a run pass. + +The 900-second default is sized for the governed Echo monorepo (approximately +1 GiB and more than 23,000 files). The previous 300-second ceiling produced a +reproducible false infrastructure failure during exact-SHA checkout while the +disk remained healthy and the checkout continued making I/O progress. + +Private GitHub targets use the dispatcher-owned GitHub App identity configured by +`ECHO_CERTFORGE_GITHUB_APP_ID` and +`ECHO_CERTFORGE_GITHUB_PRIVATE_KEY_FILE`. The worker mints a short-lived +installation token restricted to the requested repository and `contents:read`. +Git receives that token only through an ephemeral `GIT_ASKPASS` process +environment; the token is never placed in the repository URL, command line, +target record, evidence, or exception text. The askpass helper is removed after +acquisition on both success and failure. A partially configured App identity, +missing installation, invalid key, or token-mint failure blocks acquisition. +Do not replace this path with a PAT-bearing URL or a persistent credential +helper. + 1. Confirm hosted CI succeeded for the exact source SHA. 2. Execute the declared production-shaped journey with the pinned runner image. 3. Verify evidence custody, receipt chains, signatures, expiry, and revocation state. @@ -18,14 +86,43 @@ 6. Promote atomically, health-check production, and roll back on any mismatch. 7. Publish immutable machine certificates and the repository certificate graphic. +The Echo GitHub Autonomy production collector must use profile +`echo-github-autonomy-remote-mcp-v2` and exactly the three governed accounts. +Profile v1 and evidence containing the retired `Bmcbob76` identity fail closed. + ## Incident triage Capture the run ID, target SHA, environment digest, policy version, service health, dispatcher state, and sanitized logs. A failed gate is not repaired by changing a verdict field; repair the failing evidence-producing control, rerun from a new idempotency key, and retain the failed run for audit. +For `acquisition_failed` with GitHub reporting `Repository not found`, first +confirm that both GitHub App settings are present, their files are readable by +the dispatcher, and that the App is installed on the named repository. Never +print the private key, minted token, process environment, or credential-helper +output while diagnosing the failure. + ## Recovery Production uses a current-release pointer and retained prior release. If post-promotion health or identity differs, atomically restore the prior pointer, restart through the deployment gate, and record the rollback receipt. Do not delete failed evidence or reuse a run ID for another target. + +After the candidate API has been started, automatic rollback preserves the current +database, including submissions and billing events acknowledged during promotion. +With both API and dispatcher stopped, it creates a separate integrity-checked +post-promotion SQLite snapshot and restores the prior source and service units. +The original pre-promotion snapshot is retained as a separate recovery artifact. +Only failures before the first candidate API start may restore that older snapshot +automatically. A failure to stop either writer or preserve the accepted-write +snapshot stops rollback without replacing the database. If the previous source +cannot read the retained database, its health check fails closed and forward +recovery is required; acknowledged work must not be discarded to obtain green +health. Schema-changing releases must separately prove backward compatibility. + +Required-adapter staging boots with the same adapter mode and inherited owner +runtime profile as production. Its authenticated runtime probe validates the +journey commitment and operator image, rejects missing authorization and another +tenant's key, and fails before promotion when the runtime profile is unavailable. +Production dispatch smoke resolves its own exact journey declaration through the +same runtime API instead of reusing the default status environment. diff --git a/docs/P4_AUTHORITY_RENEWAL.md b/docs/P4_AUTHORITY_RENEWAL.md new file mode 100644 index 0000000..6ebc84d --- /dev/null +++ b/docs/P4_AUTHORITY_RENEWAL.md @@ -0,0 +1,46 @@ +# Renew P4 evidence with the existing owner trust key + +An expired image attestation requires fresh qualification and owner sealing. It +does not require a new trust root. The six current role recipes and sandbox/P4 +defaults pin Python3.12.16 base +`sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0`. +Each role also installs exact Alpine libcrypto3/libssl3 3.5.9-r0 and libuuid +2.42.3-r1 fixes. Actual image scans decide qualification; these declarations +alone do not establish safety. + +Use the existing full P4 builder and sealer through the admitted owner workflow. +The convenience `run_p4_gate.sh` still creates a fresh test trust key by default; +that default is unsuitable when renewing an existing production authority. + +1. Pin the exact reviewed source, existing public metadata, key identity and + public policy hashes. Verify the existing key's original protected custody + without copying its contents. Retain compromised-key exclusions and image + revocations; conflicting trust state prevents renewal. +2. Run `scripts/build_p4_images.py` with that exact source, a new unique tag + prefix and the **existing** `--public-metadata` file. It builds all six roles + twice. Verify every resulting role, revision, key-ID label, non-root user and + content identity; never overwrite another run's tag prefix. +3. Scan all twelve immutable images with the qualified scanner before invoking + the owner sealer. Require zero critical, zero fixable high/critical and zero + secret findings. Retain raw reports, scanner/database identities and runtime + checks. A failed or incomplete scan prevents signing. +4. Invoke `scripts/seal_p4_images.py` with the exact twelve-image map, source, + base digest, qualified scanner, original owner key path and a new output + directory. Only this owner process loads the existing private key; workers + receive public records. No key generation or alternate trust selection is + part of this renewal. +5. Independently verify the completed manifest, exact public key, signatures, + freshness, source/image/material bindings, all twelve runtime/security + reports and A/B normalized comparisons. Run the full hostile acceptance gate + with its genuine scanner, malware scanner and signing-tool prerequisites. + Partial sealer output is not admission or a completed phase. +6. Use the renewed runner public records to prepare the separate journey-image + request in `JOURNEY_IMAGE_ADMISSION.md`, preserving existing trust and + revocations. The ECF journey image remains a separate image and policy. + +All outputs remain separate from selected live configuration until their owner +release and rollback requirements pass. Renewing P4 image evidence does not +refresh product readiness, certify source, deploy the service or certify a +target application. Product readiness still requires measured phase evidence, +the actual master acceptance journey, exact-source hosted CI and its existing +signing authority. Do not extend old timestamps or relabel old receipts. diff --git a/docs/PROJECT_CONTRACT.md b/docs/PROJECT_CONTRACT.md index eb191dd..845bfb4 100644 --- a/docs/PROJECT_CONTRACT.md +++ b/docs/PROJECT_CONTRACT.md @@ -41,3 +41,22 @@ GS343 may propose discovery, classification, and bounded harness repairs. Determ ## Completion rule Implementation, source validation, contracts, real runtime, failure and recovery, evidence package, scoped Git record, and Commander acceptance are distinct gates. A registered capability is not presumed visible, healthy, schema-complete, authorized, or runnable. + +## Production E2E verdict gate + +`PRODUCTION_READY` requires a current Ed25519-signed `certforge.production-e2e.v1` +attestation from an independently pinned collector. The attestation must bind the +exact target and environment identities, exact deployed source revision, real +critical journeys, negative controls, repeated stability probes, and external +acceptance. Source checks, local journeys, HTTP 200, unsigned status fields, and +target-authored evidence cannot satisfy this gate. Missing, expired, untrusted, +partially passing, or identity-mismatched attestations remain `NOT_READY`. + +For Echo GitHub Autonomy, target-specific profile +`echo-github-autonomy-remote-mcp-v2` additionally requires the canonical MCP +and OAuth surface, 30-tool schema, repeated discovery plus invocation without +registry loss, reconciled public/private inventory and read/write/certify +authority for exactly the three governed GitHub account IDs (`echoomegaprime`, +`ECHO-OMEGA-PRIME`, and `bobmcwilliams4`), and matching private-repository +fingerprints from ChatGPT, Claude, Codex, and Grok. The retired `Bmcbob76` +identity is invalid evidence for this profile. diff --git a/docs/RUNTIME_IDENTITY.md b/docs/RUNTIME_IDENTITY.md new file mode 100644 index 0000000..c6f2f58 --- /dev/null +++ b/docs/RUNTIME_IDENTITY.md @@ -0,0 +1,126 @@ +# Measured worker runtime identity + +The `certforge.runtime.v1` contract replaces fixed `certforge-worker-env:*` labels. +An environment commits to the selected manifest digest, exact journey argv and +mandatory rules, executable CertForge package bytes, dependency declarations, +actual Python executable and installed public dependency versions, host platform, +pinned sandbox image, and effective sandbox resource/isolation settings. Verified +adapter bundle and adapter-set commitments bind their prompt/configuration and +model-routing inputs. Missing adapter evidence is explicitly represented as absent; +it does not become a measured or qualified adapter. + +The published `runner_image_digest` is the exact SHA-256 pin passed to Docker. +Both repository references ending in `@sha256:<64 hex>` and local immutable Docker +content IDs `sha256:<64 hex>` are supported. The sandbox commitment records +`repository_digest` or `docker_content_id` explicitly; an image ID is never +represented as a registry manifest receipt or fabricated `RepoDigest`. +Measuring it proves selection, not image execution. Journey and independently +signed production E2E evidence still prove execution and acceptance. A host fixture +run is identified as having no container isolation; its runner field commits to +the measured host runtime and is never presented by the production service as a +container-image attestation. + +## Shared operator configuration + +Set `ECHO_CERTFORGE_RUNTIME_PROFILE` to an operator-owned public JSON file for both +the API and dispatcher. `--runtime-profile` selects the same file for a CLI launch. +It accepts only `schema_version` (`certforge.runtime.v1`), `journey` (an argv array +or null), and `sandbox`. The sandbox accepts `image`, `memory`, `cpus`, `pids_limit`, +`tmpfs_size`, `timeout_s`, and `user`. Other fields fail closed. The image must use +an exact repository digest or content ID, user must be non-root, and limits must be positive. Omitted +limits use `DockerSandbox` defaults. Image selection may also come from +`ECHO_CERTFORGE_SANDBOX_IMAGE` or `--sandbox-image`; a conflicting profile pin is +rejected. The Docker command must agree between API and worker configuration. + +The profile must contain no secrets, credentials, or private data. Arbitrary +environment variables and signing files are never scanned or hashed. Sandbox +`extra_env` is unsupported by this version and fails closed rather than excluding +an execution-affecting input from the commitment. Only package `*.py` files, +`pyproject.toml`, and `images/requirements.lock` are read for the harness commitment; +target files, `var/`, trust stores, and private keys are outside that traversal. +Wheels include those two public declarations under `runtime_data/`; measurement +uses the actual installed package files and the same canonical names as the +source checkout. The installed interpreter directory is never treated as a +repository, and missing packaged declarations fail closed. + +The API publishes `certification_environment_identity_digest`, +`runner_image_digest`, `runtime_profile_schema`, `runtime_journey_json`, and +`runtime_journey_mode`. +The production dispatcher loads the same profile and passes its actual sandbox to +the worker. A non-null operator journey remains an exact pin. A null journey +selects `per_job` mode: each persisted job supplies its own bounded argv while +the operator's image, Docker command and isolation limits remain exact pins. +Before acquisition, the worker rejects a different sandbox, an incompatible +operator journey, or an environment declaration that does not match the measured +job inputs. +It measures the effective inputs again and uses existing transactional environment +reconciliation to fence changes before target execution. Source and installed +dependencies must be immutable within a release; restart API and dispatcher on a +release or public-profile change. A missing/unreadable measured input fails closed. + +For a shared service, resolve the exact job argv through authenticated read-only +`POST /v1/subscriber/runtime/environment` (`echo.certforge.runtime_environment`). +It accepts only `journey` (null or 1–32 nonempty strings of at most 2048 characters, +without NUL). It returns canonical `environment` components and +`submit_environment` for the existing submission contract. Use that exact argv +and declaration together. This operation does not execute code, create a run, +reserve quota, select an image, or certify readiness. It uses the existing +subscriber read permission and rate/authentication controls. + +The null-journey environment advertised by `/v1/status` describes the shared +profile's default plan; it is not a declaration for a job with a different argv. +Without an explicit image profile, the selected pinned default sandbox is used. +Omitting a real job journey still fails the mandatory critical-journey gate. +The existing minimal Python image does not imply that target dependencies are +installed. A dependency-complete image must be built, independently pinned and +selected by the release owner before running a program that needs those packages. + +## Migration and rollback + +All old fixed-label environment commitments differ from the measured contract. +Existing verdicts are not re-signed, upgraded, or silently reused. Queued requests +declaring an old or mismatched environment fail closed through existing worker +reconciliation. An operator sandbox mismatch or unavailable measurement opens the +dispatcher circuit: its exact unexecuted claim returns to pending without +spending retry budget, and the process exits before touching another run. The service +supervisor's restart delay bounds automatic retries; operators must resolve runtime +drift before the queue can progress. Tenant environment and journey mismatches use +a persisted 60-second retry delay, allowing other tenants to progress, and consume +the normal bounded retry budget. Other job failures +that exhaust bounded pre-execution retries become `INFRASTRUCTURE_FAILURE` and are +compensated once in their original billing period. No target acquisition is allowed +for a mismatched profile. After admission of the changed release, callers must obtain the +fresh per-job environment and submit a new exact-identity request with a new +idempotency key. Nothing in this change automatically updates production profiles, +queues, trust roots, collector attestations, or service units. + +This repair retains measured deployed revision +`d8ad9fd881365801354b78a758632d2a31ab6828`. The ECF release reconciliation merges +canonical main `4f7d99e2dd4422f3d81cf5b709581be963f5818a`, measured-runtime repair +`690ea5b58cee637e76bb1011e7d08da0895422fa` (PR28), and image-admission/local-intake +repair `77cba79e0fd971ad7301203242108c76998d26e5` (PR29). It preserves the deployed +acquisition and production-E2E protections alongside main's longer bounded +readiness polling and retained staging diagnostics. The P7 intake probe uses one +measured environment for its exact journey and the same manifest-scoped request +digest and idempotency key as production intake. Its v1 host-worker scenarios are +subscriber regression evidence, not v2 Docker runtime admission. Release acceptance +must separately exercise the authenticated runtime endpoint and actual pinned +Docker worker under the production policy. Original owner branches are retained. +This source reconciliation is not runtime admission, a certificate, or a +production promotion. The exact combined source still requires those gates; +replacing the deployed release with older main is not a valid rollback strategy. + +Focused verification is `python -m pytest -q tests/test_runtime_identity.py tests/test_runtime_job_binding.py`. +The tests cover changed image, journey, policy, harness and sandbox limits; +secret-field rejection; service/dispatcher/worker identity agreement; and rejection +before acquisition, authenticated per-job resolution, caller-image rejection, +and equivalent source/real-built-wheel harness commitments. Subscriber regression +tests additionally verify that changed +journey commitments cannot execute or receive a signed verdict. Full repository +and acceptance checks remain required before a release claim. + +Host Python and kernel measurements are intentional identity inputs. A host update +can invalidate queued job declarations even if application source is unchanged. +Coordinate host updates with API and dispatcher restarts, resolve a fresh per-job +environment, and submit a new identity-bound request. Do not keep old identity +labels or weaken these measurements to preserve availability. diff --git a/images/anchor/Dockerfile b/images/anchor/Dockerfile index 387e74f..86ff114 100644 --- a/images/anchor/Dockerfile +++ b/images/anchor/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Anchor" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="anchor" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/images/custody/Dockerfile b/images/custody/Dockerfile index 6d7f995..657b210 100644 --- a/images/custody/Dockerfile +++ b/images/custody/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Custody" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="custody" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/images/journey-node24-python311/Dockerfile b/images/journey-node24-python311/Dockerfile new file mode 100644 index 0000000..c2b32ea --- /dev/null +++ b/images/journey-node24-python311/Dockerfile @@ -0,0 +1,18 @@ +# Official linux/amd64 manifests resolved through the scoped owner on 2026-10-09. +# No target code, package installation, credentials, or mutable base references. +FROM node:24.19.0-trixie-slim@sha256:db4f733c26868756bae92ac764d7e99bf31809ce7db886ed525960fe7d544601 AS node +FROM python:3.11-slim-trixie@sha256:e529028263dbe6910a2d96f7d2b8f5266385e917fd45d286ef166977c094a51e +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/lib/x86_64-linux-gnu/libstdc++.so.6* /usr/lib/x86_64-linux-gnu/ +COPY --from=node /usr/lib/x86_64-linux-gnu/libgcc_s.so.1 /usr/lib/x86_64-linux-gnu/ +ENV ECHO_TEST_PYTHON=/usr/local/bin/python3 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONIOENCODING=utf-8 +RUN --network=none python3 -m pip uninstall --yes pip setuptools wheel \ + && python3 -c "import pathlib,shutil,sysconfig; shutil.rmtree(pathlib.Path(sysconfig.get_path('stdlib'))/'ensurepip'/'_bundled')" \ + && node -e "if (process.version !== 'v24.19.0') process.exit(1)" \ + && python3 -c "import sys; assert sys.version_info[:2] == (3, 11)" +USER 65534:65534 +WORKDIR /work +ENTRYPOINT [] +CMD ["node", "--version"] diff --git a/images/journey-node24-python311/README.md b/images/journey-node24-python311/README.md new file mode 100644 index 0000000..902de07 --- /dev/null +++ b/images/journey-node24-python311/README.md @@ -0,0 +1,52 @@ +# Node 24 and Python 3.11 journey candidate + +This recipe builds an **unadmitted development candidate** for journeys that need +Node 24.19.0 and Python 3.11 together. It selects official Linux/amd64 Debian +Trixie manifests by immutable digest and copies Node with its matching C++/GCC +runtime libraries into the pinned Python image. No package installation or target +code is involved. Installer packages and bundled ensurepip wheels are removed: +the declared journey needs Node built-ins and Python's standard library only. +This avoids carrying unused installer vulnerabilities into the runtime. Build +with networking disabled for all RUN steps. + +```text +docker build --network=none --platform linux/amd64 --pull=false \ + --tag echo-certforge/discord-memory-candidate: \ + images/journey-node24-python311 +``` + +The image sets `ECHO_TEST_PYTHON=/usr/local/bin/python3`, UTF-8 pipe encoding and +non-root UID/GID 65534. Execution still requires the existing DockerSandbox +network, read-only root/source, capability, privilege, CPU, memory, swap, PID, +tmpfs, timeout and cleanup controls. The recipe does not select a live image, +change any project, alter a policy, create credentials or sign an attestation. + +Before admission, require exact source/recipe/base provenance, a complete OS and +language SBOM (including the copied Node libraries), current vulnerability and +secret scans, independent build comparison, and an attestation from the existing +trusted image authority. The existing `scan_dockerfile`, `scan_build_context`, +`evaluate_image_admission` and P4 evidence schema remain authoritative. The +twelve-role `seal_p4_images.py` wrapper does not itself accept this different +layout; do not fabricate its record or relabel a local image as admitted. + +The API and worker bind a selected sandbox's actual pinned image and complete +execution profile through the shared measured runtime identity from PR28. See +`docs/JOURNEY_IMAGE_ADMISSION.md` for the separate unsigned preparation and +existing-authority sealing procedure. Legacy fixed-label environment reuse is +invalidated by the measured runtime contract. +Before a reviewed owner rollout, drain or reconcile queued reservations through +supported governance; do not edit their identity or rewrite completed evidence. +The source commit, configured image and fresh environment digest must match in +the API, dispatcher, signed evidence and eventual production E2E attestation. + +For a reviewed exact-run operation, the existing dispatcher accepts `--once`, +`--run-id` and `--sandbox-image`. The shared dispatcher can race that claimant; +there is no per-project selector in the current submission schema. A source-only +candidate or test does not authorize stopping the shared dispatcher or changing +its configuration. An independently reviewed owner procedure must establish +admitted image identity, current queue/lease state, credential custody, rollback +and exact-source service acceptance before any such action. + +Old failed runs remain immutable. A fresh final target SHA requires a new +idempotency key and actual signed evidence. Successful local/source journeys +cannot satisfy the independent production E2E rule or establish a 95+ bot grade. diff --git a/images/requirements.in b/images/requirements.in index c6bed32..ea9a891 100644 --- a/images/requirements.in +++ b/images/requirements.in @@ -1,4 +1,4 @@ -cryptography==49.0.0 +cryptography==50.0.0 fastapi==0.139.0 pydantic==2.13.4 uvicorn==0.51.0 diff --git a/images/requirements.lock b/images/requirements.lock index 3aa498a..07cfe86 100644 --- a/images/requirements.lock +++ b/images/requirements.lock @@ -126,53 +126,53 @@ colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via click -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via -r images/requirements.in fastapi==0.139.0 \ --hash=sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145 \ diff --git a/images/runner/Dockerfile b/images/runner/Dockerfile index 1052310..b198aa9 100644 --- a/images/runner/Dockerfile +++ b/images/runner/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Runner" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="runner" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/images/signer/Dockerfile b/images/signer/Dockerfile index fa7c7ae..3f5f293 100644 --- a/images/signer/Dockerfile +++ b/images/signer/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Verdict Signer" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="signer" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/images/verifier/Dockerfile b/images/verifier/Dockerfile index 62aa2bb..d704e95 100644 --- a/images/verifier/Dockerfile +++ b/images/verifier/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Verifier" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="verifier" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/images/worker/Dockerfile b/images/worker/Dockerfile index e4389b8..bdf054b 100644 --- a/images/worker/Dockerfile +++ b/images/worker/Dockerfile @@ -1,4 +1,5 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0 +RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +10,7 @@ LABEL org.opencontainers.image.title="Echo Certification Forge Worker" \ org.opencontainers.image.source="https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.base.name="python:3.12-alpine" \ - org.opencontainers.image.base.digest="sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" \ + org.opencontainers.image.base.digest="sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" \ echo.certforge.image.role="worker" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ diff --git a/policies/mandatory-rules.v1.json b/policies/mandatory-rules.v1.json index ae17648..25e8723 100644 --- a/policies/mandatory-rules.v1.json +++ b/policies/mandatory-rules.v1.json @@ -83,6 +83,14 @@ "conditional_allowed": false, "minimum_evidence": 1, "description": "Deployment authorization checks the exact certified digest, policy, environment, and lifecycle." + }, + { + "id": "production_e2e", + "severity": "BLOCKER", + "mandatory": true, + "conditional_allowed": false, + "minimum_evidence": 1, + "description": "A trusted independent collector proves the exact deployed identity, real critical journeys, negative controls, stability, and external client acceptance end to end." } ] } diff --git a/policies/mandatory-rules.v2.json b/policies/mandatory-rules.v2.json index 45c4ee9..30a31dc 100644 --- a/policies/mandatory-rules.v2.json +++ b/policies/mandatory-rules.v2.json @@ -91,6 +91,14 @@ "conditional_allowed": false, "minimum_evidence": 1, "description": "Deployment authorization checks the exact certified digest, policy, environment, and lifecycle." + }, + { + "id": "production_e2e", + "severity": "BLOCKER", + "mandatory": true, + "conditional_allowed": false, + "minimum_evidence": 1, + "description": "A trusted independent collector proves the exact deployed identity, real critical journeys, negative controls, stability, and external client acceptance end to end." } ] } diff --git a/pyproject.toml b/pyproject.toml index 754c58c..1b1ee50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "echo-certification-forge" -version = "1.1.0" +version = "1.2.0" description = "Deterministic, evidence-backed release authority for EchoForge" readme = "README.md" requires-python = ">=3.12" @@ -22,6 +22,7 @@ dev = [ "httpx>=0.28.0", "pytest>=8.3.0", "pytest-cov>=6.0.0", + "hatchling>=1.27", ] [project.scripts] @@ -31,6 +32,10 @@ echo-cert-graphic = "echo_certification_forge.certificate_graphic:main" [tool.hatch.build.targets.wheel] packages = ["src/echo_certification_forge"] +[tool.hatch.build.targets.wheel.force-include] +"pyproject.toml" = "echo_certification_forge/runtime_data/pyproject.toml" +"images/requirements.lock" = "echo_certification_forge/runtime_data/requirements.lock" + [tool.pytest.ini_options] addopts = "--strict-markers --strict-config" pythonpath = ["src"] diff --git a/scripts/certforge_journey.py b/scripts/certforge_journey.py new file mode 100644 index 0000000..722977e --- /dev/null +++ b/scripts/certforge_journey.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Certification Forge journey for echo-certification-forge itself (ops-20260924). + +CertForge runs this against the exact acquired commit, offline and stdlib-only, from a +read-only checkout. The product's full suite needs pydantic and cryptography, so it runs in +hosted CI and in P1-P7 acceptance. This journey proves the release contract that has to hold +for any certifiable CertForge build: + +- The production rule manifest's canonical digest equals the pinned + run_worker._PRODUCTION_MANIFEST_SHA256 and the deploy default. +- Every role image and the journey sandbox use one patched base-image digest, and + cryptography is hash-locked at a patched release. +- The dispatcher unit only Wants= the API service (issue #22). +- Every source module compiles. +- The stdlib-runnable release suites pass through scripts/certforge_testkit.py. +""" +from __future__ import annotations + +import hashlib +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import certforge_testkit # noqa: E402 + +SUITES = ("tests/test_release_pins.py", "tests/test_deploy_gate.py") + + +def fail(message: str) -> int: + print(f"CERTFORGE_SELF_JOURNEY_FAILED: {message}", file=sys.stderr) + return 1 + + +def canonical_digest(path: Path) -> str: + value = json.loads(path.read_text(encoding="utf-8")) + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8")).hexdigest() + + +def main() -> int: + cert = json.loads((ROOT / ".echo/certification.json").read_text(encoding="utf-8")) + apps = json.loads((ROOT / ".echo/apps.json").read_text(encoding="utf-8")) + if cert.get("version") != 1 or apps.get("apps", {}).get("certification-forge", {}).get("enabled") is not True: + return fail("opt-in contract invalid") + + manifest_digest = canonical_digest(ROOT / "policies/mandatory-rules.v2.json") + run_worker = (ROOT / "src/echo_certification_forge/run_worker.py").read_text(encoding="utf-8") + pinned = re.search(r'_PRODUCTION_MANIFEST_SHA256 = \(\s*"([0-9a-f]{64})"', run_worker) + deploy = (ROOT / "deploy/deploy_forge.sh").read_text(encoding="utf-8") + deploy_default = re.search(r'TRUSTED_MANIFEST_SHA256="\$\{ECHO_CERTFORGE_TRUSTED_MANIFEST_SHA256:-([0-9a-f]{64})\}"', + deploy) + if not pinned or pinned.group(1) != manifest_digest: + return fail(f"run_worker manifest pin != canonical policy digest {manifest_digest}") + if not deploy_default or deploy_default.group(1) != manifest_digest: + return fail("deploy default trusted manifest digest != canonical policy digest") + + compiled = 0 + for path in sorted((ROOT / "src").rglob("*.py")): + try: + compile(path.read_text(encoding="utf-8"), str(path), "exec") + except SyntaxError as exc: + return fail(f"syntax error: {path.relative_to(ROOT)}: {exc}") + compiled += 1 + print(json.dumps({"manifest_sha256": manifest_digest, "modules_compiled": compiled})) + # The two release suites are self-contained text/contract checks; tests/conftest.py needs + # pydantic/cryptography, which the stdlib-only sandbox does not have. + return certforge_testkit.run(ROOT, SUITES, sys_paths=("tests", "."), budget_s=60.0, + min_passed=15, conftest=False) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/certforge_testkit.py b/scripts/certforge_testkit.py new file mode 100644 index 0000000..ce78108 --- /dev/null +++ b/scripts/certforge_testkit.py @@ -0,0 +1,562 @@ +"""Stdlib-only test runner for Certification Forge journeys (ops-20260924). + +The CertForge sandbox runs a journey in ``python:3.12-alpine`` with no network, no +third-party packages, a read-only checkout and a 90 s wall clock. Most ECHO suites are +written for pytest, so this module provides the small pytest surface they use: +``raises``, ``mark.parametrize`` / ``skip`` / ``skipif`` (other marks are no-ops), ``param``, +``skip()``, ``fail()``, ``importorskip()``, ``approx``, and ``@fixture`` functions (plain or +``yield``) from the suite module and its ``conftest.py`` files, plus the built-in fixtures +``tmp_path``, ``monkeypatch`` and ``capsys``. It also runs ``unittest.TestCase`` classes and +pytest-style ``Test*`` classes. + +Third-party modules that a suite imports but never exercises can be replaced with inert +stubs (``stub_modules``). Stubs are installed only when the real module is missing, and the +report names them. Every failure, error, import error, empty run or blown time budget fails +the journey (fail closed). +""" +from __future__ import annotations + +import contextlib +import importlib +import importlib.util +import inspect +import io +import itertools +import os +import re +import sys +import tempfile +import time +import traceback +import types +import unittest +from pathlib import Path +from typing import Any, Callable, Iterable + + +class Skipped(Exception): + """Raised by ``pytest.skip`` / ``importorskip``.""" + + +class Failed(AssertionError): + """Raised by ``pytest.fail``.""" + + +class BudgetExceeded(Exception): + """The journey ran out of its wall-clock budget (fails the journey).""" + + +# ── pytest compatibility surface ───────────────────────────────────────────────────────── +class _Raises: + def __init__(self, expected: Any, match: str | None = None) -> None: + self.expected = expected + self.match = match + self.value: BaseException | None = None + + def __enter__(self) -> "_Raises": + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + if exc_type is None: + raise Failed(f"DID NOT RAISE {self.expected}") + if not issubclass(exc_type, self.expected): + return False + if self.match is not None and not re.search(self.match, str(exc)): + raise Failed(f"regex {self.match!r} did not match {str(exc)!r}") + self.value = exc + return True + + +class _Param: + def __init__(self, *values: Any, id: str | None = None, marks: Any = ()) -> None: # noqa: A002 + self.values = values + self.id = id + self.marks = marks + + +class _NoopMark: + def __call__(self, *args: Any, **kwargs: Any) -> Any: + if len(args) == 1 and not kwargs and callable(args[0]): + return args[0] + return lambda fn: fn + + +class _Mark: + @staticmethod + def parametrize(names: Any, values: Iterable[Any], **_: Any) -> Callable: + if isinstance(names, str): + names = [n.strip() for n in names.split(",") if n.strip()] + names = list(names) + rows = [] + for value in values: + if isinstance(value, _Param): + value = value.values if len(names) > 1 else value.values[0] + rows.append(tuple(value) if len(names) > 1 else (value,)) + + def deco(fn: Callable) -> Callable: + fn.__dict__.setdefault("_cf_params", []).insert(0, (names, rows)) + return fn + return deco + + @staticmethod + def skip(reason: str = "") -> Callable: + def deco(fn: Callable) -> Callable: + fn._cf_skip = reason or "skip" + return fn + return deco + + @staticmethod + def skipif(condition: Any, reason: str = "") -> Callable: + def deco(fn: Callable) -> Callable: + if condition: + fn._cf_skip = reason or "skipif" + return fn + return deco + + def __getattr__(self, name: str) -> _NoopMark: + return _NoopMark() + + +class _Approx: + def __init__(self, expected: Any, rel: float | None = None, abs: float | None = None) -> None: # noqa: A002 + self.expected, self.rel, self.abs = expected, rel, abs + + def _close(self, a: float, b: float) -> bool: + rel = 1e-6 if self.rel is None else self.rel + tol = max(rel * abs(b), 1e-12 if self.abs is None else self.abs) + return abs(a - b) <= tol + + def __eq__(self, other: Any) -> bool: + if isinstance(self.expected, (list, tuple)): + return len(other) == len(self.expected) and all( + self._close(a, b) for a, b in zip(other, self.expected)) + if isinstance(self.expected, dict): + return other.keys() == self.expected.keys() and all( + self._close(other[k], v) for k, v in self.expected.items()) + return self._close(other, self.expected) + + def __repr__(self) -> str: + return f"approx({self.expected!r})" + + +def _fixture(*args: Any, **kwargs: Any) -> Any: + def mark(fn: Callable) -> Callable: + fn._cf_fixture = True + return fn + if len(args) == 1 and callable(args[0]) and not kwargs: + return mark(args[0]) + return mark + + +def _skip(reason: str = "", **_: Any) -> None: + raise Skipped(reason) + + +def _fail(reason: str = "", **_: Any) -> None: + raise Failed(reason) + + +def _importorskip(name: str, *_: Any, **__: Any) -> Any: + try: + return importlib.import_module(name) + except ImportError as exc: + raise Skipped(f"{name} unavailable: {exc}") from None + + +def pytest_module() -> types.ModuleType: + mod = types.ModuleType("pytest") + mod.__dict__.update( + raises=_Raises, mark=_Mark(), param=_Param, fixture=_fixture, skip=_skip, + fail=_fail, importorskip=_importorskip, approx=_Approx, + __certforge_shim__=True, + ) + mod.skip.Exception = Skipped # type: ignore[attr-defined] + return mod + + +# ── built-in fixtures ──────────────────────────────────────────────────────────────────── +def _resolve_dotted(path: str) -> Any: + """Import the longest importable prefix of ``path``, then walk the remaining attributes.""" + parts = path.split(".") + for cut in range(len(parts), 0, -1): + try: + obj = importlib.import_module(".".join(parts[:cut])) + except ImportError: + continue + for attr in parts[cut:]: + obj = getattr(obj, attr) + return obj + raise ImportError(f"cannot resolve {path!r}") + + +class MonkeyPatch: + _MISSING = object() + + def __init__(self) -> None: + self._undo: list[Callable[[], None]] = [] + + def setattr(self, target: Any, name: Any, value: Any = _MISSING, raising: bool = True) -> None: + if value is self._MISSING: # "pkg.mod.attr" form + value = name + owner_path, _, name = str(target).rpartition(".") + target = _resolve_dotted(owner_path) + old = getattr(target, name, self._MISSING) + if old is self._MISSING and raising: + raise AttributeError(f"{target!r} has no attribute {name!r}") + setattr(target, name, value) + self._undo.append(lambda: delattr(target, name) if old is self._MISSING + else setattr(target, name, old)) + + def delattr(self, target: Any, name: str, raising: bool = True) -> None: + if not hasattr(target, name): + if raising: + raise AttributeError(name) + return + old = getattr(target, name) + delattr(target, name) + self._undo.append(lambda: setattr(target, name, old)) + + def setitem(self, mapping: Any, key: Any, value: Any) -> None: + old = mapping.get(key, self._MISSING) + mapping[key] = value + self._undo.append(lambda: mapping.pop(key, None) if old is self._MISSING + else mapping.__setitem__(key, old)) + + def delitem(self, mapping: Any, key: Any, raising: bool = True) -> None: + if key not in mapping: + if raising: + raise KeyError(key) + return + old = mapping.pop(key) + self._undo.append(lambda: mapping.__setitem__(key, old)) + + def setenv(self, name: str, value: Any, prepend: str | None = None) -> None: + value = str(value) + if prepend and name in os.environ: + value = value + prepend + os.environ[name] + self.setitem(os.environ, name, value) + + def delenv(self, name: str, raising: bool = True) -> None: + self.delitem(os.environ, name, raising) + + def syspath_prepend(self, path: Any) -> None: + sys.path.insert(0, str(path)) + self._undo.append(lambda: sys.path.remove(str(path))) + + def chdir(self, path: Any) -> None: + old = os.getcwd() + os.chdir(path) + self._undo.append(lambda: os.chdir(old)) + + def undo(self) -> None: + while self._undo: + self._undo.pop()() + + +class CapSys: + def __init__(self) -> None: + self._out, self._err = io.StringIO(), io.StringIO() + self._saved = (sys.stdout, sys.stderr) + sys.stdout, sys.stderr = self._out, self._err + + def readouterr(self) -> Any: + out, err = self._out.getvalue(), self._err.getvalue() + self._out.seek(0), self._out.truncate(), self._err.seek(0), self._err.truncate() + return types.SimpleNamespace(out=out, err=err) + + def close(self) -> None: + sys.stdout, sys.stderr = self._saved + + +# ── inert stubs for unexercised third-party imports ────────────────────────────────────── +class _StubObject: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + if len(args) == 1 and callable(args[0]) and not kwargs: + return args[0] # used as a decorator + return _StubObject() + + def __getattr__(self, name: str) -> Any: + return _StubObject() + + +def _stub_module(name: str) -> types.ModuleType: + mod = types.ModuleType(name) + mod.__path__ = [] # allow "import pkg.sub" + mod.__certforge_stub__ = True + + def __getattr__(attr: str) -> Any: + if attr.startswith("__"): + raise AttributeError(attr) + base = Exception if attr.endswith(("Error", "Exception")) else _StubObject + value = type(attr, (base,), {"__module__": name}) + setattr(mod, attr, value) + return value + mod.__getattr__ = __getattr__ # type: ignore[attr-defined] + return mod + + +def install_stubs(names: Iterable[str]) -> list[str]: + installed = [] + for name in names: + if name in sys.modules: + continue + try: + importlib.import_module(name) + continue + except ImportError: + pass + sys.modules[name] = _stub_module(name) + parent, _, child = name.rpartition(".") + if parent and parent in sys.modules: + setattr(sys.modules[parent], child, sys.modules[name]) + installed.append(name) + return installed + + +# ── collection and execution ───────────────────────────────────────────────────────────── +class _Session: + def __init__(self, root: Path, deadline: float) -> None: + self.root = root + self.deadline = deadline + self.passed = self.failed = self.skipped = 0 + self.failures: list[str] = [] + + # fixtures ---------------------------------------------------------------------------- + def _resolve(self, name: str, fixtures: dict, cache: dict, teardown: list) -> Any: + if name in cache: + return cache[name] + if name == "tmp_path": + value = Path(tempfile.mkdtemp(prefix="cf-")) + elif name == "monkeypatch": + value = MonkeyPatch() + teardown.append(value.undo) + elif name == "capsys": + value = CapSys() + teardown.append(value.close) + elif name == "request": + value = types.SimpleNamespace(param=None, node=None, config=None) + elif name in fixtures: + fn = fixtures[name] + kwargs = {p: self._resolve(p, fixtures, cache, teardown) + for p in inspect.signature(fn).parameters} + value = fn(**kwargs) + if inspect.isgenerator(value): + gen = value + value = next(gen) + teardown.append(lambda g=gen: next(g, None)) + else: + raise LookupError(f"fixture {name!r} not found") + cache[name] = value + return value + + def _call(self, label: str, fn: Callable, fixtures: dict, bound: dict) -> None: + if time.monotonic() > self.deadline: + raise BudgetExceeded(f"journey time budget exhausted before {label}") + skip = getattr(fn, "_cf_skip", None) + if skip: + self.skipped += 1 + return + cache: dict = dict(bound) + teardown: list = [] + try: + params = [p for p in inspect.signature(fn).parameters if p != "self"] + kwargs = {p: self._resolve(p, fixtures, cache, teardown) for p in params} + fn(**kwargs) + self.passed += 1 + except Skipped: + self.skipped += 1 + except (BudgetExceeded, KeyboardInterrupt): + raise + except BaseException as exc: # noqa: BLE001 - every error (incl. SystemExit) is a failure + self.failed += 1 + self.failures.append(f"{label}: {type(exc).__name__}: {exc}\n" + + "".join(traceback.format_exc(limit=6))[-1500:]) + finally: + for fin in reversed(teardown): + with contextlib.suppress(Exception): + fin() + + def _expand(self, label: str, fn: Callable, fixtures: dict) -> None: + grids = getattr(fn, "_cf_params", []) + if not grids: + self._call(label, fn, fixtures, {}) + return + for i, combo in enumerate(itertools.product(*[rows for _, rows in grids])): + bound: dict = {} + for (names, _), row in zip(grids, combo): + bound.update(zip(names, row)) + self._call(f"{label}[{i}]", fn, fixtures, bound) + + # suites ------------------------------------------------------------------------------ + def _conftest_fixtures(self, suite: Path) -> dict: + fixtures: dict = {} + if not getattr(self, "use_conftest", True): + return fixtures # self-contained suites; conftest needs packages the sandbox lacks + chain = [d for d in [suite.parent, *suite.parent.parents] if self.root in (d, *d.parents)] + for directory in reversed(chain): + conftest = directory / "conftest.py" + if conftest.is_file(): + mod = _load(conftest, "cf_conftest_" + _slug(conftest.relative_to(self.root))) + fixtures.update(_fixtures_of(mod)) + return fixtures + + def run_suite(self, suite: Path) -> None: + fixtures = self._conftest_fixtures(suite) + module = _load(suite, "cf_suite_" + _slug(suite.relative_to(self.root))) + fixtures.update(_fixtures_of(module)) + rel = str(suite.relative_to(self.root)) + for name, obj in list(vars(module).items()): + if isinstance(obj, type) and issubclass(obj, unittest.TestCase): + result = unittest.TestResult() + unittest.defaultTestLoader.loadTestsFromTestCase(obj).run(result) + self.passed += result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped) + self.skipped += len(result.skipped) + for case, tb in result.failures + result.errors: + self.failed += 1 + self.failures.append(f"{rel}::{case.id()}\n{tb[-1500:]}") + elif isinstance(obj, type) and name.startswith("Test") and "__init__" not in vars(obj): + for meth in [m for m in dir(obj) if m.startswith("test")]: + inst = obj() + if hasattr(inst, "setup_method"): + inst.setup_method(getattr(inst, meth)) + try: + self._expand(f"{rel}::{name}::{meth}", getattr(inst, meth), fixtures) + finally: + if hasattr(inst, "teardown_method"): + inst.teardown_method(getattr(inst, meth)) + elif (name.startswith("test") and inspect.isfunction(obj) + and obj.__module__ == module.__name__): + self._expand(f"{rel}::{name}", obj, fixtures) + + +def _slug(path: Any) -> str: + return re.sub(r"[^0-9A-Za-z]+", "_", str(path)) + + +def _load(path: Path, name: str) -> types.ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _fixtures_of(module: types.ModuleType) -> dict: + return {n: f for n, f in vars(module).items() if callable(f) and getattr(f, "_cf_fixture", False)} + + +def run(root: Path, suites: Iterable[str], *, sys_paths: Iterable[str] = (), + stub_modules: Iterable[str] = (), budget_s: float = 75.0, min_passed: int = 1, + conftest: bool = True) -> int: + """Run ``suites`` (paths relative to ``root``); return a process exit code (0 = PASS).""" + import json + + start = time.monotonic() + sys.dont_write_bytecode = True # read-only checkout + for entry in reversed(list(sys_paths)): + sys.path.insert(0, str(root / entry)) + shim = importlib.util.find_spec("pytest") is None + if shim: + sys.modules["pytest"] = pytest_module() + stubs = install_stubs(stub_modules) + suites = list(suites) + missing = [s for s in suites if not (root / s).is_file()] + if missing: + print("CERTFORGE_TESTKIT_FAILED: missing suites: " + ", ".join(missing), file=sys.stderr) + return 1 + session = _Session(root, start + budget_s) + session.use_conftest = conftest + aborted = None + for suite in suites: + try: + session.run_suite(root / suite) + except BudgetExceeded as exc: + aborted = str(exc) + break + except BaseException as exc: # noqa: BLE001 - an import/collection error fails the suite + session.failed += 1 + session.failures.append(f"{suite}: collection error {type(exc).__name__}: {exc}\n" + + traceback.format_exc(limit=6)[-1500:]) + for failure in session.failures[:20]: + print("FAIL " + failure, file=sys.stderr) + report = { + "suites": len(suites), "passed": session.passed, "failed": session.failed, + "skipped": session.skipped, "pytest_shim": shim, "stubbed_modules": stubs, + "elapsed_s": round(time.monotonic() - start, 2), "aborted": aborted, + } + ok = aborted is None and session.failed == 0 and session.passed >= min_passed + print(("CERTFORGE_TESTKIT_OK " if ok else "CERTFORGE_TESTKIT_FAILED ") + + json.dumps(report, sort_keys=True)) + return 0 if ok else 1 + + +def run_isolated(root: Path, suites: Iterable[dict], *, budget_s: float = 60.0, + per_suite_timeout_s: float = 30.0, min_passed: int = 1) -> int: + """Run each suite in its own interpreter (no module-name collisions between suites). + + ``suites``: dicts with ``path`` plus optional ``sys_paths`` / ``stub_modules``. + """ + import json + import subprocess + + start = time.monotonic() + suites = list(suites) + totals = {"passed": 0, "failed": 0, "skipped": 0} + failed_suites: list[str] = [] + for spec in suites: + remaining = budget_s - (time.monotonic() - start) + if remaining <= 1: + failed_suites.append(f"{spec['path']}: journey time budget exhausted") + break + argv = [sys.executable, "-B", str(Path(__file__).resolve()), "--root", str(root), + "--suite", spec["path"]] + for entry in spec.get("sys_paths", []): + argv += ["--sys-path", entry] + for name in spec.get("stub_modules", []): + argv += ["--stub", name] + try: + proc = subprocess.run(argv, cwd=str(root), capture_output=True, text=True, + timeout=min(per_suite_timeout_s, remaining), check=False) + except subprocess.TimeoutExpired: + failed_suites.append(f"{spec['path']}: timed out") + continue + line = next((ln for ln in reversed(proc.stdout.splitlines()) + if ln.startswith("CERTFORGE_TESTKIT_")), "") + try: + report = json.loads(line.split(" ", 1)[1]) + except (IndexError, ValueError): + report = {} + for key in totals: + totals[key] += int(report.get(key, 0) or 0) + status = "ok" if proc.returncode == 0 else "FAILED" + print(f"suite {status} {spec['path']} passed={report.get('passed')} " + f"failed={report.get('failed')} skipped={report.get('skipped')} " + f"elapsed_s={report.get('elapsed_s')}") + if proc.returncode != 0: + failed_suites.append(spec["path"]) + sys.stderr.write(proc.stderr[-3000:]) + ok = not failed_suites and totals["failed"] == 0 and totals["passed"] >= min_passed + summary = dict(totals, suites=len(suites), failed_suites=failed_suites, + elapsed_s=round(time.monotonic() - start, 2)) + print(("CERTFORGE_SUITES_OK " if ok else "CERTFORGE_SUITES_FAILED ") + + json.dumps(summary, sort_keys=True)) + return 0 if ok else 1 + + +if __name__ == "__main__": # single-suite worker used by run_isolated + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--suite", required=True) + parser.add_argument("--sys-path", action="append", default=[]) + parser.add_argument("--stub", action="append", default=[]) + parser.add_argument("--budget", type=float, default=60.0) + ns = parser.parse_args() + raise SystemExit(run(Path(ns.root), [ns.suite], sys_paths=ns.sys_path, + stub_modules=ns.stub, budget_s=ns.budget)) diff --git a/scripts/certification_journey.py b/scripts/certification_journey.py index 712b631..dfa199c 100644 --- a/scripts/certification_journey.py +++ b/scripts/certification_journey.py @@ -45,7 +45,7 @@ def require(condition: bool, message: str) -> None: def main() -> int: - require(__version__ == "1.1.0", "package version is not synchronized") + require(__version__ == "1.2.0", "package version is not synchronized") python_files = sorted(SRC.rglob("*.py")) require(bool(python_files), "source tree contains no Python modules") for path in python_files: @@ -62,8 +62,8 @@ def main() -> int: sdk = json.loads((ROOT / ".echo" / "sdk.json").read_text(encoding="utf-8")) capabilities = sdk.get("capabilities", []) - require(len(capabilities) == 60, "SDK capability declaration must contain exactly 60 entries") - require(len(set(capabilities)) == 60, "SDK capability declaration contains duplicates") + require(len(capabilities) == 62, "SDK capability declaration must contain exactly 62 entries") + require(len(set(capabilities)) == 62, "SDK capability declaration contains duplicates") require(all(isinstance(item, str) and item.startswith("echo.") for item in capabilities), "SDK capability declaration contains an invalid identifier") diff --git a/scripts/generate_certforge_sdk_contract.py b/scripts/generate_certforge_sdk_contract.py index 1537784..b061f51 100644 --- a/scripts/generate_certforge_sdk_contract.py +++ b/scripts/generate_certforge_sdk_contract.py @@ -216,6 +216,16 @@ def build_contract() -> dict[str, Any]: "input_schema": _operation_input(operation, components), "output_schema": _operation_output(operation, components), } + # Keep the least-privileged publishing capability used by the Certification Forge GitHub + # App as a first-class compatibility alias. It targets the same fail-closed route as the + # tier-2 administrative capability, but the service still re-evaluates the exact signed + # PRODUCTION_READY verdict before publishing public verification material. + publish = capabilities["echo.certforge.admin.publish"] + capabilities["echo.certforge.publish"] = { + "operation": publish["operation"], + "input_schema": publish["input_schema"], + "output_schema": publish["output_schema"], + } capabilities.update(_manual_capabilities()) return { "schema_version": "1.0.0", diff --git a/scripts/master_acceptance.py b/scripts/master_acceptance.py index e9f4a2e..1044d96 100644 --- a/scripts/master_acceptance.py +++ b/scripts/master_acceptance.py @@ -72,10 +72,10 @@ def _p7(value: dict[str, Any]) -> None: def _sdk(value: dict[str, Any]) -> None: capabilities = value.get("capabilities") - _require(value.get("capability_count") == 60, "SDK contract must contain exactly 60 capabilities") - _require(isinstance(capabilities, dict) and len(capabilities) == 60, "SDK capability rows are incomplete") + _require(value.get("capability_count") == 62, "SDK contract must contain exactly 62 capabilities") + _require(isinstance(capabilities, dict) and len(capabilities) == 62, "SDK capability rows are incomplete") names = list(capabilities) - _require(len(set(names)) == 60, "SDK capability IDs are not unique") + _require(len(set(names)) == 62, "SDK capability IDs are not unique") _require(all(isinstance(name, str) and name.startswith("echo.cert") for name in names), "SDK capability namespace drift") _require( all( diff --git a/scripts/p1_acceptance.py b/scripts/p1_acceptance.py index 77c43e4..163a603 100644 --- a/scripts/p1_acceptance.py +++ b/scripts/p1_acceptance.py @@ -70,30 +70,37 @@ def main() -> int: "reasons": list(default.reasons), } - store.register_run("cert-verified-ready", target, environment, manifest.manifest_id, manifest.digest) + # P1 is source/control-plane acceptance, not an external production E2E run. Even with + # every local rule row marked true, it must remain NOT_READY without the independently + # signed production_e2e attestation. + store.register_run("cert-source-only", target, environment, manifest.manifest_id, manifest.digest) for index, rule in enumerate(manifest.rules, start=1): artifact_id = f"proof-{index:02d}" content = json.dumps({"rule": rule.id, "passed": True}, sort_keys=True).encode() store.append_artifact( - "cert-verified-ready", target.tenant_id, artifact_id, content, + "cert-source-only", target.tenant_id, artifact_id, content, "application/json", "p1-independent-harness", RedactionStatus.COMPLETE, ) store.record_rule_result( - "cert-verified-ready", target.tenant_id, + "cert-source-only", target.tenant_id, RuleResult(rule.id, True, (artifact_id,), {"fixture": "known-good"}), ) - store.set_run_outcome("cert-verified-ready", target.tenant_id, RunOutcome.COMPLETE) - ready = engine.evaluate(store, "cert-verified-ready", target.tenant_id, manifest, signer.key_id) + store.set_run_outcome("cert-source-only", target.tenant_id, RunOutcome.COMPLETE) + ready = engine.evaluate(store, "cert-source-only", target.tenant_id, manifest, signer.key_id) envelope = signer.sign(ready) - store.save_signed_verdict("cert-verified-ready", target.tenant_id, envelope) + store.save_signed_verdict("cert-source-only", target.tenant_id, envelope) trusted = TrustedPublicKeyRegistry.empty() trusted.add_pem(signer.public_key_pem) gate = DeployGate(store, trusted).evaluate( - target.tenant_id, "cert-verified-ready", target.identity_digest, + target.tenant_id, "cert-source-only", target.identity_digest, environment.identity_digest, manifest.digest, ) - report["scenarios"]["verified_ready"] = { - "passed": ready.release_verdict.value == "PRODUCTION_READY" and gate.allowed, + report["scenarios"]["source_only_blocks"] = { + "passed": ( + ready.release_verdict.value == "NOT_READY" + and not gate.allowed + and "production_e2e_schema_invalid" in ready.reasons + ), "verdict": ready.release_verdict.value, "deploy_gate_allowed": gate.allowed, "evidence_merkle_root": ready.evidence_merkle_root, @@ -101,10 +108,10 @@ def main() -> int: "signing_key_id": envelope.key_id, } - tampered_path = workspace / "evidence" / target.tenant_id / "cert-verified-ready" / "artifacts" / "proof-01.bin" + tampered_path = workspace / "evidence" / target.tenant_id / "cert-source-only" / "artifacts" / "proof-01.bin" tampered_path.write_bytes(b"tampered") tampered_gate = DeployGate(store, trusted).evaluate( - target.tenant_id, "cert-verified-ready", target.identity_digest, + target.tenant_id, "cert-source-only", target.identity_digest, environment.identity_digest, manifest.digest, ) report["scenarios"]["tamper_blocks"] = { diff --git a/scripts/p4_hostile_acceptance.py b/scripts/p4_hostile_acceptance.py index b17dd0e..f05e19c 100644 --- a/scripts/p4_hostile_acceptance.py +++ b/scripts/p4_hostile_acceptance.py @@ -136,6 +136,25 @@ def write_json(path: Path, value: Any) -> None: path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") +def write_acceptance_report(path: Path, report: dict[str, Any]) -> None: + """Publish phase completion only after checks and final cleanup succeed.""" + report.pop("completed_phase_gate", None) + checks = report.get("checks") + cleanup = report.get("cleanup") or {} + if ( + report.get("phase") == "P4" + and report.get("passed") is True + and report.get("run_outcome") == "COMPLETE" + and isinstance(checks, dict) + and checks + and all(value is True for value in checks.values()) + and cleanup.get("unrelated_container_ids_preserved") is True + and cleanup.get("ephemeral_private_files_removed") is True + ): + report["completed_phase_gate"] = "P4" + write_json(path, report) + + def docker_json(*arguments: str) -> Any: return json.loads(run(["docker", *arguments]).stdout) @@ -1375,6 +1394,10 @@ def service_health_probe(image: str, role: ImageRole, ownership_token: str, work "ECHO_CERTFORGE_DB": "/workspace/state/certforge.sqlite3", "ECHO_CERTFORGE_EVIDENCE_ROOT": "/workspace/state/evidence", "ECHO_CERTFORGE_TRUSTED_KEYS": "/workspace/state/trusted-public-keys", + # The worker app opens its P6 deployment ledger at import time. The default + # (/var/deployments.sqlite3) is on the read-only image root, so + # the ledger joins the other state paths on the writable workspace. + "ECHO_CERTFORGE_DEPLOYMENT_LEDGER": "/workspace/state/deployments.sqlite3", } ) command = ["-lc", "mkdir -p /workspace/state/trusted-public-keys /workspace/state/evidence && exec uvicorn echo_certification_forge.app:app --host 127.0.0.1 --port 8080 --no-access-log"] @@ -1859,7 +1882,7 @@ def main() -> int: report["error"] = {"type": "CleanupError", "message": "unrelated container identity changed"} return_code = 1 report["completed_at_utc"] = to_utc_iso(datetime.now(UTC)) - write_json(output, report) + write_acceptance_report(output, report) print( json.dumps( { diff --git a/scripts/p4_hostile_acceptance_exact.py b/scripts/p4_hostile_acceptance_exact.py index 0d6d61c..732ad99 100644 --- a/scripts/p4_hostile_acceptance_exact.py +++ b/scripts/p4_hostile_acceptance_exact.py @@ -147,6 +147,10 @@ def enrich_report( if not output_path.is_file(): raise RuntimeError(f"P4 harness did not produce an output report: {output_path}") report = json.loads(output_path.read_text(encoding="utf-8")) + # Enrichment is a further acceptance boundary; invalidate any earlier marker + # before parsing captured evidence, which can raise or be interrupted. + report.pop("completed_phase_gate", None) + output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") report["harness"] = { "path": str(HERE), "sha256": sha256_file(HERE), @@ -205,6 +209,10 @@ def enrich_report( ) return_code = 1 report["wrapper_return_code"] = return_code + if return_code != 0: + report["passed"] = False + report["run_outcome"] = "INFRA_FAILED" + report["release_verdict"] = "NOT_READY" output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") return report @@ -213,6 +221,15 @@ def main(arguments: list[str] | None = None) -> int: argv = list(sys.argv[1:] if arguments is None else arguments) sealed_manifest_path, output_path = parse_wrapper_paths(argv) harness = load_harness() + publish_report = harness.write_acceptance_report + + def write_pending_report(path: Path, report: dict[str, Any]) -> None: + report.pop("completed_phase_gate", None) + harness.write_json(path, report) + + # The core can finish before the exact verifier evidence is enriched. + # Keep its on-disk result pending until every wrapper layer has returned. + harness.write_acceptance_report = write_pending_report artifacts = resolve_runner_artifacts(harness, sealed_manifest_path) capture: dict[str, Any] = {} harness.execute_container = make_execute_container_override( @@ -234,6 +251,7 @@ def main(arguments: list[str] | None = None) -> int: return_code=return_code, ) final_code = 0 if report.get("passed") is True and report.get("run_outcome") == "COMPLETE" else 1 + publish_report(output_path, report) print( json.dumps( { diff --git a/scripts/p6_acceptance.py b/scripts/p6_acceptance.py index 05c4429..15490fe 100644 --- a/scripts/p6_acceptance.py +++ b/scripts/p6_acceptance.py @@ -41,7 +41,7 @@ import time import urllib.parse import urllib.request -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -64,6 +64,12 @@ VerdictLifecycleEvent, ) from echo_certification_forge.policy import RuleManifest # noqa: E402 +from echo_certification_forge.production_e2e import ( # noqa: E402 + BASE_CHECKS, + GENERIC_PROFILE, + SCHEMA_VERSION, + verify_signed_attestation, +) from echo_certification_forge.release_hooks import ( # noqa: E402 SIGNATURE_HEADER, TIMESTAMP_HEADER, @@ -187,6 +193,9 @@ def check(check_id: str, description: str, passed: bool, detail: object) -> None signer = Ed25519VerdictSigner.generate() trusted = TrustedPublicKeyRegistry.empty() trusted.add_pem(signer.public_key_pem) + e2e_signer = Ed25519VerdictSigner.generate() + e2e_trusted = TrustedPublicKeyRegistry.empty() + e2e_trusted.add_pem(e2e_signer.public_key_pem) environment = EnvironmentIdentity( runner_image_sha256=digest("runner"), @@ -205,6 +214,26 @@ def certify(run_id: str, target: TargetIdentity) -> None: workdir = tmp_path / f"src-{run_id}" workdir.mkdir() (workdir / "hello.py").write_text("print('service ok')\n", encoding="utf-8") + observed_at = utc_now() + e2e_payload = { + "schema_version": SCHEMA_VERSION, + "attestation_id": f"p6-e2e-{target.identity_digest[:16]}", + "profile": GENERIC_PROFILE, + "target_identity_digest": target.identity_digest, + "environment_identity_digest": environment.identity_digest, + "source_commit": target.source_commit, + "deployment_sha": target.source_commit, + "canonical_target": target.canonical_ref, + "required_checks": sorted(BASE_CHECKS), + "checks": {name: True for name in sorted(BASE_CHECKS)}, + "stability_probe_count": 3, + "observed_at": to_utc_iso(observed_at), + "expires_at": to_utc_iso(observed_at + timedelta(minutes=30)), + "signing_key_id": e2e_signer.key_id, + } + production_e2e = verify_signed_attestation( + e2e_signer.sign_payload(e2e_payload), e2e_trusted + ) result = RunExecutor(store, manifest, signer).execute( run_id, target.tenant_id, @@ -215,6 +244,7 @@ def certify(run_id: str, target: TargetIdentity) -> None: "runner_control_channel": True, "signing_authority_separation": True, }, + production_e2e_attestation=production_e2e, ) if result.release_verdict != "PRODUCTION_READY": raise RuntimeError(f"certification failed: {result.blocking_findings}") @@ -225,7 +255,7 @@ def make_target(label: str) -> TargetIdentity: target_type="container", canonical_ref=f"registry.echo/app@{label}", artifact_sha256=digest(label), - source_commit="abc123def456", + source_commit=digest("source-commit")[:40], dependency_sha256=digest("dependencies"), configuration_sha256=digest("configuration"), ) diff --git a/scripts/p7_acceptance.py b/scripts/p7_acceptance.py index e467e20..223dacd 100644 --- a/scripts/p7_acceptance.py +++ b/scripts/p7_acceptance.py @@ -19,7 +19,7 @@ sys.path.insert(0, str(REPO / "src")) from echo_certification_forge.acquisition import acquire_target -from echo_certification_forge.canonical import parse_utc_iso +from echo_certification_forge.canonical import parse_utc_iso, sha256_json from echo_certification_forge.dispatch_worker import dispatch_once from echo_certification_forge.evidence import EvidenceStore from echo_certification_forge.intake import SubmitRequest @@ -91,7 +91,7 @@ def submit_local_run( canonical_ref=acquired.canonical_ref, artifact_sha256=acquired.artifact_sha256, ) - environment = _worker_environment() + environment = _worker_environment(manifest=manifest, journey=journey) payload = { "tenant_id": organization_id, "project_id": project.json()["project_id"], @@ -678,6 +678,7 @@ def main() -> int: worker_active.bootstrap_api_key, worker_source, key="p7-worker-retention-0001", + journey=[sys.executable, "-c", "print('ok')"], ) suspended_worker_run_id, _suspended_target = submit_local_run( client, @@ -686,6 +687,7 @@ def main() -> int: worker_suspended.bootstrap_api_key, worker_source, key="p7-worker-suspended-0001", + journey=[sys.executable, "journey.py"], ) with closing(sqlite3.connect(db)) as connection: connection.execute( @@ -1547,6 +1549,9 @@ def explode_policy_list(_principal): canonical_ref=intake_acquired.canonical_ref, artifact_sha256=intake_acquired.artifact_sha256, ) + intake_environment = _worker_environment( + manifest=manifest, journey=[sys.executable, "journey.py"] + ) intake_request = SubmitRequest.model_validate( { "tenant_id": intake_org.organization_id, @@ -1558,9 +1563,9 @@ def explode_policy_list(_principal): "path": intake_source.resolve().as_posix(), }, "environment": { - "identity_digest": _worker_environment().identity_digest, + "identity_digest": intake_environment.identity_digest, "runner_image_digest": "sha256:" - + _worker_environment().runner_image_sha256, + + intake_environment.runner_image_sha256, }, "policy_version": manifest.manifest_id, "idempotency_key": "p7-durable-intake-0001", @@ -1576,8 +1581,11 @@ def explode_policy_list(_principal): intake_reservation = governance.reserve_certification_run( intake_principal, project_id=str(intake_request.project_id), - idempotency_key=intake_request.idempotency_key, - request_digest=intake_request.request_digest(), + idempotency_key=f"{intake_request.idempotency_key}@{manifest.digest[:16]}", + request_digest=sha256_json({ + "request": intake_request.request_digest(), + "active_rule_manifest_digest": manifest.digest, + }), policy_version=intake_request.policy_version, target_type=intake_request.target.target_type, target_reference=intake_request.target.reference, @@ -1659,8 +1667,8 @@ def explode_policy_list(_principal): crash_claim, target_identity=crash_target.to_dict(), target_identity_digest=crash_target.identity_digest, - environment_identity=_worker_environment().to_dict(), - environment_identity_digest=_worker_environment().identity_digest, + environment_identity=_worker_environment(manifest=manifest).to_dict(), + environment_identity_digest=_worker_environment(manifest=manifest).identity_digest, ) heartbeat_expiry = governance.heartbeat_worker_claim(crash_claim) with closing(sqlite3.connect(db)) as connection: @@ -1845,6 +1853,7 @@ def explode_policy_list(_principal): boundary_org.bootstrap_api_key, boundary_source, key="p7-execution-boundary-0001", + journey=[sys.executable, "journey.py"], ) boundary_entered = threading.Event() boundary_release = threading.Event() @@ -1945,7 +1954,7 @@ def execute_boundary_run() -> None: and identity_marker.exists() and json.loads(identity_row["target_identity_json"]) == identity_target.to_dict() and json.loads(identity_row["environment_identity_json"]) - == _worker_environment().to_dict(), + == _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]).to_dict(), worker_result=identity_result, target_identity=json.loads(identity_row["target_identity_json"]), environment_identity=json.loads(identity_row["environment_identity_json"]), diff --git a/scripts/prepare_journey_image_admission.py b/scripts/prepare_journey_image_admission.py new file mode 100644 index 0000000..07f22ba --- /dev/null +++ b/scripts/prepare_journey_image_admission.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Prepare a new unsigned image policy using exact reviewed public inputs. No signing mode.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import stat +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from pydantic import ValidationError # noqa: E402 + +from echo_certification_forge.journey_image_admission import ( # noqa: E402 + JourneyQualification, prepare_admission_request, proposed_policy, +) +from echo_certification_forge.supply_chain import ( # noqa: E402 + ImageAdmissionDenied, ImageAdmissionPolicy, ImageAttestation, ImageIdentity, +) + + +def plain_path(path: Path) -> Path: + absolute = Path(os.path.abspath(path)) + for component in (*reversed(absolute.parents), absolute): + info = component.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise ImageAdmissionDenied("reparse_path_refused") + return absolute + + +def read_bounded(path: Path) -> bytes: + checked = plain_path(path) + info = checked.stat() + if not stat.S_ISREG(info.st_mode) or info.st_size > 4 * 1024 * 1024: + raise ImageAdmissionDenied("input_type_or_size_refused") + with checked.open("rb") as handle: + opened = os.fstat(handle.fileno()) + if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + raise ImageAdmissionDenied("input_changed_during_open") + raw = handle.read(4 * 1024 * 1024 + 1) + if len(raw) > 4 * 1024 * 1024: + raise ImageAdmissionDenied("input_size_limit") + return raw + + +def write_new(path: Path, value: object) -> str: + raw = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + with path.open("xb") as handle: + handle.write(raw) + return hashlib.sha256(raw).hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--packet", type=Path, required=True) + parser.add_argument("--qualification", type=Path, required=True) + parser.add_argument("--dockerfile", type=Path, required=True) + parser.add_argument("--prior-policy", type=Path, required=True) + parser.add_argument("--prior-attestation", type=Path, required=True) + parser.add_argument("--expected-source", required=True) + parser.add_argument("--expected-image", required=True) + parser.add_argument("--expected-policy-sha256", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + policy_bytes = read_bounded(args.prior_policy) + if hashlib.sha256(policy_bytes).hexdigest() != args.expected_policy_sha256: + raise ImageAdmissionDenied("prior_policy_pin_mismatch") + identity = ImageIdentity.model_validate_json(read_bounded(args.packet / "runner.identity.UNSIGNED.json")) + if (identity.source_commit, identity.image_digest) != (args.expected_source, args.expected_image): + raise ImageAdmissionDenied("candidate_pin_mismatch") + request = prepare_admission_request( + identity=identity, + qualification=JourneyQualification.model_validate_json(read_bounded(args.qualification)), + prior_policy=ImageAdmissionPolicy.model_validate_json(policy_bytes), + prior_attestation=ImageAttestation.model_validate_json(read_bounded(args.prior_attestation)), + dockerfile=read_bounded(args.dockerfile), + lockfile=read_bounded(args.packet / "journey-image-inputs.lock.json"), + sbom=read_bounded(args.packet / "runner.spdx.json"), + provenance=read_bounded(args.packet / "runner.provenance.json"), + ) + policy = proposed_policy(request) + plain_path(args.output.parent) + args.output.mkdir(exist_ok=False) + request_hash = write_new(args.output / "request.UNSIGNED.json", request.model_dump(mode="json")) + policy_hash = write_new(args.output / "policy.UNSIGNED.json", policy.model_dump(mode="json")) + receipt = { + "state": "UNSIGNED_NOT_ADMITTED", "request_digest": request.digest, + "request_file_sha256": request_hash, "policy_file_sha256": policy_hash, + "source_commit": identity.source_commit, "image_digest": identity.image_digest, + "existing_key_id": request.prior_attestation.key_id, + "live_changes": False, "signed": False, "production_ready": False, + } + # The completion receipt is written last. A partial directory is never ready. + write_new(args.output / "preparation-receipt.json", receipt) + print(json.dumps(receipt, sort_keys=True)) + return 0 + except (ImageAdmissionDenied, ValidationError, OSError, ValueError, TypeError, KeyError): + # Public input may contain private garbage: never reflect its values/errors. + print(json.dumps({"state": "NOT_READY", "error": "preparation_failed", + "signed": False, "live_changes": False})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/register_certforge_caps.sql b/scripts/register_certforge_caps.sql index 80dbd57..de2cc65 100644 --- a/scripts/register_certforge_caps.sql +++ b/scripts/register_certforge_caps.sql @@ -22,6 +22,12 @@ INSERT INTO arcanum_sdk.sdk_capabilities input_schema_json, required_scope, danger_tier, static_headers, default_timeout_seconds, lifecycle_status, health_status) VALUES + ('echo.certforge.runtime_environment', + 'Certification Forge: resolve a bounded job journey into the effective operator-selected runtime identity. Authenticated read only; no customer execution or run creation.', + 'http', 'http://127.0.0.1:8309/v1/subscriber/runtime/environment', 'POST', 'json_body', 'forge', + '{"type":"object","additionalProperties":false,"properties":{"journey":{"type":["array","null"],"minItems":1,"maxItems":32,"items":{"type":"string","minLength":1,"maxLength":2048}}}}'::jsonb, + 'certforge.read', 0, '{"X-Tenant-ID":"org-echo-sovereign","Authorization":"vault:certforge.desktop_admin_api_key"}'::jsonb, 15, 'active', 'unknown'), + ('echo.certforge.health', 'Certification Forge: service health — status, version, custody/anchor/signing posture. No tenant, no side effects.', 'http', 'http://127.0.0.1:8309/healthz', 'GET', 'query', 'forge', @@ -136,6 +142,12 @@ VALUES '{"type":"object","required":["run_id"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":128}},"additionalProperties":false}'::jsonb, 'certforge.admin.mutate', 2, '{"X-Tenant-ID":"org-echo-sovereign","Authorization":"vault:certforge.desktop_admin_api_key"}'::jsonb, 15, 'active', 'unknown'), + ('echo.certforge.publish', + 'Certification Forge: publish public-only verification material for one already-current, signed, production-ready run. The service re-evaluates the deployment gate; this capability cannot change evidence, policy, verdicts, or lifecycle state.', + 'http', 'http://127.0.0.1:8309/v1/subscriber/certifications/{run_id}/publish', 'POST', 'path', 'forge', + '{"type":"object","required":["run_id"],"properties":{"run_id":{"type":"string","minLength":1,"maxLength":128}},"additionalProperties":false}'::jsonb, + 'certforge.read', 1, '{"X-Tenant-ID":"org-echo-sovereign","Authorization":"vault:certforge.desktop_admin_api_key"}'::jsonb, 15, 'active', 'unknown'), + ('echo.certforge.admin.quarantine', 'Certification Forge administration: quarantine one authenticated runner or adapter, removing it from eligible capacity/execution without deleting telemetry. Tier-2 HMAC and Desktop reauthentication are required.', 'http', 'http://127.0.0.1:8309/v1/subscriber/operational-quarantines', 'POST', 'json_body', 'forge', diff --git a/scripts/register_certforge_sdk_schemas.sql b/scripts/register_certforge_sdk_schemas.sql index fbfaae4..7074bce 100644 --- a/scripts/register_certforge_sdk_schemas.sql +++ b/scripts/register_certforge_sdk_schemas.sql @@ -50,9 +50,11 @@ INSERT INTO certforge_sdk_contract(id, input_schema_json, output_schema_json) VA ('echo.certforge.projects.create', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"name":{"maxLength":160,"minLength":1,"type":"string"},"slug":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},"target_reference":{"maxLength":2048,"minLength":1,"type":"string"}},"required":["command","name","slug","target_reference"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), ('echo.certforge.projects.list', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"}},"required":["command"],"type":"object"}'::jsonb, '{"items":{"additionalProperties":true,"type":"object"},"type":"array"}'::jsonb), ('echo.certforge.public_verification', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"verification_id":{"type":"string"}},"required":["command","verification_id"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), + ('echo.certforge.publish', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"run_id":{"type":"string"}},"required":["command","run_id"],"type":"object"}'::jsonb, '{"additionalProperties":{"type":"string"},"type":"object"}'::jsonb), ('echo.certforge.r5.status', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"run_id":{"pattern":"^[A-Za-z0-9._-]{1,64}$","type":"string"}},"required":["command","run_id"],"type":"object"}'::jsonb, '{"additionalProperties":false,"properties":{"note":{"type":"string"},"result":{"anyOf":[{"additionalProperties":false,"properties":{"completion_marker":{"type":["string","null"]},"deployment_authorized":{"type":"boolean"},"evidence_run_id":{"type":"string"},"forge_verification":{"additionalProperties":true,"type":"object"},"mode":{"enum":["preflight","full"],"type":"string"},"operator_exit_code":{"type":"integer"},"operator_report":{"additionalProperties":true,"type":"object"},"r5_gate":{"enum":["PASS","BLOCK"],"type":"string"},"release_verdict":{"enum":["NOT_READY"],"type":"string"},"run_outcome":{"type":"string"},"schema":{"type":"string"},"stderr_tail":{"type":"string"}},"required":["schema","mode","evidence_run_id","run_outcome","release_verdict","r5_gate","deployment_authorized","completion_marker","operator_report","forge_verification","operator_exit_code"],"type":"object"},{"type":"null"}]},"run_id":{"type":"string"},"status":{"enum":["RUNNING","COMPLETE","FAILED"],"type":"string"}},"required":["run_id","status"],"type":"object"}'::jsonb), ('echo.certforge.r5.submit_async', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"dry_run":{"type":"boolean"},"evidence_run_id":{"pattern":"^[A-Za-z0-9._-]{1,64}$","type":"string"},"evidence_run_nonce":{"maxLength":128,"minLength":16,"type":"string"},"expected_base_model_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"expected_base_model_revision":{"maxLength":128,"minLength":6,"type":"string"},"expected_gs343_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"expected_r2d2_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"expected_registry_revision":{"maxLength":128,"minLength":6,"type":"string"},"expected_registry_snapshot_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"expected_server_build_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"expected_signing_key_id":{"maxLength":128,"minLength":8,"type":"string"},"target_family":{"enum":["gs343","r2d2"],"type":"string"}},"required":["command","target_family","expected_server_build_digest","expected_registry_snapshot_digest","expected_registry_revision","expected_signing_key_id","expected_base_model_digest","expected_base_model_revision","expected_gs343_digest","expected_r2d2_digest","evidence_run_id","evidence_run_nonce"],"type":"object"}'::jsonb, '{"additionalProperties":false,"properties":{"idempotent":{"type":"boolean"},"mode":{"enum":["preflight","full"],"type":"string"},"poll_capability":{"enum":["echo.certforge.r5.status"],"type":"string"},"run_id":{"type":"string"},"status":{"enum":["RESERVED","RUNNING"],"type":"string"}},"required":["run_id","status","mode"],"type":"object"}'::jsonb), ('echo.certforge.run', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"run_id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"tenant":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"}},"required":["command","run_id","tenant"],"type":"object"}'::jsonb, '{"additionalProperties":false,"properties":{"poll_capability":{"enum":["echo.certforge.status"],"type":"string"},"run_id":{"type":"string"},"sandboxed":{"type":"boolean"},"status":{"enum":["PENDING","CLAIMED"],"type":"string"},"tenant":{"type":"string"}},"required":["run_id","status","tenant","sandboxed","poll_capability"],"type":"object"}'::jsonb), + ('echo.certforge.runtime_environment', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"journey":{"anyOf":[{"items":{"maxLength":2048,"minLength":1,"pattern":"^[^\\x00]*$","type":"string"},"maxItems":32,"minItems":1,"type":"array"},{"type":"null"}]}},"required":["command"],"type":"object"}'::jsonb, '{"additionalProperties":false,"properties":{"environment":{"additionalProperties":false,"properties":{"adapter_set_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"egress_policy_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"harness_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"model_route_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"os_runtime_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"policy_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"prompt_set_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"runner_image_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"test_plan_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"}},"required":["runner_image_sha256","adapter_set_sha256","test_plan_sha256","policy_sha256","harness_sha256","prompt_set_sha256","model_route_sha256","os_runtime_sha256","egress_policy_sha256"],"type":"object"},"journey":{"anyOf":[{"items":{"maxLength":2048,"minLength":1,"pattern":"^[^\\x00]*$","type":"string"},"type":"array"},{"type":"null"}]},"schema_version":{"const":"certforge.runtime.v1","type":"string"},"submit_environment":{"additionalProperties":false,"properties":{"identity_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"runner_image_digest":{"anyOf":[{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},{"type":"null"}]}},"required":["identity_digest"],"type":"object"}},"required":["journey","environment","submit_environment"],"type":"object"}'::jsonb), ('echo.certforge.service_status', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"}},"required":["command"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), ('echo.certforge.status', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"run_id":{"type":"string"}},"required":["command","run_id"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), ('echo.certforge.submit', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"environment":{"additionalProperties":false,"properties":{"identity_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"runner_image_digest":{"anyOf":[{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},{"type":"null"}]}},"required":["identity_digest"],"type":"object"},"idempotency_key":{"maxLength":128,"minLength":8,"type":"string"},"journey":{"anyOf":[{"items":{"type":"string"},"maxItems":32,"type":"array"},{"type":"null"}]},"policy_version":{"maxLength":128,"minLength":1,"type":"string"},"project_id":{"anyOf":[{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},{"type":"null"}]},"target":{"additionalProperties":false,"properties":{"artifact_sha256":{"anyOf":[{"pattern":"^[0-9a-f]{64}$","type":"string"},{"type":"null"}]},"identity_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"path":{"anyOf":[{"maxLength":2048,"minLength":1,"type":"string"},{"type":"null"}]},"ref":{"anyOf":[{"maxLength":256,"minLength":1,"type":"string"},{"type":"null"}]},"reference":{"maxLength":2048,"minLength":1,"type":"string"},"source_commit":{"anyOf":[{"maxLength":64,"minLength":7,"type":"string"},{"type":"null"}]},"target_type":{"pattern":"^(local|git|archive|container|package|deployment|mcp|sdk|cli)$","type":"string"},"url":{"anyOf":[{"maxLength":2048,"minLength":1,"type":"string"},{"type":"null"}]}},"required":["target_type","identity_digest","reference"],"type":"object"},"tenant_id":{"maxLength":128,"minLength":1,"type":"string"}},"required":["command","environment","idempotency_key","policy_version","target","tenant_id"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), @@ -74,8 +76,8 @@ BEGIN FROM arcanum_sdk.sdk_capabilities WHERE id LIKE 'echo.certforge.%' OR id = 'echo.certification_forge.r5.run_negative_controls'; - IF live_count <> 60 THEN - RAISE EXCEPTION 'Certification Forge SDK surface drift: live %, contract 60', live_count; + IF live_count <> 62 THEN + RAISE EXCEPTION 'Certification Forge SDK surface drift: live %, contract 62', live_count; END IF; IF EXISTS ( SELECT 1 FROM certforge_sdk_contract c diff --git a/scripts/run_p4_gate.sh b/scripts/run_p4_gate.sh index 046b33b..5953702 100644 --- a/scripts/run_p4_gate.sh +++ b/scripts/run_p4_gate.sh @@ -25,7 +25,7 @@ set -Eeuo pipefail REPO_URL="${REPO_URL:-https://github.com/ECHO-OMEGA-PRIME/echo-certification-forge}" BRANCH="${BRANCH:-feat/certforge-r5-negative-controls}" GIT_CRED_FILE="${GIT_CRED_FILE:-/home/forge/.config/echo/omega_git_creds}" -BASE_DIGEST="${BASE_DIGEST:-sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df}" +BASE_DIGEST="${BASE_DIGEST:-sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0}" CLAMAV_IMAGE="${CLAMAV_IMAGE:-clamav/clamav@sha256:7f5389ccaa2368c383fa80e167ccfe44348d71e685f926fce4755eed1757673a}" COSIGN="${COSIGN:-/home/forge/.cache/echo-certforge/p4-9c07eb7/tools/cosign/cosign}" TRIVY="${TRIVY:-/home/forge/.cache/echo-certforge/p4-9c07eb7/tools/trivy/trivy}" diff --git a/scripts/seal_journey_image.py b/scripts/seal_journey_image.py new file mode 100644 index 0000000..2fcb7c6 --- /dev/null +++ b/scripts/seal_journey_image.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Admitted FORGE control-plane owner only: seal one reviewed image with its existing P4 key. + +No key generation, bootstrap, policy installation, service restart or runner access. +Run only after independent exact-request review and owner admission. The supplied +digest binds that review; it is not a replacement for authenticated authorization. +""" +from __future__ import annotations + +import argparse +import json +import os +import stat +from datetime import timedelta +from pathlib import Path + +from prepare_journey_image_admission import plain_path, read_bounded, write_new +from seal_p4_images import load_private_key + +from echo_certification_forge.journey_image_admission import ( + JourneyAdmissionRequest, proposed_policy, seal_admission_request, verify_current_authority, +) +from echo_certification_forge.supply_chain import ( + ImageAdmissionDenied, ImageAdmissionPolicy, ImageAttestation, ImageAttestationAuthority, +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--request',type=Path,required=True) + parser.add_argument('--approved-request-sha256',required=True) + parser.add_argument('--attestation-private-key',type=Path,required=True) + parser.add_argument('--current-policy',type=Path,required=True) + parser.add_argument('--current-attestation',type=Path,required=True) + parser.add_argument('--valid-days',type=int,default=7) + parser.add_argument('--output-dir',type=Path,required=True) + args = parser.parse_args() + try: + request = JourneyAdmissionRequest.model_validate_json(read_bounded(args.request)) + if request.digest != args.approved_request_sha256: + raise ImageAdmissionDenied('reviewed_request_mismatch') + proposed_policy(request) + verify_current_authority(request, + ImageAdmissionPolicy.model_validate_json(read_bounded(args.current_policy)), + ImageAttestation.model_validate_json(read_bounded(args.current_attestation))) + # The existing P4 custody is POSIX, owner-only. No mode changes or copies. + if os.name != 'posix': + raise ImageAdmissionDenied('control_plane_platform_required') + path = plain_path(args.attestation_private_key) + info = path.stat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > 8192): + raise ImageAdmissionDenied('existing_key_custody_invalid') + plain_path(args.output_dir.parent) + if args.output_dir.exists(): + raise ImageAdmissionDenied('new_output_required') + # Reuse the existing P4 loader only inside the admitted control plane. + # The key object is never serialized, printed, passed to a child or worker. + authority = ImageAttestationAuthority(load_private_key(path, expected_stat=info)) + # Re-read the actual owner files immediately before the signing guard. + # Owner serialization must span these reads, signing and output creation. + current_policy = ImageAdmissionPolicy.model_validate_json(read_bounded(args.current_policy)) + current_attestation = ImageAttestation.model_validate_json(read_bounded(args.current_attestation)) + attestation, policy = seal_admission_request(request,authority=authority, + current_policy=current_policy,current_attestation=current_attestation, + approved_request_sha256=args.approved_request_sha256, + valid_for=timedelta(days=args.valid_days)) + args.output_dir.mkdir(exist_ok=False) + attestation_hash = write_new(args.output_dir/'runner.attestation.json',attestation.model_dump(mode='json')) + policy_hash = write_new(args.output_dir/'runner.admission-policy.json',policy.model_dump(mode='json')) + receipt = {'state':'SEALED_NOT_INSTALLED','request_digest':request.digest, + 'image_digest':request.identity.image_digest,'key_id':attestation.key_id, + 'attestation_sha256':attestation_hash,'policy_sha256':policy_hash, + 'policy_id':policy.policy_id,'expires_at':attestation.expires_at.isoformat(), + 'live_changes':False,'source_certified':False,'production_ready':False} + write_new(args.output_dir/'sealing-receipt.json',receipt) + print(json.dumps(receipt,sort_keys=True)) + return 0 + except (OSError, ValueError, TypeError, RuntimeError): + print(json.dumps({'state':'NOT_READY','error':'sealing_failed', + 'live_changes':False,'production_ready':False})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/seal_p4_images.py b/scripts/seal_p4_images.py index 0698736..d422497 100644 --- a/scripts/seal_p4_images.py +++ b/scripts/seal_p4_images.py @@ -7,6 +7,7 @@ import json import os import platform +import stat import subprocess import tarfile import tempfile @@ -359,8 +360,26 @@ def trivy_scan(trivy: Path, reference: str, raw_output: Path, summary_output: Pa return summary -def load_private_key(path: Path) -> Ed25519PrivateKey: - key = serialization.load_pem_private_key(path.read_bytes(), password=None) +def load_private_key(path: Path, *, expected_stat: os.stat_result | None = None) -> Ed25519PrivateKey: + if expected_stat is None: + # Preserve the established P4 caller contract. Journey admission supplies + # already checked owner-only metadata and uses the strict descriptor path. + raw = path.read_bytes() + else: + fields = ("st_dev", "st_ino", "st_mode", "st_uid", "st_gid", "st_size", "st_mtime_ns", "st_ctime_ns") + expected = tuple(getattr(expected_stat, field) for field in fields) + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(descriptor, "rb") as handle: + opened = os.fstat(handle.fileno()) + require(tuple(getattr(opened, field) for field in fields) == expected, + "attestation key custody changed before read") + require(stat.S_ISREG(opened.st_mode) and opened.st_size <= 8192, + "attestation key type or size invalid") + raw = handle.read(8193) + after = os.fstat(handle.fileno()) + require(len(raw) <= 8192 and tuple(getattr(after, field) for field in fields) == expected, + "attestation key custody changed during read") + key = serialization.load_pem_private_key(raw, password=None) require(isinstance(key, Ed25519PrivateKey), "P4 attestation key must be Ed25519") return key diff --git a/src/echo_certification_forge/__init__.py b/src/echo_certification_forge/__init__.py index c8c894b..ddc7799 100644 --- a/src/echo_certification_forge/__init__.py +++ b/src/echo_certification_forge/__init__.py @@ -3,4 +3,4 @@ from .models import ReleaseVerdict, RunOutcome, RunState __all__ = ["ReleaseVerdict", "RunOutcome", "RunState"] -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/src/echo_certification_forge/acquisition.py b/src/echo_certification_forge/acquisition.py index c11ef29..9460694 100644 --- a/src/echo_certification_forge/acquisition.py +++ b/src/echo_certification_forge/acquisition.py @@ -13,17 +13,25 @@ """ from __future__ import annotations +import base64 import io import json +import math +import os import re import shutil import subprocess import tarfile +import time import urllib.error +import urllib.parse import urllib.request from dataclasses import dataclass from pathlib import Path, PurePosixPath +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + from .canonical import sha256_bytes from .models import is_exact_git_commit @@ -32,6 +40,176 @@ class AcquisitionError(RuntimeError): """Fail-closed acquisition failure.""" +_GIT_ACQUISITION_TIMEOUT_ENV = "ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS" +_DEFAULT_GIT_ACQUISITION_TIMEOUT_SECONDS = 300.0 +_MIN_GIT_ACQUISITION_TIMEOUT_SECONDS = 30.0 +_MAX_GIT_ACQUISITION_TIMEOUT_SECONDS = 1800.0 +_GITHUB_APP_ID_ENV = "ECHO_CERTFORGE_GITHUB_APP_ID" +_GITHUB_PRIVATE_KEY_FILE_ENV = "ECHO_CERTFORGE_GITHUB_PRIVATE_KEY_FILE" +_GITHUB_API = "https://api.github.com" +_GITHUB_REPOSITORY_URL = re.compile( + r"^https://github\.com/(?P[A-Za-z0-9_.-]+)/" + r"(?P[A-Za-z0-9_.-]+?)(?:\.git)?$" +) +_GITHUB_RESPONSE_LIMIT = 1024 * 1024 + + +def configured_git_acquisition_timeout_seconds() -> float: + """Return the bounded operator-controlled Git acquisition timeout.""" + + raw = os.getenv( + _GIT_ACQUISITION_TIMEOUT_ENV, + str(_DEFAULT_GIT_ACQUISITION_TIMEOUT_SECONDS), + ).strip() + try: + value = float(raw) + except ValueError as exc: + raise AcquisitionError("git acquisition timeout configuration is invalid") from exc + if ( + not math.isfinite(value) + or value < _MIN_GIT_ACQUISITION_TIMEOUT_SECONDS + or value > _MAX_GIT_ACQUISITION_TIMEOUT_SECONDS + ): + raise AcquisitionError("git acquisition timeout configuration is out of bounds") + return value + + +def _base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _github_app_jwt(app_id: int, private_key: bytes) -> str: + now = int(time.time()) + header = _base64url(json.dumps({"alg": "RS256", "typ": "JWT"}, separators=(",", ":")).encode()) + payload = _base64url( + json.dumps( + {"iat": now - 30, "exp": now + 540, "iss": str(app_id)}, + separators=(",", ":"), + ).encode() + ) + signing_input = f"{header}.{payload}".encode("ascii") + try: + loaded = serialization.load_pem_private_key(private_key, password=None) + except (TypeError, ValueError) as exc: + raise AcquisitionError("GitHub App private key is invalid") from exc + if not isinstance(loaded, rsa.RSAPrivateKey): + raise AcquisitionError("GitHub App private key must be RSA") + signature = loaded.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + return f"{header}.{payload}.{_base64url(signature)}" + + +def _github_json( + method: str, + path: str, + token: str, + *, + body: dict | None = None, +) -> dict: + encoded = None if body is None else json.dumps(body, separators=(",", ":")).encode() + request = urllib.request.Request( + f"{_GITHUB_API}{path}", + data=encoded, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "echo-certification-forge/1.2", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + raw = response.read(_GITHUB_RESPONSE_LIMIT + 1) + except urllib.error.HTTPError as exc: + raise AcquisitionError( + f"GitHub App credential request failed: {method} HTTP {exc.code}" + ) from None + except (OSError, TimeoutError, urllib.error.URLError) as exc: + raise AcquisitionError( + f"GitHub App credential request failed: {method} {type(exc).__name__}" + ) from None + if len(raw) > _GITHUB_RESPONSE_LIMIT: + raise AcquisitionError("GitHub App credential response exceeded size limit") + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): + raise AcquisitionError("GitHub App credential response was not JSON") from None + if not isinstance(document, dict): + raise AcquisitionError("GitHub App credential response was not an object") + return document + + +def _github_app_installation_token(url: str) -> str | None: + """Resolve a short-lived, repository-scoped token for a canonical GitHub URL. + + The token is used only through ``GIT_ASKPASS`` and is never placed in the URL, + command line, target specification, evidence, or exception text. + """ + + match = _GITHUB_REPOSITORY_URL.fullmatch(url) + if match is None: + return None + raw_app_id = os.getenv(_GITHUB_APP_ID_ENV, "").strip() + raw_key_file = os.getenv(_GITHUB_PRIVATE_KEY_FILE_ENV, "").strip() + if not raw_app_id and not raw_key_file: + return None + if not raw_app_id or not raw_key_file: + raise AcquisitionError("GitHub App acquisition credential configuration is incomplete") + try: + app_id = int(raw_app_id) + except ValueError as exc: + raise AcquisitionError("GitHub App id is invalid") from exc + if app_id <= 0: + raise AcquisitionError("GitHub App id is invalid") + key_file = Path(raw_key_file).resolve(strict=False) + try: + private_key = key_file.read_bytes() + except OSError as exc: + raise AcquisitionError("GitHub App private key file is unavailable") from exc + app_jwt = _github_app_jwt(app_id, private_key) + owner = match.group("owner") + repository = match.group("repository") + encoded_owner = urllib.parse.quote(owner, safe="") + encoded_repository = urllib.parse.quote(repository, safe="") + installation = _github_json( + "GET", + f"/repos/{encoded_owner}/{encoded_repository}/installation", + app_jwt, + ) + installation_id = installation.get("id") + if not isinstance(installation_id, int) or installation_id <= 0: + raise AcquisitionError("GitHub App repository installation is unavailable") + credential = _github_json( + "POST", + f"/app/installations/{installation_id}/access_tokens", + app_jwt, + body={ + "repositories": [repository], + "permissions": {"contents": "read"}, + }, + ) + token = credential.get("token") + if not isinstance(token, str) or not token: + raise AcquisitionError("GitHub App installation token was not returned") + return token + + +def _write_git_askpass(parent: Path) -> Path: + path = parent / ".certforge-git-askpass" + path.write_text( + "#!/bin/sh\n" + "case \"$1\" in\n" + " *Username*) printf '%s\\n' 'x-access-token' ;;\n" + " *Password*) printf '%s\\n' \"$ECHO_CERTFORGE_GIT_TOKEN\" ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + path.chmod(0o700) + return path + + @dataclass(frozen=True, slots=True) class AcquiredTarget: source_root: Path @@ -348,7 +526,12 @@ def _acquire_oci(spec: dict, dest: Path, timeout_s: float) -> AcquiredTarget: return AcquiredTarget(rootfs, "oci", canonical, raw_digest.split(":", 1)[1]) -def acquire_target(spec: dict, dest: Path, *, clone_timeout_s: float = 120.0) -> AcquiredTarget: +def acquire_target( + spec: dict, + dest: Path, + *, + clone_timeout_s: float | None = None, +) -> AcquiredTarget: """Acquire a target into (or referencing) an isolated tree and compute its immutable identity. spec: @@ -369,6 +552,8 @@ def acquire_target(spec: dict, dest: Path, *, clone_timeout_s: float = 120.0) -> return AcquiredTarget(root, "local", root.as_posix(), _tree_digest(root)) if target_type == "git": + if clone_timeout_s is None: + clone_timeout_s = configured_git_acquisition_timeout_seconds() url = str(spec.get("url", "")).strip() if not url: raise AcquisitionError("git target requires 'url'") @@ -377,6 +562,18 @@ def acquire_target(spec: dict, dest: Path, *, clone_timeout_s: float = 120.0) -> if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) + installation_token = _github_app_installation_token(url) + askpass_path: Path | None = None + git_environment = os.environ.copy() + if installation_token is not None: + askpass_path = _write_git_askpass(dest.parent) + git_environment.update( + { + "ECHO_CERTFORGE_GIT_TOKEN": installation_token, + "GIT_ASKPASS": str(askpass_path), + "GIT_TERMINAL_PROMPT": "0", + } + ) git_base = [ "git", "-c", "core.hooksPath=/dev/null", # never run target-supplied hooks @@ -389,40 +586,50 @@ def run_git(arguments: list[str], operation: str) -> subprocess.CompletedProcess process = subprocess.run( [*git_base, *arguments], capture_output=True, text=True, timeout=clone_timeout_s, shell=False, check=False, + env=git_environment, ) except (OSError, subprocess.TimeoutExpired) as exc: raise AcquisitionError(f"git {operation} failed: {type(exc).__name__}") from exc if process.returncode != 0: detail = process.stderr.strip()[-300:] + if installation_token is not None: + detail = detail.replace(installation_token, "[REDACTED]") raise AcquisitionError(f"git {operation} exited {process.returncode}: {detail}") return process actual_ref: str | None = None - if ref and is_exact_git_commit(ref): - # `git clone --branch ` does not reliably accept raw commit IDs. Fetch the - # immutable object explicitly, detach at FETCH_HEAD, and then prove HEAD is exact. - run_git(["init", "--", str(dest)], "init") - run_git(["-C", str(dest), "remote", "add", "origin", url], "remote-add") - run_git( - ["-C", str(dest), "fetch", "--depth", "1", "--no-tags", "origin", ref], - "fetch-exact-commit", - ) - run_git(["-C", str(dest), "checkout", "--detach", "FETCH_HEAD"], "checkout") - actual_ref = run_git(["-C", str(dest), "rev-parse", "HEAD"], "rev-parse").stdout.strip() - if actual_ref.lower() != ref.lower(): - shutil.rmtree(dest, ignore_errors=True) - raise AcquisitionError( - f"git revision mismatch: requested {ref.lower()}, acquired {actual_ref.lower()}" + try: + if ref and is_exact_git_commit(ref): + # `git clone --branch ` does not reliably accept raw commit IDs. Fetch the + # immutable object explicitly, detach at FETCH_HEAD, and then prove HEAD is exact. + run_git(["init", "--", str(dest)], "init") + run_git(["-C", str(dest), "remote", "add", "origin", url], "remote-add") + run_git( + ["-C", str(dest), "fetch", "--depth", "1", "--no-tags", "origin", ref], + "fetch-exact-commit", ) - else: - clone_args = ["clone", "--depth", "1", "--no-tags", "--single-branch"] - if ref: - clone_args += ["--branch", ref] - clone_args += ["--", url, str(dest)] - run_git(clone_args, "clone") - actual_ref = run_git( - ["-C", str(dest), "rev-parse", "HEAD"], "rev-parse" - ).stdout.strip() + run_git(["-C", str(dest), "checkout", "--detach", "FETCH_HEAD"], "checkout") + actual_ref = run_git( + ["-C", str(dest), "rev-parse", "HEAD"], "rev-parse" + ).stdout.strip() + if actual_ref.lower() != ref.lower(): + shutil.rmtree(dest, ignore_errors=True) + raise AcquisitionError( + f"git revision mismatch: requested {ref.lower()}, acquired {actual_ref.lower()}" + ) + else: + clone_args = ["clone", "--depth", "1", "--no-tags", "--single-branch"] + if ref: + clone_args += ["--branch", ref] + clone_args += ["--", url, str(dest)] + run_git(clone_args, "clone") + actual_ref = run_git( + ["-C", str(dest), "rev-parse", "HEAD"], "rev-parse" + ).stdout.strip() + finally: + git_environment.pop("ECHO_CERTFORGE_GIT_TOKEN", None) + if askpass_path is not None: + askpass_path.unlink(missing_ok=True) # remove the .git dir so no repo metadata/hooks enter the scanned build context git_dir = dest / ".git" if git_dir.exists(): diff --git a/src/echo_certification_forge/app.py b/src/echo_certification_forge/app.py index 3f3f7dc..a5dc8c2 100644 --- a/src/echo_certification_forge/app.py +++ b/src/echo_certification_forge/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import json from pathlib import Path from .evidence import EvidenceStore @@ -10,6 +11,7 @@ from .runner import TrustedTransportRegistry from .adapters import adapter_set_digest from .run_worker import _worker_environment, load_adapter_execution_profile +from .runtime_identity import RuntimeProfile, load_runtime_profile from .service import ServiceContext, create_app from .signing import TrustedPublicKeyRegistry from .subscriber import SubscriberGovernance, SubscriberPolicy @@ -62,9 +64,12 @@ Path(_product_readiness_report_value) if _product_readiness_report_value else None ) _source_commit = os.environ.get("ECHO_CERTFORGE_SOURCE_COMMIT") +_runtime_profile = ( + load_runtime_profile() if os.environ.get("ECHO_CERTFORGE_ADAPTER_MODE", "pending") == "required" else None +) -def _load_certification_environment() -> dict[str, str] | None: +def _load_certification_environment(runtime: RuntimeProfile | None = None) -> dict[str, str] | None: adapter_mode = os.environ.get("ECHO_CERTFORGE_ADAPTER_MODE", "pending") if adapter_mode != "required": return None @@ -78,16 +83,24 @@ def _load_certification_environment() -> dict[str, str] | None: raise RuntimeError( "required adapter mode lacks public profile inputs: " + ", ".join(missing) ) - records, _policy, _response, profile_sha256 = load_adapter_execution_profile( + records, _adapter_policy, _response, profile_sha256 = load_adapter_execution_profile( **{name: Path(value) for name, value in required.items() if value is not None} ) adapter_sha256 = adapter_set_digest(records) - environment = _worker_environment(adapter_sha256, profile_sha256) + runtime = runtime or load_runtime_profile() + environment = _worker_environment( + adapter_sha256, profile_sha256, manifest=RuleManifest.load(_policy), + sandbox=runtime.sandbox, + journey=list(runtime.journey) if runtime.journey else None, + ) return { "certification_environment_identity_digest": environment.identity_digest, "runner_image_digest": "sha256:" + environment.runner_image_sha256, "adapter_set_sha256": adapter_sha256, "adapter_execution_profile_sha256": profile_sha256, + "runtime_profile_schema": "certforge.runtime.v1", + "runtime_journey_json": json.dumps(runtime.journey), + "runtime_journey_mode": "operator_pinned" if runtime.journey is not None else "per_job", } @@ -109,7 +122,8 @@ def _load_transport_registry(directory: Path) -> TrustedTransportRegistry: deployment_credentials=WebhookSecretRegistry.from_file(_deployment_keys), transport_registry=_load_transport_registry(_transport_keys), adapter_mode=os.environ.get("ECHO_CERTFORGE_ADAPTER_MODE", "pending"), - certification_environment=_load_certification_environment(), + certification_environment=_load_certification_environment(_runtime_profile), + runtime_profile=_runtime_profile, product_readiness_report_path=_product_readiness_report, adapter_acceptance_report_path=_adapter_report, source_commit=_source_commit, diff --git a/src/echo_certification_forge/dispatch_worker.py b/src/echo_certification_forge/dispatch_worker.py index 2bc2b88..c748b31 100644 --- a/src/echo_certification_forge/dispatch_worker.py +++ b/src/echo_certification_forge/dispatch_worker.py @@ -3,16 +3,21 @@ import argparse import json +import logging import os import time from pathlib import Path from typing import Any, Callable +from pydantic import ValidationError + from .evidence import EvidenceStore +from .canonical import sha256_bytes from .intake import SubmitRequest from .policy import RuleManifest +from .production_e2e import DirectoryProductionE2EProvider from .run_worker import _load_adapter_inputs, _load_signer, run -from .sandbox import DEFAULT_IMAGE, DockerSandbox +from .runtime_identity import load_runtime_profile from .subscriber import SubscriberDispatch, SubscriberError, SubscriberGovernance, SubscriberPolicy _REPO = Path(__file__).resolve().parents[2] @@ -24,22 +29,36 @@ def recover_pending_intake( manifest: RuleManifest, ) -> int: recovered = 0 - for pending in governance.pending_intake_requests(): - request = SubmitRequest.model_validate(pending.request) - governance.materialize_reserved_run( - pending.reservation, - run_id=request.run_id(manifest.digest), - target_type=request.target.target_type, - target_reference=request.target.reference, - target_identity_digest=request.target.identity_digest, - environment_identity_digest=request.environment.identity_digest, - environment_json=request.environment.model_dump(exclude_none=True), - manifest_id=manifest.manifest_id, - manifest_digest=manifest.digest, - target_spec=pending.target_spec, - journey=pending.journey, - ) - recovered += 1 + for pending in governance.pending_intake_requests(policy_version=manifest.manifest_id): + try: + request = SubmitRequest.model_validate(pending.request) + governance.materialize_reserved_run( + pending.reservation, + run_id=request.run_id(manifest.digest), + target_type=request.target.target_type, + target_reference=request.target.reference, + target_identity_digest=request.target.identity_digest, + environment_identity_digest=request.environment.identity_digest, + environment_json=request.environment.model_dump(exclude_none=True), + manifest_id=manifest.manifest_id, + manifest_digest=manifest.digest, + target_spec=pending.target_spec, + journey=pending.journey, + ) + recovered += 1 + except (SubscriberError, ValidationError) as exc: + code = exc.code if isinstance(exc, SubscriberError) else "pending_intake_invalid" + # Service/storage failures remain visible. Request-local permanent + # refusals cannot wedge dispatch for every other tenant. + if isinstance(exc, SubscriberError) and exc.status_code >= 500: + raise + governance.release_reservation( + pending.reservation, reason=code, compensate_meter=True, only_reserved=True, + ) + logging.getLogger(__name__).warning(json.dumps({ + "event": "pending_intake_rejected", "reason": code, + "reservation_ref": sha256_bytes(pending.reservation.idempotency_key.encode())[:32], + })) return recovered @@ -117,6 +136,19 @@ def dispatch_once( ) raise error = str(result.get("error")) if result.get("error") else None + if error in {"runtime_operator_profile_mismatch", "runtime_identity_unavailable"}: + try: + governance.defer_dispatch_runtime_failure(dispatch, error=error) + except SubscriberError as exc: + # Cancellation, lease fencing or post-claim failure may already have + # consumed this claim. Preserve that state and still stop cleanly. + if exc.code != "dispatch_claim_inactive": + raise + logging.getLogger(__name__).error(json.dumps({ + "event": "dispatcher_runtime_circuit_open", "reason": error, + "run_ref": sha256_bytes(dispatch.run_id.encode())[:32], + })) + return dispatch, {**result, "dispatcher_halted": True, "retry_budget_consumed": False} try: governance.finish_dispatch(dispatch, error=error) except SubscriberError as exc: @@ -182,10 +214,11 @@ def main(argv: list[str] | None = None) -> int: ), ) parser.add_argument("--sandbox", action="store_true") + parser.add_argument("--runtime-profile", type=Path) parser.add_argument("--non-production-compat", action="store_true") parser.add_argument( "--sandbox-image", - default=os.environ.get("ECHO_CERTFORGE_SANDBOX_IMAGE", DEFAULT_IMAGE), + default=None, ) parser.add_argument( "--sandbox-docker", @@ -241,6 +274,24 @@ def main(argv: list[str] | None = None) -> int: choices=("platform", "customer"), default=os.environ.get("ECHO_CERTFORGE_SIGNING_AUTHORITY", "platform"), ) + parser.add_argument( + "--production-e2e-attestation-dir", + type=Path, + default=( + Path(os.environ["ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR"]) + if os.environ.get("ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR") + else None + ), + ) + parser.add_argument( + "--trusted-production-e2e-keys", + type=Path, + default=( + Path(os.environ["ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS"]) + if os.environ.get("ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS") + else None + ), + ) args = parser.parse_args(argv) if args.poll_seconds <= 0: parser.error("--poll-seconds must be positive") @@ -270,25 +321,46 @@ def main(argv: list[str] | None = None) -> int: return 2 store = EvidenceStore(args.db, args.evidence_root) signer = _load_signer(args.signing_key) - sandbox = ( - DockerSandbox( - image=args.sandbox_image, + production_e2e_paths = ( + args.production_e2e_attestation_dir, + args.trusted_production_e2e_keys, + ) + if sum(path is not None for path in production_e2e_paths) not in (0, 2): + print(json.dumps({"error": "production_e2e_trust_incomplete"})) + return 2 + production_e2e_provider = None + if all(path is not None for path in production_e2e_paths): + try: + production_e2e_provider = DirectoryProductionE2EProvider( + args.production_e2e_attestation_dir, + args.trusted_production_e2e_keys, + ) + except ValueError: + print(json.dumps({"error": "production_e2e_trust_unavailable"})) + return 2 + try: + runtime = load_runtime_profile( + args.runtime_profile, image=args.sandbox_image, docker=tuple(args.sandbox_docker.split()), ) - if args.sandbox - else None - ) + except ValueError as exc: + print(json.dumps({"error": "runtime_profile_invalid", "detail": str(exc)})) + return 2 + sandbox = runtime.sandbox if args.sandbox else None options = { "store": store, "manifest": manifest, "signer": signer, "subscribers": governance, "sandbox": sandbox, + "runtime_profile": runtime if not args.non_production_compat else None, "worker_id": args.worker_id, "worker_attestation_sha256": args.worker_attestation_sha256, "execution_location": args.execution_location, "signing_authority": args.signing_authority, } + if production_e2e_provider is not None: + options["production_e2e_provider"] = production_e2e_provider if supplied_count == len(adapter_paths): options["adapter_inputs"] = { "response_path": args.adapter_response, @@ -307,6 +379,8 @@ def main(argv: list[str] | None = None) -> int: ) if dispatch is not None: print(json.dumps(result, sort_keys=True)) + if result is not None and result.get("dispatcher_halted") is True: + return 2 if args.once: return 0 if dispatch is not None else 3 if dispatch is None: diff --git a/src/echo_certification_forge/executor.py b/src/echo_certification_forge/executor.py index 4105c7e..e909dc3 100644 --- a/src/echo_certification_forge/executor.py +++ b/src/echo_certification_forge/executor.py @@ -29,8 +29,11 @@ from .canonical import to_utc_iso, utc_now from .evidence import EvidenceStore from .hostile import scan_target_source -from .models import RedactionStatus, RunOutcome, RuleResult, RunState +from .models import EnvironmentIdentity, RedactionStatus, RuleResult, RunOutcome, RunState, TargetIdentity from .policy import RuleManifest +from .production_e2e import RULE_ID as PRODUCTION_E2E_RULE_ID +from .production_e2e import VerifiedProductionE2E +from .production_e2e import validate_production_e2e from .signing import Ed25519VerdictSigner from .supply_chain import scan_dockerfile from .verdict import DeterministicVerdictEngine @@ -193,6 +196,7 @@ def execute( control_attestations: dict[str, bool] | None = None, adapter_records: tuple[AdapterExecutionRecord, ...] | None = None, adapter_policy: AdapterAcceptancePolicy | None = None, + production_e2e_attestation: VerifiedProductionE2E | None = None, execution_guard: Callable[[], None] | None = None, completion_callback: Callable[[Any], None] | None = None, ) -> ExecutionResult: @@ -393,6 +397,32 @@ def bound(digest_key: str) -> bool: # Includes adapter records/assessment when supplied. evidence_chain_ok = self.store.verify_evidence(run_id, tenant_id).valid + production_e2e_passed = False + production_e2e_detail: dict[str, Any] = { + "validation": "production_e2e_attestation_missing" + } + try: + target_identity = TargetIdentity(**json.loads(run["target_identity_json"])) + environment_identity = EnvironmentIdentity(**json.loads(run["environment_identity_json"])) + production_e2e_payload = ( + production_e2e_attestation.payload + if isinstance(production_e2e_attestation, VerifiedProductionE2E) + else None + ) + production_e2e_passed, validation = validate_production_e2e( + production_e2e_payload, + target_identity, + environment_identity, + now=utc_now(), + ) + production_e2e_detail = ( + production_e2e_attestation.result_details() + if production_e2e_passed and production_e2e_attestation is not None + else {"validation": validation} + ) + except (TypeError, ValueError): + production_e2e_detail = {"validation": "production_e2e_identity_invalid"} + checks: dict[str, tuple[bool, dict]] = { "immutable_target_identity": ( bound("target_identity_digest"), @@ -429,6 +459,7 @@ def bound(digest_key: str) -> bool: bool(control_attestations.get("signing_authority_separation", False)), {"check": "trusted_caller_attestation"}, ), + PRODUCTION_E2E_RULE_ID: (production_e2e_passed, production_e2e_detail), } for rule in self.manifest.rules: if execution_guard is not None: @@ -444,7 +475,12 @@ def bound(digest_key: str) -> bool: self.store.record_rule_result( run_id, tenant_id, - RuleResult(rule.id, passed, (artifact_id,), {"source": "executor"}), + RuleResult( + rule.id, + passed, + (artifact_id,), + detail if rule.id == PRODUCTION_E2E_RULE_ID else {"source": "executor"}, + ), ) self._t(run_id, tenant_id, RunState.CLASSIFYING_FINDINGS, "classify") diff --git a/src/echo_certification_forge/intake.py b/src/echo_certification_forge/intake.py index 2842cf5..8fe7296 100644 --- a/src/echo_certification_forge/intake.py +++ b/src/echo_certification_forge/intake.py @@ -10,12 +10,21 @@ import sqlite3 from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator -from .canonical import sha256_json +from .canonical import sha256_json, utc_now from .evidence import EvidenceStore -from .models import ReleaseVerdict, RunState, declared_target_identity_digest +from .models import ( + EnvironmentIdentity, + ReleaseVerdict, + RunState, + TargetIdentity, + declared_target_identity_digest, +) +from .production_e2e import RULE_ID as PRODUCTION_E2E_RULE_ID +from .production_e2e import validate_production_e2e from .policy import RuleManifest +from .runtime_identity import _journey # Internal fine-grained RunState -> coarse public state (contracts/schemas/certification-run.v1.json). _PUBLIC_STATE: dict[RunState, str] = { @@ -55,7 +64,14 @@ class SubmitTarget(BaseModel): def worker_spec(self) -> dict[str, str]: if self.target_type == "local": - return {"type": "local", "path": self.path or self.reference} + spec = {"type": "local", "path": self.path or self.reference} + # Atomic subscriber materialization persists these commitments from + # the worker spec. Dropping them skips exact identity reconciliation. + if self.artifact_sha256 is not None: + spec["artifact_sha256"] = self.artifact_sha256 + if self.source_commit is not None: + spec["source_commit"] = self.source_commit + return spec if self.target_type == "git": if self.url is not None: spec = {"type": "git", "url": self.url} @@ -113,6 +129,12 @@ class SubmitRequest(BaseModel): idempotency_key: str = Field(min_length=8, max_length=128) journey: list[str] | None = Field(default=None, max_length=32) + @field_validator("journey") + @classmethod + def validate_journey(cls, value: list[str] | None) -> list[str] | None: + _journey(value) + return value + def request_digest(self) -> str: """Semantic identity of the request (excludes the idempotency key itself).""" return sha256_json( @@ -152,6 +174,59 @@ def project_run(store: EvidenceStore, row: dict[str, Any]) -> dict[str, Any]: payload = json.loads(verdict_row["payload_json"]) release_verdict = payload.get("release_verdict", release_verdict) evidence_merkle_root = payload.get("evidence_merkle_root") + production_e2e = { + "verified": False, + "profile": None, + "attestation_id": None, + "source_commit": None, + "deployment_sha": None, + "target_identity_digest": None, + "environment_identity_digest": None, + "canonical_target": None, + "tool_count": None, + "signature_verified": False, + "collector_key_id": None, + "attestation_envelope_sha256": None, + } + result = store.list_rule_results(run_id, tenant_id).get(PRODUCTION_E2E_RULE_ID) + if result is not None: + try: + target = TargetIdentity(**json.loads(row["target_identity_json"])) + environment = EnvironmentIdentity(**json.loads(row["environment_identity_json"])) + verified, _reason = validate_production_e2e( + result.details, + target, + environment, + now=utc_now(), + ) + production_e2e = { + "verified": verified, + "profile": result.details.get("profile") if verified else None, + "attestation_id": result.details.get("attestation_id") if verified else None, + "source_commit": result.details.get("source_commit") if verified else None, + "deployment_sha": result.details.get("deployment_sha") if verified else None, + "target_identity_digest": ( + result.details.get("target_identity_digest") if verified else None + ), + "environment_identity_digest": ( + result.details.get("environment_identity_digest") if verified else None + ), + "canonical_target": ( + result.details.get("canonical_target") if verified else None + ), + "tool_count": result.details.get("tool_count") if verified else None, + "signature_verified": ( + result.details.get("signature_verified") is True if verified else False + ), + "collector_key_id": ( + result.details.get("collector_key_id") if verified else None + ), + "attestation_envelope_sha256": ( + result.details.get("attestation_envelope_sha256") if verified else None + ), + } + except (TypeError, ValueError): + pass projected = { "run_id": run_id, "tenant_id": tenant_id, @@ -162,6 +237,7 @@ def project_run(store: EvidenceStore, row: dict[str, Any]) -> dict[str, Any]: "environment_identity_digest": row["environment_identity_digest"], "policy_version": row["policy_version"] or row["rule_manifest_id"], "evidence_merkle_root": evidence_merkle_root, + "production_e2e": production_e2e, "created_at": row["created_at"], "updated_at": row["updated_at"], } diff --git a/src/echo_certification_forge/journey_image_admission.py b/src/echo_certification_forge/journey_image_admission.py new file mode 100644 index 0000000..c4b99e1 --- /dev/null +++ b/src/echo_certification_forge/journey_image_admission.py @@ -0,0 +1,230 @@ +"""Bounded journey-image sealing through an existing, independently admitted authority. + +Preparation is unsigned evidence, not authorization. This module never loads keys, +changes a live policy, executes Docker, or changes the twelve-image P4 manifest. +An admitted control-plane owner may inject its existing signing authority only +after independently reviewing the exact request digest. +""" +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime, timedelta +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .canonical import canonical_json, require_sha256, sha256_bytes +from .supply_chain import ( + ImageAdmissionDenied, + ImageAdmissionPolicy, + ImageAttestation, + ImageAttestationAuthority, + ImageIdentity, + ImageRole, + evaluate_image_admission, + scan_dockerfile, + verify_spdx_document, +) + + +class JourneyQualification(BaseModel): + """Public evidence commitments; authenticity remains an independent review gate.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + identity_digest: str + first_build_receipt_id: str = Field(pattern=r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$") + second_build_receipt_id: str = Field(pattern=r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$") + archive_sha256: str + independent_archive_sha256: str + normalized_first_sha256: str + normalized_second_sha256: str + scanner_report_sha256: str + node_core_report_sha256: str + runtime_probe_sha256: str + critical_count: int = Field(ge=0) + fixed_high_or_critical_count: int = Field(ge=0) + secret_count: int = Field(ge=0) + node_core_finding_count: int = Field(ge=0) + node_version: Literal["24.19.0", "24.22.1"] + python_version: str = Field(pattern=r"^(?:3\.11\.[0-9]+|3\.12\.16)$") + uid: int = Field(gt=0) + read_only_root: bool + network_disabled: bool + utf8: bool + observed_at: datetime + + @model_validator(mode="after") + def check_qualification(self) -> JourneyQualification: + # Preserve the original serialized contract and its legacy pair. The + # ECF runner adds one exact measured pair, not an arbitrary version range. + legacy_pair = self.node_version == "24.19.0" and self.python_version.startswith("3.11.") + ecf_pair = (self.node_version, self.python_version) == ("24.22.1", "3.12.16") + if not (legacy_pair or ecf_pair): + raise ValueError("unsupported_runtime_pair") + for name in ( + "identity_digest", "archive_sha256", "independent_archive_sha256", + "normalized_first_sha256", "normalized_second_sha256", + "scanner_report_sha256", "node_core_report_sha256", "runtime_probe_sha256", + ): + require_sha256(getattr(self, name), name) + if self.first_build_receipt_id == self.second_build_receipt_id: + raise ValueError("distinct_build_receipts_required") + if self.normalized_first_sha256 != self.normalized_second_sha256: + raise ValueError("normalized_build_mismatch") + if any((self.critical_count, self.fixed_high_or_critical_count, + self.secret_count, self.node_core_finding_count)): + raise ValueError("image_security_policy_failed") + if self.observed_at.tzinfo is None: + raise ValueError("qualification_time_requires_timezone") + if not all((self.read_only_root, self.network_disabled, self.utf8)): + raise ValueError("runtime_isolation_or_encoding_failed") + return self + + +class JourneyAdmissionRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["certforge.journey-image-admission.v1"] = "certforge.journey-image-admission.v1" + state: Literal["UNSIGNED_NOT_ADMITTED"] = "UNSIGNED_NOT_ADMITTED" + identity: ImageIdentity + qualification: JourneyQualification + prior_policy: ImageAdmissionPolicy + prior_attestation: ImageAttestation + + @model_validator(mode="after") + def check_binding(self) -> JourneyAdmissionRequest: + if self.identity.role != ImageRole.RUNNER or self.prior_policy.required_role != ImageRole.RUNNER: + raise ValueError("runner_role_required") + if self.identity.architecture != "amd64" or self.identity.operating_system != "linux": + raise ValueError("unsupported_journey_platform") + if self.qualification.identity_digest != self.identity.digest: + raise ValueError("qualification_identity_mismatch") + if self.identity.image_digest in self.prior_policy.revoked_image_digests: + raise ValueError("candidate_image_revoked") + return self + + @property + def digest(self) -> str: + return sha256_bytes(canonical_json(self.model_dump(mode="json")).encode("utf-8")) + + +def proposed_policy(request: JourneyAdmissionRequest, *, now: datetime | None = None) -> ImageAdmissionPolicy: + """Derive a separate exact-image proposal, preserving every public trust constraint.""" + current = now or datetime.now(UTC) + if current.tzinfo is None: + raise ImageAdmissionDenied("verification_time_requires_timezone") + age = current - request.qualification.observed_at + if age < timedelta(seconds=-30) or age > timedelta(hours=24): + raise ImageAdmissionDenied("qualification_stale_or_future") + if request.identity.created_at > current + timedelta(seconds=30): + raise ImageAdmissionDenied("identity_time_in_future") + admission = evaluate_image_admission(request.prior_attestation, request.prior_policy, now=current) + if not admission.allowed: + raise ImageAdmissionDenied("existing_authority_not_admitted") + identity = request.identity + # A new policy ID avoids overwriting the existing P4 runner or its other roles. + proposal = request.prior_policy.model_dump(mode="json") + proposal.update( + policy_id="journey.runner." + identity.digest[:32], + expected_image_digest=identity.image_digest, + approved_base_digests=[identity.base_image_digest], + approved_source_commits=[identity.source_commit], + approved_dockerfile_sha256=identity.dockerfile_sha256, + approved_lockfile_sha256=identity.lockfile_sha256, + ) + return ImageAdmissionPolicy.model_validate(proposal) + + +def prepare_admission_request( + *, identity: ImageIdentity, qualification: JourneyQualification, + prior_policy: ImageAdmissionPolicy, prior_attestation: ImageAttestation, + dockerfile: bytes, lockfile: bytes, sbom: bytes, provenance: bytes, + now: datetime | None = None, +) -> JourneyAdmissionRequest: + """Bind exact material bytes before producing any unsigned policy proposal.""" + qualification = JourneyQualification.model_validate_json(qualification.model_dump_json()) + for name, raw in (("dockerfile", dockerfile), ("lockfile", lockfile), + ("sbom", sbom), ("provenance", provenance)): + if len(raw) > 4 * 1024 * 1024: + raise ImageAdmissionDenied("material_size_limit") + if hashlib.sha256(raw).hexdigest() != getattr(identity, name + "_sha256"): + raise ImageAdmissionDenied(name + "_hash_mismatch") + scan = scan_dockerfile(dockerfile.decode("utf-8")) + if not scan.valid: + raise ImageAdmissionDenied("dockerfile_policy_failed") + bases = [line.split()[1].split("@", 1)[1] for line in dockerfile.decode("utf-8").splitlines() + if line.strip().upper().startswith("FROM ")] + if identity.base_image_digest != bases[-1]: + raise ImageAdmissionDenied("final_base_mismatch") + lock = json.loads(lockfile) + provenance_record = json.loads(provenance) + if not isinstance(lock, dict) or not isinstance(provenance_record, dict): + raise ImageAdmissionDenied("material_object_required") + if lock.get("source_commit") != identity.source_commit or provenance_record.get("source_commit") != identity.source_commit: + raise ImageAdmissionDenied("material_source_mismatch") + sbom_record = json.loads(sbom) + if (not isinstance(sbom_record, dict) or not isinstance(sbom_record.get("packages"), list) + or not all(isinstance(row, dict) for row in sbom_record["packages"])): + raise ImageAdmissionDenied("sbom_invalid") + valid, _ = verify_spdx_document(sbom_record, image_digest=identity.image_digest) + if not valid: + raise ImageAdmissionDenied("sbom_invalid") + packages = {(row.get("name"), row.get("versionInfo")) for row in sbom_record["packages"] + if isinstance(row.get("name"), str) and isinstance(row.get("versionInfo"), str)} + if ("nodejs", qualification.node_version) not in packages or ("CPython", qualification.python_version) not in packages: + raise ImageAdmissionDenied("runtime_packages_missing_from_sbom") + if qualification.node_version == "24.22.1": + for name, version in (("nodejs", qualification.node_version), ("CPython", qualification.python_version)): + runtime_rows = [row for row in sbom_record["packages"] if row.get("name") == name] + if len(runtime_rows) != 1 or runtime_rows[0].get("versionInfo") != version: + raise ImageAdmissionDenied("ambiguous_runtime_packages") + request = JourneyAdmissionRequest(identity=identity, qualification=qualification, + prior_policy=prior_policy, prior_attestation=prior_attestation) + proposed_policy(request, now=now) + return request + + +def seal_admission_request( + request: JourneyAdmissionRequest, *, authority: ImageAttestationAuthority, + current_policy: ImageAdmissionPolicy, current_attestation: ImageAttestation, + approved_request_sha256: str, now: datetime | None = None, + valid_for: timedelta = timedelta(days=7), +) -> tuple[ImageAttestation, ImageAdmissionPolicy]: + """Owner-controlled signing adapter, with no key loader or trust bootstrap. + + The approval digest is an integrity check, never an authorization claim. The + caller must be the admitted control-plane owner. Existing authority lifetime + bounds the result; stale evidence, changed inputs and untrusted keys fail + before the signing method is called. + """ + request = JourneyAdmissionRequest.model_validate_json(request.model_dump_json()) + require_sha256(approved_request_sha256, "approved_request_sha256") + if request.digest != approved_request_sha256: + raise ImageAdmissionDenied("reviewed_request_mismatch") + current = now or datetime.now(UTC) + verify_current_authority(request, current_policy, current_attestation, now=current) + policy = proposed_policy(request, now=current) + previous = request.prior_attestation + if authority.key_id != previous.key_id or authority.public_key_pem != previous.public_key_pem: + raise ImageAdmissionDenied("existing_authority_required") + remaining = previous.expires_at - current + if valid_for <= timedelta(0) or valid_for > timedelta(days=30) or valid_for > remaining: + raise ImageAdmissionDenied("authority_lifetime_exceeded") + attestation = authority.sign(request.identity, issued_at=current, valid_for=valid_for) + if not evaluate_image_admission(attestation, policy, now=current).allowed: + raise ImageAdmissionDenied("signed_candidate_not_admitted") + return attestation, policy + + +def verify_current_authority( + request: JourneyAdmissionRequest, policy: ImageAdmissionPolicy, + attestation: ImageAttestation, *, now: datetime | None = None, +) -> None: + """Reject policy, revocation or signature drift since the reviewed preparation.""" + if (policy.model_dump(mode="json") != request.prior_policy.model_dump(mode="json") + or attestation.model_dump(mode="json") != request.prior_attestation.model_dump(mode="json")): + raise ImageAdmissionDenied("current_authority_changed_since_review") + if not evaluate_image_admission(attestation, policy, now=now).allowed: + raise ImageAdmissionDenied("current_authority_not_admitted") diff --git a/src/echo_certification_forge/models.py b/src/echo_certification_forge/models.py index 546faa7..74602d6 100644 --- a/src/echo_certification_forge/models.py +++ b/src/echo_certification_forge/models.py @@ -182,6 +182,9 @@ class VerdictDecision: rule_manifest_id: str rule_manifest_digest: str evidence_merkle_root: str + production_e2e_attestation_id: str | None + production_e2e_profile: str | None + production_e2e_envelope_sha256: str | None signing_key_id: str issued_at: datetime expires_at: datetime @@ -199,6 +202,9 @@ def to_dict(self) -> dict[str, Any]: "rule_manifest_id": self.rule_manifest_id, "rule_manifest_digest": self.rule_manifest_digest, "evidence_merkle_root": self.evidence_merkle_root, + "production_e2e_attestation_id": self.production_e2e_attestation_id, + "production_e2e_profile": self.production_e2e_profile, + "production_e2e_envelope_sha256": self.production_e2e_envelope_sha256, "signing_key_id": self.signing_key_id, "issued_at": to_utc_iso(self.issued_at), "expires_at": to_utc_iso(self.expires_at), diff --git a/src/echo_certification_forge/production_e2e.py b/src/echo_certification_forge/production_e2e.py new file mode 100644 index 0000000..490f180 --- /dev/null +++ b/src/echo_certification_forge/production_e2e.py @@ -0,0 +1,401 @@ +"""Fail-closed validation for signed production end-to-end attestations. + +Source checks, a successful process exit, and a reachable URL are not production +evidence. This contract is evaluated by the deterministic verdict authority +after a trusted collector has signed the attestation. Target-controlled code +cannot manufacture the attestation or select its trust key. +""" +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .canonical import sha256_json +from .models import EnvironmentIdentity, SignedVerdictEnvelope, TargetIdentity +from .signing import TrustedPublicKeyRegistry + +RULE_ID = "production_e2e" +SCHEMA_VERSION = "certforge.production-e2e.v1" +GENERIC_PROFILE = "generic-production-v1" +ECHO_GITHUB_AUTONOMY_PROFILE = "echo-github-autonomy-remote-mcp-v2" +ECHO_GITHUB_AUTONOMY_REPOSITORY = "echoomegaprime/echo-github-autonomy" +ECHO_GITHUB_AUTONOMY_CANONICAL_MCP = "https://echo-ghub.grok.me/api/plugin/mcp" + +BASE_CHECKS = frozenset( + { + "runtime_or_artifact_executed", + "exact_identity_readback", + "critical_journeys_complete", + "negative_controls_pass", + "stability_verified", + "external_acceptance_verified", + } +) +ECHO_GITHUB_AUTONOMY_CHECKS = BASE_CHECKS | frozenset( + { + "canonical_mcp_health", + "oauth_discovery", + "mcp_initialize", + "tool_schema", + "repeated_tool_invocation", + "registry_persistence", + "three_account_reconciliation", + "private_public_visibility", + "read_write_certify", + "cross_client_consistency", + } +) +ECHO_GITHUB_ACCOUNTS = { + "echoomegaprime": 314902331, + "ECHO-OMEGA-PRIME": 264607697, + "bobmcwilliams4": 235318155, +} +ECHO_CLIENTS = frozenset({"chatgpt", "claude", "codex", "grok"}) + +_SHA = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_SIGNING_KEY_ID = re.compile(r"^ed25519:[0-9a-f]{32}$") +_SECRET_VALUE = re.compile( + r"(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|" + r"-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+[A-Za-z0-9._~-]{16,})" +) +_RESTRICTED_KEYS = { + "password", + "passwd", + "secret", + "token", + "private_key", + "authorization", + "cookie", + "api_key", +} +_VERIFIED_MARKER = object() + + +@dataclass(frozen=True, slots=True) +class VerifiedProductionE2E: + """An attestation whose envelope was verified by an independently pinned key registry.""" + + payload: dict[str, Any] + collector_key_id: str + envelope_sha256: str + _marker: object + + def __post_init__(self) -> None: + if self._marker is not _VERIFIED_MARKER: + raise ValueError("VerifiedProductionE2E must come from signature verification") + + def result_details(self) -> dict[str, Any]: + return { + **self.payload, + "signature_verified": True, + "collector_key_id": self.collector_key_id, + "attestation_envelope_sha256": self.envelope_sha256, + } + + +def _parse_time(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.astimezone(UTC) if parsed.tzinfo is not None else None + + +def _contains_restricted_value(value: Any, *, key_name: str = "") -> bool: + normalized = key_name.casefold().replace("-", "_") + if normalized in _RESTRICTED_KEYS: + return True + if isinstance(value, Mapping): + return any( + _contains_restricted_value(item, key_name=str(key)) for key, item in value.items() + ) + if isinstance(value, (list, tuple, set)): + return any(_contains_restricted_value(item) for item in value) + return isinstance(value, str) and bool(_SECRET_VALUE.search(value)) + + +def _source_repository(target: TargetIdentity) -> str | None: + reference = target.canonical_ref.casefold() + marker = "github.com/" + if marker not in reference: + return None + suffix = reference.split(marker, 1)[1].split("@", 1)[0] + if suffix.endswith(".git"): + suffix = suffix[:-4] + return suffix + + +def _validate_common( + payload: Mapping[str, Any], + target: TargetIdentity, + environment: EnvironmentIdentity, + *, + now: datetime, +) -> str | None: + if _contains_restricted_value(payload): + return "production_e2e_contains_restricted_value" + if payload.get("schema_version") != SCHEMA_VERSION: + return "production_e2e_schema_invalid" + if not _IDENTIFIER.fullmatch(str(payload.get("attestation_id") or "")): + return "production_e2e_attestation_id_invalid" + if payload.get("target_identity_digest") != target.identity_digest: + return "production_e2e_target_identity_mismatch" + if payload.get("environment_identity_digest") != environment.identity_digest: + return "production_e2e_environment_identity_mismatch" + if not _SIGNING_KEY_ID.fullmatch(str(payload.get("signing_key_id") or "")): + return "production_e2e_signing_key_id_invalid" + + observed_at = _parse_time(payload.get("observed_at")) + expires_at = _parse_time(payload.get("expires_at")) + current = now.astimezone(UTC) if now.tzinfo is not None else None + if current is None or observed_at is None or expires_at is None: + return "production_e2e_time_invalid" + if expires_at <= observed_at or (expires_at - observed_at).total_seconds() > 3600: + return "production_e2e_lifetime_invalid" + if observed_at > current or current >= expires_at: + return "production_e2e_attestation_not_current" + + source_commit = payload.get("source_commit") + deployment_sha = payload.get("deployment_sha") + if target.source_commit is not None: + expected = target.source_commit.casefold() + if not _SHA.fullmatch(expected): + return "production_e2e_target_source_commit_not_exact" + if source_commit != expected or deployment_sha != expected: + return "production_e2e_deployment_sha_mismatch" + elif source_commit is not None or deployment_sha is not None: + if source_commit != deployment_sha or not _SHA.fullmatch(str(source_commit)): + return "production_e2e_deployment_sha_invalid" + + required_checks = payload.get("required_checks") + checks = payload.get("checks") + if not isinstance(required_checks, list) or not isinstance(checks, Mapping): + return "production_e2e_checks_invalid" + if len(required_checks) != len(set(required_checks)): + return "production_e2e_checks_duplicated" + check_set = frozenset(required_checks) + if set(checks) != set(check_set) or any(checks.get(name) is not True for name in check_set): + return "production_e2e_checks_incomplete" + if not BASE_CHECKS <= check_set: + return "production_e2e_baseline_checks_missing" + if not isinstance(payload.get("stability_probe_count"), int) or payload["stability_probe_count"] < 3: + return "production_e2e_stability_probe_count_insufficient" + return None + + +def _validate_echo_github_autonomy(payload: Mapping[str, Any]) -> str | None: + if payload.get("canonical_target") != ECHO_GITHUB_AUTONOMY_CANONICAL_MCP: + return "production_e2e_canonical_mcp_mismatch" + if frozenset(payload.get("required_checks") or ()) != ECHO_GITHUB_AUTONOMY_CHECKS: + return "production_e2e_github_autonomy_checks_incomplete" + if payload.get("tool_count") != 30: + return "production_e2e_tool_count_mismatch" + + repositories = payload.get("sample_private_repositories") + if not isinstance(repositories, Mapping) or set(repositories) != set(ECHO_GITHUB_ACCOUNTS): + return "production_e2e_sample_repositories_incomplete" + sample_digests: dict[str, str] = {} + for login, sample in repositories.items(): + if not isinstance(sample, Mapping): + return "production_e2e_sample_repository_invalid" + if ( + not isinstance(sample.get("repository_id"), int) + or isinstance(sample["repository_id"], bool) + or sample["repository_id"] <= 0 + ): + return "production_e2e_sample_repository_id_invalid" + if not str(sample.get("node_id") or "").strip(): + return "production_e2e_sample_node_id_invalid" + if not str(sample.get("default_branch") or "").strip(): + return "production_e2e_sample_default_branch_invalid" + if not _SHA.fullmatch(str(sample.get("head_sha") or "")): + return "production_e2e_sample_head_sha_invalid" + digest = str(sample.get("fingerprint_sha256") or "") + if not _SHA256.fullmatch(digest): + return "production_e2e_sample_fingerprint_invalid" + sample_digests[login] = digest + + accounts = payload.get("accounts") + if not isinstance(accounts, Mapping) or set(accounts) != set(ECHO_GITHUB_ACCOUNTS): + return "production_e2e_accounts_incomplete" + for login, account_id in ECHO_GITHUB_ACCOUNTS.items(): + account = accounts.get(login) + if not isinstance(account, Mapping) or account.get("account_id") != account_id: + return "production_e2e_account_identity_mismatch" + counts = ( + account.get("enumerated_count"), + account.get("upstream_total_count"), + account.get("public_count"), + account.get("private_count"), + ) + if any(not isinstance(value, int) or isinstance(value, bool) or value < 0 for value in counts): + return "production_e2e_account_counts_invalid" + if counts[0] != counts[1] or counts[2] <= 0 or counts[3] <= 0: + return "production_e2e_account_reconciliation_failed" + if any(account.get(name) is not True for name in ("read", "write", "certify")): + return "production_e2e_account_authority_incomplete" + if account.get("credential_source") not in { + "github_app_installation", + "vault_user_token_fallback", + }: + return "production_e2e_credential_source_invalid" + if account.get("secret_exposed") is not False: + return "production_e2e_secret_boundary_failed" + + clients = payload.get("clients") + if not isinstance(clients, Mapping) or set(clients) != set(ECHO_CLIENTS): + return "production_e2e_clients_incomplete" + for client in clients.values(): + if not isinstance(client, Mapping) or client.get("accepted") is not True: + return "production_e2e_client_not_accepted" + fingerprints = client.get("repository_fingerprints") + if not isinstance(fingerprints, Mapping) or dict(fingerprints) != sample_digests: + return "production_e2e_cross_client_repository_mismatch" + return None + + +def validate_production_e2e( + payload: Mapping[str, Any] | None, + target: TargetIdentity, + environment: EnvironmentIdentity, + *, + now: datetime, +) -> tuple[bool, str]: + """Validate one trusted-collector payload against the exact run identities.""" + + if not isinstance(payload, Mapping): + return False, "production_e2e_attestation_missing" + error = _validate_common(payload, target, environment, now=now) + if error is not None: + return False, error + repository = _source_repository(target) + profile = payload.get("profile") + if repository == ECHO_GITHUB_AUTONOMY_REPOSITORY: + if profile != ECHO_GITHUB_AUTONOMY_PROFILE: + return False, "production_e2e_profile_mismatch" + error = _validate_echo_github_autonomy(payload) + return (error is None, error or "production_e2e_verified") + if profile != GENERIC_PROFILE: + return False, "production_e2e_profile_mismatch" + if frozenset(payload.get("required_checks") or ()) != BASE_CHECKS: + return False, "production_e2e_generic_checks_incomplete" + return True, "production_e2e_verified" + + +def validate_attestation_trust_metadata(payload: Mapping[str, Any]) -> tuple[bool, str]: + """Require the signature-verification metadata added by ``VerifiedProductionE2E``.""" + + if payload.get("signature_verified") is not True: + return False, "production_e2e_signature_not_verified" + if payload.get("collector_key_id") != payload.get("signing_key_id"): + return False, "production_e2e_collector_key_mismatch" + if not _SIGNING_KEY_ID.fullmatch(str(payload.get("collector_key_id") or "")): + return False, "production_e2e_collector_key_invalid" + if not _SHA256.fullmatch(str(payload.get("attestation_envelope_sha256") or "")): + return False, "production_e2e_envelope_sha256_invalid" + return True, "production_e2e_signature_verified" + + +def verify_signed_attestation( + envelope: SignedVerdictEnvelope, + trusted_keys: TrustedPublicKeyRegistry, +) -> VerifiedProductionE2E: + """Verify one collector envelope against an independently selected key registry.""" + + verified, reason = trusted_keys.verify(envelope) + if not verified: + raise ValueError(f"production E2E attestation is not trusted: {reason}") + if envelope.payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError("production E2E attestation schema is unsupported") + if envelope.payload.get("signing_key_id") != envelope.key_id: + raise ValueError("production E2E attestation key binding is invalid") + envelope_sha256 = sha256_json( + { + "payload": envelope.payload, + "signature_b64": envelope.signature_b64, + "key_id": envelope.key_id, + } + ) + return VerifiedProductionE2E( + payload=dict(envelope.payload), + collector_key_id=envelope.key_id, + envelope_sha256=envelope_sha256, + _marker=_VERIFIED_MARKER, + ) + + +def load_signed_attestation(path: Path, trusted_key_directory: Path) -> VerifiedProductionE2E: + """Load and independently verify a collector envelope using pinned public keys.""" + + try: + document = json.loads(path.read_text(encoding="utf-8")) + envelope = SignedVerdictEnvelope( + payload=dict(document["payload"]), + signature_b64=str(document["signature_b64"]), + key_id=str(document["key_id"]), + public_key_pem=str(document.get("public_key_pem") or ""), + ) + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc: + raise ValueError("production E2E attestation envelope is invalid") from exc + registry = TrustedPublicKeyRegistry.from_directory(trusted_key_directory) + return verify_signed_attestation(envelope, registry) + + +@dataclass(frozen=True, slots=True) +class DirectoryProductionE2EProvider: + """Resolve a signed attestation for one exact target/environment pair. + + The directory and trust roots are selected by the control plane. Target code + can neither choose an arbitrary file nor supply a public key. Missing or + untrusted envelopes are treated as absent so the deterministic verdict stays + ``NOT_READY``; a successfully verified envelope is still identity- and + freshness-checked by the executor. + """ + + attestation_directory: Path + trusted_key_directory: Path + + def __post_init__(self) -> None: + try: + attestation_root = self.attestation_directory.resolve(strict=True) + trust_root = self.trusted_key_directory.resolve(strict=True) + except OSError as exc: + raise ValueError("production E2E trust root is unavailable") from exc + if not attestation_root.is_dir() or not trust_root.is_dir(): + raise ValueError("production E2E trust root is not a directory") + object.__setattr__(self, "attestation_directory", attestation_root) + object.__setattr__(self, "trusted_key_directory", trust_root) + + def __call__( + self, + target: TargetIdentity, + environment: EnvironmentIdentity, + ) -> VerifiedProductionE2E | None: + target_digest = target.identity_digest + environment_digest = environment.identity_digest + if not _SHA256.fullmatch(target_digest) or not _SHA256.fullmatch(environment_digest): + return None + candidate = self.attestation_directory / ( + f"{target_digest}.{environment_digest}.json" + ) + try: + if candidate.is_symlink() or not candidate.is_file(): + return None + resolved = candidate.resolve(strict=True) + except OSError: + return None + if resolved.parent != self.attestation_directory: + return None + try: + return load_signed_attestation(resolved, self.trusted_key_directory) + except (OSError, TypeError, ValueError): + return None diff --git a/src/echo_certification_forge/run_worker.py b/src/echo_certification_forge/run_worker.py index 3774b5d..59edece 100644 --- a/src/echo_certification_forge/run_worker.py +++ b/src/echo_certification_forge/run_worker.py @@ -12,11 +12,13 @@ import argparse import dataclasses import json +import logging import os import shutil import sqlite3 import tempfile import threading +from collections.abc import Callable from pathlib import Path from cryptography.hazmat.primitives import serialization @@ -38,16 +40,19 @@ from .executor import RetentionPolicy, RunExecutor, StaticEntitlement from .models import EnvironmentIdentity, RunOutcome, RunState, TargetIdentity from .policy import RuleManifest +from .production_e2e import VerifiedProductionE2E, load_signed_attestation from .runner import RunnerResponse -from .sandbox import DEFAULT_IMAGE, DockerSandbox, sandboxed_journey_runner +from .runtime_identity import RuntimeProfile, _journey, load_runtime_profile, measured_environment +from .sandbox import DockerSandbox, sandboxed_journey_runner from .signing import Ed25519VerdictSigner from .subscriber import SubscriberError, SubscriberGovernance, SubscriberPolicy _REPO = Path(__file__).resolve().parents[2] +_LOGGER = logging.getLogger(__name__) _ADAPTER_RULE = "adapter_identity_and_quality" _PRODUCTION_MANIFEST_ID = "certforge.release-strict.v2" _PRODUCTION_MANIFEST_SHA256 = ( - "7dc98e0e95e6dd2c000ec069a8c46c4d1d49a4fe869ad4eae25e059d103644f4" + "08ba068ceb3e14bfed2690337edbb94c546e3e0a1a89b1321f7657653d8eea43" ) class _ClaimHeartbeat: def __init__(self, governance: SubscriberGovernance, claim) -> None: @@ -92,38 +97,20 @@ def assert_active(self) -> None: raise SubscriberError(409, self._failure_code) -def _env_digest(component: str) -> str: - return sha256_bytes(f"certforge-worker-env:{component}".encode("utf-8")) - - def _worker_environment( adapter_set_sha256: str | None = None, adapter_execution_profile_sha256: str | None = None, + *, + manifest: RuleManifest | None = None, + sandbox: DockerSandbox | None = None, + journey: list[str] | None = None, ) -> EnvironmentIdentity: - """Declared certification environment. - - The legacy v1 path retains its historical environment commitment. P5/v2 callers pass the exact - digest derived from the verified signed adapter execution records. - """ - return EnvironmentIdentity( - runner_image_sha256=_env_digest("runner-image"), - adapter_set_sha256=adapter_set_sha256 or _env_digest("adapter-set"), - test_plan_sha256=_env_digest("test-plan"), - policy_sha256=_env_digest("policy"), - harness_sha256=_env_digest("harness"), - prompt_set_sha256=_env_digest("prompt-set"), - model_route_sha256=( - sha256_json( - { - "base_model_route_sha256": _env_digest("model-route"), - "adapter_execution_profile_sha256": adapter_execution_profile_sha256, - } - ) - if adapter_execution_profile_sha256 - else _env_digest("model-route") - ), - os_runtime_sha256=_env_digest("os-runtime"), - egress_policy_sha256=_env_digest("egress-policy"), + """Compatibility entrypoint for the measured public runtime contract.""" + return measured_environment( + manifest=manifest or RuleManifest.load(_REPO / "policies/mandatory-rules.v2.json"), + sandbox=sandbox, journey=journey, + adapter_set_sha256=adapter_set_sha256, + adapter_execution_profile_sha256=adapter_execution_profile_sha256, ) @@ -157,14 +144,66 @@ def run( subscribers: SubscriberGovernance | None = None, journey: list[str] | None = None, sandbox: DockerSandbox | None = None, + runtime_profile: RuntimeProfile | None = None, adapter_records: tuple[AdapterExecutionRecord, ...] | None = None, adapter_policy: AdapterAcceptancePolicy | None = None, adapter_bundle_response: RunnerResponse | None = None, + production_e2e_attestation: VerifiedProductionE2E | None = None, + production_e2e_provider: ( + Callable[ + [TargetIdentity, EnvironmentIdentity], + VerifiedProductionE2E | None, + ] + | None + ) = None, worker_id: str | None = None, worker_attestation_sha256: str | None = None, execution_location: str = "local", signing_authority: str = "platform", ) -> dict: + try: + _journey(journey) + except ValueError: + # Applies to persisted legacy jobs and non-production compatibility too. + return {"run_id": run_id, "error": "runtime_profile_mismatch"} + if runtime_profile is not None: + try: + # Check operator-controlled inputs independently of tenant argv. + runtime_profile.check_execution(sandbox, runtime_profile.journey) + except ValueError: + return {"run_id": run_id, "error": "runtime_operator_profile_mismatch"} + try: + runtime_profile.check_execution(sandbox, journey) + except ValueError: + _LOGGER.warning("runtime_profile_rejected", extra={"run_id": run_id, "error_code": "runtime_profile_mismatch"}) + return {"run_id": run_id, "error": "runtime_profile_mismatch"} + try: + # Reject unmeasurable inputs before claiming work or acquiring a target. + prepared = _worker_environment( + adapter_set_digest(adapter_records) if adapter_records is not None else None, + sha256_json(adapter_bundle_response.body) if adapter_bundle_response is not None else None, + manifest=manifest, sandbox=sandbox, journey=journey, + ) + if runtime_profile is not None: + try: + declared = store.get_run(run_id, tenant) + except KeyError: + declared = None + if declared is not None: + try: + public = json.loads(str(declared["environment_identity_json"])) + except (ValueError, KeyError): + return {"run_id": run_id, "error": "runtime_environment_mismatch"} + if not isinstance(public, dict): + return {"run_id": run_id, "error": "runtime_environment_mismatch"} + image = public.get("runner_image_digest") + if declared["environment_identity_digest"] != prepared.identity_digest or ( + image is not None and image != "sha256:" + prepared.runner_image_sha256 + ): + return {"run_id": run_id, "error": "runtime_environment_mismatch"} + except (ValueError, OSError, sqlite3.Error): + _LOGGER.warning("runtime_identity_rejected", extra={"run_id": run_id, "error_code": "runtime_identity_unavailable"}) + return {"run_id": run_id, "error": "runtime_identity_unavailable"} if subscribers is None: try: with sqlite3.connect(store.db_path) as connection: @@ -347,7 +386,38 @@ def run( if adapter_bundle_response is not None else None ) - environment = _worker_environment(adapter_digest, adapter_execution_profile_sha256) + runtime_error = "runtime_profile_mismatch" + try: + # Acquisition can replace an OCI sandbox image. Fence the effective image, + # not merely the launcher's original selection, before any customer code. + if runtime_profile is not None: + runtime_profile.check_execution(sandbox_effective, journey) + runtime_error = "runtime_identity_unavailable" + environment = _worker_environment( + adapter_digest, adapter_execution_profile_sha256, + manifest=manifest, sandbox=sandbox_effective, journey=journey, + ) + except (ValueError, OSError): + if heartbeat is not None: + heartbeat.stop() + claim_released = claim is None + if claim is not None and subscribers is not None: + try: + subscribers.fail_worker_execution(claim, reason=runtime_error) + claim_released = True + except (OSError, sqlite3.Error, SubscriberError): + # The existing lease fences signing and eventually expires; never + # claim successful cleanup when storage could not confirm it. + pass + shutil.rmtree(workdir, ignore_errors=True) + cleanup_complete = claim_released and not workdir.exists() + _LOGGER.warning("runtime_identity_rejected_after_acquisition", extra={ + "run_id": run_id, "error_code": runtime_error, + "cleanup_complete": cleanup_complete, + }) + return {"run_id": run_id, "error": runtime_error, "cleanup_complete": cleanup_complete} + if production_e2e_attestation is None and production_e2e_provider is not None: + production_e2e_attestation = production_e2e_provider(target, environment) if subscribers is not None: if existing is None or existing["state"] != RunState.QUEUED.value: if claim is not None: @@ -530,6 +600,7 @@ def complete_subscriber_execution(envelope): }, adapter_records=adapter_records, adapter_policy=adapter_policy, + production_e2e_attestation=production_e2e_attestation, execution_guard=execution_guard, completion_callback=completion_callback, ) @@ -669,6 +740,26 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--tenant", required=True) parser.add_argument("--target-json", required=True, help='e.g. {"type":"local","path":"/abs/dir"}') parser.add_argument("--journey-json", default=None, help='e.g. ["python3","hello.py"]') + parser.add_argument( + "--production-e2e-attestation", + type=Path, + default=( + Path(os.environ["ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION"]) + if os.environ.get("ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION") + else None + ), + help="trusted-collector signed production E2E envelope", + ) + parser.add_argument( + "--trusted-production-e2e-keys", + type=Path, + default=( + Path(os.environ["ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS"]) + if os.environ.get("ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS") + else None + ), + help="directory of pinned Ed25519 collector public keys", + ) parser.add_argument("--policy-version", default=None) parser.add_argument( "--db", @@ -711,6 +802,7 @@ def main(argv: list[str] | None = None) -> int: ), ) parser.add_argument("--sandbox", action="store_true") + parser.add_argument("--runtime-profile", type=Path) parser.add_argument( "--non-production-compat", action="store_true", @@ -718,7 +810,7 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument( "--sandbox-image", - default=os.environ.get("ECHO_CERTFORGE_SANDBOX_IMAGE", DEFAULT_IMAGE), + default=None, ) parser.add_argument( "--sandbox-docker", @@ -875,12 +967,28 @@ def main(argv: list[str] | None = None) -> int: ) target_spec = json.loads(args.target_json) journey = json.loads(args.journey_json) if args.journey_json else None - sandbox = None - if args.sandbox: - sandbox = DockerSandbox( - image=args.sandbox_image, + production_e2e_attestation = None + if args.production_e2e_attestation is not None: + if args.trusted_production_e2e_keys is None: + print(json.dumps({"error": "trusted_production_e2e_keys_missing"})) + return 2 + try: + production_e2e_attestation = load_signed_attestation( + args.production_e2e_attestation, + args.trusted_production_e2e_keys, + ) + except ValueError as exc: + print(json.dumps({"error": "production_e2e_attestation_invalid", "detail": str(exc)})) + return 2 + try: + runtime = load_runtime_profile( + args.runtime_profile, image=args.sandbox_image, docker=tuple(args.sandbox_docker.split()), ) + except ValueError as exc: + print(json.dumps({"error": "runtime_profile_invalid", "detail": str(exc)})) + return 2 + sandbox = runtime.sandbox if args.sandbox else None result = run( args.run_id, @@ -893,9 +1001,11 @@ def main(argv: list[str] | None = None) -> int: subscribers=subscribers, journey=journey, sandbox=sandbox, + runtime_profile=(runtime if args.runtime_profile or os.environ.get("ECHO_CERTFORGE_RUNTIME_PROFILE") else None), adapter_records=adapter_records, adapter_policy=adapter_policy, adapter_bundle_response=adapter_rebound_response, + production_e2e_attestation=production_e2e_attestation, worker_id=args.worker_id, worker_attestation_sha256=args.worker_attestation_sha256, execution_location=args.execution_location, diff --git a/src/echo_certification_forge/runtime_identity.py b/src/echo_certification_forge/runtime_identity.py new file mode 100644 index 0000000..49a0a9d --- /dev/null +++ b/src/echo_certification_forge/runtime_identity.py @@ -0,0 +1,192 @@ +"""Measure public execution inputs; never inspect environment values or signing material. + +The optional operator-owned profile selects one advertised journey and sandbox. Its +identity is derived again from the actual execution inputs at the worker boundary. +This module does not attest that an image was launched or that a journey passed. +""" +from __future__ import annotations + +import dataclasses +import importlib.metadata +import json +import logging +import math +import os +import platform +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +from .canonical import sha256_bytes, sha256_json +from .models import EnvironmentIdentity +from .policy import RuleManifest +from .sandbox import DEFAULT_IMAGE, DockerSandbox + +_PACKAGE = Path(__file__).resolve().parent +_LOG = logging.getLogger(__name__) +_SCHEMA = "certforge.runtime.v1" +_IMAGE = re.compile(r"(?:[^@\s]+@)?sha256:([0-9a-f]{64})\Z") +_FIELDS = frozenset({"image", "memory", "cpus", "pids_limit", "tmpfs_size", "timeout_s", "user"}) + + +def _sandbox_inputs(sandbox: DockerSandbox | None) -> dict: + if sandbox is None: + return {"isolation": "none", "network": "host", "production": False} + if sandbox.extra_env: + raise ValueError("runtime_extra_env_not_supported") + if not isinstance(sandbox.image, str) or not _IMAGE.fullmatch(sandbox.image): + raise ValueError("runtime_image_requires_exact_digest") + for value in (sandbox.memory, sandbox.tmpfs_size): + if not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]*[kmg]?", value): + raise ValueError("runtime_resource_limit_invalid") + for value in (sandbox.cpus, sandbox.timeout_s): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError("runtime_resource_limit_invalid") from exc + if isinstance(value, bool) or not math.isfinite(number) or number <= 0: + raise ValueError("runtime_resource_limit_invalid") + if type(sandbox.pids_limit) is not int or sandbox.pids_limit < 1: + raise ValueError("runtime_resource_limit_invalid") + if not isinstance(sandbox.user, str) or not re.fullmatch(r"[1-9][0-9]*:[1-9][0-9]*", sandbox.user): + raise ValueError("runtime_nonroot_user_required") + return { + "isolation": "docker", "image": sandbox.image, + "image_identifier_kind": ( + "docker_content_id" if sandbox.image.startswith("sha256:") else "repository_digest" + ), + "memory": sandbox.memory, "memory_swap": sandbox.memory, + "cpus": str(sandbox.cpus), "pids_limit": sandbox.pids_limit, + "tmpfs_size": sandbox.tmpfs_size, "timeout_s": float(sandbox.timeout_s), + "user": sandbox.user, "docker": list(sandbox.docker), + "network": "none", "read_only": True, "source_mount": "/work:ro", + "tmpfs_options": ["rw", "noexec", "nosuid"], + "cap_drop": "ALL", "no_new_privileges": True, + } + + +def _journey(value: object) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, (tuple, list)) or not 1 <= len(value) <= 32: + raise ValueError("runtime_journey_invalid") + if any(not isinstance(item, str) or not item or len(item) > 2048 or "\x00" in item for item in value): + raise ValueError("runtime_journey_invalid") + return tuple(value) + + +@dataclass(frozen=True) +class RuntimeProfile: + sandbox: DockerSandbox + journey: tuple[str, ...] | None = None + + def check_execution(self, sandbox: DockerSandbox | None, journey: list[str] | None) -> None: + selected = _journey(journey) + if _sandbox_inputs(sandbox) != _sandbox_inputs(self.sandbox) or ( + self.journey is not None and selected != self.journey + ): + raise ValueError("runtime_profile_mismatch") + + +def load_runtime_profile( + path: Path | None = None, *, image: str | None = None, docker: tuple[str, ...] | None = None, +) -> RuntimeProfile: + """Read only an explicitly selected public profile; unknown fields fail closed.""" + selected = path or os.environ.get("ECHO_CERTFORGE_RUNTIME_PROFILE") + raw = {} + if selected: + try: + raw = json.loads(Path(selected).read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ValueError("runtime_profile_unreadable") from exc + if not isinstance(raw, dict) or raw.get("schema_version") != _SCHEMA: + raise ValueError("runtime_profile_schema_invalid") + if set(raw) - {"schema_version", "sandbox", "journey"}: + raise ValueError("runtime_profile_fields_invalid") + config = raw.get("sandbox", {}) + if not isinstance(config, dict) or set(config) - _FIELDS: + raise ValueError("runtime_profile_sandbox_fields_invalid") + config = dict(config) + configured_image = image or os.environ.get("ECHO_CERTFORGE_SANDBOX_IMAGE") + if configured_image and "image" in config and config["image"] != configured_image: + raise ValueError("runtime_profile_image_mismatch") + config.setdefault("image", configured_image or DEFAULT_IMAGE) + command = docker or tuple(os.environ.get("ECHO_CERTFORGE_SANDBOX_DOCKER", "docker").split()) + sandbox = DockerSandbox(**config, docker=command) + _sandbox_inputs(sandbox) + return RuntimeProfile(sandbox, _journey(raw.get("journey"))) + + +def _harness_digest(source_root: Path | None = None) -> str: + """Hash the explicit executable package and dependency declarations, never var/ or keys.""" + package = source_root / "src/echo_certification_forge" if source_root else _PACKAGE + repository = source_root or (package.parent.parent if package.parent.name == "src" else None) + paths = sorted(package.rglob("*.py")) + if not paths or not (package / "executor.py").is_file(): + raise ValueError("runtime_harness_source_missing") + declarations = { + "pyproject.toml": repository / "pyproject.toml" if repository else package / "runtime_data/pyproject.toml", + "images/requirements.lock": repository / "images/requirements.lock" if repository else package / "runtime_data/requirements.lock", + } + try: + hashes = {"src/echo_certification_forge/" + path.relative_to(package).as_posix(): sha256_bytes(path.read_bytes()) + for path in paths} + hashes.update({name: sha256_bytes(path.read_bytes()) for name, path in declarations.items()}) + return sha256_json(hashes) + except OSError as exc: + raise ValueError("runtime_harness_source_unreadable") from exc + + +def measured_environment( + *, manifest: RuleManifest, sandbox: DockerSandbox | None = None, + journey: list[str] | tuple[str, ...] | None = None, + adapter_set_sha256: str | None = None, + adapter_execution_profile_sha256: str | None = None, + source_root: Path | None = None, +) -> EnvironmentIdentity: + config = _sandbox_inputs(sandbox) + plan = _journey(journey) + try: + executable_sha256 = sha256_bytes(Path(sys.executable).read_bytes()) + packages = { + name: importlib.metadata.version(name) + for name in ("cryptography", "fastapi", "pydantic", "uvicorn") + } + except (OSError, importlib.metadata.PackageNotFoundError) as exc: + raise ValueError("runtime_metadata_unavailable") from exc + runtime = { + "python_implementation": sys.implementation.name, + "python_version": platform.python_version(), "os": platform.system(), + "kernel": platform.release(), "machine": platform.machine(), + "python_executable_sha256": executable_sha256, + "packages": packages, + "sandbox": config, + } + adapters = { + "adapter_set_sha256": adapter_set_sha256, + "verified_execution_profile_sha256": adapter_execution_profile_sha256, + "mode": "verified_adapter_bundle" if adapter_execution_profile_sha256 else "no_verified_adapter_bundle", + } + environment = EnvironmentIdentity( + runner_image_sha256=( + _IMAGE.fullmatch(sandbox.image).group(1) if sandbox else sha256_json(runtime) + ), + adapter_set_sha256=adapter_set_sha256 or sha256_json([]), + test_plan_sha256=sha256_json({ + "schema_version": _SCHEMA, "journey": list(plan) if plan else None, + "mandatory_rules": [dataclasses.asdict(rule) for rule in manifest.rules], + }), + policy_sha256=manifest.digest, + harness_sha256=_harness_digest(source_root), + prompt_set_sha256=sha256_json({"component": "adapter_prompts", **adapters}), + model_route_sha256=sha256_json({"component": "adapter_model_routes", **adapters}), + os_runtime_sha256=sha256_json(runtime), + egress_policy_sha256=sha256_json(config), + ) + _LOG.debug("runtime_identity_measured", extra={ + "environment_identity_digest": environment.identity_digest, + "runner_image_sha256": environment.runner_image_sha256, + "runtime_schema": _SCHEMA, "isolation": config["isolation"], + }) + return environment diff --git a/src/echo_certification_forge/sandbox.py b/src/echo_certification_forge/sandbox.py index a6ed098..fac3ed5 100644 --- a/src/echo_certification_forge/sandbox.py +++ b/src/echo_certification_forge/sandbox.py @@ -25,7 +25,7 @@ from typing import Callable # Pinned minimal Python base (same digest the P4 supply-chain pipeline pins). Override per policy. -DEFAULT_IMAGE = "python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" +DEFAULT_IMAGE = "python:3.12-alpine@sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" class SandboxError(RuntimeError): diff --git a/src/echo_certification_forge/service.py b/src/echo_certification_forge/service.py index 422cdf3..425536b 100644 --- a/src/echo_certification_forge/service.py +++ b/src/echo_certification_forge/service.py @@ -8,12 +8,12 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Literal +from typing import Annotated, Any, Literal from fastapi import FastAPI, Header, HTTPException, Query, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from .canonical import sha256_json, to_utc_iso, utc_now from .deploy_gate import DeployGate @@ -45,6 +45,7 @@ SignedOperationalReport, ) from .runner import TrustedTransportRegistry +from .runtime_identity import RuntimeProfile, _journey, measured_environment from .signing import TrustedPublicKeyRegistry from .subscriber import ( MemberRole, @@ -84,6 +85,7 @@ class ServiceContext: operational_registry: OperationalTelemetryRegistry | None = None adapter_mode: str = "pending" certification_environment: dict[str, str] | None = None + runtime_profile: RuntimeProfile | None = None product_readiness_report_path: Path | None = None # Adapter acceptance evidence. Deliberately has no usable default: an unset path makes # the adapter gate BLOCK, so a misconfigured deployment fails closed instead of @@ -108,6 +110,44 @@ class DeployGateRequest(BaseModel): rule_manifest_digest: str = Field(pattern=r"^[0-9a-f]{64}$") +JourneyArgument = Annotated[str, Field(min_length=1, max_length=2048, pattern=r"^[^\x00]*$")] + + +class RuntimeEnvironmentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + journey: list[JourneyArgument] | None = Field(default=None, min_length=1, max_length=32) + + @field_validator("journey") + @classmethod + def validate_journey(cls, value: list[str] | None) -> list[str] | None: + _journey(value) + return value + + +RuntimeIdentityDigest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + + +class RuntimeEnvironmentComponents(BaseModel): + model_config = ConfigDict(extra="forbid") + runner_image_sha256: RuntimeIdentityDigest + adapter_set_sha256: RuntimeIdentityDigest + test_plan_sha256: RuntimeIdentityDigest + policy_sha256: RuntimeIdentityDigest + harness_sha256: RuntimeIdentityDigest + prompt_set_sha256: RuntimeIdentityDigest + model_route_sha256: RuntimeIdentityDigest + os_runtime_sha256: RuntimeIdentityDigest + egress_policy_sha256: RuntimeIdentityDigest + + +class RuntimeEnvironmentResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + schema_version: Literal["certforge.runtime.v1"] = "certforge.runtime.v1" + journey: list[JourneyArgument] | None + environment: RuntimeEnvironmentComponents + submit_environment: SubmitEnvironment + + class ProjectCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") command: str | None = Field(default=None, min_length=1, max_length=64) @@ -495,6 +535,37 @@ def status() -> dict[str, object]: result.update(context.certification_environment) return result + @app.post("/v1/subscriber/runtime/environment") + def resolve_runtime_environment( + request: RuntimeEnvironmentRequest, + x_tenant_id: str | None = Header(default=None), + authorization: str | None = Header(default=None), + ) -> RuntimeEnvironmentResponse: + subscriber_principal(x_tenant_id, authorization, Permission.RUN_READ, "runtime.environment.resolve") + profile = context.runtime_profile + public = context.certification_environment + if profile is None or public is None: + raise HTTPException(status_code=503, detail="runtime_profile_unavailable") + try: + profile.check_execution(profile.sandbox, request.journey) + environment = measured_environment( + manifest=context.manifest, sandbox=profile.sandbox, journey=request.journey, + adapter_set_sha256=public.get("adapter_set_sha256"), + adapter_execution_profile_sha256=public.get("adapter_execution_profile_sha256"), + ) + except ValueError as exc: + code = str(exc) + if code in {"runtime_profile_mismatch", "runtime_journey_invalid"}: + raise HTTPException(status_code=422, detail=code) from exc + raise HTTPException(status_code=503, detail="runtime_identity_unavailable") from exc + return RuntimeEnvironmentResponse( + journey=request.journey, environment=RuntimeEnvironmentComponents.model_validate(environment.to_dict()), + submit_environment=SubmitEnvironment( + identity_digest=environment.identity_digest, + runner_image_digest="sha256:" + environment.runner_image_sha256, + ), + ) + @app.post("/v1/certifications", status_code=201) def submit_certification( request: SubmitRequest, @@ -563,13 +634,13 @@ def submit_certification( except SubmitError as exc: if reservation is not None and reservation.created: context.subscribers.release_reservation( - reservation, reason="submit_rejected", compensate_meter=True + reservation, reason="submit_rejected", compensate_meter=True, only_reserved=True ) raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc except SubscriberError as exc: if reservation is not None and reservation.created: context.subscribers.release_reservation( - reservation, reason="governance_rejected", compensate_meter=True + reservation, reason="governance_rejected", compensate_meter=True, only_reserved=True ) raise subscriber_error(exc) from exc except sqlite3.Error as exc: @@ -579,6 +650,7 @@ def submit_certification( reservation, reason="subscriber_storage_failure", compensate_meter=True, + only_reserved=True, ) except sqlite3.Error: pass @@ -957,7 +1029,7 @@ def subscriber_rerun( except SubscriberError as exc: if reservation is not None and reservation.created: context.subscribers.release_reservation( - reservation, reason="rerun_rejected", compensate_meter=True + reservation, reason="rerun_rejected", compensate_meter=True, only_reserved=True ) raise subscriber_error(exc) from exc diff --git a/src/echo_certification_forge/subscriber.py b/src/echo_certification_forge/subscriber.py index 3c69fb4..4fb28ba 100644 --- a/src/echo_certification_forge/subscriber.py +++ b/src/echo_certification_forge/subscriber.py @@ -4,6 +4,7 @@ import hashlib import hmac import json +import logging import secrets import sqlite3 from collections.abc import Collection, Iterator, Mapping @@ -3410,7 +3411,9 @@ def rerun_dispatch_template( raise SubscriberError(503, "source_dispatch_invalid") return target, journey - def pending_intake_requests(self, limit: int = 100) -> list[PendingSubscriberIntake]: + def pending_intake_requests( + self, limit: int = 100, *, policy_version: str | None = None + ) -> list[PendingSubscriberIntake]: if limit <= 0 or limit > 1000: raise ValueError("limit must be between 1 and 1000") with self._connection() as connection: @@ -3424,13 +3427,22 @@ def pending_intake_requests(self, limit: int = 100) -> list[PendingSubscriberInt WHERE state = 'RESERVED' AND dispatch_target_json IS NOT NULL AND submit_request_json IS NOT NULL + AND (? IS NULL OR policy_version = ?) ORDER BY created_at, idempotency_key LIMIT ? """, - (limit,), + (policy_version, policy_version, limit), ).fetchall() pending: list[PendingSubscriberIntake] = [] for row in rows: + reservation = RunReservation( + organization_id=str(row["organization_id"]), + idempotency_key=str(row["idempotency_key"]), + request_digest=str(row["request_digest"]), created=False, + project_id=str(row["project_id"]), policy_version=str(row["policy_version"]), + target_type=str(row["target_type"]), target_reference=str(row["target_reference"]), + target_identity_digest=str(row["target_identity_digest"]), + ) try: target_spec = json.loads(str(row["dispatch_target_json"])) request = json.loads(str(row["submit_request_json"])) @@ -3439,25 +3451,21 @@ def pending_intake_requests(self, limit: int = 100) -> list[PendingSubscriberInt if row["dispatch_journey_json"] is not None else None ) - except json.JSONDecodeError as exc: - raise SubscriberError(503, "pending_intake_invalid") from exc - if not isinstance(target_spec, dict) or not isinstance(request, dict): - raise SubscriberError(503, "pending_intake_invalid") - if journey is not None and not isinstance(journey, list): - raise SubscriberError(503, "pending_intake_invalid") + if not isinstance(target_spec, dict) or not isinstance(request, dict): + raise ValueError("pending_intake_invalid") + if journey is not None and not isinstance(journey, list): + raise ValueError("pending_intake_invalid") + except ValueError: + self.release_reservation(reservation, reason="pending_intake_invalid", + compensate_meter=True, only_reserved=True) + logging.getLogger(__name__).warning(json.dumps({ + "event": "pending_intake_rejected", "reason": "pending_intake_invalid", + "reservation_ref": sha256_bytes(reservation.idempotency_key.encode())[:32], + })) + continue pending.append( PendingSubscriberIntake( - reservation=RunReservation( - organization_id=str(row["organization_id"]), - idempotency_key=str(row["idempotency_key"]), - request_digest=str(row["request_digest"]), - created=False, - project_id=str(row["project_id"]), - policy_version=str(row["policy_version"]), - target_type=str(row["target_type"]), - target_reference=str(row["target_reference"]), - target_identity_digest=str(row["target_identity_digest"]), - ), + reservation=reservation, request=request, target_spec=target_spec, journey=journey, @@ -4122,8 +4130,9 @@ def claim_dispatch( with self._connection(immediate=True) as connection: self._recover_expired_claims(connection, organization_id, now_value) self._recover_expired_dispatches(connection, now_value) - filters = ["d.state = 'PENDING'", "r.state = 'BOUND'", "runs.state = 'QUEUED'"] - parameters: list[Any] = [] + filters = ["d.state = 'PENDING'", "r.state = 'BOUND'", "runs.state = 'QUEUED'", + "(d.last_error IS NULL OR d.last_error NOT IN ('runtime_profile_mismatch', 'runtime_environment_mismatch') OR d.updated_at <= ?)"] + parameters: list[Any] = [to_utc_iso(now_value - timedelta(seconds=60))] if run_id is not None: filters.append("d.run_id = ?") parameters.append(run_id) @@ -4211,6 +4220,43 @@ def claim_dispatch( attempts=int(row["attempts"]) + 1, ) + def defer_dispatch_runtime_failure(self, dispatch: SubscriberDispatch, *, error: str) -> None: + """Return an unexecuted claim without spending a customer's retry budget. + + Runtime drift can affect the entire dispatcher. The caller must stop its + polling loop; a supervisor may restart it after the configured delay. + """ + if error not in {"runtime_operator_profile_mismatch", "runtime_identity_unavailable"}: + raise ValueError("runtime deferral requires a runtime failure") + now = to_utc_iso(self._now()) + with self._connection(immediate=True) as connection: + cursor = connection.execute( + """ + UPDATE subscriber_run_dispatches + SET state = 'PENDING', attempts = MAX(attempts - 1, 0), + claim_token = NULL, lease_owner = NULL, lease_expires_at = NULL, + last_error = ?, updated_at = ? + WHERE run_id = ? AND organization_id = ? AND claim_token = ? + AND state = 'CLAIMED' + AND EXISTS (SELECT 1 FROM runs WHERE runs.run_id = subscriber_run_dispatches.run_id + AND runs.tenant_id = subscriber_run_dispatches.organization_id + AND runs.state = 'QUEUED') + AND EXISTS (SELECT 1 FROM subscriber_run_reservations r + WHERE r.run_id = subscriber_run_dispatches.run_id + AND r.organization_id = subscriber_run_dispatches.organization_id + AND r.state = 'BOUND') + """, + (error, now, dispatch.run_id, dispatch.organization_id, dispatch.claim_token), + ) + if cursor.rowcount != 1: + raise SubscriberError(409, "dispatch_claim_inactive") + self._append_audit( + connection, organization_id=dispatch.organization_id, + actor_ref="certforge.dispatcher", action="certification.runtime_deferred", + resource_type="certification", resource_id=dispatch.run_id, + outcome="denied", details={"reason": error, "retry_budget_consumed": False}, + ) + def finish_dispatch( self, dispatch: SubscriberDispatch, @@ -4291,6 +4337,29 @@ def finish_dispatch( state = "FAILED" elif error is not None and int(row["attempts"]) < self.policy.max_dispatch_attempts: state = "PENDING" + elif (error is not None and row["reservation_state"] == "BOUND" + and run_state in {"CREATED", "QUEUED"}): + self._transition_run_with_connection( + connection, dispatch.run_id, dispatch.organization_id, run_state, + "INFRASTRUCTURE_FAILURE", "certforge.dispatcher", + "dispatch_attempts_exhausted_before_execution", "p7.subscriber", now, + ) + connection.execute( + "UPDATE runs SET run_outcome = 'INFRA_FAILED', updated_at = ? " + "WHERE run_id = ? AND tenant_id = ?", + (now, dispatch.run_id, dispatch.organization_id), + ) + connection.execute( + "UPDATE subscriber_run_reservations SET state = 'RELEASED', updated_at = ? " + "WHERE run_id = ? AND organization_id = ? AND state = 'BOUND'", + (now, dispatch.run_id, dispatch.organization_id), + ) + self._compensate_crash_meter( + connection, dispatch.organization_id, str(row["idempotency_key"]), + dispatch.run_id, str(row["metered_at"]), key_prefix="dispatch-retry-exhausted", + reason="dispatch_attempts_exhausted_before_execution", + ) + state = "FAILED" else: state = "FAILED" connection.execute( @@ -5189,14 +5258,15 @@ def finish_worker_claim( ) def release_reservation( - self, reservation: RunReservation, *, reason: str, compensate_meter: bool + self, reservation: RunReservation, *, reason: str, compensate_meter: bool, + only_reserved: bool = False, ) -> None: require_identifier(reason, "reason") now = to_utc_iso(self._now()) with self._connection(immediate=True) as connection: row = connection.execute( """ - SELECT r.state, r.created_at, + SELECT r.state, r.created_at, r.request_digest, ( SELECT u.occurred_at FROM subscriber_usage_events u @@ -5213,6 +5283,11 @@ def release_reservation( ).fetchone() if row is None or row["state"] == "RELEASED": return + # Recovery owns only the exact still-unbound reservation it observed. + # A concurrent API materialization or dispatcher claim must survive. + if only_reserved and (row["state"] != "RESERVED" + or row["request_digest"] != reservation.request_digest): + return connection.execute( """ UPDATE subscriber_run_reservations SET state = 'RELEASED', updated_at = ? diff --git a/src/echo_certification_forge/verdict.py b/src/echo_certification_forge/verdict.py index c739357..82c1e12 100644 --- a/src/echo_certification_forge/verdict.py +++ b/src/echo_certification_forge/verdict.py @@ -9,6 +9,9 @@ from .evidence import EvidenceStore from .models import EnvironmentIdentity, ReleaseVerdict, RunOutcome, TargetIdentity, VerdictDecision from .policy import RuleManifest +from .production_e2e import RULE_ID as PRODUCTION_E2E_RULE_ID +from .production_e2e import validate_attestation_trust_metadata +from .production_e2e import validate_production_e2e class DeterministicVerdictEngine: @@ -34,6 +37,8 @@ def evaluate( # ({tenant_id, target_type, declared_identity_digest, reference}), not the full canonical # TargetIdentity. Such a run has not been reconciled to an acquired artifact, so it cannot be # certified: fail-closed with an explicit reason instead of crashing on TargetIdentity(**...). + target: TargetIdentity | None = None + environment: EnvironmentIdentity | None = None if "declared_identity_digest" in target_data or "artifact_sha256" not in target_data: reasons.append("target_identity_not_reconciled") else: @@ -72,6 +77,44 @@ def evaluate( if invalid_references: reasons.append(f"invalid_rule_evidence:{rule.id}") + # PRODUCTION_READY is impossible without a current, signed, exact-identity E2E + # attestation. This is an engine invariant, not an optional policy convention; + # legacy/custom manifests that omit the rule fail closed as well. + if not any(rule.id == PRODUCTION_E2E_RULE_ID for rule in manifest.rules): + reasons.append("production_e2e_rule_missing_from_policy") + e2e_result = results.get(PRODUCTION_E2E_RULE_ID) + verified_e2e: dict[str, Any] | None = None + if target is None or environment is None: + reasons.append("production_e2e_identity_unavailable") + elif e2e_result is None: + reasons.append("production_e2e_attestation_missing") + elif ( + not e2e_result.passed + and e2e_result.details.get("validation") + == "production_e2e_attestation_missing" + ): + # The executor records a fail-closed rule row even when no attestation was supplied. + # Preserve that explicit cause instead of re-validating the diagnostic placeholder as + # though it were a malformed attestation payload. + reasons.append("production_e2e_attestation_missing") + else: + e2e_valid, e2e_reason = validate_production_e2e( + e2e_result.details, + target, + environment, + now=utc_now(), + ) + if not e2e_valid: + reasons.append(e2e_reason) + else: + trust_valid, trust_reason = validate_attestation_trust_metadata( + e2e_result.details + ) + if not trust_valid: + reasons.append(trust_reason) + else: + verified_e2e = e2e_result.details + if store.blocking_findings(run_id, tenant_id): reasons.append("blocking_findings_present") @@ -97,6 +140,17 @@ def evaluate( rule_manifest_id=manifest.manifest_id, rule_manifest_digest=manifest.digest, evidence_merkle_root=verification.merkle_root, + production_e2e_attestation_id=( + str(verified_e2e["attestation_id"]) if verified_e2e is not None else None + ), + production_e2e_profile=( + str(verified_e2e["profile"]) if verified_e2e is not None else None + ), + production_e2e_envelope_sha256=( + str(verified_e2e["attestation_envelope_sha256"]) + if verified_e2e is not None + else None + ), signing_key_id=signing_key_id, issued_at=issued_at, expires_at=issued_at + timedelta(seconds=manifest.verdict_ttl_seconds), diff --git a/tests/conftest.py b/tests/conftest.py index f674a9a..138dac5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,12 +26,13 @@ def store(tmp_path: Path) -> EvidenceStore: @pytest.fixture def target() -> TargetIdentity: + source_commit = "a" * 40 return TargetIdentity( tenant_id="tenant-alpha", target_type="git", - canonical_ref="https://github.com/example/project@abc123", + canonical_ref=f"https://github.com/example/project@{source_commit}", artifact_sha256=digest("artifact"), - source_commit="abc123", + source_commit=source_commit, dependency_sha256=digest("dependencies"), configuration_sha256=digest("configuration"), ) diff --git a/tests/production_e2e_support.py b/tests/production_e2e_support.py new file mode 100644 index 0000000..8c3d2c7 --- /dev/null +++ b/tests/production_e2e_support.py @@ -0,0 +1,46 @@ +"""Trusted production-E2E attestations used by tests that exercise ready paths.""" + +from __future__ import annotations + +from datetime import timedelta + +from echo_certification_forge.canonical import to_utc_iso, utc_now +from echo_certification_forge.models import EnvironmentIdentity, TargetIdentity +from echo_certification_forge.production_e2e import ( + BASE_CHECKS, + GENERIC_PROFILE, + SCHEMA_VERSION, + VerifiedProductionE2E, + verify_signed_attestation, +) +from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry + + +def trusted_generic_production_e2e( + target: TargetIdentity, + environment: EnvironmentIdentity, +) -> VerifiedProductionE2E: + """Create a fresh signed generic attestation through the real trust verifier.""" + + observed_at = utc_now() + signer = Ed25519VerdictSigner.generate() + payload = { + "schema_version": SCHEMA_VERSION, + "attestation_id": f"test-e2e-{target.identity_digest[:16]}", + "profile": GENERIC_PROFILE, + "target_identity_digest": target.identity_digest, + "environment_identity_digest": environment.identity_digest, + "source_commit": target.source_commit, + "deployment_sha": target.source_commit, + "canonical_target": target.canonical_ref, + "required_checks": sorted(BASE_CHECKS), + "checks": {name: True for name in sorted(BASE_CHECKS)}, + "stability_probe_count": 3, + "observed_at": to_utc_iso(observed_at), + "expires_at": to_utc_iso(observed_at + timedelta(minutes=30)), + "signing_key_id": signer.key_id, + } + envelope = signer.sign_payload(payload) + trusted = TrustedPublicKeyRegistry.empty() + trusted.add_pem(signer.public_key_pem) + return verify_signed_attestation(envelope, trusted) diff --git a/tests/test_additional_contracts.py b/tests/test_additional_contracts.py index 7687852..362b3f7 100644 --- a/tests/test_additional_contracts.py +++ b/tests/test_additional_contracts.py @@ -22,10 +22,12 @@ from echo_certification_forge.service import ServiceContext, create_app from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry from echo_certification_forge.verdict import DeterministicVerdictEngine +from production_e2e_support import trusted_generic_production_e2e def complete_run(store, manifest, target, environment, run_id="cert-extra"): store.register_run(run_id, target, environment, manifest.manifest_id, manifest.digest) + production_e2e = trusted_generic_production_e2e(target, environment).result_details() for index, rule in enumerate(manifest.rules, start=1): artifact_id = f"extra-{index:02d}" store.append_artifact( @@ -34,7 +36,12 @@ def complete_run(store, manifest, target, environment, run_id="cert-extra"): ) store.record_rule_result( run_id, target.tenant_id, - RuleResult(rule.id, True, (artifact_id,), {"verified": True}), + RuleResult( + rule.id, + True, + (artifact_id,), + production_e2e if rule.id == "production_e2e" else {"verified": True}, + ), ) store.set_run_outcome(run_id, target.tenant_id, RunOutcome.COMPLETE) return run_id diff --git a/tests/test_deploy_dispatch_smoke_contract.py b/tests/test_deploy_dispatch_smoke_contract.py new file mode 100644 index 0000000..2118fa4 --- /dev/null +++ b/tests/test_deploy_dispatch_smoke_contract.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "certforge_deploy_smoke_dispatch", ROOT / "deploy" / "smoke_dispatch.py" +) +assert SPEC is not None and SPEC.loader is not None +SMOKE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SMOKE) + + +def test_production_dispatch_smoke_requires_fail_closed_unattested_verdict() -> None: + SMOKE._require_attestation_gate_closed( # noqa: SLF001 + { + "release_verdict": "NOT_READY", + "production_e2e": {"verified": False}, + } + ) + + +@pytest.mark.parametrize( + "terminal", + [ + { + "release_verdict": "PRODUCTION_READY", + "production_e2e": {"verified": False}, + }, + { + "release_verdict": "NOT_READY", + "production_e2e": {"verified": True}, + }, + {"release_verdict": "NOT_READY"}, + ], +) +def test_production_dispatch_smoke_rejects_open_or_ambiguous_gate(terminal: dict) -> None: + with pytest.raises(RuntimeError): + SMOKE._require_attestation_gate_closed(terminal) # noqa: SLF001 diff --git a/tests/test_deploy_gate.py b/tests/test_deploy_gate.py index 8a0a1ed..2b9009e 100644 --- a/tests/test_deploy_gate.py +++ b/tests/test_deploy_gate.py @@ -96,6 +96,33 @@ def test_deploy_gate_snapshots_and_restores_persistent_database() -> None: assert 'DB_SNAPSHOT_READY=1' in DEPLOY_SCRIPT +def test_deploy_gate_uses_the_effective_production_database_everywhere() -> None: + assert 'PROD_DB_PATH="${ECHO_CERTFORGE_DB:-}"' in DEPLOY_SCRIPT + assert 'systemctl show "$SERVICE.service" --property MainPID --value' in DEPLOY_SCRIPT + assert 'Path(f"/proc/{sys.argv[1]}/environ")' in DEPLOY_SCRIPT + assert 'entry.startswith(b"ECHO_CERTFORGE_DB=")' in DEPLOY_SCRIPT + assert 'PROD_DB_PATH="${PROD_DB_PATH:-$STATE_ROOT/certforge.sqlite3}"' in DEPLOY_SCRIPT + assert '/mnt/documentation/echo/certforge/*' in DEPLOY_SCRIPT + assert 'DB_PATH="$PROD_DB_PATH"' in DEPLOY_SCRIPT + assert DEPLOY_SCRIPT.count("Environment=ECHO_CERTFORGE_DB=$DB_PATH") == 2 + assert DEPLOY_SCRIPT.count('ECHO_CERTFORGE_DB="$DB_PATH" \\') == 2 + assert 'ECHO_CERTFORGE_DB="$STATE_ROOT/certforge.sqlite3"' not in DEPLOY_SCRIPT + + +def test_deploy_gate_allows_large_exact_sha_checkout_without_removing_bound() -> None: + assert ( + 'GIT_ACQUISITION_TIMEOUT_SECONDS="${ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS:-900}"' + in DEPLOY_SCRIPT + ) + assert "GIT_ACQUISITION_TIMEOUT_SECONDS < 30" in DEPLOY_SCRIPT + assert "GIT_ACQUISITION_TIMEOUT_SECONDS > 1800" in DEPLOY_SCRIPT + assert ( + "Environment=ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS=" + "$GIT_ACQUISITION_TIMEOUT_SECONDS" + in DEPLOY_SCRIPT + ) + + def test_deploy_gate_restores_prior_service_lifecycle() -> None: assert ( 'PREV_ENABLED="$(systemctl is-enabled "$SERVICE.service"' diff --git a/tests/test_echo_sdk_manifest.py b/tests/test_echo_sdk_manifest.py index d3dfe13..a91d3fe 100644 --- a/tests/test_echo_sdk_manifest.py +++ b/tests/test_echo_sdk_manifest.py @@ -13,7 +13,7 @@ def test_echo_sdk_manifest_matches_the_authoritative_contract() -> None: contract = json.loads(contract_path.read_text(encoding="utf-8")) assert manifest["version"] == 1 - assert len(manifest["capabilities"]) == contract["capability_count"] == 60 + assert len(manifest["capabilities"]) == contract["capability_count"] == 62 assert set(manifest["capabilities"]) == set(contract["capabilities"]) assert len(manifest["capabilities"]) == len(set(manifest["capabilities"])) assert all(capability.startswith("echo.") for capability in manifest["capabilities"]) diff --git a/tests/test_evidence_and_verdict.py b/tests/test_evidence_and_verdict.py index 716f488..c616c7c 100644 --- a/tests/test_evidence_and_verdict.py +++ b/tests/test_evidence_and_verdict.py @@ -3,6 +3,7 @@ import json import sqlite3 from contextlib import closing +from datetime import timedelta from pathlib import Path import pytest @@ -13,10 +14,13 @@ ReleaseVerdict, RuleResult, RunOutcome, + EnvironmentIdentity, TargetIdentity, VerdictLifecycleEvent, ) from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from echo_certification_forge.canonical import to_utc_iso, utc_now +from echo_certification_forge.production_e2e import BASE_CHECKS, GENERIC_PROFILE, SCHEMA_VERSION from echo_certification_forge.verdict import DeterministicVerdictEngine @@ -25,8 +29,33 @@ def register(store, manifest, target, environment, run_id="cert-001"): return run_id -def satisfy_all_rules(store, manifest, run_id, tenant_id): +def satisfy_all_rules(store, manifest, run_id, tenant_id, *, include_production_e2e=True): + run = store.get_run(run_id, tenant_id) + target = TargetIdentity(**json.loads(run["target_identity_json"])) + environment = EnvironmentIdentity(**json.loads(run["environment_identity_json"])) + observed_at = utc_now() + production_e2e = { + "schema_version": SCHEMA_VERSION, + "attestation_id": f"e2e-{run_id}", + "profile": GENERIC_PROFILE, + "target_identity_digest": target.identity_digest, + "environment_identity_digest": environment.identity_digest, + "source_commit": target.source_commit, + "deployment_sha": target.source_commit, + "canonical_target": "https://example.test/runtime", + "required_checks": sorted(BASE_CHECKS), + "checks": {name: True for name in sorted(BASE_CHECKS)}, + "stability_probe_count": 3, + "observed_at": to_utc_iso(observed_at), + "expires_at": to_utc_iso(observed_at + timedelta(minutes=30)), + "signing_key_id": "ed25519:" + "a" * 32, + "signature_verified": True, + "collector_key_id": "ed25519:" + "a" * 32, + "attestation_envelope_sha256": "f" * 64, + } for index, rule in enumerate(manifest.rules, start=1): + if rule.id == "production_e2e" and not include_production_e2e: + continue artifact_id = f"evidence-{index:02d}" store.append_artifact( run_id, @@ -40,7 +69,12 @@ def satisfy_all_rules(store, manifest, run_id, tenant_id): store.record_rule_result( run_id, tenant_id, - RuleResult(rule.id, True, (artifact_id,), {"source": "acceptance"}), + RuleResult( + rule.id, + True, + (artifact_id,), + production_e2e if rule.id == "production_e2e" else {"source": "acceptance"}, + ), ) @@ -81,6 +115,68 @@ def test_complete_run_with_verified_evidence_gets_signed_ready(store, manifest, assert gate.allowed +def test_source_and_rule_rows_without_production_e2e_never_get_ready( + store, manifest, target, environment +): + run_id = register(store, manifest, target, environment) + satisfy_all_rules( + store, + manifest, + run_id, + target.tenant_id, + include_production_e2e=False, + ) + store.set_run_outcome(run_id, target.tenant_id, RunOutcome.COMPLETE) + signer = Ed25519VerdictSigner.generate() + decision = DeterministicVerdictEngine().evaluate( + store, run_id, target.tenant_id, manifest, signer.key_id + ) + assert decision.release_verdict is ReleaseVerdict.NOT_READY + assert "production_e2e_attestation_missing" in decision.reasons + + +def test_failed_executor_rule_preserves_missing_attestation_reason( + store, manifest, target, environment +): + run_id = register(store, manifest, target, environment) + for index, rule in enumerate(manifest.rules, start=1): + artifact_id = f"executor-evidence-{index:02d}" + store.append_artifact( + run_id, + target.tenant_id, + artifact_id, + json.dumps({"rule": rule.id}, sort_keys=True).encode(), + "application/json", + "executor-regression", + RedactionStatus.COMPLETE, + ) + is_production_e2e = rule.id == "production_e2e" + store.record_rule_result( + run_id, + target.tenant_id, + RuleResult( + rule.id, + not is_production_e2e, + (artifact_id,), + ( + {"validation": "production_e2e_attestation_missing"} + if is_production_e2e + else {"source": "executor-regression"} + ), + ), + ) + store.set_run_outcome(run_id, target.tenant_id, RunOutcome.COMPLETE) + + signer = Ed25519VerdictSigner.generate() + decision = DeterministicVerdictEngine().evaluate( + store, run_id, target.tenant_id, manifest, signer.key_id + ) + + assert decision.release_verdict is ReleaseVerdict.NOT_READY + assert "production_e2e_attestation_missing" in decision.reasons + assert "production_e2e_schema_invalid" not in decision.reasons + + def test_bare_database_rows_cannot_manufacture_ready(store, manifest, target, environment): run_id = register(store, manifest, target, environment) now = "2026-07-16T00:00:00Z" diff --git a/tests/test_intake_recovery_isolation.py b/tests/test_intake_recovery_isolation.py new file mode 100644 index 0000000..1668d1a --- /dev/null +++ b/tests/test_intake_recovery_isolation.py @@ -0,0 +1,242 @@ +"""A stale tenant's crash gap cannot prevent independent work from dispatching.""" +import sqlite3 +from dataclasses import replace + +import pytest + +from echo_certification_forge.dispatch_worker import recover_pending_intake +from echo_certification_forge.intake import SubmitRequest +from echo_certification_forge.run_worker import _worker_environment +from echo_certification_forge.subscriber import Permission +from test_p7_subscriber_governance import _stack, _provision, _headers, _owner + + +def reserve(governance, client, manifest, tmp_path, label): + org = _provision(governance, label) + reference = (tmp_path / label).resolve().as_posix() + project = client.post('/v1/subscriber/projects', headers=_headers(org.organization_id, org.bootstrap_api_key), + json={'slug': label, 'name': label, 'target_reference': reference}).json() + environment = _worker_environment(manifest=manifest) + request = SubmitRequest.model_validate({ + 'tenant_id': org.organization_id, 'project_id': project['project_id'], + 'target': {'target_type': 'local', 'identity_digest': 'a' * 64, 'reference': reference, 'path': reference}, + 'environment': {'identity_digest': environment.identity_digest, 'runner_image_digest': 'sha256:' + environment.runner_image_sha256}, + 'policy_version': manifest.manifest_id, 'idempotency_key': label + '-request', + }) + principal = _owner(governance, org.organization_id, org.bootstrap_api_key, Permission.RUN_CREATE) + reservation = governance.reserve_certification_run( + principal, project_id=request.project_id, idempotency_key=request.idempotency_key, + request_digest=request.request_digest(), policy_version=request.policy_version, + target_type=request.target.target_type, target_reference=request.target.reference, + target_identity_digest=request.target.identity_digest, dispatch_target_spec=request.target.worker_spec(), + journey=request.journey, submit_request=request.model_dump(exclude_none=True), + ) + return org, request, reservation + + +@pytest.mark.parametrize('failure', ['archived_project', 'invalid_request', 'invalid_target_shape']) +def test_bad_intake_is_released_once_and_other_tenant_recovers(tmp_path, manifest, failure): + store, governance, client = _stack(tmp_path, manifest) + bad_org, bad_request, bad = reserve(governance, client, manifest, tmp_path, 'bad-first') + good_org, good_request, good = reserve(governance, client, manifest, tmp_path, 'good-second') + with sqlite3.connect(store.db_path) as db: + if failure == 'archived_project': + db.execute("UPDATE subscriber_projects SET status='ARCHIVED' WHERE project_id=?", (bad_request.project_id,)) + elif failure == 'invalid_request': + db.execute("UPDATE subscriber_run_reservations SET submit_request_json='{}' WHERE organization_id=?", (bad_org.organization_id,)) + else: + db.execute("UPDATE subscriber_run_reservations SET dispatch_target_json='[]' WHERE organization_id=?", (bad_org.organization_id,)) + assert recover_pending_intake(governance, store, manifest) == 1 + assert recover_pending_intake(governance, store, manifest) == 0 + assert store.get_run(good_request.run_id(manifest.digest), good_org.organization_id)['state'] == 'QUEUED' + assert governance.pending_intake_requests() == [] + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT state FROM subscriber_run_reservations WHERE organization_id=?', (bad_org.organization_id,)).fetchone() == ('RELEASED',) + assert db.execute("SELECT count(*),sum(quantity) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0", (bad_org.organization_id,)).fetchone() == (1, -1) + assert governance.claim_dispatch('healthy-dispatcher', organization_id=good_org.organization_id) is not None + + +def test_recovery_leaves_other_policy_and_concurrent_materialization_owned(tmp_path, manifest): + store, governance, client = _stack(tmp_path, manifest) + org, request, reservation = reserve(governance, client, manifest, tmp_path, 'other-policy') + with sqlite3.connect(store.db_path) as db: + db.execute("UPDATE subscriber_run_reservations SET policy_version='different-policy' WHERE organization_id=?", (org.organization_id,)) + assert recover_pending_intake(governance, store, manifest) == 0 + assert len(governance.pending_intake_requests()) == 1 + with sqlite3.connect(store.db_path) as db: + db.execute('UPDATE subscriber_run_reservations SET policy_version=? WHERE organization_id=?', (manifest.manifest_id, org.organization_id)) + assert recover_pending_intake(governance, store, manifest) == 1 + governance.release_reservation(reservation, reason='pending_intake_invalid', compensate_meter=True, only_reserved=True) + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT state FROM subscriber_run_reservations WHERE organization_id=?', (org.organization_id,)).fetchone() == ('BOUND',) + assert db.execute('SELECT count(*) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0', (org.organization_id,)).fetchone() == (0,) + + +def test_recovery_release_requires_the_observed_request_digest(tmp_path, manifest): + store, governance, client = _stack(tmp_path, manifest) + org, _, reservation = reserve(governance, client, manifest, tmp_path, 'digest-fence') + governance.release_reservation(replace(reservation, request_digest='b'*64), reason='pending_intake_invalid', compensate_meter=True, only_reserved=True) + assert len(governance.pending_intake_requests()) == 1 + + +def test_operator_runtime_mismatch_preserves_queued_work_and_retry_budget_before_acquisition(tmp_path, manifest, monkeypatch): + from echo_certification_forge import run_worker + from echo_certification_forge.dispatch_worker import dispatch_once + from echo_certification_forge.runtime_identity import RuntimeProfile + from echo_certification_forge.sandbox import DockerSandbox + from echo_certification_forge.signing import Ed25519VerdictSigner + store, governance, client = _stack(tmp_path, manifest) + org, request, reservation = reserve(governance, client, manifest, tmp_path, 'runtime-mismatch') + profile = RuntimeProfile(DockerSandbox(image='sha256:' + 'a' * 64)) + monkeypatch.setattr(run_worker, 'acquire_target', lambda *a, **k: pytest.fail('mismatched runtime must not acquire')) + for attempt in range(governance.policy.max_dispatch_attempts + 2): + dispatch, result = dispatch_once(governance, run_worker.run, dispatcher_id='mismatch-dispatcher', + worker_options={'store': store, 'manifest': manifest, 'signer': Ed25519VerdictSigner.generate(), + 'subscribers': governance, 'sandbox': replace(profile.sandbox, memory='5g'), 'runtime_profile': profile}) + assert result['error'] == 'runtime_operator_profile_mismatch' + assert result['dispatcher_halted'] is True + assert result['retry_budget_consumed'] is False + assert governance.dispatch_status(dispatch.run_id, org.organization_id)['state'] == 'PENDING' + assert governance.dispatch_status(dispatch.run_id, org.organization_id)['attempts'] == 0 + run_id = request.run_id(manifest.digest) + assert store.get_run(run_id, org.organization_id)['state'] == 'QUEUED' + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT state FROM subscriber_run_reservations WHERE organization_id=?', (org.organization_id,)).fetchone() == ('BOUND',) + assert db.execute('SELECT count(*) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0', (org.organization_id,)).fetchone() == (0,) + # A stale claim cannot undo the next dispatcher's ownership or attempts. + next_claim = governance.claim_dispatch('next-dispatcher', organization_id=org.organization_id) + from echo_certification_forge.subscriber import SubscriberError + with pytest.raises(SubscriberError, match='dispatch_claim_inactive'): + governance.defer_dispatch_runtime_failure(dispatch, error='runtime_identity_unavailable') + assert governance.dispatch_status(run_id, org.organization_id)['attempts'] == 1 + + +def test_job_specific_failure_exhaustion_compensates_once(tmp_path, manifest): + from echo_certification_forge.dispatch_worker import dispatch_once + from echo_certification_forge.signing import Ed25519VerdictSigner + store, governance, client = _stack(tmp_path, manifest) + org, request, _ = reserve(governance, client, manifest, tmp_path, 'specific-failure') + for _ in range(governance.policy.max_dispatch_attempts): + dispatch_once(governance, lambda *a, **k: {'error': 'target_unavailable'}, dispatcher_id='job-dispatcher', + worker_options={'store': store, 'manifest': manifest, 'signer': Ed25519VerdictSigner.generate(), 'subscribers': governance}) + assert store.get_run(request.run_id(manifest.digest), org.organization_id)['state'] == 'INFRASTRUCTURE_FAILURE' + assert governance.claim_dispatch('after-terminal', organization_id=org.organization_id) is None + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT state FROM subscriber_run_reservations WHERE organization_id=?', (org.organization_id,)).fetchone() == ('RELEASED',) + assert db.execute('SELECT count(*),sum(quantity) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0', (org.organization_id,)).fetchone() == (1, -1) + + +def test_api_error_cannot_refund_concurrently_claimed_recovery(tmp_path, manifest, monkeypatch): + from echo_certification_forge.signing import Ed25519VerdictSigner + from echo_certification_forge.subscriber import SubscriberError + store, governance, client = _stack(tmp_path, manifest) + org, request, initial = reserve(governance, client, manifest, tmp_path, 'api-race') + governance.release_reservation(initial, reason='fixture_setup', compensate_meter=True) + # Use a fresh idempotency key so the API owns its newly created reservation. + request = request.model_copy(update={'idempotency_key': 'api-race-new-request'}) + materialize = governance.materialize_reserved_run + def race(reservation, **kwargs): + materialize(reservation, **kwargs) + governance.claim_worker_run(run_id=kwargs['run_id'], organization_id=org.organization_id, + policy_version=manifest.manifest_id, target_type='local', target_reference=request.target.reference, + worker_id=None, worker_attestation_sha256=None, execution_location='local', + signing_authority='platform', signing_key_id=Ed25519VerdictSigner.generate().key_id) + raise SubscriberError(409, 'run_materialization_state_conflict') + monkeypatch.setattr(governance, 'materialize_reserved_run', race) + response = client.post('/v1/certifications', headers=_headers(org.organization_id, org.bootstrap_api_key), + json=request.model_dump(exclude_none=True)) + assert response.status_code == 409 + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT state FROM subscriber_run_reservations WHERE run_id=?', (request.run_id(manifest.digest),)).fetchone() == ('EXECUTING',) + assert db.execute('SELECT count(*) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0', (org.organization_id,)).fetchone() == (1,) + + +def test_dispatcher_main_exits_on_circuit_open_before_next_claim(tmp_path, monkeypatch): + from echo_certification_forge import dispatch_worker + from echo_certification_forge.signing import Ed25519VerdictSigner + calls = [] + def dispatch(*args, **kwargs): + calls.append(True) + assert len(calls) == 1, 'circuit must stop before claiming another run' + return object(), {'error': 'runtime_identity_unavailable', 'dispatcher_halted': True} + monkeypatch.setenv('ECHO_CERTFORGE_API_KEY_PEPPER', 'synthetic-isolated-circuit-test-pepper') + monkeypatch.setattr(dispatch_worker, '_load_signer', lambda _: Ed25519VerdictSigner.generate()) + monkeypatch.setattr(dispatch_worker, 'dispatch_once', dispatch) + result = dispatch_worker.main(['--non-production-compat', '--db', str(tmp_path/'isolated.sqlite3'), + '--evidence-root', str(tmp_path/'evidence')]) + assert result == 2 and len(calls) == 1 + + +def test_tenant_runtime_mismatch_delays_retries_and_other_tenant_progresses(tmp_path, manifest, monkeypatch): + from datetime import timedelta + from echo_certification_forge import run_worker + from echo_certification_forge.dispatch_worker import dispatch_once + from echo_certification_forge.runtime_identity import RuntimeProfile + from echo_certification_forge.sandbox import DockerSandbox + from echo_certification_forge.signing import Ed25519VerdictSigner + store, governance, client = _stack(tmp_path, manifest) + org, request, _ = reserve(governance, client, manifest, tmp_path, 'tenant-mismatch') + other, _, _ = reserve(governance, client, manifest, tmp_path, 'tenant-other') + profile = RuntimeProfile(DockerSandbox(image='sha256:'+'a'*64)) + clock = [governance._now()] + monkeypatch.setattr(governance, '_now', lambda: clock[0]) + monkeypatch.setattr(run_worker, 'acquire_target', lambda *a, **k: pytest.fail('bad declaration must not acquire')) + for attempt in range(governance.policy.max_dispatch_attempts): + dispatch, result = dispatch_once(governance, run_worker.run, dispatcher_id='tenant-delay', organization_id=org.organization_id, + worker_options={'store':store,'manifest':manifest,'subscribers':governance, + 'signer':Ed25519VerdictSigner.generate(),'sandbox':profile.sandbox,'runtime_profile':profile}) + assert result['error'] == 'runtime_environment_mismatch' + assert not result.get('dispatcher_halted') + assert governance.claim_dispatch('immediate-retry', organization_id=org.organization_id) is None + if attempt == 0: + assert governance.claim_dispatch('other-progress', organization_id=other.organization_id) is not None + clock[0] += timedelta(seconds=61) + assert store.get_run(request.run_id(manifest.digest), org.organization_id)['state'] == 'INFRASTRUCTURE_FAILURE' + with sqlite3.connect(store.db_path) as db: + assert db.execute('SELECT count(*) FROM subscriber_usage_events WHERE organization_id=? AND quantity<0', (org.organization_id,)).fetchone() == (1,) + + +def test_post_claim_measurement_failure_halts_cleanly_without_reopening_terminal_run(tmp_path, manifest, monkeypatch): + from echo_certification_forge import run_worker + from echo_certification_forge.dispatch_worker import dispatch_once + from echo_certification_forge.signing import Ed25519VerdictSigner + from test_p7_subscriber_governance import _submit_local_run + store, governance, client = _stack(tmp_path, manifest) + org = _provision(governance, 'post-claim-runtime', plan_code='professional') + source = tmp_path/'source';source.mkdir();(source/'README').write_text('trusted synthetic fixture') + run_id, _ = _submit_local_run(client,manifest,org.organization_id,org.bootstrap_api_key,source,key='post-claim-runtime-request') + acquire = run_worker.acquire_target + def after_acquisition(*args): + acquired = acquire(*args) + def unavailable(*a, **k):raise ValueError('runtime_metadata_unavailable') + monkeypatch.setattr(run_worker, '_worker_environment', unavailable) + return acquired + monkeypatch.setattr(run_worker,'acquire_target',after_acquisition) + _, result = dispatch_once(governance,run_worker.run,dispatcher_id='post-claim-runtime', + worker_options={'store':store,'manifest':manifest,'subscribers':governance,'signer':Ed25519VerdictSigner.generate()}) + assert result['dispatcher_halted'] is True + assert store.get_run(run_id,org.organization_id)['state']=='INFRASTRUCTURE_FAILURE' + assert governance.dispatch_status(run_id,org.organization_id)['state']=='FAILED' + + +@pytest.mark.parametrize('journey', [[], [''], ['x\x00y'], ['x'*2049]]) +def test_invalid_journey_is_rejected_at_intake_and_classified_per_job_in_compat(tmp_path, manifest, monkeypatch, journey): + from pydantic import ValidationError + from echo_certification_forge import run_worker + from echo_certification_forge.dispatch_worker import dispatch_once + from echo_certification_forge.signing import Ed25519VerdictSigner + store, governance, client = _stack(tmp_path, manifest) + org, request, _ = reserve(governance, client, manifest, tmp_path, 'invalid-journey') + payload = request.model_dump(exclude_none=True);payload['journey']=journey + with pytest.raises(ValidationError):SubmitRequest.model_validate(payload) + assert recover_pending_intake(governance,store,manifest)==1 + # Emulate an older persisted dispatch that predates item validation. + import json + with sqlite3.connect(store.db_path) as db: + db.execute('UPDATE subscriber_run_dispatches SET journey_json=? WHERE organization_id=?',(json.dumps(journey),org.organization_id)) + monkeypatch.setattr(run_worker,'acquire_target',lambda *a,**k:pytest.fail('invalid argv must not acquire')) + _,result=dispatch_once(governance,run_worker.run,dispatcher_id='compat-invalid', + worker_options={'store':store,'manifest':manifest,'subscribers':governance,'signer':Ed25519VerdictSigner.generate()}) + assert result['error']=='runtime_profile_mismatch' + assert not result.get('dispatcher_halted') + assert governance.claim_dispatch('immediate-compat-retry',organization_id=org.organization_id) is None diff --git a/tests/test_journey_image_admission.py b/tests/test_journey_image_admission.py new file mode 100644 index 0000000..323cecc --- /dev/null +++ b/tests/test_journey_image_admission.py @@ -0,0 +1,406 @@ +"""Synthetic keys are local fixtures only; no production key or Docker access.""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import stat +from types import SimpleNamespace +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pydantic import ValidationError + +from echo_certification_forge.journey_image_admission import ( + JourneyQualification, prepare_admission_request, proposed_policy, seal_admission_request, +) +from echo_certification_forge.supply_chain import ( + ImageAdmissionDenied, ImageAdmissionPolicy, ImageAttestationAuthority, + ImageIdentity, ImageRole, build_spdx_document, evaluate_image_admission, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def encoded(value): + return (json.dumps(value, sort_keys=True) + "\n").encode() + + +@pytest.fixture +def prepared(): + now = datetime.now(UTC) + image = "sha256:" + "2" * 64 + source = "3" * 40 + material = { + "dockerfile": ("FROM python@sha256:" + "4" * 64 + "\nUSER 65534:65534\n").encode(), + "lockfile": encoded({"source_commit": source}), + "sbom": encoded(build_spdx_document(image_name="synthetic", image_digest=image, created_at=now, + packages=[{"name":"nodejs","version":"24.19.0"},{"name":"CPython","version":"3.11.17"}])), + "provenance": encoded({"source_commit":source}), + } + identity = ImageIdentity(role=ImageRole.RUNNER, image_digest=image, + base_image_digest="sha256:"+"4"*64,source_commit=source, + **{name+"_sha256":hashlib.sha256(raw).hexdigest() for name,raw in material.items()}, + architecture="amd64",operating_system="linux",created_at=now) + authority = ImageAttestationAuthority.generate() + prior_identity = identity.model_copy(update={"image_digest":"sha256:"+"1"*64}) + prior_attestation = authority.sign(prior_identity,issued_at=now-timedelta(hours=1),valid_for=timedelta(days=14)) + policy = ImageAdmissionPolicy(policy_id="p4.runner.production.v1", required_role=ImageRole.RUNNER, + expected_image_digest=prior_identity.image_digest,approved_base_digests=(identity.base_image_digest,), + approved_source_commits=(source,),approved_dockerfile_sha256=identity.dockerfile_sha256, + approved_lockfile_sha256=identity.lockfile_sha256,trusted_attestation_keys={authority.key_id:authority.public_key_pem}, + revoked_image_digests=("sha256:"+"9"*64,),compromised_key_ids=("historical-compromised-fixture",)) + qualification = JourneyQualification(identity_digest=identity.digest, + first_build_receipt_id='11111111-1111-1111-1111-111111111111', + second_build_receipt_id='22222222-2222-2222-2222-222222222222', + archive_sha256="5"*64,independent_archive_sha256="6"*64, + normalized_first_sha256="7"*64,normalized_second_sha256="7"*64, + scanner_report_sha256="8"*64,node_core_report_sha256="a"*64,runtime_probe_sha256="b"*64, + critical_count=0,fixed_high_or_critical_count=0,secret_count=0,node_core_finding_count=0, + node_version="24.19.0",python_version="3.11.17",uid=65534,read_only_root=True, + network_disabled=True,utf8=True,observed_at=now) + kwargs=dict(identity=identity,qualification=qualification,prior_policy=policy, + prior_attestation=prior_attestation,**material,now=now) + return kwargs,authority + + +def test_owner_seal_retains_trust_and_does_not_mutate_old_policy(prepared): + kwargs,authority=prepared + before=kwargs['prior_policy'].model_dump_json() + request=prepare_admission_request(**kwargs) + proposed=proposed_policy(request,now=kwargs['now']) + assert proposed.policy_id != kwargs['prior_policy'].policy_id + assert proposed.trusted_attestation_keys == kwargs['prior_policy'].trusted_attestation_keys + assert proposed.revoked_image_digests == kwargs['prior_policy'].revoked_image_digests + assert proposed.compromised_key_ids == kwargs['prior_policy'].compromised_key_ids + assert request.state == 'UNSIGNED_NOT_ADMITTED' + signed,policy=seal_admission_request(request,authority=authority,approved_request_sha256=request.digest, + current_policy=kwargs['prior_policy'],current_attestation=kwargs['prior_attestation'],now=kwargs['now']) + assert evaluate_image_admission(signed,policy,now=kwargs['now']).allowed + assert not evaluate_image_admission(signed,kwargs['prior_policy'],now=kwargs['now']).allowed + assert kwargs['prior_policy'].model_dump_json() == before + + +@pytest.mark.parametrize('field',['dockerfile','lockfile','sbom','provenance']) +def test_material_drift_rejected(prepared,field): + kwargs,_=prepared + kwargs[field]+=b' ' + with pytest.raises(ImageAdmissionDenied,match=field+'_hash_mismatch'): + prepare_admission_request(**kwargs) + + +@pytest.mark.parametrize('field,value',[ + ('critical_count',1),('fixed_high_or_critical_count',1),('secret_count',1), + ('node_core_finding_count',1),('critical_count',False),('secret_count','0'), + ('read_only_root',1),('read_only_root',False),('network_disabled',False),('utf8',False), + ('normalized_second_sha256','c'*64),('second_build_receipt_id','11111111-1111-1111-1111-111111111111'), + ('node_version','24.18.0'),('python_version','3.12.1'),('uid',0), +]) +def test_failed_or_coerced_qualification_refused(prepared,field,value): + kwargs,_=prepared + payload=kwargs['qualification'].model_dump() + payload[field]=value + with pytest.raises(ValidationError): JourneyQualification.model_validate(payload) + + +@pytest.mark.parametrize('node,python', [ + ('24.19.0', '3.12.16'), ('24.22.1', '3.11.17'), + ('24.22.1', '3.12.15'), ('24.22.1', '3.12.17'), ('24.20.0', '3.12.16'), +]) +def test_runtime_pair_rejects_cross_pair_and_unreviewed_patch(prepared, node, python): + kwargs, _ = prepared + data = kwargs['qualification'].model_dump() + data.update(node_version=node, python_version=python) + with pytest.raises(ValidationError): + JourneyQualification.model_validate(data) + + +def test_ecf_pair_requires_matching_actual_sbom(prepared): + kwargs, _ = prepared + kwargs['qualification'] = kwargs['qualification'].model_copy( + update={'node_version': '24.22.1', 'python_version': '3.12.16'}) + with pytest.raises(ImageAdmissionDenied, match='runtime_packages_missing_from_sbom'): + prepare_admission_request(**kwargs) + + +def test_ecf_pair_seals_only_with_bound_material_and_existing_authority(prepared): + kwargs, authority = prepared + kwargs['sbom'] = encoded(build_spdx_document( + image_name='synthetic-ecf', image_digest=kwargs['identity'].image_digest, + created_at=kwargs['now'], packages=[{'name': 'nodejs', 'version': '24.22.1'}, + {'name': 'CPython', 'version': '3.12.16'}])) + kwargs['identity'] = kwargs['identity'].model_copy( + update={'sbom_sha256': hashlib.sha256(kwargs['sbom']).hexdigest()}) + kwargs['qualification'] = kwargs['qualification'].model_copy(update={ + 'node_version': '24.22.1', 'python_version': '3.12.16', + 'identity_digest': kwargs['identity'].digest}) + prior = kwargs['prior_policy'].model_dump_json() + request = prepare_admission_request(**kwargs) + signed, policy = seal_admission_request( + request, authority=authority, approved_request_sha256=request.digest, + current_policy=kwargs['prior_policy'], current_attestation=kwargs['prior_attestation'], + now=kwargs['now']) + assert signed.identity == kwargs['identity'] + assert evaluate_image_admission(signed, policy, now=kwargs['now']).allowed + assert policy.trusted_attestation_keys == kwargs['prior_policy'].trusted_attestation_keys + assert kwargs['prior_policy'].model_dump_json() == prior + # A supported runtime pair cannot bypass the existing security evidence gate. + kwargs['qualification'] = kwargs['qualification'].model_copy(update={'node_core_finding_count': 1}) + with pytest.raises(ValidationError, match='image_security_policy_failed'): + prepare_admission_request(**kwargs) + + +@pytest.mark.parametrize('extra', [ + {'name': 'nodejs', 'version': '24.19.0'}, + {'name': 'CPython', 'version': '3.11.17'}, + {'name': 'nodejs', 'version': '24.22.1'}, +]) +def test_ecf_pair_rejects_ambiguous_runtime_inventory(prepared, extra): + kwargs, _ = prepared + kwargs['sbom'] = encoded(build_spdx_document( + image_name='synthetic-ecf', image_digest=kwargs['identity'].image_digest, + created_at=kwargs['now'], packages=[{'name': 'nodejs', 'version': '24.22.1'}, + {'name': 'CPython', 'version': '3.12.16'}, extra])) + kwargs['identity'] = kwargs['identity'].model_copy( + update={'sbom_sha256': hashlib.sha256(kwargs['sbom']).hexdigest()}) + kwargs['qualification'] = kwargs['qualification'].model_copy(update={ + 'node_version': '24.22.1', 'python_version': '3.12.16', + 'identity_digest': kwargs['identity'].digest}) + with pytest.raises(ImageAdmissionDenied, match='ambiguous_runtime_packages'): + prepare_admission_request(**kwargs) + + +def test_legacy_qualification_serialization_retained(): + # Golden digest was measured using the unmodified 15a41b0 source. + data = {name: char * 64 for name, char in [ + ('identity_digest', '1'), ('archive_sha256', '2'), ('independent_archive_sha256', '3'), + ('normalized_first_sha256', '4'), ('normalized_second_sha256', '4'), + ('scanner_report_sha256', '5'), ('node_core_report_sha256', '6'), ('runtime_probe_sha256', '7')]} + data.update(first_build_receipt_id='11111111-1111-1111-1111-111111111111', + second_build_receipt_id='22222222-2222-2222-2222-222222222222', + critical_count=0, fixed_high_or_critical_count=0, secret_count=0, + node_core_finding_count=0, node_version='24.19.0', python_version='3.11.17', + uid=65534, read_only_root=True, network_disabled=True, utf8=True, + observed_at=datetime(2026, 10, 27, tzinfo=UTC)) + raw = JourneyQualification.model_validate(data).model_dump_json() + assert hashlib.sha256(raw.encode()).hexdigest() == '5ef44f4648e2698f66b33e496c4ca11ef28eba72863cd3bb0f143ab832323a90' + + +@pytest.mark.parametrize('change',['unknown_key','expired','revoked','stale','future','wrong_identity']) +def test_trust_and_identity_fail_before_signing(prepared,change): + kwargs,authority=prepared + if change=='unknown_key': kwargs['prior_policy']=kwargs['prior_policy'].model_copy(update={'trusted_attestation_keys':{}}) + elif change=='expired': + kwargs['now']+=timedelta(days=15) + kwargs['qualification']=kwargs['qualification'].model_copy(update={'observed_at':kwargs['now']}) + elif change=='revoked': kwargs['prior_policy']=kwargs['prior_policy'].model_copy(update={'revoked_image_digests':(kwargs['identity'].image_digest,)}) + elif change=='stale': kwargs['now']+=timedelta(hours=25) + elif change=='future': kwargs['now']-=timedelta(minutes=1) + else: kwargs['qualification']=kwargs['qualification'].model_copy(update={'identity_digest':'f'*64}) + authority.sign=Mock(side_effect=AssertionError('must never sign')) + with pytest.raises((ImageAdmissionDenied,ValidationError)): prepare_admission_request(**kwargs) + authority.sign.assert_not_called() + + +def test_invalid_model_copy_does_not_bypass_qualification(prepared): + kwargs,_=prepared + kwargs['qualification']=kwargs['qualification'].model_copy(update={'critical_count':1}) + with pytest.raises(ValidationError): prepare_admission_request(**kwargs) + + +@pytest.mark.parametrize('change',['changed_review','new_authority','too_long','zero_lifetime']) +def test_seal_requires_exact_review_and_existing_authority(prepared,change): + kwargs,authority=prepared + request=prepare_admission_request(**kwargs) + if change=='new_authority': authority=ImageAttestationAuthority.generate() + authority.sign=Mock(side_effect=AssertionError('must never sign')) + options={'approved_request_sha256':request.digest,'valid_for':timedelta(days=7), + 'current_policy':kwargs['prior_policy'],'current_attestation':kwargs['prior_attestation']} + if change=='changed_review': options['approved_request_sha256']='f'*64 + elif change=='too_long': options['valid_for']=timedelta(days=20) + elif change=='zero_lifetime': options['valid_for']=timedelta(0) + with pytest.raises(ImageAdmissionDenied): seal_admission_request(request,authority=authority,now=kwargs['now'],**options) + authority.sign.assert_not_called() + + +def test_changed_signer_output_refused(prepared): + kwargs,authority=prepared + request=prepare_admission_request(**kwargs) + authority.sign=Mock(return_value=kwargs['prior_attestation']) + with pytest.raises(ImageAdmissionDenied,match='signed_candidate_not_admitted'): + seal_admission_request(request,authority=authority,approved_request_sha256=request.digest, + current_policy=kwargs['prior_policy'],current_attestation=kwargs['prior_attestation'],now=kwargs['now']) + + +def test_cli_new_outputs_only_and_safe_error(prepared,tmp_path): + kwargs,_=prepared + packet=tmp_path/'packet';packet.mkdir() + for file,field in [('Dockerfile','dockerfile'),('journey-image-inputs.lock.json','lockfile'), + ('runner.spdx.json','sbom'),('runner.provenance.json','provenance')]: + (packet/file).write_bytes(kwargs[field]) + for file,field in [('runner.identity.UNSIGNED.json','identity'),('qualification.json','qualification'), + ('policy.json','prior_policy'),('attestation.json','prior_attestation')]: + (packet/file).write_text(kwargs[field].model_dump_json(),encoding='utf-8') + out=tmp_path/'new-output' + command=[sys.executable,str(ROOT/'scripts/prepare_journey_image_admission.py'), + '--packet',str(packet),'--qualification',str(packet/'qualification.json'), + '--dockerfile',str(packet/'Dockerfile'),'--prior-policy',str(packet/'policy.json'), + '--prior-attestation',str(packet/'attestation.json'),'--expected-source',kwargs['identity'].source_commit, + '--expected-image',kwargs['identity'].image_digest,'--expected-policy-sha256', + hashlib.sha256((packet/'policy.json').read_bytes()).hexdigest(),'--output',str(out)] + result=subprocess.run(command,capture_output=True,text=True,timeout=15) + assert result.returncode==0,result.stderr + receipt=json.loads(result.stdout) + assert receipt['signed'] is False and receipt['state']=='UNSIGNED_NOT_ADMITTED' + before={p.name:p.read_bytes() for p in out.iterdir()} + repeated=subprocess.run(command,capture_output=True,text=True,timeout=15) + assert repeated.returncode==1 and not repeated.stderr + assert {p.name:p.read_bytes() for p in out.iterdir()}==before + (packet/'qualification.json').write_text('{"private":"synthetic-sensitive-marker"}',encoding='utf-8') + failed=subprocess.run(command,capture_output=True,text=True,timeout=15) + assert failed.returncode==1 and 'synthetic-sensitive-marker' not in failed.stdout+failed.stderr + + +def test_reader_rejects_oversize_and_reparse(tmp_path): + spec=importlib.util.spec_from_file_location('preparer',ROOT/'scripts/prepare_journey_image_admission.py') + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + huge=tmp_path/'huge';huge.write_bytes(b'x'*(4*1024*1024+1)) + with pytest.raises(ImageAdmissionDenied,match='input_type_or_size_refused'): module.read_bounded(huge) + link=tmp_path/'link' + try: link.symlink_to(huge) + except OSError: pytest.skip('host does not admit synthetic symlink creation') + with pytest.raises(ImageAdmissionDenied,match='reparse_path_refused'): module.read_bounded(link) + + +@pytest.mark.parametrize('field,value,category',[ + ('lockfile',[], 'material_object_required'), + ('provenance',{'source_commit':'f'*40}, 'material_source_mismatch'), + ('sbom',[], 'sbom_invalid'), +]) +def test_rehashed_malformed_material_is_not_accepted(prepared,field,value,category): + kwargs,_=prepared + kwargs[field]=encoded(value) + kwargs['identity']=kwargs['identity'].model_copy(update={field+'_sha256':hashlib.sha256(kwargs[field]).hexdigest()}) + kwargs['qualification']=kwargs['qualification'].model_copy(update={'identity_digest':kwargs['identity'].digest}) + with pytest.raises(ImageAdmissionDenied,match=category): prepare_admission_request(**kwargs) + + +@pytest.mark.parametrize('invalid',['none','review','custody','output','revoked_before','revoked_during_key_load']) +def test_owner_wrapper_uses_only_existing_injected_fixture_key(prepared,tmp_path,monkeypatch,capsys,invalid): + kwargs,authority=prepared + request=prepare_admission_request(**kwargs) + request_file=tmp_path/'request.json';request_file.write_text(request.model_dump_json(),encoding='utf-8') + current_policy=tmp_path/'current-policy.json' + current_attestation=tmp_path/'current-attestation.json' + current_policy.write_text(kwargs['prior_policy'].model_dump_json(),encoding='utf-8') + current_attestation.write_text(kwargs['prior_attestation'].model_dump_json(),encoding='utf-8') + output=tmp_path/'sealed' + if invalid=='output': output.mkdir() + keypath=tmp_path/'test-key-placeholder' + keypath.write_text('No private key bytes in this fixture',encoding='utf-8') + monkeypatch.syspath_prepend(str(ROOT/'scripts')) + spec=importlib.util.spec_from_file_location('journey_sealer',ROOT/'scripts/seal_journey_image.py') + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + original_path=module.plain_path + info=SimpleNamespace(st_mode=stat.S_IFREG|(0o644 if invalid=='custody' else 0o600),st_uid=1000,st_size=100) + monkeypatch.setattr(module,'plain_path',lambda p:SimpleNamespace(stat=lambda:info) if p==keypath else original_path(p)) + monkeypatch.setattr(module,'os',SimpleNamespace(name='posix',geteuid=lambda:1000)) + loader=Mock(return_value=authority._private_key) + def revoke_during_load(path, **unused): + changed=kwargs['prior_policy'].model_copy(update={'revoked_image_digests':(request.identity.image_digest,)}) + current_policy.write_text(changed.model_dump_json(),encoding='utf-8') + return authority._private_key + if invalid=='revoked_before': revoke_during_load(None) + elif invalid=='revoked_during_key_load': loader.side_effect=revoke_during_load + if invalid.startswith('revoked_'): + authority.sign=Mock(side_effect=AssertionError('must not sign after authority drift')) + monkeypatch.setattr(module,'ImageAttestationAuthority',lambda key:authority) + monkeypatch.setattr(module,'load_private_key',loader) + monkeypatch.setattr(sys,'argv',['seal','--request',str(request_file), + '--approved-request-sha256','0'*64 if invalid=='review' else request.digest, + '--attestation-private-key',str(keypath),'--current-policy',str(current_policy), + '--current-attestation',str(current_attestation),'--output-dir',str(output)]) + code=module.main() + printed=capsys.readouterr() + assert not printed.err and 'PRIVATE KEY' not in printed.out + if invalid=='none': + assert code==0 and json.loads(printed.out)['state']=='SEALED_NOT_INSTALLED' + loader.assert_called_once() + assert (output/'sealing-receipt.json').is_file() + else: + assert code==1 and json.loads(printed.out)['state']=='NOT_READY' + if invalid=='revoked_during_key_load': loader.assert_called_once() + else: loader.assert_not_called() + if invalid.startswith('revoked_'): authority.sign.assert_not_called() + assert not (output/'sealing-receipt.json').exists() + + +def test_bit_identical_archives_are_valid_with_distinct_observed_receipts(prepared): + kwargs,_=prepared + data=kwargs['qualification'].model_dump() + data['independent_archive_sha256']=data['archive_sha256'] + kwargs['qualification']=JourneyQualification.model_validate(data) + assert prepare_admission_request(**kwargs).state=='UNSIGNED_NOT_ADMITTED' + + +@pytest.mark.parametrize('drift',['revoked_image','compromised_key','removed_trust','changed_attestation']) +def test_current_authority_drift_blocks_before_sign(prepared,drift): + kwargs,authority=prepared + request=prepare_admission_request(**kwargs) + policy=kwargs['prior_policy'];attestation=kwargs['prior_attestation'] + if drift=='revoked_image': policy=policy.model_copy(update={'revoked_image_digests':(request.identity.image_digest,)}) + elif drift=='compromised_key': policy=policy.model_copy(update={'compromised_key_ids':(authority.key_id,)}) + elif drift=='removed_trust': policy=policy.model_copy(update={'trusted_attestation_keys':{}}) + else: attestation=attestation.model_copy(update={'signature_b64':'changed-fixture-signature'}) + authority.sign=Mock(side_effect=AssertionError('must not sign')) + with pytest.raises(ImageAdmissionDenied,match='current_authority_changed_since_review'): + seal_admission_request(request,authority=authority,approved_request_sha256=request.digest, + current_policy=policy,current_attestation=attestation,now=kwargs['now']) + authority.sign.assert_not_called() + + +@pytest.mark.skipif(os.name != 'posix', reason='Strict sealing requires POSIX descriptor custody; Windows stat/fstat ctime differ') +def test_strict_key_loader_binds_actual_descriptor_before_crypto(tmp_path,monkeypatch): + monkeypatch.syspath_prepend(str(ROOT/'scripts')) + import seal_p4_images as sealer + path=tmp_path/'owned-key-fixture' + path.write_bytes(b'original synthetic non-key bytes') + checked=path.stat() + replacement=tmp_path/'replacement-fixture' + replacement.write_bytes(b'replacement synthetic non-key bytes') + replacement.replace(path) + crypto=Mock(side_effect=AssertionError('crypto must not receive replaced bytes')) + monkeypatch.setattr(sealer.serialization,'load_pem_private_key',crypto) + with pytest.raises(RuntimeError,match='custody changed before read'): + sealer.load_private_key(path,expected_stat=checked) + crypto.assert_not_called() + + +@pytest.mark.skipif(os.name != 'posix', reason='Strict sealing requires POSIX descriptor custody; Windows stat/fstat ctime differ') +def test_strict_key_loader_binds_metadata_and_size(tmp_path,monkeypatch): + monkeypatch.syspath_prepend(str(ROOT/'scripts')) + import seal_p4_images as sealer + path=tmp_path/'oversized-key-fixture';path.write_bytes(b'x'*8193) + crypto=Mock(side_effect=AssertionError('crypto must not receive oversized bytes')) + monkeypatch.setattr(sealer.serialization,'load_pem_private_key',crypto) + with pytest.raises(RuntimeError,match='type or size invalid'): + sealer.load_private_key(path,expected_stat=path.stat()) + crypto.assert_not_called() + + +@pytest.mark.skipif(os.name != 'posix', reason='Strict sealing requires POSIX descriptor custody; Windows stat/fstat ctime differ') +def test_strict_key_loader_accepts_existing_test_key_with_matching_metadata(tmp_path,monkeypatch,prepared): + from cryptography.hazmat.primitives import serialization + monkeypatch.syspath_prepend(str(ROOT/'scripts')) + import seal_p4_images as sealer + _,authority=prepared + path=tmp_path/'ephemeral-unit-test-key' + path.write_bytes(authority._private_key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8,serialization.NoEncryption())) + loaded=sealer.load_private_key(path,expected_stat=path.stat()) + assert ImageAttestationAuthority(loaded).key_id==authority.key_id diff --git a/tests/test_p4_master_report_contract.py b/tests/test_p4_master_report_contract.py new file mode 100644 index 0000000..4026343 --- /dev/null +++ b/tests/test_p4_master_report_contract.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def load_script(name: str): + spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def completed_report() -> dict: + # Contract fixture only: no real P4 execution or certification is claimed. + return { + "phase": "P4", + "passed": True, + "run_outcome": "COMPLETE", + "release_verdict": "NOT_READY", + "source_commit": "a" * 40, + "checks": {"sealed_twelve_image_manifest_verified": True, "public_verifier_passed": True}, + "cleanup": { + "unrelated_container_ids_preserved": True, + "ephemeral_private_files_removed": True, + }, + } + + +def test_successful_producer_report_is_consumable_without_changing_master_gate(tmp_path: Path) -> None: + producer = load_script("p4_hostile_acceptance") + consumer = load_script("master_acceptance") + report = completed_report() + output = tmp_path / "p4.json" + + producer.write_acceptance_report(output, report) + + gate = consumer._gate("P4", output, consumer._p4) + assert gate == {"state": "COMPLETE", "evidence_sha256": hashlib.sha256(output.read_bytes()).hexdigest()} + emitted = json.loads(output.read_text()) + assert emitted["source_commit"] == "a" * 40 + assert emitted["release_verdict"] == "NOT_READY" + + +@pytest.mark.parametrize("failure", ["checks", "empty_checks", "outcome", "passed", "containers", "private_files"]) +def test_incomplete_producer_report_cannot_satisfy_master_gate(tmp_path: Path, failure: str) -> None: + producer = load_script("p4_hostile_acceptance") + consumer = load_script("master_acceptance") + report = completed_report() + # A stale marker must be removed if a later verification or cleanup fails. + report["completed_phase_gate"] = "P4" + if failure == "checks": + report["checks"]["public_verifier_passed"] = False + elif failure == "empty_checks": + report["checks"] = {} + elif failure == "outcome": + report["run_outcome"] = "INFRA_FAILED" + elif failure == "passed": + report["passed"] = False + elif failure == "containers": + report["cleanup"]["unrelated_container_ids_preserved"] = False + else: + report["cleanup"]["ephemeral_private_files_removed"] = False + output = tmp_path / "p4.json" + + producer.write_acceptance_report(output, report) + + with pytest.raises(ValueError, match="P4 gate is incomplete"): + consumer._gate("P4", output, consumer._p4) + assert "completed_phase_gate" not in json.loads(output.read_text()) + + +def test_wrapper_failure_removes_previously_completed_marker(tmp_path: Path) -> None: + producer = load_script("p4_hostile_acceptance") + wrapper = load_script("p4_hostile_acceptance_exact") + consumer = load_script("master_acceptance") + output = tmp_path / "p4.json" + producer.write_acceptance_report(output, completed_report()) + + result = wrapper.enrich_report(output_path=output, harness=producer, capture={}, return_code=1) + + assert result["passed"] is False + assert "completed_phase_gate" not in result + with pytest.raises(ValueError, match="P4 gate is incomplete"): + consumer._gate("P4", output, consumer._p4) + + +@pytest.mark.parametrize("scenario", ["success", "nonzero", "malformed", "interrupted"]) +def test_exact_entrypoint_publishes_only_after_complete_enrichment(tmp_path: Path, monkeypatch, scenario: str) -> None: + producer = load_script("p4_hostile_acceptance") + wrapper = load_script("p4_hostile_acceptance_exact") + consumer = load_script("master_acceptance") + output = tmp_path / "p4.json" + capture = { + "result": { + "exit_code": 0, "timed_out": False, "oom_killed": False, + "containment": {"network_none": True}, + "logs": json.dumps({"passed": True, "private_key_loaded": False, + "sbom_valid": True, "admission": {"allowed": True}}), + }, + "artifacts": {}, "execution_limits": {}, + } + if scenario == "malformed": + capture["result"] = None + + def core_main(): + producer.write_acceptance_report(output, completed_report()) + # Even a successful core result is pending while the wrapper is running. + with pytest.raises(ValueError, match="P4 gate is incomplete"): + consumer._gate("P4", output, consumer._p4) + if scenario == "interrupted": + raise RuntimeError("simulated interruption after core serialization") + return 1 if scenario == "nonzero" else 0 + + def capture_override(original, *, artifacts, capture: dict): + capture.update(captured) + return original + + captured = capture + monkeypatch.setattr(producer, "main", core_main) + monkeypatch.setattr(wrapper, "load_harness", lambda: producer) + monkeypatch.setattr(wrapper, "parse_wrapper_paths", lambda argv: (tmp_path / "manifest.json", output)) + monkeypatch.setattr(wrapper, "resolve_runner_artifacts", lambda *args: {}) + monkeypatch.setattr(wrapper, "make_execute_container_override", capture_override) + if scenario in {"malformed", "interrupted"}: + with pytest.raises((AttributeError, RuntimeError)): + wrapper.main([]) + else: + assert wrapper.main([]) == (0 if scenario == "success" else 1) + if scenario == "success": + assert consumer._gate("P4", output, consumer._p4)["state"] == "COMPLETE" + else: + with pytest.raises(ValueError, match="P4 gate is incomplete"): + consumer._gate("P4", output, consumer._p4) diff --git a/tests/test_p4_worker_service_env.py b/tests/test_p4_worker_service_env.py new file mode 100644 index 0000000..db4d071 --- /dev/null +++ b/tests/test_p4_worker_service_env.py @@ -0,0 +1,51 @@ +"""The P4 worker health probe keeps every state path on the writable workspace. + +The hardened probe runs the worker image with a read-only root. Since P6 the worker app opens its +deployment ledger at import time, and its default path lives on the image root, so the probe died with +"Read-only file system: '/opt/var'" before /healthz answered (P4 INFRA_FAILED, ops-20260924). +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "p4_hostile_acceptance.py" +STATE_PATHS = ( + "ECHO_CERTFORGE_DB", + "ECHO_CERTFORGE_EVIDENCE_ROOT", + "ECHO_CERTFORGE_TRUSTED_KEYS", + "ECHO_CERTFORGE_DEPLOYMENT_LEDGER", +) + + +class _Captured(Exception): + pass + + +def _load_harness(): + spec = importlib.util.spec_from_file_location("p4_hostile_acceptance_worker_env_test", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_worker_health_probe_keeps_state_on_the_writable_workspace(monkeypatch, tmp_path: Path) -> None: + harness = _load_harness() + captured: dict = {} + + def fake_create_container(**kwargs): + captured.update(kwargs) + raise _Captured() + + monkeypatch.setattr(harness, "create_container", fake_create_container) + with pytest.raises(_Captured): + harness.service_health_probe("image@sha256:" + "0" * 64, harness.ImageRole.WORKER, "token-12345678", tmp_path) + environment = captured["environment"] + assert environment["ECHO_CERTFORGE_DEPLOYMENT_LEDGER"] == "/workspace/state/deployments.sqlite3" + for name in STATE_PATHS: + assert environment[name].startswith("/workspace/state/"), name diff --git a/tests/test_p5_adapter_execution.py b/tests/test_p5_adapter_execution.py index 36f9fef..8c02742 100644 --- a/tests/test_p5_adapter_execution.py +++ b/tests/test_p5_adapter_execution.py @@ -45,6 +45,7 @@ main as run_worker_main, ) from echo_certification_forge.run_worker import _worker_environment +from echo_certification_forge.sandbox import DockerSandbox from echo_certification_forge.evidence import EvidenceStore from echo_certification_forge.production_launch import production_worker_args from test_family_r5 import FakeFamilyTransport, expected as expected_r5 @@ -720,6 +721,7 @@ def test_production_router_arguments_rebind_bundle_and_reach_worker_execution( expected_environment = _worker_environment( adapter_set_digest(records), result["adapter_execution_profile_sha256"], + sandbox=DockerSandbox(), ) assert result["environment_identity_digest"] == expected_environment.identity_digest store = EvidenceStore(db_path, evidence_root) diff --git a/tests/test_p5_executor_adapter_gate.py b/tests/test_p5_executor_adapter_gate.py index 9f1b9fd..8607e44 100644 --- a/tests/test_p5_executor_adapter_gate.py +++ b/tests/test_p5_executor_adapter_gate.py @@ -17,6 +17,7 @@ from echo_certification_forge.models import EnvironmentIdentity from echo_certification_forge.policy import RuleManifest from echo_certification_forge.signing import Ed25519VerdictSigner +from production_e2e_support import trusted_generic_production_e2e RUN = "cert-p5-executor" @@ -89,7 +90,8 @@ def manifest_v2() -> RuleManifest: def execute(store, target, tmp_path: Path, *, records, adapter_policy, adapter_digest: str): manifest = manifest_v2() - store.register_run(RUN, target, environment(adapter_digest), manifest.manifest_id, manifest.digest) + run_environment = environment(adapter_digest) + store.register_run(RUN, target, run_environment, manifest.manifest_id, manifest.digest) executor = RunExecutor(store, manifest, Ed25519VerdictSigner.generate()) return executor.execute( RUN, @@ -103,6 +105,7 @@ def execute(store, target, tmp_path: Path, *, records, adapter_policy, adapter_d }, adapter_records=records, adapter_policy=adapter_policy, + production_e2e_attestation=trusted_generic_production_e2e(target, run_environment), ) diff --git a/tests/test_p5_production_launchers.py b/tests/test_p5_production_launchers.py index bedf210..d0c594b 100644 --- a/tests/test_p5_production_launchers.py +++ b/tests/test_p5_production_launchers.py @@ -21,6 +21,9 @@ def test_production_dispatcher_requires_v2_and_all_adapter_inputs() -> None: assert "--adapter-runner-signing-key" in launch assert "--adapter-runner-signing-key" in dispatcher assert "production_adapter_inputs_unavailable" in dispatcher + assert "--production-e2e-attestation-dir" in dispatcher + assert "--trusted-production-e2e-keys" in dispatcher + assert "DirectoryProductionE2EProvider" in dispatcher def test_production_deploy_requires_v2_adapter_artifacts() -> None: @@ -53,6 +56,14 @@ def test_deploy_clears_stale_systemd_start_limits_before_start_and_rollback() -> assert promotion.index(dispatcher_reset) < promotion.index(dispatcher_restart) +def test_deploy_binds_dispatcher_to_root_owned_production_e2e_trust_roots() -> None: + text = (ROOT / "deploy" / "deploy_forge.sh").read_text(encoding="utf-8") + assert "ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR" in text + assert "ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS" in text + assert 'sudo install -d -o root -g root -m 0755 "$PRODUCTION_E2E_ATTESTATION_DIR"' in text + assert 'sudo install -d -o root -g root -m 0755 "$PRODUCTION_E2E_TRUSTED_KEYS"' in text + + def test_deploy_streams_private_sdk_sql_into_one_postgres_transaction() -> None: text = (ROOT / "deploy" / "deploy_forge.sh").read_text(encoding="utf-8") registration = text.split( diff --git a/tests/test_p6_deployment_enforcement.py b/tests/test_p6_deployment_enforcement.py index 5c4496a..937915b 100644 --- a/tests/test_p6_deployment_enforcement.py +++ b/tests/test_p6_deployment_enforcement.py @@ -41,6 +41,7 @@ VerdictLifecycleEvent, ) from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from production_e2e_support import trusted_generic_production_e2e TENANT = "tenant-alpha" OTHER_TENANT = "tenant-beta" @@ -57,7 +58,7 @@ def _target(artifact_label: str, tenant_id: str = TENANT) -> TargetIdentity: target_type="container", canonical_ref=f"registry.echo/app@{artifact_label}", artifact_sha256=_digest(artifact_label), - source_commit="abc123def456", + source_commit="abc123abc123abc123abc123abc123abc123abcd", dependency_sha256=_digest("dependencies"), configuration_sha256=_digest("configuration"), ) @@ -107,6 +108,7 @@ def _certify( entitlement=StaticEntitlement(frozenset({target.tenant_id})), journey=[sys.executable, "hello.py"], control_attestations={"runner_control_channel": True, "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e(target, environment), ) assert result.release_verdict == "PRODUCTION_READY", result.blocking_findings @@ -1120,4 +1122,4 @@ def test_delayed_stale_binding_cannot_shadow_active_certification( _admission(target.artifact_sha256, "staging", environment, rolled_manifest, "deploy-shadow-s2"), ACTOR, ) - assert stale_decision.run_id == "cert-shadow-new" \ No newline at end of file + assert stale_decision.run_id == "cert-shadow-new" diff --git a/tests/test_p6_platform_integration.py b/tests/test_p6_platform_integration.py index e5406f3..6a6f3c6 100644 --- a/tests/test_p6_platform_integration.py +++ b/tests/test_p6_platform_integration.py @@ -51,12 +51,14 @@ from echo_certification_forge.service import ServiceContext, create_app from echo_certification_forge.sandbox import DockerSandbox from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from production_e2e_support import trusted_generic_production_e2e TENANT = "tenant-alpha" OTHER_TENANT = "tenant-beta" SECRET = "p6-webhook-secret-0123456789abcdef" DEPLOY_SECRET = "p6-deploy-credential-alpha-0001" OTHER_DEPLOY_SECRET = "p6-deploy-credential-beta-0002" +SOURCE_COMMIT = "abc123abc123abc123abc123abc123abc123abcd" def _digest(label: str) -> str: @@ -101,6 +103,7 @@ def _certify(store, manifest, signer, environment, tmp_path, run_id, target) -> entitlement=StaticEntitlement(frozenset({target.tenant_id})), journey=[sys.executable, "hello.py"], control_attestations={"runner_control_channel": True, "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e(target, environment), ) assert result.release_verdict == "PRODUCTION_READY", result.blocking_findings @@ -112,7 +115,7 @@ def certified_target(store, manifest, signer, environment, tmp_path) -> TargetId target_type="container", canonical_ref="registry.echo/app@v1", artifact_sha256=_digest("http-app-v1"), - source_commit="abc123def456", + source_commit=SOURCE_COMMIT, dependency_sha256=_digest("dependencies"), configuration_sha256=_digest("configuration"), ) @@ -401,7 +404,7 @@ def test_http_binding_refuses_unsigned_run_and_foreign_tenant( target_type="container", canonical_ref="registry.echo/app@u1", artifact_sha256=_digest("unsigned-app"), - source_commit="abc123def456", + source_commit=SOURCE_COMMIT, ) store.register_run("cert-unsigned", target, environment, manifest.manifest_id, manifest.digest) response = _post_signed(client, "/v1/certifications/cert-unsigned/bindings") @@ -442,7 +445,7 @@ def _build_event(event_id: str = "evt-00000001") -> dict: "event_type": "build.artifact.published", "tenant_id": TENANT, "artifact_sha256": _digest("built-artifact"), - "source_commit": "abc123def456", + "source_commit": SOURCE_COMMIT, "repository": "https://github.com/echo/app", "environment_identity_digest": _digest("build-env"), "policy_version": "certforge.release-strict.v1", @@ -488,7 +491,7 @@ def test_registry_webhook_maps_container_target(client): "tenant_id": TENANT, "image_digest": f"sha256:{_digest('pushed-image')}", "image_repository": "registry.echo/app", - "source_commit": "abc123def456", + "source_commit": SOURCE_COMMIT, "environment_identity_digest": _digest("registry-env"), "policy_version": "certforge.release-strict.v1", } @@ -557,7 +560,7 @@ def _declared_event(artifact: str, environment, manifest, event_id: str = "evt-r "event_type": "build.artifact.published", "tenant_id": TENANT, "artifact_sha256": artifact, - "source_commit": "abc123def456", + "source_commit": SOURCE_COMMIT, "repository": "https://github.com/echo/app", "environment_identity_digest": environment.identity_digest, "policy_version": manifest.manifest_id, @@ -582,9 +585,9 @@ def test_webhook_declared_run_reconciles_certifies_and_deploys_end_to_end( acquired = TargetIdentity( tenant_id=TENANT, target_type="container", - canonical_ref="registry.echo/app@abc123def456", + canonical_ref=f"registry.echo/app@{SOURCE_COMMIT}", artifact_sha256=artifact, - source_commit="abc123def456", + source_commit=SOURCE_COMMIT, ) store.reconcile_declared_target(run_id, TENANT, acquired, environment) row = store.get_run(run_id, TENANT) @@ -602,6 +605,7 @@ def test_webhook_declared_run_reconciles_certifies_and_deploys_end_to_end( entitlement=StaticEntitlement(frozenset({TENANT})), journey=[sys.executable, "hello.py"], control_attestations={"runner_control_channel": True, "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e(acquired, environment), ) assert result.release_verdict == "PRODUCTION_READY", result.blocking_findings @@ -646,9 +650,9 @@ def test_reconciliation_refuses_mismatched_artifact_and_run_stays_undeployable( tampered = TargetIdentity( tenant_id=TENANT, target_type="container", - canonical_ref="registry.echo/app@abc123def456", + canonical_ref=f"registry.echo/app@{SOURCE_COMMIT}", artifact_sha256=_digest("DIFFERENT-acquired-artifact"), - source_commit="abc123def456", + source_commit=SOURCE_COMMIT, ) with pytest.raises(ValueError, match="target_declared_artifact_mismatch"): store.reconcile_declared_target(run_id, TENANT, tampered, environment) @@ -732,9 +736,9 @@ def test_reconciliation_window_closes_at_acquisition(client, store, manifest, en acquired = TargetIdentity( tenant_id=TENANT, target_type="container", - canonical_ref="registry.echo/app@abc123def456", + canonical_ref=f"registry.echo/app@{SOURCE_COMMIT}", artifact_sha256=artifact, - source_commit="abc123def456", + source_commit=SOURCE_COMMIT, ) with pytest.raises(ValueError, match="target_reconciliation_window_closed"): store.reconcile_declared_target(run_id, TENANT, acquired, environment) @@ -752,7 +756,9 @@ def test_run_worker_executes_webhook_declared_run_end_to_end( workdir.mkdir() (workdir / "hello.py").write_text("print('ok')\n", encoding="utf-8") probe = acquire_target({"type": "local", "path": str(workdir)}, tmp_path / "probe") - worker_environment = run_worker._worker_environment() + worker_environment = run_worker._worker_environment( + manifest=manifest, journey=[sys.executable, "hello.py"] + ) body = json.dumps( _declared_event( @@ -772,6 +778,7 @@ def test_run_worker_executes_webhook_declared_run_end_to_end( signer=signer, entitled=frozenset({TENANT}), journey=[sys.executable, "hello.py"], + production_e2e_provider=trusted_generic_production_e2e, ) assert outcome.get("error") is None, outcome assert outcome["release_verdict"] == "PRODUCTION_READY" @@ -1837,9 +1844,15 @@ def test_registry_webhook_oci_run_certifies_and_deploys_end_to_end( "tenant_id": TENANT, "image_digest": manifest_digest, "image_repository": repo, - "source_commit": "abc123def456", + "source_commit": SOURCE_COMMIT, # the platform declares the WORKER's environment commitment for the run - "environment_identity_digest": run_worker._worker_environment().identity_digest, + "environment_identity_digest": run_worker._worker_environment( + manifest=manifest, journey=["python3", "app/hello.py"], + sandbox=DockerSandbox( + image=f"127.0.0.1:{registry.port}/testapp@{manifest_digest}", + docker=(sys.executable, str(stub)), + ), + ).identity_digest, "policy_version": manifest.manifest_id, } body = json.dumps(event).encode("utf-8") @@ -1862,6 +1875,7 @@ def test_registry_webhook_oci_run_certifies_and_deploys_end_to_end( entitled=frozenset({TENANT}), journey=["python3", "app/hello.py"], sandbox=DockerSandbox(docker=(sys.executable, str(stub))), + production_e2e_provider=trusted_generic_production_e2e, ) finally: os.environ.pop("CERTFORGE_TEST_DOCKER_LOG", None) @@ -2025,7 +2039,7 @@ def test_registry_webhook_oci_journey_without_sandbox_fails_closed( "tenant_id": TENANT, "image_digest": manifest_digest, "image_repository": repo, - "source_commit": "abc123def456", + "source_commit": SOURCE_COMMIT, "environment_identity_digest": run_worker._worker_environment().identity_digest, "policy_version": manifest.manifest_id, } diff --git a/tests/test_p7_acceptance_runtime_identity.py b/tests/test_p7_acceptance_runtime_identity.py new file mode 100644 index 0000000..d4a1c65 --- /dev/null +++ b/tests/test_p7_acceptance_runtime_identity.py @@ -0,0 +1,29 @@ +"""Keep the hosted P7 generator executable against measured worker identity.""" +import importlib.util +import json +import shutil +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_p7_acceptance_preserves_positive_and_negative_execution_boundaries(tmp_path, monkeypatch): + spec = importlib.util.spec_from_file_location("p7_acceptance_identity_test", ROOT / "scripts/p7_acceptance.py") + acceptance = importlib.util.module_from_spec(spec) + spec.loader.exec_module(acceptance) + for name in ("policies", "contracts"): + shutil.copytree(ROOT / name, tmp_path / name) + (tmp_path / "artifacts").mkdir() + monkeypatch.setattr(acceptance, "REPO", tmp_path) + + result = acceptance.main() + report = json.loads((tmp_path / "artifacts/p7_acceptance_report.json").read_text(encoding="utf-8")) + failed = [name for name, value in report["scenarios"].items() if not value["passed"]] + assert failed == [], failed + assert result == 0 and report["passed"] is True + assert report["release_verdict"] == "NOT_READY" + assert report["scenarios"]["canonical_identity_reconciliation"]["worker_result"]["signed"] is True + assert report["scenarios"]["suspension_wins_execution_boundary"]["customer_code_executed"] is False + assert report["scenarios"]["expired_lease_cannot_commit_signed_completion"]["worker_result"]["error"] == "subscriber_execution_fenced" + assert not (tmp_path / "var/p7-acceptance-runtime").exists() diff --git a/tests/test_p7_subscriber_governance.py b/tests/test_p7_subscriber_governance.py index ce9ba38..46c3f98 100644 --- a/tests/test_p7_subscriber_governance.py +++ b/tests/test_p7_subscriber_governance.py @@ -40,6 +40,7 @@ SubscriberGovernance, SubscriberPolicy, ) +from production_e2e_support import trusted_generic_production_e2e PEPPER = b"p7-acceptance-pepper-material-32-bytes-minimum" @@ -166,7 +167,7 @@ def _submit_local_run( canonical_ref=acquired.canonical_ref, artifact_sha256=acquired.artifact_sha256, ) - environment = _worker_environment() + environment = _worker_environment(manifest=manifest, journey=journey) payload = { "tenant_id": organization_id, "project_id": project_response.json()["project_id"], @@ -353,7 +354,7 @@ def test_subscriber_oci_without_sandbox_fails_and_releases_claim( target_reference=reference, target_identity_digest=_digest("subscriber-oci-declared-target"), ) - environment = _worker_environment() + environment = _worker_environment(manifest=manifest) run_id = "cert_" + _digest("subscriber-oci-run")[:40] governance.materialize_reserved_run( reservation, @@ -1166,6 +1167,7 @@ def test_run_worker_enforces_live_subscriber_entitlement( entitled=frozenset({org.organization_id}), subscribers=governance, journey=[sys.executable, "journey.py"], + production_e2e_provider=trusted_generic_production_e2e, ) assert result["error"] == "subscriber_worker_claim_denied" @@ -1189,6 +1191,7 @@ def test_run_worker_uses_plan_retention_immediately_before_execution( org.bootstrap_api_key, source, key="worker-retention-0001", + journey=[sys.executable, "hello.py"], ) result = run( @@ -2269,9 +2272,9 @@ def test_dispatcher_recovers_crash_after_durable_reservation( "path": source.resolve().as_posix(), }, "environment": { - "identity_digest": _worker_environment().identity_digest, + "identity_digest": _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]).identity_digest, "runner_image_digest": "sha256:" - + _worker_environment().runner_image_sha256, + + _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]).runner_image_sha256, }, "policy_version": manifest.manifest_id, "idempotency_key": "durable-intake-0001", @@ -2396,8 +2399,8 @@ def test_worker_lease_heartbeat_and_started_crash_recovery(tmp_path, manifest): claim, target_identity=target.to_dict(), target_identity_digest=target.identity_digest, - environment_identity=_worker_environment().to_dict(), - environment_identity_digest=_worker_environment().identity_digest, + environment_identity=_worker_environment(manifest=manifest).to_dict(), + environment_identity_digest=_worker_environment(manifest=manifest).identity_digest, ) assert authorization.subscription_version >= 1 @@ -2738,7 +2741,7 @@ def test_api_worker_reconciles_canonical_identities_and_rejects_digest_drift( assert result["signed"] is True assert marker.exists() assert json.loads(persisted["target_identity_json"]) == target.to_dict() - assert json.loads(persisted["environment_identity_json"]) == _worker_environment().to_dict() + assert json.loads(persisted["environment_identity_json"]) == _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]).to_dict() assert store.latest_signed_verdict(run_id, org.organization_id) is not None drift_source = tmp_path / "identity-drift-source" @@ -2837,6 +2840,69 @@ def test_api_worker_reconciles_canonical_identities_and_rejects_digest_drift( assert store.latest_signed_verdict(environment_run_id, org.organization_id) is None +def test_runtime_measurement_loss_after_acquisition_releases_claim(tmp_path, manifest, monkeypatch): + from echo_certification_forge import run_worker + + store, governance, client = _stack(tmp_path, manifest) + org = _provision(governance, "runtime-measurement-loss", plan_code="professional") + source = tmp_path / "runtime-measurement-source" + source.mkdir() + (source / "README.txt").write_text("trusted fixture") + run_id, _ = _submit_local_run( + client, manifest, org.organization_id, org.bootstrap_api_key, source, + key="runtime-measurement-loss-0001", + ) + original_acquire = run_worker.acquire_target + + def unavailable_after_acquisition(*args): + acquired = original_acquire(*args) + + def unavailable(*args, **kwargs): + raise ValueError("runtime_metadata_unavailable") + + monkeypatch.setattr(run_worker, "_worker_environment", unavailable) + return acquired + + monkeypatch.setattr(run_worker, "acquire_target", unavailable_after_acquisition) + result = run_worker.run( + run_id, org.organization_id, {"type": "local", "path": str(source)}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + subscribers=governance, + ) + assert result.get("error") == "runtime_identity_unavailable" + assert store.latest_signed_verdict(run_id, org.organization_id) is None + assert store.get_run(run_id, org.organization_id)["state"] == RunState.INFRASTRUCTURE_FAILURE.value + assert not any((store.evidence_root / ".worker").iterdir()) + usage = governance.usage_summary(_owner( + governance, org.organization_id, org.bootstrap_api_key, Permission.USAGE_READ, + )) + assert usage["active_run_reservations"] == 0 + + +def test_worker_rejects_changed_journey_before_execution(tmp_path, manifest): + store, governance, client = _stack(tmp_path, manifest) + org = _provision(governance, "journey-identity-fence", plan_code="professional") + source = tmp_path / "journey-identity" + source.mkdir() + marker = tmp_path / "must-not-execute" + (source / "journey.py").write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('unsafe')\n", encoding="utf-8", + ) + run_id, _ = _submit_local_run( + client, manifest, org.organization_id, org.bootstrap_api_key, source, + key="journey-identity-fence-0001", journey=[sys.executable, "journey.py"], + ) + result = run( + run_id, org.organization_id, {"type": "local", "path": str(source)}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + subscribers=governance, journey=[sys.executable, "journey.py", "changed-test-plan"], + ) + assert result["detail"] == "worker_environment_identity_mismatch" + assert not marker.exists() + assert store.latest_signed_verdict(run_id, org.organization_id) is None + + def test_api_worker_reconciles_exact_git_commit_without_declared_tree_digest( tmp_path, manifest, monkeypatch ): @@ -2862,7 +2928,7 @@ def test_api_worker_reconciles_exact_git_commit_without_declared_tree_digest( repository = "https://github.com/echo/example.git" reference = f"{repository}@{source_commit}" artifact_sha256 = _digest("verified-acquired-git-tree") - environment = _worker_environment() + environment = _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]) declared_digest = declared_target_identity_digest( org.organization_id, "git", @@ -2934,6 +3000,101 @@ def test_api_worker_reconciles_exact_git_commit_without_declared_tree_digest( assert persisted["target_identity_digest"] == expected_target.identity_digest +@pytest.mark.parametrize("drift", [None, "artifact", "source"]) +def test_local_declared_commitments_survive_intake_and_fail_closed_on_drift( + tmp_path, manifest, monkeypatch, drift +): + """Exercise API validation, atomic reservation, acquisition and worker admission.""" + store, governance, client = _stack(tmp_path, manifest) + org = _provision(governance, "local-commitments", plan_code="professional") + project = _project( + client, org.organization_id, org.bootstrap_api_key, slug="local-commitments" + ) + assert project.status_code == 201 + source = tmp_path / "local-committed-source" + source.mkdir() + marker = tmp_path / "local-committed-executed" + (source / "journey.py").write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n", + encoding="utf-8", + ) + acquired = acquire_target( + {"type": "local", "path": str(source)}, tmp_path / "unused-acquisition" + ) + source_commit = "a" * 40 + environment = _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]) + target = { + "target_type": "local", + "identity_digest": declared_target_identity_digest( + org.organization_id, "local", acquired.artifact_sha256, + source_commit, acquired.canonical_ref, + ), + "reference": acquired.canonical_ref, + "path": str(source), + "artifact_sha256": acquired.artifact_sha256, + "source_commit": source_commit, + } + payload = { + "tenant_id": org.organization_id, + "project_id": project.json()["project_id"], + "target": target, + "environment": { + "identity_digest": environment.identity_digest, + "runner_image_digest": "sha256:" + environment.runner_image_sha256, + }, + "policy_version": manifest.manifest_id, + "idempotency_key": "local-commitments-0001", + "journey": [sys.executable, "journey.py"], + } + headers = _headers(org.organization_id, org.bootstrap_api_key) + response = client.post("/v1/certifications", headers=headers, json=payload) + assert response.status_code == 201, response.text + run_id = response.json()["run_id"] + persisted = store.get_run(run_id, org.organization_id) + declared = json.loads(persisted["target_identity_json"]) + assert declared["declared_artifact_sha256"] == acquired.artifact_sha256 + assert declared["declared_source_commit"] == source_commit + replay = client.post("/v1/certifications", headers=headers, json=payload) + assert replay.status_code == 200 + assert replay.json()["run_id"] == run_id + + if drift == "artifact": + (source / "post-intake-change.txt").write_text("drift\n", encoding="utf-8") + elif drift == "source": + # Preserve real local acquisition and change only its observed commit. + # This proves the worker cannot substitute another source identity. + def mismatched_source(spec, destination): + return replace(acquire_target(spec, destination), source_commit="b" * 40) + + monkeypatch.setattr( + "echo_certification_forge.run_worker.acquire_target", mismatched_source + ) + request = SubmitRequest.model_validate(payload) + result = run( + run_id, org.organization_id, request.target.worker_spec(), store=store, + manifest=manifest, signer=Ed25519VerdictSigner.generate(), + subscribers=governance, journey=payload["journey"], + ) + persisted = store.get_run(run_id, org.organization_id) + if drift: + assert result["error"] == "target_reconciliation_failed" + assert persisted["state"] == RunState.INFRASTRUCTURE_FAILURE.value + assert not marker.exists() + assert store.latest_signed_verdict(run_id, org.organization_id) is None + else: + expected = TargetIdentity( + tenant_id=org.organization_id, target_type="local", + canonical_ref=acquired.canonical_ref, + artifact_sha256=acquired.artifact_sha256, source_commit=source_commit, + ) + assert result["state"] == RunState.COMPLETED.value + assert result["signed"] is True + assert marker.exists() + assert json.loads(persisted["target_identity_json"]) == expected.to_dict() + assert persisted["target_identity_digest"] == expected.identity_digest + + def test_atomic_materialization_rolls_back_and_concurrent_suspension_terminalizes( tmp_path, manifest ): @@ -2958,7 +3119,7 @@ def test_atomic_materialization_rolls_back_and_concurrent_suspension_terminalize canonical_ref=acquired.canonical_ref, artifact_sha256=acquired.artifact_sha256, ) - environment = _worker_environment() + environment = _worker_environment(manifest=manifest, journey=[sys.executable, "journey.py"]) request = SubmitRequest.model_validate( { "tenant_id": org.organization_id, @@ -3578,6 +3739,7 @@ def test_legal_hold_lifecycle_and_public_verification_use_hardened_subscriber_au entitled=frozenset({owner.organization_id}), subscribers=governance, journey=[sys.executable, "journey.py"], + production_e2e_provider=trusted_generic_production_e2e, ) assert result["state"] == RunState.COMPLETED.value headers = _headers(owner.organization_id, owner.bootstrap_api_key) diff --git a/tests/test_production_e2e.py b/tests/test_production_e2e.py new file mode 100644 index 0000000..3358c55 --- /dev/null +++ b/tests/test_production_e2e.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import pytest + +from echo_certification_forge.models import EnvironmentIdentity, TargetIdentity +from echo_certification_forge.production_e2e import ( + DirectoryProductionE2EProvider, + ECHO_CLIENTS, + ECHO_GITHUB_ACCOUNTS, + ECHO_GITHUB_AUTONOMY_CANONICAL_MCP, + ECHO_GITHUB_AUTONOMY_CHECKS, + ECHO_GITHUB_AUTONOMY_PROFILE, + SCHEMA_VERSION, + load_signed_attestation, + validate_production_e2e, +) +from echo_certification_forge.signing import Ed25519VerdictSigner + +NOW = datetime(2026, 8, 30, 12, 0, tzinfo=UTC) +SOURCE_SHA = "5" * 40 + + +def _target() -> TargetIdentity: + return TargetIdentity( + tenant_id="echo-github-apps", + target_type="git", + canonical_ref=( + "https://github.com/echoomegaprime/echo-github-autonomy.git@" + SOURCE_SHA + ), + artifact_sha256="a" * 64, + source_commit=SOURCE_SHA, + dependency_sha256="b" * 64, + configuration_sha256="c" * 64, + ) + + +def _environment() -> EnvironmentIdentity: + return EnvironmentIdentity( + **{ + name: str(index) * 64 + for index, name in enumerate(EnvironmentIdentity.__dataclass_fields__, start=1) + } + ) + + +def _payload(*, signing_key_id: str = "ed25519:" + "d" * 32) -> dict: + target = _target() + environment = _environment() + samples = { + login: { + "repository_id": index, + "node_id": f"R_repo_{index}", + "default_branch": "main", + "head_sha": str(index) * 40, + "fingerprint_sha256": str(index) * 64, + } + for index, login in enumerate(ECHO_GITHUB_ACCOUNTS, start=1) + } + accounts = { + login: { + "account_id": account_id, + "enumerated_count": 10 + index, + "upstream_total_count": 10 + index, + "public_count": 5, + "private_count": 5 + index, + "read": True, + "write": True, + "certify": True, + "credential_source": ( + "github_app_installation" + if index == 1 + else "vault_user_token_fallback" + ), + "secret_exposed": False, + } + for index, (login, account_id) in enumerate(ECHO_GITHUB_ACCOUNTS.items(), start=1) + } + fingerprints = { + login: sample["fingerprint_sha256"] for login, sample in samples.items() + } + return { + "schema_version": SCHEMA_VERSION, + "attestation_id": "echo-github-autonomy-live-001", + "profile": ECHO_GITHUB_AUTONOMY_PROFILE, + "target_identity_digest": target.identity_digest, + "environment_identity_digest": environment.identity_digest, + "source_commit": SOURCE_SHA, + "deployment_sha": SOURCE_SHA, + "canonical_target": ECHO_GITHUB_AUTONOMY_CANONICAL_MCP, + "required_checks": sorted(ECHO_GITHUB_AUTONOMY_CHECKS), + "checks": {name: True for name in sorted(ECHO_GITHUB_AUTONOMY_CHECKS)}, + "stability_probe_count": 3, + "tool_count": 30, + "accounts": accounts, + "sample_private_repositories": samples, + "clients": { + name: {"accepted": True, "repository_fingerprints": fingerprints} + for name in sorted(ECHO_CLIENTS) + }, + "observed_at": NOW.isoformat(), + "expires_at": (NOW + timedelta(minutes=30)).isoformat(), + "signing_key_id": signing_key_id, + } + + +def test_echo_github_autonomy_requires_complete_exact_cross_client_e2e() -> None: + valid, reason = validate_production_e2e( + _payload(), _target(), _environment(), now=NOW + timedelta(minutes=1) + ) + assert valid, reason + + +@pytest.mark.parametrize( + ("mutation", "reason"), + [ + (lambda value: value.__setitem__("deployment_sha", "6" * 40), "production_e2e_deployment_sha_mismatch"), + (lambda value: value.__setitem__("tool_count", 27), "production_e2e_tool_count_mismatch"), + ( + lambda value: value["accounts"]["bobmcwilliams4"].__setitem__( + "private_count", 0 + ), + "production_e2e_account_reconciliation_failed", + ), + ( + lambda value: value["clients"]["grok"].__setitem__("accepted", False), + "production_e2e_client_not_accepted", + ), + ( + lambda value: value["accounts"]["bobmcwilliams4"].__setitem__( + "credential_source", "model_config_pat" + ), + "production_e2e_credential_source_invalid", + ), + ( + lambda value: value["accounts"]["bobmcwilliams4"].__setitem__( + "secret_exposed", True + ), + "production_e2e_secret_boundary_failed", + ), + ( + lambda value: value["sample_private_repositories"]["bobmcwilliams4"].__setitem__( + "repository_id", True + ), + "production_e2e_sample_repository_id_invalid", + ), + ], +) +def test_incomplete_or_mismatched_e2e_fails_closed(mutation, reason: str) -> None: + payload = _payload() + mutation(payload) + valid, actual = validate_production_e2e( + payload, _target(), _environment(), now=NOW + timedelta(minutes=1) + ) + assert not valid + assert actual == reason + + +def test_stale_e2e_attestation_fails_closed() -> None: + valid, reason = validate_production_e2e( + _payload(), _target(), _environment(), now=NOW + timedelta(hours=2) + ) + assert not valid + assert reason == "production_e2e_attestation_not_current" + + +def test_retired_account_and_v1_profile_are_rejected() -> None: + retired_account = _payload() + retired_account["accounts"]["Bmcbob76"] = { + **retired_account["accounts"]["bobmcwilliams4"], + "account_id": 203470412, + } + valid, reason = validate_production_e2e( + retired_account, _target(), _environment(), now=NOW + timedelta(minutes=1) + ) + assert not valid + assert reason == "production_e2e_accounts_incomplete" + + retired_profile = _payload() + retired_profile["profile"] = "echo-github-autonomy-remote-mcp-v1" + valid, reason = validate_production_e2e( + retired_profile, _target(), _environment(), now=NOW + timedelta(minutes=1) + ) + assert not valid + assert reason == "production_e2e_profile_mismatch" + + +def test_attestation_loader_requires_a_pinned_collector_key(tmp_path) -> None: + signer = Ed25519VerdictSigner.generate() + payload = _payload(signing_key_id=signer.key_id) + envelope = signer.sign_payload(payload) + envelope_path = tmp_path / "attestation.json" + envelope_path.write_text( + json.dumps( + { + "payload": envelope.payload, + "signature_b64": envelope.signature_b64, + "key_id": envelope.key_id, + "public_key_pem": envelope.public_key_pem, + } + ), + encoding="utf-8", + ) + trusted = tmp_path / "trusted" + trusted.mkdir() + with pytest.raises(ValueError, match="untrusted_signing_key"): + load_signed_attestation(envelope_path, trusted) + (trusted / "collector.pem").write_text(signer.public_key_pem, encoding="ascii") + verified = load_signed_attestation(envelope_path, trusted) + assert verified.payload == payload + assert verified.collector_key_id == signer.key_id + assert len(verified.envelope_sha256) == 64 + + +def test_self_selected_public_key_cannot_replace_the_pinned_collector(tmp_path) -> None: + trusted_signer = Ed25519VerdictSigner.generate() + attacker = Ed25519VerdictSigner.generate() + payload = _payload(signing_key_id=attacker.key_id) + envelope = attacker.sign_payload(payload) + envelope_path = tmp_path / "attestation.json" + envelope_path.write_text( + json.dumps( + { + "payload": envelope.payload, + "signature_b64": envelope.signature_b64, + "key_id": envelope.key_id, + "public_key_pem": envelope.public_key_pem, + } + ), + encoding="utf-8", + ) + trusted = tmp_path / "trusted" + trusted.mkdir() + (trusted / "collector.pem").write_text(trusted_signer.public_key_pem, encoding="ascii") + with pytest.raises(ValueError, match="untrusted_signing_key"): + load_signed_attestation(envelope_path, trusted) + + +def test_directory_provider_resolves_only_the_exact_target_and_environment(tmp_path) -> None: + signer = Ed25519VerdictSigner.generate() + payload = _payload(signing_key_id=signer.key_id) + envelope = signer.sign_payload(payload) + attestations = tmp_path / "attestations" + trusted = tmp_path / "trusted" + attestations.mkdir() + trusted.mkdir() + (trusted / "collector.pem").write_text(signer.public_key_pem, encoding="ascii") + expected_path = attestations / ( + f"{_target().identity_digest}.{_environment().identity_digest}.json" + ) + expected_path.write_text( + json.dumps( + { + "payload": envelope.payload, + "signature_b64": envelope.signature_b64, + "key_id": envelope.key_id, + "public_key_pem": envelope.public_key_pem, + } + ), + encoding="utf-8", + ) + + provider = DirectoryProductionE2EProvider(attestations, trusted) + verified = provider(_target(), _environment()) + assert verified is not None + assert verified.payload == payload + + +def test_directory_provider_fails_closed_for_missing_or_untrusted_envelopes(tmp_path) -> None: + trusted_signer = Ed25519VerdictSigner.generate() + attacker = Ed25519VerdictSigner.generate() + attestations = tmp_path / "attestations" + trusted = tmp_path / "trusted" + attestations.mkdir() + trusted.mkdir() + (trusted / "collector.pem").write_text( + trusted_signer.public_key_pem, + encoding="ascii", + ) + provider = DirectoryProductionE2EProvider(attestations, trusted) + assert provider(_target(), _environment()) is None + + payload = _payload(signing_key_id=attacker.key_id) + envelope = attacker.sign_payload(payload) + expected_path = attestations / ( + f"{_target().identity_digest}.{_environment().identity_digest}.json" + ) + expected_path.write_text( + json.dumps( + { + "payload": envelope.payload, + "signature_b64": envelope.signature_b64, + "key_id": envelope.key_id, + "public_key_pem": envelope.public_key_pem, + } + ), + encoding="utf-8", + ) + assert provider(_target(), _environment()) is None diff --git a/tests/test_release_pins.py b/tests/test_release_pins.py new file mode 100644 index 0000000..463c5c0 --- /dev/null +++ b/tests/test_release_pins.py @@ -0,0 +1,71 @@ +"""ops-20260924: the supply-chain pins stay consistent and patched (issue #22 security release). + +P4 image sealing fails closed on HIGH vulnerabilities with an available fix. This guards the +fix: one patched base image digest everywhere, the patched cryptography release hash-locked, +and the dispatcher unit decoupled from API restarts. +""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PATCHED_BASE = "sha256:5789aa194f757888f41fa3d48e14b6feaa1b57c0af0b56fcb1372d6474d3bff0" +VULNERABLE_BASE = "sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" +ROLES = ("runner", "custody", "anchor", "signer", "worker", "verifier") + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_every_role_image_uses_the_patched_base() -> None: + for role in ROLES: + dockerfile = _text(f"images/{role}/Dockerfile") + assert dockerfile.startswith(f"FROM python:3.12-alpine@{PATCHED_BASE}\n"), role + assert f'org.opencontainers.image.base.digest="{PATCHED_BASE}"' in dockerfile, role + assert VULNERABLE_BASE not in dockerfile, role + assert "sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" not in dockerfile, role + assert "RUN apk add --no-cache libcrypto3=3.5.9-r0 libssl3=3.5.9-r0 libuuid=2.42.3-r1" in dockerfile, role + + +def test_sandbox_and_p4_gate_share_the_patched_base() -> None: + assert f'DEFAULT_IMAGE = "python:3.12-alpine@{PATCHED_BASE}"' in _text( + "src/echo_certification_forge/sandbox.py") + assert f'BASE_DIGEST="${{BASE_DIGEST:-{PATCHED_BASE}}}"' in _text("scripts/run_p4_gate.sh") + + +def test_cryptography_is_the_patched_hash_locked_release() -> None: + assert "cryptography==50.0.0" in _text("images/requirements.in") + lock = _text("images/requirements.lock").replace("\r\n", "\n") + block = re.search(r"^cryptography==(\S+) \\\n((?: --hash=sha256:[0-9a-f]{64}(?: \\)?\n)+)", lock, re.M) + assert block is not None + major = int(block.group(1).split(".")[0]) + assert major >= 50 + assert len(re.findall(r"sha256:[0-9a-f]{64}", block.group(2))) >= 20 + assert "cryptography==49" not in lock + + +def test_dispatcher_wants_but_does_not_require_the_api_service() -> None: + script = _text("deploy/deploy_forge.sh") + dispatcher = script[script.index('sudo tee "$DISPATCH_UNIT_PATH"'):] + unit = dispatcher[: dispatcher.index("[Service]")] + assert "Wants=$SERVICE.service" in unit + assert "Requires=$SERVICE.service" not in unit + assert "After=network.target $SERVICE.service" in unit + + +def test_source_fetch_credential_helper_is_configurable_with_safe_default() -> None: + script = _text("deploy/deploy_forge.sh") + assert ('GIT_CREDENTIAL_HELPER="${CERTFORGE_GIT_CREDENTIAL_HELPER:-store --file=' + '/home/forge/.config/echo/omega_git_creds}"') in script + assert 'GITC=(-c credential.helper= -c credential.helper="$GIT_CREDENTIAL_HELPER")' in script + + +def test_deploy_default_manifest_digest_matches_the_enforced_pin() -> None: + script = _text("deploy/deploy_forge.sh") + run_worker = _text("src/echo_certification_forge/run_worker.py") + pinned = re.search(r'_PRODUCTION_MANIFEST_SHA256 = \(\s*"([0-9a-f]{64})"', run_worker) + default = re.search(r'TRUSTED_MANIFEST_SHA256="\$\{ECHO_CERTFORGE_TRUSTED_MANIFEST_SHA256:-([0-9a-f]{64})\}"', + script) + assert pinned and default and default.group(1) == pinned.group(1) diff --git a/tests/test_rollback_database_preservation.py b/tests/test_rollback_database_preservation.py new file mode 100644 index 0000000..9999605 --- /dev/null +++ b/tests/test_rollback_database_preservation.py @@ -0,0 +1,42 @@ +import importlib.util +from pathlib import Path +import sqlite3 + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('rollback_snapshot_test', ROOT/'deploy/preserve_rollback_database.py') +HELPER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HELPER) + + +def test_post_promotion_snapshot_keeps_acknowledged_writes_and_previous_backup(tmp_path): + live = tmp_path/'live.sqlite' + before = tmp_path/'before.sqlite' + after = tmp_path/'after.sqlite' + with sqlite3.connect(live) as db: + db.execute('CREATE TABLE accepted(value TEXT)') + db.execute("INSERT INTO accepted VALUES ('before')") + HELPER.preserve_database(live, before) + baseline = before.read_bytes() + with sqlite3.connect(live) as db: + db.execute("INSERT INTO accepted VALUES ('acknowledged-during-promotion')") + receipt = HELPER.preserve_database(live, after) + assert receipt['preserved'] and receipt['live_database_replaced'] is False + for path in [live, after]: + with sqlite3.connect(path) as db: + assert db.execute('SELECT value FROM accepted ORDER BY rowid').fetchall() == [('before',), ('acknowledged-during-promotion',)] + assert before.read_bytes() == baseline + with pytest.raises(ValueError): + HELPER.preserve_database(live, before) + assert before.read_bytes() == baseline + + +def test_deployment_fences_database_restore_before_first_possible_api_write(): + script = (ROOT/'deploy/deploy_forge.sh').read_text(encoding='utf-8') + assert script.index('PROD_API_STARTED=1') < script.index('sudo systemctl restart "$SERVICE.service"') + rollback = script.split('rollback_production() {', 1)[1].split('trap rollback_production EXIT', 1)[0] + assert '[ "$DB_SNAPSHOT_READY" = 1 ] && [ "$PROD_API_STARTED" = 1 ]' in rollback + assert rollback.index('preserve_rollback_database.py') < rollback.index('elif [ "$DB_SNAPSHOT_READY" = 1 ]') < rollback.index('mv -f "$DB_RESTORE" "$DB_PATH"') + assert '"$DISPATCH_RELEASE_DROPIN_BACKUP" "$DB_BACKUP"' not in script + assert 'rm -f "$UNIT_BACKUP" "$DISPATCH_UNIT_BACKUP" "$DB_BACKUP"' not in script diff --git a/tests/test_runner.py b/tests/test_runner.py index 7a16b63..0266497 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -634,7 +634,12 @@ def test_workspace_owner_marker_and_symlink_detection(tmp_path: Path) -> None: ) target = tmp_path / "outside.txt" target.write_text("outside") - os.symlink(target, workspace / "link") + try: + os.symlink(target, workspace / "link") + except OSError as exc: + if os.name == "nt" and getattr(exc, "winerror", None) == 1314: + pytest.skip("Windows symlink privilege is unavailable") + raise with pytest.raises(IsolationFailure, match="symlink_not_allowed"): assert_no_symlinks(workspace) diff --git a/tests/test_runtime_identity.py b/tests/test_runtime_identity.py new file mode 100644 index 0000000..b01c62c --- /dev/null +++ b/tests/test_runtime_identity.py @@ -0,0 +1,242 @@ +"""Runtime identity must describe executable inputs, not fixed labels.""" +import dataclasses +import json +import shutil +from pathlib import Path + +import pytest + +from echo_certification_forge.canonical import sha256_bytes +from echo_certification_forge.policy import RuleManifest +from echo_certification_forge.run_worker import _worker_environment +from echo_certification_forge.sandbox import DockerSandbox + +ROOT = Path(__file__).resolve().parents[1] + + +def test_environment_binds_selected_mandatory_manifest(): + manifest = RuleManifest.load(ROOT / "policies/mandatory-rules.v2.json") + assert _worker_environment().policy_sha256 == manifest.digest + + +def test_environment_does_not_publish_static_harness_label_as_measurement(): + assert _worker_environment().harness_sha256 != sha256_bytes( + b"certforge-worker-env:harness" + ) + + +def test_actual_pinned_image_and_journey_change_environment(): + first = DockerSandbox(image="python@sha256:" + "1" * 64) + second = dataclasses.replace(first, image="python@sha256:" + "2" * 64) + base = _worker_environment(sandbox=first, journey=["python", "journey.py"]) + assert base.runner_image_sha256 == "1" * 64 + assert base.identity_digest != _worker_environment( + sandbox=second, journey=["python", "journey.py"] + ).identity_digest + assert base.test_plan_sha256 != _worker_environment( + sandbox=first, journey=["python", "other.py"] + ).test_plan_sha256 + + +def test_local_content_image_id_is_measured_without_fabricating_repository_digest(): + from echo_certification_forge.runtime_identity import _sandbox_inputs + + sandbox = DockerSandbox(image="sha256:" + "a" * 64) + environment = _worker_environment(sandbox=sandbox) + assert environment.runner_image_sha256 == "a" * 64 + assert _sandbox_inputs(sandbox)["image_identifier_kind"] == "docker_content_id" + repository = dataclasses.replace(sandbox, image="python@sha256:" + "a" * 64) + assert _sandbox_inputs(repository)["image_identifier_kind"] == "repository_digest" + assert environment.identity_digest != _worker_environment(sandbox=repository).identity_digest + + +def test_missing_runtime_dependency_metadata_fails_closed(monkeypatch, store, manifest): + import importlib.metadata + from echo_certification_forge import run_worker + from echo_certification_forge.signing import Ed25519VerdictSigner + + def missing(name): + raise importlib.metadata.PackageNotFoundError("do-not-disclose-metadata-detail") + + monkeypatch.setattr(importlib.metadata, "version", missing) + with pytest.raises(ValueError, match="runtime_metadata_unavailable"): + _worker_environment() + result = run_worker.run( + "runtime-missing", "runtime-test", {"type": "local", "path": "never-acquired"}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + ) + assert result == {"run_id": "runtime-missing", "error": "runtime_identity_unavailable"} + + +@pytest.mark.parametrize("field,value", [("memory", "768m"), ("timeout_s", 120), ("pids_limit", 64)]) +def test_effective_sandbox_limits_change_environment(field, value): + sandbox = DockerSandbox() + assert _worker_environment(sandbox=sandbox).identity_digest != _worker_environment( + sandbox=dataclasses.replace(sandbox, **{field: value}) + ).identity_digest + + +def test_harness_bytes_and_policy_changes_invalidate_identity(tmp_path): + from echo_certification_forge.runtime_identity import measured_environment + + shutil.copytree(ROOT / "src/echo_certification_forge", tmp_path / "src/echo_certification_forge") + shutil.copy2(ROOT / "pyproject.toml", tmp_path) + shutil.copytree(ROOT / "images", tmp_path / "images") + manifest = RuleManifest.load(ROOT / "policies/mandatory-rules.v2.json") + before = measured_environment(manifest=manifest, source_root=tmp_path) + harness = tmp_path / "src/echo_certification_forge/executor.py" + harness.write_bytes(harness.read_bytes() + b"\n# changed executable harness\n") + after = measured_environment(manifest=manifest, source_root=tmp_path) + assert before.harness_sha256 != after.harness_sha256 + assert before.identity_digest != after.identity_digest + assert after.identity_digest != measured_environment( + manifest=dataclasses.replace(manifest, digest="e" * 64), source_root=tmp_path + ).identity_digest + + +def test_profile_is_shared_public_configuration_and_checks_actual_execution(tmp_path, monkeypatch): + from echo_certification_forge.runtime_identity import load_runtime_profile + + profile_path = tmp_path / "profile.json" + profile_path.write_text(json.dumps({ + "schema_version": "certforge.runtime.v1", + "journey": ["python", "journey.py"], + "sandbox": {"image": "python@sha256:" + "3" * 64, "memory": "768m"}, + })) + monkeypatch.setenv("ECHO_CERTFORGE_RUNTIME_PROFILE", str(profile_path)) + profile = load_runtime_profile() + assert profile.sandbox.memory == "768m" + assert profile.journey == ("python", "journey.py") + profile.check_execution(profile.sandbox, list(profile.journey)) + with pytest.raises(ValueError, match="runtime_profile_mismatch"): + profile.check_execution(profile.sandbox, ["python", "different.py"]) + with pytest.raises(ValueError, match="runtime_profile_mismatch"): + profile.check_execution(dataclasses.replace(profile.sandbox, memory="512m"), list(profile.journey)) + + +@pytest.mark.parametrize("profile", [ + {"schema_version": "certforge.runtime.v1", "secret": "never-disclose-this"}, + {"schema_version": "certforge.runtime.v1", "sandbox": {"image": "python:latest"}}, + {"schema_version": "certforge.runtime.v1", "sandbox": {"extra_env": {"TOKEN": "never-disclose-this"}}}, +]) +def test_profile_rejects_unknown_secret_fields_and_mutable_images(tmp_path, profile): + from echo_certification_forge.runtime_identity import load_runtime_profile + + path = tmp_path / "profile.json" + path.write_text(json.dumps(profile)) + with pytest.raises(ValueError) as caught: + load_runtime_profile(path) + assert "never-disclose-this" not in str(caught.value) + + +def test_arbitrary_environment_is_not_read_or_hashed(monkeypatch): + before = _worker_environment() + monkeypatch.setenv("SECRET_RUNTIME_TOKEN", "first-private-value") + monkeypatch.setenv("ECHO_CERTFORGE_API_KEY_PEPPER", "second-private-value") + assert before == _worker_environment() + with pytest.raises(ValueError, match="runtime_extra_env_not_supported"): + _worker_environment(sandbox=DockerSandbox(extra_env={"SECRET": "private"})) + + +def test_service_dispatcher_and_worker_share_the_effective_profile(tmp_path, monkeypatch): + """Exercise real launcher/config/status/worker paths; stub only adapter trust and queue poll.""" + import importlib + from fastapi.testclient import TestClient + from echo_certification_forge import dispatch_worker, run_worker + from echo_certification_forge.service import ServiceContext, create_app + from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry + + profile_path = tmp_path / "runtime.json" + profile_path.write_text(json.dumps({ + "schema_version": "certforge.runtime.v1", "journey": None, + "sandbox": {"image": "python@sha256:" + "4" * 64, "memory": "768m"}, + })) + monkeypatch.setenv("ECHO_CERTFORGE_RUNTIME_PROFILE", str(profile_path)) + monkeypatch.setenv("ECHO_CERTFORGE_DB", str(tmp_path / "test.sqlite")) + monkeypatch.setenv("ECHO_CERTFORGE_EVIDENCE_ROOT", str(tmp_path / "evidence")) + monkeypatch.setenv("ECHO_CERTFORGE_API_KEY_PEPPER", "explicit-test-pepper-" + "x" * 32) + monkeypatch.setenv("ECHO_CERTFORGE_ADAPTER_MODE", "pending") + app_module = importlib.import_module("echo_certification_forge.app") + manifest = RuleManifest.load(ROOT / "policies/mandatory-rules.v2.json") + monkeypatch.setattr(app_module, "_policy", ROOT / "policies/mandatory-rules.v2.json") + monkeypatch.setenv("ECHO_CERTFORGE_ADAPTER_MODE", "required") + for name in ("PROD_ADAPTER_RESPONSE", "PROD_ADAPTER_POLICY", "ADAPTER_REGISTRY"): + monkeypatch.setenv("ECHO_CERTFORGE_" + name, str(tmp_path / name)) + # Verification itself is covered by the signed adapter tests; this isolates runtime wiring. + monkeypatch.setattr(app_module, "load_adapter_execution_profile", lambda **kw: ((), None, None, None)) + public = app_module._load_certification_environment() + captured = {} + + def observe_dispatch(*args, **kwargs): + captured.update(kwargs["worker_options"]) + return None, None + + signer = Ed25519VerdictSigner.generate() + monkeypatch.setattr(dispatch_worker, "dispatch_once", observe_dispatch) + monkeypatch.setattr(dispatch_worker, "_load_signer", lambda path: signer) + assert dispatch_worker.main([ + "--once", "--sandbox", "--adapter-runner-signing-key", str(tmp_path / "test-only-key"), + ]) == 3 # explicitly empty stubbed queue, no worker claim + assert captured["runtime_profile"].sandbox == captured["sandbox"] + assert captured["sandbox"].memory == "768m" + source = tmp_path / "target" + source.mkdir() + (source / "README.txt").write_text("trusted fixture; no journey executes") + result = run_worker.run( + "runtime-agreement", "runtime-test", {"type": "local", "path": str(source)}, + store=captured["store"], manifest=manifest, signer=signer, + entitled=frozenset({"runtime-test"}), + sandbox=captured["sandbox"], runtime_profile=captured["runtime_profile"], + adapter_records=(), + ) + assert "error" not in result + assert result["release_verdict"] == "NOT_READY" + context = ServiceContext( + store=captured["store"], manifest=manifest, + trusted_keys=TrustedPublicKeyRegistry.empty(), certification_environment=public, + ) + status = TestClient(create_app(context)).get("/v1/status").json() + assert status["certification_environment_identity_digest"] == result["environment_identity_digest"] + assert status["runner_image_digest"] == "sha256:" + "4" * 64 + + +def test_profile_mismatch_rejects_before_acquisition(tmp_path, monkeypatch, store, manifest): + from echo_certification_forge import run_worker + from echo_certification_forge.runtime_identity import RuntimeProfile + from echo_certification_forge.signing import Ed25519VerdictSigner + + def forbidden_acquisition(*args, **kwargs): + pytest.fail("mismatched execution must never acquire customer input") + + monkeypatch.setattr(run_worker, "acquire_target", forbidden_acquisition) + profile = RuntimeProfile(DockerSandbox(), ("python", "approved.py")) + result = run_worker.run( + "runtime-reject", "runtime-test", {"type": "local", "path": str(tmp_path)}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + sandbox=profile.sandbox, runtime_profile=profile, journey=["python", "other.py"], + ) + assert result == {"run_id": "runtime-reject", "error": "runtime_profile_mismatch"} + + +def test_acquired_oci_image_cannot_override_an_explicit_profile(tmp_path, monkeypatch, store, manifest): + from echo_certification_forge import run_worker + from echo_certification_forge.acquisition import AcquiredTarget + from echo_certification_forge.runtime_identity import RuntimeProfile + from echo_certification_forge.sandbox import SandboxResult + from echo_certification_forge.signing import Ed25519VerdictSigner + + executed = [] + monkeypatch.setattr(DockerSandbox, "run", lambda *args: executed.append(True) or SandboxResult(0, "", "", False)) + monkeypatch.setattr(run_worker, "acquire_target", lambda *args: AcquiredTarget( + tmp_path, "oci", "registry.test/acquired@sha256:" + "b" * 64, "b" * 64, + )) + profile = RuntimeProfile(DockerSandbox(image="registry.test/approved@sha256:" + "a" * 64), ("python", "app.py")) + result = run_worker.run( + "oci-runtime-fence", "runtime-test", {"type": "oci"}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + entitled=frozenset({"runtime-test"}), sandbox=profile.sandbox, + runtime_profile=profile, journey=list(profile.journey), + ) + assert result.get("error") == "runtime_profile_mismatch" + assert not executed + assert not any((store.evidence_root / ".worker").iterdir()) diff --git a/tests/test_runtime_job_binding.py b/tests/test_runtime_job_binding.py new file mode 100644 index 0000000..b1f6496 --- /dev/null +++ b/tests/test_runtime_job_binding.py @@ -0,0 +1,296 @@ +"""Shared jobs resolve their own bounded plans while operator runtime choices stay pinned.""" +import dataclasses +import json +import os +from pathlib import Path +import subprocess +import sys +import zipfile + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from echo_certification_forge.evidence import EvidenceStore +from echo_certification_forge.run_worker import _worker_environment +from echo_certification_forge.runtime_identity import RuntimeProfile +from echo_certification_forge.sandbox import DockerSandbox +from echo_certification_forge.service import RuntimeEnvironmentResponse, ServiceContext, create_app +from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from echo_certification_forge.subscriber import SubscriberGovernance, SubscriberPolicy + +ROOT = Path(__file__).resolve().parents[1] + + +def test_image_only_profile_accepts_separate_job_plans_but_rejects_changed_runtime(): + profile = RuntimeProfile(DockerSandbox(image="sha256:" + "a" * 64)) + for journey in (["python3", "first.py"], ["python3", "second.py"]): + profile.check_execution(profile.sandbox, journey) + with pytest.raises(ValueError, match="runtime_profile_mismatch"): + profile.check_execution(dataclasses.replace(profile.sandbox, image="sha256:" + "b" * 64), ["python3", "first.py"]) + with pytest.raises(ValueError, match="runtime_journey_invalid"): + profile.check_execution(profile.sandbox, [""]) + + +@pytest.fixture +def runtime_api(tmp_path, manifest): + store = EvidenceStore(tmp_path / "runtime.sqlite", tmp_path / "evidence") + governance = SubscriberGovernance( + store.db_path, SubscriberPolicy.load(ROOT / "policies/subscriber-governance.v1.json"), + b"runtime-planning-test-only-pepper-32-bytes", + ) + owner = governance.provision_organization( + slug="runtime-plan", display_name="Runtime planning test", owner_email="owner@example.test", + owner_display_name="Owner", plan_code="developer", + ) + profile = RuntimeProfile(DockerSandbox(image="sha256:" + "a" * 64)) + context = ServiceContext( + store, manifest, TrustedPublicKeyRegistry.empty(), governance, + certification_environment={"adapter_set_sha256": "b" * 64, "adapter_execution_profile_sha256": "c" * 64}, + runtime_profile=profile, + ) + headers = {"X-Tenant-ID": owner.organization_id, "Authorization": "Bearer " + owner.bootstrap_api_key} + return TestClient(create_app(context)), context, headers + + +def test_authenticated_environment_resolution_distinguishes_jobs_without_execution(runtime_api): + client, context, headers = runtime_api + plans = [["python3", "-B", "scripts/first.py"], ["python3", "-B", "scripts/second.py"]] + replies = [client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": plan}) for plan in plans] + assert [reply.status_code for reply in replies] == [200, 200] + first, second = [reply.json() for reply in replies] + assert first["submit_environment"]["identity_digest"] != second["submit_environment"]["identity_digest"] + assert first["submit_environment"]["runner_image_digest"] == "sha256:" + "a" * 64 + measured = _worker_environment( + "b" * 64, "c" * 64, manifest=context.manifest, + sandbox=context.runtime_profile.sandbox, journey=plans[0], + ) + assert first["environment"] == measured.to_dict() + assert first["submit_environment"]["identity_digest"] == measured.identity_digest + assert not context.store.list_runs(headers["X-Tenant-ID"]) + assert client.post("/v1/subscriber/runtime/environment", json={"journey": plans[0]}).status_code == 401 + other = dict(headers, **{"X-Tenant-ID": "other-tenant"}) + assert client.post("/v1/subscriber/runtime/environment", headers=other, json={"journey": plans[0]}).status_code == 404 + + +def test_deployment_smoke_uses_authenticated_job_identity_not_status_default(runtime_api, monkeypatch): + import importlib.util + spec = importlib.util.spec_from_file_location("runtime_smoke_test", ROOT / "deploy/smoke_dispatch.py") + smoke = importlib.util.module_from_spec(spec) + spec.loader.exec_module(smoke) + client, context, headers = runtime_api + journey = ["python3", "certforge_smoke.py"] + def request(base, method, path, **kwargs): + assert method == "POST" and path == "/v1/subscriber/runtime/environment" + assert kwargs["tenant"] == headers["X-Tenant-ID"] + response = client.post(path, headers=headers, json=kwargs["body"]) + return response.status_code, response.json() + monkeypatch.setattr(smoke, "_request", request) + actual = smoke._resolve_environment("http://isolated.test", {"tenant": headers["X-Tenant-ID"], "token": "synthetic"}, journey, context.runtime_profile.sandbox.image) + default = _worker_environment("b" * 64, "c" * 64, manifest=context.manifest, sandbox=context.runtime_profile.sandbox) + expected = _worker_environment("b" * 64, "c" * 64, manifest=context.manifest, sandbox=context.runtime_profile.sandbox, journey=journey) + assert actual["identity_digest"] == expected.identity_digest + assert actual["identity_digest"] != default.identity_digest + with pytest.raises(RuntimeError, match="operator image"): + smoke._resolve_environment("http://isolated.test", {"tenant": headers["X-Tenant-ID"], "token": "synthetic"}, journey, "sha256:" + "d" * 64) + + +def test_staging_runtime_probe_exercises_real_auth_and_fails_missing_profile(runtime_api, monkeypatch): + import importlib.util + from types import SimpleNamespace + spec = importlib.util.spec_from_file_location("staging_runtime_probe_test", ROOT / "deploy/smoke_live.py") + smoke = importlib.util.module_from_spec(spec) + spec.loader.exec_module(smoke) + client, context, headers = runtime_api + owner = SimpleNamespace(organization_id=headers['X-Tenant-ID'], bootstrap_api_key=headers['Authorization'][7:]) + intruder = context.subscribers.provision_organization( + slug='runtime-intruder', display_name='Synthetic intruder', owner_email='intruder@example.test', + owner_display_name='Synthetic intruder', plan_code='developer') + def request(base, method, path, **kwargs): + auth = {} + if kwargs.get('tenant'): auth['X-Tenant-ID'] = kwargs['tenant'] + if kwargs.get('token'): auth['Authorization'] = 'Bearer ' + kwargs['token'] + response = client.post(path, headers=auth, json=kwargs.get('body')) + return response.status_code, response.json() + monkeypatch.setattr(smoke, '_req', request) + public = {'runner_image_digest': context.runtime_profile.sandbox.image} + assert smoke.check_runtime_profile('http://isolated.test', owner, intruder, public) + context.runtime_profile = None + assert not smoke.check_runtime_profile('http://isolated.test', owner, intruder, public) + + +def test_sdk_command_selector_is_removed_before_runtime_resolution(runtime_api): + client, context, headers = runtime_api + plan = ["python3", "test.py"] + direct = client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": plan}) + sdk = client.post( + "/v1/subscriber/runtime/environment", headers=headers, + json={"command": "runtime_environment", "journey": plan}, + ) + assert direct.status_code == sdk.status_code == 200 + assert direct.json() == sdk.json() + assert not context.store.list_runs(headers["X-Tenant-ID"]) + + +@pytest.mark.parametrize("change", [{"unexpected": "x"}, {"harness_sha256": "invalid"}]) +def test_runtime_response_components_reject_extras_and_invalid_hashes(runtime_api, change): + client, _context, headers = runtime_api + response = client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": ["python3", "test.py"]}).json() + response["environment"].update(change) + with pytest.raises(ValidationError): + RuntimeEnvironmentResponse.model_validate(response) + + +def test_two_resolved_job_plans_agree_with_worker_and_keep_release_gates(runtime_api, tmp_path, monkeypatch): + from echo_certification_forge import run_worker + from echo_certification_forge.acquisition import acquire_target + from echo_certification_forge.models import TargetIdentity + from echo_certification_forge.sandbox import SandboxResult + + client, context, headers = runtime_api + # Adapter verification is covered by P5. This wiring case has no qualified bundle + # and must retain NOT_READY even when its stubbed sandbox transport succeeds. + context.certification_environment = {} + source = tmp_path / "trusted-target" + source.mkdir() + (source / "README.txt").write_text("trusted fixture; customer code never runs on host") + acquired = acquire_target({"type": "local", "path": str(source)}, tmp_path / "unused") + target = TargetIdentity(headers["X-Tenant-ID"], acquired.target_type, acquired.canonical_ref, acquired.artifact_sha256) + project = client.post("/v1/subscriber/projects", headers=headers, json={ + "slug": "runtime-target", "name": "Runtime target", "target_reference": target.canonical_ref, + }).json() + executed = [] + monkeypatch.setattr(DockerSandbox, "run", lambda self, argv, *args: executed.append(argv) or SandboxResult(0, "fixture", "", False)) + plans = [["python3", "first.py"], ["python3", "second.py"]] + for index, plan in enumerate(plans): + declaration = client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": plan}).json()["submit_environment"] + submitted = client.post("/v1/certifications", headers=headers, json={ + "tenant_id": headers["X-Tenant-ID"], "project_id": project["project_id"], + "target": {"target_type": "local", "reference": target.canonical_ref, "identity_digest": target.identity_digest}, + "environment": declaration, "policy_version": context.manifest.manifest_id, + "idempotency_key": "resolved-runtime-job-" + str(index), "journey": plan, + }) + assert submitted.status_code == 201 + result = run_worker.run( + submitted.json()["run_id"], headers["X-Tenant-ID"], {"type": "local", "path": str(source)}, + store=context.store, manifest=context.manifest, signer=Ed25519VerdictSigner.generate(), + subscribers=context.subscribers, sandbox=context.runtime_profile.sandbox, + runtime_profile=context.runtime_profile, journey=plan, + ) + assert "error" not in result + assert result["environment_identity_digest"] == declaration["identity_digest"] + assert result["release_verdict"] == "NOT_READY" + assert executed == plans + + +@pytest.mark.parametrize("body", [ + {"journey": ["python3"], "image": "sha256:" + "d" * 64}, + {"journey": []}, {"journey": [""]}, {"journey": ["bad\u0000argument"]}, + {"journey": ["x" * 2049]}, {"journey": ["python3"] * 33}, {"journey": [7]}, +]) +def test_environment_resolution_rejects_invalid_or_caller_selected_runtime(runtime_api, body): + client, context, headers = runtime_api + assert client.post("/v1/subscriber/runtime/environment", headers=headers, json=body).status_code == 422 + assert not context.store.list_runs(headers["X-Tenant-ID"]) + + +def test_explicit_operator_journey_remains_pinned(runtime_api): + client, context, headers = runtime_api + context.runtime_profile = RuntimeProfile(context.runtime_profile.sandbox, ("python3", "approved.py")) + assert client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": ["python3", "other.py"]}).status_code == 422 + assert client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": ["python3", "approved.py"]}).status_code == 200 + + +def test_environment_resolution_requires_configured_runtime(runtime_api): + client, context, headers = runtime_api + context.runtime_profile = None + reply = client.post("/v1/subscriber/runtime/environment", headers=headers, json={"journey": ["python3", "test.py"]}) + assert reply.status_code == 503 + assert reply.json()["detail"] == "runtime_profile_unavailable" + + +@pytest.mark.parametrize("with_bearer", [False, True]) +def test_environment_resolution_requires_subscriber_governance(runtime_api, with_bearer): + client, context, headers = runtime_api + context.subscribers = None + request_headers = headers if with_bearer else {"X-Tenant-ID": headers["X-Tenant-ID"]} + reply = client.post( + "/v1/subscriber/runtime/environment", headers=request_headers, + json={"journey": ["python3", "test.py"]}, + ) + assert reply.status_code == 503 + assert reply.json()["detail"] == "subscriber_governance_disabled" + assert not context.store.list_runs(headers["X-Tenant-ID"]) + + +def test_declared_job_runtime_mismatch_rejects_before_acquisition(monkeypatch, store, manifest): + from echo_certification_forge import run_worker + + store.register_declared_run( + run_id="wrong-runtime", tenant_id="runtime-test", target_type="git", + target_identity_digest="d" * 64, target_reference="https://example.test/repo@abc", + environment_identity_digest="e" * 64, environment_json={"identity_digest": "e" * 64}, + policy_version=manifest.manifest_id, manifest_id=manifest.manifest_id, manifest_digest=manifest.digest, + ) + monkeypatch.setattr(run_worker, "acquire_target", lambda *args: pytest.fail("changed runtime must not acquire customer code")) + profile = RuntimeProfile(DockerSandbox(image="sha256:" + "a" * 64)) + reply = run_worker.run( + "wrong-runtime", "runtime-test", {"type": "git", "url": "https://example.test/repo", "ref": "abc"}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + sandbox=profile.sandbox, runtime_profile=profile, + ) + assert reply["error"] == "runtime_environment_mismatch" + + +def test_changed_job_argv_cannot_reuse_another_resolved_environment(monkeypatch, store, manifest): + from echo_certification_forge import run_worker + + profile = RuntimeProfile(DockerSandbox(image="sha256:" + "a" * 64)) + declared = _worker_environment(manifest=manifest, sandbox=profile.sandbox, journey=["python3", "first.py"]) + store.register_declared_run( + run_id="changed-plan", tenant_id="runtime-test", target_type="git", + target_identity_digest="d" * 64, target_reference="https://example.test/repo@abc", + environment_identity_digest=declared.identity_digest, environment_json={"identity_digest": declared.identity_digest}, + policy_version=manifest.manifest_id, manifest_id=manifest.manifest_id, manifest_digest=manifest.digest, + ) + monkeypatch.setattr(run_worker, "acquire_target", lambda *args: pytest.fail("changed plan must not acquire customer code")) + reply = run_worker.run( + "changed-plan", "runtime-test", {"type": "git", "url": "https://example.test/repo", "ref": "abc"}, + store=store, manifest=manifest, signer=Ed25519VerdictSigner.generate(), + sandbox=profile.sandbox, runtime_profile=profile, journey=["python3", "second.py"], + ) + assert reply["error"] == "runtime_environment_mismatch" + + +def test_built_wheel_measures_public_packaged_harness(tmp_path, manifest): + wheels = tmp_path / "wheels" + subprocess.run( + [sys.executable, "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", "--wheel-dir", str(wheels), str(ROOT)], + check=True, capture_output=True, text=True, + ) + wheel = next(wheels.glob("echo_certification_forge-*.whl")) + installed = tmp_path / "installed" + with zipfile.ZipFile(wheel) as archive: + archive.extractall(installed) + package = installed / "echo_certification_forge" + assert (package / "runtime_data/pyproject.toml").read_bytes() == (ROOT / "pyproject.toml").read_bytes() + assert (package / "runtime_data/requirements.lock").read_bytes() == (ROOT / "images/requirements.lock").read_bytes() + probe = tmp_path / "wheel_probe.py" + probe.write_text( + "import json, sys\nfrom pathlib import Path\n" + "from echo_certification_forge.policy import RuleManifest\n" + "from echo_certification_forge.runtime_identity import measured_environment\n" + "from echo_certification_forge.sandbox import DockerSandbox\n" + "environment = measured_environment(manifest=RuleManifest.load(Path(sys.argv[1])), sandbox=DockerSandbox(), journey=['python3', 'journey.py'])\n" + "print(json.dumps({'harness': environment.harness_sha256, 'image': environment.runner_image_sha256}))\n", + encoding="utf-8", + ) + process = subprocess.run( + [sys.executable, "-B", str(probe), str(ROOT / "policies/mandatory-rules.v2.json")], + env=dict(os.environ, PYTHONPATH=str(installed), PYTHONDONTWRITEBYTECODE="1"), + cwd=tmp_path, capture_output=True, text=True, check=True, + ) + actual = json.loads(process.stdout) + expected = _worker_environment(sandbox=DockerSandbox(), journey=["python3", "journey.py"]) + assert actual["harness"] == expected.harness_sha256 + assert actual["image"] == expected.runner_image_sha256 diff --git a/tests/test_sdk_capability_contract.py b/tests/test_sdk_capability_contract.py index adcb6ef..37a781b 100644 --- a/tests/test_sdk_capability_contract.py +++ b/tests/test_sdk_capability_contract.py @@ -32,7 +32,7 @@ def test_generated_sdk_contract_is_current() -> None: def test_every_certforge_capability_has_command_and_output_schema() -> None: contract = json.loads(CONTRACT.read_text(encoding="utf-8")) capabilities = contract["capabilities"] - assert contract["capability_count"] == 60 == len(capabilities) + assert contract["capability_count"] == 62 == len(capabilities) for capability, item in capabilities.items(): input_schema = item["input_schema"] output_schema = item["output_schema"] @@ -48,6 +48,23 @@ def test_every_certforge_capability_has_command_and_output_schema() -> None: assert "type" in output_schema or "anyOf" in output_schema, capability +def test_runtime_environment_components_are_strict_sha256_fields() -> None: + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + environment = contract["capabilities"]["echo.certforge.runtime_environment"]["output_schema"]["properties"]["environment"] + assert environment["additionalProperties"] is False + assert set(environment["required"]) == set(environment["properties"]) + assert len(environment["properties"]) == 9 + assert all(item == {"type": "string", "pattern": "^[0-9a-f]{64}$"} for item in environment["properties"].values()) + + +def test_github_app_publish_alias_is_exactly_schema_compatible() -> None: + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + capabilities = contract["capabilities"] + least_privileged = capabilities["echo.certforge.publish"] + administrative = capabilities["echo.certforge.admin.publish"] + assert least_privileged == administrative + + def test_registered_capability_set_equals_sdk_contract() -> None: contract = json.loads(CONTRACT.read_text(encoding="utf-8")) registered: set[str] = set() diff --git a/tests/test_t4_live_run.py b/tests/test_t4_live_run.py index 3d7e6fa..be4d6e8 100644 --- a/tests/test_t4_live_run.py +++ b/tests/test_t4_live_run.py @@ -18,6 +18,7 @@ from echo_certification_forge.models import SignedVerdictEnvelope from echo_certification_forge.run_worker import run from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from production_e2e_support import trusted_generic_production_e2e def _benign(tmp_path: Path) -> Path: @@ -76,6 +77,125 @@ def fake_run(command, **_kwargs): assert not any("clone" in command and "--branch" in command for command in commands) +def test_acquire_private_github_uses_ephemeral_askpass_without_secret_in_command( + tmp_path, monkeypatch +): + source_commit = "a" * 40 + installation_token = "installation-token-must-not-leak" + commands: list[list[str]] = [] + observed_environments: list[dict[str, str]] = [] + askpass_bodies: list[str] = [] + + monkeypatch.setattr( + "echo_certification_forge.acquisition._github_app_installation_token", + lambda _url: installation_token, + ) + + def fake_run(command, **kwargs): + commands.append(command) + environment = kwargs["env"] + observed_environments.append(dict(environment)) + askpass = Path(environment["GIT_ASKPASS"]) + askpass_bodies.append(askpass.read_text(encoding="utf-8")) + destination = tmp_path / "private" + if "init" in command: + (destination / ".git").mkdir(parents=True) + if "checkout" in command: + (destination / "README.md").write_text("private exact revision\n", encoding="utf-8") + stdout = f"{source_commit}\n" if "rev-parse" in command else "" + return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="") + + monkeypatch.setattr("echo_certification_forge.acquisition.subprocess.run", fake_run) + acquired = acquire_target( + { + "type": "git", + "url": "https://github.com/echoomegaprime/private-project.git", + "ref": source_commit, + }, + tmp_path / "private", + ) + + assert acquired.source_commit == source_commit + assert observed_environments + assert all(item["ECHO_CERTFORGE_GIT_TOKEN"] == installation_token for item in observed_environments) + assert all(item["GIT_TERMINAL_PROMPT"] == "0" for item in observed_environments) + assert installation_token not in json.dumps(commands) + assert all(installation_token not in body for body in askpass_bodies) + assert not (tmp_path / ".certforge-git-askpass").exists() + + +def test_acquire_private_github_redacts_token_from_git_failure(tmp_path, monkeypatch): + installation_token = "installation-token-must-not-leak" + monkeypatch.setattr( + "echo_certification_forge.acquisition._github_app_installation_token", + lambda _url: installation_token, + ) + + def fake_run(command, **_kwargs): + if "fetch" in command: + return subprocess.CompletedProcess( + command, + 128, + stdout="", + stderr=f"fatal: rejected {installation_token}", + ) + destination = tmp_path / "private-failure" + if "init" in command: + (destination / ".git").mkdir(parents=True) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("echo_certification_forge.acquisition.subprocess.run", fake_run) + with pytest.raises(AcquisitionError) as error: + acquire_target( + { + "type": "git", + "url": "https://github.com/echoomegaprime/private-project.git", + "ref": "a" * 40, + }, + tmp_path / "private-failure", + ) + assert installation_token not in str(error.value) + assert "[REDACTED]" in str(error.value) + assert not (tmp_path / ".certforge-git-askpass").exists() + + +def test_acquire_git_uses_bounded_configured_timeout(tmp_path, monkeypatch): + source_commit = "a" * 40 + observed_timeouts: list[float] = [] + + def fake_run(command, **kwargs): + observed_timeouts.append(kwargs["timeout"]) + destination = tmp_path / "configured-timeout" + if "init" in command: + (destination / ".git").mkdir(parents=True) + if "checkout" in command: + (destination / "README.md").write_text("exact revision\n", encoding="utf-8") + stdout = f"{source_commit}\n" if "rev-parse" in command else "" + return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="") + + monkeypatch.setenv("ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS", "321") + monkeypatch.setattr("echo_certification_forge.acquisition.subprocess.run", fake_run) + acquired = acquire_target( + {"type": "git", "url": "https://github.com/example/project.git", "ref": source_commit}, + tmp_path / "configured-timeout", + ) + + assert acquired.source_commit == source_commit + assert observed_timeouts + assert set(observed_timeouts) == {321.0} + + +@pytest.mark.parametrize("configured", ["invalid", "29", "1801", "nan", "inf"]) +def test_acquire_git_rejects_invalid_configured_timeout(tmp_path, monkeypatch, configured): + monkeypatch.setenv("ECHO_CERTFORGE_GIT_ACQUISITION_TIMEOUT_SECONDS", configured) + with pytest.raises(AcquisitionError, match="timeout configuration"): + acquire_target( + {"type": "git", "url": "https://github.com/example/project.git", "ref": "a" * 40}, + tmp_path / "invalid-timeout", + ) + assert not (tmp_path / "invalid-timeout").exists() + + def test_acquire_git_exact_commit_fails_closed_on_revision_mismatch(tmp_path, monkeypatch): source_commit = "a" * 40 @@ -99,7 +219,8 @@ def _run_worker(store, manifest, tenant, source, journey): signer = Ed25519VerdictSigner.generate() result = run("cert-live", tenant, {"type": "local", "path": str(source)}, store=store, manifest=manifest, signer=signer, - entitled=frozenset({tenant}), journey=journey) + entitled=frozenset({tenant}), journey=journey, + production_e2e_provider=trusted_generic_production_e2e) return result, signer diff --git a/tests/test_t4p5_executor.py b/tests/test_t4p5_executor.py index f4cd76d..60dbfc8 100644 --- a/tests/test_t4p5_executor.py +++ b/tests/test_t4p5_executor.py @@ -11,6 +11,7 @@ from echo_certification_forge.canonical import to_utc_iso, utc_now from echo_certification_forge.executor import RunExecutor, StaticEntitlement from echo_certification_forge.signing import Ed25519VerdictSigner +from production_e2e_support import trusted_generic_production_e2e RUN = "cert-exec" @@ -140,7 +141,10 @@ def test_retention_purge_removes_expired_content_but_keeps_signed_verdict( entitlement=StaticEntitlement(frozenset({target.tenant_id})), journey=[sys.executable, "hello.py"], control_attestations={"runner_control_channel": True, - "signing_authority_separation": True}) + "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e( + target, environment + )) assert result.release_verdict == "PRODUCTION_READY", result.blocking_findings verdict_before = store.latest_signed_verdict(RUN, target.tenant_id) diff --git a/tests/test_t4p7_e2e.py b/tests/test_t4p7_e2e.py index 025c9a8..e733c57 100644 --- a/tests/test_t4p7_e2e.py +++ b/tests/test_t4p7_e2e.py @@ -15,6 +15,7 @@ from echo_certification_forge.executor import RunExecutor, StaticEntitlement from echo_certification_forge.models import SignedVerdictEnvelope from echo_certification_forge.signing import Ed25519VerdictSigner, TrustedPublicKeyRegistry +from production_e2e_support import trusted_generic_production_e2e RUN = "cert-e2e" @@ -46,6 +47,7 @@ def test_e2e_benign_target_certified_and_deploy_gate_allows(store, manifest, tar entitlement=StaticEntitlement(frozenset({target.tenant_id})), journey=[sys.executable, "hello.py"], control_attestations={"runner_control_channel": True, "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e(target, environment), ) # certified through the full path diff --git a/tests/test_t4p8_sandbox.py b/tests/test_t4p8_sandbox.py index b0d3d45..a1e9dc5 100644 --- a/tests/test_t4p8_sandbox.py +++ b/tests/test_t4p8_sandbox.py @@ -15,6 +15,7 @@ DEFAULT_IMAGE, DockerSandbox, SandboxError, sandboxed_journey_runner, ) from echo_certification_forge.signing import Ed25519VerdictSigner +from production_e2e_support import trusted_generic_production_e2e def test_build_command_has_all_hardening_flags(tmp_path): @@ -72,6 +73,7 @@ def fake_runner(argv, workdir): entitlement=StaticEntitlement(frozenset({target.tenant_id})), journey=["python3", "hello.py"], journey_runner=fake_runner, control_attestations={"runner_control_channel": True, "signing_authority_separation": True}, + production_e2e_attestation=trusted_generic_production_e2e(target, environment), ) assert calls and calls[0][0] == ["python3", "hello.py"] # the injected runner was used assert result.release_verdict == "PRODUCTION_READY"