# 你的智能体秘密泄漏测试真的能证明什么？

智能体不需要打印令牌，也可能已经接收到了它。如果 bearer 令牌进入智能体的环境、命令行、工具载荷、会话记录、子进程或崩溃工件，边界就已经失守。模型给出礼貌的回复，也无法挽回这个错误。

你需要的测试不是「智能体有没有避免重复秘密？」而是「我们能否通过测试网关执行真实操作，收集智能体一侧可能产生的所有工件，并证明其中没有任何独一无二且仍可使用的凭据？」这个结论足够明确，可以进行测试，也足够有力，能够捕获生产环境中常见的失败。

使用一次性账户、一次性主机，并为每次运行生成新的金丝雀凭据。只把秘密放在网关配置中。然后让智能体执行成功的 HTTP 和 SSH 操作，主动触发失败，再像预期会发生泄漏一样检查智能体一侧。这种测试方式才能找到那些最难看的路径。

## 干净的回答不等于干净的边界

智能体可以接收秘密，却从不把它放进自然语言。常见的一种泄漏是：工具实现要求智能体自己调用 API，然后把 `Authorization` 值放入工具输入。另一种是子进程启动器在执行前没有替换环境，导致子进程继承了 `API_TOKEN`。还有一种是 SSH 辅助工具把临时身份文件写到智能体可以读取的位置。

这些是不同的故障，但后果相同：智能体进程获得了能够绕过网关执行操作的材料。一旦发生这种情况，批准提示和审计日志只能描述部分风险。智能体可以把秘密复制到代码仓库，发送给其他服务，或将其留在稍后有人导出的会话记录中。

要把下面两个结论分开：

- **操作隔离**意味着智能体请求一次操作并获得操作结果，另一个组件负责注入凭据，并执行网络或 SSH 交互。
- **秘密不传递**意味着凭据及其可使用的衍生形式不会进入智能体进程，也不会进入智能体能够读取的文件。

团队经常测试第一个结论，却默认第二个结论也成立。于是，智能体可以通过网关成功调用测试 API，同时又在调试字段或继承的环境变量中收到令牌。

Apple 的 Process 文档明确说明了继承关系：除非启动器在启动前修改环境，否则子进程会从启动它的进程继承环境。相关 API 还会把命令参数和环境数据暴露给进程本身。因此，在这项测试中，子进程不是一个无关紧要的实现细节，而是另一个观察点。

测试标准应该写成这样：

> 假设一个新生成的凭据金丝雀只存储在网关中，智能体可以完成指定的 HTTP 和 SSH 操作，但智能体可触达的进程、会话记录、日志、失败输出或收集到的诊断工件中，都不包含该金丝雀或可识别的编码形式。

不要声称黑盒测试能证明秘密从未占用网关内存中的任何字节。它做不到。测试可以确认用户真正关心的边界：智能体不会通过它控制的接口和工件获得可使用的凭据。

## 测试凭据需要用途和指纹

测试令牌应该只完成一件有用的事情。对于 HTTP，创建一个只能调用单个端点的测试账户，例如 `GET /whoami` 或 `POST /echo-action`，并返回无害的账户标识符。对于 SSH，在一次性主机上创建受限账户，只允许执行少量命令，并写入服务器端事件记录。

不要使用 `test-token` 这样的令牌，然后再搜索它。过短且可预测的标记会产生误匹配，也让编码检查失去意义。每次测试运行都生成不同的字符串。给它加上容易识别的前缀，再接上随机数据，这样人可以识别故障，又不会把普通输出误认为秘密。

例如，测试工具可以生成如下形式的金丝雀：

```text
sallyport_probe_7M3jP4Fqk2rV9dN8xC5a
```

这个字符串是凭据值，不是打印给智能体的标识符。为日志单独保留一个公开标签，例如 `run-2026-07-22-ssh-04`。标签可以出现在会话记录中，金丝雀不可以。

为每个通道和每条失败路径使用不同的金丝雀。在所有测试中重复使用同一个 HTTP 令牌，会让一次泄漏变成一堆过期匹配，也很难判断后续工件来自当前运行，还是来自之前清理失败的运行。

一个实用的测试夹具有四个部分：

