初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,31 @@
|
||||
"""司命枢:权限、风险与审批(见 safety-governor-design)。"""
|
||||
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
InMemoryConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation, SafetyError
|
||||
from app.modules.safety.governor import (
|
||||
PermissionPolicy,
|
||||
SafetyDecision,
|
||||
SafetyGovernor,
|
||||
SafetyResult,
|
||||
ToolPolicy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationStore",
|
||||
"InMemoryConfirmationStore",
|
||||
"PendingConfirmation",
|
||||
"PermissionDenied",
|
||||
"PermissionPolicy",
|
||||
"RequiresConfirmation",
|
||||
"SafetyDecision",
|
||||
"SafetyError",
|
||||
"SafetyGovernor",
|
||||
"SafetyResult",
|
||||
"ToolPolicy",
|
||||
"new_confirmation_id",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""待确认动作的存储(高风险工具调用的确认中转)。
|
||||
|
||||
司命决策为 ask 时不直接执行,而是登记一个 PendingConfirmation 并返回 confirmation_id;
|
||||
前端展示确认卡片,用户 approve/deny 后由安全 API 消费。
|
||||
|
||||
提供两种实现:
|
||||
- ``InMemoryConfirmationStore``:进程内 dict,默认开发模式,不依赖 DB
|
||||
- ``DbConfirmationStore``:数据库持久化,生产多 worker 模式,确认项跨进程可见
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def new_confirmation_id() -> str:
|
||||
return f"confirm_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingConfirmation:
|
||||
"""一次待确认的工具调用。"""
|
||||
|
||||
confirmation_id: str
|
||||
tool: str
|
||||
input: dict[str, Any]
|
||||
risk_level: str
|
||||
reason: str
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
created_at: str = field(default_factory=_now_iso)
|
||||
|
||||
|
||||
class ConfirmationStore(Protocol):
|
||||
"""确认存储协议(全异步接口)。"""
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation: ...
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]: ...
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryConfirmationStore:
|
||||
"""内存确认存储,供本地与测试使用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, PendingConfirmation] = {}
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
self._items[pending.confirmation_id] = pending
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.get(confirmation_id)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.pop(confirmation_id, None)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
return sorted(self._items.values(), key=lambda item: item.created_at)
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
self._items.pop(confirmation_id, None)
|
||||
|
||||
|
||||
class DbConfirmationStore:
|
||||
"""数据库确认存储,供多 worker 生产环境使用。
|
||||
|
||||
待确认项持久化到 ``pending_confirmations`` 表,跨进程可见。
|
||||
需要从 ``app.state.session_factory`` 或 ``async_sessionmaker`` 注入。
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
async with self._session_factory() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input, ensure_ascii=False),
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "consumed"
|
||||
await session.commit()
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord)
|
||||
.where(PendingConfirmationRecord.status == "pending")
|
||||
.order_by(PendingConfirmationRecord.created_at.asc())
|
||||
)
|
||||
records: Sequence[PendingConfirmationRecord] = result.scalars().all()
|
||||
return [self._record_to_pending(r) for r in records]
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
"""将待确认记录标记为超时(Agent 循环确认等待超时)。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return
|
||||
record.status = "timed_out"
|
||||
record.resolved_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _record_to_pending(record: PendingConfirmationRecord) -> PendingConfirmation:
|
||||
return PendingConfirmation(
|
||||
confirmation_id=record.id,
|
||||
tool=record.tool,
|
||||
input=json.loads(record.input_json) if record.input_json else {},
|
||||
risk_level=record.risk_level,
|
||||
reason=record.reason,
|
||||
run_id=record.run_id,
|
||||
character_id=record.character_id,
|
||||
trace_id=record.trace_id,
|
||||
created_at=record.created_at.isoformat() if record.created_at else _now_iso(),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""司命异常。错误码映射契约字典 API 标准错误码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SafetyError(Exception):
|
||||
"""司命决策异常基类。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class PermissionDenied(SafetyError):
|
||||
"""动作被司命拒绝(映射 permission_denied)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__("permission_denied", message)
|
||||
|
||||
|
||||
class RequiresConfirmation(SafetyError):
|
||||
"""高风险动作需用户确认(映射 requires_confirmation)。"""
|
||||
|
||||
def __init__(self, message: str, *, risk_level: str | None = None) -> None:
|
||||
super().__init__("requires_confirmation", message)
|
||||
self.risk_level = risk_level
|
||||
@@ -0,0 +1,115 @@
|
||||
"""司命决策器:按工具策略 + 风险等级 + 是否已确认,输出 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
|
||||
)
|
||||
Reference in new issue
Block a user