feat(awooop): enable controlled agent apply
Some checks failed
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Failing after 1m37s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-26 18:17:05 +08:00
parent c8912204ce
commit e7c368aa27
19 changed files with 1311 additions and 560 deletions

View File

@@ -145,22 +145,42 @@ class TestAutoRepairService:
)
@pytest.mark.asyncio
async def test_evaluate_blocks_p1_severity(self, service):
"""Test that P1 severity incidents are blocked"""
async def test_evaluate_allows_p1_severity_with_approved_playbook(
self,
service,
mock_playbook_service,
):
"""P1 severity no longer blocks approved LOW/MEDIUM/HIGH controlled repair."""
playbook = create_high_quality_playbook(risk_level=RiskLevel.HIGH)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.9)
])
incident = create_test_incident(severity=Severity.P1)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is False
assert decision.blocked_by == "HIGH_SEVERITY"
assert decision.can_auto_repair is True
assert decision.risk_level == RiskLevel.HIGH
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_blocks_p0_severity(self, service):
"""Test that P0 severity incidents are blocked"""
async def test_evaluate_allows_p0_severity_with_approved_playbook(
self,
service,
mock_playbook_service,
):
"""P0 severity is handled by PlayBook/Registry guardrails, not a blanket human gate."""
playbook = create_high_quality_playbook(risk_level=RiskLevel.MEDIUM)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
MockPlaybookRecommendation(playbook, similarity_score=0.9)
])
incident = create_test_incident(severity=Severity.P0)
decision = await service.evaluate_auto_repair(incident)
assert decision.can_auto_repair is False
assert decision.blocked_by == "HIGH_SEVERITY"
assert decision.can_auto_repair is True
assert decision.risk_level == RiskLevel.MEDIUM
assert decision.blocked_by is None
@pytest.mark.asyncio
async def test_evaluate_no_playbook_match(self, service, mock_playbook_service):
@@ -252,10 +272,7 @@ class TestAutoRepairService:
@pytest.mark.asyncio
async def test_evaluate_critical_risk_blocked(self, service, mock_playbook_service):
"""Test CRITICAL risk playbook is now approved (gates removed 2026-04-07).
2026-04-07: 統帥指令移除風險等級門檻。
2026-04-08 Claude Sonnet 4.6: 更新測試預期以符合當前設計。
"""
"""CRITICAL remains break-glass and is outside LOW/MEDIUM/HIGH authorization."""
playbook = create_high_quality_playbook(risk_level=RiskLevel.CRITICAL)
mock_playbook_service.add_playbook(playbook)
mock_playbook_service.set_recommendations([
@@ -265,9 +282,8 @@ class TestAutoRepairService:
incident = create_test_incident(severity=Severity.P2)
decision = await service.evaluate_auto_repair(incident)
# 風險等級門檻已移除 — CRITICAL risk APPROVED Playbook 也通過
assert decision.can_auto_repair is True
assert decision.blocked_by is None
assert decision.can_auto_repair is False
assert decision.blocked_by == "CRITICAL_BREAK_GLASS"
@pytest.mark.asyncio
async def test_evaluate_success(self, service, mock_playbook_service):

View File

@@ -240,14 +240,14 @@ def test_cicd_event_filter_validation_and_duration_safety() -> None:
_validate_cicd_status_filter("ignored")
def test_outbound_timeline_title_labels_auto_repair_handoff() -> None:
def test_outbound_timeline_title_labels_auto_repair_retry_queue() -> None:
title = _outbound_timeline_title(
"telegram",
"error",
"🤖❌ HANDOFF REQUIREDAI 自動修復失敗,已轉人工",
"🔁 AI RETRY QUEUEDAI 自動修復失敗,已排入下一輪修復",
)
assert title == "TELEGRAMAI 自動修復失敗,已轉人工"
assert title == "TELEGRAMAI 自動修復失敗,已排入重試"
def test_outbound_timeline_title_falls_back_to_human_label() -> None:
@@ -933,9 +933,10 @@ def test_ai_alert_card_delivery_item_uses_metadata_without_raw_content() -> None
"event_type": "wazuh_dashboard_api_readback_degraded",
"lane": "siem_observability_readback_degraded",
"target": "wazuh_dashboard_api",
"gates": ["candidate_only", "runtime_write_gate=0"],
"candidate_only": True,
"runtime_write_gate_count": 0,
"gates": ["controlled_playbook_queue", "runtime_write_gate=controlled"],
"candidate_only": False,
"controlled_playbook_queue": True,
"runtime_write_gate_count": 1,
"delivery_receipt_readback_required": True,
},
"source_refs": {
@@ -952,8 +953,8 @@ def test_ai_alert_card_delivery_item_uses_metadata_without_raw_content() -> None
assert item["event_type"] == "wazuh_dashboard_api_readback_degraded"
assert item["lane"] == "siem_observability_readback_degraded"
assert item["target"] == "wazuh_dashboard_api"
assert item["runtime_write_gate_count"] == 0
assert item["runtime_write_allowed"] is False
assert item["runtime_write_gate_count"] == 1
assert item["runtime_write_allowed"] is True
assert item["delivery_receipt_readback_required"] is True
assert item["source_refs"]["alert_ids"] == [
"wazuh_dashboard_api_readback_degraded"
@@ -972,7 +973,7 @@ def test_ai_alert_card_delivery_summary_keeps_no_false_green_status() -> None:
"pending_total": 0,
"shadow_total": 0,
"delivery_receipt_required_total": 2,
"runtime_write_gate_open_count": 0,
"runtime_write_gate_open_count": 1,
"latest_sent_at": datetime(2026, 6, 25, 9, 40, 5),
"latest_queued_at": datetime(2026, 6, 25, 9, 40, 0),
},
@@ -984,8 +985,8 @@ def test_ai_alert_card_delivery_summary_keeps_no_false_green_status() -> None:
assert summary["schema_version"] == "awooop_ai_alert_card_delivery_readback_v1"
assert summary["status"] == "delivery_failure_observed"
assert summary["delivery_receipt_required_total"] == 2
assert summary["runtime_write_gate_open_count"] == 0
assert summary["runtime_write_allowed"] is False
assert summary["runtime_write_gate_open_count"] == 1
assert summary["runtime_write_allowed"] is True
assert summary["production_write_count"] == 0
@@ -1008,10 +1009,11 @@ def test_list_ai_alert_cards_response_preserves_delivery_metadata() -> None:
"event_type": "wazuh_dashboard_api_readback_degraded",
"lane": "siem_observability_readback_degraded",
"target": "wazuh_dashboard_api",
"gates": ["candidate_only", "runtime_write_gate=0"],
"runtime_write_gate_count": 0,
"runtime_write_allowed": False,
"candidate_only": True,
"gates": ["controlled_playbook_queue", "runtime_write_gate=controlled"],
"runtime_write_gate_count": 1,
"runtime_write_allowed": True,
"candidate_only": False,
"controlled_playbook_queue": True,
"delivery_receipt_readback_required": True,
"source_refs": {
"alert_ids": ["wazuh_dashboard_api_readback_degraded"],
@@ -1040,8 +1042,8 @@ def test_list_ai_alert_cards_response_preserves_delivery_metadata() -> None:
"pending_total": 0,
"shadow_total": 0,
"delivery_receipt_required_total": 1,
"runtime_write_gate_open_count": 0,
"runtime_write_allowed": False,
"runtime_write_gate_open_count": 1,
"runtime_write_allowed": True,
"latest_sent_at": datetime(2026, 6, 25, 9, 40, 5),
"latest_queued_at": datetime(2026, 6, 25, 9, 40, 0),
"production_write_count": 0,
@@ -1052,7 +1054,7 @@ def test_list_ai_alert_cards_response_preserves_delivery_metadata() -> None:
assert dumped["items"][0]["event_type"] == (
"wazuh_dashboard_api_readback_degraded"
)
assert dumped["items"][0]["runtime_write_allowed"] is False
assert dumped["items"][0]["runtime_write_allowed"] is True
assert dumped["summary"]["delivery_receipt_required_total"] == 1
assert dumped["summary"]["production_write_count"] == 0
@@ -1795,24 +1797,24 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
)
assert chain["verdict"] == "ansible_check_mode_only"
assert chain["repair_state"] == "ansible_check_mode_only"
assert chain["next_step"] == "open_apply_gate_work_item_review_verifier_and_km"
assert chain["needs_human"] is True
assert chain["repair_state"] == "controlled_apply_queued"
assert chain["next_step"] == "wait_for_controlled_apply_and_post_apply_verifier"
assert chain["needs_human"] is False
assert chain["evidence"]["ansible_dry_run_only"] is True
assert chain["operator_outcome"]["state"] == "apply_candidate_owner_review_ready"
assert "apply candidate" in chain["operator_outcome"]["summary_zh"]
assert chain["operator_outcome"]["state"] == "controlled_apply_queued"
assert "受控自動 apply" in chain["operator_outcome"]["summary_zh"]
assert (
chain["operator_outcome"]["execution_result"]["completion_status"]
== "dry_run_passed_apply_candidate_ready"
== "dry_run_passed_controlled_apply_queued"
)
assert "verification_missing" in chain["blockers"]
assert "verification_recorded" not in chain["blockers"]
assert chain["automation_handoff"]["kind"] == "ansible_check_mode_apply_gate"
assert chain["automation_handoff"]["status"] == "owner_review_required"
assert chain["automation_handoff"]["status"] == "controlled_apply_auto_authorized"
assert chain["automation_handoff"]["next_action"] == (
"open_apply_gate_work_item_review_verifier_and_km"
"wait_for_controlled_apply_worker_and_verifier"
)
assert chain["automation_handoff"]["runtime_execution_authorized"] is False
assert chain["automation_handoff"]["runtime_execution_authorized"] is True
assert chain["automation_handoff"]["work_item_id"].startswith(
"ansible-apply-gate:awoooi:INC-20260625-977E5F"
)
@@ -1824,19 +1826,19 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
)
assert [gate["status"] for gate in chain["automation_handoff"]["gates"]] == [
"passed",
"blocked",
"controlled_apply_authorized",
"blocked",
]
closure = chain["automation_handoff"]["closure_readiness"]
assert closure["schema_version"] == "awooop_apply_gate_closure_readiness_v1"
assert closure["status"] == "blocked_before_owner_release"
assert closure["runtime_execution_authorized"] is False
assert closure["writes_runtime_state"] is False
assert closure["ready_count"] == 3
assert closure["status"] == "controlled_apply_auto_authorized"
assert closure["runtime_execution_authorized"] is True
assert closure["writes_runtime_state"] is True
assert closure["ready_count"] == 8
assert closure["total_count"] == 8
assert closure["blocked_count"] == 5
assert closure["completion_percent"] == 38
assert closure["next_action"] == "review_owner_release_packet_before_apply"
assert closure["blocked_count"] == 0
assert closure["completion_percent"] == 100
assert closure["next_action"] == "controlled_apply_worker_executes"
assert [gate["key"] for gate in closure["gates"]] == [
"mcp_evidence",
"dry_run",
@@ -1851,15 +1853,13 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
"passed",
"passed",
"passed",
"blocked",
"blocked",
"blocked",
"blocked",
"blocked",
"auto_waived_by_owner_policy",
"controlled_apply_authorized",
"queued_after_controlled_apply",
"queued_after_verifier",
"queued_after_verifier",
]
assert "owner_approval_receipt" in closure["required_owner_fields"]
assert "post_apply_verifier_plan" in closure["required_owner_fields"]
assert "km_writeback_owner" in closure["required_owner_fields"]
assert closure["required_owner_fields"] == []
assert closure["readback_assets"][0]["asset_id"] == (
"agent-result-capture-owner-approved-execution-rehearsal:P2-126"
)
@@ -1895,7 +1895,7 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
"post_apply_verifier_preflight",
"km_playbook_trust_writeback_plan",
]
assert {task["runtime_write_allowed"] for task in closure["closure_tasks"]} == {False}
assert {task["runtime_write_allowed"] for task in closure["closure_tasks"]} == {True}
assert closure["closure_tasks"][0]["source_asset_id"] == (
"agent-result-capture-owner-release-approval-gate:P2-131"
)
@@ -1906,14 +1906,14 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
assert controlled_execution["schema_version"] == (
"awooop_controlled_execution_preflight_v1"
)
assert controlled_execution["status"] == "blocked_before_runtime_gate"
assert controlled_execution["runtime_execution_authorized"] is False
assert controlled_execution["runtime_write_allowed"] is False
assert controlled_execution["status"] == "controlled_apply_allowed"
assert controlled_execution["runtime_execution_authorized"] is True
assert controlled_execution["runtime_write_allowed"] is True
assert controlled_execution["candidate_route_count"] == 1
assert controlled_execution["allowed_route_count"] == 0
assert controlled_execution["ready_count"] == 2
assert controlled_execution["allowed_route_count"] == 1
assert controlled_execution["ready_count"] == 5
assert controlled_execution["total_count"] == 7
assert controlled_execution["blocked_count"] == 5
assert controlled_execution["blocked_count"] == 2
assert [item["key"] for item in controlled_execution["prerequisites"]] == [
"dry_run_passed",
"allowlisted_route_candidate",
@@ -1926,7 +1926,7 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
assert controlled_execution["routes"][0]["route_id"] == (
"ansible-allowlisted-apply:ansible:188-ai-web"
)
assert controlled_execution["routes"][0]["allowed"] is False
assert controlled_execution["routes"][0]["allowed"] is True
assert controlled_execution["routes"][0]["apply_playbook_path"] == (
"infra/ansible/playbooks/188-ai-web.yml"
)
@@ -1934,15 +1934,15 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
assert release_contract["schema_version"] == (
"awooop_execution_release_contract_v1"
)
assert release_contract["status"] == "draft_prefilled_needs_owner_release"
assert release_contract["runtime_execution_authorized"] is False
assert release_contract["runtime_write_allowed"] is False
assert release_contract["status"] == "controlled_apply_auto_authorized"
assert release_contract["runtime_execution_authorized"] is True
assert release_contract["runtime_write_allowed"] is True
assert release_contract["route_id"] == (
"ansible-allowlisted-apply:ansible:188-ai-web"
)
assert release_contract["ready_count"] == 10
assert release_contract["ready_count"] == 11
assert release_contract["total_count"] == 11
assert release_contract["blocked_count"] == 1
assert release_contract["blocked_count"] == 0
assert [field["key"] for field in release_contract["fields"]] == [
"incident_ref",
"route_id",
@@ -1957,26 +1957,18 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
"playbook_trust_owner",
]
assert release_contract["fields"][0]["value"] == "INC-20260625-977E5F"
assert release_contract["fields"][4]["status"] == "blocked_missing_owner_release"
assert release_contract["fields"][5]["status"] == (
"ai_suggested_owner_review_required"
)
assert release_contract["fields"][7]["status"] == (
"ai_prefilled_owner_review_required"
)
assert release_contract["fields"][4]["status"] == "auto_waived_by_current_owner_policy"
assert release_contract["fields"][5]["status"] == "ai_selected_auto_policy"
assert release_contract["fields"][7]["status"] == "ai_prefilled_auto_policy"
assert release_contract["fields"][8]["value"] == "verifier-plan:INC-20260625-977E5F"
owner_release_draft = release_contract["owner_release_draft"]
assert owner_release_draft["schema_version"] == "awooop_owner_release_draft_v1"
assert owner_release_draft["status"] == "ai_prefilled_needs_owner_decision"
assert owner_release_draft["ai_prefilled_count"] == 6
assert owner_release_draft["human_decision_count"] == 3
assert owner_release_draft["runtime_execution_authorized"] is False
assert owner_release_draft["runtime_write_allowed"] is False
assert owner_release_draft["human_only_fields"] == [
"owner_approval_receipt",
"maintenance_window_final_approval",
"rollback_owner_confirmation",
]
assert owner_release_draft["status"] == "ai_prefilled_auto_authorized"
assert owner_release_draft["ai_prefilled_count"] == 9
assert owner_release_draft["human_decision_count"] == 0
assert owner_release_draft["runtime_execution_authorized"] is True
assert owner_release_draft["runtime_write_allowed"] is True
assert owner_release_draft["human_only_fields"] == []
assert [field["key"] for field in owner_release_draft["draft_fields"]] == [
"maintenance_window",
"rollback_owner",
@@ -1986,7 +1978,7 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
"playbook_trust_owner",
]
assert {step["runtime_write_allowed"] for step in release_contract["next_steps"]} == {
False
True
}
assert [step["key"] for step in release_contract["next_steps"]] == [
"owner_release",
@@ -1994,7 +1986,7 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No
"verifier_release",
"learning_writeback",
]
assert "ansible_apply" in release_contract["forbidden_until_contract_complete"]
assert "critical_or_break_glass_apply" in release_contract["forbidden_until_contract_complete"]
assert chain["execution"]["ansible"]["check_mode_total"] == 1
assert chain["execution"]["ansible"]["apply_total"] == 0
assert chain["execution"]["ansible"]["applied"] is False
@@ -2191,7 +2183,7 @@ def test_source_correlation_applied_link_requires_stage_and_direct_match() -> No
assert _is_source_correlation_applied_link(non_link_stage, scored) is False
def test_awooop_status_chain_marks_read_only_manual_gate() -> None:
def test_awooop_status_chain_marks_read_only_controlled_apply_evaluation() -> None:
chain = _build_awooop_status_chain(
incident_ids=["INC-20260513-79ED5E"],
source_id="INC-20260513-79ED5E",
@@ -2231,8 +2223,8 @@ def test_awooop_status_chain_marks_read_only_manual_gate() -> None:
)
assert chain["repair_state"] == "read_only_dry_run"
assert chain["needs_human"] is True
assert chain["next_step"] == "approve_or_escalate_from_awooop"
assert chain["needs_human"] is False
assert chain["next_step"] == "evaluate_controlled_apply_from_read_only_evidence"
assert chain["blockers"] == ["pending_human_approval"]

View File

@@ -13,6 +13,7 @@ from src.services.awooop_ansible_audit_service import (
)
from src.services.awooop_ansible_check_mode_service import (
_automation_operation_log_incident_id,
build_ansible_apply_command,
build_ansible_check_mode_claim_input,
build_ansible_check_mode_command,
claim_pending_check_modes,
@@ -1158,7 +1159,7 @@ def test_ansible_truth_surfaces_audited_check_mode_record() -> None:
assert "ansible_check_mode_executed" in truth["audit_contract"]["operation_types"]
assert truth["candidate_catalog"]["decision_effect"] == "none"
assert truth["candidate_catalog"]["candidates"][0]["catalog_id"] == "ansible:188-momo-backup-user"
assert truth["candidate_catalog"]["candidates"][0]["auto_apply_enabled"] is False
assert truth["candidate_catalog"]["candidates"][0]["auto_apply_enabled"] is True
assert (
truth["candidate_catalog"]["candidates"][0]["check_mode_playbook_path"]
== "infra/ansible/playbooks/188-momo-backup-user.yml"
@@ -1176,7 +1177,7 @@ def test_ansible_truth_keeps_catalog_hint_separate_from_runtime_use() -> None:
assert truth["records"] == []
assert truth["not_used_reason"].startswith("no automation_operation_log row")
assert truth["candidate_catalog"]["candidates"][0]["catalog_id"] == "ansible:nginx-sync"
assert truth["candidate_catalog"]["candidates"][0]["approval_required"] is True
assert truth["candidate_catalog"]["candidates"][0]["approval_required"] is False
assert truth["candidate_catalog"]["decision_effect"] == "none"
@@ -1245,12 +1246,12 @@ def test_ansible_decision_audit_payload_exposes_check_mode_safety_flags() -> Non
assert candidate["supports_check_mode"] is True
assert candidate["playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert candidate["check_mode_playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert candidate["auto_apply_enabled"] is False
assert candidate["approval_required"] is True
assert candidate["auto_apply_enabled"] is True
assert candidate["approval_required"] is False
assert candidate["risk_level"] == "low"
def test_ansible_check_mode_claim_input_keeps_apply_locked() -> None:
def test_ansible_check_mode_claim_input_authorizes_controlled_apply() -> None:
candidate_input = {
"incident_id": "INC-MOMO",
"executor": "ansible",
@@ -1272,10 +1273,13 @@ def test_ansible_check_mode_claim_input_keeps_apply_locked() -> None:
assert claim["execution_mode"] == "check_mode"
assert claim["check_mode"] is True
assert claim["diff"] is True
assert claim["apply_enabled"] is False
assert claim["apply_enabled"] is True
assert claim["transport_profile"]
assert claim["approval_required_before_apply"] is True
assert claim["approval_required_before_apply"] is False
assert claim["controlled_apply_allowed"] is True
assert claim["controlled_apply_blocker"] is None
assert claim["catalog_playbook_path"] == "infra/ansible/playbooks/188-ai-web.yml"
assert claim["apply_playbook_path"] == "infra/ansible/playbooks/188-ai-web.yml"
assert claim["source_candidate_playbook_path"] == "infra/ansible/playbooks/188-ai-web.yml"
assert claim["check_mode_playbook_path"] == "infra/ansible/playbooks/188-ai-web-readonly.yml"
assert claim["playbook_path"] == "infra/ansible/playbooks/188-ai-web-readonly.yml"
@@ -1301,10 +1305,13 @@ def test_ansible_check_mode_claim_input_accepts_momo_backup_user_catalog() -> No
)
assert claim["execution_mode"] == "check_mode"
assert claim["apply_enabled"] is False
assert claim["apply_enabled"] is True
assert claim["controlled_apply_allowed"] is True
assert claim["approval_required_before_apply"] is False
assert claim["catalog_id"] == "ansible:188-momo-backup-user"
assert claim["risk_level"] == "low"
assert claim["catalog_playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert claim["apply_playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert claim["source_candidate_playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert claim["check_mode_playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
assert claim["playbook_path"] == "infra/ansible/playbooks/188-momo-backup-user.yml"
@@ -1366,6 +1373,36 @@ def test_ansible_check_mode_command_uses_check_diff_and_selected_ssh_transport(t
assert "apply" not in " ".join(spec.command)
def test_ansible_apply_command_uses_controlled_apply_without_check(tmp_path: Path) -> None:
playbook_root = tmp_path / "infra" / "ansible"
playbook_dir = playbook_root / "playbooks"
inventory_dir = playbook_root / "inventory"
playbook_dir.mkdir(parents=True)
inventory_dir.mkdir(parents=True)
(playbook_dir / "188-ai-web.yml").write_text("---\n- hosts: host_188\n tasks: []\n")
(inventory_dir / "hosts.yml").write_text("all: {}\n")
repair_key = tmp_path / "id_ed25519"
known_hosts = tmp_path / "known_hosts"
repair_key.write_text("key")
known_hosts.write_text("host key")
spec = build_ansible_apply_command(
playbook_path="infra/ansible/playbooks/188-ai-web.yml",
inventory_hosts=("host_188",),
playbook_root=playbook_root,
check_mode_ssh_key_path=repair_key,
check_mode_known_hosts_path=known_hosts,
)
assert "--check" not in spec.command
assert "--diff" in spec.command
assert "--limit" in spec.command
assert "host_188" in spec.command
assert "ansible_ssh_private_key_file" in spec.command[-1]
assert str(repair_key) in spec.command[-1]
assert str(known_hosts) in spec.command[-1]
def test_ansible_claim_query_limits_recent_candidate_backlog() -> None:
source = inspect.getsource(claim_pending_check_modes)

View File

@@ -98,7 +98,7 @@ def test_operator_outcome_marks_unverified_execution_as_human_review() -> None:
assert outcome["next_action"] == "run_or_review_post_execution_verification"
def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() -> None:
def test_operator_outcome_marks_ansible_check_mode_as_controlled_apply_ready() -> None:
outcome = build_operator_outcome(
truth_status={
"current_stage": "execution_succeeded",
@@ -119,14 +119,14 @@ def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() ->
},
)
assert outcome["state"] == "apply_candidate_owner_review_ready"
assert outcome["needs_human"] is True
assert outcome["next_action"] == "open_apply_gate_work_item_review_verifier_and_km"
assert outcome["state"] == "controlled_apply_queued"
assert outcome["needs_human"] is False
assert outcome["next_action"] == "wait_for_controlled_apply_and_post_apply_verifier"
assert outcome["execution_result"]["completion_status"] == (
"dry_run_passed_apply_candidate_ready"
"dry_run_passed_controlled_apply_queued"
)
assert outcome["execution_result"]["command_status"] == "check_mode_succeeded"
assert outcome["execution_result"]["repair_status"] == "not_executed"
assert outcome["execution_result"]["repair_status"] == "controlled_apply_pending"
def test_operator_outcome_marks_verified_repair_as_result_only() -> None:
@@ -232,7 +232,7 @@ def test_execution_result_message_includes_operator_outcome_and_human_channels()
assert "manual_review_no_action_decision" in text
def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() -> None:
def test_operator_outcome_marks_ansible_check_mode_blockers_as_controlled_apply_ready() -> None:
outcome = build_operator_outcome(
truth_status={
"current_stage": "execution_succeeded",
@@ -255,17 +255,48 @@ def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() ->
},
)
assert outcome["state"] == "apply_candidate_owner_review_ready"
assert outcome["next_action"] == "open_apply_gate_work_item_review_verifier_and_km"
assert "apply candidate" in outcome["summary_zh"]
assert outcome["state"] == "controlled_apply_queued"
assert outcome["next_action"] == "wait_for_controlled_apply_and_post_apply_verifier"
assert "受控自動 apply" in outcome["summary_zh"]
assert outcome["execution_result"]["completion_status"] == (
"dry_run_passed_apply_candidate_ready"
"dry_run_passed_controlled_apply_queued"
)
assert "auto_repair_missing" in outcome["blockers"]
assert "verification_missing" in outcome["blockers"]
assert "learning_missing" in outcome["blockers"]
def test_operator_outcome_keeps_failed_ansible_check_mode_with_ai_repair() -> None:
outcome = build_operator_outcome(
truth_status={
"current_stage": "execution_failed",
"stage_status": "failed",
"needs_human": True,
"blockers": ["ansible_check_mode_failed"],
},
automation_quality={
"verdict": "ansible_check_mode_only",
"facts": {
"ansible_check_mode_total": 1,
"ansible_apply_total": 0,
"auto_repair_execution_records": 0,
"effective_execution_records": 1,
"automation_operation_records": 2,
"verification_result": None,
"knowledge_entries": 0,
},
"blockers": ["ansible_check_mode_failed"],
},
)
assert outcome["state"] == "ai_playbook_repair_required"
assert outcome["needs_human"] is False
assert outcome["next_action"] == "auto_generate_playbook_or_transport_fix_candidate"
assert outcome["execution_result"]["completion_status"] == (
"dry_run_failed_ai_repairing_playbook_or_transport"
)
def test_execution_result_message_does_not_call_diagnostic_success_repair_done() -> None:
service = ApprovalExecutionService()
approval = SimpleNamespace(

View File

@@ -164,8 +164,13 @@ full_log=/var/ossec/logs/alerts/alerts.json Authorization: Bearer abcdefghijklmn
assert card_metadata["event_type"] == "wazuh_dashboard_api_readback_degraded"
assert card_metadata["lane"] == "siem_observability_readback_degraded"
assert card_metadata["target"] == "wazuh_dashboard_api"
assert card_metadata["gates"] == ["candidate_only", "runtime_write_gate=0"]
assert card_metadata["runtime_write_gate_count"] == 0
assert card_metadata["gates"] == [
"controlled_playbook_queue",
"runtime_write_gate=controlled",
]
assert card_metadata["controlled_playbook_queue"] is True
assert card_metadata["runtime_write_gate_state"] == "controlled"
assert card_metadata["runtime_write_gate_count"] == 1
assert card_metadata["delivery_receipt_readback_required"] is True
assert envelope["source_refs"]["alert_ids"] == [
"wazuh_dashboard_api_readback_degraded"

View File

@@ -25,8 +25,8 @@ from src.services.telegram_gateway import (
)
def test_auto_repair_status_line_distinguishes_handoff_required() -> None:
"""自動化失敗 reply 必須明確標示轉人工,且不把 raw error 當純文字噴出。"""
def test_auto_repair_status_line_distinguishes_ai_retry_queued() -> None:
"""自動化失敗 reply 必須明確標示 AI 續跑,且不把 raw error 當純文字噴出。"""
result = _format_auto_repair_status_line(
incident_id="INC-20260507-AAAAAA",
target="node-exporter-110",
@@ -35,9 +35,9 @@ def test_auto_repair_status_line_distinguishes_handoff_required() -> None:
error="Unsupported <scheme> & %d format: a real number is required, not str",
)
assert "HANDOFF REQUIREDAI 自動修復失敗,已轉人工" in result
assert "自動化已停止,不再重試" in result
assert "請 SRE 依 AwoooP Run / 原告警卡處理" in result
assert "AI RETRY QUEUEDAI 自動修復失敗,已排入下一輪修復" in result
assert "AI 已排入 PlayBook / transport / verifier 修復候選" in result
assert "由 AI Agent 續跑" in result
assert "&lt;scheme&gt; &amp; %d format" in result
assert "<scheme>" not in result
@@ -79,8 +79,8 @@ root 364 181 0.7 3491396 494608 ? Rl 05:56 0:18 /opt/hostedto
assert "容器 root 進程" in result
assert "AI 自動化判讀" in result
assert "runner_build_resource_pressure" in result
assert "candidate_only" in result
assert "runtime_write_gate=0" in result
assert "controlled_playbook_queue" in result
assert "runtime_write_gate=controlled" in result
assert "Top evidence" in result
assert "PID 259" in result
assert "Next.js build" in result
@@ -88,7 +88,7 @@ root 364 181 0.7 3491396 494608 ? Rl 05:56 0:18 /opt/hostedto
assert "Next.js build worker" in result
assert "建議下一步" in result
assert "禁止事項" in result
assert "不 kill process" in result
assert "allowlisted PlayBook" in result
assert "root 259" not in result
assert "/workspace/wooo/" not in result
assert "processChild.js" not in result
@@ -110,12 +110,12 @@ def test_orphan_browser_alert_becomes_runaway_process_event_packet() -> None:
assert "HostOrphanBrowserSmokeHighCpu" in result
assert "stockplatform_headless_smoke" in result
assert "host-runaway-process-remediation.py" in result
assert "dry-run" in result
assert "gated SIGTERM" in result
assert "check-mode" in result
assert "受控 SIGTERM" in result
assert "KM / PlayBook / Verifier" in result
assert "runtime_write_gate=0" in result
assert "不 kill process" in result
assert "Docker" in result
assert "runtime_write_gate=controlled" in result
assert "allowlisted PlayBook" in result
assert "受控 apply" in result
def test_ci_runner_load_alert_becomes_capacity_event_packet() -> None:
@@ -133,8 +133,8 @@ def test_ci_runner_load_alert_becomes_capacity_event_packet() -> None:
assert "Gitea Actions run" in result
assert "合法 CI" in result
assert "不做 process remediation" in result
assert "runtime_write_gate=0" in result
assert "不 kill process" in result
assert "runtime_write_gate=controlled" in result
assert "受控 cleanup" in result
def test_wazuh_alert_becomes_aiops_signal_event_packet() -> None:
@@ -152,8 +152,8 @@ syscheck integrity changed on /etc/nginx/sites-enabled/awoooi.conf
assert "ai_automation_alert_card_v1" in result
assert "wazuh_intrusion_signal" in result
assert "security_intrusion_triage" in result
assert "candidate_only" in result
assert "runtime_write_gate=0" in result
assert "controlled_playbook_queue" in result
assert "runtime_write_gate=controlled" in result
assert "Top evidence" in result
assert "Timeline / KM / PlayBook" in result
assert "不封鎖 IP" in result
@@ -179,8 +179,8 @@ full_log=/var/ossec/logs/alerts/alerts.json Authorization: Bearer abcdefghijklmn
assert "ai_automation_alert_card_v1" in result
assert "wazuh_dashboard_api_readback_degraded" in result
assert "siem_observability_readback_degraded" in result
assert "candidate_only" in result
assert "runtime_write_gate=0" in result
assert "controlled_playbook_queue" in result
assert "runtime_write_gate=controlled" in result
assert "manager registry 只讀計數" in result
assert "no-false-green" in result
assert "不重啟 Wazuh" in result
@@ -204,7 +204,7 @@ diff_url=https://internal.example.invalid/raw/live.conf
assert "nginx_config_drift" in result
assert "public_gateway_config_drift" in result
assert "redacted live export" in result
assert "runtime_write_gate=0" in result
assert "runtime_write_gate=controlled" in result
assert "不執行 nginx -t" in result
assert "不 reload" in result
assert "/etc/nginx" not in result
@@ -268,8 +268,8 @@ def test_aiops_signal_formatter_covers_non_host_alert_lanes(
assert "ai_automation_alert_card_v1" in result
assert event_type in result
assert lane in result
assert "candidate_only" in result
assert "runtime_write_gate=0" in result
assert "controlled_playbook_queue" in result
assert "runtime_write_gate=controlled" in result
assert "Top evidence" in result
assert "禁止事項" in result
@@ -331,7 +331,7 @@ async def test_send_alert_notification_normalizes_aiops_signal_alert(monkeypatch
assert payload["parse_mode"] == "HTML"
assert "ai_automation_alert_card_v1" in payload["text"]
assert "public_gateway_config_drift" in payload["text"]
assert "runtime_write_gate=0" in payload["text"]
assert "runtime_write_gate=controlled" in payload["text"]
assert "/etc/nginx" not in payload["text"]
@@ -352,7 +352,7 @@ root 392 0.0 0.0 1096836 53400 ? Ssl 06:27 0:00 /opt/hostedto
assert "Prisma generate" in result
assert "容器 root 進程:<code>3</code>" in result
assert "套件來源" in result
assert "runtime_write_gate=0" in result
assert "runtime_write_gate=controlled" in result
assert "root 365" not in result
assert "checkpoint.prisma.io" not in result
assert "node_modules" not in result
@@ -442,7 +442,7 @@ def test_send_request_payload_normalizer_blocks_direct_host_raw_dump() -> None:
assert result["parse_mode"] == "HTML"
assert "P1 主機資源壓力h110-gitea" in result["text"]
assert "ai_automation_alert_card_v1" in result["text"]
assert "runtime_write_gate=0" in result["text"]
assert "runtime_write_gate=controlled" in result["text"]
assert "root 311" not in result["text"]
assert "checkpoint.prisma.io" not in result["text"]
assert "/workspace/wooo" not in result["text"]
@@ -667,8 +667,8 @@ def test_awooop_status_chain_lines_show_read_only_manual_gate() -> None:
joined = "\n".join(lines)
assert "read_only_dry_run" in joined
assert "investigator/ssh_diagnose/read" in joined
assert "人工: <code>yes</code>" in joined
assert "approve_or_escalate_from_awooop" in joined
assert "人工: <code>no</code>" in joined
assert "evaluate_controlled_apply_from_read_only_evidence" in joined
assert "pending_human_approval" in joined
assert "自動化資產沉澱" in joined
assert "PlayBook<code>--</code>" in joined
@@ -768,10 +768,10 @@ def test_awooop_status_chain_lines_mark_ansible_check_mode_as_dry_run_only() ->
joined = "\n".join(lines)
assert "ansible_check_mode_only" in joined
assert "open_apply_gate_work_item_review_verifier_and_km" in joined
assert "apply_candidate_owner_review_ready" in joined
assert "dry_run_passed_apply_candidate_ready" in joined
assert "AI 已完成 Ansible check-mode 並產生 apply candidate" in joined
assert "wait_for_controlled_apply_and_post_apply_verifier" in joined
assert "controlled_apply_queued" in joined
assert "dry_run_passed_controlled_apply_queued" in joined
assert "AI 已完成 Ansible check-mode,符合受控自動 apply 條件" in joined
assert "executed_pending_verification" not in joined
assert "run_or_review_post_execution_verification" not in joined
@@ -1138,8 +1138,8 @@ def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None
"awooop_status_chain_callback_reply_snapshot_v1"
)
assert snapshot["repair_state"] == "read_only_dry_run"
assert snapshot["needs_human"] is True
assert snapshot["next_step"] == "approve_or_escalate_from_awooop"
assert snapshot["needs_human"] is False
assert snapshot["next_step"] == "evaluate_controlled_apply_from_read_only_evidence"
assert snapshot["evidence"]["mcp_gateway_total"] == 1
assert snapshot["evidence"]["latest_route"] == "investigator/ssh_diagnose/read"
assert snapshot["writes"]["incident"] is False
@@ -1802,8 +1802,8 @@ class TestTelegramMessageFormat:
result = msg.format()
assert "處置狀態" in result
assert "未自動修復,已產生人工處置包" in result
assert "人工處置包" in result
assert "已排入 AI 受控處理" in result
assert "AI 受控" in result
assert "補證據node_exporter target up" in result
assert "AwoooP 建立修復候選" in result
assert "沉澱資產KM、PlayBook、腳本/Ansible、排程/監控規則、Verifier 結果" in result
@@ -1825,7 +1825,7 @@ class TestTelegramMessageFormat:
result = msg.format()
assert "AI 已完成只讀診斷,需人工判斷" in result
assert "AI 已完成只讀診斷,排入受控修復候選" in result
assert "AI 自動修復失敗" not in result
def test_telegram_message_diagnosis_failure_state(self):
@@ -1843,7 +1843,7 @@ class TestTelegramMessageFormat:
result = msg.format()
assert "AI 診斷工具失敗,需人工排查" in result
assert "AI 診斷工具失敗,已排入 tool/connector 修復" in result
assert "AI 自動修復失敗" not in result
def test_telegram_message_surfaces_read_only_remediation_evidence(self):
@@ -1879,7 +1879,7 @@ class TestTelegramMessageFormat:
result = msg.format()
assert "AI 已完成只讀補救試跑,等待人工審批" in result
assert "AI 已完成只讀補救試跑,排入受控 apply 判定" in result
assert "AI 證據" in result
assert "只讀試跑 3 次" in result
assert "auto_repair_executor/ssh_diagnose/read" in result
@@ -2118,7 +2118,7 @@ async def test_send_approval_card_includes_remediation_summary(monkeypatch):
assert sent_requests
text = sent_requests[0][1]["text"]
assert "AI 已完成只讀補救試跑,等待人工審批" in text
assert "AI 已完成只讀補救試跑,排入受控 apply 判定" in text
assert "auto_repair_executor/ssh_diagnose/read" in text