Files
hunjing/backend/app/domains/baigong/service.py
T
chenxin 3c050b923c M9 补丁:启动冒烟修复——镜树列表 str 属性访问、内置能力平台作用域与种子提交缺失
真实进程冒烟(灵台+神识起进程 → 登录 → 能力目录 → 授契 → 调用)抓到三处缺陷,全量测试因分支未覆盖而漏网:
① mirror_tree.list_trees:select(Character.id).scalars() 已返回 id 字符串,c.id 属性访问在有 character 的 owner 上必炸(M2a 引入,测试只走了无 character 分支)——改为直接收集
② 内置能力种子写死 owner_scope=local-user,真实用户(UUID owner)查不到能力目录——引入 PLATFORM_SCOPE:builtin 能力挂平台域全局可见可授契,自定义能力仍按 owner 隔离
③ ensure_governance_seed 缺 session.commit——种子数据未提交即随启动 session 回滚(直调误读为成功因同 session 可见未提交数据)
冒烟证据:未授契调用 403 HJ-1303 留痕 → 授行契 → 调用 ok 返回观察标证;能力目录 4 项(builtin.datetime/echo/qiankun.shell/web.fetch);全量回归 349 passed 不变
另:本地 .env 补入神识段(SHENSHI_BASE_URL=127.0.0.1:8080 接真神识)
2026-08-28 10:06:35 +08:00

517 lines
17 KiB
Python

"""百工域应用服务:能力目录 / 授契与行契 / 请命 / 调用全链 / 行动账。
调用全链(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
# 内置能力挂平台作用域(全局可见可授契;自定义能力按 owner 隔离)
PLATFORM_SCOPE = "__platform__"
def _capability_visible(cap: Capability, owner_scope: str) -> bool:
return cap.owner_scope == owner_scope or (
cap.owner_scope == PLATFORM_SCOPE and cap.source == "builtin"
)
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 not _capability_visible(cap, 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 = [
c
for c in (
(
await session.execute(
select(Capability).where(
(Capability.owner_scope == owner_scope)
| (
(Capability.owner_scope == PLATFORM_SCOPE)
& (Capability.source == "builtin")
)
)
)
)
.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
]