# 自主智能体的 SSH 端口转发：更安全的隧道

自主智能体的 SSH 端口转发，需要比普通开发者访问更严格的设计。隧道可以通过一条 SSH 命令，让原本只能在本地主机访问的数据库、管理面板或预发布服务变成智能体能够访问的目标。如果智能体持有权限过宽的 SSH 凭据，隧道只是表面症状。真正的问题是，没有人限制这条命令背后的目标、持续时间和授权范围。

我见过团队因为有人在终端里输入了 `ssh -L`，就把隧道称为「临时」隧道。结果终端连续运行了几天，隧道端口成了另一个进程的依赖，而当安全团队询问生产相关服务为什么出现一个无法解释的监听端口时，打开隧道的人已经无法联系。自主任务会让这种模式更加危险，因为智能体比一个分心的人更容易持续重试、重新连接，并稳定地使用隧道。

安全做法很明确：给智能体一条范围严格受限的连接路径，批准一次具体的临时暴露，记录足够的上下文以便日后还原事件，并让关闭隧道成为可以强制执行的事件，而不是一句口头意图。

## 隧道改变的是网络可达性，而不只是 SSH 行为

SSH 端口转发会在 SSH 连接的一端创建监听器，并将流量传送到另一端可以访问的目标。这个监听器就是一条访问路径。如果只把它当成「SSH 访问」来审查，就会忽略真正产生风险的部分：谁可以连接、流量最终到达哪里，以及可以通过隧道传输什么。

假设一个运行在构建主机上的智能体需要查询 `db-admin.internal.example` 的 5432 端口。本地转发可以让该服务在构建主机上显示为 `127.0.0.1:15432`。数据库仍然不会暴露给公共互联网，但该主机上所有能够访问这个监听器的进程，现在都可能尝试连接。SSH 服务器也因此成为通往数据库的一条授权路径。

这可能是可以接受的，但它不等于允许智能体「使用 SSH 进行维护」。批准记录应准确描述这条路径：

- 发起任务和主机
- 监听地址和端口
- 目标主机和端口
- 建立路径的原因
- 过期时间和批准人

远程转发会反转监听器的位置。如果智能体连接到堡垒机，并要求堡垒机监听 18080 端口，那么能够访问该监听器的进程就能接收返回智能体主机的流量。远程转发经常让团队措手不及，因为它可以在不打开入站防火墙规则的情况下，暴露防火墙后面的服务。

OpenSSH 在 `ssh(1)` 中分别记录了这些模式：`-L` 创建本地转发，`-R` 创建远程转发，`-D` 创建 SOCKS 代理。这种区分对运维很有帮助。不要把「端口转发」作为一个不加区分的权限来批准。每种模式都会暴露不同的监听器，也需要不同的限制。

## 本地、远程和动态转发需要不同的决策

当智能体需要通过受控跳板机访问一个已知服务时，本地转发通常适合智能体任务。监听器存在于智能体一侧，SSH 服务器负责连接内部目标。命令形式如下：

```sh
ssh -N \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

`-N` 要求 SSH 不运行远程命令，但这不会让连接变得无害。绑定地址 `127.0.0.1` 将监听器限制在本地主机，`db-admin.internal.example:5432` 则声明了请求的目标。这是两个独立属性，批准记录必须同时包含它们。

只有在有人明确选择让远程一侧创建监听器时，远程转发才适用。下面的命令要求堡垒机在自己的回环接口上监听，并将流量传回智能体机器的 8080 端口：

```sh
ssh -N \\
  -R 127.0.0.1:18080:127.0.0.1:8080 \\
  agent-return-path@bastion.internal.example
