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 个预存环境失败不变)
This commit is contained in:
chenxin committed 2026-08-27 19:17:21 +08:00
1 parent 144415247e
commit 684261f4b4
97 files changed
+3075 -5554

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
"""百工域:能力注册、授契/行契、请命与行动账(rewrite/1.0 M5)。
- models:capabilities / mandates / tool_calls(请命复用 pending_confirmations,迁移 006 增列);
- siming:司命裁决器——禁域为代码常量,任何 Profile/插件/配置不可关闭;
- executor:执行分派(builtin / qiankun 乾坤 HMAC / mcp / http);
- sanitize:结果净化与观察标证(观察非事实,不自动入忆);
- api:/capabilities /mandates /petitions /tool-calls。
"""
from app.domains.baigong import executor, models, sanitize, siming # noqa: F401
__all__ = ["executor", "models", "sanitize", "siming"]
+179
View File
@@ -0,0 +1,179 @@
"""百工与司命 API(契约词典 §5.9 pivot=capability_registry)。
- 能力目录与调用:GET/POST /capabilities、POST /capabilities/{id}/invoke;
- 授契与行契:GET/POST /mandates、DELETE /mandates/{id}(撤销立即生效);
- 请命:GET /petitions、POST /petitions/{id}/approve|deny(超时=拒绝执行 HJ-1302);
- 行动账:GET /tool-calls(净化摘要+观察标证)。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.baigong import service
from app.schemas.auth import CurrentUser
cap_router = APIRouter(prefix="/capabilities", tags=["baigong"])
mandate_router = APIRouter(prefix="/mandates", tags=["baigong"])
petition_router = APIRouter(prefix="/petitions", tags=["baigong"])
ledger_router = APIRouter(prefix="/tool-calls", tags=["baigong"])
class InvokeRequest(BaseModel):
params: dict = Field(default_factory=dict, description="能力参数。")
run_id: str | None = None
character_id: str | None = None
trace_id: str | None = None
confirmed: bool = Field(default=False, description="请命批准后的当次执行回执。")
class CreateCapabilityRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str = ""
executor_type: str = Field(description="builtin / qiankun / mcp / http")
executor_ref: str = ""
params_schema: dict = Field(default_factory=dict)
risk_dims: dict = Field(default_factory=dict)
data_scope: str = ""
destinations: str = ""
consent_mode: str = "allow"
source: str = "user_custom"
class GrantMandateRequest(BaseModel):
capability_id: str
kind: str = Field(default="grant", description="grant 授契 / charter 行契")
character_id: str | None = None
scope: dict = Field(default_factory=dict)
budget_total: int = Field(default=1, ge=0)
expires_at: datetime | None = None
# ── 能力目录 ──
@cap_router.get("")
async def list_capabilities(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_capabilities(user.owner_scope)
@cap_router.post("", status_code=status.HTTP_201_CREATED)
async def create_capability(
payload: CreateCapabilityRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
import json
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability
if payload.executor_type not in ("builtin", "qiankun", "mcp", "http"):
from app.core.errors import AppError
raise AppError("invalid_request", "executor_type 须为 builtin/qiankun/mcp/http")
async with AsyncSessionLocal() as session:
cap = Capability(
name=payload.name,
owner_scope=user.owner_scope,
description=payload.description,
executor_type=payload.executor_type,
executor_ref=payload.executor_ref,
params_schema_json=json.dumps(payload.params_schema, ensure_ascii=False),
risk_dims_json=json.dumps(payload.risk_dims, ensure_ascii=False),
data_scope=payload.data_scope,
destinations=payload.destinations,
consent_mode=payload.consent_mode,
source=payload.source,
)
session.add(cap)
try:
await session.commit()
except Exception as exc: # noqa: BLE001
from app.core.errors import AppError
await session.rollback()
raise AppError("invalid_request", f"能力登记失败(名称重复?):{exc}") from None
return {"id": cap.id, "name": cap.name, "status": "registered"}
@cap_router.post("/{capability_id}/invoke")
async def invoke_capability(
capability_id: str,
payload: InvokeRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await service.invoke(
request,
capability_id,
payload.params,
user=user,
run_id=payload.run_id,
character_id=payload.character_id,
trace_id=payload.trace_id,
confirmed=payload.confirmed,
)
# ── 授契与行契 ──
@mandate_router.get("")
async def list_mandates(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_mandates(user.owner_scope)
@mandate_router.post("", status_code=status.HTTP_201_CREATED)
async def grant_mandate(
payload: GrantMandateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.grant_mandate(
user.owner_scope,
capability_id=payload.capability_id,
kind=payload.kind,
character_id=payload.character_id,
scope=payload.scope,
budget_total=payload.budget_total,
expires_at=payload.expires_at,
)
@mandate_router.delete("/{mandate_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_mandate(
mandate_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await service.revoke_mandate(user.owner_scope, mandate_id)
# ── 请命 ──
@petition_router.get("")
async def list_petitions(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_petitions()
@petition_router.post("/{petition_id}/approve")
async def approve_petition(
petition_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.resolve_petition(petition_id, approved=True)
@petition_router.post("/{petition_id}/deny")
async def deny_petition(petition_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.resolve_petition(petition_id, approved=False)
# ── 行动账 ──
@ledger_router.get("")
async def list_tool_calls(
user: CurrentUser = Depends(get_current_user), limit: int = 100
) -> list[dict]:
return await service.list_tool_calls(user.owner_scope, limit=limit)
routers = [cap_router, mandate_router, petition_router, ledger_router]
+115
View File
@@ -0,0 +1,115 @@
"""百工执行器:按能力执行类型分派(builtin / qiankun 乾坤 / mcp / http)。
乾坤经 ``integrations/sandbox`` 的 SandboxClient——HMAC 短期凭证授契由沙箱客户端
签发(SANDBOX_AUTH_SECRET 两端一致),执行审计证据经行动账落库。
"""
from __future__ import annotations
import json
import shlex
from dataclasses import dataclass
from typing import Any
import httpx
from sqlalchemy import select
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability
from app.integrations.mcp.client import call_tool
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.models import ExecRequest as SandboxExecRequest
_HTTP_TIMEOUT_SECONDS = 30.0
@dataclass
class ExecOutcome:
ok: bool
output: str
error_code: str | None = None
duration_ms: int = 0
class Executor:
"""能力执行分派器。构造时注入乾坤客户端(未配置则乾坤类能力 503)。"""
def __init__(self, sandbox: SandboxClient | None, http: httpx.AsyncClient | None = None) -> None:
self._sandbox = sandbox
self._http = http
async def execute(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
dispatch = {
"builtin": self._exec_builtin,
"qiankun": self._exec_qiankun,
"mcp": self._exec_mcp,
"http": self._exec_http,
}
handler = dispatch.get(capability.executor_type)
if handler is None:
raise AppError("invalid_request", f"未知执行类型:{capability.executor_type}")
return await handler(capability, params)
# ── 内置:无副作用纯计算(时间/回显等) ──
async def _exec_builtin(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
from app.core.time import to_iso, utc_now
if capability.executor_ref == "echo":
return ExecOutcome(ok=True, output=f"[echo] {params.get('text', '')}")
if capability.executor_ref == "datetime.now":
return ExecOutcome(ok=True, output=to_iso(utc_now()))
raise AppError("not_found", f"内置能力不存在:{capability.executor_ref}")
# ── 乾坤:不可信执行隔离(HMAC 授契由 SandboxClient 签发) ──
async def _exec_qiankun(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
if self._sandbox is None:
raise AppError(
"service_unavailable", "乾坤沙箱未就绪(SANDBOX 未配置),无法执行。", status_code=503
)
command = str(params.get("command", ""))
if not command:
raise AppError("invalid_request", "qiankun 能力需要 command 参数")
result = await self._sandbox.exec(SandboxExecRequest(argv=shlex.split(command)))
output = result.stdout or result.stderr or ""
return ExecOutcome(
ok=result.ok,
output=output,
error_code=None if result.ok else "qiankun_exit",
duration_ms=result.duration_ms,
)
# ── MCP:外部能力服务(博闻等);executor_ref = mcp_servers.name ──
async def _exec_mcp(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
from app.models.mcp_servers import McpServer
async with AsyncSessionLocal() as session:
server = (
await session.execute(
select(McpServer).where(McpServer.name == capability.executor_ref)
)
).scalars().first()
if server is None or not server.endpoint_url:
raise AppError(
"not_found", f"MCP 服务未登记或无端点:{capability.executor_ref}"
)
endpoint, token = server.endpoint_url, server.auth_token
session.expunge(server)
client = self._http or httpx.AsyncClient(trust_env=False, timeout=_HTTP_TIMEOUT_SECONDS)
result = await call_tool(client, endpoint, capability.name.removeprefix(f"mcp_{capability.executor_ref}_"), params, token)
payload = getattr(result, "content", None) or result
return ExecOutcome(ok=True, output=json.dumps(payload, ensure_ascii=False, default=str))
# ── HTTP:受授契约束的外部 HTTP 动作 ──
async def _exec_http(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
url = str(params.get("url", ""))
if not url:
raise AppError("invalid_request", "http 能力需要 url 参数")
async with httpx.AsyncClient(trust_env=False, timeout=_HTTP_TIMEOUT_SECONDS) as client:
resp = await client.request(str(params.get("method", "GET")).upper(), url)
return ExecOutcome(
ok=resp.status_code < 400,
output=f"HTTP {resp.status_code}: {resp.text[:500]}",
error_code=None if resp.status_code < 400 else f"http_{resp.status_code}",
)
+86
View File
@@ -0,0 +1,86 @@
"""乾坤沙箱工作区文件读取(百工执行产物预览)。
隔离前缀语义:workspace/<subject>/(subject=character_id,一角色一持久工作区);
subject 经白名单替换杜绝路径注入,沙箱端另有 canonicalize 校验兜底。
"""
from __future__ import annotations
import re
import uuid
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.models import (
DirEntry,
FileListRequest,
FileReadRequest,
FileReadResponse,
)
_ISOLATE_SAFE = re.compile(r"[^A-Za-z0-9_.-]")
def isolate_workdir(
base: str | None,
*,
subject_id: str | None,
run_id: str | None,
trace_id: str | None,
) -> str:
"""把工作区根目录隔离到主体级子目录,防跨主体信息泄漏。"""
root = base or "workspace"
key = subject_id or run_id or trace_id or f"adhoc_{uuid.uuid4().hex[:8]}"
safe = _ISOLATE_SAFE.sub("_", key).strip("._-") or "adhoc"
return f"{root}/{safe}"
async def read_sandbox_file(
sandbox: SandboxClient,
path: str,
*,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
isolated: bool = True,
) -> FileReadResponse:
effective = path
if isolated:
workdir = isolate_workdir("workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id)
effective = f"{workdir}/{path.lstrip('/')}"
return await sandbox.file_read(
FileReadRequest(path=effective),
AuthorizationContext(
trace_id=trace_id or str(uuid.uuid4()),
risk_level="L0",
tool="file_read",
character_id=character_id,
run_id=run_id,
),
)
async def list_sandbox_dir(
sandbox: SandboxClient,
path: str = ".",
*,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
isolated: bool = True,
) -> list[DirEntry]:
effective = path
if isolated:
workdir = isolate_workdir("workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id)
effective = f"{workdir}/{path.lstrip('/')}"
result = await sandbox.file_list(
FileListRequest(path=effective),
AuthorizationContext(
trace_id=trace_id or str(uuid.uuid4()),
risk_level="L0",
tool="file_list",
character_id=character_id,
run_id=run_id,
),
)
return result.entries
+110
View File
@@ -0,0 +1,110 @@
"""百工域表模型:能力注册表 / 授契与行契 / 调用记录。
设计依据 docs/domain/mandate-and-capability-model.md 与 docs/architecture/data-model.md §6:
- ``capabilities`` 统一能力契约(工具/MCP/Skill/感官/形器),保留执行类型;
- ``mandates`` 授契(单次范围)与行契(重复执行:范围/预算/时间窗),撤销立即生效;
- ``tool_calls`` 调用记录与证据链(净化后的输入输出摘要 + 观察标证);
- 请命复用 ``pending_confirmations`` 表(models/safety.py,迁移 006 增列改造)。
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
# 执行类型(data-model §6):HTTP / MCP / 乾坤 / 内置
EXECUTOR_TYPES = ("builtin", "qiankun", "mcp", "http")
# 四维风险标注(mandate-and-capability-model.md §3):能力/数据/后果/情境
RISK_DIMENSIONS = ("capability", "data", "consequence", "context")
class Capability(Base):
"""百工能力注册表:稳定标识 + 参数 Schema + 执行类型 + 四维风险标注。"""
__tablename__ = "capabilities"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cap"))
# 稳定标识(契约词典 §5:capability_registry);同 owner 内唯一
name: Mapped[str] = mapped_column(String(120), nullable=False)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 执行类型:builtin / qiankun / mcp / http
executor_type: Mapped[str] = mapped_column(String(20), nullable=False)
# 执行器绑定(builtin 工具名 / MCP server id / 乾坤命令模板等)
executor_ref: Mapped[str] = mapped_column(String(200), nullable=False, default="")
# 参数 Schema(JSON Schema 子集;供校验与 UI 表单)
params_schema_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 四维风险标注:{"capability": "L1", "data": "L2", "consequence": "L0", "context": "L1"}
risk_dims_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 读取/传出/修改的数据范围与外部目的地(能力契约展示)
data_scope: Mapped[str] = mapped_column(Text, nullable=False, default="")
destinations: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 来源与版本:builtin / official_signed / user_custom
source: Mapped[str] = mapped_column(String(20), nullable=False, default="builtin")
version: Mapped[str] = mapped_column(String(40), nullable=False, default="1")
# 司命裁决模式:allow(授契即行)/ ask(每次请命);禁域由裁决器硬编码,不落库不可配
consent_mode: Mapped[str] = mapped_column(String(10), nullable=False, default="allow")
enabled: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
__table_args__ = (
Index("uq_capabilities_owner_name", "owner_scope", "name", unique=True),
)
class Mandate(Base):
"""授契(grant:单次范围)与行契(charter:限定条件内重复执行)。"""
__tablename__ = "mandates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("mdt"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 授予人物(空 = 用户全局,所有人物可用)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
capability_id: Mapped[str] = mapped_column(
String(80), ForeignKey("capabilities.id"), nullable=False, index=True
)
# grant(授契:范围用完即止)/ charter(行契:条件内重复执行)
kind: Mapped[str] = mapped_column(String(10), nullable=False, default="grant")
# 范围(允许的参数/数据/目标约束,JSON;行契含预算与停止条件)
scope_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
# 行契:时间窗与预算(次数上限;0 = 不限次但受停止条件约束)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
budget_total: Mapped[int] = mapped_column(nullable=False, default=1)
budget_used: Mapped[int] = mapped_column(nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ToolCall(Base):
"""调用记录与证据链:净化后的输入输出摘要 + 观察标证(不落原文)。"""
__tablename__ = "tool_calls"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("tc"))
run_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("runs.id"), index=True)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True
)
capability_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
trace_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 司命裁决:allow / ask_approved / denied_forbidden(HJ-1301) / denied_unmandated(HJ-1303)
decision: Mapped[str] = mapped_column(String(40), nullable=False)
# 命中的授契/行契(拒绝时为空)
mandate_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ok")
error_code: Mapped[str | None] = mapped_column(String(40))
# 净化后摘要(体量受限,不含原文全文)
input_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
output_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 观察标证:来源、执行条件、观察语义(非事实)——供灵台作为观察接纳
evidence_json: Mapped[str] = mapped_column(JSON, nullable=False, default=dict)
duration_ms: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
+52
View File
@@ -0,0 +1,52 @@
"""结果净化与观察标证(mandate-and-capability-model.md §6)。
能力返回不可信输入:截断体量、去除敏感材料、标注来源与执行条件;
结果以「观察」进入灵台,接纳不等于成为事实/答复/记忆。
"""
from __future__ import annotations
import json
import re
from typing import Any
MAX_DIGEST_CHARS = 2000
_REDACT = re.compile(r"sk-[a-zA-Z0-9]{8,}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|Bearer\s+[a-zA-Z0-9._-]{12,}")
def redact(text: str) -> str:
"""敏感材料遮蔽(密钥/凭证形态)。"""
return _REDACT.sub("[已遮蔽]", text)
def digest(value: Any, limit: int = MAX_DIGEST_CHARS) -> str:
"""净化摘要:脱敏 + 截断(超长以省略号收尾,不落原文全文)。"""
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
text = redact(text)
if len(text) > limit:
return text[:limit] + "…[截断]"
return text
def observation(
*,
capability_name: str,
executor_type: str,
output_digest: str,
trace_id: str | None,
decision: str,
duration_ms: int,
) -> dict:
"""观察标证:灵台接纳工具结果的唯一形态(kind=observation,非事实非记忆)。"""
return {
"kind": "observation",
"capability": capability_name,
"executor": executor_type,
"trace_id": trace_id,
"decision": decision,
"duration_ms": duration_ms,
"output": output_digest,
# 认识论声明:观察被接纳不等于成为事实/答复/记忆(分别受认识论/镜树归真/记忆规则约束)
"epistemic": "observation_not_fact",
}
+497
View File
@@ -0,0 +1,497 @@
"""百工域应用服务:能力目录 / 授契与行契 / 请命 / 调用全链 / 行动账。
调用全链(mandate-and-capability-model.md §1 流程图):
禁域(代码常量,最先且不可绕过)→ 司命裁决(授契/行契/四维)→ 请命(超时不执行)
→ 隔离执行(乾坤 HMAC / MCP / 内置)→ 净化标证 → 行动账与调用记录落库。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import Request
from sqlalchemy import select
from app.core.errors import AppError
from app.core.ids import new_confirmation_id
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains.baigong import siming
from app.domains.baigong.executor import ExecOutcome, Executor
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.baigong.sanitize import digest, observation, redact
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.schemas.auth import CurrentUser
async def _get_capability(capability_id: str, owner_scope: str) -> Capability:
async with AsyncSessionLocal() as session:
cap = await session.get(Capability, capability_id)
if cap is None or cap.owner_scope != owner_scope:
raise AppError("not_found", "能力不存在。", status_code=404)
session.expunge(cap)
return cap
async def _audit(
*,
action: str,
resource_id: str,
decision: str,
risk_level: str,
reason: str,
input_summary: str,
output_summary: str,
run_id: str | None,
character_id: str | None,
trace_id: str | None,
) -> None:
async with AsyncSessionLocal() as session:
session.add(
AuditEvent(
pivot="capability_registry",
action=action,
actor="baigong",
risk_level=risk_level,
decision=decision,
reason=reason,
input_summary=input_summary,
output_summary=output_summary,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
resource_type="capability",
resource_id=resource_id,
)
)
await session.commit()
async def _record_call(
*,
capability: Capability,
decision: str,
mandate_id: str | None,
status: str,
error_code: str | None,
params: dict[str, Any],
outcome: ExecOutcome | None,
run_id: str | None,
character_id: str | None,
trace_id: str | None,
duration_ms: int,
) -> ToolCall:
obs = observation(
capability_name=capability.name,
executor_type=capability.executor_type,
output_digest=outcome.output if outcome else "",
trace_id=trace_id,
decision=decision,
duration_ms=duration_ms,
)
async with AsyncSessionLocal() as session:
call = ToolCall(
run_id=run_id,
character_id=character_id,
capability_id=capability.id,
trace_id=trace_id,
decision=decision,
mandate_id=mandate_id,
status=status,
error_code=error_code,
input_digest=digest(params, limit=500),
output_digest=digest(outcome.output if outcome else ""),
evidence_json=obs,
duration_ms=duration_ms,
)
session.add(call)
await session.commit()
session.expunge(call)
return call
async def invoke(
request: Request,
capability_id: str,
params: dict[str, Any],
*,
user: CurrentUser,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
confirmed: bool = False,
) -> dict[str, Any]:
"""能力调用全链。返回 allow 执行结果 / requires_petition 请命 / 拒绝抛错。"""
capability = await _get_capability(capability_id, user.owner_scope)
# ① 禁域最先:任何配置、标注与确认都不能越过
siming.check_forbidden(capability, params)
# ② 司命裁决:授契/行契/四维
ruling = await siming.rule(
capability,
params,
owner_scope=user.owner_scope,
character_id=character_id,
confirmed=confirmed,
)
if ruling.decision == "denied":
await _audit(
action="capability.denied",
resource_id=capability.name,
decision="denied",
risk_level=ruling.risk_level,
reason=ruling.reason,
input_summary=digest(params, limit=500),
output_summary="",
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
await _record_call(
capability=capability,
decision="denied_unmandated" if "未授契" in ruling.reason else "denied",
mandate_id=None,
status="rejected",
error_code="HJ-1303",
params=params,
outcome=None,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=0,
)
raise AppError("HJ-1303", ruling.reason, status_code=403)
if ruling.decision == "ask":
petition_id = new_confirmation_id()
deadline = siming.petition_deadline()
brief = {
# 请命说明六要素:谁在做/做什么/用什么/去哪里/后果/替代
"who": f"character:{character_id or 'user'}",
"what": capability.description or capability.name,
"capability": capability.name,
"executor": capability.executor_type,
"destinations": capability.destinations,
"consequences": f"风险等级 {ruling.risk_level}",
"alternatives": "拒绝后可调整参数或改用低风险能力",
"params": digest(params, limit=500),
}
async with AsyncSessionLocal() as session:
session.add(
PendingConfirmationRecord(
id=petition_id,
tool=capability.name,
input_json=json.dumps(params, ensure_ascii=False, default=str),
risk_level=ruling.risk_level,
reason="请命:高风险动作需当次确认",
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
expires_at=deadline,
brief_json=json.dumps(brief, ensure_ascii=False),
)
)
await session.commit()
await _record_call(
capability=capability,
decision="ask",
mandate_id=ruling.mandate.id if ruling.mandate else None,
status="petition_pending",
error_code=None,
params=params,
outcome=None,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=0,
)
return {
"status": "requires_petition",
"petition_id": petition_id,
"risk_level": ruling.risk_level,
"expires_at": deadline.isoformat(),
"brief": brief,
}
# ③ 执行 → 净化标证 → 落库
executor: Executor = request.app.state.baigong_executor
started = utc_now()
try:
outcome = await executor.execute(capability, params)
except AppError:
raise
duration_ms = (utc_now() - started).microseconds // 1000 + (
outcome.duration_ms if "outcome" in locals() else 0
)
decision = "ask_approved" if confirmed else "allow"
call = await _record_call(
capability=capability,
decision=decision,
mandate_id=ruling.mandate.id if ruling.mandate else None,
status="ok" if outcome.ok else "failed",
error_code=outcome.error_code,
params=params,
outcome=outcome,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=duration_ms,
)
# 行契预算核销
if ruling.mandate is not None and ruling.mandate.budget_total > 0:
async with AsyncSessionLocal() as session:
mandate = await session.get(Mandate, ruling.mandate.id)
if mandate is not None:
mandate.budget_used += 1
await session.commit()
await _audit(
action="capability.executed",
resource_id=capability.name,
decision="allow",
risk_level=ruling.risk_level,
reason=f"mandate={ruling.mandate.id if ruling.mandate else 'none'}",
input_summary=call.input_digest,
output_summary=call.output_digest,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
return {
"status": "ok" if outcome.ok else "failed",
"call_id": call.id,
"observation": call.evidence_json,
}
async def resolve_petition(petition_id: str, approved: bool) -> dict[str, Any]:
"""请命裁决:approve 允许当次执行(客户端以 confirmed=True 重调);超时拒绝。"""
async with AsyncSessionLocal() as session:
record = await session.get(PendingConfirmationRecord, petition_id)
if record is None or record.status != "pending":
raise AppError("not_found", "请命不存在或已处理。", status_code=404)
expires = record.expires_at
if expires is not None:
if expires.tzinfo is None: # SQLite 回读 naive(开发档)
expires = expires.replace(tzinfo=UTC)
if expires is not None and expires <= datetime.now(UTC):
record.status = "timed_out"
record.resolved_at = utc_now()
await session.commit()
raise AppError("HJ-1302", "请命超时未确认,未执行。", status_code=410)
record.status = "approved" if approved else "denied"
record.resolved_at = utc_now()
await session.commit()
return {
"status": "resolved" if approved else "cancelled",
"approved": approved,
"capability": redact(record.tool),
"run_id": record.run_id,
}
# ── 查询面 ──
async def list_capabilities(owner_scope: str) -> list[dict[str, Any]]:
async with AsyncSessionLocal() as session:
caps = (
(
await session.execute(
select(Capability).where(Capability.owner_scope == owner_scope)
)
)
.scalars()
.all()
)
mandates = (
(
await session.execute(
select(Mandate).where(
Mandate.owner_scope == owner_scope, Mandate.status == "active"
)
)
)
.scalars()
.all()
)
mandated_ids = {m.capability_id for m in mandates}
return [
{
"id": c.id,
"name": c.name,
"description": c.description,
"executor_type": c.executor_type,
"executor_ref": c.executor_ref,
"risk_dims": json.loads(c.risk_dims_json or "{}"),
"data_scope": c.data_scope,
"destinations": c.destinations,
"source": c.source,
"version": c.version,
"consent_mode": c.consent_mode,
"enabled": c.enabled,
"mandated": c.id in mandated_ids,
}
for c in caps
]
async def grant_mandate(
owner_scope: str,
*,
capability_id: str,
kind: str,
character_id: str | None,
scope: dict,
budget_total: int,
expires_at: datetime | None,
) -> dict[str, Any]:
await _get_capability(capability_id, owner_scope)
if kind not in ("grant", "charter"):
raise AppError("invalid_request", "kind 须为 grant / charter")
async with AsyncSessionLocal() as session:
mandate = Mandate(
owner_scope=owner_scope,
capability_id=capability_id,
kind=kind,
scope_json=scope,
character_id=character_id,
budget_total=budget_total,
expires_at=expires_at,
)
session.add(mandate)
await session.commit()
session.expunge(mandate)
return {
"id": mandate.id,
"capability_id": capability_id,
"kind": kind,
"budget_total": budget_total,
"budget_used": 0,
"status": mandate.status,
}
async def revoke_mandate(owner_scope: str, mandate_id: str) -> None:
"""撤销授契:立即生效(后续裁决不再命中)。"""
async with AsyncSessionLocal() as session:
mandate = await session.get(Mandate, mandate_id)
if mandate is None or mandate.owner_scope != owner_scope:
raise AppError("not_found", "授契不存在。", status_code=404)
mandate.status = "revoked"
mandate.revoked_at = utc_now()
await session.commit()
async def list_mandates(owner_scope: str) -> list[dict[str, Any]]:
async with AsyncSessionLocal() as session:
rows = (
(
await session.execute(
select(Mandate)
.where(Mandate.owner_scope == owner_scope)
.order_by(Mandate.granted_at.desc())
)
)
.scalars()
.all()
)
return [
{
"id": m.id,
"capability_id": m.capability_id,
"character_id": m.character_id,
"kind": m.kind,
"scope": m.scope_json,
"budget_total": m.budget_total,
"budget_used": m.budget_used,
"status": m.status,
"granted_at": m.granted_at.isoformat() if m.granted_at else None,
"expires_at": m.expires_at.isoformat() if m.expires_at else None,
}
for m in rows
]
async def list_petitions(owner_scope: str | None = None) -> list[dict[str, Any]]:
"""待决请命清单(行动账待办)。"""
async with AsyncSessionLocal() as session:
rows = (
(
await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.desc())
)
)
.scalars()
.all()
)
out = []
for r in rows:
try:
brief = json.loads(r.brief_json or "{}")
except ValueError:
brief = {}
out.append(
{
"id": r.id,
"capability": r.tool,
"risk_level": r.risk_level,
"reason": r.reason,
"brief": brief,
"run_id": r.run_id,
"character_id": r.character_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"expires_at": r.expires_at.isoformat() if r.expires_at else None,
}
)
return out
async def list_tool_calls(owner_scope: str, limit: int = 100) -> list[dict[str, Any]]:
"""行动账:调用记录(净化摘要 + 观察标证)。"""
async with AsyncSessionLocal() as session:
cap_ids = set(
(
await session.execute(
select(Capability.id).where(Capability.owner_scope == owner_scope)
)
)
.scalars()
.all()
)
rows = (
(
await session.execute(
select(ToolCall).order_by(ToolCall.created_at.desc()).limit(limit)
)
)
.scalars()
.all()
)
return [
{
"id": t.id,
"capability_id": t.capability_id,
"run_id": t.run_id,
"character_id": t.character_id,
"trace_id": t.trace_id,
"decision": t.decision,
"mandate_id": t.mandate_id,
"status": t.status,
"error_code": t.error_code,
"input_digest": t.input_digest,
"output_digest": t.output_digest,
"evidence": t.evidence_json,
"duration_ms": t.duration_ms,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in rows
if t.capability_id in cap_ids
]
+160
View File
@@ -0,0 +1,160 @@
"""司命裁决器:行动前的四维裁决与禁域否决(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