1. 一个 HTTP 测试账户，身份验证成功后返回固定的非秘密响应。
2. 一个 SSH 测试账户，其强制命令记录操作标识符并返回固定消息。
3. 一个包含新金丝雀的网关凭据条目，不保留任何智能体可访问的副本。
4. 一个存放在工件目录之外的清单，将运行标签映射到本次运行使用的金丝雀。

清单属于敏感测试材料。把它存放在智能体无法读取的位置，并在运行结束后删除金丝雀。清理完成后，测试账户应该拒绝这些凭据，即使故障导致副本留在本地归档中也一样。

Sallyport 可以让网关持有测试用的 HTTP 和 SSH 凭据，同时让智能体获得操作结果，而不是凭据本身。

服务器端记录很重要。它证明身份验证确实发生过，避免测试因为操作根本没有执行而错误通过。例如，`authenticated action accepted for run-2026-07-22-ssh-04` 这样的响应，足以让智能体确认成功，却不会回显认证输入。

## 捕获启动信息，在第一次工具调用前发现泄漏

在启动边界捕获智能体最初的参数和环境。这项测试可以发现 shell 脚本、CI 配置、编辑器集成、包装器和便捷启动器传递的秘密。它也能发现人们在接入网关后常犯的错误：旧的 `API_TOKEN` 导出仍然存在，只因为新的路径看起来可以正常工作。

通过你控制的包装器启动智能体。包装器把自身精确的参数向量和环境快照写入受保护的测试目录，然后用智能体可执行文件替换自身。替换进程很重要，因为这样记录的是实际智能体启动时收到的值，而不是多层进程启动后重新猜出的值。

这个小型 Python 包装器就足以用于本地测试工具：

```python
#!/usr/bin/env python3
import json
import os
import pathlib
import sys

out = pathlib.Path(os.environ["PROBE_LAUNCH_RECORD"])
out.parent.mkdir(parents=True, exist_ok=True)
record = {
    "argv": sys.argv[1:],
    "environment": dict(os.environ),
}
out.write_text(json.dumps(record, sort_keys=True), encoding="utf-8")
os.execvp(sys.argv[1], sys.argv[1:])
```

使用有意精简的环境启动它。只加入智能体查找可执行文件、临时目录、MCP 端点或 stdio shim，以及包装器写入记录所需的内容。不要习惯性地继承开发者的完整 shell 环境。完整的 shell 环境会带入无关凭据、云配置、软件包仓库令牌和旧的 SSH 设置，导致测试因为错误的原因失败。

预期的启动记录大致如下：

```json
{
  "argv": ["agent-command", "run", "tests/agent-task.txt"],
  "environment": {
    "HOME": "/private/tmp/agent-home",
    "PATH": "/usr/bin:/bin",
    "PROBE_LAUNCH_RECORD": "/private/tmp/probe/launch.json"
  }
}
```

具体路径并不重要。重要的是记录中不能出现 HTTP 或 SSH 金丝雀、它的 base64 形式、URL 编码形式，或保存私钥的文件名。

不要在扫描器看到记录前对它脱敏。脱敏应该用于面向人的报告。原始记录才是证据。如果发布测试只记录清理后的版本，它可能只能证明脱敏器工作正常，却把你真正需要发现的泄漏藏起来。

启动快照有一个限制：它只能告诉你进程启动时拥有了什么，无法告诉你之后的工具调用是否把秘密放进了子进程环境或临时文件。因此，下一组测试需要强迫智能体在启动后执行操作。

## 子进程属于智能体边界

智能体经常启动格式化工具、软件包管理器、测试运行器、Git 命令、SSH 客户端和脚本。如果智能体能够启动子进程，子进程就可能把自己的环境和参数写入磁盘，将其作为输出返回，或传递给网络请求。除非有明确的技术理由，否则应把每个子进程都视为智能体可触达。

给智能体一个无害任务，让它在完成网关操作后运行探针程序。探针会以机器可读的形式打印自己的参数和环境。由于它是智能体一侧运行时的子进程，它能观察到运行时在该时刻传递的值。

让探针写入受控目录，而不是把整个环境转储到模型会话中。你是在测试泄漏，不是在邀请泄漏进入会话记录。

