无秘密代理工具:经得起考验的操作契约
无秘密代理工具通过操作契约、受限参数、人工审批和可审计执行,让凭据留在 AI 进程之外。

代理应该请求执行操作,而不是拿到冒充某个人或服务账户所需的手段。道理听起来很明显,但检查一个典型的代理工具就会发现问题:http_request 函数接受 URL、请求头、方法和请求体,而代理从环境变量中获得 bearer 令牌。工具调用看起来很整洁,权限却散落在提示词文本、进程内存、日志、shell 历史记录,以及代理接下来启动的任何子进程中。
无秘密设计会在意图与执行之间建立一道明确边界。代理说:“为这个环境中的这个服务创建一次部署。”拥有凭据的执行层决定是否允许该操作,选择正确的身份,发起经过身份验证的调用,并返回结果。这样一来,操作就更容易审计、审批和撤销,也会迫使你设计出真正配得上自主执行的接口。
契约必须描述意图,而不是传输方式
操作契约应给出用户能够理解的操作名称,并将输入限制为该操作真正需要的事实。传输细节应留在边界之后。这个区别很容易被忽略,因为 HTTP 会把每个操作都表现成方法、URL、请求头和 JSON 请求体。
下面比较两个用于创建变更请求的工具接口。第一个很常见,但不适合自主进程:
{
"name": "http_request",
"input": {
"method": "POST",
"url": "https://code.example/api/projects/alpha/changes",
"headers": {
"Authorization": "Bearer ${TOKEN}",
"Content-Type": "application/json"
},
"body": {
"title": "Fix timeout",
"branch": "agent/fix-timeout"
}
}
}
这个接口让代理可以控制目标地址、身份验证方式和请求结构。即使移除了明文令牌,只要代理还能选择请求头别名、凭据标识符、代理 URL,或启动一个会从别处读取令牌的 shell 命令,问题就没有解决。你只是移动了秘密,并没有减少权限。
面向契约的接口更接近下面这样:
{
"name": "create_change_request",
"input": {
"project": "alpha",
"source_branch": "agent/fix-timeout",
"title": "Fix timeout in retry path",
"description": "Adds a bounded retry and a regression test."
}
}
执行层会将 project 映射到已知端点和获批账户,并自行添加身份验证请求头。它可以拒绝分支名称,验证目标仓库,请求审批,或者返回远程服务的错误。代理没有任何参数可以表达“使用权限最大的凭据”。
人们经常混淆一个重要区别:无秘密不等于隐藏令牌。隐藏令牌试图控制代理在获得权限之后能看到什么。操作契约则从一开始就不让代理持有这项权限。如果模型提示词泄露、工具记录被复制,或子进程读取了环境变量,第一种设计已经丢失凭据。第二种设计可能暴露操作数据,这些数据需要自己的控制措施,但不会交出签名材料。
契约也不应假装每个端点都值得创建一个专用工具。人在一句话中能够表达预期结果时,自定义操作才有意义。“重启这个预发布环境中的工作负载”是一个结果。“向任意 URL 发送 PATCH 请求”是一个传输原语。如果维护任务确实需要这个原语,应将它交给一个独立且严格受限的集成,而不是通用编程代理。
凭据所有者必须执行请求
如果代理仍在挂载秘密的进程中执行最终网络调用,那么契约并不能保护任何东西。存储凭据的组件必须自己发起 HTTP 请求或 SSH 连接。
这意味着执行边界要承担五项工作:
- 将操作名称解析为固定目标和固定的协议行为。
- 从少量获批身份中选择一个存储的身份。
- 仅在出站请求或 SSH 身份验证交换中注入凭据。
- 记录请求、决定和结果,但不把秘密材料写入记录。
- 返回适合该操作的响应,而不是内部状态的完整转储。
模型进程不应接收令牌或私钥,包括临时令牌和临时私钥。避免使用 TOKEN=$(vault read ...) 这样的 shell 约定,避免在工作目录中放置凭据文件,避免在生成的 curl 命令中写入 Authorization 值,也不要让代理运行的 shell 共享 SSH agent。这些做法看起来方便,因为它们保留了现有脚本,但都会让代理进程成为凭据持有者。
IETF 的 OAuth 2.0 Security Best Current Practice 在另一个场景中表达了同一个实际要点:必须保护 bearer 令牌在存储和传输中的安全,因为任何持有令牌的人都可以使用它。告诉模型不要打印令牌,并不能让 bearer 令牌变得安全。持有本身就是授权检查。对于代理工具,更好的设计是避免让进程持有令牌。
对于 SSH,边界需要拥有的不只是私钥。即使密钥从未离开辅助工具,原始的 ssh host command 接口仍会给代理很大的操作范围。辅助工具应选择存储的主机定义和身份,然后强制使用适合该主机的命令形式。部署主机可以允许带服务名称的 status、restart-service 和 tail-release-log,但不应因为有人想走捷径,就悄悄接受 bash -c。
不要把这和中间人代理混为一谈。代理会转发任意客户端流量,通常也会在传输途中看到凭据。拥有凭据的操作层接收具名操作请求,构造出站调用,并将凭据保留在自己的保险库中。这个区别决定了代理能否把一个获批操作变成另一个操作。
参数设计决定会泄露多少权限
契约中的每个字段都会创造一个自由度。好的字段用于标识工作对象,或提供该操作真正需要的内容。坏的字段会改变权限流向、使用的身份,或要执行的底层操作。
对每个拟加入的输入都做一次检查:如果代理改变这个值,它能否将特权请求重定向到另一个系统,扩大受影响资源的范围,或改变身份验证方式?如果可以,就移除该字段,将它改成由执行器映射的枚举,或把操作拆成多个独立契约。
部署接口可以说明这一点:
{
"name": "deploy_release",
"input_schema": {
"type": "object",
"additionalProperties": false,
"required": ["service", "environment", "version", "reason"],
"properties": {
"service": {"type": "string", "enum": ["api", "worker"]},
"environment": {"type": "string", "enum": ["test", "production"]},
"version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"},
"reason": {"type": "string", "maxLength": 500}
}
}
}
这个模式会阻止 url、headers、credential_name 或 command 等意外字段。执行器可以将 service 和 environment 映射到已知部署目标。additionalProperties: false 的重要性超出很多人的想象。如果没有它,宽松的验证器可能会保留无法识别的字段,之后有人为了“灵活性”把该字段接入 HTTP 客户端。一个看似无害的扩展点,就这样变成了凭据逃逸通道。
枚举并不总是答案。仓库名称、分支、工单编号或文件路径可能确实需要变化。应根据各自的领域验证这些值,并在解析之后再执行边界检查。例如,先通过本地允许列表解析仓库标识符,再使用映射后的远程位置。不要接受仓库 URL,然后试图判断它看起来是否友好。
自由文本需要单独判断。代理可能需要撰写问题描述、拉取请求摘要或支持回复。这些文本属于内容,而不是权限,但仍可能通过提及、标记、模板,或下游服务执行的嵌入式命令造成危害。限制文本长度,明确其渲染行为,不要将它插入 shell 命令。操作必须执行命令时,应直接构造参数数组,让不受信任的文本只作为数据参数存在,绝不能放进命令字符串。
通用请求工具会制造隐藏的策略引擎
通用 HTTP 工具很受欢迎,因为团队可以在一个下午内把代理接入任何服务。但对大多数特权代理工作来说,它并不合适,因为每条提示词、每个工具描述和每个代码分支都会变成非正式的授权策略。
团队通常会从下面这样的包装器开始:
request(method, url, headers, body)
然后不断添加防护措施:阻止几个域名,移除 Authorization,只允许某些方法,解析 URL 前缀,拒绝 localhost,对高风险调用显示审批对话框。几个月后,有人需要一个带自定义请求头的新端点,于是加上例外。包装器逐渐拥有了一套没有测试、也没有明确负责人的策略语言。
问题并不是通用工具永远有害。它适合人工操作的调试控制台,因为操作员本来就拥有权限,可以检查每个字节。它也适合由受控代码调用、并拥有狭窄网络身份的集成服务。自主代理不同,它可以发起大量调用,发现意外路径,并根据不受信任的文本采取行动。因此它需要更少的自由度。
围绕稳定的工作单元编写具名操作。对于源代码管理服务,优先使用 read_merge_request、comment_on_merge_request 和 create_branch,而不是通用 REST 客户端。对于运维,优先使用 get_service_status、fetch_release_logs 和 request_deployment。契约可能会更多,但每个契约都有负责人、测试集、清晰的审批标签,以及可以审查的影响范围。
也不要在具名操作内部隐藏通用请求。名为 update_ticket、却接受任意 path、method 和 body 的工具,只是换了标签。契约必须绑定这些细节。如果下游 API 需要,可以公开受控的补丁对象,但端点、HTTP 方法、内容类型和账户都应由执行器决定。
Model Context Protocol 规范通过允许服务器向客户端发布工具名称、描述和 JSON 输入模式,帮助实现工具发现。这个模式很有用,但无法让过于宽泛的操作变得安全。JSON Schema 可以告诉你 URL 是字符串,却无法告诉你它是否是生产凭据唯一允许访问的计费端点。授权仍然是执行层的职责。
审批应当使用人能够判断的操作名称
当人看到的是一个易于理解的请求,并且可以迅速拒绝时,人工审批才有效。如果审批提示要求人在代理已经做出重要选择之后,去批准一组不透明的传输细节,审批就会失效。
比较下面两张审批卡片:
Allow POST https://api.example/v1/resources/882?
Headers: Authorization, X-Region, X-Client
Deploy version 2.14.3 of api to production
Reason: Fixes failed payment retries
Requested by: signed agent process build-worker
第二张卡片让操作员能够判断意图,也让审计记录拥有一句有用的话。第一张卡片要求操作员从 URL 和请求头列表中重新推断含义,很容易造成审批疲劳。普通代理运行一次就可能生成多张审批卡片,而人们尤其容易在看不懂内容时直接点击通过。
应在决定会改变权限的地方进行审批。执行层可以在一个会话中为新的代理进程授权一次,然后针对选定的敏感凭据或破坏性操作要求重新决定。这样既能让日常工作保持顺畅,又不会把所有凭据视为同等重要。只读项目令牌和生产部署身份不应仅仅因为都通过 HTTP 请求头传递,就共享同一审批规则。
审批文字必须说明是谁请求了操作。进程身份很有用,因为终端代理、后台辅助进程和未知可执行文件不应获得相同程度的信任。在 macOS 上,代码签名信息可以为审批者提供具体的来源信号。它不能证明每条提示词指令都安全,但能回答第一个问题:哪个进程正在请求使用这个账户执行操作?
绝不要把审批当成唯一控制措施。人可能误读提示,在时间压力下批准,或让会话一直处于开放状态。契约仍需限制输入,并固定凭据路径。反过来,如果清晰的操作契约加上一项审批选择已经足够,就不要再添加一套策略语言。比较任意字段、时间窗口、正则表达式和用户声明的规则,很快就会变成另一套没人能在事故期间有把握审查的程序。
一次失败的部署揭示了宽松契约的断裂点
一种常见的失败始于这样的代理:它可以通过 shell 工具部署到测试环境。团队将云令牌存入代理环境,因为部署 CLI 需要它。工具模式接受 environment 和 extra_args,而当时只有测试环境,所以看起来没什么问题。
一张工单要求代理“在测试环境验证紧急修复并分享结果”。代理运行了预期命令,随后在仓库中看到一条过时的部署消息,于是尝试使用从旧脚本复制来的额外参数。这个参数可能选择生产环境,改变目标账户,或注入 shell 扩展。由于维护独立凭据似乎太麻烦,令牌拥有生产权限。到了这一步,提示词措辞已经救不了你。进程持有宽泛权限,接口又允许它选择目标。
契约边界会改变整个过程:
- 代理使用枚举的服务、环境、版本和原因调用
deploy_release。 - 执行器将环境解析为固定目标,并选择分配给该目标的身份。
- 如果该身份需要审批,执行器会请求决定,然后将结果记录在发起请求的代理运行记录下。
- 执行器返回部署标识符和状态,或返回结构化拒绝信息,说明代理为什么无法继续。
代理无法添加 --account,无法设置云端点,也无法读取令牌。错误地填写 production 仍然可能发生,因为人和模型都可能请求错误的操作。但现在审批文字会用清晰语言写出 production,所选身份可以只拥有生产部署所需的权限,操作记录也会将决定与进程和请求关联起来。
这个区别在诊断期间非常重要。在宽松设计中,调查人员往往只能找到一些碎片:shell 记录、云审计事件、CI 日志,以及可能需要立即轮换的令牌值。在契约设计中,他们可以检查请求的操作、解析后的目标、身份标签、审批结果、响应状态,以及发起操作的会话。审计轨迹无法抹去错误,但能缩短猜测实际执行路径所需的时间。
错误响应应指导恢复,同时不暴露内部细节
安全的操作层应返回代理可以采取行动的错误,但不应返回秘密、请求签名数据或内部保险库结构。过于模糊的失败会促使代理不断重试和寻找变通办法。过于详细的失败则会让错误日志变成信息通道。
使用稳定的错误代码和小型公开结构:
{
"ok": false,
"error": {
"code": "APPROVAL_REQUIRED",
"message": "Deployment to production needs user approval.",
"retryable": true,
"request_id": "act_01H..."
}
}
锁定的保险库应报告 VAULT_LOCKED,用户拒绝决定应报告 APPROVAL_DENIED,契约违规应报告 INVALID_ARGUMENT,下游 429 响应可以报告 REMOTE_RATE_LIMITED。代理可以报告当前状态,等待条件改变后重试,或采取非破坏性替代方案。它不应收到包含原始授权请求头、令牌主体转储、私有主机配置或完整签名请求的错误。
应区分授权失败和远程失败。“权限被拒绝”可能意味着本地执行器拒绝了操作,也可能意味着选定的远程账户没有权限,或下游服务拒绝了格式错误的身份验证。这些情况需要不同的修复方式。公开消息可以保持简洁,而执行器的受保护审计记录则可以保存精确原因代码和远程状态。
重试需要符合契约语义。读取操作通常可以安全重试,但创建工单、发送消息或启动部署可能不行。在远程 API 支持时加入幂等标识符,由执行器生成,或由调用方提供受限的请求标识符。发送请求前先记录关联关系,然后在重试时复用它。不要让代理每次看到超时都生成新的标识符,否则它可能在试图帮忙时创建重复工作。
对于不支持远程幂等性的操作,可以使用准备和确认安排。准备操作返回一个短期有效的计划,说明目标和差异。确认操作引用该计划,并要求当前审批。虽然这会增加一次往返,但比在网络失败含义不明后重复付款、删除或生产变更便宜得多。
审计记录需要两个视图和一个事实来源
有用的审计系统要回答两个不同问题:这次代理运行尝试执行了什么,以及每次特权调用做了什么?如果把两者合并成一条没有区分的事件流,人们要么难以还原会话,要么难以找到某一项请求。
为每次运行保留会话日志。它应显示进程身份、开始和结束时间、授权决定、撤销状态,以及该运行期间请求的操作。为调用保留活动日志。它应显示操作名称、规范化参数、不包含秘密的凭据标签、审批结果、时间信息、目标类别和结果。
两个视图都应来自同一份只追加记录。否则,当某个写入器崩溃,或不同组件以不同方式过滤事件时,会话界面和调用日志可能不一致。只能写入而不能读取的加密日志还有一个实际优势:追加事件的组件无需解密过去的记录,就能写入新记录。
防篡改证据需要离线检查。哈希链可以让验证者在拥有日志序列时,检测记录删除、替换或重新排序。检查应针对密文运行,这样审计员无需获得保险库密钥也能验证连续性。但这不能证明遭到入侵的机器从未漏记事件,只能证明保留下来的链在事后没有被悄悄编辑。两者是不同的结论。
命令行验证器应让失败位置清晰可见。输出可以简单到这样:
$ sp audit verify audit.log
verified: 184 records
first sequence: 9012
last sequence: 9195
chain: valid
如果第 9137 条记录被修改,命令应指出第一个断裂的序号,并以非零状态退出。不要只报告“验证失败”。事故响应人员需要知道从哪里开始,证据不再可信。
Sallyport 使用这种分视图方式记录代理会话和单独的活动,并从同一份加密、带哈希链的审计日志中生成两个视图。sp audit verify 无需保险库密钥即可检查该日志。对于本地代理网关来说,这是正确的形态,因为撤销正在运行的会话和调查单次调用是两项不同工作。
契约需要尝试逃逸的测试
成功路径测试只能证明操作能够运行。安全测试则要证明,声明的输入是调用方拥有的全部控制手段。在添加便利参数之前编写这些测试,因为权限往往就是通过便利参数重新渗入的。
对每个操作,至少测试以下情况:
- 拒绝未预期字段,包括
headers、url、command和凭据引用。 - 拒绝解析后超出操作允许资源集合的值。
- 确认只有在执行器构造目标之后,出站 HTTP 请求才会获得凭据。
- 确认审计条目不包含令牌值、私钥材料和签名授权字段。
- 确认被拒绝或已撤销的会话无法复用之前的审批。
在测试中使用虚假的出站服务器,并检查它实际收到的请求。测试应断言执行器构造出的真实 URL、方法和请求头,以及调用方无法控制的身份验证是否缺失。只模拟执行器内部客户端,会错过最重要的问题:如果代理提供恶意参数,哪些内容会离开这台机器?
还要测试模型最终可能生成的棘手输入:带有 scheme 前缀的仓库标识符,含 shell 标点的分支名称,环境标签中的 Unicode 视觉相似字符,重复的 JSON 字段,超长描述,以及远程服务已接受操作后发生的超时。契约验证应默认拒绝。如果执行器无法有把握地解析请求目标,就应拒绝调用并返回有用错误。
像审查代码一样审查拥有权限的契约。问问自己,新字段是否让调用方获得了通往另一个主机、更宽泛账户、不同命令或不同对象类型的路径。如果是,就应在操作名称和审批行为中明确表达这项权限。带有自由格式绝对路径的 delete_file 操作,比在已知项目内解析资产标识符的 remove_preview_asset 更难理解和控制。
通常最值得首先修复的契约,是接受任意 URL 或 shell 字符串的契约。将它替换成能够覆盖实际工作需求的最小具名操作。接口会变得不那么聪明,代理也会以你以后能够解释的方式变得不那么强大。这就是进步。
常见问题
AI 代理可以在不接收 API 密钥的情况下使用 API 吗?
可以。独立的执行层可以持有凭据并执行请求,而不会向代理公开凭据。代理只需提供操作名称和受限参数,然后接收响应或错误。
什么是 AI 代理的操作契约?
操作契约描述一项操作的意图、允许的参数、预期结果和失败行为。它比 API 规范更窄,因为它只应描述代理可以请求执行层执行的操作。
应该从代理工具接口中移除哪些字段?
代理工具可以接收仓库、环境、问题文本或资源标识符等业务输入。不应接收授权请求头、Cookie 字符串、私钥路径,或允许调用方间接选择凭据的任意请求对象。
通用 HTTP 请求工具对自主代理安全吗?
只有当你确实有意将其作为传输原语,并接受由此产生的权限范围时,通用 HTTP 请求工具才适合自主代理。大多数自主代理工具应改为公开具名操作,因为通用的 HTTP 方法和 URL 往往会让每条提示词都变成隐藏的策略引擎。
代理应如何处理被拒绝的操作?
网关应返回稳定、机器可读的错误,说明操作需要审批或保险库处于锁定状态,同时不暴露秘密材料。代理可以报告这一状态并等待,但绝不能通过另一条凭据路径绕过拒绝。
如何在不传递凭据的情况下支持多个账户?
当同一操作确实允许使用多个身份时,可以在契约中加入账户选择器。执行层将该选择器映射到存储的凭据,并拒绝未知选择器,而不是接受代理传入的任意凭据引用。
过滤工具输出能解决凭据暴露问题吗?
不能。过滤输出可以减少意外泄露,但无法撤销代理已经通过令牌或私钥获得的权限。首先要让凭据留在进程之外,然后再将响应处理视为独立问题。
无秘密工具可以处理 SSH 命令吗?
SSH 需要具名主机、允许的命令形式,以及由执行层选择的存储身份。通过环境变量、临时文件或代理参数传递私钥,只是改变了泄露位置。
操作契约如何防止破坏性请求重复执行?
使用可防重放的标识符、受限操作,以及由执行层记录的请求 ID。对于不可幂等的操作,应在执行时要求人工审批,或设计带有明确过期时间的准备和确认流程。
MCP 会为代理工具提供授权吗?
可以使用 Model Context Protocol 的工具模式来实现发现和输入验证,但不要把模式验证误认为授权。契约仍需将请求的操作绑定到代理进程之外持有的凭据,并绑定到由人控制的执行决定。