真实进程冒烟(灵台+神识起进程 → 登录 → 能力目录 → 授契 → 调用)抓到三处缺陷,全量测试因分支未覆盖而漏网: ① 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 接真神识)
517 lines
17 KiB
Python
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
|
|
]
|