""" solver_agent._extract_candidates 單元測試 2026-04-24 ogt + Claude Sonnet 4.6: 修法 A — kubectl_command 優先路徑驗證 2026-04-24 ogt + Claude Sonnet 4.6: C1/C2/C3 安全漏洞修復驗證 C1: 換行注入防禦(\\n / \\r / \\t / \\x00) C2: action_title 路徑補防護(白名單檢驗) C3: ReDoS 防禦(有界 quantifier + 長度上限) """ from __future__ import annotations import sys import os # 確保 src 可找到 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../")) import time import pytest from src.agents.solver_agent import _extract_candidates, _is_safe_kubectl_command class TestExtractCandidatesNemoFormat: """OpenClaw Nemo 格式解析測試""" def test_kubectl_command_field_preserves_confidence(self): """修法 A 核心:kubectl_command 存在時,confidence 不被壓縮""" parsed = { "action_title": "重啟 Crash Looping Pod", "kubectl_command": "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 c = result[0] assert c.confidence == 0.9, f"期望 0.9,實際 {c.confidence}" assert c.action == "kubectl rollout restart deployment/awoooi-api -n awoooi-prod" assert "OpenClaw Nemo" in c.rationale assert "重啟 Crash Looping Pod" in c.rationale def test_kubectl_command_field_high_confidence(self): """kubectl_command 存在,confidence 0.85 仍完整保留""" parsed = { "action_title": "Scale Down Pod Count", "kubectl_command": "kubectl scale deployment/awoooi-api --replicas=2 -n awoooi-prod", "confidence": 0.85, "risk_level": "low", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.85 assert result[0].blast_radius == 10 # risk_level=low → blast=10 def test_kubectl_command_field_takes_priority_over_synthesis(self): """kubectl_command 存在時,不走語意合成(不被 min(0.5) 壓)""" parsed = { "action_title": "重啟服務", # 若無 kubectl_command,會走語意合成被壓到 0.5 "kubectl_command": "kubectl rollout restart deployment/api -n awoooi-prod", "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.9, "kubectl_command 路徑不應觸發 min(confidence, 0.5)" def test_no_kubectl_command_action_title_has_kubectl(self): """舊路徑向後相容:action_title 本身含 kubectl,無 kubectl_command""" parsed = { "action_title": "kubectl rollout restart deployment -n awoooi-prod", "confidence": 0.8, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.8 assert "kubectl rollout restart" in result[0].action def test_no_kubectl_command_synthesis_caps_confidence(self): """語意合成備援路徑:confidence 仍被 min(0.5) 壓制(預期行為) 2026-04-25 修復 L3:需提供 target 欄位才能合成完整 kubectl 指令 根本原因:無 target 會生成殘缺指令 → 下游解析失敗 → 執行失敗無回報 """ parsed = { "action_title": "重啟服務", # 無 kubectl_command,觸發語意合成 "target": "awoooi-api", # 2026-04-25 補上 target,使語意合成能生成完整指令 "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.5, "語意合成路徑 confidence 必須被壓到 0.5" assert "[語意合成]" in result[0].rationale def test_kubectl_command_empty_string_falls_through(self): """kubectl_command 為空字串時,回落到既有邏輯 2026-04-25 修復 L3:需提供 target 欄位 """ parsed = { "action_title": "重啟服務", "kubectl_command": "", "target": "awoooi-api", # 2026-04-25 補上 target "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) # 空 kubectl_command → 走語意合成 → confidence 被壓 assert len(result) == 1 assert result[0].confidence == 0.5 def test_kubectl_command_not_starting_with_kubectl_falls_through(self): """kubectl_command 非 kubectl 開頭(可能是雜訊),回落到既有邏輯 2026-04-25 修復 L3:需提供 target 欄位 """ parsed = { "action_title": "重啟服務", "kubectl_command": "helm rollback awoooi-api", "target": "awoooi-api", # 2026-04-25 補上 target "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) # helm 指令不被採用 → 語意合成 → confidence 被壓 assert len(result) == 1 assert result[0].confidence == 0.5 def test_no_action_title_no_candidates_uses_standard_path(self): """標準 candidates 陣列格式不受影響""" parsed = { "candidates": [ { "action": "kubectl rollout restart deployment/api -n awoooi-prod", "blast_radius": 25, "rollback_cost": 20, "confidence": 0.85, "rationale": "重啟修復 OOM", } ] } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.85 assert result[0].blast_radius == 25 def test_risk_level_blast_radius_mapping(self): """risk_level → blast_radius 映射正確""" cases = [ ("critical", 60), ("high", 40), ("medium", 25), ("low", 10), ("unknown", 30), # 預設 ] for risk_level, expected_blast in cases: parsed = { "action_title": "fix", "kubectl_command": "kubectl get pods -n awoooi-prod", "confidence": 0.9, "risk_level": risk_level, } result = _extract_candidates(parsed) assert result[0].blast_radius == expected_blast, ( f"risk_level={risk_level} → 期望 blast={expected_blast},實際 {result[0].blast_radius}" ) class TestShellMetacharacterBlocking: """2026-04-24 Major #1 改版:白名單正則測試(取代原黑名單枚舉)""" @pytest.mark.parametrize("malicious_cmd,desc", [ ( "kubectl rollout restart deployment/api -n awoooi-prod; rm -rf /", "分號注入", ), ( "kubectl get secret -n awoooi-prod | base64 -d", "pipe 注入", ), ( "kubectl get pods -n awoooi-prod `id`", "反引號注入", ), ( "kubectl exec deployment/api -- sh -c $EVIL_CMD", "$ 變數展開", ), ( "kubectl get pods -n awoooi-prod > /tmp/out", "> 重定向", ), ( "kubectl get pods -n awoooi-prod < /etc/passwd", "< 重定向", ), ]) def test_nemo_kubectl_command_invalid_regex_blocked(self, malicious_cmd, desc): """Nemo 路徑:各類惡意 kubectl_command 均被白名單正則攔截 2026-04-25 修復 L3:被攔截 → 回落語意合成路徑需 target 欄位 """ parsed = { "action_title": "重啟服務", "kubectl_command": malicious_cmd, "target": "awoooi-api", # 2026-04-25 補上 target,使回落路徑能合成 "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) # 惡意命令被阻擋 → fall-through → 語意合成 "重啟" → confidence 被壓到 0.5 assert len(result) == 1, f"{desc}: 期望 1 個結果(語意合成兜底)" # 確認惡意片段未出現在最終 action 中 assert "rm -rf" not in result[0].action, f"{desc}: rm -rf 不應出現" assert "base64" not in result[0].action, f"{desc}: base64 不應出現" assert result[0].confidence == 0.5, f"{desc}: 語意合成路徑 confidence 必須被壓到 0.5" def test_is_safe_kubectl_command_accepts_valid_commands(self): """白名單 helper:合法 kubectl 命令應通過驗證""" valid_cmds = [ "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", "kubectl scale deployment/awoooi-api --replicas=3 -n awoooi-prod", "kubectl rollout undo deployment/awoooi-api -n awoooi-prod", "kubectl get pods -n awoooi-prod -o wide", "kubectl top pods -n awoooi-prod --sort-by=cpu", "kubectl logs -n awoooi-prod --tail=100 --selector=app=awoooi-api", ] for cmd in valid_cmds: assert _is_safe_kubectl_command(cmd), f"合法命令被誤拒:{cmd}" def test_is_safe_kubectl_command_rejects_non_kubectl(self): """白名單 helper:非 kubectl 開頭的命令拒絕""" assert not _is_safe_kubectl_command("helm rollback awoooi-api") assert not _is_safe_kubectl_command("rm -rf /") assert not _is_safe_kubectl_command("") class TestConfidenceClampAndTypeError: """2026-04-24 Major #3/#4:confidence clamp + type error 防禦測試""" def test_confidence_clamp_above_1_0(self): """confidence > 1.0 被 clamp 到 1.0,防 auto_approve 門檻被破壞""" parsed = { "action_title": "重啟服務", "kubectl_command": "kubectl rollout restart deployment/api -n awoooi-prod", "confidence": 1.5, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 1.0, f"期望 1.0(clamp),實際 {result[0].confidence}" def test_confidence_clamp_below_0_0(self): """confidence < 0.0 被 clamp 到 0.0""" parsed = { "action_title": "重啟服務", "kubectl_command": "kubectl rollout restart deployment/api -n awoooi-prod", "confidence": -0.3, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.0, f"期望 0.0(clamp),實際 {result[0].confidence}" def test_confidence_non_numeric_string_fallback(self): """confidence 為非數字字串(如 "high")→ 預設 0.5""" parsed = { "action_title": "重啟服務", "kubectl_command": "kubectl rollout restart deployment/api -n awoooi-prod", "confidence": "high", "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.5, f"期望 0.5(fallback),實際 {result[0].confidence}" def test_confidence_none_type_fallback(self): """confidence 為 None → 預設 0.5""" parsed = { "action_title": "重啟服務", "kubectl_command": "kubectl rollout restart deployment/api -n awoooi-prod", "confidence": None, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.5, f"期望 0.5(fallback),實際 {result[0].confidence}" class TestStandardCandidatesPathSafety: """2026-04-24 Major #2:標準 candidates 路徑白名單防護測試""" def test_standard_path_action_unsafe_blocked(self): """標準路徑:含 shell 元字符的 action 被跳過,不加入 candidates""" parsed = { "candidates": [ { "action": "kubectl rollout restart deployment/api -n awoooi-prod; curl evil.com", "blast_radius": 10, "rollback_cost": 5, "confidence": 0.85, "rationale": "惡意注入", } ] } result = _extract_candidates(parsed) assert len(result) == 0, "含 shell 元字符的 action 不應加入 candidates" def test_standard_path_safe_action_passes(self): """標準路徑:合法 kubectl action 正常通過白名單""" parsed = { "candidates": [ { "action": "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", "blast_radius": 10, "rollback_cost": 5, "confidence": 0.85, "rationale": "重啟修復 OOM", } ] } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.85 assert result[0].blast_radius == 10 def test_standard_path_mixed_safe_unsafe_candidates(self): """標準路徑混合場景:5 個 candidates,2 個安全 3 個不安全,只回傳 2 個""" parsed = { "candidates": [ { "action": "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", "blast_radius": 10, "rollback_cost": 5, "confidence": 0.9, "rationale": "安全 1", }, { "action": "kubectl get pods -n awoooi-prod; rm -rf /", "blast_radius": 10, "rollback_cost": 5, "confidence": 0.8, "rationale": "不安全 1:分號注入", }, { "action": "kubectl scale deployment/awoooi-api --replicas=2 -n awoooi-prod", "blast_radius": 15, "rollback_cost": 10, "confidence": 0.75, "rationale": "安全 2", }, { "action": "kubectl exec deployment/api -- sh -c $EVIL", "blast_radius": 50, "rollback_cost": 30, "confidence": 0.7, "rationale": "不安全 2:$ 展開", }, { "action": "kubectl get secrets -n awoooi-prod | base64 -d", "blast_radius": 5, "rollback_cost": 1, "confidence": 0.6, "rationale": "不安全 3:pipe", }, ] } result = _extract_candidates(parsed) assert len(result) == 2, f"期望 2 個安全 candidates,實際 {len(result)}" # 結果按 confidence 降序排列 assert result[0].confidence == 0.9 assert result[1].confidence == 0.75 # 確認安全 candidates 的 action 內容正確 actions = [r.action for r in result] assert "kubectl rollout restart deployment/awoooi-api -n awoooi-prod" in actions assert "kubectl scale deployment/awoooi-api --replicas=2 -n awoooi-prod" in actions class TestC1NewlineInjectionBlocked: """C1:換行注入防禦測試(\\n / \\r / \\t / \\x00)""" def test_newline_injection_blocked(self): """LF 換行注入:kubectl get pods\\nrm -rf / 必須被拒絕""" assert not _is_safe_kubectl_command("kubectl get pods\nrm -rf /") def test_carriage_return_injection_blocked(self): """CR 歸位注入:kubectl get pods\\rcurl evil.com 必須被拒絕""" assert not _is_safe_kubectl_command("kubectl get pods\rcurl evil.com") def test_tab_injection_blocked(self): """Tab 注入:kubectl get\\tpods 必須被拒絕""" assert not _is_safe_kubectl_command("kubectl get\tpods") def test_null_byte_injection_blocked(self): """Null byte 注入:kubectl get pods\\x00rm -rf / 必須被拒絕""" assert not _is_safe_kubectl_command("kubectl get pods\x00rm -rf /") def test_newline_in_nemo_kubectl_command_falls_through(self): """換行注入進 Nemo kubectl_command 欄位:被擋後 fall-through 到語意合成 2026-04-25 修復 L3:被攔截 → 回落語意合成路徑需 target 欄位 """ parsed = { "action_title": "重啟服務", "kubectl_command": "kubectl get pods\nrm -rf /", "target": "awoooi-api", # 2026-04-25 補上 target "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) # 惡意 kubectl_command 被擋 → fall-through → "重啟" 語意合成 → confidence 壓到 0.5 assert len(result) == 1 assert result[0].confidence == 0.5 assert "rm -rf" not in result[0].action class TestC3ReDoSPerformance: """C3:ReDoS 防禦測試(有界 quantifier + 長度上限 O(1) 硬檢查)""" def test_redos_long_command_fast(self): """5000 字元輸入必須在 10ms 內完成(長度硬檢查先攔截,不觸發正則)""" long_cmd = "kubectl " + " " * 5000 start = time.perf_counter() result = _is_safe_kubectl_command(long_cmd) elapsed_ms = (time.perf_counter() - start) * 1000 assert result is False, "超長命令必須被拒絕" assert elapsed_ms < 10, f"長度硬檢查應 <10ms,實際 {elapsed_ms:.2f}ms(可能 ReDoS)" def test_max_len_boundary_accepted(self): """剛好 500 字元的合法命令應通過驗證(邊界值測試)""" # "kubectl " (8 chars) + 492 'a' = 500 chars total cmd = "kubectl " + "a" * 492 assert _is_safe_kubectl_command(cmd), "500 字元邊界應通過" def test_max_len_plus_one_rejected(self): """501 字元的命令必須被拒絕(邊界 +1)""" cmd = "kubectl " + "a" * 493 # 8 + 493 = 501 assert not _is_safe_kubectl_command(cmd), "501 字元必須被拒絕" class TestC2ActionTitlePathSafety: """C2:action_title 路徑補防護測試""" def test_action_title_with_semicolon_blocked_falls_through(self): """action_title 含分號:被擋且 fall-through(無語意關鍵字 → return [])""" parsed = { "action_title": "kubectl get pods; rm -rf /", "confidence": 0.9, "risk_level": "medium", } result = _extract_candidates(parsed) # "kubectl get pods; rm -rf /" 含 kubectl → 進入 C2 檢驗路徑 # 不通過白名單 → fall-through 語意合成 # "pods" / "rm" 無匹配語意關鍵字 → _synthesized = None → return [] assert len(result) == 0, "含分號的惡意 action_title 不應產生 candidates" def test_action_title_safe_kubectl_accepted(self): """action_title 是合法 kubectl 命令(無 kubectl_command 欄位):正常接受""" parsed = { "action_title": "kubectl rollout restart deployment/awoooi-api -n awoooi-prod", "confidence": 0.8, "risk_level": "medium", } result = _extract_candidates(parsed) assert len(result) == 1 assert result[0].confidence == 0.8 assert "kubectl rollout restart" in result[0].action def test_standard_path_semicolon_blocked(self): """標準 candidates 路徑:含分號的 action 被 skip,不進入結果""" parsed = { "candidates": [ { "action": "kubectl rollout restart deployment/api -n awoooi-prod; curl evil.com", "blast_radius": 10, "rollback_cost": 5, "confidence": 0.9, "rationale": "含分號注入", }, { "action": "kubectl get pods -n awoooi-prod", "blast_radius": 5, "rollback_cost": 2, "confidence": 0.7, "rationale": "合法命令", }, ] } result = _extract_candidates(parsed) assert len(result) == 1, "只有合法命令應通過" assert result[0].confidence == 0.7 assert "curl" not in result[0].action