# 本地 AI 代理的 MCP stdio 安全边界

本地 MCP 服务器经常被认为没有风险，因为它们使用 stdio，并且与代理运行在同一台 Mac 上。但只要服务器能够调用外部 API、使用 SSH、读取凭据文件，或以开发者权限启动 Shell 命令，这个结论就不成立。使用本地传输只是少了一次网络跳转，并没有降低接收请求的进程所拥有的权限。

真正重要的边界很简单：MCP 服务器应该提供上下文和范围严格受限的计算；受信任的操作网关则负责保存凭据，并执行离开本机的操作。把这两种职责混在一起，就会让语言模型获得一条方便的路径，从不受信任的指令直达持久权限。我见过这种错误以整洁的单文件工具形式出现，随后逐渐变成令牌、子进程调用和例外规则的杂物集合，出了事故后没人说得清它到底做了什么。

## Stdio 是传输方式，不是信任判断

MCP stdio 安全的起点，是承认标准输入和标准输出无法证明请求的意图。MCP 客户端启动服务器，并通过管道交换 JSON-RPC 消息。Model Context Protocol 规范将 stdio 描述为一种传输方式：服务器从标准输入读取消息，再向标准输出写入消息。它并没有声称这种传输能够证明请求是安全的、经过人工批准的，甚至不能证明请求来自你预期的模型。

本地客户端可以直接发送 `tools/call` 请求。它可以绕过正常的模型循环，以机器速度重复调用，选择工具描述不建议使用的参数，并保留收到的每个结果。如果是一个遭到入侵的编辑器扩展启动了客户端，除非外围设计向服务器提供了相关信息，否则服务器没有神奇的方法判断调用者不是 Claude Code 或其他预期客户端。

常见的进程树也没有人们想象的那么强的隔离效果。如果代理以你的登录身份启动 MCP 服务器，服务器通常会继承你的用户身份、工作目录、环境、文件权限、网络访问能力，以及你留在环境变量中的任何秘密。管道不会缩小这些权限。

把每次工具调用都视为来自不受信任进程的请求。这个进程可能说服了模型，也可能冒充模型来发出请求。这个假设听起来严格，因为它确实严格，但它也能应对提示注入、有缺陷的客户端、被复制的配置，以及开发者用原始 JSON-RPC 脚本测试请求的情况。

## 服务器应在获得可重复使用的权限之前停下

MCP 服务器在需要披露或管理可重复使用的凭据之前就应该结束自己的工作。它适合搜索已建立索引的代码仓库、解析构建输出、格式化请求数据、读取明确开放的项目文件，以及生成供人工审核的命令。这些任务写得不好同样可能造成损害，但它们不需要一个在会话结束后仍然有用的秘密。

外部操作需要另一位负责人。经过身份验证的 API 请求、SSH 连接、发布软件包、查询生产环境或更新问题，都会把不受信任的输入与一个可能产生后果的身份连接起来。把凭据放在一个由操作网关管理的组件中，由它自己发出请求，然后向 MCP 服务器或代理返回范围受限的结果。

这一区分经常被忽略，因为两个组件都可能是本地可执行程序。但它们不能互相替代：

- MCP 服务器将代理请求转换为有限操作，或转换为对某项操作的请求。
- 操作网关负责凭据，判断当前进程是否可以使用凭据，执行外部调用，并记录发生的事情。
- 代理接收的是输出，而不是在网关之外重复执行经过身份验证操作的手段。

不要把 `API_TOKEN=...` 作为工具结果发送。不要发送保险库引用后就把它称为安全。不要暴露一个会把私钥打印到标准输出的命令，然后指望模型主动避开它。客户端一旦拿到秘密，之后的所有控制都只能算建议。

