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

@@ -1,86 +0,0 @@
"""内置工具规格测试:覆盖风险分级、env 传参与必填校验。"""
from __future__ import annotations
import pytest
from app.application.tool_runtime.builtins import builtin_specs
from app.modules.safety.governor import PermissionPolicy
def test_builtin_specs_cover_risk_levels() -> None:
specs = builtin_specs()
assert {"L0", "L1", "L2", "L3"} <= {s.risk_level for s in specs.values()}
assert {
"datetime",
"read_file",
"list_dir",
"write_file",
"shell",
"fetch_url",
} == set(specs)
def test_write_file_is_ask_and_side_effect() -> None:
spec = builtin_specs()["write_file"]
assert spec.permission_policy is PermissionPolicy.ask
assert spec.side_effect is True
def test_read_tools_allow_and_no_side_effect() -> None:
specs = builtin_specs()
for name in ["datetime", "read_file", "list_dir"]:
assert specs[name].permission_policy is PermissionPolicy.allow
assert specs[name].side_effect is False
def test_file_tools_use_workspace() -> None:
req = builtin_specs()["read_file"].build_request({"path": "a.txt"})
assert req.workdir == "workspace"
assert req.env.get("PATH_ARG") == "a.txt"
def test_read_file_pagination_builds_sed() -> None:
"""read_file 传 offset/limit 时用 sed 行分页,缺省时 cat 全文。"""
specs = builtin_specs()
full = specs["read_file"].build_request({"path": "a.txt"})
assert full.argv[2] == 'cat -- "$PATH_ARG"'
paged = specs["read_file"].build_request({"path": "a.txt", "offset": 10, "limit": 5})
assert paged.argv[2] == "sed -n '10,14p' \"$PATH_ARG\""
assert paged.env.get("PATH_ARG") == "a.txt"
assert paged.workdir == "workspace"
tail = specs["read_file"].build_request({"path": "a.txt", "offset": 100})
assert tail.argv[2] == "sed -n '100,$p' \"$PATH_ARG\""
def test_read_file_pagination_ignores_bad_input() -> None:
"""offset/limit 非正整数时回退全文 cat,不误构造非法 sed 地址。"""
spec = builtin_specs()["read_file"]
assert (
spec.build_request({"path": "a", "offset": "x", "limit": -3}).argv[2]
== 'cat -- "$PATH_ARG"'
)
assert spec.build_request({"path": "a", "offset": 0}).argv[2] == 'cat -- "$PATH_ARG"'
def test_required_inputs_validated() -> None:
specs = builtin_specs()
with pytest.raises(ValueError):
specs["read_file"].build_request({})
with pytest.raises(ValueError):
specs["write_file"].build_request({"content": "x"})
with pytest.raises(ValueError):
specs["shell"].build_request({})
def test_fetch_url_uses_executor_not_sandbox() -> None:
"""fetch_url 使用 executor 直接执行,不走沙箱。"""
specs = builtin_specs()
spec = specs["fetch_url"]
assert spec.executor is not None
assert spec.permission_policy is PermissionPolicy.ask
assert spec.side_effect is True
assert spec.audit_required is True
@@ -1,74 +0,0 @@
"""MCP 工具转 ToolSpec 测试。"""
import pytest
from app.application.tool_runtime import mcp as mcp_module
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.modules.safety.governor import PermissionPolicy
from app.modules.tool_runtime.definition import ToolResult
class _Server:
def __init__(self, name, endpoint_url, auth_token=None):
self.name = name
self.endpoint_url = endpoint_url
self.auth_token = auth_token
def test_make_mcp_tool_specs_basic() -> None:
tools = [{"name": "weather", "description": "查天气", "risk_level": "L1"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert len(specs) == 1
s = specs[0]
assert s.name == "mcp_srv_weather"
assert s.description == "查天气"
assert s.risk_level == "L1"
assert s.permission_policy == PermissionPolicy.allow
def test_make_mcp_tool_specs_default_risk_l2() -> None:
tools = [{"name": "t"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert specs[0].risk_level == "L2"
assert specs[0].permission_policy == PermissionPolicy.ask
def test_make_mcp_tool_specs_skips_unnamed() -> None:
tools = [{"name": "t"}, {"description": "无名"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert len(specs) == 1
@pytest.mark.asyncio
async def test_mcp_executor_calls_call_tool(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
async def fake_call_tool(
_http_client, endpoint_url, tool_name, input, auth_token=None, **_kwargs
):
captured.update(tool=tool_name, input=input, endpoint=endpoint_url, auth=auth_token)
return ToolResult(
ok=True,
exit_code=0,
stdout="ok",
stderr="",
duration_ms=1,
timeout=False,
truncated=False,
trace_id="",
)
monkeypatch.setattr(mcp_module, "call_tool", fake_call_tool)
tools = [{"name": "t"}]
specs = make_mcp_tool_specs(
_Server("srv", "http://mcp.local", auth_token="tok"), tools, http_client="HTTP"
)
result = await specs[0].executor({"x": 1}, None)
assert result.ok is True
assert captured == {
"tool": "t",
"input": {"x": 1},
"endpoint": "http://mcp.local",
"auth": "tok",
}
@@ -1,382 +0,0 @@
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
def _settings() -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ok_response() -> httpx.Response:
return httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 3,
"timeout": False,
"truncated": False,
"trace_id": "t1",
},
)
@pytest.fixture
async def sandbox_client() -> AsyncIterator[SandboxClient]:
async with httpx.AsyncClient() as http:
yield SandboxClient(http, _settings())
@respx.mock
async def test_allow_executes_and_records_started_completed(
sandbox_client: SandboxClient,
) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
assert result.ok
assert result.stdout == "42\n"
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
sandbox = MagicMock()
started = asyncio.Event()
async def exec_forever(*_args, **_kwargs):
started.set()
await asyncio.Event().wait()
sandbox.exec = AsyncMock(side_effect=exec_forever)
sandbox.cancel = AsyncMock(return_value=True)
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
sandbox.cancel.assert_awaited_once()
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
@respx.mock
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(RequiresConfirmation):
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
# 未确认不应进入执行,故无 started 事件
assert all(e.type != "tool.started" for e in store.all())
@respx.mock
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
assert result.ok
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
@respx.mock
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, governor, store)
with pytest.raises(PermissionDenied):
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
failed = [e for e in store.all() if e.type == "tool.failed"]
assert len(failed) == 1
assert failed[0].payload["error_code"] == "permission_denied"
assert failed[0].trace_id == "t1"
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(PermissionDenied):
await runtime.execute("nope", {}, run_id="run_1")
assert any(e.type == "tool.failed" for e in store.all())
@respx.mock
async def test_file_tool_isolates_workdir_by_character(
sandbox_client: SandboxClient,
) -> None:
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
await runtime.execute(
"write_file",
{"path": "a.txt", "content": "x"},
character_id="char_01HABC",
run_id="run_42",
confirmed=True,
)
body = json.loads(route.calls.last.request.content)
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
assert body["workdir"] == "workspace/char_01HABC"
async def test_isolate_workdir_sanitizes_and_falls_back(
sandbox_client: SandboxClient,
) -> None:
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
traversed = runtime._isolate_workdir(
"workspace", subject_id="../../etc", run_id=None, trace_id=None
)
key = traversed.removeprefix("workspace/")
assert key == "etc"
assert "/" not in key and ".." not in key
# 正常 subject_id(character_id)原样保留
assert (
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
== "workspace/char_01HABC"
)
# 缺 subject_id 时回退 run_id
assert (
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
== "workspace/run_01HABC"
)
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
assert runtime._isolate_workdir(
"workspace", subject_id=None, run_id=None, trace_id=None
).startswith("workspace/adhoc_")
@respx.mock
async def test_execute_stream_forwards_stdout_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
sse = (
b"event: stdout\n"
b"data: aGVsbG8K\n"
b"\n"
b"event: done\n"
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
b"\n"
)
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(200, content=sse)
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
assert [e.kind for e in events] == ["stdout", "done"]
assert events[-1].payload["exit_code"] == 0
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
@respx.mock
async def test_execute_stream_requires_confirmation_for_high_risk(
sandbox_client: SandboxClient,
) -> None:
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
with pytest.raises(RequiresConfirmation):
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
pass
# --------------------------------------------------------------------------- #
# executor 工具集成测试(fetch_url 走直接执行路径)
# --------------------------------------------------------------------------- #
@respx.mock
async def test_executor_tool_runs_directly_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
respx.get("https://example.test").mock(
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
)
async with httpx.AsyncClient() as tools_http:
store = InMemoryEventStore()
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
store,
http_client=tools_http,
)
# fetch_url permission=ask,confirmed=True 绕过确认。
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
run_id="run_fu",
trace_id="t_fu",
confirmed=True,
)
assert result.ok
assert "Hello" in result.stdout
assert result.trace_id == "t_fu"
# 沙箱未被调用(直接执行路径)
events = store.all()
assert [e.type for e in events] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_fu" for e in events)
@respx.mock
async def test_executor_tool_requires_confirmation_when_ask(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
async with httpx.AsyncClient() as tools_http:
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
http_client=tools_http,
)
with pytest.raises(RequiresConfirmation):
await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
)
async def test_executor_tool_without_http_client_returns_error(
sandbox_client: SandboxClient,
) -> None:
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
# 故意不传 http_client
)
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
confirmed=True,
)
assert not result.ok
assert "http_client" in result.stderr
# --------------------------------------------------------------------------- #
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
# --------------------------------------------------------------------------- #
def test_builtin_changes_reflected_in_list_specs_without_reinit(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
from app.application.tool_runtime import builtins as builtins_module
original = builtins_module.builtin_specs()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 基线:包含全部 builtin 工具
baseline_names = {s.name for s in runtime.list_specs()}
assert "datetime" in baseline_names
assert "shell" in baseline_names
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
pruned = {k: v for k, v in original.items() if k != "shell"}
def _pruned_specs() -> dict:
return dict(pruned)
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
after_names = {s.name for s in runtime.list_specs()}
assert "datetime" in after_names
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
def test_register_overrides_builtin_and_survives_builtin_change(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
from app.application.tool_runtime import builtins as builtins_module
from app.modules.tool_runtime.definition import ToolSpec
custom_shell = ToolSpec(
name="shell",
description="自定义 shell(覆盖 builtin)",
risk_level="L1",
permission_policy=PermissionPolicy.allow,
build_request=lambda _input: _ok_exec_request(),
side_effect=False,
)
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
runtime.register(custom_shell)
registered = {s.name: s for s in runtime.list_specs()}
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
after = {s.name: s for s in runtime.list_specs()}
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
def _ok_exec_request():
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
from app.integrations.sandbox.models import ExecRequest
return ExecRequest(argv=["sh", "-c", "echo ok"])
@@ -1,117 +0,0 @@
"""fetch_url executor 单元测试。
mock 边界:
- ``respx`` 模拟 httpx.AsyncClient 的 GET(不真实联网)。
- executor 直接调用,不经过 ToolRuntime(runtime 集成路径由 test_runtime 覆盖)。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
import respx
from app.application.tool_runtime.web_tools import _fetch_url_executor
@pytest.fixture
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient() as client:
yield client
def _ctx(http_client: httpx.AsyncClient, **extra: Any) -> dict[str, Any]:
"""构造 executor 上下文。"""
base: dict[str, Any] = {"http_client": http_client, "trace_id": "trace_test"}
base.update(extra)
return base
@respx.mock
async def test_fetch_url_converts_html_to_markdown(
http_client: httpx.AsyncClient,
) -> None:
"""fetch_url 把 HTML 转成 markdown 并截断。"""
html = (
"<html><head><title>T</title></head>"
"<body><h1>标题</h1><p>这是一段正文。</p>"
'<a href="https://example.com">链接</a></body></html>'
)
respx.get("https://example.com/page").mock(return_value=httpx.Response(200, text=html))
result = await _fetch_url_executor({"url": "https://example.com/page"}, _ctx(http_client))
assert result.ok
assert "标题" in result.stdout
assert "这是一段正文。" in result.stdout
# html2text 默认保留链接
assert "example.com" in result.stdout
@respx.mock
async def test_fetch_url_truncates_to_max_chars(
http_client: httpx.AsyncClient,
) -> None:
"""max_chars 截断生效。"""
long_text = "A" * 500
html = f"<html><body><p>{long_text}</p></body></html>"
respx.get("https://example.com/big").mock(return_value=httpx.Response(200, text=html))
result = await _fetch_url_executor(
{"url": "https://example.com/big", "max_chars": 50}, _ctx(http_client)
)
assert result.ok
assert "已截断" in result.stdout
assert len(result.stdout) < 500
@respx.mock
async def test_fetch_url_http_error_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""HTTP 错误返回 ok=False。"""
respx.get("https://example.com/missing").mock(
return_value=httpx.Response(404, text="not found")
)
result = await _fetch_url_executor({"url": "https://example.com/missing"}, _ctx(http_client))
assert not result.ok
assert "404" in result.stderr
async def test_fetch_url_missing_url_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""缺 url 输入时返回 ok=False。"""
result = await _fetch_url_executor({}, _ctx(http_client))
assert not result.ok
assert "url" in result.stderr
async def test_fetch_url_missing_http_client_returns_error() -> None:
"""缺注入的 http_client 时返回 ok=False。"""
result = await _fetch_url_executor({"url": "https://example.com"}, None)
assert not result.ok
assert "http_client" in result.stderr
@respx.mock
async def test_fetch_url_network_error_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""网络错误(HTTPError)返回 ok=False 而不抛异常。"""
respx.get("https://example.com/neterr").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await _fetch_url_executor({"url": "https://example.com/neterr"}, _ctx(http_client))
assert not result.ok
assert "请求失败" in result.stderr