阅读需 8 分钟

面向 AI 代理的安全 API:可追踪的写入

面向 AI 代理的安全 API 应采用范围狭窄的操作、幂等写入、可执行的错误信息和能保持控制力与可追踪性的请求 ID。

面向 AI 代理的安全 API:可追踪的写入

AI 代理本身不会让 API 变得危险。真正危险的是 API 提供了范围过大的动词、含义不明确的结果,以及迫使调用方猜测的错误信息。人类操作人员会用上下文、犹豫和在聊天中快速发消息来弥补这些问题。代理则会重试,再调用一次工具。这种差异可能把一次无害的超时变成两次退款、两次部署,或误删一条没人想动的记录。

安全的 AI 代理 API 会把允许执行的操作做小,让重复写入不会造成伤害,并留下日后可以由人追踪的记录。这是 API 设计工作,不是提示词工作。提示词可以告诉代理要小心,但端点仍然必须拒绝超出契约范围的操作。

我见过一些团队在宽泛的管理端点前加上审批页面,然后把它称为控制措施。这还不够。如果获批的调用意味着「更改此账户中的任何内容」,审批就要求人在时间压力下检查一组隐藏的后果。先把精确性放进 API。这样,审批人面对的才是一个可以理解和批准的操作。

宽泛的动词会迫使代理猜测

代理应该调用一个操作,其名称、输入和副作用可以用一句话说清楚。宽泛的端点会迫使它从松散的字段、旧示例,或只写着「请求错误」的错误信息中推断业务规则。不安全的临时发挥就是从这里开始的。

考虑这样一个端点:POST /admin/execute,请求体包含 action 和任意 JSON。手写客户端今天可能只使用五种操作,但这个端点会把当前和未来的所有操作都呈现给任何获得访问权限的调用方。服务器无法表达有用的权限边界,审批人也无法在不把请求体当源代码阅读的情况下判断代理会做什么。

把它替换成能够明确说明状态转换的操作:

  • POST /projects/{project_id}/deployments 根据指定版本创建一次部署。
  • POST /invoices/{invoice_id}/refunds 按明确的金额和原因创建一次退款。
  • POST /users/{user_id}/access-revocations 移除指定用户的访问权限。
  • POST /exports 按声明的数据类别启动一次定义明确的导出。

这些操作仍然可能有风险。重点在于,每个操作都为服务器提供了执行规则的明确位置,例如有效的状态转换、金额上限、目标所有权、必需的审批,以及应当出现在哪里的原因字段。

不要把通用 CRUD 接口误认为适合代理使用的接口。PATCH /customers/{id} 会邀请调用方修改任何可写字段。如果修改 billing_email 是日常操作,而修改 tax_status 会启动合规流程,那么这两个变更就不该隐藏在同一个随手可用的 patch 后面。为影响更大的状态转换创建专用操作,并让其输入模型体现这项决策。

范围狭窄的操作也能改善恢复过程。当代理说「部署请求超时了」,操作人员可以搜索一次部署创建操作。当代理说「管理命令超时了」,操作人员首先还得查清楚它拼出了什么命令。

把前置条件放进请求

写入操作应说明它成立所需的条件。审批费用的请求可以包含预期的审核状态。更新文档的请求可以包含它读取的版本。如果状态已经变化,服务器应拒绝写入,而不是悄悄把它应用到另一个现实上。

HTTP 已经提供了有用的机制。RFC 9110 通过 If-Match 等请求头定义了条件请求;服务器可以用 412 Precondition Failed 拒绝过时的实体标签。API 也可以在更合适时公开 expected_version 字段。选择哪种方式并不如遵守这条原则重要:客户端必须说清楚它打算修改哪个版本或状态。

不要接受客户端的 force: true,把它当成解决所有冲突的逃生口。这个字段很容易变成代理绕过你刚刚添加的安全检查的方式。需要覆盖时,应使用单独的操作、更高的授权级别,以及清晰可见的审计记录。

写入操作需要独立于 HTTP 尝试的身份

每个对外可见的写入操作都应有一个由客户端提供的幂等标识符。服务器用它识别多次传输尝试表达的是同一个预期动作。

请求 ID 和幂等标识符解决的是不同的故障。网关或服务器通常会为每次 HTTP 尝试创建一个请求 ID。如果网络在服务器提交写入后、响应到达调用方前中断,重试会收到新的请求 ID。幂等标识符必须保持不变,因为预期写入并没有改变。

