Files
2026-08-21 16:00:13 +08:00

116 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""司命决策器:按工具策略 + 风险等级 + 是否已确认,输出 allow / ask / deny。
风险分级与权限策略取值见契约字典(risk_level L0–L4、permission_policy
allow/ask/always_ask/forbidden);决策枚举对齐 AuditEvent.decision(allow/deny/ask)。
策略来源:``SafetyGovernor()`` 用代码内置默认;``from_policy_dict(data)`` 从已解析字典
加载(文件 IO 由 ``services/safety.load_safety_governor`` 承担,本模块保持纯领域、零 IO)。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class PermissionPolicy(StrEnum):
allow = "allow"
ask = "ask"
always_ask = "always_ask"
forbidden = "forbidden"
class SafetyDecision(StrEnum):
allow = "allow"
ask = "ask"
deny = "deny"
# 风险等级排序,便于阈值判断
RISK_RANK: dict[str, int] = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
def default_policy(risk_level: str) -> PermissionPolicy:
"""无显式工具策略时,按风险等级推导默认策略。"""
rank = RISK_RANK.get(risk_level, 2)
if rank >= 4:
return PermissionPolicy.forbidden
if rank >= 3:
return PermissionPolicy.always_ask
if rank >= 2:
return PermissionPolicy.ask
return PermissionPolicy.allow
@dataclass(frozen=True)
class ToolPolicy:
"""工具的固定策略(由工具定义或配置提供)。"""
risk_level: str # L0–L4
permission_policy: PermissionPolicy
@dataclass(frozen=True)
class SafetyResult:
decision: SafetyDecision
reason: str
risk_level: str
policy: PermissionPolicy
class SafetyGovernor:
"""司命决策器。无状态、同步——便于测试与上层组合确认流。"""
def __init__(self, policies: dict[str, ToolPolicy] | None = None) -> None:
self._policies: dict[str, ToolPolicy] = dict(policies or {})
@classmethod
def from_policy_dict(cls, data: dict) -> SafetyGovernor:
"""从已解析的策略字典构造 Governor(纯逻辑,不读文件)。
文件读取由 ``services/safety.load_safety_governor`` 承担,调用方传入已
``yaml.safe_load`` 的字典;空字典或缺 ``tools`` 返回默认(空策略)Governor。
"""
policies: dict[str, ToolPolicy] = {}
tools = data.get("tools") if isinstance(data, dict) else None
if isinstance(tools, dict):
for name, cfg in tools.items():
if not isinstance(cfg, dict):
continue
policies[name] = ToolPolicy(
risk_level=str(cfg.get("risk_level", "L2")),
permission_policy=PermissionPolicy(str(cfg.get("permission_policy", "ask"))),
)
return cls(policies)
def evaluate(
self,
*,
tool: str,
risk_level: str | None = None,
confirmed: bool = False,
) -> SafetyResult:
policy = self._policies.get(tool)
if policy is None:
resolved_risk = risk_level or "L2"
perm = default_policy(resolved_risk)
else:
resolved_risk = policy.risk_level
perm = policy.permission_policy
# L4 一律禁止,即便策略写 allow
if RISK_RANK.get(resolved_risk, 2) >= 4:
return SafetyResult(
SafetyDecision.deny, "L4 风险禁止执行", resolved_risk, PermissionPolicy.forbidden
)
if perm is PermissionPolicy.forbidden:
return SafetyResult(SafetyDecision.deny, "工具策略为 forbidden", resolved_risk, perm)
if perm is PermissionPolicy.allow:
return SafetyResult(SafetyDecision.allow, "策略放行", resolved_risk, perm)
# ask / always_ask
if confirmed:
return SafetyResult(SafetyDecision.allow, "用户已确认", resolved_risk, perm)
return SafetyResult(
SafetyDecision.ask, f"策略 {perm.value} 需用户确认", resolved_risk, perm
)