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

+48 -36
View File
@@ -1,14 +1,13 @@
"""司命台管理聚合服务(api-spec §19.1–§19.3)。
九枢健康度由各枢运行/治理数据派生(不落长期状态,§19.1);朱批待办复用
ConfirmationStore(§19.2);起居注审计复用 audit_events(§19.3)。
待决请命(§19.2);起居注审计复用 audit_events(§19.3)。
"""
import json
from sqlalchemy import func, select
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import settings
from app.core.security import redact_text
from app.core.time import to_iso, utc_now
@@ -23,8 +22,7 @@ from app.models.governance import (
from app.models.idempotency import IdempotencyKey
from app.models.xirang import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.repositories import audit as audit_repo
from app.repositories import characters as character_repo
from app.repositories import governance as governance_repo
@@ -66,12 +64,22 @@ def _summarize_input(value: dict | None) -> str:
async def get_pivots_health(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
async with AsyncSessionLocal() as session:
pending_count = len(await confirmation_store.list_pending())
pending_count = len(
list(
(
await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.status == "pending"
)
)
)
.scalars()
.all()
)
)
goals_n = await governance_repo.count(session, Goal)
world_n = await governance_repo.count(session, WorldEntity)
plans_n = await governance_repo.count(session, Plan)
@@ -80,7 +88,7 @@ async def get_pivots_health(
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
now = to_iso(utc_now())
raw: list[tuple[str, str, str, bool, str]] = [
@@ -128,38 +136,42 @@ async def get_pivots_health(
)
async def get_pending_approvals(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
) -> list[PendingApproval]:
specs = {spec.name: spec for spec in tool_runtime.list_specs()} if tool_runtime else {}
async def get_pending_approvals() -> list[PendingApproval]:
async with AsyncSessionLocal() as session:
records = (
(
await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.asc())
)
)
.scalars()
.all()
)
approvals: list[PendingApproval] = []
for pending in await confirmation_store.list_pending():
spec = specs.get(pending.tool)
permission_policy = str(spec.permission_policy) if spec else "ask"
possible_impact = "可能产生副作用" if (spec and spec.side_effect) else None
approvals.append(_pending_to_approval(pending, permission_policy, possible_impact))
for record in records:
try:
input_payload = json.loads(record.input_json) if record.input_json else {}
except ValueError:
input_payload = {}
approvals.append(
PendingApproval(
confirmation_id=record.id,
character_id=record.character_id,
tool=record.tool,
risk_level=record.risk_level,
permission_policy="ask",
action_summary=f"调用能力 {record.tool}",
input_summary=_summarize_input(input_payload),
possible_impact=None,
reason=record.reason,
created_at=record.created_at.isoformat() if record.created_at else "",
)
)
return approvals
def _pending_to_approval(
pending: PendingConfirmation, permission_policy: str, possible_impact: str | None
) -> PendingApproval:
return PendingApproval(
confirmation_id=pending.confirmation_id,
character_id=pending.character_id,
tool=pending.tool,
risk_level=pending.risk_level,
permission_policy=permission_policy,
action_summary=f"调用工具 {pending.tool}",
input_summary=_summarize_input(pending.input),
possible_impact=possible_impact,
reason=pending.reason,
created_at=pending.created_at,
)
async def get_audit_timeline(
*,
pivot: str | None = None,
+4 -123
View File
@@ -14,7 +14,6 @@ from app.models.governance import (
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.modules.runtime.events import Event
@@ -39,11 +38,7 @@ from app.schemas.admin import (
WorldEntityResponse,
)
from app.schemas.safety import (
CreateToolPolicyRequest,
SafetyPolicyItem,
ToolCallDetail,
ToolPolicyResponse,
UpdateToolPolicyRequest,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
@@ -51,8 +46,6 @@ from app.services.serializers import (
goal_to_response,
perception_channel_to_response,
plan_to_response,
tool_policy_to_response,
tool_policy_to_safety_item,
world_entity_to_response,
)
@@ -81,28 +74,17 @@ async def load_active_goals_context(character_id: str) -> str | None:
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
from app.modules.goal_orchestrator import prioritize_active_goals
from app.modules.runtime.goals import build_goals_context
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
goals_dicts = [
{
"title": g.title,
"level": g.level,
"status": g.status,
"priority": g.priority,
"progress": g.progress,
}
for g in goals
lines = [
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
for g in sorted(goals, key=lambda g: -(g.priority or 0))
]
# 优先级排序后注入运行时,使最高优先级目标最先被看到
sorted_goals = prioritize_active_goals(goals_dicts)
return build_goals_context(sorted_goals)
return "【当前目标】\n" + "\n".join(lines)
async def emit_goal_event(
@@ -520,107 +502,6 @@ async def delete_agent(role: str) -> None:
# —— 百工枢 · 工具策略(§15 / §16 共享)——
def _builtin_tool_names() -> set[str]:
"""builtin 工具名集合(惰性 import,避免 application→services 循环依赖)。"""
from app.application.tool_runtime.builtins import builtin_specs
return set(builtin_specs().keys())
async def list_tool_policies() -> list[ToolPolicyResponse]:
"""GET /tools:完整元数据,executable 按 builtin 派生。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
builtins = _builtin_tool_names()
return [tool_policy_to_response(p, executable=p.name in builtins) for p in policies]
async def list_safety_policies() -> list[SafetyPolicyItem]:
"""GET /safety/policies:策略子集。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
return [tool_policy_to_safety_item(p) for p in policies]
async def create_tool_policy(payload: CreateToolPolicyRequest) -> ToolPolicyResponse:
"""POST /tools、POST /safety/policies:注册工具策略(source=custom)。"""
async with AsyncSessionLocal() as session:
if await governance_repo.get_tool_policy(session, payload.name) is not None:
raise AppError("invalid_request", f"工具 {payload.name} 已存在。")
policy = ToolPolicy(
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
permission_policy=payload.permission_policy,
side_effect=payload.side_effect,
audit_required=payload.audit_required,
source="custom",
enabled=payload.enabled,
)
await governance_repo.add_tool_policy(session, policy)
await record_audit_event(
session,
action="tool.policy_created",
actor="user",
risk_level=payload.risk_level,
input_summary=payload.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def update_tool_policy(name: str, payload: UpdateToolPolicyRequest) -> ToolPolicyResponse:
"""PATCH /tools/{name}、PATCH /safety/policies/{tool}:编辑策略元数据。"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise AppError("invalid_request", "至少需要提供一个待更新字段。")
for key, value in updates.items():
setattr(policy, key, value)
policy.updated_at = utc_now()
await record_audit_event(
session,
action="tool.policy_updated",
actor="user",
risk_level=policy.risk_level,
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def delete_tool_policy(name: str) -> None:
"""DELETE /tools/{name}、DELETE /safety/policies/{tool}:注销策略。
builtin 工具的策略记录不可删除(执行实现仍在代码层);只允许注销 custom 工具。
"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
if policy.source == "builtin":
raise AppError("invalid_request", f"内置工具 {name} 不可删除,可改用 PATCH 禁用。")
await session.delete(policy)
await record_audit_event(
session,
action="tool.policy_deleted",
actor="user",
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
+87 -105
View File
@@ -13,8 +13,9 @@ from __future__ import annotations
import json
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.application.tool_runtime.runtime import ToolRuntime
from sqlalchemy import select
from app.domains.baigong.models import Capability
from app.core.errors import AppError
from app.core.ids import new_mcp_server_id
from app.db.session import AsyncSessionLocal
@@ -38,7 +39,6 @@ async def register_mcp_server(
env_json: str | None = None,
cwd: str | None = None,
status: str = "enabled",
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
async with AsyncSessionLocal() as session:
@@ -65,9 +65,7 @@ async def register_mcp_server(
transport_instance = get_transport(server)
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
if process_manager is not None:
process_manager.register_transport(server.id, transport_instance)
@@ -79,11 +77,7 @@ async def register_mcp_server(
await transport_instance.start()
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(
server, tools, runtime.http_client, transport=transport_instance
):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
await mcp_repo.add_server(session, server)
await record_audit_event(
@@ -109,7 +103,6 @@ async def list_mcp_servers(*, status: str | None = None) -> list[McpServerRespon
async def delete_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> None:
async with AsyncSessionLocal() as session:
@@ -124,13 +117,7 @@ async def delete_mcp_server(
await transport.stop()
process_manager.unregister(server_id)
# unregister ToolSpec
if runtime is not None and server.tools_json:
tools = json.loads(server.tools_json) if server.tools_json else []
for tool in tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
await _drop_capabilities(session, server.name)
name = server.name
await session.delete(server)
@@ -148,7 +135,6 @@ async def delete_mcp_server(
async def start_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具清单并注册。"""
@@ -174,15 +160,11 @@ async def start_mcp_server(
server.tools_json = dumps(tools)
await session.commit()
# 注册 ToolSpec
if runtime is not None and runtime.http_client is not None:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
# MCP 工具派生为百工能力(博闻登记接入点)
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is not None:
await _sync_capabilities(session, server, tools)
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
@@ -191,7 +173,6 @@ async def start_mcp_server(
async def stop_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程并注销 ToolSpec。"""
@@ -207,16 +188,10 @@ async def stop_mcp_server(
await transport.stop()
# unregister ToolSpec
if runtime is not None:
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server and server.tools_json:
tools = json.loads(server.tools_json)
for tool in tools:
name = tool.get("name") if isinstance(tool, dict) else None
if name:
runtime.unregister(f"mcp_{server.name}_{name}")
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is not None:
await _sync_capabilities(session, server, [])
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
@@ -225,18 +200,16 @@ async def stop_mcp_server(
async def restart_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""重启 stdio MCP server。"""
await stop_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
return await start_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
await stop_mcp_server(server_id, process_manager=process_manager)
return await start_mcp_server(server_id, process_manager=process_manager)
async def refresh_mcp_server_tools(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""重新拉取 MCP server 工具清单,更新缓存并注册到运行时。"""
@@ -245,13 +218,6 @@ async def refresh_mcp_server_tools(
if server is None:
raise AppError("not_found", "MCP server 不存在。")
# 注销旧工具
old_tools = json.loads(server.tools_json) if server.tools_json else []
if runtime is not None:
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 重新拉取工具清单(优先用 process_manager 中的传输实例,确保 stdio 进程已启动)
transport_instance = None
@@ -262,10 +228,7 @@ async def refresh_mcp_server_tools(
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
await record_audit_event(
session,
@@ -302,7 +265,6 @@ async def update_mcp_server(
args_json: str | None = None,
env_json: str | None = None,
cwd: str | None = None,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""更新 MCP server 字段(部分更新,不传的字段保持不变)。
@@ -320,31 +282,22 @@ async def update_mcp_server(
if status is not None:
old_status = server.status
server.status = status
# 启用→禁用:注销运行时中的工具
if (
status == "disabled"
and old_status == "enabled"
and runtime is not None
and old_tools_json
):
for tool_name in _tool_names_from_json(old_tools_json):
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 禁用→启用(HTTP):重新拉取工具并注册
# 启用→禁用:撤下派生能力
if status == "disabled" and old_status == "enabled" and old_tools_json:
await _drop_capabilities(session, server.name)
# 禁用→启用(HTTP):重新拉取工具清单并派生
if (
status == "enabled"
and old_status == "disabled"
and server.transport == "streamable-http"
and server.endpoint_url
):
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, new_tools)
endpoint_changed = endpoint_url is not None and endpoint_url != server.endpoint_url
if endpoint_url is not None:
@@ -360,20 +313,11 @@ async def update_mcp_server(
if cwd is not None:
server.cwd = cwd
# streamable-http 且 endpoint 变化时:重新拉取工具清单并注册
# streamable-http 且 endpoint 变化时:重新拉取工具清单并派生能力
if endpoint_changed and server.transport == "streamable-http" and server.endpoint_url:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
# 注销旧工具
if runtime is not None and old_tools_json:
old_tools = json.loads(old_tools_json) if old_tools_json else []
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 拉取新工具(优先用 process_manager 中的传输实例)
await _drop_capabilities(session, server.name)
transport_instance = None
if process_manager is not None:
transport_instance = process_manager.get(server_id)
@@ -381,11 +325,7 @@ async def update_mcp_server(
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, new_tools)
await record_audit_event(
session,
@@ -419,29 +359,18 @@ async def get_mcp_server_status(
return McpServerStatusResponse(**status)
async def load_registered_mcp_servers(
runtime: ToolRuntime, process_manager: ProcessManager | None = None
) -> None:
"""启动加载:按 transport 类型注册 MCP server。"""
if runtime.http_client is None:
return
async def load_registered_mcp_servers(process_manager: ProcessManager | None = None) -> None:
"""启动加载:登记传输层实例(stdio 不自动启动);派生能力已在登记/刷新时落库。"""
async with AsyncSessionLocal() as session:
servers = await mcp_repo.list_servers(session, status="enabled")
for server in servers:
try:
if server.transport == "streamable-http":
tools = json.loads(server.tools_json) if server.tools_json else []
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
# 创建传输层实例但 no-op start
if process_manager is not None:
transport_instance = get_transport(server, runtime.http_client)
process_manager.register_transport(server.id, transport_instance)
elif server.transport == "stdio":
# stdio 不自动启动,只注册传输层实例到管理器
if process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
if server.transport == "streamable-http" and process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
elif server.transport == "stdio" and process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
except Exception: # noqa: BLE001
pass
@@ -470,3 +399,56 @@ def _to_response(server: McpServer) -> McpServerResponse:
created_at=server.created_at,
updated_at=server.updated_at,
)
async def _sync_capabilities(session, server, tools: list) -> None:
"""MCP 工具清单同步为百工能力(executor_type=mcp,博闻等外部能力统一入目录)。"""
import json as _json
names = set()
for tool in tools or []:
name = tool.get("name") if isinstance(tool, dict) else None
if not name:
continue
names.add(name)
cap_name = f"mcp_{server.name}_{name}"
existing = await session.execute(
select(Capability).where(
Capability.owner_scope == "local-user", Capability.name == cap_name
)
)
cap = existing.scalars().first()
if cap is None:
session.add(
Capability(
name=cap_name,
owner_scope="local-user",
description=(tool.get("description") or "")[:500],
executor_type="mcp",
executor_ref=server.name,
risk_dims_json=_json.dumps(
{"capability": "L1", "data": "L2", "consequence": "L1", "context": "L1"}
),
data_scope="MCP 工具参数与返回",
destinations=f"mcp:{server.name}",
source="user_custom",
)
)
await _drop_capabilities(session, server.name, keep=names)
async def _drop_capabilities(session, server_name: str, *, keep: set | None = None) -> None:
"""撤下该 MCP server 不再提供的派生能力。"""
prefix = f"mcp_{server_name}_"
result = await session.execute(
select(Capability).where(
Capability.owner_scope == "local-user",
Capability.executor_type == "mcp",
Capability.name.startswith(prefix), # type: ignore[union-attr]
)
)
for cap in result.scalars().all():
tool_name = cap.name[len(prefix) :]
if keep is not None and tool_name in keep:
continue
await session.delete(cap)
-105
View File
@@ -1,105 +0,0 @@
"""安全服务:审计裁定、对话安全决策与司命策略加载。"""
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.modules.safety.governor import SafetyGovernor
from app.services.audit import record_audit_event
async def allow_action(
session: AsyncSession,
*,
action: str,
character_id: str | None = None,
run_id: str | None = None,
reason: str = "当前阶段低风险动作默认允许。",
) -> None:
await record_audit_event(
session,
action="safety.decision",
character_id=character_id,
run_id=run_id,
actor="system",
risk_level="L1",
decision="auto",
reason=reason,
input_summary=action,
)
class ChatSafetyDecision(BaseModel):
"""对话安全决策结果(当前阶段默认低风险放行 + 基础安全约束)。"""
decision: str = "allow"
risk_level: str = "L1"
constraints: list[str] = Field(
default_factory=lambda: ["不得泄露 API Key、Authorization header 或内部系统配置。"]
)
async def decide_chat_safety(
session: AsyncSession,
*,
character_id: str,
run_id: str,
) -> ChatSafetyDecision:
"""对话消息的安全决策:当前周期记录审计后默认放行。"""
await allow_action(
session,
action="chat.message.received",
character_id=character_id,
run_id=run_id,
)
return ChatSafetyDecision()
def load_safety_governor(path: str | None = None) -> SafetyGovernor:
"""从 YAML 加载司命工具策略;未配置或文件不存在时返回默认(空策略)Governor。
配置路径取自 ``path`` 参数或 ``settings.safety_policy_path``(即
``SAFETY_POLICY_PATH`` 环境变量,示例见 ``config/safety.example.yaml``)。
文件解析后交给 ``SafetyGovernor.from_policy_dict``
(纯逻辑),本函数只承担文件 IO,governor 本身保持零 IO。
注:运行时主策略源为 tool_policies 表(见 load_governor_from_db / ToolRuntime.reload_governor);
本函数保留为可选的 YAML 覆盖入口与遗留兼容。
"""
config_path = path or settings.safety_policy_path
data: dict = {}
if config_path:
config_file = Path(config_path)
if config_file.exists():
with config_file.open(encoding="utf-8") as file:
loaded = yaml.safe_load(file) or {}
if isinstance(loaded, dict):
data = loaded
return SafetyGovernor.from_policy_dict(data)
async def load_governor_from_db() -> SafetyGovernor:
"""从 tool_policies 表加载司命策略(启动期主源)。
读取所有 enabled 策略,构造覆盖层 Governor;运行时写操作后改走
``ToolRuntime.reload_governor`` 原地刷新,避免重建 ToolRuntime。
"""
from app.repositories import governance as governance_repo
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
data = {
"tools": {
p.name: {"risk_level": p.risk_level, "permission_policy": p.permission_policy}
for p in policies
if p.enabled
}
}
return SafetyGovernor.from_policy_dict(data)
-30
View File
@@ -12,7 +12,6 @@ from app.models.governance import (
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.models.imports import CharacterImportJob, ImportItem
@@ -35,7 +34,6 @@ from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse
from app.schemas.safety import SafetyPolicyItem, ToolPolicyResponse
from app.schemas.world import (
WorldEntityResponse as CharacterWorldEntityResponse,
)
@@ -301,34 +299,6 @@ def audit_to_response(event: AuditEvent) -> AuditEntry:
)
def tool_policy_to_response(policy: ToolPolicy, *, executable: bool = False) -> ToolPolicyResponse:
"""工具策略完整元数据;executable 由调用方按 builtin/registered specs 派生。"""
return ToolPolicyResponse(
name=policy.name,
description=policy.description,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
source=policy.source,
enabled=policy.enabled,
executable=executable,
created_at=to_iso(policy.created_at),
updated_at=to_iso(policy.updated_at),
)
def tool_policy_to_safety_item(policy: ToolPolicy) -> SafetyPolicyItem:
"""策略子集(§16 GET /safety/policies 元素)。"""
return SafetyPolicyItem(
tool=policy.name,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
)
def import_job_to_response(job: CharacterImportJob) -> ImportJobResponse:
return ImportJobResponse(
id=job.id,
-180
View File
@@ -1,180 +0,0 @@
"""Skill 能力包服务(CRUD + 关键词触发匹配)。
CRUD 记审计;``match_active_skills`` 加载 enabled skills 调 matcher,返回 Skill ORM
列表(供 send_message 取 prompt_fragment 注入 contract)。
"""
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.skills import Skill
from app.modules.skills.matcher import match_skills
from app.repositories import skills as skills_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.skills import SkillResponse, ToolCapability
from app.services.audit import record_audit_event
async def create_skill(
*,
name: str,
description: str = "",
trigger: str = "",
prompt_fragment: str = "",
tool_bindings: list[str] | None = None,
risk_level: str = "L1",
status: str = "enabled",
) -> SkillResponse:
async with AsyncSessionLocal() as session:
existing = await skills_repo.get_skill_by_name(session, name)
if existing is not None:
raise AppError("conflict", f"Skill 名「{name}」已存在。")
skill = Skill(
name=name,
description=description,
trigger=trigger,
prompt_fragment=prompt_fragment,
tool_bindings_json=dumps(tool_bindings or []),
risk_level=risk_level,
status=status,
)
await skills_repo.add_skill(session, skill)
await record_audit_event(
session,
action="skill.created",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def get_skill(skill_id: str) -> SkillResponse | None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
return _to_response(skill) if skill else None
async def list_skills(*, status: str | None = None) -> list[SkillResponse]:
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status=status)
return [_to_response(s) for s in skills]
async def list_all_capabilities(runtime) -> list[ToolCapability]:
"""统一列举 builtin + mcp(runtime.list_specs)+ skill(DB),含来源标记。"""
capabilities: list[ToolCapability] = []
if runtime is not None:
for spec in runtime.list_specs():
source = "mcp" if spec.name.startswith("mcp:") else "builtin"
capabilities.append(
ToolCapability(
name=spec.name,
description=spec.description,
source=source,
risk_level=spec.risk_level,
status="enabled",
)
)
for s in await list_skills(status="enabled"):
capabilities.append(
ToolCapability(
name=s.name,
description=s.description,
source="skill",
risk_level=s.risk_level,
status=s.status,
)
)
return capabilities
async def update_skill(
skill_id: str,
*,
description: str | None = None,
trigger: str | None = None,
prompt_fragment: str | None = None,
tool_bindings: list[str] | None = None,
risk_level: str | None = None,
status: str | None = None,
) -> SkillResponse:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
if description is not None:
skill.description = description
if trigger is not None:
skill.trigger = trigger
if prompt_fragment is not None:
skill.prompt_fragment = prompt_fragment
if tool_bindings is not None:
skill.tool_bindings_json = dumps(tool_bindings)
if risk_level is not None:
skill.risk_level = risk_level
if status is not None:
skill.status = status
await record_audit_event(
session,
action="skill.updated",
actor="user",
input_summary=skill.name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def delete_skill(skill_id: str) -> None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
name = skill.name
await session.delete(skill)
await record_audit_event(
session,
action="skill.deleted",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill_id,
)
await session.commit()
async def match_active_skills(context_text: str) -> list[Skill]:
"""加载 enabled skills,关键词匹配;返回命中的 Skill ORM(供注入 prompt_fragment)。"""
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return match_skills(context_text, skills)
async def get_enabled_skills_by_names(names: tuple[str, ...]) -> list[Skill]:
"""按名称返回 enabled Skills,供 Agent 显式引用。"""
wanted = set(names)
if not wanted:
return []
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return [skill for skill in skills if skill.name in wanted]
def _to_response(skill: Skill) -> SkillResponse:
return SkillResponse(
id=skill.id,
name=skill.name,
description=skill.description,
trigger=skill.trigger,
prompt_fragment=skill.prompt_fragment,
tool_bindings=loads_list(skill.tool_bindings_json),
risk_level=skill.risk_level,
status=skill.status,
created_at=skill.created_at,
updated_at=skill.updated_at,
)