分支 4 是最难构造的一种,两个前提缺一不可:
1. **工具的角色集合必须比 Agent 的更窄**。Agent 层的 validate_access(base.py:101)会先按
AgentDefinition.allowed_roles 拦截,两者一致时永远进不到工具层的角色校验。所以把
probe_alt 收窄为 ("risk_operator",),而 Agent 仍允许 admin。
2. **调用者必须有工具要求的权限**,否则会先命中权限分支。所以脚本临时给 admin 授
probe:read,验证后撤销。
过程中又修掉一处自己写错的地方:探针的 handle 原先硬编码调用 PROBE_TOOL,导致分支 4
(需要调 probe_alt)与分支 2(需要调白名单之外的那一个)互相干扰——第一次跑出来的结果
是"工具不在当前意图白名单"。改为按消息里的 "alt" 选择要调的工具。
四种分支的实测结果,message 各自独立、指向不同处置动作:
- 白名单为空 → 该意图未配置工具白名单
- 工具不在白名单 → 工具不在当前意图白名单
- 缺少工具权限 → 缺少工具权限
- 角色不符 → 角色不能使用工具
目标的另一半也验证了:审计里是完整细节(reason = "角色 ['admin'] 与工具允许的角色
['risk_operator'] 无交集",并带 tool_name / intent / trace_id),而异常 message 只有
"角色不能使用工具"、不含角色集合。**内部配置只进审计,不进客户可见响应。**
环境复原:生效配置 9 条(与起点一致);sys_permission / sys_role_permission 中
probe:read 的行数为 0。
80 lines
3.5 KiB
Python
80 lines
3.5 KiB
Python
"""基座验证探针:端到端触发 `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"
|
||
# 第二个探针工具。**存在的唯一理由**:让"工具不在白名单"这一分支可被触发。
|
||
# `governance.resolve` 取的是「发布白名单 ∩ 代码声明的 allowed_tools」,配置**只能缩小
|
||
# 不能放大**;所以 Agent 只有一个工具时,白名单要么为空、要么正好包含它,
|
||
# 永远构造不出"有白名单、但不含被调用的那个工具"的场景——这是我做端到端验证时撞到的。
|
||
PROBE_ALT_TOOL = "probe_alt"
|
||
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}
|
||
|
||
|
||
async def probe_alt_tool(arguments: BaseModel, context: RequestContext) -> dict[str, Any]:
|
||
"""备用探针工具:只为让"工具不在白名单"的分支可被构造,与 probe_echo 同样只读。"""
|
||
return {"alt": True, "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, PROBE_ALT_TOOL),
|
||
supported_intents=(INTENT_PROBE,),
|
||
)
|
||
|
||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||
# 按消息里的 "alt" 决定调哪个探针工具。两个工具都必须可被指定调用:
|
||
# 分支 2(工具不在白名单)要调白名单之外的那个,分支 4(角色不符)要调角色更窄的
|
||
# 那个——硬编码其中一个的话,这两个分支会互相干扰(实测踩到过)。
|
||
tool = PROBE_ALT_TOOL if "alt" in request.message.lower() else PROBE_TOOL
|
||
output = await self.call_tool(
|
||
tool,
|
||
{"note": request.message[:50]},
|
||
intent=INTENT_PROBE,
|
||
context=context,
|
||
)
|
||
return CoreResult(text=f"probe ok: {output}", intent=self._classified_intent)
|