命令的远程投送:SSH 隧道与受控执行器后端
(第 81 篇:Agent 动力学之安全防线)
当你的 Agent 逻辑运行在本地或中心控制台,但它需要操控的是云端的 Linux 服务器、生产环境数据库或遍布全球的边缘节点时,远程执行 (Remote Execution) 就成了避不开的技术关卡。
这篇文章不讲“SSH 是什么”“怎么连上去”这种科普。 我们只讲一个更硬核也更现实的问题:
- Agent 的“大脑”在 A 处(本地/控制台/服务端推理集群)。
- 真正需要动手的“战场”在 B 处(生产机/跳板机/边缘节点/隔离沙箱)。
- 你希望 Agent 能做事,但你不希望它拥有永久后门,也不希望它能在失控时做任何超出任务范围的事情。
结论先给出来:
- SSH 只是一条加密传输通道,不是授权系统,更不是“安全执行器”。
- 你要的是“受控执行 (controlled execution)”,不是“远程登录”。
- 工业级做法一定会引入中继层、短期凭证、执行白名单、以及可审计证据链。
下面从架构、协议与强制点三个层面把这条链路拆开。
1. 威胁模型:你到底在防什么
把威胁模型说清楚,后面的每个工程选项才有“能否反制”的判断标准。
你要防的通常不是“某个命令写错了”,而是这些情况:
- 生成侧被 prompt injection:模型输出被人诱导成危险动作。
- 执行侧被二次利用:Agent 拿到可交互 shell 后,被引导去下载并运行 payload。
- 凭证泄漏:永久私钥外溢等于长期后门。
- 横向移动:拿到一台机器后继续渗透更多资产。
- 不可追溯:事后无法证明“它做了什么”,也无法复盘“它为什么这么做”。
我们会用这些“硬约束”去顶住风险:
- 凭证必须短期化(分钟级),并且能被撤销/自然过期。
- 授权必须绑定任务上下文(task_id、目标资源、动作类型、时效)。
- 执行必须在强制点落地(PEP 必须在执行器侧,而不是大脑侧写几条 if)。
- 全链路必须可审计(谁、何时、对谁、做了什么、结果如何)。
2. 架构核心:代理中继 (Proxy Relay) 模式
Agent 永远不应该直接接触到目标服务器的长期 SSH 私钥,也不应该直连生产资产。 你必须在“大脑”与“战场”之间建立一个执行中继层。
一个可落地的三段式模型:
- Brain (本地/云端):只负责生成“意图”(intent) 与任务计划。
- Relay (中继网关):持有与目标主机的 SSH 连线,负责二次安全审计与授权下发。
- Target (工作节点):只运行一个极小执行器,不运行大脑,不解释自然语言。
这种模式的关键收益:
- Brain 进程即使被劫持,攻击者也只能通过 Relay 走“白名单动作”,无法拿到通用 shell。
- 凭证与权限可收敛到 Relay,Brain 不持有“能直接入侵生产”的钥匙。
- 审计与证据链集中在 Relay/Target,而不是散落在各个开发机。
把 Relay 视为远程执行的防腐层:
- Brain 负责生成“要做什么”。
- Relay 负责把意图翻译成“可被执行器接受、可被审计、可被撤销”的动作。
- Target 只接受结构化动作,不接受自由文本命令。
3. 协议层:SSH 真正提供了什么,不提供什么
SSH 的协议架构把能力拆成了传输层加密、用户认证、连接与信道 (channel) 三类。 它定义的是“安全通道”的分层架构,而不是“授权与审计系统”。 citeturn0search1
对 Agent 而言,SSH 最有价值的通常不是“交互式 shell”,而是这些能力:
- exec channel:一次性执行一个远程动作并返回 stdout/stderr。
- port forwarding:把目标服务入口收敛到少量受控端点。
- subsystem (SFTP):做文件投送与拉取;注意 SFTP 不是“FTP over SSH”,它是独立的协议。 citeturn0search14
- multiplexing:复用握手后的连接,减少握手与认证开销(工程层优化,不是安全层)。
但 SSH 不会替你做这些事:
- 不会替你定义“这次任务允许的动作集合”。
- 不会替你决定“动作的幂等边界”。
- 不会替你对输出做数据分级/脱敏。
- 不会替你保证“模型不说谎”。
所以真正的关键是:把授权与强制点落在 sshd 与受控执行器上,而不是落在大脑里。
4. 凭证生命周期:JIT (Just-In-Time) 临时证书
给 Agent 一个永久有效的 SSH Key 是运维噩梦。 你会失去两种能力:
- 你无法确定“它今天是不是还应该能连”。
- 你无法把授权绑定到“这一次任务”。
工业级做法是使用 OpenSSH 的证书体系(不是 X.509):
- CA 只存在于安全域内(例如 Vault/Teleport/自建 CA 服务)。
- 连接前由 CA 签发一个短期的 user certificate(分钟级 TTL)。
- sshd 侧通过
TrustedUserCAKeys信任 CA 公钥,并通过 principals 决定“谁能以哪个用户登录”。 citeturn0search8turn0search7
一个最小签发示意(仅用于理解):
ssh-keygen -s /path/to/ca_key -I "task-20260421-0001" -n "agent-runner" -V "+10m" id_ed25519.pub
这类用法的核心点是:服务端通过 TrustedUserCAKeys 建立信任锚,客户端拿到的是短期证书而不是长期私钥。 citeturn0search3turn0search8
你获得的直接收益:
- 证书自然过期:任务结束后,它不再能登录。
- 证书可携带元数据:key id、principals、valid-after/valid-before。
- 授权可任务化:每个 task_id 对应一个短期证书,天然可追溯、可撤销。
5. 服务端强制点:用 sshd 把“能做什么”锁死
到这里必须明确一个观念:
远程执行的安全边界不在客户端,而在服务端。 因为客户端是 Agent 进程所在的地方,它可能被 prompt injection 或依赖投毒影响。
你需要把限制写进 sshd 的强制点里(sshd_config 与 authorized_keys)。
5.1 sshd_config:全局与 Match 规则
OpenSSH sshd_config 提供大量“限制能力”的开关,例如:
ForceCommandPermitOpenPermitListenPermitTTYLogLevelTrustedUserCAKeys
这些选项在 sshd_config(5) 里有明确语义。 citeturn0search8
典型做法是对专用用户 agent-runner 用 Match User 单独收紧:
# /etc/ssh/sshd_config (片段示意)
TrustedUserCAKeys /etc/ssh/trusted_user_ca.pub
Match User agent-runner
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
GatewayPorts no
LogLevel VERBOSE
# 关键:把“能执行什么”收敛到一个固定执行器
ForceCommand /usr/local/bin/agent-exec
# 关键:把转发能力限制在明确的目的地范围内
AllowTcpForwarding yes
PermitOpen 127.0.0.1:5432
PermitOpen 127.0.0.1:6379
这段配置背后的意图:
ForceCommand把登录后的可执行入口钉死,禁止拿到通用 shell。 citeturn0search8PermitTTY no砍掉交互式 TTY,减少“人机交互壳”被二阶段利用的空间。PermitOpen收敛转发能力,避免把 SSH 变成内网扫描器/横向移动工具。 citeturn0search8LogLevel VERBOSE提高登录审计粒度,至少你要能关联到 key/cert。 citeturn0search8
5.2 authorized_keys:每把钥匙的“微权限”
除了 sshd_config 的全局/Match 策略,你还能在 authorized_keys 给每个 key 附加限制。
例如 command="..."、permitopen="host:port" 等(不同 OpenSSH 版本支持项略有差异)。
相关语义在 OpenSSH 的 man page 生态里可查到。 citeturn0search24
在 Agent 体系里,建议:
- “真正的权限控制”放在服务端
ForceCommand的执行器上。 authorized_keys只做最后一道兜底。
原因很简单:你需要“以任务为中心”的授权,而不是“以 key 为中心”的授权。
6. 受控执行器:把“字符串命令”升级为“结构化动作”
前面的策略会把所有登录都重定向到 agent-exec。
现在的问题变成:agent-exec 应该怎么设计,才能真正做到“受控”?
关键原则:
- 执行器不要解释自然语言。
- 执行器不要接受任意 shell 字符串。
- 执行器要做 allowlist,并且用
execve这类“参数化执行”走系统调用,不要sh -c ...。
原因很直白:
只要你让模型输出拼进 shell 字符串,就永远存在注入空间。
而 execve / subprocess.run([...], shell=False) 这类参数化执行可以从机制上规避一大类注入问题。 citeturn0search9
6.1 Relay 到 Target:一个可审计的请求协议
Relay 与 Target 之间传递的不要是 cmd: "rm -rf /",而应该是结构化动作:
{
"task_id": "task-20260421-0001",
"action": "tail_log",
"args": {
"path": "/var/log/nginx/error.log",
"lines": 200
}
}
Target 的 agent-exec 只支持少量 action:
tail_logread_filerun_migrationrestart_servicecheck_health
并且每个 action 都有:
- 严格的参数校验(拒绝未知字段)。
- 路径白名单(例如只允许
/var/log/下的只读)。 - 超时与资源限制(防止卡死与输出爆炸)。
- 审计事件写入(task_id 必须贯穿)。
6.2 agent-exec 的最小实现形态
实现语言不重要(Go/Rust/C 均可),重要的是它的系统调用路径:
- 读取 stdin(来自 SSH 的 ForceCommand)。
- JSON 解码 + schema 校验。
- 动作到命令的映射(allowlist)。
- 以
execve/posix_spawn启动目标程序(无 shell)。 - 捕获 stdout/stderr,输出结构化结果(JSON)。
- 写审计日志(task_id、action、argv、exit_code、耗时、目标资源)。
下面给一个 Relay 侧传输示意,用 AsyncSSH 只做“传输通道”,不承载安全边界:
import asyncio
import asyncssh
import json
async def send_action(host: str, username: str, client_keys: list[str], payload: dict) -> dict:
async with asyncssh.connect(host, username=username, client_keys=client_keys) as conn:
# ForceCommand 会接管实际执行入口,这里只需要把 payload 发给执行器即可
proc = await conn.create_process("agent-exec")
proc.stdin.write(json.dumps(payload))
proc.stdin.write("\n")
proc.stdin.write_eof()
stdout = await proc.stdout.read()
stderr = await proc.stderr.read()
if stderr.strip():
raise RuntimeError(stderr)
return json.loads(stdout)
为什么不在客户端层面追求“把 argv 直接传进去就安全”?
因为很多 SSH 服务端在执行远程命令时,最终仍会走 shell 的 -c 语义,
这会把“参数化执行”又打回“字符串命令”。
真正可靠的边界是:
- 服务端
ForceCommand固定为执行器。 - 执行器自己用
execve做参数化执行。
7. 隧道与转发:把攻击面从全网收敛成几根管道
SSH 隧道经常被误用成“万能内网通道”。 正确用法是:把目标资产的入口收敛到少量、可审计、可撤销的管道里。
工程上常见两种模式:
- 本地转发:把远端服务映射到 Relay 本机(
-L local:remote)。 - 动态转发:把 Relay 变成 SOCKS 代理(
-D local)。
对 Agent 来说,动态转发通常是危险的,因为它太通用了。
你更希望使用 PermitOpen 把能连的 remote host:port 锁死。 citeturn0search8
同时要避免“Brain 直连生产”,应该通过 Relay 做多跳收敛(ProxyJump 思路):
ssh -J relay.example.com agent-runner@target.internal
8. 审计与取证:你要能回答“它到底做了什么”
远程执行体系做得再严,如果不可审计,也只是“自信”,不是“安全”。
建议把审计链路分三层:
- SSH 层:记录谁连进来、用什么 key/cert、什么时候、从哪来(
LogLevel VERBOSE能提高登录日志粒度)。 citeturn0search8 - 执行器层:
agent-exec记录 task_id、action、argv、exit_code、耗时、输出摘要(避免泄露敏感数据)。 - 系统层:对关键目录/进程做系统审计(例如 auditd、journald、容器运行时事件)。
不要迷信“TTY 录屏”就够了:
- 如果你禁用了 TTY(推荐),TTY 录屏本身就不存在。
- 你需要的是结构化审计事件,而不是一段难以机器检索的字符流。
9. 性能与稳定性:让安全链路不拖垮系统
Agent 经常会“高频、小步”地远程检查状态。 如果每次都重新握手与认证,会非常慢。
这时可以使用 SSH 的连接复用(multiplexing)降低握手开销,但注意:
- 复用的是“已认证连接”,会放大单连接被劫持时的影响面。
- 所以只在 Relay 内部使用,不要让 Brain 直接持有可复用主连接。
证书体系也会引入时间敏感性:
- 机器时间漂移会导致“证书还没生效/已经过期”的诡异失败。
- 解决方案是 NTP + 明确的错误分类与重试策略(不能无脑重试)。
10. 失败模式清单:这些坑踩一次就够了
- 把“能执行什么”放在 Brain 侧:Brain 一旦被注入,所有约束都失效。
- 允许交互 shell:shell 变成二阶段载荷的落点。
- 使用永久私钥:一旦泄漏就是长期后门。
- 不做 known_hosts 校验:MITM 直接把你带去假主机。
- 放开动态转发:把 SSH 变成“内网任意访问工具”。
- 没有任务级审计:你无法回答“谁发起的”“为何发起的”。
11. 终极隔离:Disposable Backends (用完即焚后端)
对于高风险任务(例如让 Agent 去分析和处置一段未知来源的脚本),最极致的做法仍然是:
随用随建,用完即焚。
生命周期管理建议做到“物理可验证”:
- 启动:用 IaC(Terraform/Pulumi)拉起最小镜像,网络默认拒绝,只开放到 Relay 的入站。
- 执行:Relay 用短期证书登录,强制
agent-exec,执行限定 action。 - 审计:收集 SSH 登录日志 + 执行器审计事件 + 系统审计事件,打包归档到不可变存储。
- 销毁:云层面删除实例与磁盘(不要只 stop)。
这不是“更安全的 SSH”,这是把攻击面时间窗压缩到分钟级, 并且让残留状态在物理层被擦掉。
本章精粹
- SSH 不是授权系统,它只是安全通道;你需要的是“受控执行器”。
- 引入 Relay,把大脑与战场隔离;把强制点放在 sshd 与执行器。
- 用短期 SSH 证书(CA +
TrustedUserCAKeys+ principals)替代永久私钥。 citeturn0search8turn0search3 - 用
ForceCommand把远程执行钉死到agent-exec,并配合PermitOpen收敛攻击面。 citeturn0search8 - 让执行器用参数化执行走
execve,不要走sh -c,否则注入问题永远存在。 citeturn0search9
下一章,我们把这个思路推进到极致:零信任工具权限模型。 我们要讨论如何把“每一次 tool call”都变成一个可度量、可审批、可回滚的受控动作。 citeturn0search8
(本文完 - 深度解析系列 81)