test(base): 造只读探针端到端验证工具拒绝,并修掉它暴露的一个死分支

**为什么造探针**:ToolExecutor 的四种拒绝在真实链路上很难安全触发——要么改客服、风控的
生效配置,要么动 RBAC,两条路都会影响正在工作的 Agent。platform_probe 是个只读、无副作用
的探针:它只声明 probe 一个意图(所以意图分类只可能返回它)、没有发布工具白名单
(天然处于"未配置"状态)、工具只回显参数不碰业务数据。

**它立刻查出一个死分支**:探针报的是「工具不在当前意图白名单」,而不是我新加的
「该意图未配置工具白名单」。原因是 governance.resolve 会为每个 supported_intents
**预填条目**(governance.py:55-61),未配置时得到的是**空元组**——所以
intent not in configured_tools 在运行期**永远不成立**,那个分支是死代码。

单元测试没能发现它,因为我在测试里手工构造了 configured={},而真实链路不产生这个形状。
**这正是端到端测试的价值**:单元测试验证的是我设想的形状,端到端验证的是真实形状。

修法:改判"白名单为空"而非"缺键",文案改为"该意图的工具白名单为空",并注明经过 governance
装配后"完全没配"与"配了空列表"无法区分、也不假装能区分(两者运维动作相同)。新增一条按
**真实形状**({"faq": ()})构造的用例把它锁住。

实测:探针调用 → failed / AGENT_PERMISSION_DENIED,stderr 为
ForbiddenAgentError: 该意图未配置工具白名单(tool_executor.py:108)。

ruff / mypy(136 文件) / 611 unit+contract 全绿。
This commit is contained in:
2026-09-11 13:10:04 +08:00
parent cdbd85b27c
commit edc0c43245
4 changed files with 125 additions and 13 deletions
+22
View File
@@ -17,6 +17,13 @@ from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance from app.service.agent.governance import PlatformGovernance
from app.service.agent.implementations.customer_service import CustomerServiceAgent from app.service.agent.implementations.customer_service import CustomerServiceAgent
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
from app.service.agent.implementations.platform_probe import (
PROBE_PERMISSION,
PROBE_TOOL,
PlatformProbeAgent,
ProbeEchoArgs,
probe_echo_tool,
)
from app.service.agent.implementations.risk_agent import RiskAgent from app.service.agent.implementations.risk_agent import RiskAgent
from app.service.fund_quote_service import query_fund_quote_tool from app.service.fund_quote_service import query_fund_quote_tool
from app.service.intent_classifier import IntentClassifier from app.service.intent_classifier import IntentClassifier
@@ -171,6 +178,15 @@ def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService:
def get_agent_factory() -> AgentFactory: def get_agent_factory() -> AgentFactory:
"""HTTP 与 Worker 共用的唯一底座依赖组装入口。""" """HTTP 与 Worker 共用的唯一底座依赖组装入口。"""
registry = ToolRegistry() registry = ToolRegistry()
# 基座验证探针的工具:只回显参数,用于端到端触发 ToolExecutor 的四种拒绝分支。
# 它不查库、不写状态,注册在这里也不会被任何业务 Agent 的白名单引用。
registry.register(ToolDefinition(
name=PROBE_TOOL,
input_model=ProbeEchoArgs,
handler=cast(Any, probe_echo_tool),
required_permission=PROBE_PERMISSION,
allowed_roles=("admin",),
))
registry.register(ToolDefinition( registry.register(ToolDefinition(
name="check_suitability", name="check_suitability",
input_model=SuitabilityToolInput, input_model=SuitabilityToolInput,
@@ -263,3 +279,9 @@ def register_business_agents(factory: AgentFactory) -> None:
RiskAgent.definition, RiskAgent.definition,
lambda _context: RiskAgent(RiskAgent.definition), lambda _context: RiskAgent(RiskAgent.definition),
) )
# 基座验证探针:只读、无副作用,用于端到端验证工具链路的拒绝行为。
# 角色限定 admin,业务上不对外暴露用途。
factory.register(
PlatformProbeAgent.definition,
lambda _context: PlatformProbeAgent(PlatformProbeAgent.definition),
)
@@ -0,0 +1,66 @@
"""基座验证探针:端到端触发 `ToolExecutor` 的四种拒绝分支。
**为什么专门做一个 Agent**:`ToolExecutor` 的四种拒绝(意图未配置工具白名单 / 工具不在
白名单 / 缺权限 / 角色不符)在真实链路上很难**安全**触发 —— 要么去改客服、风控的生效配置,
要么去动 RBAC,两条路都会影响正在工作的 Agent。用一个只读、无副作用的探针把这件事隔离出来。
**它不碰任何业务数据**:唯一的工具只回显调用方给的参数,不查库、不调模型、不写状态。
角色限定为 `admin`,意图只有 `probe` 一个。
用法见 `tools/verify_tool_executor_denials.py`:它按顺序调整探针的意图配置与工具白名单,
分别触发四种拒绝,最后把配置恢复到验证前的状态。
"""
from typing import Any
from pydantic import BaseModel, ConfigDict
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
)
from app.service.agent.base import BaseAgent
AGENT_TYPE = "platform_probe"
INTENT_PROBE = "probe"
PROBE_TOOL = "probe_echo"
PROBE_PERMISSION = "probe:read"
class ProbeEchoArgs(BaseModel):
"""严格入参:探针不接收自由文本,避免被当成通用执行入口。"""
model_config = ConfigDict(extra="forbid", frozen=True)
note: str = ""
async def probe_echo_tool(arguments: BaseModel, context: RequestContext) -> dict[str, Any]:
"""回显参数。**只读且无副作用** —— 不查库、不写状态、不调外部服务。"""
note = getattr(arguments, "note", "")
return {"echo": note, "trace_id": context.trace_id}
class PlatformProbeAgent(BaseAgent):
"""基座探针:只用于验证工具链路的拒绝行为,不承载任何业务。"""
definition = AgentDefinition(
agent_type=AGENT_TYPE,
version="1.0.0",
allowed_roles=("admin",),
allowed_portals=("api",),
allowed_tools=(PROBE_TOOL,),
supported_intents=(INTENT_PROBE,),
)
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
# 走公共工具链路:探针要验证的正是这条路上的白名单、权限与角色校验。
output = await self.call_tool(
PROBE_TOOL,
{"note": request.message[:50]},
intent=INTENT_PROBE,
context=context,
)
return CoreResult(text=f"probe ok: {output}", intent=self._classified_intent)
+19 -12
View File
@@ -75,18 +75,25 @@ class ToolExecutor:
# 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。 # 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。
reason = "" reason = ""
detail = "" detail = ""
if intent not in configured_tools: configured = configured_tools.get(intent, ())
reason = "该意图未配置工具白名单" if name not in configured:
detail = ( if not configured:
f"意图 {intent!r} 在发布版本里没有任何工具白名单" # 白名单为空。**注意**:经过 governance 装配后,"意图完全没配"与"配了空列表"
f"(agent_tools / <agent_type>:{intent}),工具失败关闭" # 在这里无法区分——governance 会为每个 supported_intent 预填条目,所以
) # `intent not in configured_tools` 这个判断在真实链路上永远不会成立
elif name not in configured_tools[intent]: # (这一点是端到端跑探针才发现的,单元测试里手工构造空 dict 掩盖了它)。
reason = "工具不在当前意图白名单" # 好在两者的运维动作相同:都该去看 agent_tools 里 <agent_type>:<intent>
detail = ( # 这一项,所以合并成一句准确的话,不假装能区分。
f"工具 {name!r} 不在意图 {intent!r} 的白名单 " reason = "该意图未配置工具白名单"
f"{list(configured_tools[intent])} 内" detail = (
) f"意图 {intent!r} 的工具白名单为空:发布配置里没有为它配置任何工具"
f"(agent_tools / <agent_type>:{intent}),工具失败关闭"
)
else:
reason = "工具不在当前意图白名单"
detail = (
f"工具 {name!r} 不在意图 {intent!r} 的白名单 {list(configured)} 内"
)
elif definition.required_permission not in context.permissions: elif definition.required_permission not in context.permissions:
reason = "缺少工具权限" reason = "缺少工具权限"
detail = f"缺少权限 {definition.required_permission!r}" detail = f"缺少权限 {definition.required_permission!r}"
@@ -124,5 +124,22 @@ async def test_audit_names_what_to_fix_when_config_is_absent() -> None:
await _reject(executor, _context(permissions=(PERMISSION,)), intent="faq", configured={}) await _reject(executor, _context(permissions=(PERMISSION,)), intent="faq", configured={})
detail = str(audits[0][3]) detail = str(audits[0][3])
assert "没有任何工具白名单" in detail assert "工具白名单为空" in detail
assert "faq" in detail assert "faq" in detail
@pytest.mark.asyncio
async def test_empty_whitelist_is_what_the_real_pipeline_produces() -> None:
"""真实链路里拿到的是**空元组**,而不是"缺键"。
`governance.resolve` 会为每个 `supported_intents` 预填条目(governance.py:55-61),
所以 `intent not in configured_tools` 这个判断在运行期**永远不成立** —— 第一版就是
那么写的,而这条用例原先用 `configured={}` 手工构造,把它掩盖了,直到端到端跑
探针 Agent 才暴露出来。现在按真实形状构造。
"""
executor, _ = _executor()
context = _context(permissions=(PERMISSION,))
message = await _reject(executor, context, intent="faq", configured={"faq": ()})
assert "未配置" in message