```python
#!/usr/bin/env python3
import json
import os
import pathlib
import sys

path = pathlib.Path(os.environ["PROBE_CHILD_RECORD"])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
    json.dumps(
        {"argv": sys.argv, "environment": dict(os.environ)},
        sort_keys=True,
    ),
    encoding="utf-8",
)
print("child probe completed")
```

先让智能体执行一次经过身份验证的操作，再用一个普通参数，例如 `after-http-action`，调用探针。在 SSH 操作后重复相同流程。如果网关实现把凭据注入辅助工具的环境变量，并让该辅助工具成为智能体的后代进程，这项测试就能发现它。

分别检查参数中的路径。开发者知道环境变量会泄漏，却经常忽视命令参数，因为进程检查、 shell 历史、错误格式化和诊断收集器都可能记录它们。Apple 文档说明，进程可以通过 `CommandLine.arguments` 访问自己的参数，`ProcessInfo` 也会暴露参数和环境。这意味着，传入 argv 的秘密会立即对进程内部运行的代码可见。

不要因为令牌字节没有出现，就接受 `--token-file=/private/tmp/secret` 这样的参数。测试还应该检查这个文件路径。如果智能体能够读取文件，它就获得了秘密。如果文件只能由独立的网关进程读取，且从未进入智能体控制的工作目录，应在测试设置中记录这一事实。

对于 SSH，不要只检查私钥文本。发现身份文件路径、暴露测试身份的代理套接字、包含私密材料的生成 `known_hosts` 记录，或包含密码的命令行，都应失败。存放在临时文件中的私钥仍然是私钥，即使智能体只收到文件路径。

## 成功操作需要带有攻击性的会话记录

返回 `200 OK` 的正常路径几乎不能证明什么。它只能证明某人发出了请求。让智能体通过网关请求真实操作，然后保留智能体一侧产生的全部会话记录和工具跟踪信息。

HTTP 测试应该请求一个确认测试账户身份的端点，但不要回显请求头。一个有用的响应可以是这样的固定对象：

```json
{
  "account": "gateway-test-http",
  "accepted": true,
  "request_label": "run-2026-07-22-http-01"
}
```

智能体可以根据这个响应进行判断。它不需要 bearer 令牌、授权方案、注入的请求头名称，也不需要经过脱敏的令牌前缀。如果操作接口为了调试返回请求对象，应把它作为单独的测试目标，因为这很可能造成泄漏。除非能保证在跨过智能体边界前删除凭据材料，否则名为 `request_headers` 的结果字段就是设计缺陷。

对于 SSH，让主机接受一个固定命令，例如 `report-status <run-label>`。服务器记录经过身份验证的账户、请求的命令和标签，并返回类似 `status recorded` 的响应。智能体不应收到私钥、SSH 代理导出内容或认证交互记录。

从智能体一侧保存这些工件：

- 原始智能体提示词和模型会话记录。
- 原始 MCP 消息，或等价的操作请求与响应。
- 智能体控制的命令产生的标准输出和标准错误。
- 工具调试日志、重试日志和结构化事件文件。
- 智能体工作区、临时目录和配置缓存目录下写入的文件。

在清理程序删除证据前收集这些内容。然后扫描精确的字节，而不只是按 UTF-8 解码后的文本。秘密可能出现在 JSON 转义、百分号编码、base64、压缩跟踪信息中，也可能出现在因为包含无效字节而被普通文本搜索跳过的文件里。

Sallyport 的 `sp mcp` shim 很适合用作测试对象，因为测试可以使用智能体实际采用的常规 MCP 路径，同时让凭据留在应用的保险库中。

不要把脱敏后的会话记录当成证据。日志行 `Authorization: [REDACTED]` 对操作员输出来说可能没问题，但产生它的原始事件对象仍可能包含令牌。先在显示格式化前捕获数据，再单独测试格式化器。这是两项不同的责任。

## 错误处理通常是秘密隔离失效的地方

网关可以让成功响应保持干净，却在出错时泄漏秘密。错误路径会带入调试上下文、请求重建、异常链和重试消息。要主动运行这些路径。

从不同阶段发生的 HTTP 失败开始：