常见的故障过程如下:

  1. 代理提交创建一笔付款的请求。
  2. 服务器保存付款记录,并调用上游提供商。
  3. 连接在代理收到成功响应前中断。
  4. 代理看到结果未知,于是重试。
  5. 服务器看到的是新的 HTTP 请求,因此又创建了一笔付款。

造成问题的不是重试策略,而是 API 把传输当成了意图。

使用一个请求头或请求字段。客户端应在第一次尝试前创建它,并一直保留到收到最终答复。HTTP 请求头通常使用 Idempotency-Key,不过这个标识符不必是秘密。随机 UUID 通常就很好。不要只根据时间戳生成它,也不要使用可能在无关写入之间发生冲突的标识符。

保存请求指纹和结果

服务器必须把幂等标识符绑定到不止一个状态标记上。保存调用方身份、目标路由、请求体中与语义相关内容的规范化指纹,以及重放所需的完整结果。当同一个调用方使用相同标识符和相同指纹重试时,返回原始响应。如果请求体不同,则以冲突错误拒绝请求。

IETF 草案《The Idempotency-Key HTTP Header Field》将这个请求头描述为一种让客户端具备容错能力的方式,用于处理非幂等 HTTP 方法。它对唯一性的提醒很重要:客户端不应把同一个值用于不同请求。我在实现上会再往前走一步,在服务器端强制执行这条规则,因为代理会重试、重启,也偶尔会重复使用人类客户端本来会丢弃的状态。

一个精简的契约可以是这样:

POST /v1/projects/prj_48/deployments
Idempotency-Key: 8c8d77c1-4ef9-4fae-b0ba-5480f686ce4c
Content-Type: application/json

{
  "revision": "a1b2c3d4",
  "environment": "staging",
  "expected_project_version": 17
}

第一次接受调用时,返回一个资源以及两个标识符:

{
  "request_id": "req_01J8X7QK3JZ6",
  "deployment": {
    "id": "dep_01J8X7R5G2",
    "state": "queued",
    "revision": "a1b2c3d4",
    "environment": "staging"
  }
}

如果代理在超时后重复提交完全相同的请求,应返回同一个 dep_01J8X7R5G2,而不是第二次部署。如果它保留标识符,却把 environment 改成 production,应返回一个能明确指出修复方式的冲突:

{
  "error": {
    "code": "idempotency_payload_mismatch",
    "message": "This idempotency identifier belongs to a deployment request with different parameters.",
    "request_id": "req_01J8X84S9P2V"
  }
}

幂等记录的保留时间至少应覆盖客户端可能进行重试和任务恢复的现实时间。保留时间过短,会制造一种生产环境中偶尔出现的延迟重复。如果存储压力迫使你过期删除记录,就应明确写出保留窗口,并让消费者选择符合该窗口的重试行为。

只有在结果足够明确时,重试才有意义

代理应该重试传输故障和选定的临时响应,但绝不能为了摆脱不确定性而凭空发明一个新动作。响应类别必须让它能够做出正确选择。

RFC 9110 定义了 429 Too Many Requests,并允许使用 Retry-After。如果发送了这个字段,就应遵守相应机制。调用方可以等待指定时间,保留幂等标识符,然后提交同一个请求。对于临时服务器故障,返回带有请求 ID 的 5xx 响应,并说明服务器是否已经接受操作。不要把校验失败或授权拒绝都用模糊的 500 表示,否则客户端会学到错误的重试行为。

对于异步写入,接受和完成是两件不同的事。202 Accepted 响应应返回一个能够说明任务及其状态的操作资源。代理可以在超时后查询这个资源,而不是重新提交副作用。

{
  "request_id": "req_01J8X9FW7GH2",
  "operation": {
    "id": "op_01J8X9FTVX",
    "state": "running",
    "status_url": "/v1/operations/op_01J8X9FTVX"
  }
}

状态资源需要提供的不只是 runningfailed。加入终止状态,成功时提供结果引用,worker 无法完成任务时提供公开的失败代码。例如,健康检查失败的部署不应看起来像 API 传输失败。代理需要报告或修复部署失败;只有在服务器从未接受请求时,它才应重试连接失败。

