"""司命裁决器:行动前的四维裁决与禁域否决(mandate-and-capability-model.md §3/§4)。 裁决顺序(后者不可被前者越过): 1. **禁域**:代码常量,无任何配置/Profile/插件写入路径——Prompt、人物、模型、扩展、 普通管理员与自托管配置均不能关闭(专项测试钉死); 2. **能力与授契**:能力未注册/停用/未授契 → 拒绝并留痕(HJ-1303); 3. **行契条件**:预算耗尽/过期/超范围 → 降级为请命或拒绝; 4. **请命**:consent_mode=ask 或裁决为高风险 → 登记请命,用户当次同意前不执行, 超时视为拒绝(HJ-1302)。 四维(能力/数据/后果/情境)取能力注册表的风险标注与调用上下文综合,亲密或 "过去允许过"不构成授权。 """ from __future__ import annotations import re from dataclasses import dataclass from datetime import UTC, datetime, timedelta from sqlalchemy import select from app.core.errors import AppError from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.domains.baigong.models import Capability, Mandate, RISK_DIMENSIONS # ── 禁域:不可覆盖的核心边界 ────────────────────────────── # 形态:对能力输入的硬否决规则。模块级常量,无运行时写入口; # 任何 Profile/插件/DB 配置在裁决顺序上位于其后,无法绕过。 _FORBIDDEN_SHELL = re.compile( r"rm\s+-[rfRF]+\s+/(?:\s|$)" # rm -rf / 递归删除根 r"|mkfs(\.\w+)?\s" r"|dd\s+.*of=/dev/(?:sd|disk|nvme)" r"|:\(\)\{.*\};\s*:", # fork 炸弹 ) _FORBIDDEN_URL = re.compile(r"^file://", re.IGNORECASE) # 密钥与身份材料不得经任何能力外发 _SENSITIVE_KEY = re.compile(r"sk-[a-zA-Z0-9]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----") class ForbiddenViolation(AppError): """禁域违规:HJ-1301,不可申诉绕过。""" def __init__(self, rule: str) -> None: super().__init__("HJ-1301", f"禁域裁决拒绝:{rule}", status_code=403) self.rule = rule def check_forbidden(capability: Capability, params: dict) -> None: """禁域检查:任何能力、任何参数形态都先过这里。抛 ForbiddenViolation。""" text = " ".join(str(v) for v in params.values()) if capability.executor_type == "qiankun" and _FORBIDDEN_SHELL.search(text): raise ForbiddenViolation("禁止递归删除根文件系统/格式化/写裸设备/炸弹进程") if _FORBIDDEN_URL.search(text): raise ForbiddenViolation("禁止经能力访问本机文件协议") if _SENSITIVE_KEY.search(text): raise ForbiddenViolation("禁止将密钥材料作为能力参数外发") # ── 裁决结果 ────────────────────────────── @dataclass class Ruling: """一次司命裁决的结论。""" decision: str # allow / ask / denied mandate: Mandate | None = None risk_level: str = "L1" reason: str = "" def _risk_level(dims: dict) -> str: """四维取最高档:L0 < L1 < L2 < L3 < L4。""" order = ["L0", "L1", "L2", "L3", "L4"] level = "L0" for dim in RISK_DIMENSIONS: v = str(dims.get(dim, "L1")) if v in order and order.index(v) > order.index(level): level = v return level async def _active_mandate( capability: Capability, *, owner_scope: str, character_id: str | None, now: datetime, ) -> Mandate | None: """查命中且仍有效的授契/行契:用户全局或本人物;未过期、未撤销、预算未尽。""" async with AsyncSessionLocal() as session: result = await session.execute( select(Mandate).where( Mandate.capability_id == capability.id, Mandate.owner_scope == owner_scope, Mandate.status == "active", (Mandate.character_id.is_(None)) | (Mandate.character_id == (character_id or "")), (Mandate.expires_at.is_(None)) | (Mandate.expires_at > now), (Mandate.budget_total == 0) | (Mandate.budget_used < Mandate.budget_total), ) ) mandate = result.scalars().first() if mandate is not None: session.expunge(mandate) return mandate async def rule( capability: Capability, params: dict, *, owner_scope: str, character_id: str | None, confirmed: bool = False, ) -> Ruling: """四维裁决入口。禁域在调用方(service.invoke)最先执行,这里做授契与请命裁决。""" now = datetime.now(UTC) if not capability.enabled: return Ruling(decision="denied", reason=f"能力已停用:{capability.name}") mandate = await _active_mandate( capability, owner_scope=owner_scope, character_id=character_id, now=now ) risk = _risk_level(_load_dims(capability)) if mandate is None: return Ruling( decision="denied", risk_level=risk, reason=f"未授契能力被拒:{capability.name}(HJ-1303)", ) # 行契维度:高数据/后果风险即便授契也要请命(一次确认不得自动扩大为无限行契) needs_ask = capability.consent_mode == "ask" or risk in ("L3", "L4") if needs_ask and not confirmed: return Ruling(decision="ask", mandate=mandate, risk_level=risk, reason="高风险动作需当次请命") return Ruling(decision="allow", mandate=mandate, risk_level=risk) def _load_dims(capability: Capability) -> dict: import json try: return dict(json.loads(capability.risk_dims_json or "{}")) except ValueError: return {} # ── 请命超时 ────────────────────────────── PETITION_TTL = timedelta(minutes=10) def petition_deadline() -> datetime: """请命截止时刻:超时即视为拒绝(不执行,HJ-1302)。""" return utc_now() + PETITION_TTL