Files
group_fqcd_jr/app/service/tool_executor.py
T
lzf_0626 edc0c43245 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 全绿。
2026-09-11 13:10:04 +08:00

144 lines
6.4 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 asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
from pydantic import BaseModel, ValidationError
from app.core.contracts import RequestContext, SourceReference, ToolCallRecord
from app.core.errors import (
DependencyUnavailableError,
ForbiddenAgentError,
UpstreamTimeoutError,
ValidationAgentError,
)
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
class ToolHandler(Protocol):
async def __call__(self, arguments: BaseModel, context: RequestContext) -> Any: ...
@dataclass(frozen=True)
class ToolDefinition:
name: str
input_model: type[BaseModel]
handler: ToolHandler
required_permission: str
allowed_roles: tuple[str, ...]
read_only: bool = True
timeout_seconds: float = 5
@dataclass(frozen=True)
class ToolExecution:
output: Any
record: ToolCallRecord
references: tuple[SourceReference, ...] = ()
class ToolRegistry:
def __init__(self) -> None:
self._tools: dict[str, ToolDefinition] = {}
def register(self, definition: ToolDefinition) -> None:
if definition.name in self._tools:
raise ValidationAgentError("工具名称重复")
if not definition.read_only:
raise ValidationAgentError("Agent 公共工具仅允许只读")
self._tools[definition.name] = definition
def get(self, name: str) -> ToolDefinition:
definition = self._tools.get(name)
if definition is None:
raise ForbiddenAgentError("工具未注册")
return definition
class ToolExecutor:
def __init__(self, registry: ToolRegistry) -> None:
self.registry = registry
async def execute(
self, *, name: str, arguments: dict[str, Any], intent: str,
configured_tools: dict[str, tuple[str, ...]], context: RequestContext,
) -> ToolExecution:
definition = self.registry.get(name)
# 把三种"用不了"分开,并且**审计写详细、异常给通用**:
#
# 原先前两种情况共用一句"工具不在当前意图白名单",运维无法判断该去补发布配置、
# 还是该改白名单内容——本项目已经因此踩坑两次(客服、风控的意图码都要求三处对齐,
# 而缺配置时是静默失败关闭)。权限与角色两处也只说"缺少工具权限",不说是哪一个。
#
# 细节只进审计:异常 message 会随 API 响应返回给调用方,
# 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。
reason = ""
detail = ""
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 里 <agent_type>:<intent>
# 这一项,所以合并成一句准确的话,不假装能区分。
reason = "该意图未配置工具白名单"
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:
reason = "缺少工具权限"
detail = f"缺少权限 {definition.required_permission!r}"
elif not set(definition.allowed_roles).intersection(context.roles):
reason = "角色不能使用工具"
detail = (
f"角色 {list(context.roles)} 与工具允许的角色 "
f"{list(definition.allowed_roles)} 无交集"
)
if reason:
await self._audit(name, intent, context, "denied", detail)
raise ForbiddenAgentError(reason)
try:
validated = definition.input_model.model_validate(arguments)
except ValidationError as exc:
await self._audit(name, intent, context, "denied", "参数校验失败")
raise ValidationAgentError("工具参数校验失败") from exc
try:
async with asyncio.timeout(definition.timeout_seconds):
output = await definition.handler(validated, context)
except TimeoutError as exc:
await self._audit(name, intent, context, "failed", "timeout")
raise UpstreamTimeoutError("工具调用超时") from exc
except Exception as exc:
await self._audit(name, intent, context, "failed", type(exc).__name__)
raise DependencyUnavailableError("工具调用失败") from exc
record = ToolCallRecord(
tool_name=name, status="succeeded",
input_summary={key: "[redacted]" for key in arguments},
output_summary={"result_type": type(output).__name__},
)
await self._audit(name, intent, context, "succeeded", "ok")
reference = SourceReference(source_type="tool", source_id=f"{context.trace_id}:{name}",
title=name)
return ToolExecution(output=output, record=record, references=(reference,))
async def _audit(
self, name: str, intent: str, context: RequestContext, status: str, reason: str
) -> None:
async with SessionFactory() as session, session.begin():
session.add(InteractionAudit(
actor_type="agent", actor_id=int(context.user_id), portal=context.portal,
action_type="agent.tool_executed", detail={
"tool_name": name, "intent": intent, "status": status,
"reason": reason, "trace_id": context.trace_id,
}, created_at=datetime.now(UTC).replace(tzinfo=None),
))