OpenShell 实测:给 Coding Agent 一条看得见的边界

OpenShell 实测:给 Coding Agent 一条看得见的边界

TL;DR

在 M1 Mac 上把 OpenShell 从装到跑通第一个受控 Coding Agent 的完整路径,四个真实的坑各有根因和修法。

为什么 Agent 需要沙箱

Agent 越有用,越危险——这不是修辞,是结构性的。OpenShell 文档里有一句立意,说得比大多数安全宣传都直白:

AI agents are most useful when they can read files, install packages, call APIs, and use credentials. That same access can create material risk.

Agent 要能干活,就得能读文件、装依赖、调 API、用凭证;而这四样东西,恰好就是攻击者想要的四样。翻译过来是一个能力悖论:有用的访问和危险的访问是同一批访问,你没法只给前者不给后者——除非有一层策略在中间做区分。

具体是哪四类风险:

威胁不设防时的样子本文对应章节
数据外传Agent 把你的源码 POST 到任意它想发的端点第四节策略模型
凭证失窃docker run -e API_KEY=... 的 key 明文落进容器 env第五节凭证注入
未授权调用拿着 GitHub token 去打它不该打的 API第五节跨端点实验
越权执行sudo、setuid、危险系统调用第三节内核边界

有人说 Agent 是自己写的 prompt,不会乱来。但 prompt injection 是常态不是假设:Agent 会执行网页内容里夹带的指令,而“信任 Agent 的输出”从来不等于“信任 Agent 的行为”。边界得由基础设施来划,不能靠模型自觉。

现有的选项都不完整。裸 Docker 有隔离没策略:凭证明文进 env,网络要么全开要么 --network none 全关,没有中间态。Docker Sandboxes 往前走了一步——microVM 隔离加凭证代理转发,我在 这个系列 里测过——但它的网络策略是容器级的,管不到“哪个程序能发什么请求”这个粒度。

OpenShell 把粒度推到了底:程序 × 端点 × HTTP 方法三级策略、凭证只以占位符进沙箱、策略变更先过形式化检查。这三件套是不是真的成立,正是本文要跟着命令逐条验证的事。

环境与版本声明

以下所有命令实测于 2026-10-24/25,OpenShell 钉在 v0.1.5(2026-10-20 发布)。OpenShell 每周二发稳定版,跟随本文操作时建议先 openshell --version 对一下版本——它的迭代速度很快,行为差异可能存在。

机器是 M1 Mac(macOS 26.6),Docker 引擎用的是 OrbStack 2.2.4——注意这不在官方支持矩阵里(矩阵只列了 Docker Desktop)。先说结论:全程没遇到一个 OrbStack 特有的问题,包括 Landlock 内核资格、本地镜像识别和隔夜存活;唯一一次“卡死”是 ghcr.io 拉镜像慢导致的,跟引擎无关(第二节展开)。

安装,与一个假死的 gateway

安装脚本一条命令,但建议下载后审阅再执行——这也是对“往系统里装一个安全组件”的基本尊重:

curl -fsSL -o install-openshell.sh \
  https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh
OPENSHELL_VERSION=v0.1.5 sh install-openshell.sh

macOS 分支做的事:下载 Homebrew formula、安装 CLI 和 gateway、用 brew services 把 gateway 跑在 127.0.0.1:17670(mTLS 双向认证),最后注册并等待连通。装完本体只要 4 秒。

然后是本文的第一个坑:安装脚本最后一步报错退出,说 gateway 30 秒内没有监听。此时进程明明活着。

三条排查线索,按顺序来:

lsof -nP -iTCP:17670 -sTCP:LISTEN   # 空——确实没监听
tail /opt/homebrew/var/log/openshell/openshell-gateway.out.log
# 日志停在 Using compute driver driver=docker,之后零输出

第三条最有信息量——看 gateway 进程的文件描述符连着谁:

lsof -p "$(pgrep -f openshell-gateway)" | grep unix
# 一条 unix socket,对端是 OrbStack 的 docker.sock

