diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1 index 656ceabac..80f062b83 100644 --- a/agent99-control-plane.ps1 +++ b/agent99-control-plane.ps1 @@ -4453,6 +4453,201 @@ function Test-AgentScheduledTaskHealth { $results } +function Test-AgentLegacySenderQuarantine { + $legacyTaskName = "Wooo-Agent99-Host188-Performance-Guard" + $replacementTaskName = "Wooo-Agent99-Performance-Guard" + $expectedLegacyActionSha256 = "a2f98560673ebed5aa1f828b4e577c8a34d1c277b2e498044c3526a4ffe15379" + $expectedPowerShellExecutable = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $expectedReplacementLauncher = Join-Path $Config.agentRoot "agent99-run.ps1" + $expectedReplacementArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$expectedReplacementLauncher`" -Mode Perf -ControlledApply" + $receiptPath = Join-Path $EvidenceDir "controlled-task-quarantine\agent99-legacy-sender-deployment-quarantine.json" + $runtimeManifestPath = Join-Path $Config.agentRoot "state\runtime-manifest.json" + $registerTaskProducerPath = Join-Path $Config.agentRoot "bin\agent99-register-tasks.ps1" + + try { + $legacyTask = Get-ScheduledTask ` + -TaskName $legacyTaskName ` + -TaskPath "\" ` + -ErrorAction SilentlyContinue + $replacementTask = Get-ScheduledTask ` + -TaskName $replacementTaskName ` + -TaskPath "\" ` + -ErrorAction Stop + $legacyActionValid = [bool]($null -eq $legacyTask) + $legacyActionSha256 = $null + if ($legacyTask) { + $legacyActions = @($legacyTask.Actions) + if ($legacyActions.Count -eq 1) { + $legacyAction = $legacyActions[0] + $legacyActionSha256 = Get-AgentSha256Hex ([string]$legacyAction.Arguments) + $legacyActionValid = [bool]( + [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$legacyAction.Execute, + [string]$expectedPowerShellExecutable + ) -and + ([string]$legacyAction.WorkingDirectory).Length -eq 0 -and + $legacyActionSha256 -eq $expectedLegacyActionSha256 + ) + } + } + $replacementActions = @($replacementTask.Actions) + $replacementActionValid = $false + if ($replacementActions.Count -eq 1) { + $replacementAction = $replacementActions[0] + $replacementArguments = [string]$replacementAction.Arguments + $replacementActionValid = [bool]( + [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$replacementAction.Execute, + [string]$expectedPowerShellExecutable + ) -and + ([string]$replacementAction.WorkingDirectory).Length -eq 0 -and + $replacementArguments -ceq $expectedReplacementArguments -and + $replacementArguments -notmatch "api\.telegram\.org|sendMessage|BotToken|ChatId" + ) + } + $legacyEnabled = [bool]( + $legacyTask -and [bool]$legacyTask.Settings.Enabled + ) + if (-not (Test-Path -LiteralPath $receiptPath -PathType Leaf)) { + throw "legacy_sender_quarantine_receipt_missing" + } + if (-not (Test-Path -LiteralPath $runtimeManifestPath -PathType Leaf)) { + throw "legacy_sender_runtime_manifest_missing" + } + if (-not (Test-Path -LiteralPath $registerTaskProducerPath -PathType Leaf)) { + throw "legacy_sender_producer_missing" + } + $receipt = Get-Content -LiteralPath $receiptPath -Raw | ConvertFrom-Json + $runtimeManifest = Get-Content -LiteralPath $runtimeManifestPath -Raw | ConvertFrom-Json + $registerTaskManifestRows = @($runtimeManifest.files | Where-Object { + [string]$_.name -eq "agent99-register-tasks.ps1" + }) + $registerTaskProducerSha256 = (Get-FileHash ` + -LiteralPath $registerTaskProducerPath ` + -Algorithm SHA256).Hash.ToLowerInvariant() + $producerIdentityValid = [bool]( + $runtimeManifest.runtimeMatched -is [bool] -and + $runtimeManifest.runtimeMatched -eq $true -and + -not [string]::IsNullOrWhiteSpace([string]$runtimeManifest.sourceRevision) -and + $registerTaskManifestRows.Count -eq 1 -and + $registerTaskManifestRows[0].matched -is [bool] -and + $registerTaskManifestRows[0].matched -eq $true -and + [string]$registerTaskManifestRows[0].runtimeSha256 -eq $registerTaskProducerSha256 -and + [string]$registerTaskManifestRows[0].sourceSha256 -eq $registerTaskProducerSha256 -and + [string]$receipt.sourceRevision -eq [string]$runtimeManifest.sourceRevision -and + [string]$receipt.producerSha256 -eq $registerTaskProducerSha256 -and + $receipt.producerManifestBound -is [bool] -and + $receipt.producerManifestBound -eq $true + ) + $receiptBooleanTypesValid = [bool]( + $receipt.legacyTaskFound -is [bool] -and + $receipt.legacyEnabledBefore -is [bool] -and + $receipt.legacyEnabledAfter -is [bool] -and + $receipt.replacementEnabled -is [bool] -and + $receipt.legacyActionIdentityVerified -is [bool] -and + $receipt.replacementActionIdentityVerified -is [bool] -and + $receipt.producerManifestBound -is [bool] -and + $receipt.runtimeWritePerformed -is [bool] -and + $receipt.receiptWritePerformed -is [bool] -and + $receipt.taskDeleted -is [bool] -and + $receipt.serviceRestart -is [bool] -and + $receipt.hostReboot -is [bool] -and + $receipt.vmPowerChange -is [bool] -and + $receipt.secretValueRead -is [bool] -and + $receipt.rawTaskArgumentsStored -is [bool] + ) + $receiptStatusValid = [bool]( + [string]$receipt.status -in @( + "legacy_sender_quarantined_verified", + "legacy_sender_absent_verified" + ) + ) + $expectedReceiptStatus = if ($legacyTask) { + "legacy_sender_quarantined_verified" + } else { + "legacy_sender_absent_verified" + } + $receiptStatusMatchesState = [bool]( + [string]$receipt.status -eq $expectedReceiptStatus + ) + $receiptIdentityValid = [bool]( + [string]$receipt.schemaVersion -eq "agent99_legacy_sender_deployment_quarantine_v1" -and + [string]$receipt.canonicalAssetId -eq "windows-vmware:host_99" -and + [string]$receipt.legacyTask -eq $legacyTaskName -and + [string]$receipt.replacementTask -eq $replacementTaskName -and + [string]$receipt.legacyActionSha256 -eq $expectedLegacyActionSha256 -and + $receipt.legacyActionIdentityVerified -eq $true -and + $receipt.replacementActionIdentityVerified -eq $true -and + [string]$receipt.verifier -eq "agent99_selfcheck_legacy_sender_quarantine_v1" -and + $receipt.legacyEnabledAfter -eq $false -and + $receipt.replacementEnabled -eq $true -and + $receipt.taskDeleted -eq $false -and + $receipt.hostReboot -eq $false -and + $receipt.vmPowerChange -eq $false -and + $receipt.secretValueRead -eq $false -and + $receipt.rawTaskArgumentsStored -eq $false + ) + $currentStateMatchesReceipt = [bool]( + $receipt.legacyTaskFound -is [bool] -and + $receipt.legacyTaskFound -eq [bool]($null -ne $legacyTask) + ) + $verified = [bool]( + -not $legacyEnabled -and + $legacyActionValid -and + [bool]$replacementTask.Settings.Enabled -and + $replacementActionValid -and + $receiptStatusValid -and + $receiptStatusMatchesState -and + $receiptBooleanTypesValid -and + $receiptIdentityValid -and + $producerIdentityValid -and + $currentStateMatchesReceipt + ) + return [pscustomobject]@{ + ok = $verified + severity = if ($verified) { "ok" } else { "critical" } + reason = if ($verified) { + "legacy_sender_quarantine_verified" + } else { + "legacy_sender_quarantine_drift" + } + legacyTaskFound = [bool]($null -ne $legacyTask) + legacyEnabled = $legacyEnabled + legacyActionValid = $legacyActionValid + replacementEnabled = [bool]$replacementTask.Settings.Enabled + replacementActionValid = $replacementActionValid + receiptStatus = [string]$receipt.status + receiptStatusMatchesState = $receiptStatusMatchesState + receiptBooleanTypesValid = $receiptBooleanTypesValid + receiptIdentityValid = $receiptIdentityValid + producerIdentityValid = $producerIdentityValid + currentStateMatchesReceipt = $currentStateMatchesReceipt + runtimeWritePerformed = $false + secretValueRead = $false + evidenceRef = Split-Path -Leaf $receiptPath + evidenceSha256 = (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + } catch { + return [pscustomobject]@{ + ok = $false + severity = "critical" + reason = "legacy_sender_quarantine_readback_failed" + errorCode = if ([string]$_.Exception.Message -match "^[A-Za-z0-9_:-]{1,96}$") { + [string]$_.Exception.Message + } else { + "legacy_sender_quarantine_readback_failed" + } + runtimeWritePerformed = $false + secretValueRead = $false + evidenceRef = if (Test-Path -LiteralPath $receiptPath -PathType Leaf) { + Split-Path -Leaf $receiptPath + } else { + $null + } + } + } +} + function Test-AgentEvidenceFreshness { param( [object[]]$Contracts, @@ -4857,6 +5052,7 @@ function Test-AgentSelfHealth { $latestPerf = Test-AgentLatestPerformanceEvidence $selfConfig.requiredHosts $runtimeManifest = Test-AgentRuntimeManifest $windowsControlBaseline = Test-AgentWindowsControlBaseline $runtimeManifest + $legacySenderQuarantine = Test-AgentLegacySenderQuarantine $windowsProbeProcesses = Get-AgentStaleWindowsProbeProcessReadback $runningQueue = Get-AgentRunningQueueHealth $telegram = if ($selfConfig.requireRecentTelegramDelivery) { @@ -4882,13 +5078,14 @@ function Test-AgentSelfHealth { if ($telegram.severity -eq "critical") { $critical += 1 } if ($runtimeManifest.severity -eq "critical") { $critical += 1 } if ($windowsControlBaseline.severity -eq "critical") { $critical += 1 } + if ($legacySenderQuarantine.severity -eq "critical") { $critical += 1 } if (-not $windowsProbeProcesses.ok) { $critical += 1 } if ($runningQueue.severity -eq "critical") { $critical += 1 } $warning = $evidenceWarning if ($windowsProbeProcesses.ok -and $windowsProbeProcesses.staleCount -gt 0) { $warning += 1 } $severity = if ($critical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" } - $message = "selfHealth severity=$severity taskFailures=$taskFailures evidenceCritical=$evidenceCritical evidenceWarning=$evidenceWarning perfSignal=$($latestPerf.severity) perfReadbackFailures=$($perfReadbackFailures.Count) telegram=$($telegram.severity) runtimeManifest=$($runtimeManifest.severity) windowsControlBaseline=$($windowsControlBaseline.severity) staleWindowsProbes=$($windowsProbeProcesses.staleCount) staleRunningQueue=$($runningQueue.staleCount)" + $message = "selfHealth severity=$severity taskFailures=$taskFailures evidenceCritical=$evidenceCritical evidenceWarning=$evidenceWarning perfSignal=$($latestPerf.severity) perfReadbackFailures=$($perfReadbackFailures.Count) telegram=$($telegram.severity) runtimeManifest=$($runtimeManifest.severity) windowsControlBaseline=$($windowsControlBaseline.severity) legacySenderQuarantine=$($legacySenderQuarantine.severity) staleWindowsProbes=$($windowsProbeProcesses.staleCount) staleRunningQueue=$($runningQueue.staleCount)" Record-AgentEvent "agent_selfcheck" $(if ($severity -eq "ok") { "info" } else { $severity }) $message $null -Alert [pscustomobject]@{ @@ -4900,6 +5097,7 @@ function Test-AgentSelfHealth { telegramDelivery = $telegram runtimeManifest = $runtimeManifest windowsControlBaseline = $windowsControlBaseline + legacySenderQuarantine = $legacySenderQuarantine windowsProbeProcesses = $windowsProbeProcesses runningQueue = $runningQueue summary = [pscustomobject]@{ @@ -4916,6 +5114,9 @@ function Test-AgentSelfHealth { windowsControlBaselineSeverity = $windowsControlBaseline.severity windowsControlBaselineReason = $windowsControlBaseline.reason windowsControlBaselineEvidenceRef = $windowsControlBaseline.evidenceRef + legacySenderQuarantineSeverity = $legacySenderQuarantine.severity + legacySenderQuarantineReason = $legacySenderQuarantine.reason + legacySenderQuarantineEvidenceRef = $legacySenderQuarantine.evidenceRef windowsProbeProcessReadbackOk = $windowsProbeProcesses.ok windowsProbeProcessStaleCount = $windowsProbeProcesses.staleCount windowsProbeProcessWorkingSetMiB = $windowsProbeProcesses.totalWorkingSetMiB diff --git a/agent99-register-tasks.ps1 b/agent99-register-tasks.ps1 index cc3a93bd5..097cdfcef 100644 --- a/agent99-register-tasks.ps1 +++ b/agent99-register-tasks.ps1 @@ -183,15 +183,92 @@ Register-ScheduledTask ` # closed instead of being disabled blindly. $legacyHost188TaskName = "Wooo-Agent99-Host188-Performance-Guard" $legacyHost188ActionSha256 = "a2f98560673ebed5aa1f828b4e577c8a34d1c277b2e498044c3526a4ffe15379" +$legacySenderEvidenceRoot = Join-Path $AgentRoot "evidence\controlled-task-quarantine" +$legacySenderReceiptPath = Join-Path $legacySenderEvidenceRoot "agent99-legacy-sender-deployment-quarantine.json" +$legacySenderTemporaryReceiptPath = "$legacySenderReceiptPath.tmp-$PID" $legacyHost188Task = Get-ScheduledTask ` -TaskName $legacyHost188TaskName ` -TaskPath "\" ` -ErrorAction SilentlyContinue +$legacyHost188TaskFound = [bool]($null -ne $legacyHost188Task) +$legacyHost188EnabledBefore = [bool]( + $legacyHost188Task -and [bool]$legacyHost188Task.Settings.Enabled +) +$legacyHost188ActionIdentityVerified = [bool](-not $legacyHost188TaskFound) +$runtimeManifestPath = Join-Path $AgentRoot "state\runtime-manifest.json" +$registerTaskProducerSha256 = (Get-FileHash ` + -LiteralPath $MyInvocation.MyCommand.Path ` + -Algorithm SHA256).Hash.ToLowerInvariant() +$registerTaskSourceRevision = "unbound" +$registerTaskProducerManifestBound = $false +if (Test-Path -LiteralPath $runtimeManifestPath -PathType Leaf) { + try { + $runtimeManifest = Get-Content -LiteralPath $runtimeManifestPath -Raw | ConvertFrom-Json + $registerTaskManifestRows = @($runtimeManifest.files | Where-Object { + [string]$_.name -eq "agent99-register-tasks.ps1" + }) + $registerTaskSourceRevision = [string]$runtimeManifest.sourceRevision + $registerTaskProducerManifestBound = [bool]( + $runtimeManifest.runtimeMatched -is [bool] -and + $runtimeManifest.runtimeMatched -eq $true -and + -not [string]::IsNullOrWhiteSpace($registerTaskSourceRevision) -and + $registerTaskManifestRows.Count -eq 1 -and + $registerTaskManifestRows[0].matched -is [bool] -and + $registerTaskManifestRows[0].matched -eq $true -and + [string]$registerTaskManifestRows[0].runtimeSha256 -eq $registerTaskProducerSha256 -and + [string]$registerTaskManifestRows[0].sourceSha256 -eq $registerTaskProducerSha256 + ) + } catch { + $registerTaskSourceRevision = "unbound" + $registerTaskProducerManifestBound = $false + } +} + +# Verify the canonical replacement before mutating the legacy task so a +# replacement drift cannot leave the host with both senders unavailable. +$canonicalPerformanceTask = Get-ScheduledTask ` + -TaskName "Wooo-Agent99-Performance-Guard" ` + -TaskPath "\" ` + -ErrorAction Stop +$canonicalPerformanceActions = @($canonicalPerformanceTask.Actions) +if ( + $canonicalPerformanceActions.Count -ne 1 -or + -not [bool]$canonicalPerformanceTask.Settings.Enabled +) { + throw "canonical_performance_sender_not_ready" +} +$canonicalPerformanceAction = $canonicalPerformanceActions[0] +$canonicalPerformanceArguments = [string]$canonicalPerformanceActions[0].Arguments +if (-not [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$canonicalPerformanceAction.Execute, + [string]$ps +)) { + throw "canonical_performance_sender_executable_identity_mismatch" +} +if ([string]$canonicalPerformanceAction.WorkingDirectory) { + throw "canonical_performance_sender_working_directory_identity_mismatch" +} +if ($canonicalPerformanceArguments -cne $perfArgs) { + throw "canonical_performance_sender_action_mismatch" +} +if ($canonicalPerformanceArguments -match "api\.telegram\.org|sendMessage|BotToken|ChatId") { + throw "canonical_performance_sender_direct_telegram_marker" +} + if ($legacyHost188Task) { $legacyHost188Actions = @($legacyHost188Task.Actions) if ($legacyHost188Actions.Count -ne 1) { throw "legacy_host188_sender_action_count_mismatch" } + if (-not [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$legacyHost188Actions[0].Execute, + [string]$ps + )) { + throw "legacy_host188_sender_executable_identity_mismatch" + } + if ([string]$legacyHost188Actions[0].WorkingDirectory) { + throw "legacy_host188_sender_working_directory_identity_mismatch" + } $sha256 = [Security.Cryptography.SHA256]::Create() try { $argumentBytes = [Text.Encoding]::UTF8.GetBytes( @@ -205,6 +282,7 @@ if ($legacyHost188Task) { if ($actualLegacyActionSha256 -ne $legacyHost188ActionSha256) { throw "legacy_host188_sender_action_identity_mismatch" } + $legacyHost188ActionIdentityVerified = $true if ([string]$legacyHost188Task.State -eq "Running") { throw "legacy_host188_sender_running_refuse_quarantine" } @@ -222,6 +300,59 @@ if ($legacyHost188Task) { } } +# Persist a public-safe deployment receipt. No task arguments, Bot token, +# chat ID, environment value, or other credential material is stored in it. +$legacyHost188TaskReadback = Get-ScheduledTask ` + -TaskName $legacyHost188TaskName ` + -TaskPath "\" ` + -ErrorAction SilentlyContinue +$legacyHost188EnabledAfter = [bool]( + $legacyHost188TaskReadback -and + [bool]$legacyHost188TaskReadback.Settings.Enabled +) +if ($legacyHost188EnabledAfter) { + throw "legacy_host188_sender_quarantine_post_verifier_failed" +} + +New-Item -ItemType Directory -Force -Path $legacySenderEvidenceRoot | Out-Null +$legacySenderReceipt = [ordered]@{ + schemaVersion = "agent99_legacy_sender_deployment_quarantine_v1" + status = if ($legacyHost188TaskFound) { + "legacy_sender_quarantined_verified" + } else { + "legacy_sender_absent_verified" + } + canonicalAssetId = "windows-vmware:host_99" + legacyTask = $legacyHost188TaskName + replacementTask = "Wooo-Agent99-Performance-Guard" + legacyTaskFound = $legacyHost188TaskFound + legacyEnabledBefore = $legacyHost188EnabledBefore + legacyEnabledAfter = $legacyHost188EnabledAfter + replacementEnabled = [bool]$canonicalPerformanceTask.Settings.Enabled + legacyActionSha256 = $legacyHost188ActionSha256 + legacyActionIdentityVerified = $legacyHost188ActionIdentityVerified + replacementActionIdentityVerified = $true + sourceRevision = $registerTaskSourceRevision + producerSha256 = $registerTaskProducerSha256 + producerManifestBound = $registerTaskProducerManifestBound + runtimeWritePerformed = [bool]($legacyHost188EnabledBefore -and -not $legacyHost188EnabledAfter) + receiptWritePerformed = $true + taskDeleted = $false + serviceRestart = $false + hostReboot = $false + vmPowerChange = $false + secretValueRead = $false + rawTaskArgumentsStored = $false + verifier = "agent99_selfcheck_legacy_sender_quarantine_v1" + nextSafeAction = "independent_selfcheck_then_confirm_no_legacy_bot_delivery" + generatedAt = (Get-Date -Format o) +} +$legacySenderReceipt | ConvertTo-Json -Depth 6 | + Set-Content -LiteralPath $legacySenderTemporaryReceiptPath -Encoding UTF8 +Move-Item -Force ` + -LiteralPath $legacySenderTemporaryReceiptPath ` + -Destination $legacySenderReceiptPath + $taskNames = @( "Wooo-Agent99-Startup-Recovery", "Wooo-Agent99-Heartbeat", diff --git a/scripts/reboot-recovery/agent99-legacy-sender-quarantine.ps1 b/scripts/reboot-recovery/agent99-legacy-sender-quarantine.ps1 index d2734c60f..e43ce58bd 100644 --- a/scripts/reboot-recovery/agent99-legacy-sender-quarantine.ps1 +++ b/scripts/reboot-recovery/agent99-legacy-sender-quarantine.ps1 @@ -17,11 +17,17 @@ $ProgressPreference = "SilentlyContinue" $LegacyTaskName = "Wooo-Agent99-Host188-Performance-Guard" $ReplacementTaskName = "Wooo-Agent99-Performance-Guard" $ExpectedLegacyActionSha256 = "a2f98560673ebed5aa1f828b4e577c8a34d1c277b2e498044c3526a4ffe15379" +$ExpectedPowerShellExecutable = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" +$ExpectedReplacementLauncher = Join-Path $AgentRoot "agent99-run.ps1" +$ExpectedReplacementArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$ExpectedReplacementLauncher`" -Mode Perf -ControlledApply" $EvidenceRoot = Join-Path $AgentRoot "evidence\controlled-task-quarantine" $ReceiptPath = Join-Path $EvidenceRoot "$RunId.json" $RollbackPath = Join-Path $EvidenceRoot "$RunId.rollback.xml" $TemporaryReceiptPath = "$ReceiptPath.tmp" $SafeIdentityPattern = "^[A-Za-z0-9](?:[A-Za-z0-9_.:-]{0,158}[A-Za-z0-9])?$" +$ExecutorSourceSha256 = (Get-FileHash ` + -LiteralPath $MyInvocation.MyCommand.Path ` + -Algorithm SHA256).Hash.ToLowerInvariant() function Get-ActionArgumentSha256 { param([Parameter(Mandatory = $true)][object]$Action) @@ -42,6 +48,15 @@ function Get-ControlledTaskState { $replacementActions = @($replacement.Actions) if ($legacyActions.Count -ne 1) { throw "legacy_task_action_count_mismatch" } + if (-not [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$legacyActions[0].Execute, + [string]$ExpectedPowerShellExecutable + )) { + throw "legacy_task_executable_identity_mismatch" + } + if (([string]$legacyActions[0].WorkingDirectory).Length -ne 0) { + throw "legacy_task_working_directory_identity_mismatch" + } if ((Get-ActionArgumentSha256 $legacyActions[0]) -ne $ExpectedLegacyActionSha256) { throw "legacy_task_action_identity_mismatch" } @@ -50,8 +65,18 @@ function Get-ControlledTaskState { throw "canonical_replacement_not_ready" } - $replacementArguments = [string]$replacementActions[0].Arguments - if ($replacementArguments -notmatch "agent99-run\.ps1.+-Mode\s+Perf") { + $replacementAction = $replacementActions[0] + $replacementArguments = [string]$replacementAction.Arguments + if (-not [StringComparer]::OrdinalIgnoreCase.Equals( + [string]$replacementAction.Execute, + [string]$ExpectedPowerShellExecutable + )) { + throw "canonical_replacement_executable_identity_mismatch" + } + if (([string]$replacementAction.WorkingDirectory).Length -ne 0) { + throw "canonical_replacement_working_directory_identity_mismatch" + } + if ($replacementArguments -cne $ExpectedReplacementArguments) { throw "canonical_replacement_action_mismatch" } if ($replacementArguments -match "api\.telegram\.org|sendMessage|BotToken|ChatId") { @@ -63,6 +88,8 @@ function Get-ControlledTaskState { legacyState = [string]$legacy.State replacementEnabled = [bool]$replacement.Settings.Enabled legacyActionSha256 = $ExpectedLegacyActionSha256 + legacyActionIdentityVerified = $true + replacementActionIdentityVerified = $true } } @@ -109,7 +136,63 @@ if ($Mode -eq "Check") { if ($Mode -eq "Verify") { $receiptAvailable = Test-Path -LiteralPath $ReceiptPath -PathType Leaf - $verified = [bool](-not $state.legacyEnabled -and $state.replacementEnabled -and $receiptAvailable) + $receiptValid = $false + if ($receiptAvailable) { + try { + $receipt = Get-Content -LiteralPath $ReceiptPath -Raw | ConvertFrom-Json + $rollbackAvailable = Test-Path -LiteralPath $RollbackPath -PathType Leaf + $rollbackSha256 = if ($rollbackAvailable) { + (Get-FileHash -LiteralPath $RollbackPath -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + "" + } + $receiptBooleanTypesValid = [bool]( + $receipt.legacyActionIdentityVerified -is [bool] -and + $receipt.replacementActionIdentityVerified -is [bool] -and + $receipt.legacyEnabledBefore -is [bool] -and + $receipt.legacyEnabledAfter -is [bool] -and + $receipt.replacementEnabledAfter -is [bool] -and + $receipt.remoteWritePerformed -is [bool] -and + $receipt.scheduledTaskModification -is [bool] -and + $receipt.taskDeleted -is [bool] -and + $receipt.serviceRestart -is [bool] -and + $receipt.hostReboot -is [bool] -and + $receipt.vmPowerChange -is [bool] -and + $receipt.secretValueRead -is [bool] -and + $receipt.privateKeyValueRead -is [bool] -and + $receipt.tokenValueRead -is [bool] -and + $receipt.environmentSecretRead -is [bool] -and + $receipt.rawTaskArgumentsStored -is [bool] -and + $receipt.rollbackAvailable -is [bool] -and + $receipt.verifierPassed -is [bool] + ) + $receiptValid = [bool]( + $receiptBooleanTypesValid -and + [string]$receipt.schemaVersion -eq "agent99_legacy_sender_quarantine_receipt_v1" -and + [string]$receipt.traceId -eq $TraceId -and + [string]$receipt.runId -eq $RunId -and + [string]$receipt.workItemId -eq $WorkItemId -and + [string]$receipt.canonicalAssetId -eq "windows-vmware:host_99" -and + [string]$receipt.legacyTask -eq $LegacyTaskName -and + [string]$receipt.replacementTask -eq $ReplacementTaskName -and + [string]$receipt.legacyActionSha256 -eq $ExpectedLegacyActionSha256 -and + [string]$receipt.executorSourceSha256 -eq $ExecutorSourceSha256 -and + $rollbackAvailable -and + [string]$receipt.rollbackArtifactSha256 -eq $rollbackSha256 -and + $receipt.legacyActionIdentityVerified -eq $true -and + $receipt.replacementActionIdentityVerified -eq $true -and + $receipt.legacyEnabledAfter -eq $false -and + $receipt.replacementEnabledAfter -eq $true -and + $receipt.taskDeleted -eq $false -and + $receipt.secretValueRead -eq $false -and + $receipt.rawTaskArgumentsStored -eq $false -and + $receipt.verifierPassed -eq $true + ) + } catch { + $receiptValid = $false + } + } + $verified = [bool](-not $state.legacyEnabled -and $state.replacementEnabled -and $receiptValid) [ordered]@{ schemaVersion = "agent99_legacy_sender_quarantine_verify_v1" status = if ($verified) { "quarantine_verified" } else { "quarantine_unverified" } @@ -121,6 +204,12 @@ if ($Mode -eq "Verify") { legacyEnabled = $state.legacyEnabled replacementEnabled = $state.replacementEnabled durableReceiptAvailable = $receiptAvailable + durableReceiptValid = $receiptValid + durableReceiptSha256 = if ($receiptAvailable) { + (Get-FileHash -LiteralPath $ReceiptPath -Algorithm SHA256).Hash.ToLowerInvariant() + } else { + $null + } remoteWritePerformed = $false secretValueRead = $false } | ConvertTo-Json -Compress -Depth 6 @@ -161,6 +250,9 @@ try { legacyTask = $LegacyTaskName replacementTask = $ReplacementTaskName legacyActionSha256 = $ExpectedLegacyActionSha256 + executorSourceSha256 = $ExecutorSourceSha256 + legacyActionIdentityVerified = $after.legacyActionIdentityVerified + replacementActionIdentityVerified = $after.replacementActionIdentityVerified rollbackArtifactSha256 = $rollbackSha256 legacyEnabledBefore = $state.legacyEnabled legacyEnabledAfter = $after.legacyEnabled @@ -175,6 +267,7 @@ try { privateKeyValueRead = $false tokenValueRead = $false environmentSecretRead = $false + rawTaskArgumentsStored = $false rollbackAvailable = $true verifierPassed = $true nextSafeAction = "persist_exact_legacy_sender_sunset_policy_in_gitea_and_verify_no_new_wooo_bot_receipts" diff --git a/scripts/reboot-recovery/tests/test_agent99_legacy_sender_quarantine.py b/scripts/reboot-recovery/tests/test_agent99_legacy_sender_quarantine.py index 2c8e9563e..526361ef3 100644 --- a/scripts/reboot-recovery/tests/test_agent99_legacy_sender_quarantine.py +++ b/scripts/reboot-recovery/tests/test_agent99_legacy_sender_quarantine.py @@ -4,6 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts/reboot-recovery/agent99-legacy-sender-quarantine.ps1" REGISTER_TASKS = ROOT / "agent99-register-tasks.ps1" +CONTROL_PLANE = ROOT / "agent99-control-plane.ps1" def test_legacy_sender_quarantine_is_exact_bounded_and_rollback_ready() -> None: @@ -21,6 +22,16 @@ def test_legacy_sender_quarantine_is_exact_bounded_and_rollback_ready() -> None: assert "Restart-Service" not in source assert "Restart-Computer" not in source assert r"api\.telegram\.org|sendMessage|BotToken|ChatId" in source + assert "legacy_task_executable_identity_mismatch" in source + assert "legacy_task_working_directory_identity_mismatch" in source + assert "canonical_replacement_executable_identity_mismatch" in source + assert "canonical_replacement_working_directory_identity_mismatch" in source + assert "$replacementArguments -cne $ExpectedReplacementArguments" in source + assert "durableReceiptValid" in source + assert "receiptBooleanTypesValid" in source + assert "executorSourceSha256" in source + assert "rollbackArtifactSha256 -eq $rollbackSha256" in source + assert "rawTaskArgumentsStored = $false" in source assert "secretValueRead = $false" in source assert "taskDeleted = $false" in source @@ -42,11 +53,59 @@ def test_legacy_sender_quarantine_has_check_apply_verify_receipts() -> None: def test_task_registration_persists_the_exact_legacy_sender_sunset() -> None: source = REGISTER_TASKS.read_text(encoding="utf-8") + quarantine_source = source[source.index("# Superseded by the canonical all-host performance guard.") :] assert '"Wooo-Agent99-Host188-Performance-Guard"' in source assert "a2f98560673ebed5aa1f828b4e577c8a34d1c277b2e498044c3526a4ffe15379" in source assert "Disable-ScheduledTask" in source assert "Unregister-ScheduledTask" not in source assert "legacy_host188_sender_action_identity_mismatch" in source + assert "legacy_host188_sender_executable_identity_mismatch" in source + assert "legacy_host188_sender_working_directory_identity_mismatch" in source assert "legacy_host188_sender_running_refuse_quarantine" in source assert "legacy_host188_sender_quarantine_post_verifier_failed" in source + assert "agent99_legacy_sender_deployment_quarantine_v1" in source + assert "agent99-legacy-sender-deployment-quarantine.json" in source + assert "canonical_performance_sender_not_ready" in source + assert "canonical_performance_sender_direct_telegram_marker" in source + assert "canonical_performance_sender_executable_identity_mismatch" in source + assert "canonical_performance_sender_working_directory_identity_mismatch" in source + assert "$canonicalPerformanceArguments -cne $perfArgs" in source + assert "legacyActionIdentityVerified" in source + assert "replacementActionIdentityVerified" in source + assert "$registerTaskProducerManifestBound = [bool](" in source + assert "producerManifestBound = $registerTaskProducerManifestBound" in source + assert "rawTaskArgumentsStored = $false" in source + assert "secretValueRead = $false" in source + assert "receiptWritePerformed = $true" in source + assert "Move-Item -Force" in source + assert quarantine_source.index("$canonicalPerformanceTask = Get-ScheduledTask") < quarantine_source.index( + "Disable-ScheduledTask" + ) + + +def test_selfcheck_independently_verifies_legacy_sender_quarantine() -> None: + source = CONTROL_PLANE.read_text(encoding="utf-8") + + assert "function Test-AgentLegacySenderQuarantine" in source + assert "agent99_legacy_sender_deployment_quarantine_v1" in source + assert "legacy_sender_quarantine_receipt_missing" in source + assert "legacy_sender_quarantine_verified" in source + assert "legacy_sender_quarantine_drift" in source + assert "currentStateMatchesReceipt" in source + assert "receiptStatusMatchesState" in source + assert "replacementActionValid" in source + assert "legacyActionValid" in source + assert "$legacyActionSha256 = Get-AgentSha256Hex" in source + assert "$replacementArguments -ceq $expectedReplacementArguments" in source + assert "[StringComparer]::OrdinalIgnoreCase.Equals" in source + assert "legacyActionIdentityVerified -eq $true" in source + assert "replacementActionIdentityVerified -eq $true" in source + assert "receiptBooleanTypesValid" in source + assert "producerIdentityValid" in source + assert "producerManifestBound -is [bool]" in source + assert "legacyTaskFound -is [bool]" in source + assert "rawTaskArgumentsStored -eq $false" in source + assert "$legacySenderQuarantine = Test-AgentLegacySenderQuarantine" in source + assert 'if ($legacySenderQuarantine.severity -eq "critical")' in source + assert "legacySenderQuarantineEvidenceRef" in source