diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index 76c33ac..b0898f0 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -17,6 +17,13 @@ from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance from app.service.agent.implementations.customer_service import CustomerServiceAgent 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.fund_quote_service import query_fund_quote_tool from app.service.intent_classifier import IntentClassifier @@ -171,6 +178,15 @@ def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService: def get_agent_factory() -> AgentFactory: """HTTP 与 Worker 共用的唯一底座依赖组装入口。""" 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( name="check_suitability", input_model=SuitabilityToolInput, @@ -263,3 +279,9 @@ def register_business_agents(factory: AgentFactory) -> None: RiskAgent.definition, lambda _context: RiskAgent(RiskAgent.definition), ) + # 基座验证探针:只读、无副作用,用于端到端验证工具链路的拒绝行为。 + # 角色限定 admin,业务上不对外暴露用途。 + factory.register( + PlatformProbeAgent.definition, + lambda _context: PlatformProbeAgent(PlatformProbeAgent.definition), + ) diff --git a/app/service/agent/implementations/platform_probe.py b/app/service/agent/implementations/platform_probe.py new file mode 100644 index 0000000..5dc2b33 --- /dev/null +++ b/app/service/agent/implementations/platform_probe.py @@ -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) diff --git a/app/service/tool_executor.py b/app/service/tool_executor.py index 3d2623f..49c4859 100644 --- a/app/service/tool_executor.py +++ b/app/service/tool_executor.py @@ -75,18 +75,25 @@ class ToolExecutor: # 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。 reason = "" detail = "" - if intent not in configured_tools: - reason = "该意图未配置工具白名单" - detail = ( - f"意图 {intent!r} 在发布版本里没有任何工具白名单" - f"(agent_tools / :{intent}),工具失败关闭" - ) - elif name not in configured_tools[intent]: - reason = "工具不在当前意图白名单" - detail = ( - f"工具 {name!r} 不在意图 {intent!r} 的白名单 " - f"{list(configured_tools[intent])} 内" - ) + configured = configured_tools.get(intent, ()) + if name not in configured: + if not configured: + # 白名单为空。**注意**:经过 governance 装配后,"意图完全没配"与"配了空列表" + # 在这里无法区分——governance 会为每个 supported_intent 预填条目,所以 + # `intent not in configured_tools` 这个判断在真实链路上永远不会成立 + # (这一点是端到端跑探针才发现的,单元测试里手工构造空 dict 掩盖了它)。 + # 好在两者的运维动作相同:都该去看 agent_tools 里 : + # 这一项,所以合并成一句准确的话,不假装能区分。 + reason = "该意图未配置工具白名单" + detail = ( + f"意图 {intent!r} 的工具白名单为空:发布配置里没有为它配置任何工具" + f"(agent_tools / :{intent}),工具失败关闭" + ) + else: + reason = "工具不在当前意图白名单" + detail = ( + f"工具 {name!r} 不在意图 {intent!r} 的白名单 {list(configured)} 内" + ) elif definition.required_permission not in context.permissions: reason = "缺少工具权限" detail = f"缺少权限 {definition.required_permission!r}" diff --git a/tests/unit/service/test_tool_executor_denials.py b/tests/unit/service/test_tool_executor_denials.py index 00a54db..a4c0e9e 100644 --- a/tests/unit/service/test_tool_executor_denials.py +++ b/tests/unit/service/test_tool_executor_denials.py @@ -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={}) detail = str(audits[0][3]) - assert "没有任何工具白名单" in detail + assert "工具白名单为空" 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