```

不要因为远程监听器绑定到回环地址，就自动认为它安全。堡垒机上的本地进程可能比智能体源机器拥有更多权限，共享堡垒机上的其他用户也可能访问它。允许之前，应检查远程主机上的用户和本地进程。

对于自主智能体，动态转发默认应拒绝：

```sh
ssh -N -D 127.0.0.1:1080 agent@bastion.internal.example
```

这会启动一个 SOCKS 监听器。客户端之后通过 SOCKS 请求选择目标，而不是在 SSH 命令中指定一个目标。团队喜欢这种方式，因为它可以快速让内部 Web 界面在浏览器或测试工具中工作。但这种便利也移除了无人值守任务所需的目标边界。审核人员无法批准单条路径，普通 SSH 目标限制也无法为任意 SOCKS 流量表达有用的允许列表。

不要通过填写更长的批准表来解决这个问题。拒绝智能体账户使用动态转发。如果任务需要多个目标，应分别定义每条转发，或在服务前面放置一个专用代理，为代理配置独立的身份验证和日志。

## 绑定地址决定谁可以使用监听器

绑定地址属于安全决策的一部分，因为它决定哪些机器可以连接隧道监听器。绑定到回环地址的转发，与绑定到所有接口的转发，即使使用相同目标，也会造成完全不同的暴露范围。

对于本地转发，应使用明确的回环地址：

```sh
-L 127.0.0.1:15432:db-admin.internal.example:5432
```

不要依赖默认行为。省略绑定地址时，OpenSSH 通常会将本地转发绑定到回环地址，但写明地址可以让审查、日志和事件调查更加明确，也能防止后续客户端配置变化悄悄扩大监听器范围。

下面这种写法很危险：

```sh
-L 0.0.0.0:15432:db-admin.internal.example:5432
```

它会在客户端主机的每个 IPv4 接口上暴露 15432 端口。任何能够连接客户端的主机，都可以尝试使用这条隧道。共享子网中的构建运行器可能因此成为通往内部数据库的桥梁，即使数据库本身只接受来自堡垒机的连接。

IPv6 也需要同样关注。`::1` 是回环地址，`::` 则会监听所有 IPv6 接口。请检查两种地址族。我见过团队确认 `127.0.0.1` 安全，之后却发现自动化流程通过另一条配置路径同时打开了 IPv6 监听器。

对于远程转发，SSH 服务器控制它接受哪些绑定地址。在 `sshd_config` 中，`GatewayPorts` 会影响远程转发的绑定行为。OpenSSH 文档说明，除非受到请求地址和服务器策略影响，远程转发默认绑定到回环地址。除非有经过审查的理由允许更广泛的远程监听，否则保持 `GatewayPorts no`。`GatewayPorts clientspecified` 会让客户端对智能体账户拥有过多控制权。

打开转发后，应在拥有监听器的机器上检查它。在 macOS 或 Linux 上，可以进行如下本地检查：

```sh
lsof -nP -iTCP:15432 -sTCP:LISTEN
```

输出应显示一个 `ssh` 进程，地址类似 `127.0.0.1:15432` 或 `::1:15432`。如果显示 `*:15432`，应停止任务并检查命令和客户端配置。这个命令只能证明本地监听器地址，无法证明远端仅限于预期目标。

## 专用 SSH 账户必须表达允许的路径

带有服务器端限制的专用 SSH 账户，是智能体隧道最低限度的合理边界。客户端配置文件无法阻止能够修改自身命令参数的智能体。应把限制放在 SSH 服务器接受连接的地方。

对于仅允许本地转发的账户，可以在 `sshd_config` 中从这样的匹配块开始：

```text
Match User agent-db-tunnel
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PubkeyAuthentication yes
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    AllowStreamLocalForwarding no
    AllowTcpForwarding local
    PermitOpen db-admin.internal.example:5432
    GatewayPorts no
    PermitUserEnvironment no
