原文连接:Hermes Agent 12 | 沙箱与执行环境:六种终端后端的安全隔离
给危险一个可以犯错的房间,它就不再危险。
为什么需要六种后端
先回答一个直觉上的问题:一个 Agent 框架为什么要支持六种执行环境?
因为不同的部署场景有不同的约束:
| 场景 | 核心需求 | 适合的后端 |
|---|---|---|
| 本地开发 | 零延迟、直接访问文件系统 | Local |
| 安全隔离 | 破坏性命令不影响宿主机 | Docker |
| 远程开发 | Agent 在本机,代码在服务器 | SSH |
| 云沙箱(有状态) | 按需启停、休眠节省成本 | Daytona |
| HPC 集群 | Singularity 是唯一选项 | Singularity |
| 云 GPU 计算 | 按秒计费、快照持久化 | Modal |
一个 Agent runtime 同时服务 CLI 用户、Telegram bot、cron 调度------它不可能假设所有命令都在本机跑。
BaseEnvironment:统一抽象
六种后端共享一个基类------tools/environments/base.py:265 的 BaseEnvironment:
class BaseEnvironment(ABC):
"""Common interface and unified execution flow for all Hermes backends.
Subclasses implement _run_bash() and cleanup(). The base class
provides execute() with session snapshot sourcing, CWD tracking,
interrupt handling, and timeout enforcement.
"""
子类只需实现两个方法:
| 方法 | 职责 |
|---|---|
_run_bash(cmd_string, *, login, timeout, stdin_data) |
在目标环境里启动一个 bash 进程 |
cleanup() |
释放后端资源(停容器、关连接、删沙箱) |
基类处理所有"不管在哪个环境里都一样"的逻辑:
Session Snapshot:环境一致性的保证
每个后端启动时(init_session()),基类会在目标环境里执行一段 shell 脚本,导出当前所有环境变量、函数和别名,存成一个 snapshot 文件(/tmp/hermes-snap-{session_id}.sh)。此后每次执行命令前,先 source 这个 snapshot------保证后续命令看到的环境与第一次一致。
为什么不用 bash -l? 因为 login shell 每次都要走完 /etc/profile、~/.bashrc、~/.bash_profile 的完整链路,慢且不可控。Snapshot 是一次性导出、多次复用。
CWD 追踪:跨命令的目录记忆
用户执行 cd /tmp/myproject 后,下一条命令应该在 /tmp/myproject 里。但每次 _run_bash() 都是一个新 bash 进程------cd 的效果会丢失。
基类在每条命令末尾追加 pwd 输出,通过 marker(__HERMES_CWD_{session_id}__)从 stdout 中解析当前目录,存在 self.cwd 里,下次执行前先 cd 过去。
Local 后端 用临时文件读 CWD(更快),远端后端(SSH/Modal/Daytona)从 stdout marker 解析。
六种后端逐个拆
1. Local:零开销,但没有隔离
tools/environments/local.py------最简单的后端,每条命令 fork 一个 bash -c 子进程。
安全措施不在隔离,而在环境变量过滤 。Local 后端有一个 50+ 项的环境变量黑名单(local.py:19-107),过滤掉:
-
所有 LLM 提供商密钥(
ANTHROPIC_API_KEY、OPENAI_API_KEY......) -
消息平台 Token(Slack、Discord、Telegram......)
-
基础设施凭证(
GITHUB_APP_PRIVATE_KEY、MODAL_TOKEN_*......)
Agent 执行的命令看不到这些变量------防止 Agent 被诱导通过 echo $OPENAI_API_KEY 泄露密钥。
进程组隔离 :使用 os.setsid() 创建新会话,确保子进程不会继承 Agent 的进程组------Ctrl-C 杀 Agent 时不会误杀用户正在跑的后台进程。
适用场景:本地开发、信任环境、需要直接访问宿主文件系统的场景。
2. Docker:生产级隔离
tools/environments/docker.py------最常用的隔离后端。
安全参数 (docker.py:153-163,每个容器都会应用):
_SECURITY_ARGS = [
"--cap-drop", "ALL", # 移除所有 Linux 能力
"--cap-add", "DAC_OVERRIDE", # root 可写宿主挂载目录
"--cap-add", "CHOWN", # 包管理器需要改属主
"--cap-add", "FOWNER",
"--security-opt", "no-new-privileges", # 阻止权限提升
"--pids-limit", "256", # 防 fork bomb
"--tmpfs", "/tmp:rw,nosuid,size=512m",
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
"--tmpfs", "/run:rw,noexec,nosuid,size=64m",
]
两种持久化模式:
| 模式 | 存储 | 生命周期 | 用途 |
|---|---|---|---|
| 持久化 | bind mount HERMES_HOME/sandboxes/docker/{task_id}/... |
挂载目录跨 session 保留 | 长期开发项目 |
| 临时 | tmpfs(/workspace:10g, /home:1g) |
容器删除即消失 | 一次性任务 |
这里要注意一个实现细节:持久化的核心不是"复用同一个容器",而是复用宿主机上的 bind mount 目录。 当前实现会把 /root 映射到 HERMES_HOME/sandboxes/docker/{task_id}/home;如果用户没有显式把 /workspace 另行挂载,还会再映射一个 .../workspace。下次创建环境时,Hermes 仍然会重新 docker run -d 一个新的随机容器,但挂载目录里的内容会被重新带进来。
所以真正持久化的是这些挂载目录里的文件,而不是"整个容器原样续跑"。临时模式则一切写入 tmpfs,速度快但不持久。
资源限制(可配置):
terminal:
backend: docker
container_cpu: 1 # CPU 核数
container_memory: 5120 # MB(默认 5GB)
container_disk: 51200 # MB(默认 50GB,需要 overlay2+XFS+pquota)
container_persistent: true
环境变量转发 :Docker 后端有一个 docker_forward_env 配置------显式声明要传入容器的环境变量。只在 init_session 时通过 -e KEY=VALUE 注入,后续命令通过 snapshot 获取,不再每次传递。
容器内跳过命令审批 :因为容器本身就是安全边界,Docker 后端执行命令时不触发第 2 层的危险命令检查。rm -rf / 在容器里只影响容器自己。
3. SSH:Agent 在本地,代码在远端
tools/environments/ssh.py------通过 SSH 在远程服务器上执行命令。
ControlMaster 连接复用 (ssh.py:68-82):
- o ControlPath={socket_path} # ~/.hermes/ssh/{user}@{host}:{port}.sock
- o ControlMaster=auto # 复用已有连接
- o ControlPersist=300 # 5 分钟无命令后关闭
- o BatchMode=yes # 禁止密码提示
- o StrictHostKeyChecking=accept-new # 接受新主机,拒绝已变更的密钥
第一次连接建立 SSH 通道,后续命令复用同一个 socket------避免每条命令都做 TCP+SSH 握手。
文件同步 :SSH 后端使用 FileSyncManager,通过 tar-over-pipe 批量上传文件:
tar -chf - -C /staging . | ssh user@host "tar xf - -C /"
一次 TCP 流传几百个文件,比逐个 scp 快一个数量级。
配置:
TERMINAL_SSH_HOST=dev-server.example.com
TERMINAL_SSH_USER=ubuntu
TERMINAL_SSH_PORT=22
TERMINAL_SSH_KEY=~/.ssh/id_ed25519
4. Daytona:云端有状态沙箱
tools/environments/daytona.py------Daytona 是一个云开发环境 SDK,提供按需启停的托管沙箱。
冷启动 / 休眠 / 恢复:
首次创建:sandbox = Daytona.create(image=..., auto_stop_interval=0)
正常关闭:sandbox.stop()(保留文件系统)
下次启动:sandbox = Daytona.get(name) → sandbox.start()(恢复文件系统)
auto_stop_interval=0------关闭 Daytona 的自动休眠,由 Hermes Agent 完全控制生命周期。
每次命令执行前 (daytona.py:195-200)会检查沙箱状态------如果意外进入了 STOPPED 或 ARCHIVED 状态,自动调 sandbox.start() 恢复。对用户透明。
持久化 :默认持久(cleanup() 调 stop() 不调 delete())。非持久模式调 delete(),沙箱及其文件系统被彻底删除。
5. Singularity:HPC 集群的容器标准
tools/environments/singularity.py------Singularity(现名 Apptainer)是 HPC 集群上最常用的容器运行时。
核心安全标志:
apptainer instance start \
--containall --no-home \
[--overlay {dir}] # 持久化模式
[--writable-tmpfs] # 临时模式
{image} {instance_id}
-
--containall:隔离 /tmp、/var/tmp、/dev、/proc、cgroup -
--no-home:不挂载用户 home 目录
Overlay 文件系统 :持久化模式用 overlay 目录。路径优先级是:先看 TERMINAL_SCRATCH_DIR,否则在可写时优先落到 /scratch/<user>/hermes-agent/hermes-overlays/overlay-{task_id}/,再回退到 HERMES_HOME/sandboxes/singularity/hermes-overlays/overlay-{task_id}/。基础镜像是只读的,所有修改都写入 overlay 层。
SIF 镜像缓存 (singularity.py:107-153):第一次使用时从 Docker 镜像构建 SIF 文件,缓存到本地。后续启动直接用缓存的 SIF------避免每次都从 Docker Hub 拉取。
HPC 适配 :优先使用 /scratch(HPC 节点本地高速存储),回退到 sandbox 目录。支持 --memory 和 --cpus 通过 cgroup 限制资源。
6. Modal:云端 GPU + 按秒计费
tools/environments/modal.py------Modal 是一个无服务器计算平台,特别适合需要 GPU 的场景。
创建沙箱 (modal.py:223-237):
sandbox = await modal.Sandbox.create.aio(
"sleep", "infinity",
image=image_spec,
app=app,
timeout=3600,
**sandbox_kwargs, # cpu, memory, mounts
)
快照持久化 ------Modal 的独特能力(modal.py:433-451):
# cleanup 时拍快照
img = await sandbox.snapshot_filesystem.aio()
_store_direct_snapshot(task_id, img.object_id)
# 下次创建时从快照恢复
snapshot_id = _get_snapshot_restore_candidate(task_id)
sandbox = await Sandbox.create.aio(image=snapshot_id or base_image, ...)
snapshot_filesystem 把整个容器文件系统序列化为一个 Modal Image------下次从快照恢复比冷启动快得多。快照 ID 存在 ~/.hermes/modal_snapshots.json。
AsyncWorker (modal.py:115-145):Modal SDK 是纯异步的,但 Hermes 的 BaseEnvironment 是同步接口。_AsyncWorker 类在后台线程维护一个独立事件循环,通过 run_coroutine() 桥接:
class _AsyncWorker:
def run_coroutine(self, coro, timeout=600):
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(timeout=timeout)
只读凭证挂载 (modal.py:188-219):通过 modal.Mount.from_local_file() 把凭证文件以只读方式挂载到容器内------沙箱能读取但不能修改凭证。
冷启动:Modal 的冷启动通常 30-60 秒------比 Docker 慢一个数量级。快照恢复可以缩短到 10-20 秒。
六种后端对比
| Local | Docker | SSH | Daytona | Singularity | Modal | |
|---|---|---|---|---|---|---|
| 隔离级别 | 无(env 过滤) | 容器 | 网络隔离 | 云隔离 | 容器 | 云隔离 |
| 冷启动 | 即时 | 5-10s | 1-2s | 10-20s | 5-10s | 30-60s |
| 持久化 | 不适用 | bind mount | 远端文件系统 | stopped sandbox | overlay dir | 快照 |
| 资源限制 | 无 | CPU/mem/disk/PID | 无 | CPU/mem/disk | CPU/mem | CPU/mem/disk |
| 文件同步 | 不需要 | bind mount | tar-over-SSH | Daytona SDK | bind mount | tar-over-stdin |
| 命令审批 | 需要 | 跳过 | 需要 | 跳过 | 跳过 | 跳过 |
| 适用场景 | 本地开发 | 通用隔离 | 远程开发 | 云开发 | HPC | GPU 计算 |
"命令审批跳过"一行 是一个关键的设计洞察:容器化后端(Docker / Singularity / Modal / Daytona)本身就是安全边界------容器内的破坏性命令不影响宿主机,所以不需要再让用户逐条审批。这让容器化部署的用户体验显著优于 Local 后端------不会被频繁的审批提示打断工作流。
检查点管理器:影子 Git 仓库
容器隔离保护的是宿主机。但如果 Agent 在 Local 后端(或容器的持久化 workspace 里)改坏了文件------怎么恢复?
tools/checkpoint_manager.py 提供了一套不污染用户项目的文件恢复机制。
影子 Git 仓库
核心思路:在用户的工作目录之外,维护一个隐藏的 Git 仓库来追踪文件变更。
~/.hermes/checkpoints/{sha256(abs_workdir)[:16]}/
├── HEAD, refs/, objects/ # 标准 git 内部结构
├── HERMES_WORKDIR # 记录对应的工作目录路径
└── info/exclude # 默认排除:node_modules, .env, __pycache__...
为什么叫"影子"? 因为用户的项目目录里没有 .git/ 文件夹------所有 git 元数据都存在 ~/.hermes/checkpoints/ 下。通过 GIT_DIR + GIT_WORK_TREE 环境变量把 git 指向影子仓库,工作树指向用户的项目目录。
Git 环境隔离
影子仓库必须完全隔离于用户的 git 配置 (checkpoint_manager.py:128-159):
env["GIT_DIR"] = str(shadow_repo)
env["GIT_WORK_TREE"] = str(normalized_working_dir)
env["GIT_CONFIG_GLOBAL"] = os.devnull # 忽略 ~/.gitconfig
env["GIT_CONFIG_SYSTEM"] = os.devnull # 忽略 /etc/gitconfig
env["GIT_CONFIG_NOSYSTEM"] = "1" # 兼容旧版 git
为什么要隔离? 因为用户的 ~/.gitconfig 可能有 commit.gpgsign = true------如果影子仓库继承了这个配置,每次自动快照都会弹 GPG pinentry 对话框,卡住整个 Agent。
快照的触发时机
检查点在文件变更前自动创建,但触发条件比"所有写操作"更保守:
-
write_file、patch:执行前直接触发ensure_checkpoint() -
terminal:只有命中_is_destructive_command()启发式规则时才触发,比如rm、mv、sed -i、git reset/clean/checkout、覆盖式重定向等
去重:每个工作目录每次 Agent 主循环迭代最多一次快照。也就是说,同一轮用户请求里如果 Agent 经过了多次工具迭代,仍可能拿到多个 checkpoint;但不会因为一次迭代里连改 10 个文件就创建 10 个快照。
安全检查 :跳过根目录 /、home 目录、文件数超过 50K 的目录------避免在巨大目录上做 git add -A 卡死。
恢复流程
恢复前先快照当前状态 (checkpoint_manager.py:492)------这样如果用户发现恢复搞错了,还能撤销。
# 恢复前先快照
ensure_checkpoint(working_dir, reason=f"pre-rollback snapshot (restoring to {commit[:8]})")
# 然后恢复
git checkout {commit_hash} -- . # 全目录恢复
git checkout {commit_hash} -- {file} # 单文件恢复
不移动 HEAD------恢复后影子仓库的 HEAD 不变,用户可以继续正常工作或重新提交。
实战:用 Docker 后端搭建安全的代码执行环境
第一步:配置 Docker 后端
编辑 ~/.hermes/config.yaml(或设置环境变量):
terminal:
backend: docker
docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
container_persistent: true
container_memory: 4096 # 4GB
container_cpu: 2
或者用环境变量:
export TERMINAL_ENV=docker
export TERMINAL_DOCKER_IMAGE="nikolaik/python-nodejs:python3.11-nodejs20"
export TERMINAL_CONTAINER_PERSISTENT=true
第二步:启动 Hermes Agent
hermes chat
这一步还不会立刻创建容器。当前实现是按需懒创建 :只有第一次真正调用 terminal 工具时,terminal_tool.py 才会进入 _create_environment(...) 拉起 Docker 容器。
所以更准确的验证顺序是:先进入 hermes chat,再给 Agent 一条明确要调用终端的指令,然后用 docker ps 观察容器出现。
第三步:验证隔离
在 Agent 里执行一条"危险"命令来验证隔离:
请在终端里执行 ls / 看看容器的根目录
你会看到容器内的文件系统------与宿主机不同。容器内的 /tmp 是一个 512MB 的 tmpfs。
注意命令审批不会弹出------Docker 后端跳过了第 2 层审批。
第四步:集成到 Telegram
对于 Gateway + Telegram 的部署,Docker 后端是最推荐的选择------来自 Telegram 的命令会落到隔离容器里执行,即使用户输入了恶意命令也不影响宿主机:
# .env
TERMINAL_ENV=docker
TERMINAL_CONTAINER_PERSISTENT=true
TELEGRAM_BOT_TOKEN=...
TELEGRAM_ALLOWED_USERS=...
# 启动 Gateway
hermes gateway run
更严谨地说,容器复用粒度是 session ,不是无条件的"一人一容器"。Gateway 侧终端环境按 task_id 复用,而主会话的 task_id 就是 session_id;session_id 又来自 session_key 规则。默认私聊和多数群消息通常能做到按用户隔离,但线程消息在默认配置下可能是"线程内多人共享一个 session"。
第五步:观察持久化
退出 Agent,过一会儿再启动:
docker ps -a | grep hermes
你通常会看到旧容器处于 Exited(停止但未删除)状态;这说明上一次环境被 stop 了,但并不意味着下一次一定会 docker start 回这个旧容器。
更准确地说:如果新的会话继续命中了同一个 task_id/session_id,Hermes 会新建一个容器,并把先前 bind mount 目录里的内容重新挂进去。因此 /root 与 /workspace 挂载层里的文件通常还在;但写在容器根文件系统其他位置、又不在挂载目录里的改动,并不承诺持久化。