除非服务器可以一直把去重机制延伸到最终效果,否则不要自动重试发送邮件、收取款项、轮换凭据或调用外部系统的操作。数据库中的幂等性并不能防止发送两封邮件,因为 worker 可能在邮件提供商接受消息后、记录完成前崩溃。可以使用 outbox 记录,并在提供商支持时使用稳定的提供商端去重引用。如果外部系统无法去重,就让操作保持可观察,并在结果未知后要求人做决定。

错误必须告诉调用方如何修复请求

有用的错误消息描述的是失败的契约,而不是服务器的尴尬。代理可以处理精确的错误,却无法安全处理 HTML 错误页、堆栈跟踪,或一个包含十个字段的请求只返回「输入无效」。

对每种预期失败,都返回一致的 JSON 封装。提供供程序使用的稳定 code、供日志和人阅读的简短 message、请求 ID,以及在安全可公开时提供字段级详情。RFC 9457《Problem Details for HTTP APIs》通过 typetitlestatusdetailinstance 等字段提供了标准结构。你不必采用所有字段,但应理解它的核心经验:错误属于 API 契约,而不是随手写下的说明文字。

这个响应会准确告诉代理需要改什么:

{
  "error": {
    "code": "invalid_state_transition",
    "message": "A refund can be created only for a paid invoice.",
    "request_id": "req_01J8XAS2D8M4",
    "details": {
      "invoice_id": "inv_204",
      "current_state": "draft",
      "allowed_states": ["paid", "partially_paid"]
    }
  }
}

这个响应只会让它猜测:

{
  "error": "Request failed"
}

第二种响应会把代理推回文档、源代码或探索性调用。对写入 API 进行探索性调用,正是小缺陷变成嘈杂事故的方式。

不要把秘密放进错误消息。不要回显授权请求头、访问令牌、签名 URL、原始数据库查询,或可能包含其他客户数据的上游服务响应。一个常见的坏模式是捕获所有异常,然后把异常消息返回给调用方。这样做可能让调试在一天内变得容易,却会在未来几年制造数据泄露通道。

把无效输入与权限不足分开。422 Unprocessable Content 可以说明格式正确但违反业务规则的请求体。403 Forbidden 应说明请求的操作需要某项权限或审批,但不要泄露调用方无权查看的资源。你也可以在有意隐藏资源存在性时使用 404 Not Found。确定语义,写入文档,并始终保持一致。

好的错误还会说明重试何时没有意义。invalid_state_transitionidempotency_payload_mismatchapproval_required 应停止盲目重试。带有重试延迟的 rate_limitedupstream_temporarily_unavailable 则可以允许受控重试。这种区分比聪明的代理提示词更能减少损害。

请求标识符能把有争议的操作变成调查

让 SSH 凭据远离代理
随附的 sp-ssh helper 可以执行 SSH 命令,无需把 SSH 密钥交给代理。

为每个入站请求提供请求 ID,在响应正文或响应头中返回它,并将其传递给每次内部调用、队列消息、worker 任务和外部提供商调用。当代理说它没有收到响应时,你需要回答两个问题:API 是否接受了这个操作,之后每个组件做了什么?

如果调用方没有提供请求 ID,就在信任边界生成它。你可以接受调用方的关联 ID 供其内部记录,但不要让不受信任的调用方覆盖服务器签发的标识符。需要时保留两者。服务器标识符固定日志,调用方标识符则连接一系列代理决策。

记录结构化事件,不要拼接出操作人员以后还要用正则表达式解析的文字日志。至少记录请求 ID、经过身份验证的主体、操作名称、目标资源、存在时的幂等标识符、授权决策、结果状态,以及已创建资源的引用。按字段模式做脱敏,而不是临时用字符串过滤。名为 token 的字段很容易脱敏,嵌在任意文本中的凭据却不是。

追踪必须保留顺序,但不能假装它证明了超出范围的事情。请求 ID 可以显示 API 接受了一个任务,worker 提交了一个提供商调用,但它无法证明某个人确实有意执行该操作,除非系统另外记录了这项决定。要清楚区分:

  • 关联记录把属于同一个请求的事件连接起来。
  • 审计记录说明谁或什么授权了操作,以及系统做了什么。
  • 幂等记录防止一次逻辑写入产生重复。

