diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index ba7ecaed1..fc7c55f2d 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -9580,6 +9580,9 @@ async def claim_pending_check_modes( ) ORDER BY CASE + WHEN candidate.input ->> 'work_item_id' + = 'P0-03-WAZUH-MANAGER-POSTURE' + THEN -1 WHEN candidate.input ->> 'work_item_id' LIKE 'P0-%' THEN 0 WHEN candidate.input ->> 'proposal_source' @@ -12022,6 +12025,18 @@ async def run_pending_check_modes_once( 0, remaining_limit - len(wazuh_capability_retry_claims), ) + fresh_claims = ( + await claim_pending_check_modes( + project_id=project_id, + limit=min(1, remaining_limit), + candidate_max_age_hours=( + settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS + ), + ) + if remaining_limit + else [] + ) + remaining_limit = max(0, remaining_limit - len(fresh_claims)) reclaimed_claims = ( await claim_stale_pending_check_modes( project_id=project_id, @@ -12123,25 +12138,14 @@ async def run_pending_check_modes_once( fresh_candidate_claim_continues=True, ) remaining_limit = max(0, remaining_limit - len(catalog_replay_claims)) - fresh_claims = ( - await claim_pending_check_modes( - project_id=project_id, - limit=remaining_limit, - candidate_max_age_hours=( - settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS - ), - ) - if remaining_limit - else [] - ) claims = [ *wazuh_capability_retry_claims, + *fresh_claims, *reclaimed_claims, *stdin_boundary_replay_claims, *semantic_catalog_reconciliation_claims, *failed_apply_catalog_replay_claims, *catalog_replay_claims, - *fresh_claims, ] execution_claims = [*wazuh_break_glass_apply_claims, *claims] completed = 0 diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 03d95e804..be77f285d 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -3295,6 +3295,11 @@ async def test_execution_broker_stdin_replay_never_enters_apply( "claim_stale_pending_check_modes", no_claims, ) + monkeypatch.setattr( + service, + "claim_pending_check_modes", + no_claims, + ) monkeypatch.setattr( service, "claim_stdin_boundary_failed_check_modes", @@ -4139,15 +4144,27 @@ def test_ansible_claim_query_limits_recent_candidate_backlog() -> None: def test_ansible_claim_query_honors_bounded_execution_priority_before_fifo() -> None: source = inspect.getsource(claim_pending_check_modes) + assert "P0-03-WAZUH-MANAGER-POSTURE" in source assert "candidate.input ->> 'work_item_id' LIKE 'P0-%'" in source assert "alert_webhook_controlled_router" in source assert "ELSE 30" in source assert "candidate.input ->> 'execution_priority'" in source assert "LEAST(" in source assert "100" in source + assert source.index("P0-03-WAZUH-MANAGER-POSTURE") < source.index( + "candidate.input ->> 'work_item_id' LIKE 'P0-%'" + ) assert source.index("execution_priority") < source.index("candidate.created_at ASC") +def test_broker_claims_fresh_p0_before_general_replay_lanes() -> None: + source = inspect.getsource(run_pending_check_modes_once) + + assert source.index("fresh_claims = (") < source.index("reclaimed_claims = (") + assert "limit=min(1, remaining_limit)" in source + assert source.index("*fresh_claims,") < source.index("*reclaimed_claims,") + + def test_ansible_transport_blocker_detects_repair_forced_command_denial() -> None: blockers = detect_ansible_transport_blockers( "fatal: host unreachable REPAIR_DENIED:invalid_command", diff --git a/scripts/reboot-recovery/agent99-control-loop-maintenance-restore.ps1 b/scripts/reboot-recovery/agent99-control-loop-maintenance-restore.ps1 index b3998d582..437a90301 100644 --- a/scripts/reboot-recovery/agent99-control-loop-maintenance-restore.ps1 +++ b/scripts/reboot-recovery/agent99-control-loop-maintenance-restore.ps1 @@ -5,8 +5,10 @@ param( [Parameter(Mandatory = $true)] [ValidatePattern("^agent99-control-loop-maintenance-2[0-9]{7}-[0-9]{6}-[0-9]{3}-[0-9a-f]{8}\.json$")] [string]$MaintenanceReceiptRef, - [ValidateRange(1, 60)] + [ValidateRange(1, 180)] [int]$MaxReceiptAgeMinutes = 30, + [ValidatePattern("^(?:|[0-9a-f]{64})$")] + [string]$ExpectedMaintenanceReceiptSha256 = "", [string]$AgentRoot = "C:\Wooo\Agent99" ) @@ -155,13 +157,33 @@ try { $blockers.Add("maintenance_receipt_missing") } else { try { - $maintenance = Get-Content -LiteralPath $maintenancePath -Raw | ConvertFrom-Json - $maintenanceSha256 = (Get-FileHash -LiteralPath $maintenancePath -Algorithm SHA256).Hash.ToLowerInvariant() + $maintenanceBytes = [IO.File]::ReadAllBytes($maintenancePath) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $maintenanceSha256 = ([BitConverter]::ToString($sha256.ComputeHash($maintenanceBytes))).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } + $strictUtf8 = New-Object Text.UTF8Encoding($false, $true) + $maintenanceJson = $strictUtf8.GetString($maintenanceBytes) + $maintenance = $maintenanceJson | ConvertFrom-Json + if ($null -eq $maintenance -or $maintenance -isnot [pscustomobject]) { + throw "maintenance_receipt_shape_invalid" + } $maintenanceAgeMinutes = ((Get-Date) - (Get-Item -LiteralPath $maintenancePath).LastWriteTime).TotalMinutes } catch { $blockers.Add("maintenance_receipt_invalid") } } + if ($MaxReceiptAgeMinutes -gt 60 -and [string]::IsNullOrWhiteSpace($ExpectedMaintenanceReceiptSha256)) { + $blockers.Add("extended_receipt_window_requires_exact_sha256") + } + if ( + -not [string]::IsNullOrWhiteSpace($ExpectedMaintenanceReceiptSha256) -and + -not [string]::Equals($maintenanceSha256, $ExpectedMaintenanceReceiptSha256, [StringComparison]::Ordinal) + ) { + $blockers.Add("maintenance_receipt_sha256_mismatch") + } if ($maintenance) { if ( [string]$maintenance.schemaVersion -ne "agent99_control_loop_maintenance_v2" -or @@ -191,6 +213,9 @@ try { $intentPath = Join-Path $evidenceDir $intentRef try { $maintenanceIntent = Get-Content -LiteralPath $intentPath -Raw -ErrorAction Stop | ConvertFrom-Json + if ($null -eq $maintenanceIntent -or $maintenanceIntent -isnot [pscustomobject]) { + throw "maintenance_intent_shape_invalid" + } } catch { $blockers.Add("maintenance_intent_invalid") } @@ -257,6 +282,8 @@ try { taskName = $taskName maintenanceReceiptRef = $MaintenanceReceiptRef maintenanceReceiptSha256 = $maintenanceSha256 + maxReceiptAgeMinutes = $MaxReceiptAgeMinutes + extendedReceiptWindow = [bool]($MaxReceiptAgeMinutes -gt 60) plannedActions = @("enable_exact_control_task", "start_exact_control_task", "verify_control_task_started") rollback = "stop and disable the exact control task if postcheck fails" vmPowerChangePerformed = $false @@ -310,6 +337,8 @@ try { maintenanceReceiptRef = $MaintenanceReceiptRef maintenanceReceiptSha256 = $maintenanceSha256 maintenanceAgeMinutes = if ($null -ne $maintenanceAgeMinutes) { [math]::Round($maintenanceAgeMinutes, 2) } else { $null } + maxReceiptAgeMinutes = $MaxReceiptAgeMinutes + extendedReceiptWindow = [bool]($MaxReceiptAgeMinutes -gt 60) precheckPassed = $precheckPassed blockers = @($blockers) operationIntentRef = $operationIntentRef diff --git a/scripts/reboot-recovery/tests/test_agent99_vmrun_timeout_guard.py b/scripts/reboot-recovery/tests/test_agent99_vmrun_timeout_guard.py index b317e26f0..452e9d060 100644 --- a/scripts/reboot-recovery/tests/test_agent99_vmrun_timeout_guard.py +++ b/scripts/reboot-recovery/tests/test_agent99_vmrun_timeout_guard.py @@ -191,6 +191,18 @@ def test_control_loop_maintenance_restore_is_bounded_and_receipted() -> None: assert 'if ($env:COMPUTERNAME -ne "WOOO-SUPER")' in source assert "Global\\WoooAgent99ControlLoopMaintenanceV1" in source assert 'ValidateSet("Check", "Apply")' in source + assert "ValidateRange(1, 180)" in source + assert "ExpectedMaintenanceReceiptSha256" in source + assert "extended_receipt_window_requires_exact_sha256" in source + assert "maintenance_receipt_sha256_mismatch" in source + assert "[IO.File]::ReadAllBytes($maintenancePath)" in source + assert "ComputeHash($maintenanceBytes)" in source + assert "maintenance_receipt_shape_invalid" in source + assert "maintenance_intent_shape_invalid" in source + assert "Get-Content -LiteralPath $maintenancePath" not in source + extended_guard = source.index("extended_receipt_window_requires_exact_sha256") + maintenance_contract = source.index('schemaVersion -ne "agent99_control_loop_maintenance_v2"') + assert extended_guard < maintenance_contract assert "maintenanceReceiptSha256" in source assert 'schemaVersion -ne "agent99_control_loop_maintenance_v2"' in source assert 'terminal -ne "control_loop_maintenance_frozen"' in source