# 为更安全的 AI 代理工作流提供无状态 SSH 助手

自主编程代理不应把一个运行中的 SSH 会话带到下一次运行。每次操作都使用全新的本地执行上下文，可以减少早先运行向后续运行借出权限、身份或无法解释的副作用。它也为审查者提供了清晰的证据单元：这个进程针对这台主机请求了这条命令，而助手返回了这个结果。

这并不意味着 SSH 就变得安全无害。新连接不会撤销一次部署，不会停止后台进程，也不会保护一个权限过大的服务器账户。无状态执行解决的是一个范围更窄、却非常实际的问题：移除本地状态的延续，而代理尤其容易无意中利用这种延续。

## 无状态 SSH 助手移除的是本地延续，不是远程状态

无状态 SSH 助手为一次操作创建所需的连接，执行操作，返回结果，然后退出，不为后续调用保留已认证的传输通道。下一次操作会重新启动助手进程，并再次尝试建立新连接。

这个区别很重要，因为人们常说「无状态 SSH」，其实想表达的是「任何地方都不会持久化」。这不正确。SSH 有多个地方可以保存状态：

- 客户端可以保留主连接、多路复用套接字、known-hosts 条目、代理套接字、配置或临时文件。
- 远程主机可以保留工作目录、Shell 历史、上传的文件、锁文件、服务进程、软件包缓存以及被修改的数据库。
- 授权系统可以保留账户、SSH 证书、公钥和权限。

无状态助手针对的是第一类。它不会让后续代理调用悄悄继承早先调用的活动连接或本地凭据上下文。远程端仍是一台真实的计算机，也仍会产生真实后果。

因此，相比「全新的服务器」，「全新的连接」是更准确的运维说法。新的是助手，服务器并没有变新。

假设代理运行了一次部署检查，随后因为审查者拒绝了下一项变更而退出。如果第一次运行留下了多路复用套接字，同一台机器上的另一个进程就可能接入已经完成身份验证的通道。后一个进程绕过了操作人员以为每次运行都应经过的安全边界。使用无状态助手时，第二个进程必须重新请求操作，并再次经过预期的授权路径。

这种好处一部分来自安全性，另一部分来自诊断能力。当事故审查者看到一个存活了数小时的共享连接时，必须重建多个进程究竟使用了它。当每个请求都拥有自己的连接生命周期时，记录天然有明确的开始和结束。

## 连接共享与按运行归属相冲突

OpenSSH 有意支持连接共享。它的 `ssh_config` 手册将 `ControlMaster` 描述为允许多个会话共享一个网络连接。`ControlPersist` 可以在客户端退出后让主连接继续在后台保持打开。这些选项适合管理员在一个终端中连续执行多条命令，却不适合作为需要独立权限和独立记录的代理运行的默认设置。

看下面这个常见配置：

```sshconfig
Host build-box
  HostName 10.0.0.24
  User deploy
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 15m
```

第一次 SSH 调用会在 `~/.ssh` 下创建已认证的主套接字。之后能够访问该套接字的调用，可以在十五分钟的持久化窗口内复用这条连接。原进程退出后才启动的代理进程，可能在自己的记录中看起来像是建立了独立连接，实际上却借用了早先建立的传输通道。

这会带来四个不同的问题。

第一，批准的含义发生漂移。如果某人批准了某个代理进程运行检查，这项批准不应自动覆盖一个碰巧找到同一套接字的新进程。

第二，目标身份更难在正确的时间检查。SSH 主连接建立时会进行主机验证，后续客户端会继承这一结果。审查后续调用的人必须找到更早的连接事件，才能知道当时进行了怎样的主机验证。

第三，日志失去了请求与传输之间的简单对应关系。传统 SSH 服务器日志可能记录一次登录，而应用层记录却显示许多条命令。两者都可能准确，但在最需要清晰信息的时候，关联它们会变成额外工作。

第四，套接字本身成了资产。OpenSSH 在 `ssh_config` 中警告，任何能访问控制套接字的人都能访问该连接。文件权限确实有帮助，但无法让共享的已认证通道适用于一个可能遭到攻击，或只是容易混淆的工作区。

需要清晰归属的调用应禁用多路复用。直接调用可以明确表达这一决定：

