把 Hermes 关进 OpenShell 沙箱(基于 pi 镜像):rootless Podman 与凭证不落地
九月 11, 2026 更新: 九月 13, 2026 [虚拟化与容器] #openshell #podman #sandbox #landlock #apt背景
我有一台 Ubuntu 桌面 VM(下面叫 dut),平时在上面跑两个 agent:
- pi:极简的终端编码 agent(
@earendil-works/pi-coding-agent); - Hermes:Nous Research 的自改进 agent,带 Web dashboard。
2026-09 更新:沙箱最终常驻跑的是 Hermes,沙箱名 / provider / 仓库目录已从
pi统一改名为hermes(镜像仍是上游 pi 镜像,pi 在镜像里可作备用);文中命令均已用新名字。
直接跑在宿主上有三个担心:
- 隐私文件:agent 的
bash工具能读~/.ssh、~/.password-store、各种文档; - 密钥:模型 API key 在进程环境或配置里,prompt injection 可以把 key 外传;
- 破坏性操作:
rm -rf直接作用在宿主文件系统。
目标:把 agent 关进沙箱,文件只读/只写工作区、出网白名单、真 key 不进入 agent 进程,同时保留可观测性。最后选了 NVIDIA OpenShell——它的 policy 引擎正好覆盖这三件事。
这篇文章分两部分:OpenShell 的部署与 policy 字段详解;以及在沙箱里加装 Hermes 的完整过程和持久化取舍。
OpenShell 架构速览
CLI ──mTLS──▶ openshell-gateway(控制面,systemd 用户服务,127.0.0.1:17670)
│ 通过 compute driver 管理沙箱
▼
sandbox 容器(rootless podman)
└─ supervisor(PID 1,root)
├─ 把 agent 降权到 sandbox 用户、清 capability
├─ 应用 filesystem policy(Landlock LSM)
├─ 建 agent netns + 在 10.200.0.1:3128 跑正向代理
└─ 每次连接按 binary+endpoint 做 OPA 判权、L7 时终结 TLS 替换凭证
几个容易误解的点:
- Docker/Podman 只是计算驱动(可换 k8s/VM),不是权限边界本身;真正的限制是 supervisor 里的 policy 层;
- 沙箱网络和宿主不是同一个网络:agent 在嵌套 netns(
veth-s10.200.0.2/24,默认路由指向 supervisor 的10.200.0.1),实测除代理端口外什么都不可达(podman 网关、宿主 LAN、公网 IP、host.openshell.internal全不通),DNS 也是代理侧解析; - 出口是应用层正向代理(不是 NAT):TLS 终结、OPA 策略、凭证占位符替换、OCSF 审计日志一条龙。
部署
1. 装 OpenShell
export https_proxy=http://192.168.5.244:10808
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
装的是 Debian 包:CLI + gateway + systemd 用户服务。装完 gateway 会自动起;openshell status 显示 Connected + Authenticated (mTLS) 即可。
2. 计算驱动:从 Docker 换 rootless Podman
这台机器原本用 Docker。换 Podman 的原因很实际:
- 拉镜像的代理:podman 拉取是客户端行为,
export HTTPS_PROXY直接生效;docker 是 daemon 拉,客户端 export 无效,必须改docker.service.d并重启 daemon(我不想动 Docker 配置,也不想重启跑着服务的 Docker); - 权限:rootless podman socket 天然属于用户,gateway 不需要 docker 组、不用改 socket 权限;
podman-docker提供dockerCLI 兼容层,旧脚本基本无痛。
sudo apt install -y podman podman-compose podman-docker passt uidmap slirp4netns
systemctl --user enable --now podman.socket
然后让网关切到 podman 驱动:
# ~/.config/openshell/gateway.toml
[openshell.gateway]
compute_drivers = ["podman"]
systemctl --user restart openshell-gateway
journalctl --user -u openshell-gateway -n 20 | grep -i driver
# INFO openshell_driver_podman::driver: Connected to Podman ... rootless=true rootless_network_cmd=pasta
坑:卸载 Docker 时 purge
containerd.io会连带删掉runc(podman 当时用的 OCI runtime),正在跑的沙箱容器直接变 unhealthy/Error,补装apt install runc后重建沙箱即可。
3. 拉镜像
export https_proxy=http://192.168.5.244:10808
podman pull ghcr.io/nvidia/openshell/supervisor:0.0.116
podman pull ghcr.io/nvidia/openshell-community/sandboxes/pi:latest
pi 镜像是上游构建好的(RUN npm install -g @earendil-works/pi-coding-agent,pi 0.77.0),沙箱里零安装。但它体积不小——3.58GB,解剖一下(podman history):
| 层 | 大小 |
|---|---|
| npm 全局包:opencode-ai + @openai/codex + @github/copilot + tar/hono | 1.87 GB |
| Node.js 22 + npm 11 + build-essential/git/vim/nano | 618 MB |
| Claude Code(官方 native 安装) | 481 MB |
uv + Python 3.14 + /sandbox/.venv | ~265 MB |
| Ubuntu 24.04 noble 底座 | 81 MB |
| apt 系统工具(curl/dnsutils/iproute2/iptables/nftables/ping/nc/traceroute…) | 72 MB |
| gh CLI | 41 MB |
pi 本体(@earendil-works/pi-coding-agent) | 157 MB |
也就是说,base 镜像是个"agent 全家桶"(四个 coding agent CLI + 完整 Node 编译链 + Python 工具链),pi 只是最上面薄薄一层。3.58GB 里 pi 自己只占 4%;自建 BYOC 时 FROM base 也继承这些,真想瘦身得绕开 base、自己搭 OpenShell 所需的 supervisor/sandbox 用户与 /etc/openshell 骨架。
凭证不落地:provider + 占位符 + L7 替换
模型 key 的最终形态是:agent 进程只拿到占位符,真值只存在 supervisor 内存里,代理在发往上游的请求上替换。
先写一个 provider profile(自定义类型):
# xiaomi-token-plan-cn-provider.yaml
id: xiaomi-token-plan-cn
category: inference
inference_capable: true
credentials:
- name: api_key
env_vars: [XIAOMI_TOKEN_PLAN_CN_API_KEY]
required: true
auth_style: bearer
header_name: authorization
endpoints:
- host: token-plan-cn.xiaomimimo.com
port: 443
protocol: rest # ← L7:必须,否则代理看不到明文、无法替换
access: read-write
enforcement: enforce
binaries:
- /usr/bin/pi
- /usr/bin/node
# ... node_modules 里的 cli.js 等
导入并用文件喂 key(key 不进命令行历史):
openshell provider profile import -f xiaomi-token-plan-cn-provider.yaml
XIAOMI_TOKEN_PLAN_CN_API_KEY="$(cat ~/.config/openshell/hermes-key)" \
openshell provider create --name hermes-xiaomi --type xiaomi-token-plan-cn \
--credential XIAOMI_TOKEN_PLAN_CN_API_KEY
shred -u ~/.config/openshell/hermes-key
机制:沙箱里 env 看到的是 openshell:resolve:env:XIAOMI_TOKEN_PLAN_CN_API_KEY;supervisor 的代理对 L7 端点做 TLS 终结(临时 CA,私钥只在内存),解析明文 HTTP 后把占位符替换成真 key,再用真实根 CA 重新加密发往上游。provider update 到运行中沙箱约 15s 同步,运行中的进程无需重启。
踩过的坑:
- credentialed endpoint 必须 L7。pi 镜像自带的 policy 里
token-plan-cn.xiaomimimo.com是裸 CONNECT 隧道,运行时会报credentialed endpoint requires L7 inspection; raw tunnel is not explicitly allowed(deny 日志[policy:hermes engine:credentials])。把端点改成protocol: rest才通。 - provider attach 的校验读的是沙箱创建时 spec 里的 policy,不是后续
policy set的 revision。所以 policy 和--provider要在sandbox create时一次给全;对已存在沙箱事后 attach 带凭证的 provider 会报 L4-only。
hermes-policy.yaml 字段详解
policy 分静态段(创建时锁定,改了要重建沙箱)和动态段(network_policies,可 policy set --wait 热更)。
| 字段 | 类别 | 含义 |
|---|---|---|
version | — | schema 版本,固定 1 |
filesystem_policy.include_workdir | 静态 | 把 driver 工作目录(/sandbox)纳入可写 |
filesystem_policy.read_only / read_write | 静态 | 白名单模型:只读/可写路径,未列出的路径不可访问;由 supervisor 用 Landlock LSM + 挂载落实 |
landlock.compatibility | 静态 | best_effort:内核支持就启用,不支持则降级 |
process.run_as_user / run_as_group | 静态 | agent 进程降权身份(pi 镜像里是 sandbox,uid 998);禁止 root |
network_policies.<key> | 动态 | 一条规则 = endpoints × binaries;规则里每个二进制都能访问该规则所有端点 |
endpoint host / port | 动态 | 目标;省略 protocol = 裸 CONNECT 隧道(L4,只能看 host:port) |
endpoint protocol: rest | 动态 | L7:代理终结 TLS、解析 HTTP、按 method/path 判、可替换凭证占位符 |
endpoint access | 动态 | 预设:read-only = GET/HEAD/OPTIONS;read-write 全放 |
endpoint rules[].allow{method,path} | 动态 | L7 方法/路径白名单(如 git 只放 clone/fetch,禁 push) |
endpoint allow_encoded_slash | 动态 | 允许路径里的 %2F(npm scoped 包必须) |
binaries[].path | 动态 | 允许使用该规则的执行者(支持 glob);代理按 /proc/<pid>/exe 的真实路径匹配(软链不算) |
filesystem_policy 的 whitelist 效果实测:
$ openshell sandbox exec -n hermes -- sh -c 'id; ls /home'
uid=998(sandbox) gid=998(sandbox) groups=998(sandbox)
ls: cannot open directory '/home': Permission denied
$ openshell sandbox exec -n hermes -- sh -c 'touch /usr/x; touch /sandbox/ok'
touch: cannot touch '/usr/x': Permission denied # 只读
$ touch /sandbox/ok # 可写
网络白名单:未列端点直接 403,且每次放行/拒绝都有 OCSF 日志:
$ openshell sandbox exec -n hermes -- sh -c 'curl -sS https://example.com'
curl: (56) CONNECT tunnel failed, response 403
$ openshell logs hermes --tail
NET:OPEN [MED] DENIED /usr/bin/curl -> example.com:443 [reason:endpoint not allowed by any policy]
HTTP:POST [INFO] ALLOWED POST http://token-plan-cn.xiaomimimo.com:443/v1/chat/completions [policy:hermes engine:l7]
创建沙箱:
openshell sandbox create --name hermes --from pi --provider hermes-xiaomi \
--policy ./hermes-policy.yaml --detach
沙箱权限边界:没有 root,也没有 sudo
把 agent 关进沙箱,"它能不能自己提权"是第一个要回答的问题。实测结论:不能,而且这是三层设计:
- policy 层:
process.run_as_user显式值只接受sandbox或 UID/GID1..4294967294,0(root)会被 schema 直接拒绝("root and the invalid identity sentinel are rejected")。 - supervisor 层:就算删掉
process段走镜像 OCIUSERfallback,源码resolve_required_oci_user()也显式拒绝OCI USER '...' selects root、数值 UID 0、解析到 UID 0 的用户、primary GID 0、root 组/GID 0——自建镜像写USER root也起不来。 - 运行时层:agent 进程
NoNewPrivs=1、CapEff=0,seccomp 禁mount/ptrace/bpf/unshare(CLONE_NEWUSER)等逃逸原语,Landlock 文件策略照旧。
实测:
$ openshell sandbox exec -n hermes -- sh -c 'id; grep -E "NoNewPrivs|CapEff" /proc/self/status; command -v sudo || echo no-sudo'
uid=998(sandbox) gid=998(sandbox) groups=998(sandbox)
NoNewPrivs: 1
CapEff: 0000000000000000
no-sudo
改时间也不可能:容器没有独立实时钟(CLOCK_REALTIME 是内核全局的,time namespace 只虚拟化 MONOTONIC/BOOTTIME),date -s 需要 CAP_SYS_TIME 且按 init user namespace 校验,rootless 用户命名空间里(连 podman exec -u root)都拿不到:
$ openshell sandbox exec -n hermes -- date -s "2020-01-01 00:00:00"
date: cannot set date: Operation not permitted
时钟共享,时区各自
容器和宿主共享同一个内核时钟(实测两边 date -u 逐秒一致、/proc/self/ns/time inode 相同),所以宿主机改时钟沙箱必然跟着变;不共享的是时区:
| 视角 | 显示 | 原因 |
|---|---|---|
| 宿主 | 16:33 CST | /etc/localtime = Asia/Shanghai |
openshell sandbox exec | 16:33 CST | gateway 给会话注入宿主 TZ=Asia/Shanghai(实测客户端 TZ= 覆盖无效) |
podman exec | 08:33 UTC | 容器 /etc/localtime = Etc/UTC(镜像默认) |
| Hermes 长驻进程 | 08:33 UTC | 无 TZ 环境变量,走容器默认 |
改宿主时区后,沙箱里"没变"的就是后两类(Hermes dashboard/gateway 都算)。统一到 CST:
podman exec -u root <容器> ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 然后重启 dashboard/gateway(glibc 对时区文件有进程级缓存,不重启不生效)
(改动在容器可写层,stop/start 保留、delete 丢;要永久就做进 BYOC 镜像。)
podman exec:绕开 supervisor 的后门
OpenShell 的约束都在 supervisor 里,而容器本身是宿主 rootless podman 管理的。从 dut 上直接 podman exec 可以进同一个容器,但它不是 openshell exec 的等价物:
| 维度 | openshell sandbox exec | podman exec(默认 root) |
|---|---|---|
| 身份 | sandbox (998),受 run_as_user 约束 | root(容器 Config.User=0:0) |
| Landlock 文件策略 | 生效(写 /usr → Permission denied) | 不生效(root 可写 /usr) |
| 网络策略 | 生效(私网 403) | 仍生效(netns 级代理,私网超时) |
| OCSF 审计 | 有记录 | 无记录 |
| 生命周期 | openshell 管 stop/start/delete/forward | gateway 不感知,别用 podman stop/rm |
注意 root 的 capability 里 CAP_DAC_OVERRIDE 被 drop:/sandbox 属 sandbox 用户 755,root 反而写不进去(要 -u sandbox);写 /usr、装包没问题。
这给了我们一条"给沙箱补系统工具"的路子——但直接 apt 会失败:
E: Failed to fetch store:/var/lib/apt/lists/partial/... rename failed, Permission denied
原因:/var/lib/apt/lists/partial 属 _apt,而 root 缺 CAP_DAC_OVERRIDE。解法(实测装通 sl):
C=openshell-default--hermes-<id>
podman exec -u root $C chown -R root:root /var/lib/apt/lists /var/cache/apt
podman exec -u root $C apt-get -o APT::Sandbox::User=root update
podman exec -u root $C apt-get -o APT::Sandbox::User=root -y --no-install-recommends install <pkg>
装完 Hermes 直接可用(工具在 /usr/bin,policy 的 binaries: /usr/** 也允许它走网络)。局限:改动在容器可写层(sandbox delete/重建丢,不在 /sandbox 卷)、绕过策略与审计、容器里没有 systemd(服务类工具起不来)、别 apt-get upgrade。要持久化就写进 BYOC 镜像的构建期。
沙箱内加装 Hermes:路线选择与持久化
Hermes 不在 pi 镜像里,有两个装法:
- 路线 A:运行时安装——在运行中的沙箱里跑官方安装器,装到
/sandbox/.hermes(本文的做法); - 路线 B:BYOC 镜像——写 Dockerfile
FROM社区 base/pi 镜像再装 Hermes,构建时网络不受 policy 限制,重建不丢。注意上游没有现成的 hermes 沙箱镜像(OpenShell-Community 的sandboxes/只有 base/droid/gemini/nvidia-gpu/ollama/pi/sdg,--from hermes会 403);--from也接受本地 Dockerfile 目录或完整镜像引用。NousResearch 官方的 hermes-agent 镜像(debian + s6-overlay)不能直接当 OpenShell 沙箱镜像:它没有 supervisor/sandbox 用户和/etc/openshell布局,ENTRYPOINT/init与 supervisor 注入冲突,BYOC 必须FROM社区 base/pi 自己装。
我这次先用 A 快速验证。持久化边界要说清楚:
| 位置 | stop/start | sandbox delete | 说明 |
|---|---|---|---|
/sandbox(podman 命名卷) | 保留 | 丢 | 宿主 ~/.local/share/containers/storage/volumes/openshell-sandbox-<id>-workspace/_data |
容器可写层(/usr 外) | 保留 | 丢 | /usr 是只读 policy,装不了全局包 |
| pi 本体 | 保留 | 丢 | 但它在镜像里,重建自动有 |
所以路线 A 装的东西随卷走,但 sandbox delete 就没了;长期用建议路线 B 把它固化进镜像。
补记(2026-09-13):表中「
/usr只读」是相对沙箱内进程(Landlock)说的;从宿主用podman exec -u root可以写/usr并 apt 装包(见后文「权限边界」节),但同样只活在容器可写层,sandbox delete照样丢。
路线 A 实操
更新(2026-09-13):早期版本要先给 policy 加
hermes_install(安装端点)和hermes_model(模型端点)两条专用规则;2026-09 网络放开后两条都已删除(单条open_internet覆盖公网 80/443),下面的policy set只是确保最新 policy 生效,不再是安装的前置条件。
openshell policy set hermes --policy hermes-policy.yaml --wait
安装(装到 /sandbox/.hermes):
openshell sandbox exec -n hermes --timeout 1500 -- bash -lc \
'curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash'
安装器是用户态安装(hermes --version 自报 Install method: git):/sandbox/.hermes/hermes-agent 是 git checkout(hermes update 即 git pull),自带 Python venv、Node 运行时和 uv,全落在 /sandbox 卷里——所以不需要 root/apt(沙箱里也没有,见上节)。
两个坑:
- uv 解释器真实路径:Hermes 的 venv python 是指向
/sandbox/.uv/python/cpython-3.11.x/bin/python3.11的软链,OPA 按/proc的真实 exe 匹配——hermes_model的 binaries 只写/sandbox/.hermes/**会被 deny,必须加上/sandbox/.uv/python/**(现由open_internet的/sandbox/**通配覆盖); - npm 的
%2F:Web UI 构建时 scoped 包路径带%2F,裸隧道端点会被 http-parser 拒绝;registry.npmjs.org要配protocol: rest+allow_encoded_slash: true(现由open_internet全局覆盖)。
模型接入(命名 provider + 环境变量占位符):
hermes config set providers.xiaomi-token-plan-cn.base_url https://token-plan-cn.xiaomimimo.com/v1
hermes config set providers.xiaomi-token-plan-cn.key_env XIAOMI_TOKEN_PLAN_CN_API_KEY
hermes config set model.provider xiaomi-token-plan-cn
hermes config set model.default mimo-v2.5
验证:
$ openshell sandbox exec -n hermes -- hermes -z "Reply with exactly one word: pong"
pong
# 日志:HTTP:POST ALLOWED .../v1/chat/completions [policy:hermes_model engine:l7]
Hermes Web UI:自带认证,不需要 caddy
Hermes 自带 BasicAuthProvider(dashboard.basic_auth.*,scrypt 密码哈希 + HMAC 签名会话),所以不用反代加认证,直接绑 0.0.0.0 暴露即可:
# 沙箱内:生成随机密码、算 scrypt 哈希写进配置
PW=$(openssl rand -base64 18)
HASH=$(./venv/bin/python -c 'from plugins.dashboard_auth.basic import hash_password; import sys; print(hash_password(sys.argv[1]))' "$PW")
hermes config set dashboard.basic_auth.username admin
hermes config set dashboard.basic_auth.password_hash "$HASH"
hermes config set dashboard.basic_auth.secret "$(openssl rand -hex 32)"
# 绑 0.0.0.0 起服务(有认证才允许非 loopback)
setsid nohup hermes dashboard --no-open --host 0.0.0.0 > /sandbox/.hermes/logs/dashboard.log 2>&1 < /dev/null &
# 宿主:把沙箱端口转发到 LAN
openshell forward start 0.0.0.0:9119 hermes -d
验证门禁:
GET / -> 302(跳登录页)
GET /api/sessions -> 401(未认证)
POST /auth/password-login(provider: basic)-> 200 并下发 cookie
带 cookie 再访问 /api/sessions -> 200
注意两点:一是 HTTP 明文(无 TLS),LAN 内密码明文传输,介意就再套 caddy TLS;二是 openshell forward 的 listener 在跑 CLI 的机器上,且 forward stop/start 有时会卡住不退出(SSH channel 不关,之前专门写过一篇排查),实际转发通常已经生效,openshell forward list 确认即可。
踩坑清单
credentialed endpoint必须 L7(protocol: rest),否则代理只能看到裸隧道,凭证替换失败;provider attach校验读创建时 spec 的 policy——policy 和--provider要sandbox create一起给;provider update到沙箱约 15s 同步,别急着测;openshell policy get输出带表格头(Version: … ---),回灌前要截---后的纯 YAML;- OPA 按
/proc真实 exe 匹配 binaries,venv/软链要写真实路径(uv python); - npm scoped 包需要
allow_encoded_slash: true; - purge
containerd.io会删runc,podman 沙箱容器会变 Error; - 沙箱里跑不了 tcpdump(没二进制 +
CapEff=0+ policy 无 capability 配置);抓包在宿主做,明文 HTTP 看openshell logs的 OCSF 事件。 - 沙箱内没有 root/sudo/apt 是设计:policy schema 拒
0/root,supervisor 连 OCIUSER root/UID0/GID0 都拒,运行时NoNewPrivs=1/CapEff=0/seccomp/Landlock;date -s同样 EPERM。宿主侧podman exec -u root是唯一 root 路径(见「权限边界」节)。 podman exec -u root里 apt 报rename failed, Permission denied:/var/lib/apt/lists/partial属_apt,root 缺CAP_DAC_OVERRIDE;先chown -R root:root两个 apt 目录 +-o APT::Sandbox::User=root即可(实测装通sl)。- 宿主改时区沙箱不跟:时钟共享、时区各自(容器
/etc/localtime=Etc/UTC);只有openshell sandbox exec会话由 gateway 注入宿主 TZ,Hermes 长驻进程是 UTC。统一 = 软链/etc/localtime+ 重启 dashboard/gateway。
总结
OpenShell 的价值在于它把「文件、进程、网络、凭证」四件事做成了一个可声明、可热更、可审计的策略层,而容器只是执行载体:
- 文件:Landlock 白名单,agent 只看得到
/sandbox和只读系统目录; - 网络:白名单 + L7 检查,未列端点 403,全部留 OCSF 日志;
- 凭证:占位符 + 代理替换,真 key 不进 agent 进程——这是普通容器/namespace 方案做不到的部分;
- 进程:无 root/sudo 是硬边界(policy schema + supervisor OCI fallback 双拒,运行时零 capability + seccomp),系统工具走构建期镜像或宿主
podman exec -u root后门(容器层,重建丢); - 扩展:沙箱里可以再装别的 agent(本文的 Hermes),代价是持久化要自己规划(卷 vs BYOC 镜像)。
如果你只是想要「文件隔离 + 用宿主工具」,bubblewrap/Landlock 更轻;但一旦涉及「模型 key 不能被 agent 读走」,OpenShell 这种带凭证注入的代理沙箱是最省心的方案。