diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 5b5ea53622..d51c67e2bd 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -209,6 +209,12 @@ The committed-main Linux evidence is recorded in `docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md`. That receipt is artifact-bound operator self-attestation, not an independently authenticated external assessment. The safe profile therefore remains opt-in. +The current attester requires a fresh schema-v4 run: it expands a committed +production-source manifest, requires exact source membership, binds ordered +scenario claims to a retained secret-free JSONL transcript, validates typed +receipt/report chronology, and hashes the intended repository record path +inside the receipt. The existing v3 record remains historical and is not +silently promoted to this stronger evidence class. Monitoring never implies remediation. On the supported Linux systemd profile, an operator may separately enroll the typed action runner: diff --git a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md index 273fe8364a..d3453f1818 100644 --- a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md +++ b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md @@ -117,16 +117,24 @@ It is historical evidence, not qualification of the current implementation. The v3 guarded lab requires a real verified apt-cache mutation, a separate stale-fingerprint refusal, durable receipt replay, an observed collector authority-reduction request (with persistence covered by API regressions), -complete boundary-source hashes, and artifact Go build stamps for the exact +the then-required fixed boundary-source hashes, and artifact Go build stamps for the exact clean commit. A fresh Ubuntu 24.04.4/systemd 255 run at committed main `b2543c5c6e03bc8f38502098d3a356983d0d41b2` passed all twelve scenarios; its separate receipt and attestation are recorded as `internal/records/secure-agent-runtime-systemd-receipt-v3-2026-08-30.json` and `internal/records/secure-agent-runtime-committed-main-attestation-v3-2026-08-30.json`. -`scripts/release_control/secure_runtime_attestation.py` rejects older receipts -and labels accepted evidence as artifact-bound, self-attested systemd evidence -rather than independently authenticated proof. The repository still needs a -fresh exact committed release-candidate run, +That schema-v3 receipt is now historical input only. The post-audit attester +requires schema v4 and will not reinterpret v3 as proof of the current tree. +V4 expands a committed source manifest across the production collector, +helper, runner, host-agent provider, API admission, update, configuration, and +receipt roots; requires the receipt hashes to match that exact set; validates +ordered timestamps and scenario-specific causal claims; binds every scenario +to a retained secret-free JSONL transcript event; validates typed receipt kind +and report chronology; and requires the intended repository record path to be +inside the hashed receipt. No schema-v4 live receipt exists yet. Accepted v4 +evidence will remain artifact-bound, self-attested systemd evidence rather than +an independently authenticated assessment. The repository still needs a fresh +exact committed release-candidate run, representative Proxmox, SMART, Docker and rootless Podman telemetry/action parity, appliance profiles, and the external security review. Until those proofs are recorded, the safe profile remains opt-in and provider degradation diff --git a/docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md b/docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md index 93967263d2..af628e4267 100644 --- a/docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md +++ b/docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md @@ -113,18 +113,36 @@ The secret-free schema-v3 receipt is separate `secure-agent-runtime-committed-main-attestation-v3-2026-08-30.json` has SHA-256 `e1339f73704883aec19225d886188dad78338659b16a11e785091b41bbf0f0c1` and -verifies the receipt, current-main ancestry at attestation time, all required -boundary-source hashes, all artifact hashes, the expected Go command packages, +verifies the receipt, current-main ancestry at attestation time, all +then-required fixed boundary-source hashes, all artifact hashes, the expected Go command packages, and clean exact-commit Go VCS identities. The authority-reduction request was observed by the systemd fixture; durable production-store reduction and exact session invalidation remain established by the focused API regressions rather than by this in-process fixture. The receipt remains operator-produced, unauthenticated, artifact-bound self-attestation rather than independent proof. +## Post-audit schema-v4 acceptance floor + +Schema v3 remains immutable historical evidence, but it is no longer accepted +by the current attester as qualification of a newer tree. A future run must use +schema v4. The committed +`scripts/release_control/secure_runtime_source_manifest_v4.json` expands the +production collector, helper, runner, host-agent provider, API admission, +configuration, update, and receipt roots, and the receipt must hash exactly +that expanded source set. Each scenario must carry ordered UTC start/completion +times, typed causal claims, and an exact reference to a retained secret-free +JSONL transcript event. The receipt also binds its intended repository path, +the transcript path and digest, the typed action receipt kind, report count and +report chronology. The attestation preserves those bindings alongside the +artifact build identities. No v4 live receipt or attestation is recorded here; +this is the acceptance contract for the next exact-commit or exact-RC run, not +a retroactive upgrade of the v3 evidence. + ## Proof classification and residuals -Focused regressions and the schema-v3 guarded systemd evidence cover the current -committed-main semantics. They are not a substitute for exact release-candidate +Focused regressions cover current code-level semantics. The schema-v3 guarded +systemd evidence covers only its historical qualified commit and the explicitly +exercised subset above. Neither is a substitute for exact release-candidate reproduction, representative provider/appliance qualification, or external review. The canonical support and residual matrix is published in `docs/AGENT_SECURITY.md`; unqualified rows remain explicit blockers rather than diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index 5b5ea53622..d51c67e2bd 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -209,6 +209,12 @@ The committed-main Linux evidence is recorded in `docs/release-control/v6/internal/records/secure-agent-runtime-qualification-foundation-2026-08-30.md`. That receipt is artifact-bound operator self-attestation, not an independently authenticated external assessment. The safe profile therefore remains opt-in. +The current attester requires a fresh schema-v4 run: it expands a committed +production-source manifest, requires exact source membership, binds ordered +scenario claims to a retained secret-free JSONL transcript, validates typed +receipt/report chronology, and hashes the intended repository record path +inside the receipt. The existing v3 record remains historical and is not +silently promoted to this stronger evidence class. Monitoring never implies remediation. On the supported Linux systemd profile, an operator may separately enroll the typed action runner: diff --git a/scripts/installtests/secure_runtime_systemd_lab_test.go b/scripts/installtests/secure_runtime_systemd_lab_test.go index 06686ad4b7..3500a7c620 100644 --- a/scripts/installtests/secure_runtime_systemd_lab_test.go +++ b/scripts/installtests/secure_runtime_systemd_lab_test.go @@ -26,6 +26,9 @@ package installtests // PULSE_SECURE_RUNTIME_HELPER="$repo/.lab-artifacts/pulse-agent-helper" \ // PULSE_SECURE_RUNTIME_RUNNER="$repo/.lab-artifacts/pulse-agent-runner" \ // PULSE_SECURE_RUNTIME_RECEIPT=/tmp/secure-runtime-receipt.json \ +// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v4.json \ +// PULSE_SECURE_RUNTIME_TRANSCRIPT=/tmp/secure-runtime-transcript.jsonl \ +// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v4.jsonl \ // sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo" // // Use the VM's GOARCH in place of arm64 when qualifying another architecture. @@ -101,6 +104,31 @@ var secureRuntimeForbiddenReceiptKeys = map[string]struct{}{ "token": {}, } +var secureRuntimeScenarioClaims = map[string][]string{ + "legacy_root_command_capable_install": {"legacy_root_command_authority_observed"}, + "read_only_inspect": {"inspection_left_stable_files_unchanged"}, + "drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"}, + "safe_profile_apply": { + "collector_non_root", + "collector_monitoring_only", + "helper_protocol_healthy", + "collector_authority_reduction_observed", + }, + "explicit_safe_profile_rollback": {"explicit_rollback_preserved_reduced_authority"}, + "automatic_failure_rollback": {"failed_activation_restored_prior_runtime"}, + "ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"}, + "final_safe_profile_apply": {"collector_reporting_continued_after_migration"}, + "separate_action_runner_install": {"action_runner_registered_separately"}, + "typed_action_receipt": { + "typed_mutation_verified", + "terminal_receipt_replayed", + "stale_precondition_refused", + "generic_command_denied", + }, + "action_runner_credential_rotation": {"fixture_credential_replacement_observed"}, + "action_runner_self_revoke": {"exact_runner_binding_revoked"}, +} + type secureRuntimeLabReport struct { ReceivedAt time.Time AgentID string @@ -541,40 +569,136 @@ func secureRuntimeSeedAPTPackageCache(t *testing.T) (string, string) { } type secureRuntimeScenarioResult struct { - Name string `json:"name"` - Passed bool `json:"passed"` - Detail string `json:"detail,omitempty"` + Sequence int `json:"sequence"` + Name string `json:"name"` + Passed bool `json:"passed"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + Evidence secureRuntimeScenarioEvidence `json:"evidence"` +} + +type secureRuntimeScenarioEvidence struct { + Kind string `json:"kind"` + Summary string `json:"summary"` + Claims []string `json:"claims"` + Observations map[string]any `json:"observations"` + TranscriptEventIDs []string `json:"transcript_event_ids"` +} + +type secureRuntimeTranscriptEvent struct { + Sequence int `json:"sequence"` + EventID string `json:"event_id"` + ObservedAt string `json:"observed_at"` + Kind string `json:"kind"` + Scenario string `json:"scenario,omitempty"` + Claims []string `json:"claims,omitempty"` + Observations map[string]any `json:"observations,omitempty"` + Summary string `json:"summary,omitempty"` + Operation string `json:"operation,omitempty"` + Output string `json:"output,omitempty"` + OutputSHA256 string `json:"output_sha256,omitempty"` +} + +type secureRuntimeSourceManifestBinding struct { + SchemaVersion int `json:"schema_version"` + ManifestID string `json:"manifest_id"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + TargetOS string `json:"target_os"` + TargetArch string `json:"target_arch"` +} + +type secureRuntimeTranscriptBinding struct { + Format string `json:"format"` + RecordPath string `json:"record_path"` + SHA256 string `json:"sha256"` + EventCount int `json:"event_count"` +} + +type secureRuntimeSourceManifest struct { + SchemaVersion int `json:"schema_version"` + ManifestID string `json:"manifest_id"` + TargetOS string `json:"target_os"` + ExactPaths []string `json:"exact_paths"` + RecursiveRoots []string `json:"recursive_roots"` + IncludeSuffixes []string `json:"include_suffixes"` + ExcludeSuffixes []string `json:"exclude_suffixes"` } type secureRuntimeLabReceipt struct { - SchemaVersion int `json:"schema_version"` - CompletedAt string `json:"completed_at"` - SourceHashes map[string]string `json:"source_hashes"` - ArtifactHashes map[string]string `json:"artifact_hashes"` - ArtifactVersions map[string]string `json:"artifact_versions"` - DisposableVMGuardHash string `json:"disposable_vm_guard_sha256"` - OSRelease string `json:"os_release"` - Kernel string `json:"kernel"` - SystemdVersion string `json:"systemd_version"` - Architecture string `json:"architecture"` - CollectorServiceUser string `json:"collector_service_user"` - CollectorProcessUID int `json:"collector_process_uid"` - CollectorAuthority string `json:"collector_authority"` - AmbientCapabilitiesNone bool `json:"ambient_capabilities_none"` - HelperProtocolHealthy bool `json:"helper_protocol_healthy"` - StateIdentityPreserved bool `json:"state_identity_preserved"` - DockerDegraded bool `json:"docker_degraded"` - ActionRunnerQualified bool `json:"action_runner_qualified"` - ActionMutationVerified bool `json:"action_mutation_verified"` - CollectorAuthorityReductionRequestObserved bool `json:"collector_authority_reduction_request_observed"` - ActionReceiptKind string `json:"action_receipt_kind,omitempty"` - CredentialRotated bool `json:"credential_rotated"` - SelfRevokeObserved bool `json:"self_revoke_observed"` - CollectorContinuity bool `json:"collector_continuity"` - ReportCount int `json:"report_count"` - FirstReportAt string `json:"first_report_at"` - LastReportAt string `json:"last_report_at"` - Scenarios []secureRuntimeScenarioResult `json:"scenarios"` + SchemaVersion int `json:"schema_version"` + RecordPath string `json:"record_path"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + SourceManifest secureRuntimeSourceManifestBinding `json:"source_manifest"` + SourceHashes map[string]string `json:"source_hashes"` + ArtifactHashes map[string]string `json:"artifact_hashes"` + ArtifactVersions map[string]string `json:"artifact_versions"` + DisposableVMGuardHash string `json:"disposable_vm_guard_sha256"` + OSRelease string `json:"os_release"` + Kernel string `json:"kernel"` + SystemdVersion string `json:"systemd_version"` + Architecture string `json:"architecture"` + CollectorServiceUser string `json:"collector_service_user"` + CollectorProcessUID int `json:"collector_process_uid"` + CollectorAuthority string `json:"collector_authority"` + AmbientCapabilitiesNone bool `json:"ambient_capabilities_none"` + HelperProtocolHealthy bool `json:"helper_protocol_healthy"` + StateIdentityPreserved bool `json:"state_identity_preserved"` + DockerDegraded bool `json:"docker_degraded"` + ActionRunnerQualified bool `json:"action_runner_qualified"` + ActionMutationVerified bool `json:"action_mutation_verified"` + CollectorAuthorityReductionRequestObserved bool `json:"collector_authority_reduction_request_observed"` + ActionReceiptKind string `json:"action_receipt_kind,omitempty"` + CredentialRotated bool `json:"credential_rotated"` + SelfRevokeObserved bool `json:"self_revoke_observed"` + CollectorContinuity bool `json:"collector_continuity"` + ReportCount int `json:"report_count"` + FirstReportAt string `json:"first_report_at"` + LastReportAt string `json:"last_report_at"` + Transcript secureRuntimeTranscriptBinding `json:"transcript"` + Scenarios []secureRuntimeScenarioResult `json:"scenarios"` +} + +var secureRuntimeTranscriptRecorder struct { + sync.Mutex + enabled bool + events []secureRuntimeTranscriptEvent +} + +func secureRuntimeResetTranscript(enabled bool) { + secureRuntimeTranscriptRecorder.Lock() + defer secureRuntimeTranscriptRecorder.Unlock() + secureRuntimeTranscriptRecorder.enabled = enabled + secureRuntimeTranscriptRecorder.events = nil +} + +func secureRuntimeRecordTranscriptEvent(event secureRuntimeTranscriptEvent) secureRuntimeTranscriptEvent { + secureRuntimeTranscriptRecorder.Lock() + defer secureRuntimeTranscriptRecorder.Unlock() + if !secureRuntimeTranscriptRecorder.enabled { + return event + } + event.Sequence = len(secureRuntimeTranscriptRecorder.events) + 1 + event.EventID = fmt.Sprintf("event-%04d", event.Sequence) + if event.ObservedAt == "" { + event.ObservedAt = time.Now().UTC().Format(time.RFC3339Nano) + } + secureRuntimeTranscriptRecorder.events = append(secureRuntimeTranscriptRecorder.events, event) + return event +} + +func secureRuntimeRecordCommandOutput(operation string, output []byte) { + secureRuntimeRecordTranscriptEvent(secureRuntimeTranscriptEvent{ + Kind: "command_output", Operation: operation, Output: string(output), + OutputSHA256: secureRuntimeHash(output), + }) +} + +func secureRuntimeTranscriptSnapshot() []secureRuntimeTranscriptEvent { + secureRuntimeTranscriptRecorder.Lock() + defer secureRuntimeTranscriptRecorder.Unlock() + return append([]secureRuntimeTranscriptEvent(nil), secureRuntimeTranscriptRecorder.events...) } func TestSecureRuntimeReceiptCredentialDetection(t *testing.T) { @@ -601,6 +725,29 @@ func TestSecureRuntimeReceiptCredentialDetection(t *testing.T) { } } +func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) { + _, hashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH) + for _, required := range []string{ + "internal/agenthelper/providers.go", + "internal/agenthelper/container_inventory.go", + "internal/hostagent/action_runner_client.go", + "internal/hostagent/action_runner_health_persistence_unix.go", + "internal/hostagent/package_updates.go", + "internal/api/action_runner_credentials.go", + "internal/api/router_routes_registration.go", + "internal/agentexec/server.go", + } { + if _, ok := hashes[required]; !ok { + t.Fatalf("secure-runtime source manifest omitted transitive boundary source %s", required) + } + } + for sourcePath := range hashes { + if strings.HasSuffix(sourcePath, "_test.go") && sourcePath != "scripts/installtests/secure_runtime_systemd_lab_test.go" { + t.Fatalf("secure-runtime production source manifest included test source %s", sourcePath) + } + } +} + func TestSecureRuntimeSystemdLab(t *testing.T) { if os.Getenv(secureRuntimeLabOptIn) != "1" { t.Skip("set PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 only inside a disposable systemd VM") @@ -620,50 +767,57 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { if err != nil { t.Fatalf("resolve installer path: %v", err) } - installer := secureRuntimeReadFile(t, installerPath) fixture := newSecureRuntimeLabFixture(collectorV1, helper, runner, collectorV1Version) defer fixture.actionServer.Shutdown() server := httptest.NewServer(fixture) defer server.Close() + recordPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH")) + if recordPath == "" || filepath.IsAbs(recordPath) || filepath.ToSlash(filepath.Clean(recordPath)) != recordPath || strings.Contains(recordPath, "..") { + t.Fatalf("PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH must be a canonical repository-relative path: %q", recordPath) + } + startedAt := time.Now().UTC() + sourceManifest, sourceHashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH) receipt := secureRuntimeLabReceipt{ - SchemaVersion: 3, - SourceHashes: map[string]string{ - "scripts/install.sh": secureRuntimeHash(installer), - "scripts/installtests/secure_runtime_systemd_lab_test.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("scripts", "installtests", "secure_runtime_systemd_lab_test.go"))), - "scripts/release_control/secure_runtime_attestation.py": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("scripts", "release_control", "secure_runtime_attestation.py"))), - "internal/agenthelper/update_activation.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agenthelper", "update_activation.go"))), - "internal/agenthelper/server.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agenthelper", "server.go"))), - "internal/agentexec/server.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "server.go"))), - "internal/agentexec/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "types.go"))), - "internal/agentexec/verifier_postconditions.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentexec", "verifier_postconditions.go"))), - "internal/api/collector_authority.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "collector_authority.go"))), - "internal/api/action_runner_credentials.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "action_runner_credentials.go"))), - "internal/api/agenttokens/install.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "agenttokens", "install.go"))), - "internal/api/agent_exec_token_binding.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "api", "agent_exec_token_binding.go"))), - "internal/actionrunner/runner.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "runner.go"))), - "internal/actionrunner/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "types.go"))), - "internal/actionrunner/transport.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "actionrunner", "transport.go"))), - "internal/hostagent/storage_cleanup.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "hostagent", "storage_cleanup.go"))), - "internal/operationreceipt/store.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "operationreceipt", "store.go"))), - "internal/operationreceipt/types.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "operationreceipt", "types.go"))), - "internal/agentupdate/update.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentupdate", "update.go"))), - "internal/agentupdate/privileged_update.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("internal", "agentupdate", "privileged_update.go"))), - "cmd/pulse-agent/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent", "main.go"))), - "cmd/pulse-agent-helper/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent-helper", "main.go"))), - "cmd/pulse-agent-runner/main.go": secureRuntimeHash(secureRuntimeReadFile(t, repoFile("cmd", "pulse-agent-runner", "main.go"))), - }, + SchemaVersion: 4, + RecordPath: recordPath, + StartedAt: startedAt.Format(time.RFC3339Nano), + SourceManifest: sourceManifest, + SourceHashes: sourceHashes, ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper), "runner": secureRuntimeHash(runner)}, ArtifactVersions: map[string]string{"collector_v1": collectorV1Version, "collector_v2": collectorV2Version}, DisposableVMGuardHash: secureRuntimeHash([]byte(secureRuntimeLabMarkerValue + "\n")), Architecture: runtime.GOARCH, } + secureRuntimeResetTranscript(true) + t.Cleanup(func() { secureRuntimeResetTranscript(false) }) receipt.OSRelease = strings.TrimSpace(string(secureRuntimeReadFile(t, "/etc/os-release"))) receipt.Kernel = secureRuntimeCommand(t, 10*time.Second, "uname", "-srvmo") receipt.SystemdVersion = strings.SplitN(secureRuntimeCommand(t, 10*time.Second, "systemctl", "--version"), "\n", 2)[0] - pass := func(name, detail string) { - receipt.Scenarios = append(receipt.Scenarios, secureRuntimeScenarioResult{Name: name, Passed: true, Detail: detail}) + scenarioStartedAt := startedAt + pass := func(name, detail string, observations map[string]any) { + claims, ok := secureRuntimeScenarioClaims[name] + if !ok { + t.Fatalf("scenario %q has no governed causal claims", name) + } + completedAt := time.Now().UTC() + sequence := len(receipt.Scenarios) + 1 + claims = append([]string(nil), claims...) + event := secureRuntimeRecordTranscriptEvent(secureRuntimeTranscriptEvent{ + ObservedAt: completedAt.Format(time.RFC3339Nano), Kind: "scenario_result", + Scenario: name, Claims: claims, Observations: observations, Summary: detail, + }) + receipt.Scenarios = append(receipt.Scenarios, secureRuntimeScenarioResult{ + Sequence: sequence, Name: name, Passed: true, + StartedAt: scenarioStartedAt.Format(time.RFC3339Nano), CompletedAt: completedAt.Format(time.RFC3339Nano), + Evidence: secureRuntimeScenarioEvidence{ + Kind: "runtime-observation-v1", Summary: detail, Claims: claims, + Observations: observations, + TranscriptEventIDs: []string{event.EventID}, + }, + }) + scenarioStartedAt = completedAt } initialArgs := []string{"--enable-commands", "--command-authority", "command-capable"} @@ -678,7 +832,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { if dockerInitiallyAvailable && !dockerInitiallyEnabled { t.Fatal("rootful Docker was available but the requested legacy profile did not enable Docker monitoring") } - pass("legacy_root_command_capable_install", fmt.Sprintf("root collector installed; docker_enabled=%t", dockerInitiallyEnabled)) + pass("legacy_root_command_capable_install", fmt.Sprintf("root collector installed; docker_enabled=%t", dockerInitiallyEnabled), map[string]any{"collector_process_uid": 0, "commands_enabled": true}) beforeInspect := secureRuntimeStableSnapshot(t) inspectOutput := secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-inspect") @@ -689,7 +843,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { if afterInspect := secureRuntimeStableSnapshot(t); !secureRuntimeIdentitiesEqual(beforeInspect, afterInspect) { t.Fatal("--safe-profile-inspect mutated installer-owned stable files") } - pass("read_only_inspect", "stable installer-owned files unchanged") + pass("read_only_inspect", "stable installer-owned files unchanged", map[string]any{"stable_files_unchanged": true}) dropInDir := "/etc/systemd/system/pulse-agent.service.d" dropInPath := filepath.Join(dropInDir, "secure-runtime-lab.conf") @@ -722,7 +876,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { _ = os.Remove(dropInDir) dropInPresent = false secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload") - pass("drop_in_fail_closed_rehearsal", "migration rejected before stable installer-owned files changed") + pass("drop_in_fail_closed_rehearsal", "migration rejected before stable installer-owned files changed", map[string]any{"rejected_before_mutation": true}) legacyBaseline := secureRuntimeStableSnapshot(t) reportsBeforeApply, preApplyLastSeen, _, _ := fixture.snapshot() @@ -744,14 +898,14 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { t.Fatalf("safe migration did not make rootful Docker degradation explicit:\n%s", applyOutput) } } - pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health") + pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health", map[string]any{"collector_service_user": "pulse-agent", "collector_authority": "monitoring-only", "helper_status": "ok"}) reportsBeforeRollback, _, _, _ := fixture.snapshot() secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-rollback") secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRollback)+1, 45*time.Second) secureRuntimeAssertRootMonitoringProfile(t) secureRuntimeWaitForStableIdentity(t, secureRuntimeWithoutCollectorUnit(legacyBaseline), 10*time.Second, "explicit rollback") - pass("explicit_safe_profile_rollback", "legacy binary and service identity restored without resurrecting collector command authority") + pass("explicit_safe_profile_rollback", "legacy binary and service identity restored without resurrecting collector command authority", map[string]any{"restored_profile": "root-monitoring", "commands_enabled": false}) automaticRollbackBaseline := secureRuntimeStableSnapshot(t) fixture.setFrozen(true) @@ -762,7 +916,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { } secureRuntimeWaitForStableIdentity(t, secureRuntimeWithoutCollectorUnit(automaticRollbackBaseline), 10*time.Second, "automatic failure rollback") secureRuntimeAssertRootMonitoringProfile(t) - pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/state identity without command authority") + pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/state identity without command authority", map[string]any{"activation_committed": false, "restored_profile": "root-monitoring"}) fixture.setCollector(collectorV2) reportsBeforeUpdate, _, _, _ := fixture.snapshot() @@ -772,7 +926,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { if got := secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/bin/pulse-agent")); got != secureRuntimeHash(collectorV2) { t.Fatalf("ordinary update collector hash = %s, want v2 hash", got) } - pass("ordinary_update_non_migration", "binary updated while the downgraded root monitoring profile remained unchanged") + pass("ordinary_update_non_migration", "binary updated while the downgraded root monitoring profile remained unchanged", map[string]any{"collector_v2_installed": true, "selected_profile": "root-monitoring"}) fixture.setServerVersion(collectorV2Version) reportsBeforeFinalApply, preFinalLastSeen, _, _ := fixture.snapshot() @@ -789,7 +943,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { secureRuntimeAssertHelperProtocol(t) reportsBeforeContinuity, _, _, _ := fixture.snapshot() secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second) - pass("final_safe_profile_apply", "collector continued reporting after committed migration") + pass("final_safe_profile_apply", "collector continued reporting after committed migration", map[string]any{"collector_service_user": "pulse-agent", "continuity_report_observed": true}) reportsBeforeRunner, _, _, _ := fixture.snapshot() secureRuntimeRunInstallerWithActionCredential(t, installerPath, server.URL, secureRuntimeRunnerSecretV1, @@ -797,7 +951,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { secureRuntimeWaitForActionRunner(t, fixture, true, 30*time.Second) secureRuntimeAssertActionRunnerInstalled(t) secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRunner)+1, 20*time.Second) - pass("separate_action_runner_install", "root action runner registered independently while the collector remained non-root and reporting") + pass("separate_action_runner_install", "root action runner registered independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent"}) actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second) defer cancelAction() @@ -843,7 +997,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { }); err == nil || !strings.Contains(err.Error(), "typed action-runner") { t.Fatalf("action runner did not reject generic command dispatch: %v", err) } - pass("typed_action_receipt", "verified apt cache mutation and durable terminal receipt; stale fingerprint refused before mutation; generic command dispatch denied") + pass("typed_action_receipt", "verified apt cache mutation and durable terminal receipt; stale fingerprint refused before mutation; generic command dispatch denied", map[string]any{"action_receipt_kind": agentexec.HostStorageCleanupReceiptKind, "mutation_started": true, "verification": "verified", "stale_precondition_mutation_started": false, "generic_command_denied": true}) currentAdmission, _, _ := fixture.actionSnapshot() wrongAdmission := currentAdmission @@ -863,7 +1017,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { if revoked || rotatedAdmission.TokenID != secureRuntimeRunnerBindingV2 { t.Fatalf("rotated action-runner admission = %+v revoked=%t", rotatedAdmission, revoked) } - pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered") + pass("action_runner_credential_rotation", "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered", map[string]any{"proof_scope": "in-memory-fixture", "superseded_session_invalidated": true, "replacement_registered": true}) reportsBeforeRemoval, _, _, _ := fixture.snapshot() uninstallOutput := secureRuntimeRunStandaloneInstaller(t, installerPath, "--uninstall-action-runner") @@ -879,7 +1033,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { secureRuntimeAssertSafeProfile(t) secureRuntimeAssertHelperProtocol(t) secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRemoval)+1, 20*time.Second) - pass("action_runner_self_revoke", "uninstall revoked the exact host binding and removed only runner state; collector/helper continuity remained healthy") + pass("action_runner_self_revoke", "uninstall revoked the exact host binding and removed only runner state; collector/helper continuity remained healthy", map[string]any{"revocation_count": revokeCount, "collector_continuity": true}) reports, _, _, _ := fixture.snapshot() if len(reports) == 0 { @@ -916,7 +1070,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { receipt.ReportCount = len(reports) receipt.FirstReportAt = reports[0].ReceivedAt.Format(time.RFC3339Nano) receipt.LastReportAt = reports[len(reports)-1].ReceivedAt.Format(time.RFC3339Nano) - secureRuntimeWriteReceipt(t, receipt) + secureRuntimeWriteEvidence(t, &receipt, secureRuntimeTranscriptSnapshot()) } func secureRuntimeRequireDisposableHost(t *testing.T) { @@ -995,6 +1149,81 @@ func secureRuntimeHash(data []byte) string { return hex.EncodeToString(sum[:]) } +func secureRuntimeLoadSourceBoundary(t *testing.T, targetArch string) (secureRuntimeSourceManifestBinding, map[string]string) { + t.Helper() + repoRoot, err := filepath.Abs(repoFile()) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v4.json" + manifestRaw := secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(manifestRelative))) + var manifest secureRuntimeSourceManifest + if err := json.Unmarshal(manifestRaw, &manifest); err != nil { + t.Fatalf("decode secure-runtime source manifest: %v", err) + } + if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v4" || manifest.TargetOS != "linux" { + t.Fatalf("unsupported secure-runtime source manifest: %+v", manifest) + } + sourceHashes := make(map[string]string) + addSource := func(relative string) { + if relative == "" || filepath.IsAbs(relative) || strings.Contains(relative, "..") || filepath.ToSlash(filepath.Clean(relative)) != relative { + t.Fatalf("source manifest contains non-canonical path %q", relative) + } + sourceHashes[relative] = secureRuntimeHash(secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(relative)))) + } + for _, relative := range manifest.ExactPaths { + addSource(relative) + } + if _, ok := sourceHashes[manifestRelative]; !ok { + t.Fatal("secure-runtime source manifest does not include itself") + } + for _, relativeRoot := range manifest.RecursiveRoots { + root := filepath.Join(repoRoot, filepath.FromSlash(relativeRoot)) + matched := 0 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + included := false + for _, suffix := range manifest.IncludeSuffixes { + included = included || strings.HasSuffix(relative, suffix) + } + for _, suffix := range manifest.ExcludeSuffixes { + if strings.HasSuffix(relative, suffix) { + included = false + } + } + if included { + addSource(relative) + matched++ + } + return nil + }) + if err != nil { + t.Fatalf("expand secure-runtime source root %s: %v", relativeRoot, err) + } + if matched == 0 { + t.Fatalf("secure-runtime source root %s matched no production source", relativeRoot) + } + } + return secureRuntimeSourceManifestBinding{ + SchemaVersion: manifest.SchemaVersion, + ManifestID: manifest.ManifestID, + Path: manifestRelative, + SHA256: secureRuntimeHash(manifestRaw), + TargetOS: manifest.TargetOS, + TargetArch: targetArch, + }, sourceHashes +} + func secureRuntimeRunInstaller(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) string { t.Helper() out, err := secureRuntimeRunInstallerErrorWithActionCredential(t, installerPath, fixtureURL, "", scenarioArgs...) @@ -1059,6 +1288,7 @@ func secureRuntimeRunInstallerErrorWithActionCredential(t *testing.T, installerP return string(out), fmt.Errorf("installer timed out: %w", ctx.Err()) } secureRuntimeAssertNoCredentialExposure(t, out) + secureRuntimeRecordCommandOutput("installer "+strings.Join(scenarioArgs, " "), out) return string(out), err } @@ -1073,6 +1303,7 @@ func secureRuntimeRunStandaloneInstaller(t *testing.T, installerPath string, sce t.Fatalf("standalone installer timed out: %v\n%s", ctx.Err(), out) } secureRuntimeAssertNoCredentialExposure(t, out) + secureRuntimeRecordCommandOutput("standalone installer "+strings.Join(scenarioArgs, " "), out) if err != nil { t.Fatalf("standalone installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out) } @@ -1122,6 +1353,8 @@ func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args if err != nil { t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) } + secureRuntimeAssertNoCredentialExposure(t, out) + secureRuntimeRecordCommandOutput(name, out) return strings.TrimSpace(string(out)) } @@ -1431,7 +1664,7 @@ func secureRuntimeCollectorOwnedRootlessAvailable(t *testing.T) bool { return false } -func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) { +func secureRuntimeWriteEvidence(t *testing.T, receipt *secureRuntimeLabReceipt, events []secureRuntimeTranscriptEvent) { t.Helper() path := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_RECEIPT")) if path == "" { @@ -1440,6 +1673,35 @@ func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) { if !filepath.IsAbs(path) { t.Fatalf("PULSE_SECURE_RUNTIME_RECEIPT must be an absolute path: %s", path) } + transcriptPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_TRANSCRIPT")) + if transcriptPath == "" || !filepath.IsAbs(transcriptPath) { + t.Fatalf("PULSE_SECURE_RUNTIME_TRANSCRIPT must be an absolute path: %s", transcriptPath) + } + transcriptRecordPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH")) + if transcriptRecordPath == "" || filepath.IsAbs(transcriptRecordPath) || filepath.ToSlash(filepath.Clean(transcriptRecordPath)) != transcriptRecordPath || strings.Contains(transcriptRecordPath, "..") { + t.Fatalf("PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH must be a canonical repository-relative path: %q", transcriptRecordPath) + } + var transcript bytes.Buffer + for _, event := range events { + encodedEvent, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal secure-runtime transcript event: %v", err) + } + if secureRuntimeReceiptContainsCredential(encodedEvent) { + t.Fatal("refusing to write a transcript containing credential material or token-labelled fields") + } + transcript.Write(encodedEvent) + transcript.WriteByte('\n') + } + transcriptBytes := transcript.Bytes() + receipt.Transcript = secureRuntimeTranscriptBinding{ + Format: "jsonl-v1", RecordPath: transcriptRecordPath, + SHA256: secureRuntimeHash(transcriptBytes), EventCount: len(events), + } + if err := secureRuntimeWritePrivateAtomic(transcriptPath, transcriptBytes); err != nil { + t.Fatalf("publish secure-runtime transcript: %v", err) + } + encoded, err := json.MarshalIndent(receipt, "", " ") if err != nil { t.Fatalf("marshal secure-runtime receipt: %v", err) @@ -1448,18 +1710,22 @@ func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) { t.Fatal("refusing to write a receipt containing credential material or token-labelled fields") } encoded = append(encoded, '\n') - temporary := path + ".tmp" - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("create receipt directory: %v", err) - } - if err := os.WriteFile(temporary, encoded, 0o600); err != nil { - t.Fatalf("write receipt temporary file: %v", err) - } - if err := os.Rename(temporary, path); err != nil { + if err := secureRuntimeWritePrivateAtomic(path, encoded); err != nil { t.Fatalf("publish receipt: %v", err) } } +func secureRuntimeWritePrivateAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, data, 0o600); err != nil { + return err + } + return os.Rename(temporary, path) +} + func secureRuntimeReceiptContainsCredential(encoded []byte) bool { for _, credential := range [][]byte{ []byte(secureRuntimeLabToken), diff --git a/scripts/release_control/secure_runtime_attestation.py b/scripts/release_control/secure_runtime_attestation.py index cccafab6dc..2c4932802f 100644 --- a/scripts/release_control/secure_runtime_attestation.py +++ b/scripts/release_control/secure_runtime_attestation.py @@ -10,12 +10,18 @@ import math import re import subprocess import sys +from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, Sequence COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +RECEIPT_SCHEMA_VERSION = 4 +ATTESTATION_SCHEMA_VERSION = 4 +SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v4.json" +SOURCE_MANIFEST_SCHEMA_VERSION = 1 +SOURCE_MANIFEST_ID = "secure-runtime-linux-v4" REQUIRED_SCENARIOS = ( "legacy_root_command_capable_install", "read_only_inspect", @@ -30,6 +36,58 @@ REQUIRED_SCENARIOS = ( "action_runner_credential_rotation", "action_runner_self_revoke", ) +SCENARIO_REQUIRED_CLAIMS = { + "legacy_root_command_capable_install": {"legacy_root_command_authority_observed"}, + "read_only_inspect": {"inspection_left_stable_files_unchanged"}, + "drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"}, + "safe_profile_apply": { + "collector_non_root", + "collector_monitoring_only", + "helper_protocol_healthy", + "collector_authority_reduction_observed", + }, + "explicit_safe_profile_rollback": {"explicit_rollback_preserved_reduced_authority"}, + "automatic_failure_rollback": {"failed_activation_restored_prior_runtime"}, + "ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"}, + "final_safe_profile_apply": {"collector_reporting_continued_after_migration"}, + "separate_action_runner_install": {"action_runner_registered_separately"}, + "typed_action_receipt": { + "typed_mutation_verified", + "terminal_receipt_replayed", + "stale_precondition_refused", + "generic_command_denied", + }, + "action_runner_credential_rotation": {"fixture_credential_replacement_observed"}, + "action_runner_self_revoke": {"exact_runner_binding_revoked"}, +} +SCENARIO_REQUIRED_OBSERVATIONS = { + "legacy_root_command_capable_install": {"collector_process_uid": 0, "commands_enabled": True}, + "read_only_inspect": {"stable_files_unchanged": True}, + "drop_in_fail_closed_rehearsal": {"rejected_before_mutation": True}, + "safe_profile_apply": { + "collector_service_user": "pulse-agent", + "collector_authority": "monitoring-only", + "helper_status": "ok", + }, + "explicit_safe_profile_rollback": {"restored_profile": "root-monitoring", "commands_enabled": False}, + "automatic_failure_rollback": {"activation_committed": False, "restored_profile": "root-monitoring"}, + "ordinary_update_non_migration": {"collector_v2_installed": True, "selected_profile": "root-monitoring"}, + "final_safe_profile_apply": {"collector_service_user": "pulse-agent", "continuity_report_observed": True}, + "separate_action_runner_install": {"runner_service_user": "root", "collector_service_user": "pulse-agent"}, + "typed_action_receipt": { + "action_receipt_kind": "pulse.host_storage_cleanup_result", + "mutation_started": True, + "verification": "verified", + "stale_precondition_mutation_started": False, + "generic_command_denied": True, + }, + "action_runner_credential_rotation": { + "proof_scope": "in-memory-fixture", + "superseded_session_invalidated": True, + "replacement_registered": True, + }, + "action_runner_self_revoke": {"revocation_count": 1, "collector_continuity": True}, +} ARTIFACT_ARGUMENTS = { "collector_v1": "collector_v1", "collector_v2": "collector_v2", @@ -42,31 +100,6 @@ EXPECTED_ARTIFACT_PACKAGES = { "helper": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper", "runner": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-runner", } -REQUIRED_SOURCE_PATHS = { - "scripts/install.sh", - "scripts/installtests/secure_runtime_systemd_lab_test.go", - "scripts/release_control/secure_runtime_attestation.py", - "internal/agenthelper/update_activation.go", - "internal/agenthelper/server.go", - "internal/agentexec/server.go", - "internal/agentexec/types.go", - "internal/agentexec/verifier_postconditions.go", - "internal/api/collector_authority.go", - "internal/api/action_runner_credentials.go", - "internal/api/agenttokens/install.go", - "internal/api/agent_exec_token_binding.go", - "internal/actionrunner/runner.go", - "internal/actionrunner/types.go", - "internal/actionrunner/transport.go", - "internal/hostagent/storage_cleanup.go", - "internal/operationreceipt/store.go", - "internal/operationreceipt/types.go", - "internal/agentupdate/update.go", - "internal/agentupdate/privileged_update.go", - "cmd/pulse-agent/main.go", - "cmd/pulse-agent-helper/main.go", - "cmd/pulse-agent-runner/main.go", -} DISPOSABLE_VM_GUARD_SHA256 = hashlib.sha256( b"PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1\n" ).hexdigest() @@ -171,48 +204,227 @@ def reject_sensitive_keys(value: Any, path: str = "receipt") -> None: reject_sensitive_keys(child, f"{path}[{index}]") +def canonical_repo_path(value: Any, label: str) -> str: + if not isinstance(value, str) or not value: + raise AttestationError(f"{label} must be a non-empty repository-relative path") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or str(path) != value: + raise AttestationError(f"{label} must be a canonical repository-relative path") + return value + + +def load_source_manifest(checkout: Path, commit: str) -> tuple[dict[str, Any], bytes, set[str]]: + raw = run_git(checkout, "show", f"{commit}:{SOURCE_MANIFEST_PATH}").stdout + try: + manifest = json.loads(raw) + except json.JSONDecodeError as exc: + raise AttestationError(f"unable to decode {SOURCE_MANIFEST_PATH}: {exc}") from exc + if not isinstance(manifest, dict): + raise AttestationError("secure-runtime source manifest must be an object") + if manifest.get("schema_version") != SOURCE_MANIFEST_SCHEMA_VERSION: + raise AttestationError("secure-runtime source manifest schema is unsupported") + if manifest.get("manifest_id") != SOURCE_MANIFEST_ID: + raise AttestationError("secure-runtime source manifest identity is unsupported") + if manifest.get("target_os") != "linux": + raise AttestationError("secure-runtime source manifest must target Linux") + + exact_paths = manifest.get("exact_paths") + recursive_roots = manifest.get("recursive_roots") + include_suffixes = manifest.get("include_suffixes") + exclude_suffixes = manifest.get("exclude_suffixes") + if not isinstance(exact_paths, list) or not exact_paths: + raise AttestationError("secure-runtime source manifest exact_paths must be a non-empty list") + if not isinstance(recursive_roots, list) or not recursive_roots: + raise AttestationError("secure-runtime source manifest recursive_roots must be a non-empty list") + if not isinstance(include_suffixes, list) or not include_suffixes: + raise AttestationError("secure-runtime source manifest include_suffixes must be a non-empty list") + if not isinstance(exclude_suffixes, list): + raise AttestationError("secure-runtime source manifest exclude_suffixes must be a list") + if any(not isinstance(suffix, str) or not suffix for suffix in include_suffixes + exclude_suffixes): + raise AttestationError("secure-runtime source manifest suffixes must be non-empty strings") + + expected = {canonical_repo_path(value, "source manifest exact path") for value in exact_paths} + if SOURCE_MANIFEST_PATH not in expected: + raise AttestationError("secure-runtime source manifest must include itself") + for raw_root in recursive_roots: + root = canonical_repo_path(raw_root, "source manifest recursive root").rstrip("/") + tree = run_git(checkout, "ls-tree", "-r", "--name-only", commit, "--", root).stdout + matched = 0 + for raw_path in tree.decode("utf-8", errors="strict").splitlines(): + source_path = canonical_repo_path(raw_path, "source manifest expanded path") + if not any(source_path.endswith(suffix) for suffix in include_suffixes): + continue + if any(source_path.endswith(suffix) for suffix in exclude_suffixes): + continue + expected.add(source_path) + matched += 1 + if matched == 0: + raise AttestationError(f"secure-runtime source manifest root has no production sources: {root}") + return manifest, raw, expected + + def load_receipt(path: Path) -> tuple[dict[str, Any], bytes]: try: raw = path.read_bytes() receipt = json.loads(raw) except (OSError, json.JSONDecodeError) as exc: raise AttestationError(f"unable to load receipt {path}: {exc}") from exc - if not isinstance(receipt, dict) or receipt.get("schema_version") != 3: - raise AttestationError("receipt must be a schema_version 3 object") + if not isinstance(receipt, dict) or receipt.get("schema_version") != RECEIPT_SCHEMA_VERSION: + raise AttestationError(f"receipt must be a schema_version {RECEIPT_SCHEMA_VERSION} object") reject_sensitive_keys(receipt) return receipt, raw -def verify_scenarios(receipt: dict[str, Any]) -> None: +def parse_utc_timestamp(value: Any, label: str) -> datetime: + if not isinstance(value, str) or not value: + raise AttestationError(f"{label} must be an RFC3339 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise AttestationError(f"{label} must be an RFC3339 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed): + raise AttestationError(f"{label} must be UTC") + return parsed + + +def load_and_verify_transcript( + path: Path, receipt: dict[str, Any] +) -> tuple[list[dict[str, Any]], bytes]: + try: + raw = path.read_bytes() + except OSError as exc: + raise AttestationError(f"unable to read transcript {path}: {exc}") from exc + transcript = receipt.get("transcript") + if not isinstance(transcript, dict): + raise AttestationError("receipt transcript binding must be an object") + if transcript.get("format") != "jsonl-v1": + raise AttestationError("receipt transcript format must be jsonl-v1") + canonical_repo_path(transcript.get("record_path"), "transcript record path") + expected_digest = transcript.get("sha256") + if not isinstance(expected_digest, str) or not SHA256_RE.fullmatch(expected_digest): + raise AttestationError("receipt transcript digest is invalid") + if sha256_bytes(raw) != expected_digest: + raise AttestationError("transcript digest mismatch") + + events: list[dict[str, Any]] = [] + previous_time: datetime | None = None + event_ids: set[str] = set() + for line_number, raw_line in enumerate(raw.splitlines(), 1): + if not raw_line.strip(): + raise AttestationError(f"transcript line {line_number} is empty") + try: + event = json.loads(raw_line) + except json.JSONDecodeError as exc: + raise AttestationError(f"transcript line {line_number} is not JSON") from exc + if not isinstance(event, dict): + raise AttestationError(f"transcript line {line_number} must be an object") + reject_sensitive_keys(event, f"transcript[{line_number}]") + if event.get("sequence") != line_number: + raise AttestationError("transcript event sequence is not contiguous") + event_id = event.get("event_id") + if not isinstance(event_id, str) or not event_id or event_id in event_ids: + raise AttestationError("transcript event IDs must be non-empty and unique") + event_ids.add(event_id) + observed_at = parse_utc_timestamp(event.get("observed_at"), f"transcript event {event_id} observed_at") + if previous_time is not None and observed_at < previous_time: + raise AttestationError("transcript chronology is not monotonic") + previous_time = observed_at + kind = event.get("kind") + if kind == "scenario_result": + if not isinstance(event.get("scenario"), str): + raise AttestationError(f"transcript event {event_id} has no scenario") + claims = event.get("claims") + if not isinstance(claims, list) or not claims or any(not isinstance(claim, str) or not claim for claim in claims): + raise AttestationError(f"transcript event {event_id} claims are invalid") + if len(set(claims)) != len(claims): + raise AttestationError(f"transcript event {event_id} repeats a claim") + observations = event.get("observations") + if not isinstance(observations, dict) or not observations: + raise AttestationError(f"transcript event {event_id} observations are invalid") + if not isinstance(event.get("summary"), str) or not event["summary"].strip(): + raise AttestationError(f"transcript event {event_id} summary is invalid") + elif kind == "command_output": + if not isinstance(event.get("operation"), str) or not event["operation"].strip(): + raise AttestationError(f"transcript command event {event_id} operation is invalid") + output = event.get("output") + if not isinstance(output, str): + raise AttestationError(f"transcript command event {event_id} output is invalid") + output_digest = event.get("output_sha256") + if not isinstance(output_digest, str) or output_digest != sha256_bytes(output.encode("utf-8")): + raise AttestationError(f"transcript command event {event_id} output digest is invalid") + else: + raise AttestationError(f"transcript event {event_id} has an unsupported kind") + events.append(event) + scenario_event_count = sum(event.get("kind") == "scenario_result" for event in events) + command_event_count = sum(event.get("kind") == "command_output" for event in events) + if transcript.get("event_count") != len(events) or scenario_event_count != len(REQUIRED_SCENARIOS) or command_event_count < 1: + raise AttestationError("transcript event count does not contain the canonical scenarios and raw command evidence") + return events, raw + + +def verify_scenarios(receipt: dict[str, Any], transcript_events: list[dict[str, Any]]) -> None: scenarios = receipt.get("scenarios") if not isinstance(scenarios, list): raise AttestationError("receipt scenarios must be a list") names: list[str] = [] - for scenario in scenarios: + run_started = parse_utc_timestamp(receipt.get("started_at"), "receipt started_at") + run_completed = parse_utc_timestamp(receipt.get("completed_at"), "receipt completed_at") + if run_completed < run_started: + raise AttestationError("receipt chronology ends before it starts") + for event in transcript_events: + observed = parse_utc_timestamp(event.get("observed_at"), f"transcript event {event.get('event_id')} observed_at") + if observed < run_started or observed > run_completed: + raise AttestationError("transcript event falls outside the receipt chronology") + previous_completed = run_started + transcript_by_id = {event["event_id"]: event for event in transcript_events} + for index, scenario in enumerate(scenarios, 1): if not isinstance(scenario, dict) or not isinstance(scenario.get("name"), str): raise AttestationError("every receipt scenario must have a string name") if scenario.get("passed") is not True: raise AttestationError(f"scenario {scenario['name']} did not pass") + if scenario.get("sequence") != index: + raise AttestationError("receipt scenario sequence is not contiguous") + started = parse_utc_timestamp(scenario.get("started_at"), f"scenario {scenario['name']} started_at") + completed = parse_utc_timestamp(scenario.get("completed_at"), f"scenario {scenario['name']} completed_at") + if started < previous_completed or completed < started or completed > run_completed: + raise AttestationError(f"scenario {scenario['name']} chronology is invalid") + previous_completed = completed + evidence = scenario.get("evidence") + if not isinstance(evidence, dict) or evidence.get("kind") != "runtime-observation-v1": + raise AttestationError(f"scenario {scenario['name']} evidence kind is invalid") + if not isinstance(evidence.get("summary"), str) or not evidence["summary"].strip(): + raise AttestationError(f"scenario {scenario['name']} evidence summary is invalid") + claims = evidence.get("claims") + if not isinstance(claims, list) or any(not isinstance(claim, str) or not claim for claim in claims): + raise AttestationError(f"scenario {scenario['name']} evidence claims are invalid") + required_claims = SCENARIO_REQUIRED_CLAIMS.get(scenario["name"], set()) + if not required_claims.issubset(set(claims)): + raise AttestationError(f"scenario {scenario['name']} omits required causal claims") + observations = evidence.get("observations") + if not isinstance(observations, dict) or not observations: + raise AttestationError(f"scenario {scenario['name']} observations are invalid") + required_observations = SCENARIO_REQUIRED_OBSERVATIONS.get(scenario["name"], {}) + if any(observations.get(key) != value for key, value in required_observations.items()): + raise AttestationError(f"scenario {scenario['name']} omits required typed observations") + event_ids = evidence.get("transcript_event_ids") + if not isinstance(event_ids, list) or len(event_ids) != 1 or not isinstance(event_ids[0], str): + raise AttestationError(f"scenario {scenario['name']} transcript evidence is invalid") + event = transcript_by_id.get(event_ids[0]) + if ( + event is None + or event.get("scenario") != scenario["name"] + or event.get("claims") != claims + or event.get("observations") != observations + ): + raise AttestationError(f"scenario {scenario['name']} is not causally bound to its transcript event") + if parse_utc_timestamp(event.get("observed_at"), f"scenario {scenario['name']} transcript time") != completed: + raise AttestationError(f"scenario {scenario['name']} completion is not bound to its transcript time") names.append(scenario["name"]) if tuple(names) != REQUIRED_SCENARIOS: raise AttestationError("receipt scenario set or order does not match the canonical 12-scenario qualification") def verify_runtime_claims(receipt: dict[str, Any]) -> None: - required_true = ( - "ambient_capabilities_none", - "helper_protocol_healthy", - "state_identity_preserved", - "action_runner_qualified", - "action_mutation_verified", - "collector_authority_reduction_request_observed", - "credential_rotated", - "self_revoke_observed", - "collector_continuity", - ) - for key in required_true: - if receipt.get(key) is not True: - raise AttestationError(f"receipt does not prove required runtime claim {key}") if receipt.get("collector_service_user") != "pulse-agent": raise AttestationError("receipt collector service user is not pulse-agent") if not isinstance(receipt.get("collector_process_uid"), int) or receipt["collector_process_uid"] <= 0: @@ -221,15 +433,48 @@ def verify_runtime_claims(receipt: dict[str, Any]) -> None: raise AttestationError("receipt collector authority is not monitoring-only") if receipt.get("disposable_vm_guard_sha256") != DISPOSABLE_VM_GUARD_SHA256: raise AttestationError("receipt disposable-VM guard claim is missing or invalid") + if receipt.get("action_receipt_kind") != "pulse.host_storage_cleanup_result": + raise AttestationError("receipt action result kind is not the qualified typed mutation") + report_count = receipt.get("report_count") + if not isinstance(report_count, int) or report_count < 2: + raise AttestationError("receipt report count does not prove continuity") + first_report = parse_utc_timestamp(receipt.get("first_report_at"), "receipt first_report_at") + last_report = parse_utc_timestamp(receipt.get("last_report_at"), "receipt last_report_at") + if last_report <= first_report: + raise AttestationError("receipt report chronology does not prove continuity") + run_started = parse_utc_timestamp(receipt.get("started_at"), "receipt started_at") + run_completed = parse_utc_timestamp(receipt.get("completed_at"), "receipt completed_at") + if first_report < run_started or last_report > run_completed: + raise AttestationError("receipt reports fall outside the qualification run chronology") -def verify_source_hashes(checkout: Path, commit: str, receipt: dict[str, Any]) -> dict[str, str]: +def verify_source_hashes( + checkout: Path, commit: str, receipt: dict[str, Any] +) -> tuple[dict[str, str], dict[str, Any]]: + manifest, manifest_raw, expected_paths = load_source_manifest(checkout, commit) + binding = receipt.get("source_manifest") + if not isinstance(binding, dict): + raise AttestationError("receipt source_manifest binding must be an object") + expected_binding = { + "schema_version": SOURCE_MANIFEST_SCHEMA_VERSION, + "manifest_id": SOURCE_MANIFEST_ID, + "path": SOURCE_MANIFEST_PATH, + "sha256": sha256_bytes(manifest_raw), + "target_os": "linux", + "target_arch": receipt.get("architecture"), + } + if binding != expected_binding: + raise AttestationError("receipt source manifest binding does not match the qualified commit") source_hashes = receipt.get("source_hashes") if not isinstance(source_hashes, dict) or not source_hashes: raise AttestationError("receipt source_hashes must be a non-empty object") - missing = REQUIRED_SOURCE_PATHS.difference(source_hashes) - if missing: - raise AttestationError(f"receipt source_hashes omit required boundary sources: {sorted(missing)}") + actual_paths = set(source_hashes) + if actual_paths != expected_paths: + missing = sorted(expected_paths.difference(actual_paths)) + unexpected = sorted(actual_paths.difference(expected_paths)) + raise AttestationError( + f"receipt source_hashes do not exactly match the governed boundary manifest; missing={missing} unexpected={unexpected}" + ) verified: dict[str, str] = {} for source_path in sorted(source_hashes): digest = source_hashes[source_path] @@ -243,7 +488,15 @@ def verify_source_hashes(checkout: Path, commit: str, receipt: dict[str, Any]) - if actual != digest: raise AttestationError(f"source digest mismatch for {source_path}") verified[source_path] = actual - return verified + return verified, { + "schema_version": manifest["schema_version"], + "manifest_id": manifest["manifest_id"], + "path": SOURCE_MANIFEST_PATH, + "sha256": sha256_bytes(manifest_raw), + "target_os": manifest["target_os"], + "target_arch": receipt.get("architecture"), + "source_count": len(verified), + } def verify_artifacts(receipt: dict[str, Any], artifacts: dict[str, Path]) -> dict[str, str]: @@ -314,6 +567,7 @@ def create_attestation( main_ref: str, receipt_path: Path, receipt_record_path: str, + transcript_path: Path, artifacts: dict[str, Path], elapsed_seconds: float, release_candidate_ref: str | None = None, @@ -330,21 +584,18 @@ def create_attestation( raise AttestationError("release candidate ref does not resolve to the qualified commit") receipt, receipt_bytes = load_receipt(receipt_path) - verify_scenarios(receipt) + record_path = canonical_repo_path(receipt_record_path, "receipt record path") + if receipt.get("record_path") != record_path: + raise AttestationError("receipt record path is not bound inside the supplied receipt") + transcript_events, transcript_bytes = load_and_verify_transcript(transcript_path, receipt) + verify_scenarios(receipt, transcript_events) verify_runtime_claims(receipt) - source_hashes = verify_source_hashes(checkout, qualified_commit, receipt) + source_hashes, source_manifest = verify_source_hashes(checkout, qualified_commit, receipt) attestation_tool_hash = sha256_file(Path(__file__)) if source_hashes["scripts/release_control/secure_runtime_attestation.py"] != attestation_tool_hash: raise AttestationError("executing attestation tool does not match the qualified commit") artifact_hashes = verify_artifacts(receipt, artifacts) artifact_build_identity = verify_artifact_build_identity(receipt, artifacts, qualified_commit) - record_path = PurePosixPath(receipt_record_path) - if ( - record_path.is_absolute() - or ".." in record_path.parts - or str(record_path) != receipt_record_path - ): - raise AttestationError("receipt record path must be a canonical repository-relative path") if ( not isinstance(elapsed_seconds, (float, int)) or not math.isfinite(elapsed_seconds) @@ -354,7 +605,7 @@ def create_attestation( classification = "release-candidate-artifact-bound-self-attested-systemd" if release_candidate_ref else "committed-main-artifact-bound-self-attested-systemd" return { - "schema_version": 3, + "schema_version": ATTESTATION_SCHEMA_VERSION, "attestation_tool": "scripts/release_control/secure_runtime_attestation.py", "attestation_tool_sha256": attestation_tool_hash, "proof_classification": classification, @@ -367,8 +618,20 @@ def create_attestation( "build_checkout_clean_except_lab_artifacts": True, "disposable_vm_guard_receipt_claim_validated": True, "execution_receipt_authentication": "none-secret-free-self-attestation", - "receipt_path": receipt_record_path, - "receipt_sha256": sha256_bytes(receipt_bytes), + "receipt": { + "record_path": record_path, + "sha256": sha256_bytes(receipt_bytes), + "path_bound_inside_receipt": True, + }, + "transcript": { + "record_path": receipt["transcript"]["record_path"], + "sha256": sha256_bytes(transcript_bytes), + "event_count": len(transcript_events), + "scenario_event_count": sum(event.get("kind") == "scenario_result" for event in transcript_events), + "command_output_event_count": sum(event.get("kind") == "command_output" for event in transcript_events), + "format": "jsonl-v1", + }, + "source_manifest": source_manifest, "source_hashes_match_commit": True, "source_hashes": source_hashes, "artifact_hashes_match_receipt": True, @@ -413,6 +676,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--release-candidate-ref") parser.add_argument("--receipt", type=Path, required=True) parser.add_argument("--receipt-record-path", required=True) + parser.add_argument("--transcript", type=Path, required=True) parser.add_argument("--collector-v1", type=Path, required=True) parser.add_argument("--collector-v2", type=Path, required=True) parser.add_argument("--helper", type=Path, required=True) @@ -432,6 +696,7 @@ def main(argv: Sequence[str] | None = None) -> int: release_candidate_ref=args.release_candidate_ref, receipt_path=args.receipt, receipt_record_path=args.receipt_record_path, + transcript_path=args.transcript, artifacts={name: getattr(args, argument) for name, argument in ARTIFACT_ARGUMENTS.items()}, elapsed_seconds=args.elapsed_seconds, ) diff --git a/scripts/release_control/secure_runtime_attestation_test.py b/scripts/release_control/secure_runtime_attestation_test.py index d891d12429..8a49ed6cc4 100644 --- a/scripts/release_control/secure_runtime_attestation_test.py +++ b/scripts/release_control/secure_runtime_attestation_test.py @@ -15,7 +15,11 @@ from secure_runtime_attestation import ( DISPOSABLE_VM_GUARD_SHA256, EXPECTED_ARTIFACT_PACKAGES, REQUIRED_SCENARIOS, - REQUIRED_SOURCE_PATHS, + SCENARIO_REQUIRED_CLAIMS, + SCENARIO_REQUIRED_OBSERVATIONS, + SOURCE_MANIFEST_ID, + SOURCE_MANIFEST_PATH, + SOURCE_MANIFEST_SCHEMA_VERSION, create_attestation, sha256_bytes, verify_artifact_build_identity, @@ -32,12 +36,30 @@ class SecureRuntimeAttestationTest(unittest.TestCase): self.git("init", "-b", "main") self.git("config", "user.name", "Pulse Test") self.git("config", "user.email", "pulse-test@example.invalid") + manifest = { + "schema_version": SOURCE_MANIFEST_SCHEMA_VERSION, + "manifest_id": SOURCE_MANIFEST_ID, + "target_os": "linux", + "exact_paths": [ + "scripts/install.sh", + "scripts/installtests/secure_runtime_systemd_lab_test.go", + "scripts/release_control/secure_runtime_attestation.py", + SOURCE_MANIFEST_PATH, + ], + "recursive_roots": ["boundary"], + "include_suffixes": [".go"], + "exclude_suffixes": ["_test.go"], + } + manifest_raw = (json.dumps(manifest, indent=2) + "\n").encode() + source_values = { + "scripts/install.sh": b"fixture installer\n", + "scripts/installtests/secure_runtime_systemd_lab_test.go": b"fixture harness\n", + "scripts/release_control/secure_runtime_attestation.py": Path(__file__).with_name("secure_runtime_attestation.py").read_bytes(), + SOURCE_MANIFEST_PATH: manifest_raw, + "boundary/runtime.go": b"package boundary\n", + } self.source_hashes = {} - for relative in sorted(REQUIRED_SOURCE_PATHS): - if relative == "scripts/release_control/secure_runtime_attestation.py": - value = Path(__file__).with_name("secure_runtime_attestation.py").read_bytes() - else: - value = f"fixture:{relative}\n".encode() + for relative, value in sorted(source_values.items()): path = self.repo / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(value) @@ -57,10 +79,77 @@ class SecureRuntimeAttestationTest(unittest.TestCase): self.artifacts[name] = path artifact_hashes[name] = sha256_bytes(value) self.receipt = self.root / "receipt.json" + self.transcript = self.root / "transcript.jsonl" + started_at = "2026-08-30T10:00:00+00:00" + scenarios = [] + command_output = "fixture command output\n" + transcript_events = [ + { + "sequence": 1, + "event_id": "event-0001", + "observed_at": started_at, + "kind": "command_output", + "operation": "fixture command", + "output": command_output, + "output_sha256": sha256_bytes(command_output.encode()), + } + ] + previous = started_at + for index, name in enumerate(REQUIRED_SCENARIOS, 1): + completed = f"2026-08-30T10:00:{index:02d}+00:00" + event_sequence = index + 1 + event_id = f"event-{event_sequence:04d}" + claims = sorted(SCENARIO_REQUIRED_CLAIMS[name]) + observations = dict(SCENARIO_REQUIRED_OBSERVATIONS[name]) + scenarios.append( + { + "sequence": index, + "name": name, + "passed": True, + "started_at": previous, + "completed_at": completed, + "evidence": { + "kind": "runtime-observation-v1", + "summary": f"observed {name}", + "claims": claims, + "observations": observations, + "transcript_event_ids": [event_id], + }, + } + ) + transcript_events.append( + { + "sequence": event_sequence, + "event_id": event_id, + "observed_at": completed, + "kind": "scenario_result", + "scenario": name, + "claims": claims, + "observations": observations, + "summary": f"observed {name}", + } + ) + previous = completed + transcript_raw = b"".join( + (json.dumps(event, separators=(",", ":")) + "\n").encode() + for event in transcript_events + ) + self.transcript.write_bytes(transcript_raw) self.receipt.write_text( json.dumps( { - "schema_version": 3, + "schema_version": 4, + "record_path": "records/receipt.json", + "started_at": started_at, + "completed_at": "2026-08-30T10:00:13+00:00", + "source_manifest": { + "schema_version": SOURCE_MANIFEST_SCHEMA_VERSION, + "manifest_id": SOURCE_MANIFEST_ID, + "path": SOURCE_MANIFEST_PATH, + "sha256": sha256_bytes(manifest_raw), + "target_os": "linux", + "target_arch": "arm64", + }, "source_hashes": self.source_hashes, "artifact_hashes": artifact_hashes, "artifact_versions": {"collector_v1": "1.0.0", "collector_v2": "1.1.0"}, @@ -69,22 +158,20 @@ class SecureRuntimeAttestationTest(unittest.TestCase): "kernel": "Linux fixture", "systemd_version": "systemd 255", "architecture": "arm64", + "transcript": { + "format": "jsonl-v1", + "record_path": "records/transcript.jsonl", + "sha256": sha256_bytes(transcript_raw), + "event_count": len(transcript_events), + }, "collector_service_user": "pulse-agent", "collector_process_uid": 1000, "collector_authority": "monitoring-only", - "ambient_capabilities_none": True, - "helper_protocol_healthy": True, - "state_identity_preserved": True, - "action_runner_qualified": True, - "action_mutation_verified": True, - "collector_authority_reduction_request_observed": True, - "credential_rotated": True, - "self_revoke_observed": True, - "collector_continuity": True, - "scenarios": [ - {"name": name, "passed": True, "detail": "passed"} - for name in REQUIRED_SCENARIOS - ], + "action_receipt_kind": "pulse.host_storage_cleanup_result", + "report_count": 3, + "first_report_at": "2026-08-30T10:00:01+00:00", + "last_report_at": "2026-08-30T10:00:12+00:00", + "scenarios": scenarios, }, indent=2, ) @@ -121,6 +208,7 @@ class SecureRuntimeAttestationTest(unittest.TestCase): "main_ref": "origin/main", "receipt_path": self.receipt, "receipt_record_path": "records/receipt.json", + "transcript_path": self.transcript, "artifacts": self.artifacts, "elapsed_seconds": 113.43, } @@ -134,6 +222,9 @@ class SecureRuntimeAttestationTest(unittest.TestCase): self.assertTrue(result["source_hashes_match_commit"]) self.assertTrue(result["artifact_hashes_match_receipt"]) self.assertEqual(result["scenario_count"], 12) + self.assertTrue(result["receipt"]["path_bound_inside_receipt"]) + self.assertEqual(result["transcript"]["event_count"], 13) + self.assertEqual(result["source_manifest"]["manifest_id"], SOURCE_MANIFEST_ID) self.assertEqual(len(result["attestation_tool_sha256"]), 64) def test_accepts_exact_release_candidate_ref(self) -> None: @@ -164,6 +255,13 @@ class SecureRuntimeAttestationTest(unittest.TestCase): with self.assertRaisesRegex(AttestationError, "source digest mismatch"): self.attest() + def test_rejects_source_set_that_omits_manifest_expansion(self) -> None: + receipt = json.loads(self.receipt.read_text(encoding="utf-8")) + del receipt["source_hashes"]["boundary/runtime.go"] + self.receipt.write_text(json.dumps(receipt), encoding="utf-8") + with self.assertRaisesRegex(AttestationError, "exactly match the governed boundary manifest"): + self.attest() + def test_rejects_artifact_digest_mismatch(self) -> None: self.artifacts["runner"].write_bytes(b"tampered\n") with self.assertRaisesRegex(AttestationError, "artifact digest mismatch"): @@ -200,18 +298,39 @@ class SecureRuntimeAttestationTest(unittest.TestCase): with self.assertRaisesRegex(AttestationError, "did not pass"): self.attest() + def test_rejects_tampered_transcript(self) -> None: + self.transcript.write_bytes(self.transcript.read_bytes() + b"{}\n") + with self.assertRaisesRegex(AttestationError, "transcript digest mismatch"): + self.attest() + + def test_rejects_scenario_chronology_drift(self) -> None: + receipt = json.loads(self.receipt.read_text(encoding="utf-8")) + receipt["scenarios"][2]["started_at"] = "2026-08-30T09:59:00+00:00" + self.receipt.write_text(json.dumps(receipt), encoding="utf-8") + with self.assertRaisesRegex(AttestationError, "chronology is invalid"): + self.attest() + def test_rejects_receipt_without_verified_mutation(self) -> None: receipt = json.loads(self.receipt.read_text(encoding="utf-8")) - receipt["action_mutation_verified"] = False + scenario = next(item for item in receipt["scenarios"] if item["name"] == "typed_action_receipt") + scenario["evidence"]["claims"].remove("typed_mutation_verified") self.receipt.write_text(json.dumps(receipt), encoding="utf-8") - with self.assertRaisesRegex(AttestationError, "action_mutation_verified"): + with self.assertRaisesRegex(AttestationError, "required causal claims"): + self.attest() + + def test_rejects_scenario_without_typed_observation(self) -> None: + receipt = json.loads(self.receipt.read_text(encoding="utf-8")) + scenario = next(item for item in receipt["scenarios"] if item["name"] == "typed_action_receipt") + del scenario["evidence"]["observations"]["verification"] + self.receipt.write_text(json.dumps(receipt), encoding="utf-8") + with self.assertRaisesRegex(AttestationError, "required typed observations"): self.attest() def test_rejects_legacy_receipt_schema(self) -> None: receipt = json.loads(self.receipt.read_text(encoding="utf-8")) - receipt["schema_version"] = 2 + receipt["schema_version"] = 3 self.receipt.write_text(json.dumps(receipt), encoding="utf-8") - with self.assertRaisesRegex(AttestationError, "schema_version 3"): + with self.assertRaisesRegex(AttestationError, "schema_version 4"): self.attest() def test_rejects_missing_scenario(self) -> None: @@ -253,6 +372,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase): with self.assertRaisesRegex(AttestationError, "canonical repository-relative"): self.attest(receipt_record_path="../receipt.json") + def test_rejects_receipt_record_path_not_bound_inside_blob(self) -> None: + with self.assertRaisesRegex(AttestationError, "not bound inside"): + self.attest(receipt_record_path="records/other.json") + def test_rejects_nonfinite_elapsed_time(self) -> None: with self.assertRaisesRegex(AttestationError, "must be positive"): self.attest(elapsed_seconds=float("nan")) diff --git a/scripts/release_control/secure_runtime_source_manifest_v4.json b/scripts/release_control/secure_runtime_source_manifest_v4.json new file mode 100644 index 0000000000..dc7d9b13fa --- /dev/null +++ b/scripts/release_control/secure_runtime_source_manifest_v4.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "manifest_id": "secure-runtime-linux-v4", + "target_os": "linux", + "description": "Production source boundary for the secure collector, typed helper, action runner, update path, control-plane admission, and systemd qualification harness.", + "exact_paths": [ + "scripts/install.sh", + "scripts/installtests/secure_runtime_systemd_lab_test.go", + "scripts/release_control/secure_runtime_attestation.py", + "scripts/release_control/secure_runtime_source_manifest_v4.json" + ], + "recursive_roots": [ + "cmd/pulse-agent", + "cmd/pulse-agent-helper", + "cmd/pulse-agent-runner", + "internal/actionrunner", + "internal/agentexec", + "internal/agenthelper", + "internal/agentupdate", + "internal/api", + "internal/config", + "internal/hostagent", + "internal/operationreceipt" + ], + "include_suffixes": [ + ".go", + ".tmpl" + ], + "exclude_suffixes": [ + "_test.go" + ] +}