```

`AllowTcpForwarding local` 允许本地转发，同时拒绝远程转发。`PermitOpen` 指定该账户可以请求的目标。这项限制很重要，因为没有它，本地转发命令可以指向堡垒机能够访问的任意主机和端口。没有 `PermitOpen`，一个确实需要访问数据库的智能体，也可能请求转发到管理 API、缓存、元数据服务或另一台 SSH 服务器。

谨慎使用主机名。SSH 服务器负责解析目标主机，因此 `PermitOpen` 中的名称必须能在服务器端解析。应确保该名称稳定，并由你的基础设施负责。如果 DNS 名称可能变更为任意地址，那么配置看起来范围很小，实际目标却可能在后台悄悄变化。对于固定设备，IP 地址可能更清楚；如果内部 DNS 控制和服务归属管理严格，主机名也可以使用。

OpenSSH 的 `sshd_config(5)` 手册将 `PermitOpen` 描述为 `host:port` 形式的目标限制。它不会把账户变成策略引擎，但会准确完成一项工作：拒绝目标不在列表中的转发请求。账户设计也应保持同样的明确性。

不要因为「智能体不会使用」就给这个账户 Shell。应直接移除相关能力。专用账户应只承担一种连接角色。如果还需要进一步防止交互式使用，可以在其授权公钥中配置强制命令，让账户在收到其他命令时退出，但必须结合你的环境测试这种做法对转发行为的影响。某些强制命令模式和包装器会干扰预期的 SSH 会话，而限制配置一旦损坏，往往有人会在截止时间前直接删除整个配置块。

同样要避免代理转发。`AllowAgentForwarding no` 可以阻止已连接的服务器要求客户端 SSH 代理为身份验证请求签名。SSH 隧道账户应该传输流量，而不应成为通往其他主机的跳板。

如果目标有两个合法服务，就列出两个明确的 `PermitOpen` 条目。不要使用通配符，也不要为了少创建一个账户，就用通用堡垒机账户替代它。多一个账户的成本，远低于解释为什么一个代码智能体能够访问整个内部子网。

## 临时访问意味着强制过期和干净关闭

只有当某种机制能够在良好意愿之外终止隧道时，隧道才算临时。SSH 会一直保持连接，直到客户端关闭、网络故障或超时中断。自主智能体可能在测试成功、失败或超时之后，仍然让进程继续运行。

应在智能体无法自行决定的范围之外，为每个任务设置固定的最长会话时间。运行器可以在支持的环境中通过 `timeout` 强制执行：

```sh
timeout 20m ssh -N \\
  -o ExitOnForwardFailure=yes \\
  -o ServerAliveInterval=30 \\
  -o ServerAliveCountMax=3 \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

`ExitOnForwardFailure=yes` 会在 SSH 无法创建请求的监听器时阻止任务继续。如果没有它，智能体可能继续运行，并报告令人困惑的应用错误，而不是隧道建立失败。服务器存活设置会在多次收不到响应后让 SSH 退出，这有助于处理客户端主机悄然失去网络连接的情况。但它们不能替代 20 分钟的时间上限。

macOS 默认不提供 GNU `timeout` 命令。可以使用运行器自身的超时功能、带截止时间的受监管进程，或一个发送 `TERM` 并确认进程退出的小型包装器。不要因为缺少截止时间，就用定时任务去终止「旧 SSH」。这样会误杀无关任务，也无法证明它停止的是哪条连接。

还应要求任务在自己的清理流程中关闭隧道。对于任务启动的进程，应保存其 PID，在任务完成时发送 `TERM`，短暂等待，然后通过 `lsof` 或 `ss` 确认监听器已经消失。截止时间可以处理崩溃，清理流程则负责正常结束。

连接复用需要特别谨慎。使用 `ControlMaster` 和共享的 `ControlPath` 时，不同的 SSH 调用可以复用同一个主连接。对于人工终端会话，这能节省建立连接的时间；对于智能体任务，它会混淆所有权和过期时间：一个任务可能继承另一个任务批准的连接，关闭某个客户端也可能不会关闭主连接。应为隧道账户禁用复用，或为每个获批任务生成唯一的控制路径。

需要重复访问的维护流程，应申请重复但分别受限的会话。不要因为重新创建隧道不方便，就让隧道一直保持打开。建立连接节省的几秒钟，会在每次有人调查异常连接时变成更多不确定性。

## 批准记录必须描述审查人员能够还原的连接

批准记录应让审查人员回答：谁批准了这条路径，什么打开了它，以及它是否始终在声明的边界内。像「已批准智能体访问」这样的笼统工单评论不是证据，因为它没有标识监听器、目标或持续时间。

应在隧道启动前记录批准。记录人工批准者的身份、时间戳、任务或运行标识、智能体进程身份、源机器、专用 SSH 账户和公钥指纹。然后完整记录请求的转发：方向、绑定地址、监听端口、目标主机、目标端口、用途和过期时间。

结果记录需要单独的字段。应包含 SSH 是否创建了转发、客户端进程标识符、开始时间、最终退出状态、关闭时间和关闭原因。超时、正常清理、操作员撤销和网络故障，在审查时具有不同含义。