```sh
ssh -o ControlMaster=no -o ControlPath=none deploy@build-box 'id; hostname'
```

预期输出应体现这条命令自身的结构，例如：

```text
uid=1004(deploy) gid=1004(deploy) groups=1004(deploy)
build-box
```

这条命令只能证明响应者使用了哪个账户以及哪台主机。它不能证明账户拥有合适的权限，也不能证明请求的任务是安全的。不过，它能让审查者把一次操作与一次连接尝试对应起来。

不要把没有 `ControlMaster` 行当成完整修复。全局 SSH 配置可以设置它，包装器可以添加它，进程也可以把 `ControlPath` 指向意料之外的位置。执行边界应自行设置 SSH 选项，而不是信任仓库中的点文件。

## 每次代理运行都需要自己的权限边界

代理运行不是一场由更快打字员完成的人类终端会话。它可能从问题、拉取请求、生成的测试输出、复制的日志文本以及并非由它创建的源文件中收到新指令。任何一种内容都可能把它引向不属于最初意图的命令。

这改变了便利功能的含义。人如果保留一个会话，通常知道自己保留了它。代理可能在完全不知道旧通道仍可用的情况下开始后续任务。它的工具界面往往只显示「运行命令」，底层客户端却可能悄悄找到套接字并获得先前的权限。

应把代理进程视为接受批准的单位。进程退出时，它的访问权限也应随之结束。如果启动了新进程，即使它由同一个编辑器或编排器启动，也应在它接触受保护主机前要求新的授权决定。

这种方式一开始比许多开发者希望的更严格。他们看到反复出现的批准请求，就想采用宽泛的允许列表。这样虽然消除了麻烦，也移除了原本有用的边界。更好的设计是把相关操作分组到一次明确的运行中，审查者查看其身份后授予这次运行访问权，并在运行结束时撤销访问。

代码签名身份在这里很重要，但它不能回答所有问题。它可以告诉审查者哪个经过签名的程序启动了进程，却不能说明程序是否正在执行可信指令，也不能说明当前仓库是否包含恶意提示。批准应把已知进程绑定到有限的权限期限，而不是为该进程今后读到的所有指令背书。

清晰的边界也能正确处理重试。网络故障可能触发新的请求。网关应记录第一次操作是在执行前还是传输过程中失败，然后把重试记录为另一项操作。把重试压缩成一条含糊的成功记录，会删除事故响应人员需要的信息。

## 代理转发会破坏凭据边界

SSH 代理转发让远程主机可以请求本地身份验证代理为挑战签名。它不会把私钥文件复制到远程主机，但这个区别听起来可能比实际情况更安全。

OpenSSH 的 `ssh` 手册警告，能够绕过远程主机文件权限的用户可以访问转发的代理。该主机上的 root 用户可能在连接保持打开期间使用转发的套接字向其他地方进行身份验证。他们仍然拿不到私钥材料，却可以使用私钥背后的权限。对自主代理而言，这通常正是你想避免的风险。

避免使用这样的调用：

```sh
ssh -A deploy@build-box 'git fetch && ./deploy.sh'
```

`-A` 标志会转发本地身份验证代理。部署脚本如果需要访问第二台机器，就可能让远程主机通过转发的套接字请求签名。遭到入侵的构建机器，或由不受信任的贡献者修改的脚本，就获得了进入另一个环境的意外路径。

应改用属于目标任务的凭据。这可能是带有限制性公钥的部署账户、为某个环境签发的短期 SSH 证书，或只能获取所需文件的远程服务账户。具体机制取决于你的基础设施，但原则不变：不要把一个代理获准执行的操作，变成从远程机器进行通用身份验证的权限。

有些团队因为嵌套 SSH 很方便而继续使用转发。他们先连接堡垒机，再跳转到私有主机。可以的话，使用 `ProxyJump` 或严格控制的网关路径。这些方式让客户端继续控制连接建立过程，而不是把强大的本地代理套接字放到中间主机上。

端口转发也应受到同样的警惕。本地、远程和动态转发都可能把一条获准命令变成一个在实际任务结束后仍继续存在的开放隧道。除非请求的任务确实需要转发功能，否则无状态助手应拒绝这些功能，或单独要求批准。

## 精确的远程账户可以限制错误指令造成的损害

本地状态是全新的，但如果远程登录可以做任何事，这并没有帮助。代理连接的账户应有明确的用途，权限也应与用途相匹配。

对部署主机来说，这可能意味着账户只能切换发布软链接、重启某个服务以及读取一个部署目录。它不应同时拥有无限制的免密码提权、访问所有用户主目录的权限，也不应能修改 CI 运行器。这些组合通常源于有人想在午饭前让自动化任务运行起来，之后却没有人回来收紧权限。

OpenSSH 在 `authorized_keys` 格式中为服务器操作人员提供了多种控制手段。手册记录了 `command=` 等选项，它可以在密钥完成身份验证时强制执行某条命令；还可以禁用端口转发、X11 转发和代理转发。当目标主机提供的命令接口小而稳定时，这些控制非常有用。

例如，运维团队可以把专用公钥绑定到受限制的远程包装器：

```text
command="/usr/local/libexec/release-action",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... agent-release
```

强制命令应以防御性方式解析请求参数。不要把原始用户文本直接传给 Shell。安全的包装器只接受少量动词，按照预期格式验证发布标识符，写入审计记录，然后执行对应的固定操作。如果代理确实需要任意 Shell 访问，就应明确承认这一点，并把账户权限限制在足以控制该风险的范围内。

强制命令不是通用策略语言，而是某台主机上的一个有意缩小的接口。它的优势正在于这种限制。你可以测试它、审查它，并明确知道它能执行哪些操作，而不必解释一堆自然语言规则。

使用 `sudo` 时要小心。即使某一行只允许执行脚本，如果脚本接受任意路径、从可写目录加载配置、调用编辑器，或通过用户可控的环境变量调用其他程序，也可能变成广泛访问。要阅读完整的调用链。权限条目只是审查的开始，不是结束。

## 操作记录应回答谁、做了什么、在哪里以及结果如何

有用的审计轨迹不能只说明发生过 SSH。它必须让人无需猜测这次操作属于哪个代理运行，就能重建整个过程。

记录代理运行标识符、进程权限、审查者决定、目标主机、远程账户、请求的命令或操作、时间、结果，以及输出的引用。如果设计允许，还应记录解析后的主机身份。拒绝的操作也要记录。一次被拒绝的操作说明边界按预期工作，也可能在造成损害前暴露错误指令。

命令需要特别处理。原始命令可能包含密码、访问令牌、查询值和私有路径。全部脱敏会让记录失去用途，而全部保留又可能把日志变成另一个秘密存储。实际做法是设计操作接口，让敏感值一开始就不会出现在参数中。必须脱敏时，同时记录发生了脱敏，并保留足够的结构化上下文来识别这项操作。

输出也存在同样的问题。部署命令的错误信息可能打印环境变量或包含凭据的 URL。应把输出与核心事件记录分开，限制可以读取它的人，并把它视为下一次代理运行可能接触到的敏感输入。不要因为代理要求诊断失败，就自动把完整的生产环境记录反馈给它。

还有一个区别必须保持清晰：记录代理请求了某项操作，不等于证明该操作已抵达远程主机。传输失败、主机密钥拒绝、远程身份验证失败、命令退出状态和连接丢失，都是不同的结果。优秀的记录会为每种结果设置独立状态，而不是把它们全部归为「失败」。

防篡改能力会改变记录的信任模型。哈希链日志可以在验证失败时显示条目被修改、删除或重新排序。它不能告诉你原始命令是否明智，也不能让遭到入侵的端点变得可信。它保护的是已有的历史，不是替代系统加固。

## 一次失败的部署说明了全新状态为何有帮助

假设编程代理收到请求，要把一个分支部署到预发布主机。第一次调用建立 SSH 连接，检查磁盘空间，并启动发布命令。发布命令因为存在迁移锁而失败。代理看到失败，读取仓库说明，然后收到一条复制来的消息，告诉它「清除锁，并使用紧急账户重试」。

这条消息可能是无意却不安全的建议，也可能是藏在代理被要求检查的文件中的提示注入。不管怎样，代理现在提出的命令已经超出了原始部署流程。

如果使用长期存在的共享连接，可能发生几件事。原始传输通道可能仍以权限宽泛的部署账户完成身份验证。新进程可能复用它。审查者只看到第一次连接批准，可能无法清楚看到后来的权限升级。如果设置还转发了 SSH 代理，预发布主机就可能获得使用另一个身份的途径。

使用无状态助手和按运行授权时，代理的下一次调用会作为新请求开始。边界识别新的代理进程，记录实际提出的命令和目标；如果这次运行尚未获得批准，就请求批准。审查者可以拒绝使用紧急账户的请求，转而批准只检查锁持有者的受限操作。

连接是否新建，并不能决定删除锁是否安全。操作仍然需要人来判断。它所防止的是旧的已批准通道悄悄让这个判断变得无关紧要。

因此，无状态 SSH 设计应与合理的命令限制并行，而不是取代它们。连接隔离限制继承来的本地权限。受限的远程接口限制新连接能够执行的操作。审计记录则让人们之后能够理解这两种决定。

## 无状态执行需要明确的运维设计

每次请求都建立连接的助手，需要对主机验证、超时、取消和失败报告制定清晰行为。把这些细节留给代理环境碰巧提供的设置，只会以另一种形式重新制造隐藏状态。

验证主机密钥。OpenSSH 的 `StrictHostKeyChecking=yes` 会在主机密钥未知或发生变化时拒绝主机，而不是进行交互式询问。对于受保护的自动化操作，这通常是正确的做法。通过受控流程配置可信主机密钥，如果目标不匹配就安全失败。

使用有界超时。停滞的 SSH 连接最终应返回一条记录过的失败，而不是无限期地作为半完成会话存在。操作系统允许时，助手还应在取消时终止子进程，并报告是否能够确认终止。传输在服务器已经启动命令后断开时，不要把远程命令报告为已取消。

保持请求结构小而易检查。操作边界可以使用目标、账户、命令、受支持时的工作目录和超时等结构化字段表示 SSH 请求。不应接受一整块 Shell 配置，让它在未经审查的情况下修改身份文件、代理行为、控制套接字路径和转发选项。

网关可以持有 SSH 凭据，让代理请求执行某项操作。Sallyport 通过内置的无状态 `sp-ssh` 助手为 SSH 遵循这一模式：代理不会接收 SSH 密钥，而是由应用执行操作。

边界还应区分会话批准和按次批准。会话批准适用于范围明确的代理运行，审查者可以接受一系列预期工作。按次批准适用于每次使用都值得单独人工决定的凭据或目标。不要假装这两种选择提供相同的控制力，它们是在打扰程度和细粒度之间做出有意的取舍。

## 在信任边界前测试隐藏的复用

你可以测试当前设置是否真的建立了相互独立的连接。请在非生产环境中进行，并使用没有敏感访问权限的账户。

首先启动两个独立的代理或助手进程，让它们各自运行类似 `id` 这样的无害命令。检查 SSH 服务器的身份验证日志和操作日志。你应看到两次连接尝试，并能把它们分别对应到两个独立的进程记录。

然后检查客户端是否存在多路复用套接字。在 macOS 和类 Linux 系统上，可以进行这样的广泛检查：

```sh
find ~/.ssh -type s -print
```

如果输出了对应控制套接字的路径，请确认是哪项配置创建了它。在共享机器上不要盲目删除套接字，先找出所有者和活动客户端。应从隔离助手的执行路径中移除连接共享设置，而不是依赖事后清理。

最后执行一次获准操作，结束代理进程，再启动第二个进程。第二个进程不应继承第一个进程的授权、身份验证传输通道，或无需新决定就调用远程 Shell 的能力。要像测试获准路径一样认真地测试拒绝路径。团队经常发现，正常流程是隔离的，但错误处理程序却会退回到直接 SSH 命令。

最后一项检查可以捕捉到常见故障：谨慎的网关处理正常请求，但重试脚本或诊断工具在压力升高时绕过网关。通常有人为了快速恢复服务而加入这个绕过路径，之后它就成了代理在第一次尝试失败时能够找到的通道。

每次 SSH 操作都使用全新状态，并不是形式主义。它为自主工作提供了一个可检查、可批准、可撤销、可调查的边界。保持远程账户权限狭窄，拒绝转发凭据，准确记录结果，并让每个新代理进程都为自己的访问权限重新取得批准。
