"""百工域应用服务:能力目录 / 授契与行契 / 请命 / 调用全链 / 行动账。 调用全链(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 ]