这一步顺带回答了一个关键问题:gateway 选了 docker driver,且默认的 /var/run/docker.sock 被 OrbStack 软链接管,连的就是它——不用任何配置。进程 CPU 接近零、日志停在 driver 初始化、socket 连着 Docker:不是崩了,是在等。

等什么?根因:gateway 启动时会同步拉取两份受信镜像(ghcr.io/nvidia/openshell/supervisor 60.6MB 和 sandbox 9.39MB),拉完才绑定端口,全程零进度日志。到 ghcr.io 链路慢的时候,单独用 docker CLI 拉 supervisor 实测要 68 秒——30 秒的探针必然超时。更糟的是,看到“卡死”就重启 gateway,会把快拉完的镜像打断,进入循环假死。

修法是什么都不用改:等它拉完,或者先手动预拉再启动:

docker pull ghcr.io/nvidia/openshell/supervisor:v0.1.5
docker pull ghcr.io/nvidia/openshell/sandbox:v0.1.5

镜像就位后重启 gateway,两秒完成绑定。验收标准:

openshell status
# Status: Connected
# Authentication: Authenticated (mTLS transport)
# Version: 0.1.5

这个坑的普适教训:装完不等于 ready。一个安全组件的启动路径里藏着静默的网络拉取,而日志一个字都不提——遇到“进程活着但不干活”,先看 fd 连着谁、日志停在哪一行,比盲目重启有用得多。

第一个沙箱:亲手验证边界

不设防的沙箱不值得用,所以第一步不是跑 Agent,是验证边界真的关着。默认镜像 nvcr.io/nvidia/base/ubuntu:24.04 恰好不含任何 Agent,是干净的探针环境:

openshell sandbox create --name lab-p0   # 4.8 秒,含拉取

网络探针:连 DNS 应答都是假的

在沙箱里解析一个未授权域名:

openshell sandbox exec -n lab-p0 --no-login-shell -- getent hosts example.com
# 198.18.0.2  example.com

198.18.0.2 不是 example.com 的真实地址——198.18.0.0/15 是 RFC 2544 的基准测试保留段。这是 OpenShell 的 fake-IP 设计:沙箱内的 DNS 由 supervisor 应答,无论授权与否都返回合成地址,真实 IP 从不进入沙箱;真正按域名做决策的位置在 supervisor,不在这台“机器”里。所以别把“DNS 解析成功”当成放行信号——解析永远成功,决策在后面。

连接层面直接被拒,且域名和裸 IP 一视同仁:

openshell sandbox exec -n lab-p0 --no-login-shell -- \
  timeout 5 bash -c 'exec 3<>/dev/tcp/example.com/443 && echo connected'
# bash: connect: Permission denied

openshell sandbox exec -n lab-p0 --no-login-shell -- \
  timeout 5 bash -c 'exec 3<>/dev/tcp/93.184.216.34/443 && echo connected'
# 同样 Permission denied

Permission denied(EACCES)、瞬时返回——这是连接被策略拒绝的样子,不是 DNS 故障也不是超时,两种误读都会让你白排查一轮。宿主机同一站点 0.09 秒返回 200,对照闭环。

文件系统探针:从错误码看出谁在管你

openshell sandbox exec -n lab-p0 --no-login-shell -- sh -c '
id                                        # uid=1000(ubuntu),非 root
touch /sandbox/w.txt && echo workdir OK   # 工作目录可写
touch /etc/probe                          # touch: Permission denied
ls /.openshell                            # Permission denied(保护路径)
ls /Users                                 # No such file(宿主路径不存在)
'

这里有个值得记住的判读技巧:/etc 写入报 EACCES 而不是 Read-only file system(EROFS)。如果是容器只读挂载,报的是 EROFS;报 EACCES 说明限制来自 Landlock——内核级 LSM 在策略层拦的,不是文件系统挂载选项。一个错误码,区分了两层完全不同的机制。

