把 Hermes 关进 OpenShell 沙箱(基于 pi 镜像):rootless Podman 与凭证不落地

九月 11, 2026 更新: 九月 13, 2026 [虚拟化与容器] #openshell #podman #sandbox #landlock #apt

背景

我有一台 Ubuntu 桌面 VM(下面叫 dut),平时在上面跑两个 agent:

2026-09 更新:沙箱最终常驻跑的是 Hermes,沙箱名 / provider / 仓库目录已从 pi 统一改名为 hermes(镜像仍是上游 pi 镜像,pi 在镜像里可作备用);文中命令均已用新名字。

直接跑在宿主上有三个担心:

  1. 隐私文件:agent 的 bash 工具能读 ~/.ssh、~/.password-store、各种文档;
  2. 密钥:模型 API key 在进程环境或配置里,prompt injection 可以把 key 外传;
  3. 破坏性操作: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 替换凭证

几个容易误解的点:

部署

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 的原因很实际:

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/hono1.87 GB
Node.js 22 + npm 11 + build-essential/git/vim/nano618 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 CLI41 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 同步,运行中的进程无需重启。

踩过的坑:

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 关进沙箱,"它能不能自己提权"是第一个要回答的问题。实测结论:不能,而且这是三层设计:

  1. policy 层:process.run_as_user 显式值只接受 sandbox 或 UID/GID 1..4294967294,0(root)会被 schema 直接拒绝("root and the invalid identity sentinel are rejected")。
  2. supervisor 层:就算删掉 process 段走镜像 OCI USER fallback,源码 resolve_required_oci_user() 也显式拒绝 OCI USER '...' selects root、数值 UID 0、解析到 UID 0 的用户、primary GID 0、root 组/GID 0——自建镜像写 USER root 也起不来。
  3. 运行时层: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 exec16:33 CSTgateway 给会话注入宿主 TZ=Asia/Shanghai(实测客户端 TZ= 覆盖无效)
podman exec08: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 execpodman 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/forwardgateway 不感知,别用 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 快速验证。持久化边界要说清楚:

位置stop/startsandbox 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(沙箱里也没有,见上节)。

两个坑:

模型接入(命名 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 确认即可。

踩坑清单

  1. credentialed endpoint 必须 L7(protocol: rest),否则代理只能看到裸隧道,凭证替换失败;
  2. provider attach 校验读创建时 spec 的 policy——policy 和 --provider 要 sandbox create 一起给;
  3. provider update 到沙箱约 15s 同步,别急着测;
  4. openshell policy get 输出带表格头(Version: … ---),回灌前要截 --- 后的纯 YAML;
  5. OPA 按 /proc 真实 exe 匹配 binaries,venv/软链要写真实路径(uv python);
  6. npm scoped 包需要 allow_encoded_slash: true;
  7. purge containerd.io 会删 runc,podman 沙箱容器会变 Error;
  8. 沙箱里跑不了 tcpdump(没二进制 + CapEff=0 + policy 无 capability 配置);抓包在宿主做,明文 HTTP 看 openshell logs 的 OCSF 事件。
  9. 沙箱内没有 root/sudo/apt 是设计:policy schema 拒 0/root,supervisor 连 OCI USER root/UID0/GID0 都拒,运行时 NoNewPrivs=1/CapEff=0/seccomp/Landlock;date -s 同样 EPERM。宿主侧 podman exec -u root 是唯一 root 路径(见「权限边界」节)。
  10. 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)。
  11. 宿主改时区沙箱不跟:时钟共享、时区各自(容器 /etc/localtime=Etc/UTC);只有 openshell sandbox exec 会话由 gateway 注入宿主 TZ,Hermes 长驻进程是 UTC。统一 = 软链 /etc/localtime + 重启 dashboard/gateway。

总结

OpenShell 的价值在于它把「文件、进程、网络、凭证」四件事做成了一个可声明、可热更、可审计的策略层,而容器只是执行载体:

如果你只是想要「文件隔离 + 用宿主工具」,bubblewrap/Landlock 更轻;但一旦涉及「模型 key 不能被 agent 读走」,OpenShell 这种带凭证注入的代理沙箱是最省心的方案。