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 f5ac263e5..defb025db 100644 --- a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md +++ b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md @@ -95,14 +95,16 @@ This is a qualification foundation, not the Slice E ratchet. The generated setup default remains the legacy/root profile. A clean disposable arm64 Ubuntu systemd exercise now proves exact-working-tree install, migration, explicit and automatic rollback, ordinary update isolation, typed helper health, fresh -registration, and continued reporting; its secret-free receipt is recorded in +registration, continued reporting, separate runner enrollment, durable typed +receipt replay, exact credential rotation invalidation, and runner self-revoke; +its secret-free receipt is recorded in `internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json`. The -repository still needs exact committed release-candidate reproduction, runner -rotation/revocation and typed action-receipt proof, 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 remains an explicit residual -rather than evidence that the candidate lane is complete. +repository still needs exact committed release-candidate reproduction, +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 +remains an explicit residual rather than evidence that the candidate lane is +complete. ## Target Architecture 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 65cc2f1b3..34426459b 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 @@ -4,9 +4,10 @@ This slice closes implementation defects found while preparing a real Linux systemd qualification of the optional collector/helper/action-runner split. It -also records a managed-development real-systemd qualification of the collector -and helper migration boundary. It does not flip the installer default and is -not exact committed release-candidate or representative live-provider proof. +also records a managed-development real-systemd qualification of the +collector/helper migration boundary and the separately credentialed action +runner lifecycle. It does not flip the installer default and is not exact +committed release-candidate or representative live-provider proof. ## Landed runtime semantics @@ -42,7 +43,7 @@ The disposable arm64 Colima profile `pulse-agent-qual` ran Ubuntu 24.04.4, kernel 6.8.0-117, and systemd 255. The exact working-tree installer and lab source are bound by the hashes in `secure-agent-runtime-systemd-receipt-2026-08-30.json`; the receipt itself has -SHA-256 `c20aee566835ac88f6163085d2d559c3fa493274b1a8eae328ed39063af06e5e`. +SHA-256 `7bf8695c2f8dd185be7552bc9192b59a054f529f8f227773f8cb9b52c495a16b`. The guarded lab passed all of these destructive scenarios from a clean VM: - legacy root/command-capable install with rootful Docker enabled; @@ -54,10 +55,20 @@ The guarded lab passed all of these destructive scenarios from a clean VM: frozen, including stable binary, unit, credential-state, ownership, and mode identity; - ordinary update without implicit privilege migration, followed by a final - committed safe-profile migration. + committed safe-profile migration; +- separate root action-runner installation with a canonical hostname and + independently scoped credential while the non-root collector continued + reporting; +- a closed typed storage-cleanup request that refused before mutation, produced + a durable terminal receipt, replayed through receipt query, and could not be + widened into generic command dispatch; +- credential replacement that rejected mismatched invalidation, closed exactly + the superseded live session, and admitted the replacement binding; +- authenticated self-revoke followed by runner-only uninstall, with the + collector and helper still healthy and reporting. The receipt is intentionally secret-free and contains source/artifact hashes, -host facts, resulting privilege posture, report timestamps, and the eight +host facts, resulting privilege posture, report timestamps, and the twelve scenario outcomes. This is managed-development proof because it exercised the working tree before its eventual commit identity existed. @@ -69,8 +80,7 @@ substitute for exact committed release-candidate evidence, representative provider/appliance qualification, or external review. The safe profile therefore remains opt-in. The default may not ratchet until -the exact release candidate reproduces the systemd result and proves runner -rotation/revocation plus typed action receipts; representative Proxmox, SMART, -Docker and rootless Podman parity is recorded; supported appliance residuals -are owned; and the helper, update, action credential, and migration boundaries -complete external security review. +the exact release candidate reproduces the complete systemd result; +representative Proxmox, SMART, Docker and rootless Podman parity is recorded; +supported appliance residuals are owned; and the helper, update, action +credential, and migration boundaries complete external security review. diff --git a/docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json b/docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json index 794426a48..29bb70337 100644 --- a/docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json +++ b/docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-2026-08-30.json @@ -1,14 +1,15 @@ { - "schema_version": 1, - "completed_at": "2026-08-30T00:39:22.928744899Z", + "schema_version": 2, + "completed_at": "2026-08-30T07:49:40.116396211Z", "source_hashes": { "scripts/install.sh": "0b557c9394f3f0741cbf4287e5ee16a88fe03e189678bddf754606458c4e276e", - "scripts/installtests/secure_runtime_systemd_lab_test.go": "cebaeca194bd2ef1a3754230026ab73cd7c8b28427c773dbc62c86f4cd69ba28" + "scripts/installtests/secure_runtime_systemd_lab_test.go": "46082cf9247cd626f9b746ec9a3acfcc4f58ca3c1ced762498376d4bfccaf3f1" }, "artifact_hashes": { - "collector_v1": "c9c775085178102d7fea5ecc899960a3e27ad7dc41c632a83f01e0f642942379", - "collector_v2": "2b52cbf20884d391acd7c072b7c4af011377ef1f19b97725a76ad18a3d1154fa", - "helper": "9d865a4adb9fdd5e70f36ee4021acefd2b5bfe1010739ab1dd916c701818032d" + "collector_v1": "76844200cec5d2ee4c015d2858d0afb37bce078fdf5cc6e0015997ae97c95400", + "collector_v2": "b493b9d7c498b41cd2615d3900ed53f9bbdaeeeebd1da93f7d2657e7bb4d38a2", + "helper": "a0932c8ce3bb5b7e94428afb061999750e8e8a4cece7d48df6a35b125eec2f59", + "runner": "7429b0b28cc711b59a8f9a6b444f21254ec7a201f145f446dda5dc13d93bbf8b" }, "os_release": "PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\nNAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nVERSION=\"24.04.4 LTS (Noble Numbat)\"\nVERSION_CODENAME=noble\nID=ubuntu\nID_LIKE=debian\nHOME_URL=\"https://www.ubuntu.com/\"\nSUPPORT_URL=\"https://help.ubuntu.com/\"\nBUG_REPORT_URL=\"https://bugs.launchpad.net/ubuntu/\"\nPRIVACY_POLICY_URL=\"https://www.ubuntu.com/legal/terms-and-policies/privacy-policy\"\nUBUNTU_CODENAME=noble\nLOGO=ubuntu-logo", "kernel": "Linux 6.8.0-117-generic #117-Ubuntu SMP PREEMPT_DYNAMIC Thu May 7 17:26:37 UTC 2026 aarch64 GNU/Linux", @@ -21,9 +22,14 @@ "helper_protocol_healthy": true, "state_identity_preserved": true, "docker_degraded": true, - "report_count": 26, - "first_report_at": "2026-08-30T00:38:25.603885842Z", - "last_report_at": "2026-08-30T00:39:22.777779187Z", + "action_runner_qualified": true, + "action_receipt_kind": "pulse.host_storage_cleanup_result", + "credential_rotated": true, + "self_revoke_observed": true, + "collector_continuity": true, + "report_count": 48, + "first_report_at": "2026-08-30T07:47:53.132981202Z", + "last_report_at": "2026-08-30T07:49:39.989110328Z", "scenarios": [ { "name": "legacy_root_command_capable_install", @@ -64,6 +70,26 @@ "name": "final_safe_profile_apply", "passed": true, "detail": "collector continued reporting after committed migration" + }, + { + "name": "separate_action_runner_install", + "passed": true, + "detail": "root action runner registered independently while the collector remained non-root and reporting" + }, + { + "name": "typed_action_receipt", + "passed": true, + "detail": "closed storage-cleanup request refused before mutation and replayed as a durable terminal receipt; generic command dispatch denied" + }, + { + "name": "action_runner_credential_rotation", + "passed": true, + "detail": "mismatched invalidation was rejected, exact superseded session closed, replacement credential registered" + }, + { + "name": "action_runner_self_revoke", + "passed": true, + "detail": "uninstall revoked the exact host binding and removed only runner state; collector/helper continuity remained healthy" } ] } diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b5e628bef..132852209 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7183,6 +7183,10 @@ The agent transport catalogue now classifies wire roles independently from mutation identity. `docker_container_lifecycle` is the typed mutation request for the existing `resource.docker.container-lifecycle` capability and may be sent only after committed action-lifecycle authority. +`proxmox_guest_lifecycle` is classified by the corresponding +`transport.agent.proxmox-guest-lifecycle` entry and may carry only the existing +`resource.proxmox.guest-lifecycle` durable authority; its result message is +non-admitting. `agent_operation_query` is query-only reconciliation; `agent_operation_query_result` and `docker_container_lifecycle_result` are non-admitting receipt/result messages. Query, result, receipt, and general diff --git a/scripts/installtests/secure_runtime_systemd_lab_test.go b/scripts/installtests/secure_runtime_systemd_lab_test.go index 7ce442f3e..9442b1bc7 100644 --- a/scripts/installtests/secure_runtime_systemd_lab_test.go +++ b/scripts/installtests/secure_runtime_systemd_lab_test.go @@ -13,6 +13,7 @@ package installtests // CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v1_ldflags" -o .lab-artifacts/pulse-agent-v1 ./cmd/pulse-agent // CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v2_ldflags" -o .lab-artifacts/pulse-agent-v2 ./cmd/pulse-agent // CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o .lab-artifacts/pulse-agent-helper ./cmd/pulse-agent-helper +// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o .lab-artifacts/pulse-agent-runner ./cmd/pulse-agent-runner // CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go test -c -o .lab-artifacts/installtests-linux-arm64.test ./scripts/installtests // colima start pulse-agent-qual --activate=false --mount "$PWD:w" // colima ssh -p pulse-agent-qual -- sudo sh -c \ @@ -23,6 +24,7 @@ package installtests // PULSE_SECURE_RUNTIME_COLLECTOR_V1="$repo/.lab-artifacts/pulse-agent-v1" \ // PULSE_SECURE_RUNTIME_COLLECTOR_V2="$repo/.lab-artifacts/pulse-agent-v2" \ // 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 \ // sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo" // @@ -53,26 +55,38 @@ import ( "syscall" "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" ) const ( - secureRuntimeLabOptIn = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB" - secureRuntimeLabMarkerPath = "/etc/pulse-secure-runtime-lab" - secureRuntimeLabMarkerValue = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1" - secureRuntimeLabToken = "8fa728ed4cbfa7466947e747627b51e2464ed3f69966ed6394e36a82d85d7d31" - secureRuntimeLabAgentID = "secure-runtime-systemd-lab" - secureRuntimeLabHostname = "pulse-secure-runtime-lab" + secureRuntimeLabOptIn = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB" + secureRuntimeLabMarkerPath = "/etc/pulse-secure-runtime-lab" + secureRuntimeLabMarkerValue = "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1" + secureRuntimeLabToken = "8fa728ed4cbfa7466947e747627b51e2464ed3f69966ed6394e36a82d85d7d31" + secureRuntimeLabAgentID = "secure-runtime-systemd-lab" + secureRuntimeLabHostname = "pulse-secure-runtime-lab" + secureRuntimeLabOrgID = "secure-runtime-lab-org" + secureRuntimeRunnerSecretV1 = "3c896fe99e29384a293def92f23c1709fbf429773e0a20ac5d930f2aab62f839" + secureRuntimeRunnerSecretV2 = "d7f6f2550788213e0595f276b62b1df290f7e018ba74c03068c4afbe6efd7601" + secureRuntimeRunnerBindingV1 = "secure-runtime-runner-binding-v1" + secureRuntimeRunnerBindingV2 = "secure-runtime-runner-binding-v2" ) var secureRuntimeInstalledPaths = []string{ "/usr/local/bin/pulse-agent", "/usr/local/lib/pulse-agent/pulse-agent-helper", + "/usr/local/lib/pulse-agent/pulse-agent-runner", "/etc/systemd/system/pulse-agent.service", "/etc/systemd/system/pulse-agent.service.d", "/etc/systemd/system/pulse-agent-helper.service", "/etc/systemd/system/pulse-agent-helper.socket", + "/etc/systemd/system/pulse-agent-runner.service", + "/etc/pulse-agent-runner", "/var/lib/pulse-agent", "/var/lib/pulse-agent-helper", + "/var/lib/pulse-agent-runner", "/var/lib/pulse-agent-profile", } @@ -92,12 +106,66 @@ type secureRuntimeLabFixture struct { mu sync.Mutex collector []byte helper []byte + runner []byte serverVersion string reports []secureRuntimeLabReport lastSeen time.Time freezeLastSeen bool authFailures int requestFailures []string + actionServer *agentexec.Server + actionSecret string + actionBindingID string + actionRevoked bool + actionRevokes int +} + +func newSecureRuntimeLabFixture(collector, helper, runner []byte, version string) *secureRuntimeLabFixture { + fixture := &secureRuntimeLabFixture{ + collector: collector, helper: helper, runner: runner, serverVersion: version, + actionSecret: secureRuntimeRunnerSecretV1, actionBindingID: secureRuntimeRunnerBindingV1, + } + fixture.actionServer = agentexec.NewServerWithAdmissionValidator(fixture.admitActionRunner, fixture.validateActionRunnerSession) + return fixture +} + +func (f *secureRuntimeLabFixture) admitActionRunner(secret, agentID, hostname string) (agentexec.AgentAdmission, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.actionRevoked || secret != f.actionSecret || strings.TrimSpace(agentID) != secureRuntimeLabAgentID || !strings.EqualFold(strings.TrimSpace(hostname), secureRuntimeLabHostname) { + return agentexec.AgentAdmission{}, false + } + return f.actionAdmissionLocked(), true +} + +func (f *secureRuntimeLabFixture) validateActionRunnerSession(admission agentexec.AgentAdmission) bool { + f.mu.Lock() + defer f.mu.Unlock() + return !f.actionRevoked && admission == f.actionAdmissionLocked() +} + +func (f *secureRuntimeLabFixture) actionAdmissionLocked() agentexec.AgentAdmission { + return agentexec.AgentAdmission{ + OrganizationID: secureRuntimeLabOrgID, TokenID: f.actionBindingID, + AgentID: secureRuntimeLabAgentID, Hostname: secureRuntimeLabHostname, + RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, + } +} + +func (f *secureRuntimeLabFixture) replaceActionCredential(secret, bindingID string) agentexec.AgentAdmission { + f.mu.Lock() + defer f.mu.Unlock() + previous := f.actionAdmissionLocked() + f.actionSecret = secret + f.actionBindingID = bindingID + f.actionRevoked = false + return previous +} + +func (f *secureRuntimeLabFixture) actionSnapshot() (agentexec.AgentAdmission, bool, int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.actionAdmissionLocked(), f.actionRevoked, f.actionRevokes } func (f *secureRuntimeLabFixture) setCollector(artifact []byte) { @@ -132,9 +200,15 @@ func (f *secureRuntimeLabFixture) ServeHTTP(w http.ResponseWriter, r *http.Reque f.mu.Unlock() writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"version": version}) case r.URL.Path == "/download/pulse-agent": - f.serveArtifact(w, r, false) + f.serveArtifact(w, r, "collector") case r.URL.Path == "/download/pulse-agent-helper": - f.serveArtifact(w, r, true) + f.serveArtifact(w, r, "helper") + case r.URL.Path == "/download/pulse-agent-runner": + f.serveArtifact(w, r, "runner") + case r.URL.Path == "/api/agent/ws": + f.actionServer.HandleWebSocket(w, r) + case r.URL.Path == "/api/agents/action-runner/credential": + f.handleActionRunnerSelfRevoke(w, r) case r.URL.Path == "/api/health": writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) case r.URL.Path == "/api/agents/agent/report": @@ -176,15 +250,18 @@ func (f *secureRuntimeLabFixture) authorized(r *http.Request) bool { return valid } -func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.Request, helper bool) { +func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.Request, artifactKind string) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } f.mu.Lock() artifact := append([]byte(nil), f.collector...) - if helper { + switch artifactKind { + case "helper": artifact = append([]byte(nil), f.helper...) + case "runner": + artifact = append([]byte(nil), f.runner...) } f.mu.Unlock() sum := sha256.Sum256(artifact) @@ -196,6 +273,36 @@ func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.R } } +func (f *secureRuntimeLabFixture) handleActionRunnerSelfRevoke(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var request struct { + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + bearer := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") + f.mu.Lock() + admission := f.actionAdmissionLocked() + valid := !f.actionRevoked && bearer == f.actionSecret && strings.TrimSpace(request.AgentID) == admission.AgentID && strings.EqualFold(strings.TrimSpace(request.Hostname), admission.Hostname) + if valid { + f.actionRevoked = true + f.actionRevokes++ + } + f.mu.Unlock() + if !valid { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + f.actionServer.InvalidateActionRunnerSession(admission) + writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{"success": true}) +} + func (f *secureRuntimeLabFixture) handleReport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -369,6 +476,11 @@ type secureRuntimeLabReceipt struct { HelperProtocolHealthy bool `json:"helper_protocol_healthy"` StateIdentityPreserved bool `json:"state_identity_preserved"` DockerDegraded bool `json:"docker_degraded"` + ActionRunnerQualified bool `json:"action_runner_qualified"` + 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"` @@ -384,6 +496,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { collectorV1 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1") collectorV2 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2") helper := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_HELPER") + runner := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_RUNNER") collectorV1Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1") collectorV2Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2") if collectorV1Version == collectorV2Version { @@ -395,17 +508,18 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { } installer := secureRuntimeReadFile(t, installerPath) - fixture := &secureRuntimeLabFixture{collector: collectorV1, helper: helper, serverVersion: collectorV1Version} + fixture := newSecureRuntimeLabFixture(collectorV1, helper, runner, collectorV1Version) + defer fixture.actionServer.Shutdown() server := httptest.NewServer(fixture) defer server.Close() receipt := secureRuntimeLabReceipt{ - SchemaVersion: 1, + SchemaVersion: 2, 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"))), }, - ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper)}, + ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper), "runner": secureRuntimeHash(runner)}, Architecture: runtime.GOARCH, } receipt.OSRelease = strings.TrimSpace(string(secureRuntimeReadFile(t, "/etc/os-release"))) @@ -537,6 +651,79 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second) pass("final_safe_profile_apply", "collector continued reporting after committed migration") + reportsBeforeRunner, _, _, _ := fixture.snapshot() + secureRuntimeRunInstallerWithActionCredential(t, installerPath, server.URL, secureRuntimeRunnerSecretV1, + "--least-privilege", "--enable-privileged-helper", "--enable-action-runner") + 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") + + actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second) + defer cancelAction() + cleanupRequest := agentexec.HostStorageCleanupPayload{ + RequestID: "secure-runtime.receipt.1", ActionID: "secure-runtime-receipt", + Operation: agentexec.HostStorageCleanupOperationPackageCache, + ExpectedFingerprint: "sha256:" + strings.Repeat("f", 64), Timeout: 15, + } + if err := agentexec.BindHostStorageCleanupPayload(&cleanupRequest); err != nil { + t.Fatalf("bind typed receipt qualification request: %v", err) + } + cleanupResult, err := fixture.actionServer.ExecuteHostStorageCleanup(actionContext, secureRuntimeLabAgentID, cleanupRequest) + if err != nil { + t.Fatalf("execute typed receipt qualification request: %v", err) + } + if cleanupResult == nil || cleanupResult.MutationStarted { + t.Fatalf("typed receipt qualification unexpectedly mutated the host: %+v", cleanupResult) + } + receiptIdentity := agentexec.HostStorageCleanupOperationIdentity(secureRuntimeLabAgentID, cleanupRequest) + query, err := fixture.actionServer.QueryAgentOperation(actionContext, secureRuntimeLabAgentID, receiptIdentity) + if err != nil || query.Status != operationreceipt.QueryFoundTerminal || query.Record == nil || query.Record.ResultKind != agentexec.HostStorageCleanupReceiptKind { + t.Fatalf("durable typed receipt query = %+v, err=%v", query, err) + } + if _, err := fixture.actionServer.ExecuteCommand(actionContext, secureRuntimeLabAgentID, agentexec.ExecuteCommandPayload{ + RequestID: "secure-runtime-shell-denial", Command: "true", TargetType: "agent", Trusted: true, + }); 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", "closed storage-cleanup request refused before mutation and replayed as a durable terminal receipt; generic command dispatch denied") + + currentAdmission, _, _ := fixture.actionSnapshot() + wrongAdmission := currentAdmission + wrongAdmission.TokenID = secureRuntimeRunnerBindingV2 + if fixture.actionServer.InvalidateActionRunnerSession(wrongAdmission) { + t.Fatal("mismatched replacement binding evicted the current action-runner session") + } + previousAdmission := fixture.replaceActionCredential(secureRuntimeRunnerSecretV2, secureRuntimeRunnerBindingV2) + if previousAdmission != currentAdmission || !fixture.actionServer.InvalidateActionRunnerSession(previousAdmission) { + t.Fatal("credential rotation did not invalidate the exact superseded action-runner session") + } + secureRuntimeWaitForActionRunner(t, fixture, false, 10*time.Second) + secureRuntimeRunInstallerWithActionCredential(t, installerPath, server.URL, secureRuntimeRunnerSecretV2, + "--least-privilege", "--enable-privileged-helper", "--enable-action-runner") + secureRuntimeWaitForActionRunner(t, fixture, true, 30*time.Second) + rotatedAdmission, revoked, _ := fixture.actionSnapshot() + 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") + + reportsBeforeRemoval, _, _, _ := fixture.snapshot() + uninstallOutput := secureRuntimeRunStandaloneInstaller(t, installerPath, "--uninstall-action-runner") + if !strings.Contains(uninstallOutput, "Revoked the action-runner credential") { + t.Fatalf("runner uninstall did not confirm self-revocation:\n%s", uninstallOutput) + } + secureRuntimeWaitForActionRunner(t, fixture, false, 10*time.Second) + secureRuntimeAssertActionRunnerRemoved(t) + _, revoked, revokeCount := fixture.actionSnapshot() + if !revoked || revokeCount != 1 { + t.Fatalf("runner self-revoke state: revoked=%t count=%d", revoked, revokeCount) + } + 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") + reports, _, _, _ := fixture.snapshot() if len(reports) == 0 { t.Fatal("no reports recorded") @@ -562,6 +749,11 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { receipt.HelperProtocolHealthy = true receipt.StateIdentityPreserved = true receipt.DockerDegraded = dockerDegraded + receipt.ActionRunnerQualified = true + receipt.ActionReceiptKind = agentexec.HostStorageCleanupReceiptKind + receipt.CredentialRotated = true + receipt.SelfRevokeObserved = true + receipt.CollectorContinuity = true receipt.ReportCount = len(reports) receipt.FirstReportAt = reports[0].ReceivedAt.Format(time.RFC3339Nano) receipt.LastReportAt = reports[len(reports)-1].ReceivedAt.Format(time.RFC3339Nano) @@ -646,7 +838,16 @@ func secureRuntimeHash(data []byte) string { func secureRuntimeRunInstaller(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) string { t.Helper() - out, err := secureRuntimeRunInstallerError(t, installerPath, fixtureURL, scenarioArgs...) + out, err := secureRuntimeRunInstallerErrorWithActionCredential(t, installerPath, fixtureURL, "", scenarioArgs...) + if err != nil { + t.Fatalf("installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out) + } + return out +} + +func secureRuntimeRunInstallerWithActionCredential(t *testing.T, installerPath, fixtureURL, actionCredential string, scenarioArgs ...string) string { + t.Helper() + out, err := secureRuntimeRunInstallerErrorWithActionCredential(t, installerPath, fixtureURL, actionCredential, scenarioArgs...) if err != nil { t.Fatalf("installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out) } @@ -654,6 +855,11 @@ func secureRuntimeRunInstaller(t *testing.T, installerPath, fixtureURL string, s } func secureRuntimeRunInstallerError(t *testing.T, installerPath, fixtureURL string, scenarioArgs ...string) (string, error) { + t.Helper() + return secureRuntimeRunInstallerErrorWithActionCredential(t, installerPath, fixtureURL, "", scenarioArgs...) +} + +func secureRuntimeRunInstallerErrorWithActionCredential(t *testing.T, installerPath, fixtureURL, actionCredential string, scenarioArgs ...string) (string, error) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -678,6 +884,13 @@ func secureRuntimeRunInstallerError(t *testing.T, installerPath, fixtureURL stri "--insecure", "--non-interactive", ) + if actionCredential != "" { + actionCredentialFile := filepath.Join(t.TempDir(), "action-credential") + if err := os.WriteFile(actionCredentialFile, []byte(actionCredential+"\n"), 0o600); err != nil { + t.Fatalf("write private action-runner credential: %v", err) + } + args = append(args, "--action-token-file", actionCredentialFile) + } args = append(args, scenarioArgs...) } cmd := exec.CommandContext(ctx, "bash", args...) @@ -686,12 +899,36 @@ func secureRuntimeRunInstallerError(t *testing.T, installerPath, fixtureURL stri if ctx.Err() != nil { return string(out), fmt.Errorf("installer timed out: %w", ctx.Err()) } - if bytes.Contains(out, []byte(secureRuntimeLabToken)) { - t.Fatal("installer output exposed the monitoring credential") - } + secureRuntimeAssertNoCredentialExposure(t, out) return string(out), err } +func secureRuntimeRunStandaloneInstaller(t *testing.T, installerPath string, scenarioArgs ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", append([]string{installerPath}, scenarioArgs...)...) + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("standalone installer timed out: %v\n%s", ctx.Err(), out) + } + secureRuntimeAssertNoCredentialExposure(t, out) + if err != nil { + t.Fatalf("standalone installer %s failed: %v\n%s", strings.Join(scenarioArgs, " "), err, out) + } + return string(out) +} + +func secureRuntimeAssertNoCredentialExposure(t *testing.T, output []byte) { + t.Helper() + for _, credential := range []string{secureRuntimeLabToken, secureRuntimeRunnerSecretV1, secureRuntimeRunnerSecretV2} { + if bytes.Contains(output, []byte(credential)) { + t.Fatal("installer output exposed a runtime credential") + } + } +} + func secureRuntimeWaitForReports(t *testing.T, fixture *secureRuntimeLabFixture, count int, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) @@ -706,6 +943,18 @@ func secureRuntimeWaitForReports(t *testing.T, fixture *secureRuntimeLabFixture, t.Fatalf("timed out waiting for %d reports; got %d (fixture failures: %v)", count, len(reports), failures) } +func secureRuntimeWaitForActionRunner(t *testing.T, fixture *secureRuntimeLabFixture, connected bool, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if fixture.actionServer.IsAgentConnectedForOrganization(secureRuntimeLabOrgID, secureRuntimeLabAgentID) == connected { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("timed out waiting for action-runner connected=%t", connected) +} + func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args ...string) string { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), timeout) @@ -722,6 +971,53 @@ func secureRuntimeSystemdProperty(t *testing.T, property string) string { return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent.service", "--property="+property, "--value") } +func secureRuntimeActionRunnerSystemdProperty(t *testing.T, property string) string { + t.Helper() + return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent-runner.service", "--property="+property, "--value") +} + +func secureRuntimeAssertActionRunnerInstalled(t *testing.T) { + t.Helper() + secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-runner.service") + if user := secureRuntimeActionRunnerSystemdProperty(t, "User"); user != "root" { + t.Fatalf("action-runner systemd User = %q, want root", user) + } + if ambient := strings.TrimSpace(secureRuntimeActionRunnerSystemdProperty(t, "AmbientCapabilities")); ambient != "" { + t.Fatalf("action-runner AmbientCapabilities = %q, want none", ambient) + } + for path, mode := range map[string]os.FileMode{ + "/usr/local/lib/pulse-agent/pulse-agent-runner": 0o755, + "/etc/pulse-agent-runner/runner.env": 0o600, + "/etc/pulse-agent-runner/token": 0o600, + "/var/lib/pulse-agent-runner/health.json": 0o600, + } { + identity := secureRuntimeStableFileIdentity(t, path) + if identity.UID != 0 || identity.GID != 0 || identity.Mode != mode { + t.Fatalf("%s identity = uid:%d gid:%d mode:%#o, want root:root %#o", path, identity.UID, identity.GID, identity.Mode, mode) + } + } + secureRuntimeAssertSafeProfile(t) +} + +func secureRuntimeAssertActionRunnerRemoved(t *testing.T) { + t.Helper() + for _, path := range []string{ + "/usr/local/lib/pulse-agent/pulse-agent-runner", + "/etc/systemd/system/pulse-agent-runner.service", + "/etc/pulse-agent-runner", + "/var/lib/pulse-agent-runner", + } { + if _, err := os.Lstat(path); err == nil { + t.Fatalf("runner-only uninstall left %s", path) + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("inspect removed action-runner path %s: %v", path, err) + } + } + if loadState := secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent-runner.service", "--property=LoadState", "--value"); loadState != "not-found" { + t.Fatalf("removed action-runner unit LoadState = %q, want not-found", loadState) + } +} + func secureRuntimeCollectorHasArgument(argument string) bool { out, err := exec.Command("systemctl", "show", "pulse-agent.service", "--property=ExecStart", "--value").Output() if err != nil { @@ -962,7 +1258,10 @@ func secureRuntimeWriteReceipt(t *testing.T, receipt secureRuntimeLabReceipt) { if err != nil { t.Fatalf("marshal secure-runtime receipt: %v", err) } - if bytes.Contains(encoded, []byte(secureRuntimeLabToken)) || bytes.Contains(bytes.ToLower(encoded), []byte("token")) { + if bytes.Contains(encoded, []byte(secureRuntimeLabToken)) || + bytes.Contains(encoded, []byte(secureRuntimeRunnerSecretV1)) || + bytes.Contains(encoded, []byte(secureRuntimeRunnerSecretV2)) || + bytes.Contains(bytes.ToLower(encoded), []byte("token")) { t.Fatal("refusing to write a receipt containing credential material or token-labelled fields") } encoded = append(encoded, '\n')