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:
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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 路径。"""
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user