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:
1 parent
144415247e
commit
684261f4b4
97 files changed
+3075
-5554
No files matched your search
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user