使用结构化记录，而不是迫使他人从文字中推断字段。许多内部系统使用下面这样的结构就足够了：

```json
{
  "run_id": "run-7c31",
  "approved_by": "oncall-engineer",
  "approved_at": "2025-04-12T14:03:00Z",
  "ssh_account": "agent-db-tunnel",
  "direction": "local",
  "listen": "127.0.0.1:15432",
  "destination": "db-admin.internal.example:5432",
  "expires_at": "2025-04-12T14:23:00Z",
  "purpose": "run migration compatibility check"
}
```

示例中的标识符只是占位符，不是必须完全照搬的模式。重要的是保持纪律，而不是字段名称：一条记录必须将人员、进程、路径和时间窗口绑定在一起。

不要让同一个智能体生成批准，并把它当成人工授权。智能体可以说明自己为什么需要连接，但允许或拒绝必须由人工或独立受治理的流程决定。如果操作足够常规，可以自动批准，就应将决定写入范围严格受限的任务定义，设置过期时间，并保留允许该操作的规则版本。

批准疲劳是设计失败。要求某人批准每个数据包，只会制造没有意义的点击。为每条临时且具体的路径请求一次批准，批准人才能真正判断这项请求。提示应保持简短，但必须包含绑定地址和确切目标。这两个字符串能发现许多出错的请求。

## 让凭据留在智能体之外，让授权不受提示词控制

自主智能体应该请求连接，而不应收到一把之后可以创建任意连接的私钥。一旦智能体在进程环境或工作区中获得权限过宽的 SSH 密钥，它就可以复制密钥、调用其他 SSH 客户端、在批准的任务结束后继续使用，或将密钥交给你没有计划信任的工具。「只能将它用于数据库」这样的提示词，并不能约束这些行为。

更好的设计是将 SSH 凭据放在独立的执行边界中。智能体提交带有路径字段的操作请求。这个边界检查保险库是否可用、会话是否获授权，以及请求的凭据是否需要批准。它执行 SSH 操作并返回结果，但不会把私钥交给智能体。

Sallyport 在 macOS 上使用这种模式：其内置的 `sp-ssh` 辅助工具执行 SSH 操作，SSH 密钥保留在应用的加密保险库中，不会到达 MCP 智能体。

当你调查故障时，这种区分很重要。凭据保管回答的是：「智能体能否在其他地方复用这个秘密？」路径限制回答的是：「这个账户能否访问未批准的目标？」批准回答的是：「谁批准了这次具体使用？」团队经常把这些控制混为一谈，最后才发现审计记录只说明某把密钥被使用过，却没有说明它启用了什么路径。

让请求接口比原始 SSH 参数更窄。可以接受 `destination_host`、`destination_port`、`listen_address`、`listen_port` 和 `max_duration` 等字段。拒绝那些会改变代理方式、转发方向、远程命令执行、身份文件或控制套接字的客户端选项。如果接受任意 `ssh` 命令字符串，就等于把策略解释权交给字符串解析。有人加入 `-R`、`-D`、`ProxyCommand` 或第二条转发后，这种方法就会失效。

不要为隧道流程暴露一个通用 Shell 作为逃生通道。当操作只是创建一条受限通道时，智能体不需要交互式运行 `ssh`。操作范围越窄，就越容易批准和撤销。

## 审计证据必须经得起严格调查

只有当遭到入侵的智能体或本地进程无法事后改写隧道日志时，这些日志才有价值。把纯文本日志放在智能体工作区旁边很方便，但如果同一个主体既能编辑隧道命令，也能编辑历史记录，那么这些日志几乎无法证明什么。

将批准事件、请求事件、执行结果和关闭结果记录为独立事件，并使用同一个运行标识关联它们。保留原始请求字段，而不是只记录渲染后的命令行。命令行可能把值隐藏在配置文件中，也可能省略影响监听器的默认设置。

从多个位置收集相互印证的记录。堡垒机的 SSH 日志可以显示成功的身份验证和转发失败。任务运行器可以显示进程创建和退出。目标服务可以显示来自堡垒机的连接。单独来看，这些记录都无法解释完整事件，但合在一起就能暴露相互矛盾之处。

