Files
group_fqcd_jr/tests/unit/service/test_agent_governance.py
T
lzf_0626 c4f4c33fd6 记忆召回补一道能力码闸门:归属关系不等于授权
上一版把员工召回改成"按 sys_customer_assignment 归属"时,只看了**归属关系**,
没校验**能力码** —— 这留下一个我自己带出来的口子:

- 平台读他人画像/记忆的正式路径(customer_profile_service、public_platform_service)
  校验的是 `memory:read:customer`(sys_permission id=9010,data_scope='own_customers')
  + `customer_ids`;
- 而召回是**另一条**把客户记忆送进模型上下文的路径,只按归属放行,等于让
  "有归属行但没有该权限"的账号(例如 operator:只有 financial:nl2sql:read
  + offsite:write)凭空获得读他人客户记忆的能力 —— 它上一版之前是读不到的。

现改为**两个条件都满足才放行**(缺一即失败关闭,并在日志里分开点名"缺能力码"
与"归属未维护",避免运维在错误的表上找问题):
  ① `memory:read:customer` 在 context.permissions 里;
  ② 客户落在 context.customer_ids(sys_customer_assignment 生效行)内。

演示账号不受影响:9002(risk_operator)、9020(advisor)、9003(admin) 三个角色都持有该权限码,
真实身份链路复验 9020 仍是"修复前 0 条 → 修复后 2 条"。

