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,76 +0,0 @@
"""Skill 能力包 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_create_and_list_skills(client: AsyncClient) -> None:
resp = await client.post(
"/api/v1/tools/skills",
json={
"name": "coding",
"trigger": "代码,编程",
"prompt_fragment": "你是代码助手",
"tool_bindings": ["read_file"],
},
)
assert resp.status_code == 201
assert resp.json()["name"] == "coding"
assert resp.json()["tool_bindings"] == ["read_file"]
listed = await client.get("/api/v1/tools/skills")
assert listed.status_code == 200
assert listed.json()[0]["name"] == "coding"
@pytest.mark.asyncio
async def test_update_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.patch(f"/api/v1/tools/skills/{sid}", json={"status": "disabled"})
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
@pytest.mark.asyncio
async def test_delete_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.delete(f"/api/v1/tools/skills/{sid}")
assert resp.status_code == 204
listed = await client.get("/api/v1/tools/skills")
assert listed.json() == []
@pytest.mark.asyncio
async def test_create_duplicate_conflict(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "dup"})
resp = await client.post("/api/v1/tools/skills", json={"name": "dup"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_list_skills_status_filter(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "a", "status": "enabled"})
await client.post("/api/v1/tools/skills", json={"name": "b", "status": "disabled"})
resp = await client.get("/api/v1/tools/skills", params={"status": "enabled"})
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "a"
-150
View File
@@ -1,150 +0,0 @@
"""工具与确认 API 测试:TestClient + respx 模拟沙箱。"""
from __future__ import annotations
from collections.abc import Iterator
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app.api.v1.deps import get_confirmation_store, get_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.main import app
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.confirmation import InMemoryConfirmationStore
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() -> 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
def runtime_bundle() -> Iterator[None]:
"""注入 mock ToolRuntime / 确认存储到 app 依赖覆盖;返回值无意义。"""
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
runtime = ToolRuntime(sandbox, SafetyGovernor(), store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
yield
finally:
app.dependency_overrides.clear()
@respx.mock
def test_list_tools(runtime_bundle: None) -> None:
_ = runtime_bundle # fixture 仅用于副作用(注入依赖覆盖)
with TestClient(app) as client:
resp = client.get("/api/v1/tools")
assert resp.status_code == 200
names = {tool["name"] for tool in resp.json()}
assert {"datetime", "shell", "fetch_url"} <= names
@respx.mock
def test_invoke_datetime_completed(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/datetime/invoke",
json={"input": {}, "trace_id": "t1"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
assert body["result"]["stdout"] == "42\n"
@respx.mock
def test_shell_invoke_then_approve(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "trace_id": "t1", "run_id": "run_tools_test"},
)
assert resp.status_code == 200
ask = resp.json()
assert ask["status"] == "requires_confirmation"
assert ask["risk_level"] == "L3"
confirmation_id = ask["confirmation_id"]
approved = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert approved.status_code == 200
assert approved.json()["status"] == "resolved"
@respx.mock
def test_deny_then_approve_returns_404(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "run_id": "run_tools_test2"},
)
confirmation_id = resp.json()["confirmation_id"]
denied = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/deny")
assert denied.status_code == 200
assert denied.json()["status"] in ("cancelled", "resolved")
again = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert again.status_code == 404
assert again.json()["error"]["code"] == "not_found"
@respx.mock
def test_forbidden_tool_returns_403() -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
runtime = ToolRuntime(sandbox, governor, store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke", json={"input": {"command": "rm -rf /"}}
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "permission_denied"
finally:
app.dependency_overrides.clear()
@@ -1,36 +0,0 @@
"""GET /tools/capabilities 统一能力列举测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.services import skills as skills_service
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_capabilities_includes_skills(client: AsyncClient) -> None:
await skills_service.create_skill(name="coding", trigger="代码", prompt_fragment="代码助手")
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
data = resp.json()
# runtime 在测试环境可能未就绪(ASGITransport 不跑 lifespan),至少含 skill
skill_caps = [c for c in data if c["source"] == "skill"]
assert any(c["name"] == "coding" for c in skill_caps)
@pytest.mark.asyncio
async def test_list_capabilities_source_values(client: AsyncClient) -> None:
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
for c in resp.json():
assert c["source"] in {"builtin", "mcp", "skill"}
assert "name" in c and "risk_level" in c
@@ -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
+5
View File
@@ -31,6 +31,11 @@ else:
os.environ["JWT_SECRET"] = "test-jwt-secret-for-testing-pytest"
# 预热模型注册(必须在上述环境变量设置之后导入,避免 settings 读到开发库配置):
# 先完整初始化 app.models,避免测试模块直接导入域模型时
# 触发 app.db.base ↔ app.models 的部分初始化循环
import app.models # noqa: E402,F401
def _assert_test_database_url(database_url: str) -> None:
if database_url.startswith("sqlite+aiosqlite:///"):
@@ -1,50 +0,0 @@
"""测试目标上下文注入。"""
from app.modules.runtime.goals import build_goals_context
def test_build_goals_context_with_active_goals():
"""生成了 Active Goals 系统提示片段。"""
goals = [
{
"title": "学习 Python",
"level": "long_term",
"status": "active",
"priority": 80,
"progress": 0.5,
},
{
"title": "完成项目文档",
"level": "task",
"status": "active",
"priority": 60,
"progress": 0.0,
"parent_id": "goal_1",
},
]
result = build_goals_context(goals)
assert "学习 Python" in result
assert "任务目标" in result or "task" in result or "长期目标" in result
assert result.startswith("当前活跃目标:") or "当前" in result
def test_build_goals_context_empty():
assert build_goals_context([]) == ""
def test_build_goals_context_sorts_by_priority():
"""按优先级降序排列输出。"""
goals = [
{"title": "low", "level": "task", "status": "active", "priority": 10, "progress": 0.0},
{
"title": "high",
"level": "long_term",
"status": "active",
"priority": 90,
"progress": 0.5,
},
]
result = build_goals_context(goals)
high_idx = result.index("high")
low_idx = result.index("low")
assert high_idx < low_idx # high priority first
@@ -1,120 +0,0 @@
"""DbConfirmationStore 单元测试。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db.base import Base
from app.modules.safety.confirmation import (
DbConfirmationStore,
PendingConfirmation,
new_confirmation_id,
)
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest_asyncio.fixture
async def store(db_session: AsyncSession) -> DbConfirmationStore:
"""创建绑定 db_session 的 DbConfirmationStore。"""
session_factory = sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
return DbConfirmationStore(session_factory)
class TestDbConfirmationStore:
"""数据库确认存储 CRUD 测试。"""
async def test_create_and_get(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="fetch_url",
input={"q": "test"},
risk_level="L2",
reason="测试确认",
)
created = await store.create(pending)
assert created.confirmation_id == pending.confirmation_id
fetched = await store.get(pending.confirmation_id)
assert fetched is not None
assert fetched.tool == "fetch_url"
assert fetched.input == {"q": "test"}
async def test_get_non_existent(self, store: DbConfirmationStore) -> None:
result = await store.get("confirm_nonexistent")
assert result is None
async def test_pop_removes_and_returns(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="shell",
input={"cmd": "ls"},
risk_level="L3",
reason="高风险操作",
)
await store.create(pending)
popped = await store.pop(pending.confirmation_id)
assert popped is not None
assert popped.tool == "shell"
# pop 后不应再可取到
fetched = await store.get(pending.confirmation_id)
assert fetched is None
async def test_pop_non_existent(self, store: DbConfirmationStore) -> None:
result = await store.pop("confirm_nonexistent")
assert result is None
async def test_list_pending(self, store: DbConfirmationStore) -> None:
# 插入两条
id1 = new_confirmation_id()
id2 = new_confirmation_id()
await store.create(
PendingConfirmation(
confirmation_id=id1, tool="fetch_url", input={}, risk_level="L1", reason="a"
)
)
await store.create(
PendingConfirmation(
confirmation_id=id2, tool="shell", input={}, risk_level="L3", reason="b"
)
)
items = await store.list_pending()
assert len(items) == 2
# 按创建时间升序
assert items[0].confirmation_id == id1
assert items[1].confirmation_id == id2
async def test_list_pending_empty(self, store: DbConfirmationStore) -> None:
items = await store.list_pending()
assert items == []
async def test_popped_not_in_list_pending(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="fetch_url",
input={},
risk_level="L2",
reason="测试",
)
await store.create(pending)
await store.pop(pending.confirmation_id)
items = await store.list_pending()
ids = [i.confirmation_id for i in items]
assert pending.confirmation_id not in ids
@@ -1,66 +0,0 @@
"""司命决策器测试:风险等级 × 权限策略 × 是否确认 的决策矩阵,以及策略字典加载。"""
from __future__ import annotations
from app.modules.safety.governor import (
PermissionPolicy,
SafetyDecision,
SafetyGovernor,
ToolPolicy,
)
def test_low_risk_defaults_to_allow() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="calc", risk_level="L1")
assert result.decision is SafetyDecision.allow
def test_medium_risk_unconfirmed_asks() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L2")
assert result.decision is SafetyDecision.ask
def test_confirmed_becomes_allow() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L3", confirmed=True)
assert result.decision is SafetyDecision.allow
def test_l4_always_denied() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L4")
assert result.decision is SafetyDecision.deny
def test_forbidden_policy_denied() -> None:
governor = SafetyGovernor(
{"pay": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
result = governor.evaluate(tool="pay", risk_level="L3")
assert result.decision is SafetyDecision.deny
assert result.policy is PermissionPolicy.forbidden
def test_explicit_tool_policy_overrides_default() -> None:
governor = SafetyGovernor(
{"read": ToolPolicy(risk_level="L2", permission_policy=PermissionPolicy.allow)}
)
result = governor.evaluate(tool="read")
assert result.decision is SafetyDecision.allow
def test_from_policy_dict_loads_policies() -> None:
governor = SafetyGovernor.from_policy_dict(
{"tools": {"custom": {"risk_level": "L2", "permission_policy": "allow"}}}
)
result = governor.evaluate(tool="custom")
assert result.decision is SafetyDecision.allow
def test_from_policy_dict_empty_returns_default() -> None:
governor = SafetyGovernor.from_policy_dict({})
# 空 dict → 空 Governor,未列工具按 risk 推导:L2 → ask
result = governor.evaluate(tool="x", risk_level="L2")
assert result.decision is SafetyDecision.ask
@@ -1,55 +0,0 @@
"""Skill 关键词触发匹配纯函数测试。"""
from types import SimpleNamespace
from app.modules.skills.matcher import match_skills
def _skill(name: str, trigger: str, status: str = "enabled") -> SimpleNamespace:
return SimpleNamespace(name=name, trigger=trigger, status=status)
def test_match_by_keyword() -> None:
skills = [_skill("coding", "代码,编程")]
assert len(match_skills("帮我写代码", skills)) == 1
def test_no_match() -> None:
skills = [_skill("coding", "代码,编程")]
assert match_skills("今天天气不错", skills) == []
def test_case_insensitive() -> None:
skills = [_skill("en", "Python")]
assert len(match_skills("用 python 写", skills)) == 1
def test_multiple_keywords_any_match() -> None:
skills = [_skill("dev", "代码,debug,编程")]
assert len(match_skills("帮我 debug", skills)) == 1
def test_disabled_not_matched() -> None:
skills = [_skill("coding", "代码", status="disabled")]
assert match_skills("写代码", skills) == []
def test_empty_trigger_not_matched() -> None:
skills = [_skill("empty", "")]
assert match_skills("任意文本", skills) == []
def test_multiple_skills_order_preserved() -> None:
skills = [_skill("a", "代码"), _skill("b", "天气"), _skill("c", "编程")]
matched = match_skills("写代码和编程", skills)
assert [s.name for s in matched] == ["a", "c"]
def test_empty_context() -> None:
skills = [_skill("a", "代码")]
assert match_skills("", skills) == []
def test_whitespace_only_trigger_ignored() -> None:
skills = [_skill("ws", " , , ")]
assert match_skills("任意文本", skills) == []
@@ -1,65 +0,0 @@
"""Skill 数据访问测试。"""
import pytest
from sqlalchemy.exc import IntegrityError
from app.db.session import AsyncSessionLocal
from app.models.skills import Skill
from app.repositories import skills as skills_repo
def _skill(name: str = "coding", **overrides) -> Skill:
base = {
"name": name,
"description": "代码辅助",
"trigger": "代码,编程",
"prompt_fragment": "你是代码助手",
"tool_bindings_json": "[]",
"risk_level": "L1",
"status": "enabled",
}
base.update(overrides)
return Skill(**base)
@pytest.mark.asyncio
async def test_add_and_get_skill() -> None:
async with AsyncSessionLocal() as session:
s = _skill()
await skills_repo.add_skill(session, s)
await session.commit()
sid = s.id
async with AsyncSessionLocal() as session:
fetched = await skills_repo.get_skill(session, sid)
assert fetched is not None
assert fetched.name == "coding"
assert fetched.trigger == "代码,编程"
assert fetched.status == "enabled"
@pytest.mark.asyncio
async def test_list_skills_with_status_filter() -> None:
async with AsyncSessionLocal() as session:
await skills_repo.add_skill(session, _skill("a", status="enabled"))
await skills_repo.add_skill(session, _skill("b", status="disabled"))
await session.commit()
async with AsyncSessionLocal() as session:
enabled = await skills_repo.list_skills(session, status="enabled")
all_skills = await skills_repo.list_skills(session)
assert len(enabled) == 1
assert enabled[0].name == "a"
assert len(all_skills) == 2
@pytest.mark.asyncio
async def test_name_unique_conflict() -> None:
async with AsyncSessionLocal() as session:
await skills_repo.add_skill(session, _skill("dup"))
await session.commit()
async with AsyncSessionLocal() as session:
with pytest.raises(IntegrityError):
await skills_repo.add_skill(session, _skill("dup"))
await session.commit()
@@ -1,75 +0,0 @@
"""Skill 服务测试(CRUD + 关键词触发匹配)。"""
import pytest
from app.core.errors import AppError
from app.services import skills as skills_service
async def _create(
name: str = "coding",
trigger: str = "代码,编程",
status: str = "enabled",
) -> None:
await skills_service.create_skill(
name=name,
description="代码辅助",
trigger=trigger,
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
risk_level="L1",
status=status,
)
@pytest.mark.asyncio
async def test_create_and_list() -> None:
resp = await skills_service.create_skill(
name="coding",
trigger="代码",
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
)
assert resp.name == "coding"
assert resp.tool_bindings == ["read_file"]
listed = await skills_service.list_skills()
assert len(listed) == 1
@pytest.mark.asyncio
async def test_create_duplicate_name_conflicts() -> None:
await _create("dup")
with pytest.raises(AppError):
await _create("dup")
@pytest.mark.asyncio
async def test_update_skill_status() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
updated = await skills_service.update_skill(sid, status="disabled")
assert updated.status == "disabled"
@pytest.mark.asyncio
async def test_match_active_skills() -> None:
await _create("coding", trigger="代码,编程")
await _create("weather", trigger="天气", status="disabled")
matched = await skills_service.match_active_skills("帮我写代码")
assert len(matched) == 1
assert matched[0].name == "coding"
assert matched[0].prompt_fragment == "你是代码助手"
@pytest.mark.asyncio
async def test_delete_skill() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
await skills_service.delete_skill(sid)
assert await skills_service.get_skill(sid) is None
-71
View File
@@ -114,40 +114,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie
app.dependency_overrides.pop(require_admin, None)
@pytest.mark.asyncio
async def test_skills_crud(client: AsyncClient) -> None:
"""Skill 能力包 CRUD——覆盖 services/skills 路径。"""
created = await client.post(
"/api/v1/tools/skills",
json={
"name": "测试技能",
"description": "用于集成测试",
"trigger": "测试触发",
"prompt_fragment": "测试提示片段",
"tool_bindings": [],
"risk_level": "L0",
},
)
assert created.status_code == 201
skill_id = created.json()["id"]
listed = await client.get("/api/v1/tools/skills")
assert listed.status_code == 200
assert any(item["id"] == skill_id for item in listed.json())
# Skills 没有 GET single 端点,跳过
updated = await client.patch(
f"/api/v1/tools/skills/{skill_id}",
json={"description": "改描述", "trigger": "新触发"},
)
assert updated.status_code == 200
assert updated.json()["trigger"] == "新触发"
deleted = await client.delete(f"/api/v1/tools/skills/{skill_id}")
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_mcp_server_crud(client: AsyncClient) -> None:
"""MCP 服务器注册 CRUD——覆盖 services/mcp 路径。"""
@@ -213,43 +179,6 @@ async def test_world_entities_crud(client: AsyncClient) -> None:
assert gone.status_code in (200, 404)
@pytest.mark.asyncio
async def test_safety_policy_crud(client: AsyncClient) -> None:
"""安全策略 CRUD——覆盖 services/governance 部分路径。"""
import uuid
policy_name = f"test-policy-{uuid.uuid4().hex[:8]}"
created = await client.post(
"/api/v1/safety/policies",
json={
"name": policy_name,
"description": "集成测试策略",
"risk_level": "L3",
"permission_policy": "ask",
"side_effect": True,
"audit_required": True,
},
)
assert created.status_code == 201, created.text
data = created.json()
assert data["name"] == policy_name
listed = await client.get("/api/v1/safety/policies")
assert listed.status_code == 200
# SafetyPolicyItem 使用 tool 字段名(不是 name)
assert any(item["tool"] == policy_name for item in listed.json())
updated = await client.patch(
f"/api/v1/safety/policies/{policy_name}",
json={"description": "改后描述", "risk_level": "L2"},
)
assert updated.status_code == 200, updated.text
assert updated.json()["risk_level"] == "L2"
deleted = await client.delete(f"/api/v1/safety/policies/{policy_name}")
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_preferences_flow(client: AsyncClient) -> None:
"""偏好设置读写——覆盖 services/preferences 路径。"""
+220
View File
@@ -0,0 +1,220 @@
"""M5 百工与司命验收测试(rewrite-plan M5 验收门)。
四条验收:
① 未授契能力调用被拒并留痕(audit_events 有拒绝记录);
② 请命超时不执行(过期确认 → HJ-1302 语义,执行计数为 0);
③ 禁域规则无法被任何 Profile/插件关闭(恶意注入 dims/consent 仍拒);
④ 工具结果先为观察(调用后记忆/实录表无新行,tool_calls 带观察标证)。
"""
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.models.user import User
from app.main import app
from app.models.safety import AuditEvent
from app.models.xirang import Memory # noqa: TC001 — 断言用
from app.domains.baigong import siming
from app.domains.baigong.models import Capability as Cap
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
"""手工装配百工执行器(builtin 回显,不经沙箱;测试不经 lifespan)。"""
from app.domains.baigong.executor import Executor
app.state.baigong_executor = Executor(None)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
del app.state.baigong_executor
async def _test_owner_scope() -> str:
"""取最新测试用户的 owner_scope(auth_headers fixture 所建)。"""
from sqlalchemy import select
async with AsyncSessionLocal() as session:
users = (await session.execute(select(User).order_by(User.created_at.desc()))).scalars().all()
return users[0].id # 最新创建者即本测试 fixture 用户
async def _seed_capability(
*,
name: str = "cap.test.echo",
executor_type: str = "builtin",
consent_mode: str = "allow",
risk_dims: dict | None = None,
) -> Capability:
import json
owner = await _test_owner_scope()
async with AsyncSessionLocal() as session:
cap = Capability(
name=name,
owner_scope=owner,
description="测试能力",
executor_type=executor_type,
executor_ref="echo",
params_schema_json="{}",
risk_dims_json=json.dumps(risk_dims or {}),
consent_mode=consent_mode,
)
session.add(cap)
await session.commit()
session.expunge(cap)
return cap
async def _grant(capability_id: str, *, kind: str = "charter", budget: int = 10) -> None:
owner = await _test_owner_scope()
async with AsyncSessionLocal() as session:
session.add(
Mandate(
owner_scope=owner,
capability_id=capability_id,
kind=kind,
budget_total=budget,
)
)
await session.commit()
async def _audit_actions(capability_name: str) -> list[str]:
async with AsyncSessionLocal() as session:
rows = await session.execute(
select(AuditEvent.action).where(AuditEvent.resource_id == capability_name)
)
return list(rows.scalars().all())
# ── ① 未授契能力调用被拒并留痕 ──────────────────────────────
@pytest.mark.asyncio
async def test_unmandated_invocation_rejected_and_audited(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.unmandated")
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "hi"}}
)
assert resp.status_code == 403
body = resp.json()
assert body["error"]["code"] == "HJ-1303"
async with AsyncSessionLocal() as session:
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert len(records) == 1
assert records[0].decision == "denied_unmandated"
# 留痕:行动账可查
actions = await _audit_actions(cap.name)
assert any("denied" in a for a in actions)
# ── ② 请命超时不执行 ──────────────────────────────
@pytest.mark.asyncio
async def test_petition_expiry_does_not_execute(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.askflow", consent_mode="ask")
await _grant(cap.id)
# 首次调用 → 请命(不执行)
ask = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "hi"}}
)
assert ask.status_code == 200
assert ask.json()["status"] == "requires_petition"
petition_id = ask.json()["petition_id"]
# 超时:直接把截止时间拨到过去,再批准 → 语义为拒绝执行(HJ-1302)
from app.models.safety import PendingConfirmationRecord
async with AsyncSessionLocal() as session:
record = await session.get(PendingConfirmationRecord, petition_id)
assert record is not None
record.expires_at = datetime.now(UTC) - timedelta(seconds=1)
await session.commit()
expired = await client.post(f"/api/v1/petitions/{petition_id}/approve")
assert expired.status_code == 410
assert expired.json()["error"]["code"] == "HJ-1302"
# 执行计数为 0(只有一条 ask 裁决记录,无成功执行)
async with AsyncSessionLocal() as session:
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert all(r.status != "ok" for r in records)
assert all(r.decision != "allow" for r in records)
# ── ③ 禁域无法被关闭(专项) ──────────────────────────────
@pytest.mark.asyncio
async def test_forbidden_zone_cannot_be_disabled(client: AsyncClient) -> None:
cap = await _seed_capability(
name="cap.test.shell",
executor_type="qiankun",
# 恶意标注:四维全 L0、consent 放行——禁域仍必须拒绝
risk_dims={"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
consent_mode="allow",
)
await _grant(cap.id)
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke",
json={"params": {"command": "rm -rf / --no-preserve-root"}},
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "HJ-1301"
# 代码级专项:直接以被篡改的领域对象断言禁域检查仍生效
tampered = Cap(
name="cap.tampered",
owner_scope="local-user",
executor_type="qiankun",
risk_dims_json="{}",
consent_mode="allow",
enabled=True,
)
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"command": "dd if=/dev/zero of=/dev/sda"})
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"url": "file:///etc/passwd"})
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"text": "sk-abcdefghijklmnopqrst"})
# ── ④ 工具结果先为观察 ──────────────────────────────
@pytest.mark.asyncio
async def test_tool_result_is_observation_not_memory(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.observe")
await _grant(cap.id)
async with AsyncSessionLocal() as session:
before = len(list((await session.execute(select(Memory))).scalars().all()))
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "工具产出的内容"}}
)
assert resp.status_code == 200
body = resp.json()
# 观察标证:结果带来源与观察语义标注,非事实
assert body["observation"]["kind"] == "observation"
assert body["observation"]["capability"] == cap.name
async with AsyncSessionLocal() as session:
after = len(list((await session.execute(select(Memory))).scalars().all()))
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert after == before, "工具结果不得自动写入记忆"
assert records and records[-1].evidence_json.get("kind") == "observation"