网关也不应变成万能 Shell API。`run(command)` 很有吸引力，因为它省去了工具设计，但它也会把参数解析、文件访问、网络目标，往往还包括秘密访问，都交给一个不透明的字符串。应当设计范围明确的操作，例如 `get_deployment_status`、`create_issue`、`run_readonly_query`，或使用指定主机和受限命令族的 `ssh_exec`。范围明确的操作才能验证，也才方便审核。

## 工具模式描述调用，但不能限制调用

工具的 JSON Schema 对输入验证很有用，但它不是授权机制。MCP 规范要求工具发布输入模式，客户端可以据此构造调用，但模型仍然可以选择任何符合模式的值。更糟的是，粗糙的实现往往接受一个符合模式的字符串，之后却把它拼接进 Shell 命令或 URL，让它在新的语境中改变含义。

假设有一个用于获取部署状态的工具：

```json
{
  "name": "deployment_status",
  "inputSchema": {
    "type": "object",
    "properties": {
      "environment": {"enum": ["staging", "production"]},
      "service": {"type": "string", "pattern": "^[a-z0-9-]{1,48}$"}
    },
    "required": ["environment", "service"],
    "additionalProperties": false
  }
}
```

这个模式可以阻止意外的顶层字段，也能拒绝 `service` 中明显的 Shell 标点。但它不能授权调用者检查生产环境，不能证明 `service` 属于当前代码仓库，也不能限制服务器构造 URL 后实际访问的 HTTP 目标。模式验证回答的是「格式是否正确？」授权回答的是「这个调用者现在能否以这个身份执行此操作？」应在代码和审核中把这两个问题分开。

糟糕的实现通常类似这样：

```python
subprocess.run(
    f"ssh {host} systemctl status {service}",
    shell=True,
    check=True,
)
```

即使 `host` 和 `service` 通过了宽松的模式验证，Shell 解析仍会引入另一种语言和另一片攻击面。应使用参数数组，在建立连接前拒绝未知主机，并让网关根据固定标识选择凭据，而不是接受代理提供的路径或令牌名称。

对于 HTTP，应在连接前解析 URL，要求使用 `https`，将规范化后的主机名与允许的精确主机列表进行比较，并禁用重定向或重新验证重定向目标。从允许主机重定向到内部地址或攻击者控制的端点，可能把看似无害的请求变成凭据泄露。不要依赖 `url.startswith("https://api.example.com")` 这样的前缀检查。用户信息、端口和相似的主机名都会让字符串检查变得不可靠。

## 在审批点明确显示进程身份

人工审批按钮只有在明确说明请求者是谁，以及审批授予了什么权限时才有帮助。「允许代理访问」这种表述很弱，因为它隐藏了将获得权限的可执行程序，也隐藏了权限持续多久。它会让人习惯于批准一个模糊的活动类别。

更好的设计会通过代码签名身份、父子进程关系、可执行文件路径和进程生命周期来识别请求进程。这样，人工可以批准一个已知客户端的一次运行，而不是永久信任一个标签。进程退出后，审批必须结束。新的进程需要重新决定。

代码签名不能证明客户端内部的每个提示或插件都是良性的。它只能回答一个范围更小、但仍然有用的问题：哪个经过签名的可执行程序请求了权限？当恶意或被修改的本地程序试图使用一个友好名称时，这一区分很重要。在 macOS 上，操作系统会提供代码签名信息，网关可以在允许进程执行操作前展示这些信息。

Sallyport 使用进程身份执行按会话授权，而它的锁定保险库会拒绝所有操作，直到用户使用 Mac 的硬件保护控制将其解锁。这种模式刻意保持简单：一个锁定的保险库、对新进程的一次审批，以及对特定凭据每次使用的可选审批。

不要试图用复杂的自然语言策略解决审批疲劳。规则一旦积累了几十个例外，人们就无法可靠地判断一套密集的规则。应使用少量、开发者能够直接看到的决定：是否可以使用秘密、这次运行允许哪个进程执行操作，以及哪些凭据每次都需要重新确认。

