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

+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"