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
|
||||
Reference in new issue
Block a user