1. 让测试服务器在收到有效金丝雀后返回非秘密的 `401`。智能体应该知道身份验证失败，而不是知道发送了什么请求头。
2. 让服务器返回包含公开运行标签的 `500` 响应。网关可以返回有界的错误正文，但不能附加请求头或等价的 curl 命令。
3. 在网关准备好认证后关闭连接。这可以发现低级异常将请求对象写入描述的情况。
4. 在身份验证成功后返回格式错误的 JSON。解析器经常会把出错的响应或周围上下文加入异常。
5. 为一条测试路径使用无法解析的 DNS 名称。这可以发现重试和端点诊断输出中的泄漏。

然后运行连接建立前后发生的 SSH 失败。使用主机身份错误的主机、以非零状态退出的远程命令，以及返回受控错误的强制命令。不要通过把测试私钥发回智能体，或让服务器记录它，来测试错误的私钥。智能体只需要看到类似 `connection rejected` 或 `remote command failed` 的分类，以及安全的请求标签。

也要测试锁定状态。保险库锁定时，操作必须在网络身份验证前失败。响应可以说明授权不可用，但不能包含令牌占位符、凭据存储路径、SSH 身份文件名或秘密的字符数。解锁后重复该操作，并要求服务器端成功记录。这一对测试可以发现先构造凭据请求、后检查锁定状态的实现。

一个有用的失败夹具应该在两侧都设置断言：

```text
Agent side: the canary is absent from every collected artifact.
Gateway side: the attempted action has the expected safe error classification.
Server side: the expected request occurred, or did not occur for a locked vault test.
```

最后一个断言可以防止错误自信。如果测试期待重试错误，但网关因为配置错误更早拒绝了调用，测试可能在完全没有经过危险代码的情况下通过泄漏扫描。

不要把原始异常跨边界传递。错误对象应包含操作标识符、安全类别、对人有用的消息，以及可能的重试提示。它们不应序列化导致异常的请求配置。为了方便调试而倾向于转储完整请求很常见，但当凭据注入器拥有请求时，这仍然不是正确的默认行为。

## 有意制造一次崩溃，测试崩溃工件

崩溃不是正常 API 结果，因此团队经常跳过它。这是错误的。开发者可能会把崩溃报告附加到问题中，支持脚本可能会归档它，诊断系统也可能收集相关日志。如果秘密进入这些地方，你构建的就不是安全边界，而是一条延迟泄漏路径。

在一次成功的 HTTP 操作后，以及一次成功的 SSH 操作后，分别让智能体一侧的探针进程异常终止。崩溃目标应与网关分开。你要测试的是智能体一侧是否继承或记录了秘密材料，而不是测试故意让凭据持有者崩溃时是否暴露其私有状态。

在 macOS 上，通过 Console 或测试环境的诊断收集路径收集报告，然后扫描未编辑的文件。Apple 将崩溃报告描述为应用状态的详细记录，并建议分析完整的操作系统报告。Apple 的崩溃报告文档还指出，报告包含进程和环境信息，例如进程身份、路径、父进程、时间信息和线程状态。

macOS 崩溃报告中没有金丝雀，并不能证明它从未出现在内存中。标准崩溃报告不是完整的内存转储。这个限制不是跳过测试的理由，而是意味着你应准确描述结果：报告没有暴露金丝雀，智能体进程也没有通过其他测试通道接收到它。

还要扫描崩溃前后生成的应用日志和支持归档。Apple 警告开发者不要在日志中加入隐私敏感信息。把这当作你自己的测试要求：如果异常打印器记录了请求对象，即使操作系统崩溃报告很干净，崩溃测试也应该失败。

如果之后在 Linux 上运行相关测试，把核心转储元数据和 journal 输出加入工件集合。`systemd-coredump` 手册记录了可以保存崩溃进程命令行和环境的字段。只扫描核心文件而忽略元数据的测试套件，会漏掉一条直接的泄漏路径。

## 扫描字节、编码和拆分值

递归 grep 总比什么都不做强，但它会漏掉 JSON、URL、堆栈跟踪和二进制包中常见的形式。构建一个按字节读取文件的扫描器，并搜索每个金丝雀的多种确定性转换形式。

