设计代理能够安全使用的 OpenAPI 操作
设计安全可用的 OpenAPI 代理操作,明确输入边界、有效结果、审批规则、凭据控制,以及对不明确失败的安全处理方式。

OpenAPI 文档可以告诉代理如何调用一个端点。但它本身无法告诉代理一次调用意味着什么、什么时候必须由人介入,或在结果不明确的失败后应该如何行动。如果把每个有文档说明的操作都当作代理操作,工具在演示中看似完整,日常使用却可能变得危险。
一个实用的操作比端点更小。它有明确的用途,有代理能够说明理由的输入,有代理可以据此行动的结果,有与影响相匹配的审批决定,还有清楚的失败处理方案。在把操作接入代理前,先完成这些设计。如果等到第一次重复扣款、误改生产环境或令牌泄露后再补救,代价会非常惨痛。
操作还不是代理操作
HTTP 端点、OpenAPI 操作和代理操作回答的是不同问题。人们容易把它们混为一谈,因为 OpenAPI 操作提供了一个方便的起点,但这些区别决定了自动化是否容易理解。
端点是 /v1/deployments 这样的地址。操作增加了 HTTP 方法,因此 POST /v1/deployments 与 GET /v1/deployments 不同。代理操作还要增加人与系统之间的契约:它要追求什么目标,接受哪些参数,可能产生什么影响,什么证据算作成功,以及谁必须同意。
OpenAPI Specification 定义了 Operation Object,其中包括 operationId、parameters、requestBody、responses 和 security 等字段。应把这些字段当作判断依据,而不是自动发布清单。一个模式写得很完整的操作,如果在描述中用无害的名称掩盖了生产环境影响,仍然可能是糟糕的代理操作。
看看下面两个操作:
GET /v1/projects/{project_id}/builds/{build_id}
POST /v1/projects/{project_id}/builds/{build_id}/promote
第一个操作获取记录。第二个可能改变流量、发布构件或修改发布渠道。路由只能暗示这种差异,操作设计必须直接说清楚。
我见过一些团队因为 API 已经有一份整洁的 OpenAPI 文件,就开放一个通用的 request 工具。代理随后可以任意拼接路径、查询字符串和请求体。这不是操作目录,而是针对业务 API 的远程代码执行,只是标点更漂亮。
只有在能够用下面的句式写出一句话后,才应开放一个操作:「此操作会对某个范围明确的对象执行某项具体操作,并返回能证明结果状态的证据。」如果必须使用「管理」「处理」或「应对」之类含糊的动词才能写出来,说明操作仍然过于宽泛。
先考虑影响,而不是请求模式
审批应该根据调用的后果决定,而不是根据 HTTP 方法或 JSON 请求体看起来是否简单。一个很小的 POST 可能产生不可撤销的义务。一个冗长的 GET 也可能泄露私密数据。DELETE 也许只会删除可以丢弃的草稿,而 PATCH 可能撤销其他所有人的访问权限。
在检查字段之前,先用负责系统的人能够理解的语言描述影响。服务器执行两次调用、对错误对象执行调用,或者比代理预期晚五分钟执行,会发生什么?这些问题能区分普通读取和需要仔细审查的操作。
我在审查候选操作时会使用四种影响类别:
- 观察:获取范围明确的信息,不产生服务器端变更。
- 可逆变更:创建、更新或删除某项内容,并且有记录清楚且实际可行的撤销路径。
- 对外承诺:发送消息、启动付费任务、发布材料或改变面向客户的状态。
- 不可逆或广泛变更:永久删除记录、轮换访问权限、修改权限,或影响大量对象。
这些类别不是权限模型,但能迫使描述更加诚实。create invoice 即使只有两个字段,也属于对外承诺。restart environment 如果一个环境包含许多服务,也可能属于广泛变更。
不要从方法名推断安全性。HTTP 在协议层面将 GET 定义为安全方法,意思是客户端不应通过它请求状态变更。这是一种约定,并不能证明某个服务器确实遵守它。我遇到过一些诊断端点,反复调用时会刷新缓存、触发报告生成,并消耗稀缺容量。要测试实际行为,不要相信动词带来的暗示。
还要把操作的影响与结果的敏感性分开。获取访问令牌可能是只读的,但把令牌返回给代理会破坏控制调用的意义。获取私密客户记录也可能需要审批,即使 API 从未改动任何字节。
一张好的操作卡片会用直白的语言记录这两个维度:
Action: promote_preview_build
Effect: Changes one named preview build into the staging release channel.
Scope: One project and one build ID.
Result: Release ID, resulting channel, and server timestamp.
Human consent: Required for every call.
Retry: Never retry automatically unless the server accepts the same idempotency token.
这张卡片经常能在代理写下一行代码前暴露 API 语义中的缺口。如果没人能说清楚重试是否安全,操作就还没有准备好。
输入需要代理无法绕过的边界
代理操作需要的输入契约通常比端点实际接受的内容更小。OpenAPI 模式定义了类型和结构,但代理还需要其他限制,防止它通过富有创意的参数扩大任务范围。
以创建部署的操作为例。原始 API 可能为内部客户端提供许多选项:环境、构件引用、区域、副本数量、环境变量、功能开关、标签以及自由格式的配置对象。把每个字段都交给代理,会把一个简单请求变成未经审查的管理入口。
创建与任务相符的操作输入。如果任务是「把通过测试的构建部署到预览环境」,代理可能只需要 project_id、build_id 和简短的 reason。执行器可以选择允许的环境,并拒绝超出操作范围的内容。
下面的请求形状让边界变得具体:
{
"project_id": "proj_4821",
"build_id": "build_9017",
"reason": "Preview requested after integration tests passed"
}
不要因为底层端点支持,就顺手加入 target_url、任意 headers、原始请求体或通用的 options 对象。每个逃生口都会把这个精心命名的操作重新变成通用客户端。
利用 OpenAPI 中已经能表达限制的字段。当对象只应接受列出的字段时,设置 additionalProperties: false。只有在允许值确实很少时,才使用 enum。如果标识符有固定格式,就设置长度和模式限制。当执行器无法安全推断某个字段时,将其标记为必填。
例如,下面的片段会拒绝未经审查的配置字段,并在模式中显示预期范围:
DeployPreviewRequest:
type: object
additionalProperties: false
required:
- project_id
- build_id
- reason
properties:
project_id:
type: string
pattern: '^proj_[A-Za-z0-9]+$'
build_id:
type: string
pattern: '^build_[A-Za-z0-9]+$'
reason:
type: string
minLength: 8
maxLength: 240
additionalProperties: false 可以防止一种常见失败:代理从另一个 API 示例中得知可以发送 environment_variables,于是把密钥或不安全的覆盖设置放进去,而服务器又默默接受。拒绝该字段会给代理一个有用的错误,而不是一次意外部署。
模式不能替代对象级授权。有效的 project_id 仍然可能指向任务范围之外的项目。执行器必须检查请求对象是否属于允许的账户、工作区、代码库或环境。把这项检查放在代理执行器附近,不能依赖代理的解释。
自由文本需要特别处理。理由字段可以帮助审查者,但绝不能变成执行器的指令通道。把它保存为审计注释,不要将其解析为命令、资源选择器或权限例外。
预期结果必须支持下一步决策
代理需要的是可以推理的结果,而不是把原始 HTTP 响应全部倾倒进上下文。返回每个请求头、调试字段和嵌套对象会增加混乱,还可能泄露代理完成任务并不需要的数据。
在选择响应代码之前,先用业务语言定义成功。对于部署操作,有用的结果应标明部署、当前状态以及服务器之后报告进度的位置。对于记录更新,应标明记录并确认哪些字段发生了变化。对于删除,应确认目标以及是否仍然可以恢复。
异步操作的简洁结果可以这样写:
{
"status": "accepted",
"deployment_id": "dep_2388",
"project_id": "proj_4821",
"build_id": "build_9017",
"target": "preview",
"operation_status": "queued"
}
这个响应表达得很准确:服务器接受了任务,但部署尚未完成。代理收到它后不应报告「已部署」,而应使用单独的只读状态操作,或告诉用户操作正在排队。
许多 OpenAPI 文档正是在这里误导代理。202 Accepted 有明确含义:服务器接受了处理请求,但处理可能尚未开始,也可能尚未完成。把 202 当作与已完成的 200 相同的成功状态,会让日志和用户消息出现错误结论。
将传输层结果与操作结果分开。HTTP 200 可能包裹着这样的领域失败:{"state":"rejected","reason":"build is not eligible"}。反过来,409 Conflict 可能告诉代理目标状态已经存在。操作包装器应把这些情况转换成少数明确状态,例如 completed、pending、already_in_desired_state、rejected 和 unknown。
不要假装所有 API 都能提供统一结果。有些 API 只返回不透明的任务 ID,这没有问题,只要你提供能解析它的状态操作。真正的错误是隐藏这个缺口。要明确说明第一次调用确认了什么,以及没有确认什么。
错误返回代理前,应先过滤其中的细节。服务器错误可能包含内部 URL、授权请求头、堆栈跟踪或其他用户的数据。代理需要的是可以据此行动的原因,例如「构建 ID 不属于项目 ID」,以及供人调查的安全关联 ID。它不需要上游异常页面。
在产生承诺的地方进行审批
当调用可能产生重要承诺时请求审批,并让审批界面说明对象和影响。一次性为未来一大组模糊权限请求审批,会让人习惯点击自己无法判断的警告。
会话审批和单次调用审批解决的是不同问题。会话审批表示:「我知道这是哪个代理进程,并允许它在运行期间使用这组操作。」单次调用审批表示:「我现在批准这一个具有实际影响的请求。」不要用一种替代另一种。
能够查看构建状态的代理可以运行一小时而不打扰任何人。要提升构建的代理,则应在请求同意的那一刻展示项目、构建 ID、发布渠道和理由。审查者可以据此判断。「允许部署工具」几乎没有可判断的信息。
不要把审批提示当作输入验证的替代品。如果一个操作允许代理指定任意目标或任意权限范围,审查者就必须在压力下解读一份庞大且不断变化的载荷。先限制输入,再让审批确认范围明确的操作。
审批频率取决于影响。对于发布内容、改变访问权限、发起外部付款或触及广泛生产范围的操作,应逐次调用审批。只读调用或范围狭窄的可逆变更可以使用会话同意,但前提是审查者能看到进程身份和操作目录。
Sallyport 将这一区分落实为:新发现的代理进程需要会话授权,特定凭据的每次使用还可以要求单独审批。它的保险库门禁在锁定时会拒绝所有操作,因此审批不会把已锁定的密钥存储变成意外例外。
不要让人审批软件本可以阻止的失败。如果构建不符合发布条件,执行器应在发起审批前拒绝它。提示应该用于合法选择,而不是让疲惫的审查者帮忙发现格式错误的状态。
超时会产生未知状态,而不是重试指令
变更请求发出后网络超时,是最能暴露代理操作设计粗糙程度的失败路径。代理已经发送请求,却丢失了响应。服务器可能什么也没做,可能已经完成变更,也可能仍在处理。代理不能通过假定自己想要的答案来得知真相。
来看一个常见失败。代理调用 POST /v1/invoices,传入客户、金额和请求超时时间。服务器提交发票后、响应返回前连接断开。代理看到超时,于是用同样的数据重试,服务器创建第二张发票。审计日志可以说代理遵循了重试策略,这在技术上没错,但在实际运营中毫无用处。
幂等令牌只有在服务器真正实现它时才有用。客户端为每个预期操作生成一个令牌,在初始请求中发送,并在重试时发送完全相同的令牌。服务器必须将令牌绑定到原始请求,并返回原始结果或兼容的冲突结果,而不是重复执行影响。
Idempotency-Key: act_01HZX7FQ2Z9K8M6R4T3V1W0Y
操作包装器必须让代理无法自行编造这个令牌。在执行时生成令牌,将其与操作尝试一起持久化,并且只在这次尝试中重复使用。代理提供的令牌可能发生冲突、被用于无关请求,或成为另一个提示注入入口。
如果 API 没有记录清楚的幂等语义,就不要在变更操作超时后自动重试。返回带有操作标识符的 unknown,并提供只读查询操作来检查服务器状态。如果连查询都没有,人必须调查后才能重复请求。这个答案让人不方便,是因为事情本来就不方便。假装确定并不会改善情况。
OpenAPI 可以记录名为 Idempotency-Key 的请求头参数,但文档本身不能保证服务器行为。应有意测试它:用同一个令牌和载荷发送两次,再用同一个令牌发送不同载荷。服务器应让前两次请求收敛到同一结果,并拒绝或明确处理变化后的请求。如果它默默执行了两次变更,这个请求头只是装饰。
其他失败也需要各自的规则。将 401 和 403 视为停止条件,不要借机寻找另一份凭据。只有当 API 提供重试延迟,或操作有明确的退避策略时,才把 429 视为等待条件。只有当验证错误指出了允许的修正方式时,代理才能将其当作反馈使用。
身份验证不会赋予代理判断力
OpenAPI 的 security 声明描述客户端如何向 API 证明身份。它不会说明代理是否应该调用操作,也不会说明它能否用某份凭据访问某个对象,更不会说明某种影响是否需要人审查。
Specification 中的 Security Requirement Object 会把操作与命名的安全方案关联起来。bearer 方案可能告诉客户端发送授权请求头,基本身份验证可能告诉客户端构造凭据请求头。这些都是传输层身份验证,不要从中推导出更多含义。
将下面四个问题分开:
- 谁或什么在调用这个操作?
- 执行器向上游 API 使用哪份凭据?
- 这份凭据允许访问哪些对象并产生哪些影响?
- 哪些操作尝试需要人批准?
团队一旦把这些问题混在一起,通常就会把令牌交给代理,然后称之为授权。令牌随后可能出现在工具输出、Shell 历史、调试日志、提示词或配置文件中。撤销它就会变成一项清理工程,而不是一次简单操作。
更安全的方式是把凭据留在执行器中。代理只提供范围明确的操作输入。执行器选择符合条件的凭据,将其注入 HTTP 请求,评估响应,并返回过滤后的结果。代理不需要获得 API 密钥明文就能请求操作。
SSH 也应遵循同样的规则。代理可能需要请求在指定主机上执行一条命令,但把通用命令和广泛信任的私钥交给代理,权限范围远超大多数任务所需。应根据操作目的限制主机身份、账户、命令形式和输出处理。
Sallyport 使用这种执行模式处理 HTTP 调用和 SSH 命令:凭据留在加密保险库中,代理收到的是操作结果而不是秘密。只有在继续开放范围狭窄的操作,并选择与其影响相匹配的审批方式时,这种设计才真正有帮助。
操作描述必须说明模式无法表达的内容
OpenAPI 描述很重要,因为代理会把它们当作指令。但描述应该解释边界,而不是偷偷塞入另一份相互矛盾的 API 契约。可执行的限制放在模式和执行器中,描述则用于说明意图、影响以及类型系统无法表达的条件。
根据用户预期的结果命名操作。getBuildStatus 比 getBuildById 表达得更多,createPreviewDeployment 比 postDeployment 更清楚。名称不能过度承诺。如果服务器只是把任务排队,就不要把操作叫作 deployBuild,除非结果能够区分「已接受」和「已完成」。
描述中应写出代理原本可能猜测的细节:
operationId: createPreviewDeployment
summary: Queue one tested build for the preview environment
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DeployPreviewRequest'
responses:
'202':
description: Request accepted. Deployment work may still be pending.
'409':
description: The build already has a preview deployment or cannot enter preview.
对于影响较大的操作,仅有摘要并不够。应在 OpenAPI 文档旁边的操作元数据中记录目标、影响类别、审批要求、重试规则和结果状态。可以使用 x- 扩展,前提是工具链明确由你管理,但要清楚标注这是私有约定。标准 OpenAPI 解析器会忽略未知扩展,因此执行器必须真正执行这些规则,而不能只是把它们显示出来。
不要依赖「请谨慎使用」这样的描述。谨慎是一种人的感受,不是可执行规则。应替换成具体限制:只能操作一个项目,只能使用预览环境,不允许任意环境变量,每次调用都要审批,未知结果后不得自动重试。
描述还应告诉代理什么时候必须拒绝操作。发布操作可以要求测试运行已经完成。数据导出可以要求客户提供案件编号。删除操作可以要求先查询并确认目标是草稿。这些前置条件可以减少无意义的提示,也让审计记录更容易解释。
用一个粗心但有能力的操作者测试操作
只测试成功路径,只能证明 API 在所有假设都成立时可以工作。测试操作时,应假设操作者能力很强,但上下文不完整,手里的标识符已经过期,而且在出错后容易重复尝试。这与自主代理的失败方式已经足够接近,测试才有价值。
用可丢弃的对象和权限符合预期执行器的账户,建立一个小型测试环境。然后运行下面这些挑战边界的案例:
- 发送未知输入字段,确认执行器会拒绝。
- 请求允许的项目或工作区之外的对象。
- 拒绝审批,确认不会发出上游请求。
- 在服务器收到变更请求后强制超时。
- 返回包含敏感调试材料的响应,确认过滤器会将其移除。
不要只检查最终的 API 状态。还要审查人看到的提示、执行器发出的准确请求、代理收到的结果以及审计记录。请求成功仍可能违反操作契约,例如提示隐藏了目标,结果过早声称完成,或者日志无法区分被拒绝的请求和上游拒绝。
对于每次调用都需要审批的操作,要测试执行顺序。执行器应先验证静态限制,并解析足够安全的上下文,以便在请求同意前展示有意义的请求。不能先发送请求再询问审批,也应避免进行一长串隐藏的读取调用,暴露最终操作并不需要的数据。
有意测试已撤销和已过期的凭据。执行器应安全失败,返回安全的解释,并避免使用同一份不可用凭据反复调用。针对被拒绝凭据的重试循环会填满日志、触发速率限制,并让简单的访问问题更难诊断。
最后,测试取消操作。如果用户在上游任务运行时停止代理,记录应说明请求从未发出、已经到达服务器,还是进入了未知状态。取消本地代理进程不一定会取消远程影响。
少发布一些操作,让每个操作都经得起解释
小型操作目录胜过通用 API 客户端,因为每个操作都可以拥有经过推敲的契约。增加操作很容易,真正困难的是持续维护真实的结果语义、对象边界、审批提示和失败行为。
先从一个获取范围明确的状态记录的操作开始。用名称说明对象,将标识符限制在预期范围内,只返回代理需要的字段。然后增加一个可逆操作,并在实现它之前强迫自己写清楚重试和审批规则。
不要因为 OpenAPI 生成器能在一个下午暴露某个操作,就把它升级为代理操作。只有在能够解释超时后会发生什么、人批准的是什么、代理能看到什么,以及之后如何证明具体发生了哪次请求时,才应该把它加入目录。如果其中任何答案依赖于「代理大概会做出合理选择」,就不要把这个端点放进目录。
常见问题
OpenAPI 操作和代理操作有什么区别?
不是。端点是一个 HTTP 地址,OpenAPI 操作则是该地址上的一种方法,例如 POST /deployments。代理操作是一份更严格的契约,还要规定输入限制、结果含义、审批方式和恢复规则。
应该先向 AI 代理开放哪些 API 操作?
先从只读且范围明确的查询开始,让它们返回代理已经需要的记录。在能够准确规定确认和恢复行为之前,不要开放转账、删除数据、发布内容或修改访问权限的操作。
收到 200 响应,就足以让代理知道操作成功了吗?
通常不够。200 只说明服务器接受或完成了一个 HTTP 请求,并不能告诉代理预期的业务变更是否真的发生。应返回简洁的结果,说明产生的资源、当前状态以及后续工作。
POST 请求超时后,代理可以重试吗?
只有在服务器提供了有文档说明的幂等机制,并且操作包装器在重试时保留同一个幂等令牌,才可以这样做。POST 超时后,代理无法知道服务器是否已经执行,因此盲目重试可能造成重复变更。
OpenAPI 安全方案能提供代理授权吗?
OpenAPI 可以描述 bearer token 或 basic authentication 等安全要求,但这只说明客户端如何完成身份验证,并不会决定某次代理运行是否应该在此刻执行重要调用。
GET 请求是否应该始终无需审批?
即使破坏性 GET 违反了 HTTP 的预期,它仍然具有破坏性。应根据操作的实际影响决定是否审批,并先用测试账户验证服务器行为,再开放该操作。
应该如何为代理命名 OpenAPI 的 operationId?
操作 ID 应该说明业务意图和对象,例如 createPreviewDeployment 或 getInvoiceStatus。避免使用 postV1Deployments 之类的传输层名称,因为代理需要了解调用的影响,而不是路由如何组织。
AI 代理是否应该从 OpenAPI 工具中获得 API 密钥?
不要把密钥交给代理。将凭据放在执行请求的组件中,在执行时注入,并返回结果或经过特意过滤的错误。代理需要的是请求执行操作的权限,而不是凭据副本。
代理审批设置应该放在 OpenAPI 扩展中吗?
参数、请求体、响应代码和安全声明使用标准 OpenAPI 字段。代理专属的限制可以放在外部操作元数据中,或放入清楚记录的 x- 扩展,因为普通 OpenAPI 客户端会忽略自己不理解的扩展。
将 API 操作交给自主代理前,应该测试什么?
针对格式错误的参数、过大的权限范围、部分成功、超时、重复请求、撤销的凭据和被拒绝的审批进行测试。一次顺利的演示几乎说明不了什么,失败场景才能告诉你代理是否会在不制造混乱的情况下运行。