测试:tests/unit/core/test_memory_scope.py +3 条(缺能力码读不到/能力码与归属是"与"关系/
现有用例补上能力码),tests/unit/service/test_agent_governance.py 同步。
`pytest tests/unit tests/contract` → 1448 passed, 2 skipped, 1 failed
(唯一失败仍是组员正在改的投顾页面)。
2026-09-14 21:40:05 +08:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pytest
from app.core.contracts import (
AgentDefinition,
AgentRequest,
AgentResult,
CoreResult,
RecalledMemory,
RequestContext,
ResolvedAgentConfig,
SourceReference,
)
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.core.memory_scope import REQUIRED_EMPLOYEE_PERMISSION
from app.service.agent import governance as governance_module
from app.service.agent.base import BaseAgent
from app.service.agent.customer_service_agent import CustomerServiceAgent
from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance, review_output
@pytest.mark.parametrize("name", ["recall_memory", "resolve_config", "check_compliance",
"bind_governance", "_execute_governed", "validate_input"])
def test_all_governance_hooks_protected(name):
with pytest.raises(TypeError):
type("Bypass", (BaseAgent,), {name: lambda *args: None})
@pytest.mark.asyncio
async def test_visitor_does_not_recall_customer_memory() -> None:
"""访客不能以匿名主体标识读取任何客户记忆。"""
class Demo(BaseAgent):
async def handle(self, request, context):
return CoreResult(text="unused")
class FailingGovernance:
async def recall(self, context):
raise AssertionError("visitor memory recall is forbidden")
definition = AgentDefinition(
agent_type="demo", version="1", allowed_roles=("visitor",), allowed_portals=("api",)
)
agent = Demo(definition)
agent.bind_governance(FailingGovernance())
request = AgentRequest(
agent_type="demo", message="公开问题", session_id="visitor-session",
idempotency_key="visitor-memory-request-0001",
)
context = RequestContext(
user_id="visitor-id", trace_id="visitor-trace", roles=("visitor",),
permissions=("agent:run",), data_scope="public",
)
await agent.recall_memory(request, context)
assert agent.memories == ()
@pytest.mark.asyncio
async def test_authenticated_customer_service_does_not_recall_customer_memory() -> None:
"""客服即使面对已登录用户,也不得触发通用长期或画像记忆读取。"""
class FailingGovernance:
async def recall(self, context):
raise AssertionError("customer service memory recall is forbidden")
agent = CustomerServiceAgent()
agent.bind_governance(FailingGovernance())
request = AgentRequest(
agent_type="customer_service", message="查询我的持仓", session_id="customer-session",
idempotency_key="customer-memory-request-0001",
)
context = RequestContext(
user_id="1", trace_id="customer-trace", roles=("customer",),
permissions=("agent:run",), data_scope="self",
)
await agent.recall_memory(request, context)
assert agent.memories == ()
async def test_resolve_recall_handle_review_order_and_snapshot(governance):
calls = []
config = ResolvedAgentConfig(config_version="released", prompt_version="p", model_endpoint="m")
memory = RecalledMemory(memory_uuid="m1", customer_id="1", content="偏好低风险")
class Governance:
async def resolve(self, definition, context):
calls.append("resolve")
return config
async def recall(self, context):
calls.append("recall")
return (memory,)
async def review(self, result, context, resolved, memories, *, agent_type: str = ""):
calls.append("review")
assert resolved is config
assert memories == (memory,)
return review_output(result, context, resolved, memories)
class Demo(BaseAgent):
async def handle(self, request, context):
calls.append("handle")
assert self.memories == (memory,)
self.config = None
self.memories = ()
self._governance = governance
return CoreResult(text="保证收益")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",),
allowed_portals=("api",))
factory = AgentFactory(Governance())
factory.register(definition, lambda _: Demo(definition))
context = RequestContext(user_id="1", trace_id="t", roles=("customer",),
permissions=("agent:run",))
agent = factory.create("demo", context)
events = [event async for event in agent.execute(
AgentRequest(agent_type="demo", session_id="s", message="test",
idempotency_key="1234567890123456"), context, "r")]
assert calls == ["resolve", "recall", "handle", "review"]
assert "保证收益" not in events[-1].payload["result"]["result"]["text"]
def test_unissued_reference_rejected_and_sensitive_numbers_redacted():
context = RequestContext(user_id="1", trace_id="t")
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
result = AgentResult(run_id="r", result=CoreResult(text="手机号13812345678"))
assert "13812345678" not in review_output(result, context, config, ()).result.text
forged = result.model_copy(update={"result": CoreResult(text="test", source_references=(
SourceReference(source_type="memory", source_id="other-customer"),))})
with pytest.raises(ForbiddenAgentError):
review_output(forged, context, config, ())
async def test_missing_factory_dependencies_fail_closed():
class Demo(BaseAgent):
async def handle(self, request, context):
pytest.fail("must not execute")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("customer",),
allowed_portals=("api",))
context = RequestContext(user_id="1", trace_id="t", roles=("customer",),
permissions=("agent:run",))
with pytest.raises(RecoverableAgentError):
_ = [e async for e in Demo(definition).execute(
AgentRequest(agent_type="demo", message="test", session_id="s",
idempotency_key="1234567890123456"), context, "r")]
# --- 记忆可读范围:员工按 sys_customer_assignment 归属(2026-09-14 拍板口径) -------------
#
# 修复前的行为:`recall()` 把 `int(context.user_id)` 当客户号,于是员工身份**恒空**,
# 且员工号与客户号同号段时会读到陌生客户的记忆并注入提示词。下面这些用例把
# "只能读到该读的"钉死。
class _StubItem:
def __init__(self, memory_uuid: str, content: str, confidence: float) -> None:
self.memory_uuid = memory_uuid
self.content = content
self.confidence = confidence
class _StubResult:
def __init__(self, items: list[_StubItem]) -> None:
self.items = tuple(items)
self.degraded = False
self.degraded_reasons: tuple[str, ...] = ()
self.from_cache = False
class _StubRecallService:
"""按客户号返回记忆的召回服务替身;记录被查过哪些客户。"""
def __init__(self, by_customer: dict[int, list[_StubItem]]) -> None:
self.by_customer = by_customer
self.queried: list[int] = []
async def recall(self, customer_id: int) -> _StubResult:
self.queried.append(customer_id)
return _StubResult(self.by_customer.get(customer_id, []))
class _DummySession:
"""`PlatformGovernance.recall` 会 `async with SessionFactory() as session`;
召回服务是替身、不碰这个 session,所以给个空壳即可,测试不连库。"""
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *exc: object) -> bool:
return False
def _governance_with(
monkeypatch: pytest.MonkeyPatch,
service: _StubRecallService,
) -> PlatformGovernance:
monkeypatch.setattr(governance_module, "SessionFactory", lambda: _DummySession())
return PlatformGovernance(recall_factory=lambda _session: service) # type: ignore[arg-type,return-value]
def _employee_context(user_id: str, customer_ids: tuple[str, ...]) -> RequestContext:
"""员工身份上下文:带 `memory:read:customer` 能力码(读他人客户记忆的授权码)。"""
return RequestContext(
user_id=user_id, trace_id="t", roles=("advisor",), customer_ids=customer_ids,
permissions=(REQUIRED_EMPLOYEE_PERMISSION,),
)
async def test_employee_recall_reads_assigned_customers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""员工(投顾/风控)按归属读到客户记忆,且每条记忆标注其真实归属客户号。"""
service = _StubRecallService({
9001: [_StubItem("uuid-a", "偏好进取型", 0.9)],
9102: [_StubItem("uuid-b", "持有约三年", 0.5)],
})
governance = _governance_with(monkeypatch, service)
context = _employee_context("9020", ("9102", "9001"))
memories = await governance.recall(context)
assert service.queried == [9001, 9102] # 升序,确定性
# 置信度降序:uuid-a(0.9) 在前;归属客户号必须逐条正确,否则提示词会张冠李戴。
assert [(memory.memory_uuid, memory.customer_id) for memory in memories] == [
("uuid-a", "9001"), ("uuid-b", "9102"),
]
async def test_employee_without_assignment_reads_nothing_and_skips_database(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""归属未维护 ⇒ 失败关闭,且**连召回服务都不该被调用**(不做无谓的库/向量查询)。"""
service = _StubRecallService({9020: [_StubItem("uuid-self", "不该被读到", 0.9)]})
governance = _governance_with(monkeypatch, service)
context = _employee_context("9020", ())
memories = await governance.recall(context)
assert memories == ()
# 关键:员工号 9020 没有被当成客户号 —— 否则这里会读到"同号客户"的记忆。
assert service.queried == []
async def test_employee_without_capability_reads_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""有归属行但缺 `memory:read:customer` 能力码 ⇒ 同样失败关闭,且不查库。"""
service = _StubRecallService({9001: [_StubItem("uuid-a", "不该被读到", 0.9)]})
governance = _governance_with(monkeypatch, service)
context = RequestContext(
user_id="9005", trace_id="t", roles=("operator",), customer_ids=("9001",),
permissions=("offsite:write",),
)
memories = await governance.recall(context)
assert memories == ()
assert service.queried == []
async def test_customer_recall_ignores_assignments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""客户身份只读自己:分配表里有别行也不能顺带读别人。"""
service = _StubRecallService({
9001: [_StubItem("uuid-self", "自己的偏好", 0.9)],
9102: [_StubItem("uuid-other", "别人的偏好", 0.9)],
})
governance = _governance_with(monkeypatch, service)
context = RequestContext(user_id="9001", trace_id="t", roles=("customer",),
customer_ids=("9102",))
memories = await governance.recall(context)
assert service.queried == [9001]
assert [memory.memory_uuid for memory in memories] == ["uuid-self"]
def test_review_output_accepts_assigned_customer_memory_reference() -> None:
"""员工引用**归属客户**的记忆不算伪造引用;引用非归属客户的记忆必须被拦。"""
config = ResolvedAgentConfig(config_version="1", prompt_version="p", model_endpoint="m")
context = _employee_context("9020", ("9001",))
recalled = (RecalledMemory(memory_uuid="uuid-a", customer_id="9001", content="偏好进取型"),)
def _result(memory_uuid: str) -> AgentResult:
return AgentResult(run_id="r", result=CoreResult(
text="分析如下",
source_references=(SourceReference(source_type="memory", source_id=memory_uuid),),
))
# 归属客户:放行(修复前 known 恒空,这里会抛 ForbiddenAgentError)。
assert review_output(_result("uuid-a"), context, config, recalled).result.text == "分析如下"
# 非归属客户:即便被塞进 memories 也必须被拦 —— 范围判定与召回共用同一口径。
forged = (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),)
with pytest.raises(ForbiddenAgentError):
review_output(_result("uuid-x"), context, config, forged)
async def test_recall_scope_guard_rejects_memory_outside_assignments() -> None:
"""`BaseAgent` 的范围守卫:越界记忆必须让整条运行失败关闭。"""
class StubGovernance:
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]:
return (RecalledMemory(memory_uuid="uuid-x", customer_id="9999", content="他人记忆"),)
class Demo(BaseAgent):
async def handle(self, request, context):
pytest.fail("越界记忆不得进入 handle")
definition = AgentDefinition(agent_type="demo", version="1", allowed_roles=("advisor",),
allowed_portals=("api",))
agent = Demo(definition)
agent.bind_governance(StubGovernance()) # type: ignore[arg-type]
request = AgentRequest(agent_type="demo", message="看看客户", session_id="s",
idempotency_key="recall-scope-guard-0001")
with pytest.raises(RecoverableAgentError, match="越过客户范围"):
await agent.recall_memory(request, _employee_context("9020", ("9001",)))