AI 代理的 SSH 访问:无需私钥执行远程命令
AI 代理使用 SSH 时,应通过受控的远程操作,而不是复制私钥。建立审批、服务器限制、主机检查和审计记录。

将 SSH 私钥交给 AI 编程代理,是一种错误的抽象。代理不需要一个可以从任何机器、在任何时间进行身份验证的长期凭据。它需要的是执行某个特定远程操作的权限,同时你仍然能够看见、停止并解释这个操作。
这听起来像是架构上的小改动,其实不是。它把一种可以永久传播的秘密,与一个包含调用方、目标、命令、结果和负责人的 SSH 请求区分开来。
我见过团队花几天时间收紧代理的提示词规则,却让同一个进程可以读取 ~/.ssh/id_ed25519。私钥一旦可用,提示词规则就只是装饰。shell 命令可以复制文件、打印文件、归档文件,或把它发送到某个你几周后才会发现的地方。
私钥不是名字更短的 API 令牌。对于所有接受其公钥部分的 SSH 服务器来说,它都是一种可重复使用的权限令牌。
给代理一个操作,而不是一个身份
代理应该请求执行 SSH 操作,由独立的可信组件持有身份并建立连接。这个组件接收结构化输入,例如目标、远程账户、命令、参数和超时时间。它检查请求,向服务器完成身份验证,捕获结果,然后只把结果返回给代理。
秘密留在一台机器上。更重要的是,使用秘密的权利不再跟着代理进程到处走。
这个区别会改变故障模式。如果代理遭到入侵,或执行了来自仓库的恶意指令,它仍然可能请求一个危险命令。但它无法悄悄导出私钥,然后下个月从一台临时虚拟机上重新使用。你把一个开放式的凭据窃取问题,缩小成了授权和命令控制问题。
第二个问题仍然需要认真对待,只是它变成了一个可以运营和管理的问题。
一个有用的请求格式应该刻意保持枯燥:
{
"host": "deploy-01.internal.example",
"user": "release",
"command": "/usr/local/libexec/release-service",
"args": ["api", "2025.03.08-4f2c1a7"],
"timeout_seconds": 120
}
不要把类似 ssh deploy-01 'cd /srv/api && git pull && sudo systemctl restart api' 的 shell 字符串经过五层传递后,就把它称为控制措施。shell 的解析规则会成为安全边界的一部分,但几乎没有人会以这种方式审查它们。固定命令路径,将参数作为独立值传递,并让远程包装器拒绝超出预期语法的值。
相比无人能限制的灵活远程终端,我更愿意使用一个稍微难用一些的操作契约。麻烦会在配置阶段出现,而不受限制的终端带来的损害,往往要到之后才出现。
代理必须收到结构足够清晰的结果,这样它才能继续工作,而不必猜测:
{
"exit_code": 0,
"stdout": "released api version 2025.03.08-4f2c1a7\n",
"stderr": "",
"duration_ms": 1842
}
不要返回凭据、代理套接字、known_hosts 内容或交互式 TTY。这些都是可信一侧的实现细节。
持有、签名和执行是不同的权限
团队经常把「SSH 访问」当成同一件事。实际上,至少有三种实质不同的权限:持有私钥、请求代理创建签名,以及让代理执行一个指定的远程命令。
持有私钥的权限最宽。任何读取文件的人都可以无限复制它,并尝试连接所有可访问的服务器。文件权限、磁盘加密和密码短语都有帮助,但它们都没有回答核心问题:AI 进程为什么一开始就需要一个可携带的凭据?
ssh-agent 不要求每个客户端都读取私钥文件。进程可以通过 SSH_AUTH_SOCK 请求签名。这改善了本地工作站的凭据管理,但签名仍然赋予了身份验证权限。OpenSSH 的 ssh-agent(1) 手册说明,代理转发期间,代理会让私钥远离网络传输,而转发的请求方仍能收到身份操作的结果。这是一个有用的特性,却不是完整的授权模型。
如果操作边界设计得足够窄,执行操作的权限会更窄。代理可以拒绝未知主机,禁止使用其他远程账户,要求人工决定,限制执行时间,拒绝分配 PTY,并记录调用。它还可以为每一类操作使用不同的 SSH 身份。
最后这一点的重要性常常被低估。一个身份如果既能读取日志、部署代码、编辑 /etc/sudoers,又能建立隧道,那么它的授权边界就和整个服务器集群一样大。四个受到严格限制的身份,则会形成四个更小的问题。
出了问题后,我宁愿轮换四个权限狭窄的身份,也不愿花整个周末证明一个管理员身份没有访问所有主机。
这种模式不会让有害命令变得无害。它会在多个位置提供拦截机会:连接前、服务器身份验证时、强制远程命令内部,以及事后的记录中。
SSH 代理转发解决的是另一个问题
SSH 代理转发可以避免把私钥复制到跳板机,但不会让交给代理的远程机器变得安全。远程主机会获得一个套接字,可以通过它请求本地身份验证代理执行操作。
OpenSSH 在 ssh_config(5) 中明确说明,应该谨慎启用代理转发,因为能够绕过远程主机权限的用户,可以通过转发连接使用本地代理。攻击者无法通过这个接口提取私钥字节,但可以要求已加载的身份进行身份验证。
对于自主工具来说,这个问题更加突出。代理可能因为任务要求检查日志而连接某台主机。恶意仓库指令、遭入侵的远程账户或隔离不当的命令,随后可能找到 SSH_AUTH_SOCK,并使用转发的签名能力。私钥在技术上仍然是秘密,但身份验证权限已经被滥用。受害者不会因为这两者存在区别而感到多少安慰。
不要全局启用 ForwardAgent yes。在基础客户端配置中设置 ForwardAgent no,只有对确实需要它的、明确命名的人工工作流,才添加显式例外。
Host *
ForwardAgent no
AddKeysToAgent no
IdentitiesOnly yes
StrictHostKeyChecking yes
Host legacy-bastion
HostName bastion.internal.example
User ops
ForwardAgent yes
上面的例外仍然需要有理由、负责人和移除日期。永久性的转发例外往往因为 SSH 运行得太顺滑而变得不可见。
受目标限制的身份可以改善这种情况,但不能代替操作边界。OpenSSH 的 ssh-add(1) 手册说明,在客户端和服务器都支持的情况下,目标限制会检查代理转发的完整连接路径。手册也警告说,拥有远程 SSH_AUTH_SOCK 的人可以再次转发该套接字,不过使用范围仍会受到允许目标的限制。
在适合的地方使用目标限制,但不要把它当成命令策略。它说明身份可以在哪里进行身份验证,却没有说明连接到那里后是否应该运行 rm -rf /srv/release-cache。
沿着命令路径追踪故障
一个合理的事故可能从一个善意的捷径开始:开发者把部署私钥放进环境变量,因为编程代理需要执行一次发布命令。代理读到一个包含诊断命令的仓库 issue。这个命令为了排查问题打印环境变量,而执行记录随后落进本地会话日志。
第一次损失发生在 SSH 之前。私钥进入了一个本来不需要持有它的进程。
代理现在有很多泄露或重复使用它的方式。它可以将值写进临时文件,可以用 scp 发送到远程主机,可以放入 Git 提交,也可以交给超出你设想边界的子进程。私钥一旦被复制,不会因为你停止了那次代理运行就自动过期。
假设团队改用了代理转发。仓库指令让代理连接 build-02,然后执行一个命令,把转发的套接字暴露给那台远程机器上的本地进程。攻击者无法打印私钥,但可以要求套接字使用开发者代理中加载的身份向另一台主机进行身份验证。这个转发原本从未打算作为通用委托机制,却变成了这种机制。
再看经代理执行的设计。代理向 deploy-01 请求执行 release-service api 2025.03.08-4f2c1a7。代理发现这是一个新代理进程的首次请求,于是要求授权。审批者可以看到调用进程的代码签名权限、远程身份和目标。代理只连接指定主机。服务器只接受受限的部署身份,并调用一个服务器端包装器。
恶意指令仍然可以请求发布,但无法把这个请求变成登录 shell、端口转发、私钥复制,或通往无关服务器的跳板。
这会大幅缩小影响范围,但代价是灵活性降低。通用代理可以针对异常的生产状态临时变通,而受限操作接口做不到。你需要为日志、服务状态、回滚、迁移检查和构件清理添加明确操作。还必须有人负责维护这些接口。这项工作无法省略,无限制的 SSH 只是把它藏了起来。
正确的做法,是让安全路径覆盖人们实际要做的工作,而不是每当安全路径缺少一个功能,就重新打开原始 shell。
将 SSH 客户端放在本地决策点之后
本地决策点应负责三件事:加密的私有身份、使用该身份的权限,以及使用它时产生的证据。代理不应负责其中任何一项。
Sallyport 在 macOS 代理上采用了这种模式:内置的 sp mcp shim 接收普通 MCP 调用,菜单栏应用将 SSH 身份保存在加密保险库中,并使用 sp-ssh 建立连接。代理收到的是命令结果,而不是凭据。
具体实现可以不同,但结构更重要:
AI agent process
-> local action request
-> authorization decision
-> SSH helper using protected identity
-> remote sshd and restricted account
-> stdout, stderr, exit status
当需要人工监督工作时,将决策点保留在开发者管理的机器上。不要把它变成一个会为所有进程悄悄签署请求的网络代理。本地进程边界可以识别调用方,并在任何网络连接开始前拒绝意外的可执行文件。
进程身份不是审批卡片中的装饰字段。来自预期签名应用的请求,与来自 /tmp 中未签名二进制文件的请求不同,即便两者都声称自己是「编程代理」。在 macOS 上,代码签名权限能为审批者提供具体的判断依据。
我希望新进程先建立会话,才能使用 SSH 身份;也希望进程退出时会话随之消失。长期授权容易操作,却很难在入侵后解释清楚。
保险库锁定后,所有 SSH 操作都应被拒绝。不应存在任何部分回退路径,例如缓存的私钥、导出的 PEM 文件,或带有隐藏副本的辅助进程。只要紧急路径能绕过保护,它迟早就会变成普通路径。
刻意让远程账户保持乏味
远程服务器必须限制成功 SSH 身份验证后可以执行的操作。受保护的私钥只是控制措施的一半,服务器还必须将该身份视为专用身份,而不是人类管理员账户。
为操作创建独立的 Unix 账户,例如 release、diagnostics 或 backup。不要给它个人 shell 配置、宽泛的主目录或密码登录。不要把它的公钥身份和工程师不受限制的个人身份放在同一个 authorized_keys 文件中,然后就声称账户彼此独立。
对于发布操作,authorized_keys 条目可以这样写:
restrict,command="/usr/local/libexec/release-service" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleOnlyReplaceThis release-broker
restrict 选项会禁用端口、代理和 X11 转发、PTY 分配,以及执行 ~/.ssh/rc。OpenSSH 的 sshd(8) 手册专门记录了这一用途,并展示了它与强制命令的组合方式。
强制命令不能把原始命令行交给 sh -c。除非你有经过测试的窄范围解析器,否则应该忽略 SSH_ORIGINAL_COMMAND。包装器应接受固定协议,验证每个字段,写入审计记录,并使用明确的参数边界调用真实操作。
只要保持足够小型,shell 包装器也可以接受:
#!/bin/sh
set -eu
service=${1:-}
version=${2:-}
case "$service" in
api|worker) ;;
*) echo "unsupported service" >&2; exit 64 ;;
esac
case "$version" in
*[!0-9A-Za-z._-]*|"") echo "invalid version" >&2; exit 64 ;;
esac
exec /usr/bin/sudo /usr/local/sbin/deploy-approved-release "$service" "$version"
由代理使用固定参数调用这个包装器。不要让客户端选择路径。如果需要更多操作,就创建更多包装器,或创建一个只接受命名子命令并拒绝其他内容的小型命令分发器。
这里存在一条明确的边界:不要让自主代理连接到共享的 ops 账户和普通 shell,然后指望用更好的提示词来弥补。共享 shell 账户会让所有服务器端防护措施都变成自愿措施。
对于有状态的工作,使用幂等操作。发布包装器在修改任何内容前,应检查请求的构件是否已经处于活动状态、服务是否健康,以及是否存在回滚点。代理可能在超时后重复请求,远程操作不应把重复请求理解成再次执行操作的许可。
主机验证和命令语法需要同等重视
私有身份可以保护客户端身份验证,却不能证明代理连接的是目标服务器。如果自动化接受任何新的主机指纹,DNS 错误、被投毒的 hosts 文件或主动网络攻击者,都可能把连接重定向到一台能够接收命令并读取输出的服务器。
对自动化身份使用 StrictHostKeyChecking yes。通过配置管理发布经过审核的主机指纹,或维护一个严格控制的 known_hosts 文件。当主机轮换 SSH 主机密钥时,执行明确的变更,并通过带外方式验证。不要教代理在主机密钥提示出现时回答「yes」。
非交互式操作的最小客户端配置通常类似这样:
Host deploy-01.internal.example
HostName deploy-01.internal.example
User release
IdentityAgent /path/to/broker.sock
IdentitiesOnly yes
StrictHostKeyChecking yes
UserKnownHostsFile /Library/Application Support/agent-ssh/known_hosts
BatchMode yes
RequestTTY no
ForwardAgent no
IdentitiesOnly yes 可以阻止 SSH 将代理中可用的每个身份都发送给目标。这会减少嘈杂的身份验证尝试,也能避免预期的操作身份失败后意外使用个人身份。OpenSSH 的 ssh_config(5) 手册说明,这个选项可以只使用配置的身份文件,而不使用代理或身份提供方额外提供的身份。
如果可以避免,就不要让主机名由代理选择。请求应引用一个已批准的逻辑目标,例如 production-api-release,然后由可信一侧将其映射到主机名、远程账户、主机指纹、身份和命令类别。这个映射本质上是一种策略,但它应当是人可以检查的数据,而不是一种鼓励钻空子的聪明语言。
命令语法同样需要克制。即使二进制文件固定,允许任意参数也可能变成任意执行,因为该二进制文件可能接受文件路径、插件名、配置导入或 --exec 标志。检查实际的 CLI,写下允许的令牌,并测试格式错误的输入、空白字符、shell 元字符、意外 Unicode 和重复标志。
花二十分钟用难看的参数攻击自己的包装器,比再增加一个审批提示更有价值。
在权限真正产生作用的时刻进行审批
人工审批应放在进程首次获得 SSH 能力的边界上,而不是每个无害命令之后,也不是破坏性命令已经到达服务器之后。
对于代理运行,按会话审批是一个实用的默认值。新进程首次尝试 SSH 操作时触发审批。审批内容应说明哪个签名进程发起请求、它想使用哪个身份,以及将获得哪一类远程访问权限。获得批准后,这个确切的进程可以在退出前发出允许的请求。
这样既不会把普通诊断变成一堆点击,也不会退回到更糟糕的做法:一次审批适用于机器上未来所有名为 node、python 或 claude 的进程。
有些身份值得在每次使用时增加第二道关卡。生产部署、紧急重启、数据库导出、基础设施变更,以及可能改变网络可达性的命令,都是合理候选。代价是会被打断,收益是被入侵的会话无法在你批准低风险状态检查后,悄悄使用最危险的身份。
Sallyport 可以对单个 SSH 身份设置按次审批,也可以通过普通会话授权,将权限关联到某个特定代理进程。保险库锁定后,Sallyport 也会拒绝操作,而不是允许后台代理在用户离开后继续运行。
审批提示不应要求用户阅读一大段序列化 JSON。先显示简洁的操作摘要,再提供详细信息:目标、远程账户、固定命令名称、提供的参数和超时时间。人们能很快分辨 production-db 和 staging-db,却无法在看到第十次提示后,可靠地从完整 shell 管道中识别危险。
拒绝也应是正式的输出结果。代理需要收到明确的拒绝,才能报告自己没有权限,而不是换一个账户、主机或绕行方案继续重试。拒绝后的重复尝试也应留下可见记录。
审计记录必须在调用方被入侵后仍然存在
代理生成的日志不够。如果代理进程能够写入或删除自己的 SSH 调用记录,它就可以只留下看起来无害的历史。
在代理进程之外记录活动。至少保留调用进程身份、会话标识符、时间、目标、远程账户、操作名称、经过脱敏规则处理的参数、授权结果、退出代码、持续时间,以及有界的 stdout 和 stderr 捕获。对于嘈杂命令,可以记录前后各 50 行;如果输出可能包含秘密,则存储内容哈希,并将完整构件放在受保护的位置。
不要记录原始私钥、承载令牌或任意环境变量。会造成第二次凭据泄露的审计系统,比没有审计系统更糟,因为人们会依赖它。
防篡改证据会改变调查方式。链式日志可以显示有人在事后删除或重新排序记录,而普通的追加文本文件通常只能证明某个文件存在。验证应该在保险库无需解锁的情况下进行,否则系统正处于最需要证据的状态时,你反而无法检查记录。
CISA 的红队评估指南给出了一个具体理由,说明为什么要重视凭据隔离。其公开的评估报告描述了一个团队从可访问文件中获取数十个 SSH 私钥,并利用高权限身份在 Linux 系统之间横向移动。这里的教训不是每个代理都会变成入侵者,而是可重复使用的 SSH 材料一旦进入不需要它的位置,就会积累成横向移动清单。
出现异常命令时,先撤销代理会话。然后在服务器上禁用受影响的身份,检查经过验证的活动记录,并查看目标主机的 sshd 和系统日志。如果私钥有任何可能离开受保护边界,就轮换该身份。采用代理模式后,这个流程会更快,因为撤销会话可以停止未来的操作请求,不必等到找到复制出去的私钥。
用四条狭窄路径代替一个通用 shell
实际落地时,先盘点代理已经执行的远程操作,再按后果进行分类。不要先问它们需要哪些私钥,这个问题从错误的一端开始。
大多数团队会发现一小组反复出现的操作:
- 读取受限的服务状态或最近的日志片段。
- 将已经构建好的构件部署到某个环境。
- 执行不会修改数据的迁移检查。
- 发布后重启指定服务。
- 收集失败任务的诊断信息。
为每一项建立一条操作路径。服务器不同,就分配专用的远程账户或身份。由服务器端强制执行命令。固定主机身份。决定操作需要会话审批还是每次审批。允许和拒绝的调用都要记录。
为特殊修复工作保留人工 SSH 路径。事故期间,人类有时确实需要交互式 shell,假装不需要只会把他们推向不安全的逃生路径。这条路径应使用独立的个人身份,尽可能启用 MFA,并在合适时使用跳板机和正常的事故控制措施。不要因为在 authorized_keys 中少写一行,就让它与自动化身份共用。
更安全的模式确实需要更多前期工程:包装器需要测试,主机指纹需要负责人,操作目录也需要维护。承担这项成本。另一种选择,是给一个概率性文本生成器一个能够超出当前任务所有防护措施生命周期的凭据。
从离生产环境最近的 SSH 私钥开始。让代理无法接触它,用一个受限操作替代一个宽泛的 shell 命令,然后在真实运行后检查记录。第一个边界会准确显示剩余工作藏在哪里。
常见问题
AI 代理可以在不接触私钥的情况下使用 SSH 吗?
可以,只要由独立的本地代理保存身份并自行完成 SSH 握手。代理提交主机、账户、命令和参数,由代理决定是否批准请求,再返回 stdout、stderr 和退出代码。这个区别很重要,因为复制出的私钥可以在任何地方重复使用,而经过中介的操作可以被停止、限制并记录。
SSH 代理转发对 AI 代理安全吗?
SSH 代理转发可以让私钥留在远程主机之外,但会通过转发的 Unix 套接字暴露签名能力。能够访问这个套接字的进程,可能使用本地 SSH 代理中已加载的身份进行身份验证。OpenSSH 警告说,如果用户能够绕过远程主机上的权限,就可以利用转发的代理执行身份验证操作。
AI 代理应该拥有自己的 SSH 身份吗?
为每个自动化边界使用独立的 SSH 身份。生产部署、只读诊断、备份和仓库维护不应共用一个权限宽泛的账户。每个身份只应拥有所需的服务器访问权和强制命令。通用管理员身份虽然方便,却会让代理的每个错误都变得昂贵得多。
怎样将 SSH 身份限制为只能执行一个命令?
当任务形态比较固定时,强制命令很有效,例如部署一个服务、收集健康报告或轮换受控的构件。将 restrict,command="..." 放在 authorized_keys 中公钥身份的前面,然后在服务器端包装器中校验参数。不要依赖将未加引号的 SSH_ORIGINAL_COMMAND 传给 shell 的强制命令。
AI 代理应该使用 root 通过 SSH 登录吗?
通常不应该。应在主机上创建独立的服务账户,禁止直接 root 登录,只授予包装器所需的 sudo 子命令。如果任务确实需要 root 权限,就把这个权限放进一个小型的服务器端程序中,让它清晰可见,而不是给代理一个交互式 root shell。
什么时候应要求人工审批 SSH 操作?
当一个已知的代理进程需要在一次受限运行期间执行多次低风险调用时,可以按会话审批。对于能够部署、修改防火墙规则、访问生产数据库或执行破坏性维护命令的身份,适合按次审批。审批提示只有在说明发起请求的签名进程,以及它将使用的账户和主机时,才真正有用。
代理应如何验证 SSH 主机身份?
在 known_hosts 中使用固定或经过审核的主机指纹,设置 StrictHostKeyChecking yes,让未知主机直接失败,而不是自动添加。不要为了完成任务就让代理接受变化后的主机指纹。主机身份发生变化,在有人证明原因之前,都应视为安全事件。
AI 驱动的 SSH 命令应该记录什么?
有用的记录包括请求进程身份、会话标识符、目标主机、远程账户、完整命令和参数、审批决定、开始和结束时间、退出状态,以及输出捕获策略。拒绝记录也要保留。没有调用方身份的成功命令,在调查时只能提供很有限的证据。
如果 AI 代理拿到了 SSH 私钥,我应该怎么办?
轮换受影响的身份,从所有授权位置移除对应的公钥,终止活动会话,并检查命令历史和目标主机日志。如果私钥曾进入模型提示词、仓库、聊天记录或构建构件,就要将每条可能的复制路径都视为已泄露。然后重新设计流程,让代理请求操作,而不是接收可重复使用的凭据。
本地 Mac SSH 网关适合 CI 或无头自动化吗?
当开发者在受管控的 Mac 上运行编程代理,并且敏感操作需要有人在场时,仅支持 Mac 的本地代理很合适。对于无人值守的 CI runner、Linux 构建主机或无法使用本地桌面审批边界的服务器端任务,它就不合适。这些场景需要不同的执行边界,例如短期工作负载身份和受控 runner。