/.openshell 是 supervisor 通道的保护目录,连列目录都不行(这条是强制基线,策略改不掉);/Users 不存在则是容器隔离的本分。三层边界各管各的,错误形态也各不相同。

此刻的沙箱就是默认策略的形态:零出站网络 + 白名单文件系统(openshell policy get lab-p0 --base 可见完整定义)。边界验证完毕,下一步才是开一条门缝。

策略模型:程序、端点、方法

默认镜像里没有 curl,而沙箱无网络装不了包——先在本地构建一个带 curl 的镜像(Docker 构建走的是 OrbStack 自己的网络,与沙箱策略无关):

FROM nvcr.io/nvidia/base/ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
docker build -q -t lab-curl:24.04 .
openshell sandbox create --name lab-p1 --from lab-curl:24.04

然后是本文的核心命令——按官方示例开一条最窄的门缝:只允许 curl 对 GitHub API 做只读访问:

openshell policy update lab-p1 \
  --rule-name github_readonly \
  --binary /usr/bin/curl \
  --add-endpoint api.github.com:443:read-only:rest:enforce \
  --wait

--add-endpoint 的五段语法值得拆开记:host:port:预设:协议:执法模式。read-only 允许 GET/HEAD/OPTIONS;rest 声明做七层检查(能查到方法和路径);enforce 是真拦——默认值是 audit,只记日志不阻断,这是新手最容易漏的一处。

规则热生效(无需重启),跑一遍矩阵:

探针结果说明
curl GET api.github.com/zen200,真实上游内容门缝内的自由
curl POST 同一地址403,响应体是 policy_denied JSON七层拦截
bash 直连同一端点Permission denied端点授权了,程序没授权
curl 打 example.com连接层拒绝程序授权了,端点没授权
curl 打 169.254.169.254连接层拒绝SSRF 黑名单是内置的

OpenShell 两阶段检查模型:连接闸门与请求检查

注意第二种拒绝和第一、三种的形态差异——这是排障的核心知识。连接层拒绝表现为 Failed to connect(curl 退出码 7),说明在“程序 × 主机 × 端口”这一关就没过;而 policy_denied 是一个真正的 HTTP 403 响应,JSON 里带 layer: "l7"、命中的规则名、发起请求的二进制真身路径——能拿到这些细节,是因为所有流量都经 supervisor 中继并在那里终结 TLS(第五节会看到这个设计对凭证的意义);第三种是上游自己的响应(GitHub 的 401/404),请求已经出了闸。三种形态对应三个完全不同的判断,别用同一个“不通”来概括。

audit 只记不拦:观察模式不是防护

想先观察再收紧,可以把 enforce 换成 audit。但注意一个坑:对已存在的 endpoint 再跑 --add-endpoint 换执法模式,会被静默忽略(只给一行警告,策略不变)。正确做法是用 policy set 整体替换:

openshell policy set lab-p1 --policy policy-audit.yaml --wait

切到 audit 后重放 POST,这次拿到的是 GitHub 自己的 404——请求穿透了,只留下一条违规日志。这验证了一条纪律:audit 阶段的数据不能用来宣称“已阻断”,观察完立即换回 enforce。

第二个坑:一条命令清空你的二进制列表

给已有规则追加变更时,如果忘了带 --binary:

openshell policy update lab-p1 --rule-name github_readonly \
  --add-endpoint api.github.com:443:read-only:rest:enforce --wait
# ✓ Policy version 3 loaded —— 表面一切正常

实际发生的:合并操作以 binaries=[] 落地,该规则的二进制列表被清空,此规则对一切程序生效为零——原本 200 的 GET 也连不上了。更迷惑的是 policy get 的输出里 binaries 键直接消失,CLI 还会提示 “rule already authorizes any binary”——与运行时的全拒行为相反,且整个清空过程零警告。恢复方法:--remove-rule 删掉整条重建,或 policy set 整体替换。截至 v0.1.5 这个行为原样如此——在它改变之前,每次 policy update 都带上完整的 --binary 列表是唯一稳妥习惯。