## 审批范围必须与操作可能造成的损害相匹配

对于读取问题跟踪系统或检查开发服务状态这类重复且影响较小的工作，每个会话审批一次就够了。但如果同一审批悄悄覆盖了破坏性数据库修改、发布软件包、资金转移、客户沟通或生产环境 SSH 访问，就会变得危险。

为每个凭据设置自己的审批敏感度。进程获得会话审批后，可以使用只读令牌。生产写入令牌或 SSH 密钥则应要求每次使用都确认。网关必须展示足够的上下文，让人能够判断操作：凭据身份、目标主机或服务、方法或命令类别，以及经过清理的参数。不要展示秘密本身。

审批应该授权一个具体请求，而不是承诺代理以后会遵守规则。如果工具调用写的是 `POST /releases`，审批界面不应把它压缩成「使用发布 API」。方法、最终目标和操作名称，才是区分无害读取与不可逆写入的事实。

常见的替代方案是设置宽泛的允许列表：批准一个域名、一个 Shell 二进制文件，或批准某个代理在整个工作日内使用权限。它看起来高效，直到注入的指令把同一项获准能力转向另一个代码仓库、端点或参数。宽泛授权减少了打断，却把审核负担转移到了没人能看到实际调用的时刻。

积极设置过期时间。会话授权应随着客户端进程结束而消失。单次调用的决定应在该操作完成后失效。如果网关以后必须支持更长的授权，就明确记录范围和过期时间，不要让缓存审批伪装成永久信任。

## SSH 需要自己的边界，不能充当 Shell 越权入口

本地代理设计经常在 SSH 上失去纪律。开发者已经拥有 SSH 代理、主机别名、转发密钥，也习惯在终端输入任意命令。于是人们很容易让 MCP 服务器使用用户现有的环境调用 `ssh`。这样一来，代理就能调用 Shell 可以访问的每个身份和主机规则。

OpenSSH 明确记录了代理转发的一项严重限制：能够访问转发代理套接字的远程用户，可以请求本地代理执行操作，虽然他们无法提取私钥。这已经足以让对方在转发持续期间冒充你执行操作。自主代理不应随意选择这条路径，因为它会把权限扩展到原始主机之外。

为代理工作使用专用 SSH 身份，并将其绑定到命名主机记录。在服务器端，根据账户用途设置适当选项，例如强制命令，并在允许的情况下禁用转发。在本地端，从代理无法修改的配置中选择主机和身份。代理可以请求 `host: build-staging` 和有限的命令操作，但不应提交任意主机名、私钥路径或 `-o ProxyCommand=...`。

这是操作网关可以验证的请求基本形式：

```json
{
  "action": "ssh_exec",
  "host_id": "build-staging",
  "command_id": "read_service_status",
  "args": {"service": "worker"}
}
```

网关将 `build-staging` 映射到已知主机、主机密钥策略、账户和专用凭据。它将 `read_service_status` 映射为固定的参数数组，而不会把这段数据拼接成 Shell 字符串。被拒绝的请求应在审计记录中说明原因，但不要把秘密材料或可能带有攻击性的内容回显到终端。

如果确实需要任意远程诊断，应把它设计成单独的高摩擦操作，每次调用都需确认，并明确限制输出。不要把任意 Shell 访问藏在 `check_server` 这类听起来轻松的工具名称下。

## 审计记录必须能在制造记录的行为者之后继续存在

由执行操作的同一进程写入的文本日志，只有在该进程不想修改它之前才算证据。代理活动需要一份能够让操作人员重建整个运行过程和每次外部调用的记录，并能在事后发现删除或修改。

记录进程身份、会话标识符、时间、操作类型、凭据标识符、获批目标、经过清理的请求结构、审批结果、响应状态和错误类别。将会话日志与操作日志分开。会话视图回答「哪个代理运行获得了权限？」操作视图回答「它利用这项权限做了什么？」不要让调查人员从一串扁平日志中自行推断这些信息。

