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