团队经常把这些内容压缩到同一行数据库记录中。结果是,这一行既要处理重试、调试、合规审查和面向用户的历史记录,却没有一项能处理干净。可以把相关引用存放在一起,但要在数据模型中保留它们各自的含义。

对于风险更高的操作,应在只追加的审计流中记录规范化请求、授权上下文、策略或审批结果,以及结果摘要。让普通应用账户无法修改这条数据流,否则被攻破的服务可能会重写暴露自身问题的历史记录。

Sallyport 为代理操作提供了一种有用的方法:它通过一个无法被写入、加密且采用哈希链的审计日志记录代理会话和单独调用,sp audit verify 可以在没有保险库密钥的情况下离线检查链条。你的 API 仍然需要自己的记录,因为网关只能说明它分发了调用,只有你的服务能说明它提交了什么状态转换。

身份验证范围无法修复不安全的操作

保留独立的操作记录
一个无法被写入的加密日志同时记录代理会话和单独调用。

短期凭据和狭窄权限范围可以减少影响范围,但不能让宽泛的端点变得安全。一个只限于某个项目的令牌,仍然可能删除该项目中的所有部署、导出所有允许访问的数据,或触发该项目中所有可用的管理操作。

让授权绑定到操作和目标。允许创建部署的调用方,不应自动获得将部署推广到生产环境的权限。允许撤销用户访问权限的调用方,也不应因为两者都位于 /users/{id} 下,就自动获得修改该用户账单资料的权限。

尽可能不要把凭据材料交给代理。拿到 bearer token 的代理可能会把它复制进对话记录、调试文件、 shell 历史或外部服务调用中。可以把凭据使用放在本地操作网关或服务器端 broker 后面,由它为获批操作选择凭据。代理提交意图和参数,受信组件只在发起外部调用时注入秘密。

这种设计并不能取代参数校验。如果代理可以指定 url: https://anything.example,注入凭据的 HTTP helper 就可能变成窃取秘密的工具。应将凭据绑定到指定的上游系统和方法。重定向后也要验证主机,而不只是验证重定向前的主机。对于 SSH,应尽可能把凭据绑定到已知主机和受限的命令接口,而不是提供任意远程 shell 访问。

人工审批有其作用,但它应覆盖一个目标和后果都清晰可见的小操作。按会话审批回答「这个代理进程是否可以运行?」按调用审批回答「它现在是否可以执行这个特定的敏感操作?」这两种审批都无法挽救一个请求体可以表达任何含义的端点。

并发需要明确的失败者

幂等性可以阻止同一意图的重复传递,却无法解决两个不同意图之间的竞态。如果两个代理读取到发票处于 paid 状态,然后使用不同的幂等标识符提交全额退款,服务器必须决定哪个请求获胜。

在存储系统支持时,使用事务性的状态转换。更新操作应包含它预期的状态;如果另一个写入者先改变了状态,服务器应报告冲突。版本字段、实体标签或条件更新,都能让 API 拒绝过时意图,而不是在事实已经变化后仍然应用它。

例如,把退款建模为针对剩余可退款余额的操作,而不是相信客户端金额的盲目命令。在一个事务中,检查当前已付款金额,扣除之前的退款,验证请求金额,预留新的退款额度,并创建退款记录。单独的异步 worker 可以在预留完成后调用支付提供商。如果 worker 重试,它应继续处理同一条退款记录,而不是创建新记录。

不要把「先检查,再执行」作为唯一的并发控制。预检 GET 可以帮助代理形成有用的请求,但另一个调用方可能在读取和写入之间改变状态。写入端点掌握正确性,因为它能在提交时看到实际状态。

取消操作也要同样谨慎地设计。DELETE /operations/{id} 不应承诺外部操作从未发生。它应返回实际的取消状态:已请求取消、分发前取消、取消前已完成,或分发后无法取消。代理和人都需要准确反映你的系统与外部提供商边界的措辞。

在代理于生产环境发现未知结果前测试它们

离线验证审计轨迹
运行 sp audit verify,即可离线检查加密的哈希链审计日志。

只检查 200 响应的测试套件,会让所有人忽略操作 API 最困难的部分。把失败场景放进契约测试,并在真实服务边界上运行,而不只是测试 mock handler。

对每个写入操作,都测试服务器已经提交效果而客户端丢失响应的过程。再次提交相同的幂等标识符,并断言服务器返回原始资源。然后用变更后的请求体提交该标识符,并断言服务器返回冲突,且没有创建另一个资源。

