初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+31
View File
@@ -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",
]
+178
View File
@@ -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(),
)
+30
View File
@@ -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
+115
View File
@@ -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
)