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..de984fb 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,250 @@ "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" + } + ] + }, + "project_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "target": { + "anyOf": [ + { + "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" + }, + { + "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 +2566,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..c571825 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 @@ -317,7 +361,7 @@ 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 @@ -515,7 +559,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 +595,16 @@ sudo tee "$DISPATCH_UNIT_PATH" >/dev/null < 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 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") @@ -177,11 +195,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 +212,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 +228,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/docs/OPERATIONS.md b/docs/OPERATIONS.md index 66e4b6f..4290af1 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -10,6 +10,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,12 +62,22 @@ 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 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..d055465 --- /dev/null +++ b/docs/RUNTIME_IDENTITY.md @@ -0,0 +1,136 @@ +# Measured worker runtime identity + +## Source-bound project profiles + +An operator may select `certforge.runtime.v2` in the same public profile file and +add `project_bindings`. Each binding requires a unique `binding_id`, exact +`tenant_id`, `project_id`, HTTPS `repository`, 40-character `source_commit`, a +nonempty `dependencies` map of repository-relative paths to SHA-256 hashes, +an exact `journey`, and `sandbox` settings using the existing restricted fields. +Image pins, non-root execution and all existing isolation checks remain required. +The operator file is not accepted from target source or the subscriber request. +Installing it still requires independent image qualification and release admission. + +The runtime resolver accepts optional `project_id` and `target` together, checks +live project access, and resolves the operator binding for that tenant, project, +repository and revision. It returns the measured environment using the existing +`certforge.runtime.v1` response contract. Clients cannot choose a profile or image. +Other tenants, projects and repositories keep the unchanged default sandbox; +unadmitted revisions of a bound repository fail closed. This also preserves other +repositories when an older App installation shares one subscriber project. + +The worker derives its project from the persisted run, selects the same binding, +and verifies the declared environment before acquisition. After acquiring the +exact revision it verifies the actual source commit and each dependency hash, +rejecting missing, oversized, changed, escaping or symlinked files before execution. +The complete operator binding digest is included in the measured environment; +changing dependency expectations therefore invalidates the old commitment. +All reservation, worker, lease, signer and final environment fences remain active. +Selection logs include binding ID, source SHA and binding digest, with no secrets. + +Deploy the paired GitHub App resolver change and regenerated SDK schemas only +through the governed release path. An old queued environment must not be silently +rewritten or replayed as a new certification. No service, trust, queue, signing key, +readiness report or production attestation is changed by this source implementation. + +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. 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 is based on measured deployed revision +`d8ad9fd881365801354b78a758632d2a31ab6828`. Canonical main was separately measured at +`4f7d99e2dd4422f3d81cf5b709581be963f5818a`; their merge base is +`153ee6b6d40d6c789436b7081fafa9714bfb3a2c`. Main lacks deployed acquisition and +production-E2E protections. The repair must be reviewed against the deployed +baseline and reconciled with canonical history before promotion; replacing the +deployed release with older main is not a valid rollback or integration 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. diff --git a/images/anchor/Dockerfile b/images/anchor/Dockerfile index 387e74f..6137490 100644 --- a/images/anchor/Dockerfile +++ b/images/anchor/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ 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..85f8ddc 100644 --- a/images/custody/Dockerfile +++ b/images/custody/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ echo.certforge.image.role="custody" \ echo.certforge.image.variant="${IMAGE_VARIANT}" \ echo.certforge.admission.policy="${ADMISSION_POLICY_ID}" \ 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..70a7e41 100644 --- a/images/runner/Dockerfile +++ b/images/runner/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ 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..183614f 100644 --- a/images/signer/Dockerfile +++ b/images/signer/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ 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..8d17357 100644 --- a/images/verifier/Dockerfile +++ b/images/verifier/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ 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..2d6b454 100644 --- a/images/worker/Dockerfile +++ b/images/worker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-alpine@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df +FROM python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 ARG SOURCE_REVISION=unknown ARG IMAGE_VARIANT=unknown @@ -9,7 +9,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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" \ 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..1aad671 100644 --- a/scripts/p7_acceptance.py +++ b/scripts/p7_acceptance.py @@ -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( @@ -1558,9 +1560,9 @@ def explode_policy_list(_principal): "path": intake_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": "p7-durable-intake-0001", @@ -1659,8 +1661,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 +1847,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 +1948,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/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..0ffbfbb 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"}]},"project_id":{"anyOf":[{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},{"type":"null"}]},"target":{"anyOf":[{"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"},{"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..21bce96 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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111}" 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/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..5648b67 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.v2" if runtime.bindings else "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..05eed1f 100644 --- a/src/echo_certification_forge/dispatch_worker.py +++ b/src/echo_certification_forge/dispatch_worker.py @@ -11,8 +11,9 @@ from .evidence import EvidenceStore 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] @@ -182,10 +183,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 +243,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 +290,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, 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..67cf2af 100644 --- a/src/echo_certification_forge/intake.py +++ b/src/echo_certification_forge/intake.py @@ -12,9 +12,17 @@ from pydantic import BaseModel, ConfigDict, Field -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 # Internal fine-grained RunState -> coarse public state (contracts/schemas/certification-run.v1.json). @@ -152,6 +160,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 +223,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/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..9737ccf 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, 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,22 @@ 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, + runtime_binding_sha256: 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, + runtime_binding_sha256=runtime_binding_sha256, ) @@ -157,14 +146,63 @@ 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: + if runtime_profile is not None: + try: + if runtime_profile.bindings: + if subscribers is None: + return {"run_id": run_id, "error": "subscriber_governance_required"} + declared = store.get_run(run_id, tenant) + # The launcher supplies only the operator default. Selection comes + # from the persisted tenant/project, never caller image fields. + runtime_profile.check_execution(sandbox, list(runtime_profile.journey) if runtime_profile.journey else None) + runtime_profile = runtime_profile.select(tenant, declared.get("project_id"), target_spec) + sandbox = runtime_profile.sandbox + runtime_profile.check_execution(sandbox, journey) + except (ValueError, KeyError) as exc: + code = "runtime_binding_target_mismatch" if str(exc) == "runtime_binding_target_mismatch" else "runtime_profile_mismatch" + _LOGGER.warning("runtime_profile_rejected", extra={"run_id": run_id, "error_code": code}) + return {"run_id": run_id, "error": code} + binding = runtime_profile.binding if runtime_profile else None + 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, + runtime_binding_sha256=binding.digest if binding else None, + ) + if runtime_profile is not None: + try: + declared = store.get_run(run_id, tenant) + except KeyError: + declared = None + if declared is not None: + public = json.loads(str(declared["environment_identity_json"])) + 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 +385,43 @@ 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) + if binding is not None: + runtime_error = "runtime_binding_source_mismatch" + binding.check_target(target_spec) + binding.check_source(acquired.source_root, acquired_source_commit) + runtime_error = "runtime_identity_unavailable" + environment = _worker_environment( + adapter_digest, adapter_execution_profile_sha256, + manifest=manifest, sandbox=sandbox_effective, journey=journey, + runtime_binding_sha256=binding.digest if binding else None, + ) + 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 +604,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 +744,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 +806,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 +814,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 +971,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 +1005,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_admission.py b/src/echo_certification_forge/runtime_admission.py new file mode 100644 index 0000000..a0d9c48 --- /dev/null +++ b/src/echo_certification_forge/runtime_admission.py @@ -0,0 +1,103 @@ +"""Public operator bindings for dependency-complete, source-specific sandboxes. + +This is configuration selection, not image qualification or a signing authority. +Only an independently admitted operator release may install this configuration. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit + +from .canonical import sha256_bytes, sha256_json +from .sandbox import DockerSandbox + + +@dataclass(frozen=True) +class RuntimeBinding: + binding_id: str + tenant_id: str + project_id: str + repository: str + source_commit: str + dependencies: tuple[tuple[str, str], ...] + sandbox: DockerSandbox + journey: tuple[str, ...] + digest: str + + def check_target(self, target: dict) -> None: + if (target.get("type") != "git" or target.get("url") != self.repository + or target.get("ref") != self.source_commit + or target.get("source_commit", self.source_commit) != self.source_commit): + raise ValueError("runtime_binding_target_mismatch") + + def check_source(self, root: Path, source_commit: str | None) -> None: + if source_commit != self.source_commit: + raise ValueError("runtime_binding_source_mismatch") + root = root.resolve(strict=True) + for name, expected in self.dependencies: + path = root.joinpath(*PurePosixPath(name).parts) + # Reject links even when they resolve within the acquired tree. + if any(p.is_symlink() for p in (path, *path.parents) if p != root and root in p.parents): + raise ValueError("runtime_binding_dependency_mismatch") + try: + if not path.resolve(strict=True).is_relative_to(root) or not path.is_file(): + raise ValueError("runtime_binding_dependency_mismatch") + with path.open("rb") as stream: + content = stream.read(2 * 1024 * 1024 + 1) + if len(content) > 2 * 1024 * 1024 or sha256_bytes(content) != expected: + raise ValueError("runtime_binding_dependency_mismatch") + except OSError as exc: + raise ValueError("runtime_binding_dependency_mismatch") from exc + + +def parse_bindings(raw: object, *, docker: tuple[str, ...], sandbox_fields: frozenset[str], + validate_sandbox, validate_journey) -> tuple[RuntimeBinding, ...]: + if not isinstance(raw, list) or len(raw) > 128: + raise ValueError("runtime_bindings_invalid") + result = [] + identities = set() + names = set() + fields = {"binding_id", "tenant_id", "project_id", "repository", "source_commit", + "dependencies", "sandbox", "journey"} + for item in raw: + if not isinstance(item, dict) or set(item) != fields: + raise ValueError("runtime_binding_fields_invalid") + for key in ("binding_id", "tenant_id", "project_id"): + if not isinstance(item[key], str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", item[key]): + raise ValueError("runtime_binding_identity_invalid") + repo = item["repository"] + if not isinstance(repo, str) or len(repo) > 2048: + raise ValueError("runtime_binding_repository_invalid") + uri = urlsplit(repo) + if uri.scheme != "https" or not uri.hostname or uri.username or uri.password or uri.query or uri.fragment: + raise ValueError("runtime_binding_repository_invalid") + if not isinstance(item["source_commit"], str) or not re.fullmatch(r"[0-9a-f]{40}", item["source_commit"]): + raise ValueError("runtime_binding_commit_invalid") + deps = item["dependencies"] + if not isinstance(deps, dict) or not 1 <= len(deps) <= 32: + raise ValueError("runtime_binding_dependencies_invalid") + for name, digest in deps.items(): + if (not isinstance(name, str) or not name or len(name) > 256 or "\\" in name or ":" in name + or any(ord(c) < 32 for c in name) + or PurePosixPath(name).is_absolute() or any(p in {"", ".", ".."} for p in name.split("/")) + or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest)): + raise ValueError("runtime_binding_dependencies_invalid") + config = item["sandbox"] + if not isinstance(config, dict) or "image" not in config or set(config) - sandbox_fields: + raise ValueError("runtime_binding_sandbox_invalid") + sandbox = DockerSandbox(**config, docker=docker) + validate_sandbox(sandbox) + journey = validate_journey(item["journey"]) + if journey is None: + raise ValueError("runtime_binding_journey_required") + identity = (item["tenant_id"], item["project_id"], repo, item["source_commit"]) + if identity in identities or item["binding_id"] in names: + raise ValueError("runtime_binding_duplicate") + identities.add(identity) + names.add(item["binding_id"]) + result.append(RuntimeBinding(item["binding_id"], item["tenant_id"], item["project_id"], repo, + item["source_commit"], tuple(sorted(deps.items())), sandbox, journey, + sha256_json(item))) + return tuple(result) diff --git a/src/echo_certification_forge/runtime_identity.py b/src/echo_certification_forge/runtime_identity.py new file mode 100644 index 0000000..a5f0ec6 --- /dev/null +++ b/src/echo_certification_forge/runtime_identity.py @@ -0,0 +1,221 @@ +"""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 +from .runtime_admission import RuntimeBinding, parse_bindings + +_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 + bindings: tuple[RuntimeBinding, ...] = () + binding: RuntimeBinding | None = None + + def select(self, tenant: str, project: str | None, target: dict) -> RuntimeProfile: + candidates = [b for b in self.bindings if b.tenant_id == tenant and b.project_id == project + and b.repository == target.get("url")] + if not candidates: + return self + for binding in candidates: + try: + binding.check_target(target) + except ValueError: + continue + _LOG.info("runtime_binding_selected", extra={"binding_id": binding.binding_id, + "binding_sha256": binding.digest, "source_commit": binding.source_commit}) + return RuntimeProfile(binding.sandbox, binding.journey, binding=binding) + raise ValueError("runtime_binding_target_mismatch") + + 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") not in {_SCHEMA, "certforge.runtime.v2"}: + raise ValueError("runtime_profile_schema_invalid") + allowed = {"schema_version", "sandbox", "journey"} + if raw.get("schema_version") == "certforge.runtime.v2": + allowed.add("project_bindings") + if set(raw) - allowed: + 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) + bindings = parse_bindings(raw.get("project_bindings", []), docker=command, + sandbox_fields=_FIELDS, validate_sandbox=_sandbox_inputs, + validate_journey=_journey) + return RuntimeProfile(sandbox, _journey(raw.get("journey")), bindings) + + +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, + runtime_binding_sha256: str | None = None, +) -> EnvironmentIdentity: + config = _sandbox_inputs(sandbox) + if runtime_binding_sha256 is not None: + if not re.fullmatch(r"[0-9a-f]{64}", runtime_binding_sha256): + raise ValueError("runtime_binding_digest_invalid") + config = dict(config, operator_binding_sha256=runtime_binding_sha256) + 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..96ed7f9 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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" class SandboxError(RuntimeError): diff --git a/src/echo_certification_forge/service.py b/src/echo_certification_forge/service.py index 422cdf3..b3e0f7c 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,46 @@ 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) + project_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + target: SubmitTarget | None = None + + @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 +537,45 @@ 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: + principal = 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") + if (request.project_id is None) != (request.target is None): + raise HTTPException(status_code=422, detail="runtime_project_target_pair_required") + try: + if request.project_id is not None: + context.subscribers.get_project(principal, request.project_id, required_status="ACTIVE") + profile = profile.select(principal.organization_id, request.project_id, request.target.worker_spec()) + 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"), + runtime_binding_sha256=profile.binding.digest if profile.binding else None, + ) + except SubscriberError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc + except ValueError as exc: + code = str(exc) + if code in {"runtime_profile_mismatch", "runtime_journey_invalid", "runtime_binding_target_mismatch"}: + 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, 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_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_subscriber_governance.py b/tests/test_p7_subscriber_governance.py index ce9ba38..4d3bb39 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", @@ -2958,7 +3024,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 +3644,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..152dc36 --- /dev/null +++ b/tests/test_release_pins.py @@ -0,0 +1,69 @@ +"""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:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111" +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 + + +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_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_admission.py b/tests/test_runtime_admission.py new file mode 100644 index 0000000..9f4de83 --- /dev/null +++ b/tests/test_runtime_admission.py @@ -0,0 +1,161 @@ +import copy +import dataclasses +import json + +import pytest + +from echo_certification_forge.canonical import sha256_bytes +from echo_certification_forge.runtime_identity import load_runtime_profile, measured_environment +from echo_certification_forge.sandbox import DEFAULT_IMAGE, DockerSandbox, SandboxResult +from echo_certification_forge.signing import Ed25519VerdictSigner +from test_runtime_job_binding import runtime_api + +SHA = "a" * 40 +REPO = "https://github.com/example/fielddesk.git" +IMAGE = "sha256:" + "e" * 64 +JOURNEY = ["python3", "-B", "scripts/certforge_journey.py"] + + +def config(tenant="tenant-a", project="project-a"): + return {"schema_version": "certforge.runtime.v2", "project_bindings": [{ + "binding_id": "fielddesk-exact", "tenant_id": tenant, "project_id": project, + "repository": REPO, "source_commit": SHA, + "dependencies": {"package-lock.json": sha256_bytes(b"locked dependencies\n")}, + "sandbox": {"image": IMAGE, "memory": "2g", "timeout_s": 120}, "journey": JOURNEY, + }]} + + +def profile(tmp_path, value): + path = tmp_path / "operator.json" + path.write_text(json.dumps(value), encoding="utf-8") + return load_runtime_profile(path) + + +def spec(**changes): + return dict({"type": "git", "url": REPO, "ref": SHA, "source_commit": SHA}, **changes) + + +def test_operator_selects_exact_source_and_keeps_default_isolation(tmp_path, manifest): + catalog = profile(tmp_path, config()) + selected = catalog.select("tenant-a", "project-a", spec()) + assert selected.sandbox.image == IMAGE + assert selected.sandbox.memory == "2g" + selected.check_execution(selected.sandbox, JOURNEY) + assert catalog.sandbox == DockerSandbox() + assert catalog.sandbox.image == DEFAULT_IMAGE + assert catalog.select("tenant-b", "project-a", spec()) is catalog + assert catalog.select("tenant-a", "project-b", spec()) is catalog + assert catalog.select("tenant-a", "project-a", spec(url="https://github.com/example/other.git")) is catalog + base = measured_environment(manifest=manifest, sandbox=selected.sandbox, journey=JOURNEY) + bound = measured_environment(manifest=manifest, sandbox=selected.sandbox, journey=JOURNEY, + runtime_binding_sha256=selected.binding.digest) + assert base.identity_digest != bound.identity_digest + changed = config() + changed["project_bindings"][0]["dependencies"]["package-lock.json"] = "f" * 64 + replacement = profile(tmp_path, changed).select("tenant-a", "project-a", spec()) + assert selected.binding.digest != replacement.binding.digest + + +@pytest.mark.parametrize("change", [{"ref": "main"}, {"source_commit": "b" * 40}, {"type": "local"}]) +def test_configured_project_rejects_unadmitted_source(tmp_path, change): + with pytest.raises(ValueError, match="runtime_binding_target_mismatch"): + profile(tmp_path, config()).select("tenant-a", "project-a", spec(**change)) + + +@pytest.mark.parametrize("change", [ + {"sandbox": {"image": "python:latest"}}, {"sandbox": {"image": IMAGE, "user": "0:0"}}, + {"sandbox": {"image": IMAGE, "network": "host"}}, {"sandbox": {"image": IMAGE, "extra_env": {}}}, + {"dependencies": {"../outside": "a" * 64}}, {"dependencies": {"C:/outside": "a" * 64}}, + {"dependencies": {}}, {"journey": None}, {"source_commit": "main"}, + {"dependencies": {"bad\u0000path": "a" * 64}}, + {"repository": "https://user:password@example.test/repo"}, {"unexpected": True}, +]) +def test_operator_binding_rejects_unsafe_or_incomplete_input(tmp_path, change): + value = config() + value["project_bindings"][0].update(change) + with pytest.raises(ValueError): + profile(tmp_path, value) + + +def test_duplicate_admission_and_v1_extension_rejected(tmp_path): + value = config() + value["project_bindings"].append(copy.deepcopy(value["project_bindings"][0])) + with pytest.raises(ValueError, match="runtime_binding_duplicate"): + profile(tmp_path, value) + value = config() + value["schema_version"] = "certforge.runtime.v1" + with pytest.raises(ValueError, match="runtime_profile_fields_invalid"): + profile(tmp_path, value) + + +@pytest.mark.parametrize("content", [b"changed", b"x" * (2 * 1024 * 1024 + 1), None], ids=["changed", "too-large", "missing"]) +def test_dependency_mismatch_or_missing_file_denied(tmp_path, content): + selected = profile(tmp_path, config()).select("tenant-a", "project-a", spec()) + if content is not None: + (tmp_path / "package-lock.json").write_bytes(content) + with pytest.raises(ValueError, match="runtime_binding_dependency_mismatch"): + selected.binding.check_source(tmp_path, SHA) + + +@pytest.mark.parametrize("tampered", [False, True]) +def test_api_and_worker_select_same_profile_and_fence_dependencies(runtime_api, tmp_path, monkeypatch, tampered): + from echo_certification_forge import run_worker + from echo_certification_forge.acquisition import acquire_target + from echo_certification_forge.models import TargetIdentity, declared_target_identity_digest + + client, context, headers = runtime_api + context.certification_environment = {} + source = tmp_path / "source" + source.mkdir() + (source / "package-lock.json").write_bytes(b"changed" if tampered else b"locked dependencies\n") + acquired = dataclasses.replace(acquire_target({"type": "local", "path": str(source)}, tmp_path / "unused"), + target_type="git", canonical_ref=REPO + "@" + SHA, source_commit=SHA) + target = TargetIdentity(headers["X-Tenant-ID"], "git", acquired.canonical_ref, acquired.artifact_sha256, source_commit=SHA) + project = client.post("/v1/subscriber/projects", headers=headers, json={ + "slug": "fielddesk", "name": "FieldDesk fixture", "target_reference": target.canonical_ref, + }).json()["project_id"] + context.runtime_profile = profile(tmp_path, config(headers["X-Tenant-ID"], project)) + target_body = {"target_type": "git", "reference": target.canonical_ref, "url": REPO, "ref": SHA, + "source_commit": SHA, "identity_digest": declared_target_identity_digest( + headers["X-Tenant-ID"], "git", None, SHA, target.canonical_ref)} + body = {"journey": JOURNEY, "project_id": project, "target": target_body} + response = client.post("/v1/subscriber/runtime/environment", headers=headers, json=body) + assert response.status_code == 200, response.text + declaration = response.json()["submit_environment"] + assert declaration["runner_image_digest"] == IMAGE + wrong = copy.deepcopy(body) + wrong["target"]["ref"] = "b" * 40 + assert client.post("/v1/subscriber/runtime/environment", headers=headers, json=wrong).status_code == 422 + wrong = dict(body, project_id="other-project") + assert client.post("/v1/subscriber/runtime/environment", headers=headers, json=wrong).status_code == 404 + assert not context.store.list_runs(headers["X-Tenant-ID"]) + submitted = client.post("/v1/certifications", headers=headers, json={ + "tenant_id": headers["X-Tenant-ID"], "project_id": project, "target": target_body, + "environment": declaration, "policy_version": context.manifest.manifest_id, + "idempotency_key": "bound-fielddesk-fixture", "journey": JOURNEY, + }) + assert submitted.status_code == 201, submitted.text + monkeypatch.setattr(run_worker, "acquire_target", lambda *args: acquired) + executions = [] + monkeypatch.setattr(DockerSandbox, "run", lambda self, argv, *args: + executions.append((self.image, self.memory, argv)) or SandboxResult(0, "fixture", "", False)) + result = run_worker.run(submitted.json()["run_id"], headers["X-Tenant-ID"], spec(), + store=context.store, manifest=context.manifest, signer=Ed25519VerdictSigner.generate(), + subscribers=context.subscribers, sandbox=context.runtime_profile.sandbox, + runtime_profile=context.runtime_profile, journey=JOURNEY) + if tampered: + assert result["error"] == "runtime_binding_source_mismatch" + assert not executions + else: + assert "error" not in result, result + assert result["environment_identity_digest"] == declaration["identity_digest"] + assert result["release_verdict"] == "NOT_READY" # Fixture transport cannot certify production. + assert executions == [(IMAGE, "2g", JOURNEY)] + + +@pytest.mark.parametrize("extra", [{"project_id": "project-a"}, {"target": { + "target_type": "git", "reference": REPO, "identity_digest": "a" * 64}}, {"profile": "fielddesk"}]) +def test_resolution_rejects_partial_binding_or_caller_profile(runtime_api, extra): + client, _context, headers = runtime_api + assert client.post("/v1/subscriber/runtime/environment", headers=headers, + json=dict({"journey": JOURNEY}, **extra)).status_code == 422 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..efd5c3f --- /dev/null +++ b/tests/test_runtime_job_binding.py @@ -0,0 +1,250 @@ +"""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_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"