feat(dispatch): B2 LLM 動態 MCP 派發安全閘 + telegram_gateway LLM 按鈕流程
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m10s

ADR-082 §B2:dispatch_llm_action() 風險閘控 + allowlist + 模板渲染
23 tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-27 15:22:31 +08:00
parent 92a5d94382
commit ea23972f7a
4 changed files with 895 additions and 9 deletions

View File

@@ -404,3 +404,165 @@ def _format_reply(
text += "...\n<i>(已截斷)</i>"
return f"{header}\n<pre>{text}</pre>"
return f"{header}\n{mcp_result}"
# =============================================================================
# B2: LLM Dynamic Action Dispatcher
# 2026-04-27 Claude Sonnet 4.6: B2 — dispatch_llm_action()
# 支援 RecommendedAction 結構化動作的風險閘控 + allowlist 驗證 + 模板渲染
# ADR-082 §B2LLM 動態 MCP 規格派發安全閘
# =============================================================================
import re as _re
def _render_llm_params(params: dict[str, str], context: dict) -> dict[str, str]:
"""
渲染 RecommendedAction.params 模板。
支援兩個命名空間:
- {labels.xxx} → context["labels"]["xxx"]
- {context.xxx} → context["xxx"](如 context.incident_id
- {incident_id} → context["incident_id"](舊式相容)
渲染失敗的 key → 保留原始字串,不 crash。
"""
def _repl(m: _re.Match) -> str:
key = m.group(1)
parts = key.split(".", 1)
try:
if parts[0] == "labels" and len(parts) == 2:
val = (context.get("labels") or {}).get(parts[1])
return str(val) if val is not None else m.group(0)
if parts[0] == "context" and len(parts) == 2:
val = context.get(parts[1])
return str(val) if val is not None else m.group(0)
# 舊式:直接 top-level key如 {incident_id}
val = context.get(key)
return str(val) if val is not None else m.group(0)
except Exception:
return m.group(0)
rendered: dict[str, str] = {}
for k, v in params.items():
if isinstance(v, str) and "{" in v:
try:
rendered[k] = _re.sub(r"\{([a-zA-Z0-9_.]+)\}", _repl, v)
except Exception:
rendered[k] = v
else:
rendered[k] = v
return rendered
def _load_llm_tool_registry() -> dict[str, dict]:
"""
Lazy import _load_mcp_tool_registry from solver_agent避免 circular import。
失敗時返回 {} 並 log warning不 crash
"""
try:
from src.agents.solver_agent import _load_mcp_tool_registry # noqa: PLC0415
return _load_mcp_tool_registry()
except Exception as exc:
logger.warning("llm_dispatch_registry_load_failed", error=str(exc))
return {}
def dispatch_llm_action(
action: Any,
context: dict,
) -> dict:
"""
B2: LLM 動態 MCP 規格派發閘控器
安全層次(依序執行):
1. Risk Gating — critical 直接拒絕high 需要 confirmed=True
2. Allowlist — mcp_tool 必須在 registry 中
3. Params 渲染 — 支援 {labels.xxx} / {context.xxx} / {incident_id}
4. Nonce 生成 — medium/high 允許執行時附帶 nonce
Args:
action: RecommendedAction dataclass來自 solver_agent B1 輸出)
context: 執行上下文 dict含 labels / incident_id / confirmed 等)
Returns:
dict — ok=True 為允許執行ok=False 附 reason 拒絕原因
"""
import time as _time # noqa: PLC0415
risk: str = getattr(action, "risk", "medium")
mcp_tool: str = getattr(action, "mcp_tool", "")
mcp_provider: str = getattr(action, "mcp_provider", "")
name: str = getattr(action, "name", "")
params: dict = dict(getattr(action, "params", {}) or {})
# ── 1. Risk Gating ────────────────────────────────────────────────────────
if risk == "critical":
logger.warning(
"llm_dispatch_critical_rejected",
mcp_tool=mcp_tool,
name=name,
incident_id=context.get("incident_id"),
)
return {"ok": False, "reason": "critical_action_rejected"}
if risk == "high":
if not context.get("confirmed"):
nonce = (
f"{mcp_tool}:{name}:{context.get('incident_id', '?')}:{int(_time.time())}"
)
logger.info(
"llm_dispatch_high_risk_pending",
mcp_tool=mcp_tool,
name=name,
incident_id=context.get("incident_id"),
)
return {
"ok": False,
"reason": "high_risk_requires_confirmation",
"nonce": nonce,
}
# ── 2. Allowlist 驗證 ─────────────────────────────────────────────────────
registry = _load_llm_tool_registry()
if mcp_tool not in registry:
logger.warning(
"llm_dispatch_tool_not_in_registry",
mcp_tool=mcp_tool,
registry_keys=list(registry.keys()),
)
return {"ok": False, "reason": "tool_not_in_registry"}
# ── 3. Params 模板渲染 ────────────────────────────────────────────────────
rendered_params = _render_llm_params(params, context)
# ── 4. Nonce 生成medium/high 允許時) ───────────────────────────────────
nonce: str | None = None
if risk in ("medium", "high"):
nonce = (
f"{mcp_tool}:{name}:{context.get('incident_id', '?')}:{int(_time.time())}"
)
logger.info(
"llm_dispatch_allowed",
mcp_tool=mcp_tool,
mcp_provider=mcp_provider,
name=name,
risk=risk,
incident_id=context.get("incident_id"),
has_nonce=nonce is not None,
)
return {
"ok": True,
"mcp_provider": mcp_provider,
"mcp_tool": mcp_tool,
"params": rendered_params,
"risk": risk,
"nonce": nonce,
"button_source": "llm",
}