Files
hunjing/backend/app/domains/baigong/siming.py
T
chenxin 684261f4b4 M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
2026-08-27 19:17:21 +08:00

161 lines
6.0 KiB
Python
Raw 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.
"""司命裁决器:行动前的四维裁决与禁域否决(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