使用不同幂等标识符对同一个状态转换测试并发请求。断言一个请求成功,另一个收到明确的过时状态或业务规则错误。如果两个请求在测试数据库中都成功,只是因为每个测试单独运行,那么你并没有测试真正重要的属性。

把错误契约当作数据来测试。断言状态码、稳定的错误代码、字段名称,以及请求 ID 的存在。不要只对英文消息做快照。措辞会随着时间改进,客户端应根据 code 而不是文字说明来分支处理。

最后,进行一次操作人员演练。选择一个已完成的操作、一个被拒绝的操作、一个服务器已经成功提交但响应超时的操作,以及一个异步失败。只给工程师请求 ID,让他们重建发生了什么。如果他们必须在互不相关的日志中搜索、查看代理对话记录,并猜测哪次重试创建了哪条记录,那么就先修复监控,再允许无人值守的写入操作。

通常最先需要修复的是范围最宽的写入端点。把它拆成命名明确的状态转换,要求提供幂等标识符,并让响应指出生成的资源。有了这份契约,代理就能快速行动,而不会把每次网络故障都当成尝试其他操作的许可。

常见问题

如何让现有 API 对 AI 代理更安全?

先从只读操作开始,再提供一小组效果可以精确定义的写入操作。为每个写入操作设计幂等契约和稳定的请求 ID;如果操作是异步执行的,还要提供清晰的状态端点。不要把宽泛的管理 API 交给代理,然后寄希望于提示词能让它保持克制。

什么样的 API 端点适合代理使用?

适合代理使用的端点有明确且范围狭窄的效果,请求结构也让无效操作难以表达。「为发票创建退款」比「执行任意账单变更」更容易约束。端点应返回创建的资源、资源状态,以及发起这次请求的请求 ID。

为什么 AI 代理需要幂等的 API 写入?

幂等意味着服务器会把同一逻辑写入的重复提交视为一次操作。它可以防止超时、连接中断和代理恢复循环导致的重复操作。客户端生成的幂等标识符必须绑定请求体,而不能只绑定端点。

如果幂等键被不同数据重复使用,API 应该怎么处理?

如果同一个幂等标识符对应了不同的请求体,应将其视为错误,通常返回 HTTP 409 Conflict。如果静默接受变更后的请求体,客户端可能会把旧操作的结果错误地关联到新的意图上。请将请求指纹与原始结果一起保存,并在每次重放时进行比较。

API 应该向 AI 代理返回什么错误格式?

返回 HTTP 状态码、稳定的机器可读错误代码、简短的面向人的说明,以及请求标识符。只有在不会泄露敏感数据时,才包含出错字段。告诉调用方下一步可以安全做什么,例如修正输入、等待,或使用其他授权路径。

请求 ID 和幂等键是同一回事吗?

请求 ID 标识调用 API 的一次尝试,幂等标识符标识跨多次尝试的一次预期写入。两者都要保留:操作人员需要前者来查看日志和追踪,服务器需要后者来防止重复效果。

API 应该如何处理代理发起的长时间运行操作?

不是。成功的 HTTP 响应可能只表示队列中的 worker 已接受任务,并不代表外部效果已经完成。返回一个带有明确状态的任务或操作资源,让代理轮询它,或通过受控渠道接收回调。

错误消息是否应该包含内部调试细节?

不要返回原始数据库错误、堆栈跟踪、上游响应正文、凭据或内部授权细节。将这些内容记录在服务器端,并通过适当的访问控制保护,然后向调用方返回稳定的公开错误代码。代理需要足够的信息来修复请求,而不是了解你的内部结构。

有范围限制的身份验证足以控制 AI 代理吗?

将权限范围和端点设计结合起来使用。一个仅限于某个项目的令牌,如果 API 允许它删除该项目中的所有资源或执行任意命令,仍然会带来问题。只提供代理真正需要的操作,并让每个操作验证目标和请求的状态转换。

哪些 API 测试对自主代理最重要?

测试重复提交、未知结果后的重试、并发更新、过期凭据、格式错误的标识符,以及 worker 延迟完成。还要测试请求 ID 是否能让操作人员重建请求在系统中的完整路径。正常流程几乎无法说明代理在不确定情况下会怎样行动。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov