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:
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user