哈希链提供了一种实用的完整性检查。对于每条记录，使用上一条记录的摘要和新加密记录的规范字节计算摘要，并将新摘要与记录一起保存。验证程序无需获取明文，就能发现记录被修改、中间记录被删除或顺序被重新排列。

审计验证程序应独立于代理运行，也不应需要访问凭据。命令界面可以简单到这样：

```text
$ sp audit verify
records: 184
first sequence: 1
last sequence: 184
chain: valid
```

这种输出形式能让操作人员把明确的信息记录到工单或事件记录中。如果验证失败，命令应报告连续性首次中断的序号，并返回非零退出状态。「日志无法读取」过于模糊，无法用于调查。

防篡改证据不等于防止篡改。拥有足够权限的本地用户仍可能删除整个日志，或回滚其存储。必须明确这一限制。如果风险等级要求防范本地回滚，应将带签名的检查点导出到另一个受控系统。不要声称本地哈希链能够解决它无法应对的威胁。

## 让秘密远离环境变量和工具输出

环境变量对人工 Shell 会话很方便，却不适合作为自主代理的隔离手段。子进程默认会继承它们。调试日志可能打印它们。列出环境变量的命令可能把它们返回给模型。崩溃报告、支持打包文件、进程检查和复制的终端记录，都曾以这种方式暴露秘密。

将凭据文件放在工作区更糟。模型可以读取它，工具可以上传它，Git 命令可以将它加入暂存区，索引服务还可能保留副本。把文件移动到隐藏目录，只能改变意外发生的概率，并不会改变安全边界。

将凭据保存在由操作网关控制的保险库中。网关根据固定的操作映射选择凭据，只将凭据注入自己的 HTTP 或 SSH 操作。只有在响应内容对代理安全时，才在过滤后返回响应正文。绝不能让 bearer 令牌经过 MCP 结果，即使已经脱敏也不行，因为脱敏错误会永久留在对话记录中。

对于 HTTP，最好定义响应契约，而不是原样转发响应。部署状态操作可以返回：

```json
{
  "environment": "staging",
  "service": "worker",
  "state": "healthy",
  "revision": "a1b2c3d4"
}
```

它不应返回可能包含会话标识符、内部路由细节或新令牌的响应头。在实现之前先决定代理需要哪些字段。原始代理响应是另一个日后很难收拾的捷径。

## 小而清晰的边界更容易在压力下运行

无需策略语言或庞大的安全项目，也可以审核本地代理集成。先列出所有操作，然后强制把每项操作归入两类之一：它要么读取或计算本地上下文，不需要可重复使用的权限；要么跨入外部服务，需要操作网关。

对于每项外部操作，写下固定的目标身份、网关选择的凭据、代理可以影响的确切参数、审批范围，以及生成的审计记录。如果某一行写着「任意命令」「任意 URL」「来自环境的令牌」或「代理选择凭据」，说明边界还没有完成。

在向代理提供有用的秘密之前，先进行以下故障演练：

1. 发送一个符合模式、但指定了意外目标或超大参数的原始请求。
2. 在客户端进程退出后，重放之前已经批准的请求。
3. 尝试重定向的 HTTP 请求，以及带有转发选项的 SSH 请求。
4. 锁定保险库，然后确认所有操作都会在建立任何网络连接之前失败。
5. 修改一条已存储的审计记录，并确认审计命令能够指出链条断裂。

这些测试可以发现愉快演示所掩盖的设计错误。一个完美遵循指令的模型，不是安全测试。

最清晰的本地架构会让 MCP 服务器保持普通且可随时丢弃。让它提供有用的上下文，并请求范围明确的操作。把秘密、面向进程的审批、执行过程和可审计记录放在操作边界之后。当有人问为什么工具不能直接拿到生产令牌时，答案应该从设计中一目了然：工具完成自己的工作根本不需要令牌。