例如，一项批准允许 `127.0.0.1:15432` 在 20 分钟内连接一个数据库。如果客户端日志显示它在 14:23 关闭，但目标服务在 15:10 仍看到来自堡垒机的流量，就应立即调查。智能体可能打开了另一条连接，复用了共享主连接，或者有人使用同一账户通过不同路径进行访问。

Sallyport 会将会话日志和单次调用日志写入加密、哈希链式的审计日志，`sp audit verify` 可以在离线状态下检查这条链，而不需要保险库密钥。这比智能体进程可以修改的日志文件更强，但它不能替代严格的账户限制。

应在日常审查时进行验证，而不是只在事件发生后验证。防篡改链可以告诉你记录历史是否保持连续，但不能告诉你一开始是否记录了足够的字段。应先决定在糟糕的一天里需要了解什么，再确保这些字段在连接开始前就写入事件。

## 先撤销路径，再调查经过

怀疑某条隧道错误或遭到滥用时，应先停止连接路径。调查可以等待几分钟，但暴露的路径可能在大家争论哪份日志最可信时继续传输数据或实现横向访问。

终止客户端进程，并确认监听器已经消失。禁用专用 SSH 账户，或在接受连接的服务器上移除其公钥。如果目标服务使用了通过隧道传输的凭据，而该凭据可能暴露给不受信任的进程，就按照该服务自身的流程轮换凭据。不要仅仅因为存在隧道，就轮换无关凭据。

然后保存证据。保存批准记录、请求的路径、进程日志、SSH 服务器日志、目标服务连接记录和关闭结果。将时间统一记录在一个时区，最好使用 UTC。当任务运行器使用本地时间、堡垒机使用 UTC、应用又写入没有时区的时间戳时，事件调查往往会浪费数小时。

宣布事件得到控制前，检查是否存在第二个监听器。在智能体主机上检查请求的端口和附近的转发进程。在堡垒机上检查该专用账户的活动 SSH 会话和身份验证日志。在目标端查找超过预期过期时间后开始的连接。目标是找出仍然存在的访问路径，而不是尽快编出一个整齐的故事。

如果人工批准启用了错误路径，应先修复请求接口。一个仍然接受任意 SSH 标志的更好审查页面，只会再次产生同样的错误。如果服务器限制失效，应在修复后测试确切的被拒绝命令，并保留失败输出。只有在确认控制措施确实拒绝了你想拒绝的路径后，人们才会信任它。

## 在智能体依赖隧道之前测试拒绝路径

隧道设计只有在同时测试获批连接和被禁止的变化形式后才算准备就绪。成功路径测试可以发现拼写错误，拒绝测试则能告诉你边界是否真的存在。

使用非生产账户，尝试一条应该成功的转发：

```sh
ssh -N -o ExitOnForwardFailure=yes \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

然后尝试一条 `PermitOpen` 不允许的目标：

```sh
ssh -N -o ExitOnForwardFailure=yes \\
  -L 127.0.0.1:15433:admin-api.internal.example:443 \\
  agent-db-tunnel@bastion.internal.example
```

第二条命令应在转发建立阶段失败。保存客户端输出和服务器日志条目。接下来尝试 `-R` 和 `-D`，对于配置了 `AllowTcpForwarding local` 的账户，两者都应失败。最后请求使用 `0.0.0.0` 监听非回环地址，并确认请求边界会在 SSH 运行之前拒绝它，或者确认本地环境确实阻止了你想要阻止的暴露。

使用很短的持续时间测试过期行为。启动获批隧道，等待监管器触发截止时间，并确认三件事：SSH 进程退出，`lsof` 不再找到监听器，关闭记录注明原因是截止时间，而不是假装任务正常结束。这个小测试可以在生产任务依赖一个永不关闭的隧道之前，发现孤立进程问题。

压力升高时，记住一条简单规则：自主智能体只能在当前任务所需的最小路径范围内获得访问权限，访问时间必须受限，并且要有明确的批准人。如果你的设置无法用一行说明这条路径，也无法在事后证明它已经关闭，那么智能体拥有的网络权限就超出了任务的合理需要。
