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

+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
]