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
@@ -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