至少为每个金丝雀生成以下搜索内容：

```text
raw bytes
base64 text
URL encoded text
JSON escaped text
hex text
first half and second half separated by one newline
```

拆分情况可以发现将长值折行的日志包装器。编码情况可以发现先序列化结构化数据、再写入文件的系统。不要只扫描令牌前缀。前缀测试可能在令牌被截断但仍足以使用时通过，也可能匹配无关的标识符。

一个紧凑的扫描器可以报告文件路径、转换名称和字节偏移，而不打印秘密本身：

```python
import base64
import json
import pathlib
import urllib.parse

secret = bytes.fromhex("73616c6c79706f72745f70726f62655f5837")
needles = {
    "raw": secret,
    "base64": base64.b64encode(secret),
    "url": urllib.parse.quote_from_bytes(secret).encode(),
    "json": json.dumps(secret.decode()).encode(),
    "hex": secret.hex().encode(),
}

for path in pathlib.Path("artifacts").rglob("*"):
    if not path.is_file():
        continue
    data = path.read_bytes()
    for name, needle in needles.items():
        offset = data.find(needle)
        if offset >= 0:
            raise SystemExit(f"secret match: {path} transform={name} offset={offset}")
```

代码有意报告偏移量，而不是匹配到的字节。测试失败不应在 CI 输出中制造第二次泄漏。只有在事件流程要求时，才把严格控制的取证副本保存到普通构建日志之外的位置。

解包后扫描归档，并将解包内容放在受保护的临时目录中。可行时也扫描压缩数据，因为原始字节扫描无法看到压缩载荷内部的金丝雀。如果遥测客户端会批量发送事件，请在批次离开测试机器前收集它。等到之后才发现扫描器只检查了本地文件，而编码后的令牌已经发送给第三方收集器，就没有意义了。

为预期出现的公开标签保留允许列表，不要为秘密保留允许列表。如果测试因为字段包含测试账户名称而失败，应判断这个名称本身是否授予访问权限。不要在扫描器变绿前加入宽泛的排除项。每一项排除都是一个你会忘记重新检查的漏洞。

## 报告必须同时展示缺失和操作

一份好的测试报告应回答四个问题，不要求读者相信你的解释。

第一，哪个网关操作成功或失败？展示公开运行标签、操作类型和服务器端观察结果。第二，收集了哪些工件？列出启动快照、子进程快照、会话记录包、工作区树、错误输出和崩溃工件。第三，扫描器搜索了哪些秘密转换形式？第四，是否有扫描读取失败，原因是权限问题、错误的编码假设，还是跳过了某个归档？

跳过的工件不算通过。将其标记为不完整，除非有记录在案的理由说明它位于智能体边界之外，否则应让套件失败。CI 设置期间这条规则可能让人不舒服，但它能防止一种很常见的情况：测试因为无法打开存放泄漏的目录，却静默报告成功。

让网关操作记录与智能体工件包分开。网关审计可以证明受凭据保护的操作发生过，智能体工件包可以证明智能体收到了什么。把两者合并成一个方便的导出文件，只会增加一个让敏感信息流动的新位置。

对于发布检查，应设置严格的通过条件：

```text
PASS only when the server confirms the intended action,
all required artifacts were collected,
and no raw or transformed canary appears in agent reachable material.
```

然后加入负向控制。运行一个有意损坏的夹具，让它通过环境变量或虚假的调试响应传递金丝雀。扫描器必须让它失败。一个从未展示过自己能够捕获已知泄漏的测试，只是在做样子。

每当有人修改凭据注入、进程启动代码、MCP 传输、错误格式化、日志记录、支持收集或 SSH 处理时，都运行这套测试。这些变化在代码审查中看似无关，却正是凭据逃逸的地方。如果崩溃和归档收集成本很高，可以在普通集成测试中保留较小的版本，把完整版本留给定期运行或发布运行。

标准并不难说清：网关可以使用秘密来执行操作，但智能体不能把秘密作为数据获得。如果测试只观察智能体说了什么，就会遗漏太多内容。让智能体执行操作，让它失败，让它启动子进程，让它崩溃，然后扫描剩下的一切。
