diff --git a/AGENT99-AI-WORKSTATION-SOP.md b/AGENT99-AI-WORKSTATION-SOP.md index 3588fc69c..d71e65770 100644 --- a/AGENT99-AI-WORKSTATION-SOP.md +++ b/AGENT99-AI-WORKSTATION-SOP.md @@ -1,6 +1,6 @@ # Agent99 AI Workstation SOP -Last updated: 2026-07-11 +Last updated: 2026-07-17 Canonical enterprise scope and work order: @@ -15,6 +15,19 @@ This SOP defines how Agent99 operates. The canonical work ledger defines what re Host 99 is not the only availability observer. An external watcher and maintenance surface must remain available when 99 or the entire local failure domain is down. +## Windows 99 Input-Independent Control Baseline + +The production control path must not depend on the active desktop keyboard layout, IME state, clipboard, pasted punctuation, or a focused VMware/PowerShell window. + +- Preserve the Windows UI, culture, system locale, `zh-Hant-TW`, and existing Bopomofo input method. +- Add `en-US` with input tip `0409:00000409` and make it the default control input method. +- Set the Administrator console default `CodePage` to `65001`; every Agent99 process also sets UTF-8 input/output explicitly. +- Keep `sshd` running with automatic startup. Infrastructure side effects enter through Windows 99 by public-key SSH and noninteractive `-EncodedCommand` or a digest-bound Agent99 broker. +- Every change runs as `Check -> bounded Apply -> independent child-process verifier -> rollback/degraded terminal` under one `trace_id/run_id/work_item_id`. +- The baseline never changes the Traditional Chinese locale, removes an existing input method, logs off the user, reboots the host, uses the clipboard, or accepts arbitrary command text. +- Canonical runtime asset: `C:\Wooo\Agent99\bin\agent99-windows-control-baseline.ps1`; its SHA is part of the 17-file `runtime-manifest.json` bundle. +- GUI and VMware Console remain emergency observation/fallback surfaces only. A GUI focus or IME failure is not allowed to block scheduled monitoring, recovery, backup checks, or verifier readback. + ## 2026-07-10 P0-001 Outcome Truth Contract - `exitCode=0` means only that the PowerShell transport completed. @@ -95,6 +108,7 @@ Agent scripts: - `agent99-submit-request.ps1` - `agent99-telegram-inbox.ps1` - `agent99-sre-alert-inbox.ps1` +- `agent99-windows-control-baseline.ps1` - `host-reboot-scorecard.ps1` - `agent99.config.json` diff --git a/HOST-REBOOT-AI-AUTOMATION-SOP.md b/HOST-REBOOT-AI-AUTOMATION-SOP.md index 314b8dc7c..c113cd634 100644 --- a/HOST-REBOOT-AI-AUTOMATION-SOP.md +++ b/HOST-REBOOT-AI-AUTOMATION-SOP.md @@ -1,6 +1,6 @@ # Host Reboot AI Automation SOP -Last updated: 2026-07-11 +Last updated: 2026-07-17 Canonical enterprise scope and current work order: @@ -122,6 +122,9 @@ P4. Degraded provider follow-up Required capabilities: - Run a local AI Agent service on Windows 99 with least-privilege operator permissions. +- Pass `agent99-windows-control-baseline.ps1` before recovery orchestration: preserve Traditional Chinese/Bopomofo, add the US keyboard fallback, default automation input to `0409:00000409`, set future console sessions to UTF-8, and verify `sshd` is running/automatic. +- Use public-key SSH plus noninteractive `-EncodedCommand` or a digest-bound Agent99 broker for infrastructure work. Desktop focus, IME state, pasted text, clipboard content, or VMware Console typing must never be the production execution dependency. +- Record the Windows control baseline under one `trace_id/run_id/work_item_id` with before state, bounded apply, independent child-process readback, rollback result, runtime-manifest SHA, and no-reboot/no-logoff flags. - Execute the reboot scorecard on a schedule and on boot. - Control VMware Workstation VM startup through `vmrun.exe`. - Use SSH keys to 110 and 120 for service-level checks and controlled repair. diff --git a/agent99-bootstrap.ps1 b/agent99-bootstrap.ps1 index 5904a2eb2..44adacc54 100644 --- a/agent99-bootstrap.ps1 +++ b/agent99-bootstrap.ps1 @@ -231,6 +231,7 @@ $agentFiles = @( "agent99-submit-request.ps1", "agent99-synthetic-tests.ps1", "agent99-telegram-inbox.ps1", + "agent99-windows-control-baseline.ps1", "agent99-windows-update-policy.ps1", "host-reboot-scorecard.ps1", "agent99.config.example.json", diff --git a/agent99-contract-check.ps1 b/agent99-contract-check.ps1 index 7504ef729..25f8be84e 100644 --- a/agent99-contract-check.ps1 +++ b/agent99-contract-check.ps1 @@ -27,6 +27,7 @@ $requiredFiles = @( "agent99-submit-request.ps1", "agent99-synthetic-tests.ps1", "agent99-telegram-inbox.ps1", + "agent99-windows-control-baseline.ps1", "agent99-windows-update-policy.ps1", "host-reboot-scorecard.ps1" ) @@ -134,11 +135,13 @@ $bootstrap = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-bootstrap.ps $deployer = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-deploy.ps1"), [Text.Encoding]::UTF8) $taskRegistration = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-register-tasks.ps1"), [Text.Encoding]::UTF8) $submitRequest = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-submit-request.ps1"), [Text.Encoding]::UTF8) +$windowsControlBaseline = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-windows-control-baseline.ps1"), [Text.Encoding]::UTF8) Add-Check "transport:dedicated_identity" ($control.Contains("sshIdentityFile") -and $control.Contains("IdentitiesOnly=yes")) "dedicated SSH identity is enforced" Add-Check "transport:jump_first" ($control.Contains("preferJumpHost") -and $control.Contains("ssh_jump_fallback")) "preferred jump with bounded fallback is present" Add-Check "transport:stale_process_guard" ($control.Contains("Invoke-AgentStaleSshProcessGuard") -and $control.Contains("taskkill.exe /PID") -and $control.Contains("ssh_stale_process_guard")) "timed-out and stale SSH clients are bounded" Add-Check "transport:cross_process_serialization" ($control.Contains("Enter-AgentSshTransportLock") -and $control.Contains("[IO.FileShare]::None") -and $control.Contains("ssh_transport_lock_failed")) "cross-process SSH sessions are serialized" +Add-Check "windows99:input_independent_control" ($windowsControlBaseline.Contains('$FallbackInputTip = "0409:00000409"') -and $windowsControlBaseline.Contains('$PreservedChineseLanguage = "zh-Hant-TW"') -and $windowsControlBaseline.Contains('encodedCommandCompatible = $true') -and $windowsControlBaseline.Contains('inputMethodIndependent = $true') -and $windowsControlBaseline.Contains('Invoke-IndependentSnapshot') -and $windowsControlBaseline.Contains('Restore-WindowsControlBaseline')) "Windows99 keeps Traditional Chinese/Bopomofo while enforcing a US-keyboard and UTF-8 fallback with an independent verifier and rollback" $dbExecutorIdentityContract = [bool]( $dbBoundedExecutor.Contains('$CommandId = "telegram_receipt_index_v2_apply_v1"') -and $dbBoundedExecutor.Contains('$CanonicalAsset = "public.awooop_outbound_message"') -and diff --git a/agent99-deploy.ps1 b/agent99-deploy.ps1 index e24c2432d..a59890425 100644 --- a/agent99-deploy.ps1 +++ b/agent99-deploy.ps1 @@ -42,6 +42,7 @@ $runtimeFiles = @( "agent99-submit-request.ps1", "agent99-synthetic-tests.ps1", "agent99-telegram-inbox.ps1", + "agent99-windows-control-baseline.ps1", "agent99-windows-update-policy.ps1", "host-reboot-scorecard.ps1", "agent99.config.example.json", diff --git a/agent99-windows-control-baseline.ps1 b/agent99-windows-control-baseline.ps1 new file mode 100644 index 000000000..51e43d94e --- /dev/null +++ b/agent99-windows-control-baseline.ps1 @@ -0,0 +1,370 @@ +[CmdletBinding()] +param( + [ValidateSet("Check", "Apply")][string]$Mode = "Check", + [Parameter(Mandatory = $true)][ValidatePattern("^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$")][string]$TraceId, + [Parameter(Mandatory = $true)][ValidatePattern("^[0-9a-f-]{36}$")][string]$RunId, + [Parameter(Mandatory = $true)][ValidatePattern("^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$")][string]$WorkItemId, + [Parameter(Mandatory = $true)][ValidatePattern("^[0-9a-f]{40}$")][string]$SourceRevision, + [string]$AgentRoot = "C:\Wooo\Agent99" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$global:OutputEncoding = [System.Text.UTF8Encoding]::new($false) + +$SchemaVersion = "agent99_windows_control_baseline_v1" +$CanonicalAsset = "host:192.168.0.99/windows-control-baseline" +$ExpectedComputerName = "WOOO-SUPER" +$ExpectedUserName = "Administrator" +$PreservedCulture = "zh-TW" +$PreservedChineseLanguage = "zh-Hant-TW" +$FallbackLanguage = "en-US" +$FallbackInputTip = "0409:00000409" +$ConsoleRegistryPath = "HKCU:\Console" +$EvidenceRoot = [System.IO.Path]::GetFullPath((Join-Path $AgentRoot "evidence\")) +$EvidencePath = [System.IO.Path]::GetFullPath((Join-Path $EvidenceRoot "agent99-WindowsControlBaseline-$RunId.json")) +$startedAt = Get-Date +$actions = @() +$errorCode = $null +$errorClass = $null +$independentReadback = $null +$rollbackAttempted = $false +$rollbackVerified = $false +$writeAttempted = $false +$before = $null +$after = $null +$beforeLanguageList = $null +$beforeOverride = $null +$beforeConsoleValueExists = $false +$beforeConsoleCodePage = $null +$beforeSshdStartMode = "" + +if (-not $EvidencePath.StartsWith($EvidenceRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "evidence_path_outside_agent_root" +} + +function Write-AtomicJson { + param([string]$Path, [object]$Value) + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null + $temporaryPath = "$Path.tmp" + [System.IO.File]::WriteAllText($temporaryPath, ($Value | ConvertTo-Json -Depth 20), [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $Path -Force +} + +function Get-ConsoleCodePageState { + if (-not (Test-Path -LiteralPath $ConsoleRegistryPath -PathType Container)) { + return [pscustomobject]@{ valueExists = $false; value = $null } + } + $properties = Get-ItemProperty -LiteralPath $ConsoleRegistryPath -ErrorAction Stop + $property = $properties.PSObject.Properties["CodePage"] + if (-not $property) { + return [pscustomobject]@{ valueExists = $false; value = $null } + } + return [pscustomobject]@{ valueExists = $true; value = [int]$property.Value } +} + +function Get-WindowsControlSnapshot { + $languageRows = @( + Get-WinUserLanguageList | ForEach-Object { + [pscustomobject]@{ + languageTag = [string]$_.LanguageTag + inputMethodTips = @($_.InputMethodTips | ForEach-Object { [string]$_ }) + } + } + ) + $override = Get-WinDefaultInputMethodOverride + $consoleState = Get-ConsoleCodePageState + $sshdService = Get-Service -Name "sshd" -ErrorAction Stop + $sshdCim = Get-CimInstance -ClassName Win32_Service -Filter "Name='sshd'" -ErrorAction Stop + [pscustomobject]@{ + computerName = [string]$env:COMPUTERNAME + userName = [string]$env:USERNAME + culture = [string](Get-Culture).Name + uiCulture = [string](Get-UICulture).Name + systemLocale = [string](Get-WinSystemLocale).Name + languages = $languageRows + defaultInputMethodOverride = if ($override) { [string]$override } else { $null } + consoleCodePageValueExists = [bool]$consoleState.valueExists + consoleCodePage = if ($consoleState.valueExists) { [int]$consoleState.value } else { $null } + sshdStatus = [string]$sshdService.Status + sshdStartMode = [string]$sshdCim.StartMode + } +} + +function Get-LanguageSignatures { + param([object[]]$Languages) + $signatures = @() + foreach ($language in @($Languages)) { + if (@($language.inputMethodTips).Count -eq 0) { + $signatures += "$([string]$language.languageTag)|" + continue + } + foreach ($tip in @($language.inputMethodTips)) { + $signatures += "$([string]$language.languageTag)|$([string]$tip)" + } + } + return @($signatures | Sort-Object -Unique) +} + +function Test-WindowsControlSnapshot { + param([object]$Snapshot, [object]$PreserveFrom) + $languageTags = @($Snapshot.languages | ForEach-Object { [string]$_.languageTag }) + $languageSignatures = @(Get-LanguageSignatures $Snapshot.languages) + $preservedSignatures = @(Get-LanguageSignatures $PreserveFrom.languages) + $missingPreserved = @($preservedSignatures | Where-Object { $_ -notin $languageSignatures }) + $fallbackSignature = "$FallbackLanguage|$FallbackInputTip" + $checks = [ordered]@{ + canonicalComputer = [string]$Snapshot.computerName -eq $ExpectedComputerName + canonicalUser = [string]$Snapshot.userName -ieq $ExpectedUserName + culturePreserved = [string]$Snapshot.culture -eq $PreservedCulture + uiCulturePreserved = [string]$Snapshot.uiCulture -eq $PreservedCulture + systemLocalePreserved = [string]$Snapshot.systemLocale -eq $PreservedCulture + chineseLanguagePreserved = $PreservedChineseLanguage -in $languageTags + priorInputMethodsPreserved = $missingPreserved.Count -eq 0 + englishFallbackPresent = $fallbackSignature -in $languageSignatures + defaultInputIsUsKeyboard = [string]$Snapshot.defaultInputMethodOverride -eq $FallbackInputTip + consoleUtf8 = [bool]$Snapshot.consoleCodePageValueExists -and [int]$Snapshot.consoleCodePage -eq 65001 + sshdRunning = [string]$Snapshot.sshdStatus -eq "Running" + sshdAutomatic = [string]$Snapshot.sshdStartMode -eq "Auto" + } + [pscustomobject]@{ + passed = @($checks.GetEnumerator() | Where-Object { -not $_.Value }).Count -eq 0 + checks = [pscustomobject]$checks + missingPreservedLanguageSignatures = $missingPreserved + } +} + +function Get-SnapshotSignature { + param([object]$Snapshot) + $normalized = [ordered]@{ + computerName = [string]$Snapshot.computerName + userName = [string]$Snapshot.userName + culture = [string]$Snapshot.culture + uiCulture = [string]$Snapshot.uiCulture + systemLocale = [string]$Snapshot.systemLocale + languages = @(Get-LanguageSignatures $Snapshot.languages) + defaultInputMethodOverride = [string]$Snapshot.defaultInputMethodOverride + consoleCodePageValueExists = [bool]$Snapshot.consoleCodePageValueExists + consoleCodePage = if ($null -ne $Snapshot.consoleCodePage) { [int]$Snapshot.consoleCodePage } else { $null } + sshdStatus = [string]$Snapshot.sshdStatus + sshdStartMode = [string]$Snapshot.sshdStartMode + } + return ($normalized | ConvertTo-Json -Depth 8 -Compress) +} + +function Invoke-IndependentSnapshot { + $verifierScript = @' +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$languages = @(Get-WinUserLanguageList | ForEach-Object { + [pscustomobject]@{ languageTag = [string]$_.LanguageTag; inputMethodTips = @($_.InputMethodTips | ForEach-Object { [string]$_ }) } +}) +$override = Get-WinDefaultInputMethodOverride +$consolePath = "HKCU:\Console" +$consoleExists = $false +$consoleCodePage = $null +if (Test-Path -LiteralPath $consolePath -PathType Container) { + $properties = Get-ItemProperty -LiteralPath $consolePath -ErrorAction Stop + $property = $properties.PSObject.Properties["CodePage"] + if ($property) { $consoleExists = $true; $consoleCodePage = [int]$property.Value } +} +$sshd = Get-Service -Name "sshd" -ErrorAction Stop +$sshdCim = Get-CimInstance -ClassName Win32_Service -Filter "Name='sshd'" -ErrorAction Stop +[ordered]@{ + processId = $PID + snapshot = [ordered]@{ + computerName = [string]$env:COMPUTERNAME + userName = [string]$env:USERNAME + culture = [string](Get-Culture).Name + uiCulture = [string](Get-UICulture).Name + systemLocale = [string](Get-WinSystemLocale).Name + languages = $languages + defaultInputMethodOverride = if ($override) { [string]$override } else { $null } + consoleCodePageValueExists = $consoleExists + consoleCodePage = $consoleCodePage + sshdStatus = [string]$sshd.Status + sshdStartMode = [string]$sshdCim.StartMode + } +} | ConvertTo-Json -Depth 10 -Compress +'@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($verifierScript)) + $token = [Guid]::NewGuid().ToString("N") + $stdoutPath = Join-Path $env:TEMP "agent99-windows-baseline-$token.out" + $stderrPath = Join-Path $env:TEMP "agent99-windows-baseline-$token.err" + try { + $process = Start-Process -FilePath "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -ArgumentList @("-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", $encoded) ` + -NoNewWindow -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -PassThru + if (-not $process.WaitForExit(120 * 1000)) { + try { & taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null } catch {} + throw "independent_verifier_timeout" + } + $process.WaitForExit() | Out-Null + $process.Refresh() + if ([int]$process.ExitCode -ne 0) { throw "independent_verifier_failed" } + try { + return (Get-Content -LiteralPath $stdoutPath -Raw | ConvertFrom-Json) + } catch { + throw "independent_verifier_receipt_invalid" + } + } finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Set-WindowsControlBaseline { + $languageList = Get-WinUserLanguageList + if ($FallbackLanguage -notin @($languageList | ForEach-Object { [string]$_.LanguageTag })) { + $fallback = New-WinUserLanguageList $FallbackLanguage + $languageList.Add($fallback[0]) | Out-Null + Set-WinUserLanguageList -LanguageList $languageList -Force + $script:actions += "added_en_us_keyboard_fallback" + $script:writeAttempted = $true + } + if ([string](Get-WinDefaultInputMethodOverride) -ne $FallbackInputTip) { + Set-WinDefaultInputMethodOverride -InputTip $FallbackInputTip + $script:actions += "set_default_input_method_us_keyboard" + $script:writeAttempted = $true + } + $consoleState = Get-ConsoleCodePageState + if (-not $consoleState.valueExists -or [int]$consoleState.value -ne 65001) { + New-Item -Path $ConsoleRegistryPath -Force | Out-Null + New-ItemProperty -Path $ConsoleRegistryPath -Name "CodePage" -Value 65001 -PropertyType DWord -Force | Out-Null + $script:actions += "set_future_console_code_page_utf8" + $script:writeAttempted = $true + } + $sshd = Get-Service -Name "sshd" -ErrorAction Stop + $sshdCim = Get-CimInstance -ClassName Win32_Service -Filter "Name='sshd'" -ErrorAction Stop + if ([string]$sshdCim.StartMode -ne "Auto") { + Set-Service -Name "sshd" -StartupType Automatic + $script:actions += "set_sshd_automatic" + $script:writeAttempted = $true + } + if ([string]$sshd.Status -ne "Running") { + Start-Service -Name "sshd" + $script:actions += "start_sshd" + $script:writeAttempted = $true + } +} + +function Restore-WindowsControlBaseline { + Set-WinUserLanguageList -LanguageList $beforeLanguageList -Force + if ($beforeOverride) { + Set-WinDefaultInputMethodOverride -InputTip ([string]$beforeOverride) + } else { + Set-WinDefaultInputMethodOverride + } + if ($beforeConsoleValueExists) { + New-Item -Path $ConsoleRegistryPath -Force | Out-Null + New-ItemProperty -Path $ConsoleRegistryPath -Name "CodePage" -Value ([int]$beforeConsoleCodePage) -PropertyType DWord -Force | Out-Null + } else { + Remove-ItemProperty -LiteralPath $ConsoleRegistryPath -Name "CodePage" -ErrorAction SilentlyContinue + } + $startupType = switch ($beforeSshdStartMode) { + "Auto" { "Automatic" } + "Manual" { "Manual" } + "Disabled" { "Disabled" } + default { "Automatic" } + } + Set-Service -Name "sshd" -StartupType $startupType +} + +try { + $beforeLanguageList = Get-WinUserLanguageList + $beforeOverride = Get-WinDefaultInputMethodOverride + $beforeConsoleState = Get-ConsoleCodePageState + $beforeConsoleValueExists = [bool]$beforeConsoleState.valueExists + $beforeConsoleCodePage = $beforeConsoleState.value + $beforeSshdStartMode = [string](Get-CimInstance -ClassName Win32_Service -Filter "Name='sshd'" -ErrorAction Stop).StartMode + $before = Get-WindowsControlSnapshot + if ([string]$before.computerName -ne $ExpectedComputerName) { throw "canonical_computer_mismatch" } + if ([string]$before.userName -ine $ExpectedUserName) { throw "canonical_user_mismatch" } + if ($Mode -eq "Apply") { Set-WindowsControlBaseline } + $after = Get-WindowsControlSnapshot + $independentReadback = Invoke-IndependentSnapshot +} catch { + $errorClass = $_.Exception.GetType().Name + $candidateCode = [string]$_.Exception.Message + $errorCode = if ($candidateCode -match "^[A-Za-z0-9_:-]{1,96}$") { $candidateCode } else { "windows_control_baseline_failed" } +} + +$postCondition = if ($after -and $before) { Test-WindowsControlSnapshot $after $before } else { $null } +$independentPostCondition = if ($independentReadback -and $before) { Test-WindowsControlSnapshot $independentReadback.snapshot $before } else { $null } +$independentAgreement = [bool]( + $after -and $independentReadback -and + (Get-SnapshotSignature $after) -eq (Get-SnapshotSignature $independentReadback.snapshot) +) +$runtimeVerified = [bool]( + -not $errorCode -and $postCondition -and $independentPostCondition -and + $postCondition.passed -and $independentPostCondition.passed -and $independentAgreement +) + +if ($Mode -eq "Apply" -and $writeAttempted -and -not $runtimeVerified) { + $rollbackAttempted = $true + try { + Restore-WindowsControlBaseline + $rollbackSnapshot = Get-WindowsControlSnapshot + $rollbackVerified = (Get-SnapshotSignature $rollbackSnapshot) -eq (Get-SnapshotSignature $before) + } catch { + $rollbackVerified = $false + } +} + +$finishedAt = Get-Date +$terminal = if ($Mode -eq "Check" -and $independentAgreement -and -not $errorCode) { + "verified_check_no_write" +} elseif ($runtimeVerified -and $writeAttempted) { + "runtime_verified_controlled_apply" +} elseif ($runtimeVerified) { + "runtime_verified_healthy_no_write" +} elseif ($rollbackAttempted -and $rollbackVerified) { + "rolled_back_verified" +} else { + "degraded_with_safe_next_action" +} +$passed = [bool]( + ($Mode -eq "Check" -and $independentAgreement -and -not $errorCode) -or + ($Mode -eq "Apply" -and $runtimeVerified) +) +$receipt = [ordered]@{ + schemaVersion = $SchemaVersion + traceId = $TraceId + runId = $RunId + workItemId = $WorkItemId + generatedAt = $finishedAt.ToString("o") + sourceRevision = $SourceRevision + executor = "host:192.168.0.99" + target = $CanonicalAsset + mode = $Mode + risk = [ordered]@{ level = if ($Mode -eq "Apply") { "medium" } else { "low" }; hostReboot = $false; userLogoff = $false; guiInteraction = $false; clipboardUsed = $false; secretValueRead = $false } + desired = [ordered]@{ preservedCulture = $PreservedCulture; preservedLanguage = $PreservedChineseLanguage; fallbackLanguage = $FallbackLanguage; fallbackInputTip = $FallbackInputTip; consoleCodePage = 65001; sshd = "Running/Auto" } + check = [ordered]@{ before = $before; postCondition = $postCondition } + execution = [ordered]@{ performed = $writeAttempted; actions = @($actions); errorCode = $errorCode; errorClass = $errorClass; elapsedMs = [math]::Round(($finishedAt - $startedAt).TotalMilliseconds) } + verifier = [ordered]@{ independentProcessId = if ($independentReadback) { [int]$independentReadback.processId } else { $null }; readback = if ($independentReadback) { $independentReadback.snapshot } else { $null }; postCondition = $independentPostCondition; exactAgreement = $independentAgreement; passed = $runtimeVerified } + rollback = [ordered]@{ attempted = $rollbackAttempted; verified = $rollbackVerified; sshdStopAllowed = $false } + transport = [ordered]@{ encodedCommandCompatible = $true; interactiveInputRequired = $false; inputMethodIndependent = $true; utf8Output = $true } + telegramNotificationSent = $false + kmRagWritebackPerformed = $false + terminal = $terminal +} +Write-AtomicJson -Path $EvidencePath -Value $receipt + +[ordered]@{ + evidencePath = $EvidencePath + evidenceSha256 = (Get-FileHash -LiteralPath $EvidencePath -Algorithm SHA256).Hash.ToLowerInvariant() + terminal = $terminal + mode = $Mode + writePerformed = $writeAttempted + ready = if ($postCondition) { [bool]$postCondition.passed } else { $false } + independentVerifier = $independentAgreement + rollbackAttempted = $rollbackAttempted + rollbackVerified = $rollbackVerified + errorCode = $errorCode +} | ConvertTo-Json -Compress -Depth 8 + +if (-not $passed) { exit 3 } +exit 0 diff --git a/docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json b/docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json index ced6b0e19..107da4ea3 100644 --- a/docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json +++ b/docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json @@ -544,7 +544,7 @@ "next_action": "Add the canonical asset to the reconciled source registry and require same-run production readback." }, "members": [ - {"canonical_id": "windows-vmware:host_99", "inventory_labels": ["Agent99 Windows/VMware control plane"], "source_rows": [], "runtime_identity": "192.168.0.99/Agent99", "findings": ["Agent99 is required for Windows/VMware and control-plane recovery but absent from the imported inventory", "Agent99 also owns the read-only host110 Alertmanager poll/reduced relay coordination role; Linux remediation still routes only to the host Ansible executor", "The atomic 16-file runtime bundle includes the poller and fixed database dispatch entrypoint but production deployment/readback is pending"], "source_refs": ["docs/operations/agent99-enterprise-ai-automation-work-items.snapshot.json", "k8s/awoooi-prod/04-configmap.yaml", "agent99-alertmanager-alertchain-poll.ps1", "agent99-db-bounded-executor.ps1", "scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh"], "owner_lane": "Agent99", "domain_router": "windows_vmware", "executor": "Agent99", "verifier": "agent99_independent_runtime_verifier"}, + {"canonical_id": "windows-vmware:host_99", "inventory_labels": ["Agent99 Windows/VMware control plane"], "source_rows": [], "runtime_identity": "192.168.0.99/Agent99", "findings": ["Agent99 is required for Windows/VMware and control-plane recovery but absent from the imported inventory", "Agent99 also owns the read-only host110 Alertmanager poll/reduced relay coordination role; Linux remediation still routes only to the host Ansible executor", "The atomic 17-file runtime bundle includes the poller, fixed database dispatch entrypoint, and Windows input-independent control baseline; production deployment/readback remains pending"], "source_refs": ["docs/operations/agent99-enterprise-ai-automation-work-items.snapshot.json", "k8s/awoooi-prod/04-configmap.yaml", "agent99-alertmanager-alertchain-poll.ps1", "agent99-db-bounded-executor.ps1", "agent99-windows-control-baseline.ps1", "scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh"], "owner_lane": "Agent99", "domain_router": "windows_vmware", "executor": "Agent99", "verifier": "agent99_independent_runtime_verifier"}, {"canonical_id": "service:ollama:host110", "inventory_labels": ["retired Host110 Ollama tombstone"], "source_rows": [], "runtime_identity": "retired-tombstone:host110/ollama/no-endpoint", "source_truth_state": "retired_tombstone_runtime_absence_verified", "reconciliation_state": "retired_tombstone_runtime_absence_verified", "findings": ["Host110 Ollama is forbidden as a provider, proxy, primary, secondary or failover target", "A tombstone is retained so stale sensors and routes create drift instead of resurrecting the endpoint", "AIA-SRE-002 run-aia-sre-002-20260716-1752 removed the exact stale Nginx 11435 proxy with a root-only rollback backup", "Independent post-verifier proved listeners 11435/11434=0, Ollama containers and systemd units=0, stale config paths=0, Nginx active/config valid and Prometheus host110 Ollama targets=0"], "source_refs": ["ops/config/service-registry.yaml", "ops/monitoring/service-registry.yaml", "docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json", "scripts/ops/retire-host110-ollama-proxy.sh"], "owner_lane": "ai_team", "domain_router": "host_systemd", "executor": "host_ansible_executor", "bounded_executor_receipt": "run-aia-sre-002-20260716-1752", "runtime_closure": {"status": "verified_absent", "trace_id": "trace-aia-sre-002-20260716-1752", "run_id": "run-aia-sre-002-20260716-1752", "work_item_id": "AIA-SRE-002", "post_verifier": "host110_ollama_runtime_and_monitoring_absence_verifier"}, "verifier": "host110_ollama_runtime_and_monitoring_absence_verifier", "monitoring": {"signals": ["runtime_endpoint_absence", "prometheus_target_absence", "route_reference_absence"], "coverage": "runtime_absence_verified_20260716"}, "next_action": "Preserve the tombstone and create a drift work item on any Host110 Ollama process, proxy, listener, target, rule or provider-route recurrence."}, {"canonical_id": "host:111", "inventory_labels": ["host111 local Ollama"], "source_rows": [], "runtime_identity": "192.168.0.111/macos-launchd/ollama", "findings": ["Approved third provider hop and Ansible host are absent from the imported inventory", "The runtime manager is a macOS LaunchAgent, not systemd", "The exact LaunchAgent playbook requires local verification plus independent origin probes from host120 and host121", "The canonical host111 Ollama sensor/rule has no fresh production series"], "source_refs": ["infra/ansible/inventory/hosts.yml", "infra/ansible/playbooks/111-ollama-fallback.yml", "ops/monitoring/service-registry.yaml", "ops/monitoring/alerts-unified.yml", "apps/api/src/services/awooop_ansible_post_verifier.py"], "owner_lane": "ai_team", "domain_router": "host_systemd", "executor": "host_ansible_executor", "verifier": "host111_launchagent_local_and_120_121_origin_verifier", "monitoring": {"signals": ["launchagent_state", "local_generation", "host120_origin_generation", "host121_origin_generation", "canonical_alert_series"], "coverage": "source_candidate_runtime_sensor_missing"}, "next_action": "Check/apply the exact LaunchAgent catalog, verify local plus host120/host121 origins, and require a fresh canonical sensor series before provider readiness."}, {"canonical_id": "ai-provider:ollama_gcp_a", "inventory_labels": ["GCP-A Ollama"], "source_rows": [], "runtime_identity": "34.143.170.20:11434", "source_truth_state": "source_reconciled_runtime_pending", "findings": ["Approved first provider hop is absent from the imported inventory", "Observed transport is public HTTP and is candidate-only until secure mesh/TLS promotion", "Only sanitized prompts may cross this boundary; unsanitized input and every tool loop fail closed before network access", "The exact Prometheus provider target is missing in production"], "source_refs": ["ops/monitoring/service-registry.yaml", "k8s/monitoring/prometheus.yml", "docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json", "apps/api/src/services/ai_provider_policy.py"], "owner_lane": "ai_team", "domain_router": "ai_provider", "executor": "deterministic_provider_router", "verifier": "provider_health_generation_and_transport_receipt_verifier", "monitoring": {"signals": ["exact_target_up", "scrape_freshness", "sanitization_receipt", "tool_loop_denial", "transport_boundary"], "coverage": "source_candidate_runtime_target_missing"}, "transport_policy": {"observed": "public_http", "execution_scope": "sanitized_candidate_only", "tool_loop": "fail_closed", "promotion_requires": "secure_mesh_or_tls_plus_runtime_readback"}, "next_action": "Deploy the exact blackbox target, verify fresh series and sanitization/tool-loop denials, then retain candidate-only status until secure transport promotion."}, @@ -556,7 +556,7 @@ {"canonical_id": "k8s:awoooi-prod:web", "inventory_labels": ["AWOOOI production Web"], "source_rows": [], "runtime_identity": "k3s/awoooi-prod/deployment/awoooi-web", "findings": ["Production Web is in the monitoring registry but absent from the imported infrastructure inventory"], "source_refs": ["ops/monitoring/service-registry.yaml", "k8s/awoooi-prod"], "owner_lane": "frontend_team", "domain_router": "kubernetes_workload", "executor": "kubernetes_controlled_executor", "verifier": "kubernetes_rollout_verifier"}, {"canonical_id": "k8s:awoooi-prod:worker", "inventory_labels": ["AWOOOI production Worker"], "source_rows": [], "runtime_identity": "k3s/awoooi-prod/deployment/awoooi-worker", "findings": ["Worker is in the monitoring registry but absent from the imported infrastructure inventory"], "source_refs": ["ops/monitoring/service-registry.yaml", "k8s/awoooi-prod"], "owner_lane": "backend_team", "domain_router": "kubernetes_workload", "executor": "kubernetes_controlled_executor", "verifier": "kubernetes_rollout_verifier"}, {"canonical_id": "k8s:awoooi-prod:broker", "inventory_labels": ["AWOOOI production Broker"], "source_rows": [], "runtime_identity": "k3s/awoooi-prod/deployment/awoooi-broker", "findings": ["Broker is part of production deployment/readback but absent from the imported inventory and monitoring service registry"], "source_refs": ["k8s/awoooi-prod", "docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json"], "owner_lane": "backend_team", "domain_router": "kubernetes_workload", "executor": "kubernetes_controlled_executor", "verifier": "kubernetes_rollout_verifier"}, - {"canonical_id": "signal:alertmanager-webhook-chain", "inventory_labels": ["AlertChainBroken_Alertmanager"], "source_rows": [], "runtime_identity": "alertmanager->awoooi-api-webhook->incident->telegram plus host99 Agent99 exact-host pull fallback", "source_truth_state": "source_reconciled_runtime_pending", "findings": ["Canonical signal is locked to container:host_110:alertmanager", "Rule, concise attribution card, exact bounded recovery and independent verifier are source-ready", "Host99 Agent99 independently polls only the exact active/critical AlertChain signal from host110 without a first-hop credential or raw-payload persistence", "The reduced event is relayed over HTTPS with stable dedupe while Linux execution remains in the host Ansible lane", "Production deployment, poll freshness/dedupe, current alert resolution and same-run learning acknowledgements remain pending"], "source_refs": ["k8s/monitoring/alert-chain-monitor.yaml", "ops/alertmanager/alertmanager.yml", "agent99-alertmanager-alertchain-poll.ps1", "agent99-sre-alert-relay.ps1", "apps/api/src/api/v1/webhooks.py", "apps/api/src/services/controlled_alert_target_router.py", "infra/ansible/playbooks/110-alertmanager-delivery-recovery.yml", "scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh"], "owner_lane": "observability_ops", "verifier": "alert_delivery_chain_plus_agent99_pull_independent_verifier", "monitoring": {"signals": ["alertmanager_delivery_counters", "primary_webhook_health", "agent99_pull_freshness", "dedupe_receipt", "telegram_delivery_receipt"], "coverage": "source_primary_and_independent_pull_ready_runtime_pending"}, "next_action": "Deploy the receiver-scoped scrape/rule/playbook and the atomic Agent99 16-file bundle; run check/apply/verify under one trace/run/work item; then require Alertmanager resolution, Telegram and KM/RAG/MCP/PlayBook acknowledgements before closure."}, + {"canonical_id": "signal:alertmanager-webhook-chain", "inventory_labels": ["AlertChainBroken_Alertmanager"], "source_rows": [], "runtime_identity": "alertmanager->awoooi-api-webhook->incident->telegram plus host99 Agent99 exact-host pull fallback", "source_truth_state": "source_reconciled_runtime_pending", "findings": ["Canonical signal is locked to container:host_110:alertmanager", "Rule, concise attribution card, exact bounded recovery and independent verifier are source-ready", "Host99 Agent99 independently polls only the exact active/critical AlertChain signal from host110 without a first-hop credential or raw-payload persistence", "The reduced event is relayed over HTTPS with stable dedupe while Linux execution remains in the host Ansible lane", "Production deployment, poll freshness/dedupe, current alert resolution and same-run learning acknowledgements remain pending"], "source_refs": ["k8s/monitoring/alert-chain-monitor.yaml", "ops/alertmanager/alertmanager.yml", "agent99-alertmanager-alertchain-poll.ps1", "agent99-sre-alert-relay.ps1", "apps/api/src/api/v1/webhooks.py", "apps/api/src/services/controlled_alert_target_router.py", "infra/ansible/playbooks/110-alertmanager-delivery-recovery.yml", "scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh"], "owner_lane": "observability_ops", "verifier": "alert_delivery_chain_plus_agent99_pull_independent_verifier", "monitoring": {"signals": ["alertmanager_delivery_counters", "primary_webhook_health", "agent99_pull_freshness", "dedupe_receipt", "telegram_delivery_receipt"], "coverage": "source_primary_and_independent_pull_ready_runtime_pending"}, "next_action": "Deploy the receiver-scoped scrape/rule/playbook and the atomic Agent99 17-file bundle; run check/apply/verify under one trace/run/work item; then require Alertmanager resolution, Telegram and KM/RAG/MCP/PlayBook acknowledgements before closure."}, {"canonical_id": "control-plane:telegram-routing-registry", "inventory_labels": ["Canonical Telegram routing registry"], "source_rows": [], "runtime_identity": "docs/security/telegram-canonical-routing-registry.snapshot.json", "findings": ["Nine product routes are blocked/disabled/not implemented; scattering alerts is forbidden"], "source_refs": ["docs/security/telegram-canonical-routing-registry.snapshot.json"], "owner_lane": "NotificationGovernance", "verifier": "telegram_route_and_delivery_receipt_verifier"}, {"canonical_id": "control-plane:km-rag", "inventory_labels": ["Knowledge Base and RAG"], "source_rows": [], "runtime_identity": "awoooi-api/knowledge-rag", "findings": ["Required learning target is implemented in source but absent from the imported inventory and runtime closure is unverified"], "source_refs": ["apps/api/src/api/v1/knowledge.py", "apps/api/src/api/v1/rag.py"], "owner_lane": "LearningPlane", "verifier": "km_rag_durable_write_ack_verifier"}, {"canonical_id": "control-plane:mcp-gateway", "inventory_labels": ["Internal MCP Gateway"], "source_rows": [], "runtime_identity": "awoooi-api/mcp-control-plane", "findings": ["MCP catalog has broad product coverage but is absent from the imported inventory; write adapters must remain policy controlled"], "source_refs": ["docs/operations/mcp-control-plane-catalog.snapshot.json"], "owner_lane": "MCPControlPlane", "verifier": "mcp_gateway_audit_and_runtime_verifier"}, diff --git a/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json b/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json index 949f70175..01c8aa2af 100644 --- a/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json +++ b/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json @@ -765,7 +765,7 @@ "the host99 Agent99 independent pull/reduced relay has no production deployment, freshness, dedupe or controlled-repair receipt", "production Alertmanager exposes integration-only notification counters; the bounded check now passes after adding a scrape-time receiver_contract label, but apply, alert resolution and same-run learning receipts remain pending" ], - "next_action": "deploy the production-schema-compatible Alertmanager runtime scrape/canonical rules, the host99 Agent99 exact-host poller and the fixed DB dispatch entrypoint through the atomic 16-file bundle; verify receiver-contract freshness, dedupe and the current firing alert resolution; then reconcile every project/product/alert class against its typed domain and canonical Telegram destination" + "next_action": "deploy the production-schema-compatible Alertmanager runtime scrape/canonical rules, the host99 Agent99 exact-host poller and the fixed DB dispatch entrypoint through the atomic 17-file bundle; verify receiver-contract freshness, dedupe and the current firing alert resolution; then reconcile every project/product/alert class against its typed domain and canonical Telegram destination" }, { "id": "AIA-SRE-018", diff --git a/scripts/reboot-recovery/agent99-live-preflight.ps1 b/scripts/reboot-recovery/agent99-live-preflight.ps1 index d0e2b79a0..d1816ab5b 100644 --- a/scripts/reboot-recovery/agent99-live-preflight.ps1 +++ b/scripts/reboot-recovery/agent99-live-preflight.ps1 @@ -486,7 +486,7 @@ $decision = Get-AgentLivePreflightDecision ` -AgentRootPresent ([bool](Test-Path $AgentRoot)) ` -Manifest $manifest ` -ManifestCheckRequired ([bool]($ReadinessScope -eq "FullRuntime")) ` - -ExpectedRuntimeFileCount 16 ` + -ExpectedRuntimeFileCount 17 ` -TaskFailureCount $taskFailures.Count ` -RelayListenerCount $relayListeners.Count ` -RequiredEvidenceStaleCount $requiredEvidenceStale.Count ` diff --git a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 index e2eec3c7c..1f368e794 100644 --- a/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 +++ b/scripts/reboot-recovery/agent99-remote-atomic-deploy-receiver.ps1 @@ -24,6 +24,7 @@ $RelayPollIntervalSeconds = 1 $ValidateOnlyTimeoutSeconds = 300 $LiveDeployTimeoutSeconds = 300 $LivePreflightTimeoutSeconds = 120 +$WindowsControlBaselineTimeoutSeconds = 300 $PromotionReadinessReserveMinutes = 15 $PromotionReadinessMaxAttempts = 2 $PromotionReadinessRetryWaitSeconds = 30 @@ -40,6 +41,7 @@ $FixedRuntimeFiles = @( "agent99-submit-request.ps1", "agent99-synthetic-tests.ps1", "agent99-telegram-inbox.ps1", + "agent99-windows-control-baseline.ps1", "agent99-windows-update-policy.ps1", "host-reboot-scorecard.ps1", "agent99.config.example.json", @@ -306,6 +308,60 @@ function Invoke-AgentDeployChild { } } +function Invoke-AgentWindowsControlBaseline { + param( + [string]$TraceId, + [string]$RunId, + [string]$WorkItemId, + [string]$SourceRevision + ) + + $scriptPath = Join-Path $BinDir "agent99-windows-control-baseline.ps1" + $evidencePath = Join-Path $EvidenceDir "agent99-WindowsControlBaseline-$RunId.json" + $arguments = @( + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", $scriptPath, + "-Mode", "Apply", + "-TraceId", $TraceId, + "-RunId", $RunId, + "-WorkItemId", $WorkItemId, + "-SourceRevision", $SourceRevision, + "-AgentRoot", $AgentRoot + ) + $processResult = Invoke-AgentBoundedPowerShell -Arguments $arguments -TimeoutSeconds $WindowsControlBaselineTimeoutSeconds + $payload = Convert-AgentChildJson @($processResult.stdout) + $evidence = $null + if (Test-Path -LiteralPath $evidencePath -PathType Leaf) { + try { $evidence = Get-Content -LiteralPath $evidencePath -Raw | ConvertFrom-Json } catch { $evidence = $null } + } + $ok = [bool]( + $processResult.exitCode -eq 0 -and + -not $processResult.timedOut -and + $payload -and + $evidence -and + [string]$evidence.schemaVersion -eq "agent99_windows_control_baseline_v1" -and + [string]$evidence.traceId -eq $TraceId -and + [string]$evidence.runId -eq $RunId -and + [string]$evidence.workItemId -eq $WorkItemId -and + [string]$evidence.sourceRevision -eq $SourceRevision -and + [string]$evidence.terminal -in @("runtime_verified_controlled_apply", "runtime_verified_healthy_no_write") -and + $evidence.verifier.passed -eq $true -and + $evidence.transport.inputMethodIndependent -eq $true -and + $evidence.risk.hostReboot -eq $false -and + $evidence.risk.userLogoff -eq $false + ) + return [pscustomobject]@{ + ok = $ok + exitCode = [int]$processResult.exitCode + timedOut = [bool]$processResult.timedOut + terminal = if ($evidence) { [string]$evidence.terminal } else { "receipt_unavailable" } + settingsWritePerformed = [bool]($evidence -and $evidence.execution.performed) + independentVerifierPassed = [bool]($evidence -and $evidence.verifier.passed) + evidencePath = $evidencePath + evidenceSha256 = Get-AgentSha256File $evidencePath + } +} + function Invoke-AgentLivePreflight { param( [string]$PreflightPath, @@ -884,7 +940,7 @@ try { } $sourceRevision = ([string]$envelope.sourceRevision).ToLowerInvariant() if ($sourceRevision -notmatch "^[0-9a-f]{40}$") { throw "invalid_source_revision" } - if ([int]$envelope.expectedRuntimeFileCount -ne 16) { throw "invalid_expected_runtime_file_count" } + if ([int]$envelope.expectedRuntimeFileCount -ne 17) { throw "invalid_expected_runtime_file_count" } $traceId = [string]$envelope.traceId $runId = [string]$envelope.runId @@ -919,7 +975,7 @@ try { $sourceManifest = [pscustomobject]@{ schemaVersion = "agent99_remote_source_manifest_v1" sourceRevision = $sourceRevision - fileCount = 16 + fileCount = 17 manifestSha256 = $manifestDigest files = @($FixedRuntimeFiles | ForEach-Object { [pscustomobject]@{ name = $_; sha256 = ([string]($filesByName[$_].sha256)).ToLowerInvariant() } @@ -956,7 +1012,7 @@ try { status = "check_ready" mode = "check" sourceRevision = $sourceRevision - expectedRuntimeFileCount = 16 + expectedRuntimeFileCount = 17 manifestSha256 = $manifestDigest plannedStagingPath = $stagePath runtimeManifest = $currentManifest @@ -1043,7 +1099,7 @@ try { if ( [string]$existingSourceManifest.schemaVersion -ne "agent99_remote_source_manifest_v1" -or [string]$existingSourceManifest.sourceRevision -ne $sourceRevision -or - [int]$existingSourceManifest.fileCount -ne 16 -or + [int]$existingSourceManifest.fileCount -ne 17 -or [string]$existingSourceManifest.manifestSha256 -ne $manifestDigest ) { throw "existing_staging_manifest_mismatch" } } catch { @@ -1067,19 +1123,21 @@ try { [string]$prior.launcherContract.sha256 -match "^[0-9a-f]{64}$" -and $live.runtimeMatched -and $live.sourceRevision -eq $sourceRevision -and - $live.fileCount -eq 16 -and + $live.fileCount -eq 17 -and $live.mismatchCount -eq 0 -and $liveLauncherContract.ok -and [string]$liveLauncherContract.sha256 -eq [string]$prior.launcherContract.sha256 ) { $script:ReceiverOperationPhase = "idempotent_replay_post_verifier" $replayPreflight = Invoke-AgentLivePreflight (Join-Path $stagePath "agent99-live-preflight.ps1") - if ($replayPreflight.ok -and $replayPreflight.sourceRevision -eq $sourceRevision -and $replayPreflight.runtimeMatched) { + $replayWindowsControlBaseline = Invoke-AgentWindowsControlBaseline $traceId $runId $workItemId $sourceRevision + if ($replayPreflight.ok -and $replayPreflight.sourceRevision -eq $sourceRevision -and $replayPreflight.runtimeMatched -and $replayWindowsControlBaseline.ok) { $originalOperationBoundaries = $prior.operationBoundaries $prior | Add-Member -NotePropertyName idempotentReplay -NotePropertyValue $true -Force $prior | Add-Member -NotePropertyName runtimeManifest -NotePropertyValue $live -Force $prior | Add-Member -NotePropertyName launcherContract -NotePropertyValue $liveLauncherContract -Force $prior | Add-Member -NotePropertyName livePreflight -NotePropertyValue $replayPreflight -Force + $prior | Add-Member -NotePropertyName windowsControlBaseline -NotePropertyValue $replayWindowsControlBaseline -Force $prior | Add-Member -NotePropertyName originalOperationBoundaries -NotePropertyValue $originalOperationBoundaries -Force $prior | Add-Member -NotePropertyName operationBoundaries -NotePropertyValue ([pscustomobject]@{ remoteWritePerformed = $script:ReceiverRemoteWritePerformed @@ -1096,6 +1154,7 @@ try { serviceRestart = $false scheduledTaskRestart = $false scheduledTaskModification = $false + windowsControlSettingsWritePerformed = [bool]$replayWindowsControlBaseline.settingsWritePerformed }) -Force Write-AgentResultAndExit $prior 0 } @@ -1113,6 +1172,7 @@ try { attemptReceiptPath = $attemptReceiptPath runtimeManifest = $live livePreflight = $replayPreflight + windowsControlBaseline = $replayWindowsControlBaseline durableReceiptWritten = $true remoteWritePerformed = $script:ReceiverRemoteWritePerformed liveRuntimeWritePerformed = $false @@ -1335,6 +1395,7 @@ try { $runtimeManifest = $null $livePreflight = $null $launcherContract = $null + $windowsControlBaseline = $null $postVerified = $false $failurePhase = "" $failureType = "" @@ -1375,7 +1436,7 @@ try { $runtimeManifest.exists -and $runtimeManifest.runtimeMatched -and $runtimeManifest.sourceRevision -eq $sourceRevision -and - $runtimeManifest.fileCount -eq 16 -and + $runtimeManifest.fileCount -eq 17 -and $runtimeManifest.mismatchCount -eq 0 -and $launcherContract.ok -and [string]$launcherContract.sha256 -eq [string]$deploy.payload.launcherContract.sha256 -and @@ -1392,6 +1453,13 @@ try { if (-not $postVerified) { $failurePhase = "post_verifier" $failureType = "independent_post_verifier_failed" + } else { + $script:ReceiverOperationPhase = "windows_control_baseline" + $windowsControlBaseline = Invoke-AgentWindowsControlBaseline $traceId $runId $workItemId $sourceRevision + if (-not $windowsControlBaseline.ok) { + $failurePhase = "windows_control_baseline" + $failureType = "windows_control_baseline_failed_or_unverified" + } } } } @@ -1425,6 +1493,7 @@ try { runtimeManifest = $runtimeManifest launcherContract = $launcherContract livePreflight = $livePreflight + windowsControlBaseline = $windowsControlBaseline durableReceiptWritten = $true idempotentReplay = $false operationBoundaries = [pscustomobject]@{ @@ -1442,6 +1511,7 @@ try { serviceRestart = $false scheduledTaskRestart = $true scheduledTaskModification = $false + windowsControlSettingsWritePerformed = [bool]$windowsControlBaseline.settingsWritePerformed } nextSafeAction = "run_existing_production_principal_verifier_and_same_trace_completion_readback" } @@ -1548,6 +1618,7 @@ try { failedRuntimeManifest = $runtimeManifest failedLauncherContract = $launcherContract failedLivePreflight = $livePreflight + failedWindowsControlBaseline = $windowsControlBaseline rollback = $rollback rollbackRelayRestart = $rollbackRelayRestart rollbackVerified = $rollbackVerified @@ -1571,6 +1642,7 @@ try { serviceRestart = $false scheduledTaskRestart = [bool](($relayReload -and $relayReload.attempted) -or $rollbackRelayRestart.attempted) scheduledTaskModification = $false + windowsControlSettingsWritePerformed = [bool]($windowsControlBaseline -and $windowsControlBaseline.settingsWritePerformed) } secretValueRead = $false privateKeyValueRead = $false diff --git a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh index e85268269..95292c831 100755 --- a/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh +++ b/scripts/reboot-recovery/deploy-agent99-via-windows99-ssh.sh @@ -25,6 +25,7 @@ RUNTIME_FILES=( "agent99-submit-request.ps1" "agent99-synthetic-tests.ps1" "agent99-telegram-inbox.ps1" + "agent99-windows-control-baseline.ps1" "agent99-windows-update-policy.ps1" "host-reboot-scorecard.ps1" "agent99.config.example.json" @@ -225,7 +226,7 @@ trace_id, run_id, work_item_id = sys.argv[4:7] output_path = Path(sys.argv[7]) runtime_names = sys.argv[8:] -if len(runtime_names) != 16 or len(set(runtime_names)) != 16: +if len(runtime_names) != 17 or len(set(runtime_names)) != 17: raise SystemExit("fixed_runtime_file_contract_failed") files: list[dict[str, str]] = [] @@ -265,7 +266,7 @@ envelope = { "runId": run_id, "workItemId": work_item_id, "sourceRevision": source_revision, - "expectedRuntimeFileCount": 16, + "expectedRuntimeFileCount": 17, "manifestSha256": manifest_sha256, "files": files, "livePreflight": { @@ -417,14 +418,14 @@ stage_token = hashlib.sha256(stage_identity.encode("utf-8")).hexdigest()[:20] script = r'''$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue' $a='C:\Wooo\Agent99';$b=Join-Path $a 'bin';$p=Join-Path $a 'state\runtime-manifest.json' $r=[ordered]@{exists=$false;sourceRevision='';runtimeMatched=$false;recordedRuntimeMatched=$false;fileCount=0;mismatchCount=-1;recordedMismatchCount=-1;parseError=''} -if(Test-Path -LiteralPath $p -PathType Leaf){try{$m=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json;$rows=@($m.files);$seen=@{};$bad=0;foreach($x in $rows){$n=[string]$x.name;if(!$n-or[IO.Path]::GetFileName($n)-ne$n-or$seen.ContainsKey($n)){$bad++;continue};$seen[$n]=1;$q=Join-Path $b $n;$h=if(Test-Path -LiteralPath $q -PathType Leaf){(Get-FileHash -LiteralPath $q -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'};if(([string]$x.runtimeSha256).ToLowerInvariant()-ne$h){$bad++}};$r.exists=$true;$r.sourceRevision=[string]$m.sourceRevision;$r.recordedRuntimeMatched=[bool]$m.runtimeMatched;$r.fileCount=[int]$m.fileCount;$r.mismatchCount=$bad;$r.recordedMismatchCount=[int]$m.mismatchCount;$r.runtimeMatched=[bool]($m.runtimeMatched-and$m.fileCount-eq 16-and$m.mismatchCount-eq 0-and$rows.Count-eq 16-and$seen.Count-eq 16-and$bad-eq 0)}catch{$r.exists=$true;$r.parseError=$_.Exception.GetType().Name}} +if(Test-Path -LiteralPath $p -PathType Leaf){try{$m=Get-Content -LiteralPath $p -Raw|ConvertFrom-Json;$rows=@($m.files);$seen=@{};$bad=0;foreach($x in $rows){$n=[string]$x.name;if(!$n-or[IO.Path]::GetFileName($n)-ne$n-or$seen.ContainsKey($n)){$bad++;continue};$seen[$n]=1;$q=Join-Path $b $n;$h=if(Test-Path -LiteralPath $q -PathType Leaf){(Get-FileHash -LiteralPath $q -Algorithm SHA256).Hash.ToLowerInvariant()}else{'missing'};if(([string]$x.runtimeSha256).ToLowerInvariant()-ne$h){$bad++}};$r.exists=$true;$r.sourceRevision=[string]$m.sourceRevision;$r.recordedRuntimeMatched=[bool]$m.runtimeMatched;$r.fileCount=[int]$m.fileCount;$r.mismatchCount=$bad;$r.recordedMismatchCount=[int]$m.mismatchCount;$r.runtimeMatched=[bool]($m.runtimeMatched-and$m.fileCount-eq 17-and$m.mismatchCount-eq 0-and$rows.Count-eq 17-and$seen.Count-eq 17-and$bad-eq 0)}catch{$r.exists=$true;$r.parseError=$_.Exception.GetType().Name}} $c=[ordered]@{executed=$false;ok=$false;exitCode=-1;errorType=''} try{& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File (Join-Path $b 'agent99-contract-check.ps1') -SourceRoot $b 2>$null|Out-Null;$c.executed=$true;$c.exitCode=$LASTEXITCODE;$c.ok=[bool]($c.exitCode-eq 0)}catch{$c.executed=$true;$c.errorType=$_.Exception.GetType().Name} $z=[ordered]@{};foreach($n in 'remoteWritePerformed,liveRuntimeWritePerformed,livePromotionAttempted,livePromotionPerformed,secretValueRead,privateKeyValueRead,tokenValueRead,environmentSecretRead,uiInteraction,vmPowerChange,hostReboot,serviceRestart,scheduledTaskRestart,scheduledTaskModification'.Split(',')){$z[$n]=$false} $ready=[bool]($c.executed-and$c.ok-and$c.exitCode-eq 0) $status=if($ready){'check_ready'}else{'check_failed_no_apply'} $next=if($ready){'rerun_with_apply_and_trace_run_work_item_after_source_commit_and_transport_check'}else{'repair_runtime_contract_before_apply'} -[ordered]@{schemaVersion='agent99_remote_atomic_deploy_receipt_v1';status=$status;mode='check';sourceRevision='__SOURCE_REVISION__';expectedRuntimeFileCount=16;manifestSha256='__MANIFEST_SHA256__';plannedStagingPath='C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__';runtimeManifest=[pscustomobject]$r;runtimeContract=[pscustomobject]$c;livePreflight=[ordered]@{executed=$false;reason='apply_only_after_validate_only'};transportPayloadMode='control_command_no_stdin';transientTransportPayloadStored=$false;transientTransportPayloadDeletedBeforeReceiver=$false;transportPayloadContainsSecrets=$false;operationBoundaries=$z;nextSafeAction=$next}|ConvertTo-Json -Compress -Depth 8 +[ordered]@{schemaVersion='agent99_remote_atomic_deploy_receipt_v1';status=$status;mode='check';sourceRevision='__SOURCE_REVISION__';expectedRuntimeFileCount=17;manifestSha256='__MANIFEST_SHA256__';plannedStagingPath='C:\Wooo\Agent99\deploy\remote-source-__STAGE_TOKEN__';runtimeManifest=[pscustomobject]$r;runtimeContract=[pscustomobject]$c;livePreflight=[ordered]@{executed=$false;reason='apply_only_after_validate_only'};transportPayloadMode='control_command_no_stdin';transientTransportPayloadStored=$false;transientTransportPayloadDeletedBeforeReceiver=$false;transportPayloadContainsSecrets=$false;operationBoundaries=$z;nextSafeAction=$next}|ConvertTo-Json -Compress -Depth 8 if(-not$ready){exit 65}''' script = ( script.replace("__SOURCE_REVISION__", source_revision) diff --git a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py index 774839fd8..4502d6183 100644 --- a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py +++ b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py @@ -36,7 +36,7 @@ def _base_case() -> dict[str, Any]: "exists": True, "sourceRevision": "a" * 40, "runtimeMatched": True, - "fileCount": 16, + "fileCount": 17, "mismatchCount": 0, "parseError": "", }, @@ -84,7 +84,7 @@ $parameters = @{{ AgentRootPresent = [bool]$case.agentRootPresent Manifest = $case.manifest ManifestCheckRequired = [bool]$case.manifestCheckRequired - ExpectedRuntimeFileCount = 16 + ExpectedRuntimeFileCount = 17 TaskFailureCount = [int]$case.taskFailureCount RelayListenerCount = [int]$case.relayListenerCount RequiredEvidenceStaleCount = [int]$case.requiredEvidenceStaleCount @@ -133,7 +133,7 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No ) assert "deploymentEligible = [bool]($blockingReasons.Count -eq 0)" in decision assert "preflightGreen = [bool]$decision.deploymentEligible" in source - assert "-ExpectedRuntimeFileCount 16" in source + assert "-ExpectedRuntimeFileCount 17" in source assert "warningReasons = $warningReasons" in source assert "selfHealthPerformanceIssues = @(" in source assert 'if ($safeHost -notmatch "^[A-Za-z0-9.:-]{1,128}$")' in source diff --git a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py index e287f9222..0dd7c27bd 100644 --- a/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py +++ b/scripts/reboot-recovery/tests/test_agent99_remote_atomic_deploy_transport_contract.py @@ -33,6 +33,7 @@ EXPECTED_RUNTIME_FILES = ( "agent99-submit-request.ps1", "agent99-synthetic-tests.ps1", "agent99-telegram-inbox.ps1", + "agent99-windows-control-baseline.ps1", "agent99-windows-update-policy.ps1", "host-reboot-scorecard.ps1", "agent99.config.example.json", @@ -86,7 +87,7 @@ def test_sender_and_receiver_allow_exactly_the_deployer_runtime_bundle() -> None ) assert preflight_count is not None assert int(preflight_count.group(1)) == len(EXPECTED_RUNTIME_FILES) - assert "expectedRuntimeFileCount\": 16" in sender + assert "expectedRuntimeFileCount\": 17" in sender assert "$filesByName.Count -ne $FixedRuntimeFiles.Count" in receiver assert "required_runtime_file_missing" in receiver assert "duplicate_runtime_file" in receiver @@ -254,13 +255,24 @@ def test_receiver_rolls_back_failed_deploy_or_failed_independent_post_verifier() assert "$process.Kill()" in source assert "$ValidateOnlyTimeoutSeconds = 300" in source assert "$LiveDeployTimeoutSeconds = 300" in source + assert "$WindowsControlBaselineTimeoutSeconds = 300" in source assert ( "$timeoutSeconds = if ($ValidateOnly) { $ValidateOnlyTimeoutSeconds } " "else { $LiveDeployTimeoutSeconds }" in source ) assert "$livePreflight.sourceRevision -eq $sourceRevision" in source assert "$runtimeManifest.sourceRevision -eq $sourceRevision" in source - assert '$runtimeManifest.fileCount -eq 16' in source + assert '$runtimeManifest.fileCount -eq 17' in source + assert "function Invoke-AgentWindowsControlBaseline" in source + assert '"agent99-windows-control-baseline.ps1"' in source + assert '"-Mode", "Apply"' in source + assert '"agent99_windows_control_baseline_v1"' in source + assert '"runtime_verified_controlled_apply"' in source + assert '"runtime_verified_healthy_no_write"' in source + assert "$evidence.transport.inputMethodIndependent -eq $true" in source + assert "$evidence.risk.hostReboot -eq $false" in source + assert "$evidence.risk.userLogoff -eq $false" in source + assert "windowsControlSettingsWritePerformed" in source assert '$runtimeManifest.mismatchCount -eq 0' in source assert 'status = "deployed_verified"' in source assert '$failurePhase = "deploy"' in source diff --git a/scripts/reboot-recovery/tests/test_agent99_windows_control_baseline.py b/scripts/reboot-recovery/tests/test_agent99_windows_control_baseline.py new file mode 100644 index 000000000..b0ea3318b --- /dev/null +++ b/scripts/reboot-recovery/tests/test_agent99_windows_control_baseline.py @@ -0,0 +1,74 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +BASELINE = ROOT / "agent99-windows-control-baseline.ps1" + + +def _source() -> str: + return BASELINE.read_text(encoding="utf-8") + + +def test_baseline_is_canonical_windows99_user_scoped_and_preserves_zh_tw() -> None: + source = _source() + + assert '$CanonicalAsset = "host:192.168.0.99/windows-control-baseline"' in source + assert '$ExpectedComputerName = "WOOO-SUPER"' in source + assert '$ExpectedUserName = "Administrator"' in source + assert '$PreservedCulture = "zh-TW"' in source + assert '$PreservedChineseLanguage = "zh-Hant-TW"' in source + assert '$FallbackLanguage = "en-US"' in source + assert '$FallbackInputTip = "0409:00000409"' in source + assert "priorInputMethodsPreserved" in source + assert "missingPreservedLanguageSignatures" in source + + +def test_baseline_supports_check_apply_independent_verify_and_rollback() -> None: + source = _source() + + assert '[ValidateSet("Check", "Apply")]' in source + assert "TraceId" in source + assert "RunId" in source + assert "WorkItemId" in source + assert "SourceRevision" in source + assert "Invoke-IndependentSnapshot" in source + assert '"-EncodedCommand", $encoded' in source + assert "Test-WindowsControlSnapshot" in source + assert "Restore-WindowsControlBaseline" in source + assert '"verified_check_no_write"' in source + assert '"runtime_verified_controlled_apply"' in source + assert '"rolled_back_verified"' in source + assert '"degraded_with_safe_next_action"' in source + + +def test_baseline_sets_only_the_bounded_control_settings() -> None: + source = _source() + + assert "Set-WinUserLanguageList" in source + assert "Set-WinDefaultInputMethodOverride" in source + assert 'New-ItemProperty -Path $ConsoleRegistryPath -Name "CodePage"' in source + assert 'Set-Service -Name "sshd" -StartupType Automatic' in source + assert 'Start-Service -Name "sshd"' in source + assert "Invoke-Expression" not in source + assert "Set-WinSystemLocale" not in source + assert "Set-Culture" not in source + assert "Restart-Computer" not in source + assert "Stop-Computer" not in source + assert "shutdown.exe" not in source + assert "logoff.exe" not in source + + +def test_baseline_receipt_marks_noninteractive_utf8_boundaries() -> None: + source = _source() + + assert "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)" in source + assert "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)" in source + assert 'guiInteraction = $false' in source + assert 'clipboardUsed = $false' in source + assert 'secretValueRead = $false' in source + assert 'encodedCommandCompatible = $true' in source + assert 'interactiveInputRequired = $false' in source + assert 'inputMethodIndependent = $true' in source + assert 'utf8Output = $true' in source + assert 'telegramNotificationSent = $false' in source + assert 'kmRagWritebackPerformed = $false' in source