- 百工域 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 个预存环境失败不变)
221 lines
8.4 KiB
Python
221 lines
8.4 KiB
Python
"""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"
|