凭证:占位符进沙箱,真实 key 永不落地

第四节埋了个伏笔:为什么 supervisor 能看到 method 和 path?因为所有流量都在那里终结 TLS。这一节会看到这个设计的另一半收益——注入凭证也发生在同一位置,于是沙箱里的 Agent 自始至终见不到真实 key。

fake-IP 请求流转与凭证注入:真实 IP 与真实 key 都不进沙箱

provider:把“哪个 key 能去哪”声明成配置

以本地 OpenAI 兼容推理服务为例(LM Studio、Ollama、vLLM 等任何监听宿主机端口的服务同理)。先写一个 provider profile,声明凭证形态、端点和允许的二进制:

id: local-llm
display_name: Local LLM
category: inference
credentials:
  - name: api_key
    env_vars: [LLM_API_KEY]
    required: true
    auth_style: bearer
    header_name: authorization
endpoints:
  - host: host.openshell.internal
    port: 8080
    protocol: rest
    access: read-write
    enforcement: enforce
binaries: [/usr/bin/curl]

注意 endpoint 里的 host.openshell.internal——这是 OpenShell 的保留主机名,专门用于沙箱访问 gateway 所在宿主机的服务。沙箱自己没有宿主的 loopback,出站策略也永远不放行裸 loopback 地址,这个域名是唯一的门(supervisor 代为连接宿主端口)。

导入 profile、创建 provider、带着它建沙箱:

openshell provider profile import -f local-llm.yaml --global
openshell provider create --name local-llm --type local-llm \
  --credential LLM_API_KEY=<your-key>
openshell sandbox create --name lab-p2 --from lab-curl:24.04 --provider local-llm

沙箱里的 env 长什么样

openshell sandbox exec -n lab-p2 --no-login-shell -- sh -c 'env | grep LLM'
# LLM_API_KEY=openshell:resolve:env:v10698058656670526006_LLM_API_KEY

一个不透明句柄,不是 key。Agent 拿着它发请求:

curl -sS -H "Authorization: Bearer $LLM_API_KEY" \
  http://host.openshell.internal:8080/v1/chat/completions ...
# 200,模型真实输出

请求成功本身就是注入发生的证明:OpenShell 对未解析的占位符一律 fail closed 不转发——如果 supervisor 没在转发前把句柄换成真实 key,这个请求根本出不去。真实 key 的生命周期到 supervisor 为止。

两道独立的边界

provider 附着还带来一个容易被忽略的事实:--full 策略里多了一条 _provider_local-llm 规则(--base 里没有)——provider 会自动追加网络规则,所以日常核对生效策略要看 --full,--base 只是你的手写部分。

而网络允许和凭证可用是两道独立的闸。实测:给沙箱另加一条允许 api.github.com 的规则(网络层面放行),然后带着同一个占位符访问 GitHub:

curl -sS -H "Authorization: Bearer $LLM_API_KEY" https://api.github.com/zen
# {"error":"credential_endpoint_mismatch", ...}   HTTP 403

key 能去哪,由 provider profile 说了算,跟网络规则无关——即使你把全网都打开,这个 key 也只对绑定的端点生效。日志里会记一条 HIGH 级别的安全事件(openshell.provider_credential.endpoint_mismatch),错误体和日志都不含 key 或占位符。

收回:轮换与 detach

轮换凭证(openshell provider update local-llm --credential LLM_API_KEY=<new-key> --wait)后,新进程拿到的句柄值直接更换,旧句柄随之作废。detach 更彻底,约 5 秒内完成三重回收:新进程的 env 里变量消失、host.openshell.internal 的 DNS 应答直接撤回(curl 报 Could not resolve host——授权没了,连名字都不解析了)、--full 里的 provider 规则消失。这 5 秒的传播窗口内旧请求仍按旧策略放行——做自动化验证时记得留出这个窗口,别在窗口内下结论。

跑一个真的:OpenCode 任务全链路

