关键词:Agent 工具源、OpenAPI 工具清单、AI Agent 接入业务系统、工具清单签名、HMAC 验签、接口清单泄露、MCP 工具安全、BailingHub、百灵中枢
当企业准备让 AI Agent 查询订单、修改库存、创建工单或发起退款时,通常要先给它一份“能做什么”的清单。
这份清单可能来自 OpenAPI,也可能由业务侧 SDK 自动生成,然后挂在一个固定地址上:
https://business.example.com/.well-known/bailing/tools.json
Agent 控制面定期拉取它,就能知道业务系统新增了哪些工具、参数怎么填写、哪些操作需要可信主体、哪些操作风险较高。
这种方式非常适合自动化接入,但也带来一个经常被忽略的问题:
只要知道这个 JSON 地址,任何人都可以下载整份工具清单吗?
如果答案是“可以”,别人看到的可能不只是几个普通接口名称,还包括退款、库存调整、员工冻结、客户资料导出等业务能力,以及它们的路径、参数、scope、风险和审批提示。
这些信息通常不应该包含密码或 Token,但它们仍然是一份机器可读的业务能力地图。
因此,真正需要讨论的不是“JSON 文件能不能放到网上”,而是:
这份能力清单是否本来就希望被公开发现?如果不希望公开,系统能不能证明未签名和错误签名的请求确实读不到它?
本文会把这个问题拆成清单读取、工具暴露、请求认证和业务授权四层,并结合开源项目 BailingHub(百灵中枢) v0.2.0 的真实实现,给出一套可配置、可验证、可兼容升级的处理方式。
一、OpenAPI 工具清单里到底暴露了什么
OpenAPI Specification 的价值,就是让人和机器在不阅读源码的情况下理解一个 HTTP 服务能做什么。它可以被用于生成文档、客户端、测试以及 Agent 工具定义。
例如,一项退款能力可能被描述为:
openapi: 3.1.0
paths:
/orders/{
order_id}/refund:
post:
operationId: order_refund
summary: 为指定订单发起退款
x-agent-capability:
version: 1
enabled: true
scope: order.refund.create
risk:
level: high
subject:
required: true
approval:
required: true
parameters:
- name: order_id
in: path
required: true
schema:
type: string
这段声明没有包含数据库密码,也没有直接赋予调用权限,但它已经告诉读取者:
- 系统存在退款能力;
- 接口路径和 HTTP 方法是什么;
- 业务动作需要哪些参数;
- 它属于哪个业务 scope;
- 操作风险较高,并且需要可信主体和人工审批。
如果清单继续包含内部域名、历史版本、调试接口、过度详细的错误说明或尚未下线的操作,它还可能帮助外部人员更快理解系统暴露面。
这并不意味着“藏住 OpenAPI 就安全了”。真正的工具接口仍然必须做好认证、授权、参数校验、限流和审计。隐藏清单不能代替这些安全措施。
但反过来也不能说:“反正接口最终还要鉴权,所以工具清单随便公开也没关系。”
清单是否公开,本来就应该是一项明确的架构决定。
二、公开工具清单不一定错,但必须是主动选择
有些工具清单适合公开:
- 开源 Demo,希望开发者直接理解并体验能力;
- 公共 API 或公开工具目录,本来就需要被生态发现;
- 面向第三方开发者的平台,接口路径和参数已经是公开产品的一部分;
- 只包含无敏感业务含义的公开查询能力。
另一些清单更适合受保护:
- 企业内部 ERP、CRM、客服、财务或运维系统;
- 包含退款、库存调整、账号冻结、批量通知等写操作;
- 描述租户、权限、风险等级或内部审批语义;
- 只有指定控制面或网关需要读取;
- 同一个 URL 位于公网,但其内容只服务于服务器之间的自动同步。
所以更合理的默认原则不是“所有 OpenAPI 都必须公开”或者“所有 OpenAPI 都必须隐藏”,而是:
明确希望公开发现
-> 显式选择公开
只供受信控制面读取
-> 默认要求签名保护
OpenAPI 官方规范中的 Security Filtering 也明确允许对接口描述本身增加访问控制,甚至根据调用者身份只展示部分路径和操作。OWASP API Security 的 API9:2023 Improper Inventory Management 同样强调 API 清单、环境、版本和文档访问范围需要被持续管理。
关键不是使用哪个框架,而是把“是否允许匿名读取”从模糊默认值变成可审查的配置意图。
三、清单读取、工具暴露和业务授权是不同的门
很多设计混乱,来自把下面几件事放在一起讨论:
| 层次 | 要回答的问题 | 典型控制 |
|---|---|---|
| 清单读取 | 谁能下载 OpenAPI / Agent 工具目录? | URL 访问策略、签名、缓存控制 |
| 工具暴露 | 哪些 operation 可以进入当前 Agent 的可见范围? | 显式 opt-in、scope、路由白名单 |
| 请求认证 | 这次工具请求是否确实来自受信控制面,内容是否被篡改? | HMAC、时间戳、HTTPS |
| 业务授权 | 当前主体此刻能否对这个业务对象执行动作? | 租户、原权限、对象状态、审批、业务规则 |
它们必须分别成立。
工具是否应该被 Agent 看见,和谁能下载这份工具目录,是两道不同的门。即使一项能力声明了 enabled: false,整份 OpenAPI 仍可能暴露其他内部路径;即使清单受到签名保护,也不代表当前用户已经获得退款权限。
同样,OpenAPI 中的 Security Scheme Object 描述的是 API 调用采用什么认证机制,它不会自动保护承载这份 OpenAPI 文档的 URL,更不会替业务系统完成租户隔离和最终授权。
因此,本文讨论的是第一道门:谁能读取能力目录。
后面的工具调用验签、可信主体、审批、幂等和最终业务授权仍然要独立设计。
四、为什么“中枢带了签名”仍然不能证明清单受到保护
假设控制面每次拉取工具清单时都附带正确签名,并且业务端返回了 200。
很多系统会据此得出结论:
签名请求成功,所以这个地址已经受到签名保护。
这个结论并不成立。
业务端可能只是收到了签名,但从未校验;也可能校验失败后仍继续返回正文;甚至可能由 CDN、Nginx 静态规则或错误路由直接绕过应用层,把同一份文件公开返回。
下面三组结果都能让“带正确签名的请求”成功:
| 正确签名 | 未签名 | 错误签名 | 实际结论 |
|---|---|---|---|
| 200 | 401 | 401 | 可以证明端点拒绝了两类负向请求 |
| 200 | 200 | 200 | 端点实际上是公开的,签名没有形成访问边界 |
| 200 | 500 | 超时 | 无法可靠判断,不能假装已经验证 |
所以验证一项安全控制不能只测“合法请求能不能通过”,还要测“非法请求是不是确实被拒绝”。
这也是为什么我们最终没有只给 BailingHub 增加一个“请求时带 HMAC”的开关,而是加入了正向请求和两类负向探针。
五、BailingHub v0.2.0:把公开意图和实际结果分开
BailingHub 是一个采用 Apache 2.0 协议开源、可以自托管的 Agent-to-Business(A2B)控制面。它把业务触发、路由、工具源、可信主体、审批、任务状态和审计放进一条可运行链路,同时让业务系统继续保留最终授权。
在开发 v0.2.0 时,我们把 URL 工具清单的访问策略收敛成两个正式选项:
| 策略 | 含义 | 默认行为 |
|---|---|---|
signed_required |
只允许能够生成正确 HMAC 的受信方读取 | 新建 URL 工具源的默认值 |
public_allowed |
管理员明确接受匿名访问者读取工具清单 | 必须显式选择并再次确认 |
这里没有“自动猜测”。
如果管理员选择 signed_required,中枢不能因为自己发出了一次带签名请求就显示“已保护”;它还必须通过负向探针观察业务端是否真的拒绝未签名和错误签名。
控制台因此分别显示两类信息:
期望:signed_required
实测:protected / public / inconclusive
“期望”来自管理员配置,“实测”来自 HTTP 证据。两者不一致时必须暴露问题,不能用配置值冒充已经验证的事实。
反过来,如果管理员选择 public_allowed,BailingHub 只发送未签名请求。公开模式失败时不会偷偷改用签名再拉一次,因为那会让系统表面上显示“公开可用”,实际却依赖一个没有被声明的秘密通道。
这项能力完全属于 BailingHub 的工具源发现面。v0.2.0 没有修改 Agent Capability Contract(ACC,Agent 能力契约)、Client API、工具调用签名或业务授权语义,也没有把“能读取清单”写成“有权调用工具”。
六、三次请求怎样形成可审查的保护证据
在 signed_required 下,一次刷新包含三类请求:
| 请求 | 预期结果 | 用途 |
|---|---|---|
| 正确签名 | 2xx | 证明受信控制面可以取得有效清单 |
| 完全未签名 | 401 / 403 / 404 | 证明匿名读取被拒绝 |
| 使用错误签名 | 401 / 403 / 404 | 证明端点不是“只检查头是否存在” |
只有三项同时满足,并且返回内容能被正确解析为工具清单时,BailingHub 才把实测状态记录为 protected 并替换缓存。
其他结果需要明确分类:
- 任一负向请求返回 2xx:记为
public,说明配置与实际暴露面不一致; - 重定向、429、5xx、网络失败或超时:记为
inconclusive,说明现有证据不足; - 正确签名请求返回 404:这是主请求失败,不是“拒绝未授权访问”的证据;
public_allowed下未签名请求成功:记为public,与管理员意图一致;public_allowed下端点实际要求签名:刷新失败,不使用秘密签名兜底。
这里最重要的工程原则是:
不能确认保护有效时,不把未知状态包装成安全状态。
七、签名到底覆盖什么
BailingHub 的工具调用和工具清单拉取共用同一套 HMAC-SHA256 构造。HMAC 的基本定义可以参考 RFC 2104。
签名材料为:
<timestamp>.<METHOD>.<path?query>.<sha256(body)>.<On-Behalf-Of>.<Job-Id>
最终请求头形如:
X-Bailing-Timestamp: <unix-seconds>
X-Bailing-Signature: sha256=<hmac-sha256-hex>
工具清单通常使用 GET,请求体为空,也没有操作主体和任务 ID,因此对应字段按空串进入同一套签名材料。
这套签名解决的是请求来源和关键请求内容的完整性校验,不提供内容加密。清单传输仍然应该使用 HTTPS;签名也不能替代业务工具接口自身的身份、权限和审批校验。
如果团队采用其他 HTTP 消息签名方案,也应至少覆盖时间、方法、目标路径和内容摘要,并明确重放窗口、密钥轮换和失败语义。不要只对请求体做签名,却把方法、路径和重要上下文留在签名之外。
八、ThinkPHP 业务系统怎样发布受保护的工具清单
BailingHub 的 PHP SDK 可以从注解或 Builder 生成 OpenAPI 工具清单,也可以帮助业务端验证清单拉取签名。
下面是一个最小 ThinkPHP 路由示例:
use think\facade\Route;
use Bailing\Connect\SpecBuilder;
use Bailing\Connect\SpecServer;
Route::get('.well-known/bailing/tools.json', function () {
$secret = config('bailing.tool_secret');
$spec = (new SpecBuilder(title: '订单业务系统'))
->addClass(OrderToolController::class);
[$status, $body] = SpecServer::handle(
$spec,
$secret,
request()->method(),
request()->url(),
request()->header()
);
$response = response($body, $status);
$response->header(SpecServer::responseHeaders($secret));
return $response;
});
这里有三个关键点:
$secret必须与 BailingHub 控制台中该工具源配置的独立密钥一致;SpecServer::handle()会校验时间戳和签名,SDK 验签失败默认返回 401;自定义端点也可以用 403 或 404 拒绝负向请求;responseHeaders($secret)会为受保护响应增加Cache-Control: private, no-store,避免代理或 CDN 缓存后旁路公开。
如果这份清单本来就希望公开,代码和控制台都应当明确表达公开意图:
[$status, $body] = SpecServer::handlePublic(
$spec,
request()->method(),
request()->url(),
request()->header()
);
同时在 BailingHub 中选择 public_allowed。旧版 SDK 使用 null 表达公开的写法仍然兼容,但新代码使用带 Public 的显式方法,更容易让代码审查发现这一项安全决定。
中枢侧的配置步骤是:
工具源
-> 新建或编辑
-> 清单来源选择“从 URL 拉取”
-> 填写 spec_url
-> 填写独立签名密钥
-> 选择“签名保护(推荐)”
-> 保存并刷新
刷新后不要只看“成功”,还要确认控制台出现类似证据:
签名 200 / 未签名 401 / 错误签名 401
这三个结果比一个“已开启安全模式”的开关更有价值,因为它们直接说明业务端实际做了什么。
九、为什么刷新失败时应该继续使用旧清单
工具清单会随着业务系统部署而变化。如果一次自动刷新遇到网关故障、错误重定向、临时限流或不完整响应,控制面有两种选择:
选择 A:清空现有工具,整条 Agent 业务链立即不可用
选择 B:拒绝新清单,继续使用上一份已经验证的缓存
BailingHub 选择第二种。
只有访问策略验证和清单解析全部通过后,系统才替换缓存。失败时记录脱敏证据和审计,同时继续服务上一份可用清单。这能避免业务侧一次发布故障直接拖垮所有依赖该工具源的 Agent 会话。
为了避免这项兼容策略变成无限等待,v0.2.0 还增加了几条边界:
- 正签、未签名和错误签名请求各自最多等待 10 秒;
- 两个负向探针并发执行,不把最坏等待叠加成 20 秒;
- 正向清单响应最多读取 5 MiB;
- 正式策略不跟随重定向,避免对跳转后的错误目标形成结论;
- 告警中不回显完整
spec_url,减少内部地址再次泄露。
继续使用旧缓存不是忽略故障,而是在“工具源暂时刷新失败”和“现有 Agent 能否继续工作”之间建立明确的降级边界。管理员仍然能够从体检、审计和探针状态中看到失败并处理。
十、历史工具源升级时,为什么不能替开发者猜
给已有系统增加访问策略时,最容易出现两个极端:
- 把所有历史地址默认标成公开,可能掩盖原本依赖签名的保护意图;
- 把所有历史地址默认标成已保护,又会把未经负向验证的端点包装成安全状态。
BailingHub v0.2.0 使用增量迁移 053_tool_spec_access_policy.sql 增加访问策略和探针字段。升级前已有的 URL 工具源在读取时显示为“待确认(历史配置)”。
这个状态内部称为 legacy_unverified,但它不是第三个公开策略:
- 控制台没有第三个可选按钮;
- API 和公开 Schema 不允许写入这个值;
- 现有缓存继续服务,刷新沿用旧的签名读取行为;
- 修改说明等非清单字段时,不强迫管理员立刻选择;
- 修改
spec_url、密钥、自动刷新、重新启用等会改变读取面的操作前,必须明确选择signed_required或public_allowed。
这样既避免升级断流,也避免系统替历史配置编造一个从未被声明、从未被验证的安全结论。
新建 URL 工具源则没有这项历史包袱,默认直接使用 signed_required。
十一、最容易踩的五个坑
1. 只验证正确签名,不验证未签名和错误签名
正向成功只能证明“合法请求可以用”,不能证明“非法请求被拒绝”。至少要同时覆盖未签名和错误签名两条负向路径。
2. 应用层受保护,CDN 却缓存了合法响应
如果一次带签名的响应被共享缓存保存,后续匿名请求可能直接命中缓存,不再进入应用验签。受保护清单应返回:
Cache-Control: private, no-store
同时检查 CDN 和反向代理是否覆盖了源站缓存头。
3. /.well-known/ 被宝塔或 Nginx 静态规则截获
一些面板会为证书验证预置 .well-known 规则。动态路由可能因此直接 404,静态文件也可能绕过 PHP 验签被公开直出。
约定路径不是强制的。无法安全调整 Nginx 时,可以把 spec_url 改为固定的非点路径,例如:
https://business.example.com/bailing/tools.json
4. 网关重写了路径或 query,业务端重新拼接后再验签
签名覆盖的是实际请求的 path?query。如果 CDN、网关或框架重写、解码、重排 query,业务端自己重组出来的字符串可能与中枢签出的内容不一致,最终持续返回 401。
优先使用原始请求 URI;存在 base_url 路径前缀时,按 SDK 文档显式传入 spec 中声明的固定路径。
5. 把 public_allowed 理解成“工具也可以公开调用”
public_allowed 只允许匿名读取工具目录。真实工具调用仍然必须校验签名、可信主体、任务身份和业务权限;高风险动作仍应进入审批和执行前校验。
十二、上线前检查清单
如果你的业务系统已经向 Agent 发布 OpenAPI 工具清单,可以按下面顺序检查:
- 清单是否包含内部路径、敏感描述或不应被发现的操作?
- 公开或受保护是否由管理员显式选择,而不是由空密钥、默认路由或历史行为决定?
- 受保护模式下,正确签名是否能够稳定返回有效清单?
- 完全未签名和使用错误签名时,业务端是否分别拒绝?
- 受保护响应是否带
Cache-Control: private, no-store? - 全链路是否使用 HTTPS,代理是否保留签名所覆盖的原始路径?
- 重定向、429、5xx、超时和超大响应是否被判定为无法确认,而不是自动放行?
- 刷新失败时是否保留上一份已验证清单,并留下可排查证据?
- 清单访问、工具暴露、工具请求认证和业务最终授权是否分别实现?
- 历史来源是否要求管理员明确确认,而不是被批量猜成公开或已保护?
其中最简单也最有效的一次现场验证,是直接观察三种请求:
正确签名:?
未签名:?
错误签名:?
如果三个答案都是 200,系统拥有的是“附带了签名的公开请求”,而不是“受到签名保护的工具清单”。
结语
AI Agent 接入业务系统以后,工具清单会逐渐从一次性配置文件变成持续更新的机器接口。谁能读取它、系统如何验证实际暴露面、失败时怎样降级,都应该成为正式设计,而不是依赖一个没人记得的 Nginx 规则或默认参数。
工具清单公开并不天然错误,受保护也不代表完整安全。
更可信的做法是:开发者明确选择公开或签名保护,控制面用正向和负向请求验证真实行为,业务系统再分别守住工具调用认证、可信主体、审批和最终授权。
BailingHub 把这套边界做进了免费开源、可自托管的 v0.2.0。你可以直接使用,也可以审查源码并在自己的网关或控制面实现相同原则。公开项目的价值,不只是给出一个开关,而是把配置、失败语义、兼容迁移、SDK 和验证证据一起交给使用者。
如果你们已经在给 Dify、MCP 客户端、企业 AI 助手或自研 Agent 发布工具目录,最值得先回答的问题是:
你们希望它被公开发现,还是只允许受信控制面读取?这个选择现在是明确配置,还是一个从未验证过的默认行为?
延伸阅读与实际入口
- BailingHub 开源仓库:Apache 2.0 开源,可自托管,包含控制面、控制台、SDK、Schema、Docker Demo 与完整文档。
- BailingHub v0.2.0 Release:查看工具清单访问策略、主动探针、兼容升级和验证范围。
- BailingHub 工具源文档:查看 OpenAPI、
x-agent-capability、工具签名与业务授权边界。 - BailingHub SDK 文档:查看 PHP、PHP7、Node、Python、Java、Go、.NET 与任意语言接入方式。
本文中的工具清单访问策略属于 BailingHub 开源实现,不是 ACC Core 字段,也不要求其他 Agent 平台采用相同配置名。无论使用哪种实现,“清单可读”与“业务动作已授权”都不应被混为一谈。