前几节的探针都是手动 curl,这次放一个真 Agent 进去。官方示例镜像是 ghcr.io/anomalyco/opencode:latest——先泼两盆冷水:它极简到没有 bash、python3、git、curl,只有 sh(Agent 跑测试和 git 提交都做不了,这两步只能回宿主机做);镜像内的 OpenCode 是 1.19.4,而上游 2.0 已发布——跟随本文时留意版本。

另一个版本坑省你半小时:1.19.4 不读项目目录的 .opencode.json,只认 ~/.config/opencode/opencode.json。把 provider 配置(第五节的 YAML 转 JSON,baseURL 指向 http://host.openshell.internal:8080/v1,apiKey 用 {env:LLM_API_KEY} 插值)放进 HOME 的全局路径,再把 HOME 指到沙箱内可写目录。

还要把 /usr/local/bin/opencode 加进 provider profile 的 binaries(openshell profile update local-llm --global -f local-llm.yaml),否则 opencode 进程够不到模型端点。

首跑撞墙,与两次人审

openshell sandbox create --name lab-p3 \
  --from ghcr.io/anomalyco/opencode:latest --provider local-llm
openshell sandbox upload lab-p3 ./fixture
openshell sandbox exec -n lab-p3 --no-login-shell -- sh -c \
  'cd /sandbox/fixture && HOME=/sandbox/fixture/.home \
   /usr/local/bin/opencode run -m local-llm/<model> "Read TASK.md and do it"'
# 1.8 秒,Error: Unexpected server error

看 openshell logs lab-p3:opencode 启动时要连 registry.npmjs.org(拉 provider 的 npm 包)和 models.opencode.ai(模型目录),全被策略拒了。这正是 OpenShell 的日常节奏:Agent 撞墙,advisor 把拒绝聚成草案,等人批。草案已经过 prover 预检:

Rule: allow_registry_npmjs_org_443
Binary: /usr/local/bin/opencode     ← 程序限定正确
Endpoints: registry.npmjs.org:443 [L4]
Prover: prover: no new findings
openshell rule get lab-p3 --status pending     # 看草案
openshell rule approve lab-p3 --chunk-id <id>  # 批 npmjs
# 重跑 → 仍失败 → 批 models.opencode.ai

两次审批、修复配置路径之后,冒烟一句 “say OK” 6.4 秒往返——整条链路是:opencode 拿占位符 → supervisor 注入真实 key → 宿主服务返回模型输出,第五节的机制在真实 Agent 上原样成立。

一个自动化要注意的细节:rule approve 没有 --wait,CLI 返回时沙箱还没换版(实测传播约 4 秒),批准后立即重试会得到假阴性。

43.5 秒的任务

正式任务:读 fixture、给 calc.py 加 multiply()、加测试、尝试跑测试。43.5 秒完成——代码正确插入,而且 Agent 主动发现沙箱里没有 python3,如实报告“无法运行测试”而不是编造结果。回收到宿主机跑测试 4/4 通过,git 提交。这一步本来该用 openshell sandbox download,但撞上了第三个坑。

第三个坑:Alpine 镜像上 download 必挂

OpenCode 官方镜像是 Alpine 底(busybox)。在这类镜像上 sandbox download 稳定失败:failed to resolve sandbox source path ... ssh probe exited with status 1——upload 和 exec 都正常,只有 download 挂。

根因在 CLI 的探针命令:它经 SSH 在沙箱里跑 pwd -P && realpath -e -- <path>,而 realpath -e 是 GNU coreutils 的旗标,busybox 的 realpath 不认识,把 -e 当文件名,退出码 1。修法一行:镜像里加 apk add coreutils,或临时用 exec cat 把文件取回来。这个坑连同根因分析我已提交到 NVIDIA/OpenShell#4909,跟随本文时可以先查一下状态。

生命周期:哪些操作稳,哪些操作是雷

沙箱不是一次性的,日常运维里对它的操作分两类——一类稳得可以写进脚本,一类是全量破坏。

第四个坑:gateway 重启,全体 Error

升级或改配置后 brew services restart openshell 是最自然的动作。重启本身只要两秒,但所有沙箱会集体进入 Error 相位:

Ready: False (StartFailed) - Failed to start sandbox during gateway startup:
Docker supervisor exited before becoming ready

workload 容器的日志给出了死因:sandbox boundary received SIGTERM before supervisor confirmation。机制上,OpenShell 的设计是只有同一个 supervisor 进程能恢复冻结的 workload——gateway 重启意味着旧 supervisor 必然消失,旧 workload 不可续命,被 SIGTERM 后由新 supervisor 接手重建;这条恢复路径一旦失败就停在 Error。更意外的是 --restart-policy always 也救不了它:实测 always 策略的沙箱同样 Error,且 Restart count 保持为 0——重启策略根本不覆盖 gateway 重启这条路径。

恢复方法是逐个 stop 再 start(实测单个 6.5 秒):

openshell sandbox stop lab-p3 && openshell sandbox start lab-p3

值得肯定的是恢复的完整性:工作区文件、全部策略版本、provider 注入链路三层都在,Agent 会话是唯一损失的的东西。这个问题连同 always 不生效的证据已提交到 NVIDIA/OpenShell#4910。升级 gateway 之前,把“逐个恢复”计入停机成本。

好消息:stop/start 和睡眠唤醒都稳

对照着说三个稳的:沙箱自己的 stop/start 循环,状态三层完整;Mac 合盖睡眠一晚(实测约 21 小时,含多次睡眠唤醒),六个 Ready 沙箱全部存活,醒来后模型调用照常;策略热更新(第四、五节的 policy update/policy set)全程不需要重启任何东西。

日常使用的实用结论:把 gateway 当成“尽量不动”的控制面——改沙箱配置用热更新,重启 gateway 前先想好哪些沙箱值得恢复。

结论:什么任务值得放进 OpenShell

第一节承诺验证三件套,现在收账:

  • 程序 × 端点 × 方法策略:成立。第四节矩阵里三种拒绝形态互可区分,连“端点授权了但程序没授权”这种细粒度都真的在拦;
  • 凭证占位符:成立。真实 key 到 supervisor 为止,占位符出了绑定端点就是 403,detach 连 DNS 应答一起收回;
  • 形式化检查:成立且诚实。advisor 的每条草案先过 prover 预检,遇到 GraphQL 这类建模不了的场景,它返回“检查不了”而不是假装通过——宁可拒绝判定,不冒充安全,这个姿态比能力本身更值得信任。

安全模型全部兑现。但要不要把它当成日常主力,是另一个问题——我的答案是把它定位成高风险任务的可选层,和已有的 Docker Sandboxes 互补而非替代:

任务类型放哪理由
跑不可信的依赖、第三方代码OpenShell程序级网络策略 + SSRF 黑名单是真的拦
要挂生产凭证的任务OpenShell占位符和绑定边界是唯一解
日常快速迭代、本地小修小补照旧每个新端点一次人审,快速迭代时是纯摩擦
需要沙箱内 git/测试工具链先补镜像官方 OpenCode 镜像连 python3 都没有

成本侧记住三件事:新环境首跑要过几轮审批(本文两次,属于正常水平);gateway 是“尽量不动”的控制面;镜像要自己维护——官方示例镜像落后上游一个大版本还带着坑,docker build 一份自己的是长期答案。

回到开头的能力悖论:有用的访问和危险的访问是同一批访问。OpenShell 给出的答案不是把 Agent 关进更厚的墙,而是在中间加了一层能分辨“谁在对谁说什么”的策略面——它终结 TLS 看得清方法和路径,也正因为如此才敢把真实凭证握在自己手里。边界能不能划住,从来不取决于墙多厚,取决于有没有人站在门口看清每一个请求。这条看得见的边界,就是 OpenShell 值得装的理由。

乱世浮生微信公众号二维码

(转载本站文章请注明作者和出处乱世浮生,请勿用于任何商业用途)