DSH 插件 / 沙箱
一天被问几十次"允许",我统计了 517 次,然后写了个插件
翻完记录才发现,一半以上的弹窗根本不需要弹
一、从一次 go build 说起
用 DSH 写 Go 代码的人大概都经历过这个循环:
- 让 AI 帮我改代码
- 它跑
go build ./...验证 - 弹窗:需要完全访问权限
- 点允许
- 它跑
go test ./... - 又弹窗
- 点允许
- ……
一天下来,点几十次"允许",手指都麻了。
更烦的是,我不敢把权限改成 danger-full-access。那等于让 AI 拥有整个文件系统的写权限——为了省几次点击,把家门钥匙交出去,不划算。
那能不能只解决"弹窗"这件事,而不放宽整体权限?
可以。我把自己 517 次历史审批记录统计了一遍,弹窗的构成是这样的:
| 原因 | 占比 |
| Go 等构建缓存写不进去 | 28% |
写工作区外的用户配置目录(~/.config 等) | 27% |
gh 读钥匙串 | 19% |
写 ~/.dsh 的 profile 配置 | 11% |
| 其余(进程管理、删除清理等) | 15% |
前两类占了 55%,而且本质是同一个:不是真的要越权,是沙箱边界画得太窄了。
二、沙箱只让写三个地方
DSH 的文件沙箱(workspace-write 模式)只允许写三个地方:
工作区目录 + /tmp + 用户临时目录($TMPDIR)
而构建工具默认把缓存写到工作区之外:
| 工具 | 默认位置 | 沙箱内 |
Go 编译缓存 GOCACHE | ~/Library/Caches/go-build | 拒绝写 |
Go 模块缓存 GOMODCACHE | ~/gvm/.../pkg/mod | 读 ✅ / 写 ❌ |
npm NPM_CONFIG_CACHE | ~/.npm | 拒绝写 |
Cargo CARGO_HOME | ~/.cargo | 拒绝写 |
于是 go build 一跑,内核就拒绝(macOS 上 Seatbelt 报 Operation not permitted)。DSH 拿到拒绝结果,只能申请提权——弹窗就是这么来的。
同一沙箱里实测:
GOCACHE=~/Library/Caches/go-build go build ./...# exit 1,operation not permitted GOCACHE=$TMPDIR/dsh/go-build go build ./...# exit 0 ✅
问题清楚了:不是权限不够,是路径不对。
三、三个模块,各解一段
我写了 dsh-sandbox-tune,从三个层面处理。
模块一:缓存处理
两种模式:
| 模式 | 做法 | 与终端共用缓存 | 后端要求 |
redirect(默认) | 用环境变量把纯缓存指到 $TMPDIR/dsh | 两套独立 | 任何后端 |
in-place | 路径不变,把真实缓存目录授权为可写 | 完全共用 | Seatbelt / bwrap |
默认用 redirect,因为它只依赖环境变量,任何平台都能用。
但这里有个前提得先搞清——缓存分两类:
| 类型 | 例子 | 丢了会怎样 | 能重定向吗 |
| 纯缓存 | GOCACHE、NPM_CONFIG_CACHE、PIP_CACHE_DIR | 只是变慢 | 可以 |
| 依赖仓库 | GOMODCACHE、CARGO_HOME | 编译直接失败 | 绝对不行 |
把 GOMODCACHE 指到空目录,go build 会立刻报"找不到模块",比弹窗更糟。
所以 redirect 模式只动纯缓存;依赖仓库只在 in-place 模式下被授权。
这也是为什么 in-place 要用 go env GOCACHE 去探测真实路径:本机用 gvm 管理 Go,GOMODCACHE 在 ~/.gvm/pkgsets/.../pkg/mod,而静态默认值会猜 ~/go/pkg/mod——猜错就等于授权了一个不存在的目录,白忙一场。
界面上是这样(设置 → 沙箱调优):
执行器卡片:模式与缓存根目录
标题写着「缓存根目录(仅 redirect 模式)」。选 in-place 后这一项就不生效了,因为该模式路径保持不变。下方有一行实时说明当前模式的含义:
当前: in-place —— 缓存路径保持原样,由 provider 授权。后端不支持时自动回退为 redirect。
模块二:可写目录授权
针对占 27% 的"写工作区外配置目录"问题。
提供四个预设,默认全部关闭:
| 预设 | 目录 | 收益 | 风险 |
user-config | ~/.config | CLI 配置目录,最常见 | 可读写所有 CLI 配置 |
user-cache | ~/.cache | 通用缓存 | 较低,缓存可被篡改 |
user-data | ~/.local/share | 应用数据 | 可修改应用数据 |
user-state | ~/.local/state | 运行状态与日志 | 较低 |
每个都标了风险,让用户自己权衡。
有个细节:user-cache 我刻意没有用 macOS 的 ~/Library/Caches。那是系统级缓存,本机实测 5.9GB(含 Docker、AndroidStudio、CloudKit),远超"工具缓存"的范围。只用 XDG 风格的 ~/.cache。
界面把收益和风险并排写出来,勾选前能看清代价:
provider 卡片:用户级目录授权,每项标注风险
三个开关各管一段:
- 启用 — 关掉后完全不改沙箱边界
- 自动探测路径 — 跑
go env/npm config get cache等只读命令拿真实路径 - 探测哪些工具 — 点选要探测的工具(go 探
GOCACHE与GOMODCACHE,cargo 探CARGO_HOME)
模块三:命令授权(默认关闭)
有些弹窗靠放宽路径解决不了。这个模块按规则自动批准沙箱升权。
但它放行的是 danger-full-access——整条命令不受文件沙箱限制。而 bash 是任意 shell 程序:
go build ./...# 你想放行的 go build ./... && rm -rf ~/Documents# 危险,但前缀一模一样
所以没有做前缀通配。只提供两种模式:
| 模式 | 判定 | 安全性 | 实用性 |
off(默认) | 一律不放行 | — | — |
exact | 逐字符相同 | 最高 | 低 |
safe-args | 按段校验(见下) | 中高 | 较高 |
safe-args 的核心是拆分检测:把命令按 &&、||、;、| 拆开,每一段都得在规则里,整条才放行。
规则:go build, go test go build && go test → ✅ 放行(两段都命中) go build && rm -rf / → ❌ 拒绝(第二段未命中) go build | sh → ❌ 拒绝(sh 未命中)
拒绝时会告诉你哪一段没过:
分段 "rm -rf /" 不匹配任何规则(共 2 段,前 1 段已匹配)
这样能立刻判断是"漏配了规则"还是"命令本身有问题"。
规则用「输入框 + 添加按钮」维护,已添加的显示成表格行,每行右侧 × 可删除:
命令授权:三档模式 + 规则列表
图中 safe-args 高亮,下方一行实时说明当前模式的含义:
当前: safe-args —— 命令以规则开头且不含 shell 组合构造时放行。
规则列表很长也不怕,超长会自动省略(悬停看全文)。模式切到 off 时输入框禁用。
重定向的处理
这里有个坑。
>``< 拆不开,一律拒绝——即使目标目录可写:
go build > ~/.zshrc# 拆开看 go build 是放行的,但实际改写配置 go build > /dev/sda# 破坏磁盘
为什么不判断目标路径?因为重定向路径可以写成任意形式:
go build > ./out.log go build > ../out.log go build > /tmp/../etc/passwd go build > $(echo /tmp)/out.log
静态分析无法可靠判断,判错就是放行任意文件写入。
不过 2>&1 和 /dev/null 是例外,它们不碰文件系统:
git push 2>&1 | tail -3 → ✅ 放行(fd 复制,只改输出流向) git log 2>/dev/null | head → ✅ 放行(写入黑洞) git push > out.txt → ❌ 拒绝(写真实文件) git push 2>&1 > out.txt → ❌ 拒绝(前半安全、后半危险)
实现上有个坑:得先剥离安全的重定向,再看剩下的部分。否则 2>&1 > out.txt 里的 > 会被笼统判危险(结论碰巧对),但 2>&1 单独出现时也会被误拒。
四、会话级配置
后来加的,为的是一个具体场景:
做实验时想放宽配置,动生产代码时想收紧。
优先级是会话覆盖 > 全局配置 > 内置默认,按字段独立判断。
两种入口。
会话头部按钮。 会话标题右侧有个「沙箱调优」按钮,鼠标悬停会提示「本会话的沙箱调优设置」:
会话头部的沙箱调优按钮
点开是 Modal,8 个可改字段,每个标注来源:
会话级设置弹窗:8 个字段,标注继承还是本会话
两列布局,字段名旁标了对应的配置键(mode / enabled / cacheRoot …),方便对照配置文件。
来源标注是这里最重要的信息:
- 蓝色「本会话」= 该字段被会话覆盖
- 灰色「继承」= 该字段用全局值
没有这个标注,用户会以为配置丢了。之前确实出过这个问题:弹窗只渲染会话显式覆盖的值,没覆盖时整片空白,看起来像全没了。现在每个字段都显示「当前生效值 + 来源」。
继承来的长列表默认折叠,避免把弹窗撑爆:
工具预设 presets 继承 ┌────────────────────────────────────────┐ │ go, npm, bun, pip, cargo │ └────────────────────────────────────────┘ 展开 5 项
点开后可「收起」。会话自己覆盖的列表不折叠——那是你刚设的,需要看得见。
底部两个按钮:
| 按钮 | 作用 |
| 查看当前 | 在会话流里打印真实值(来自 Host,可核对界面缓存是否过期) |
| 清除覆盖 | 清空本会话全部覆盖,回到全局配置 |
弹窗底部还有一条「仅全局」提示,说明命令授权两项不支持会话级设置——原因见下文。
斜杠命令。
/sandbox-tune 查看本会话生效配置 /sandbox-tune set mode in-place 改模式 /sandbox-tune set presets go,npm 改预设 /sandbox-tune reset 清除覆盖
一个重要的安全设计
autoApproveMode 和 autoApproveCommands 只能全局设置,会话级会直接报错。
理由:它们放宽的是 danger-full-access。如果允许按会话设置、并随 fork 或恢复历史会话带过去,你可能几天后完全不记得那个会话开着宽松规则。
而且是写入时就拒绝,不是"存进去但不生效"。后者会让调用方以为设置成功了,实际没生效,这类静默失败最难排查。
数组字段:叠加还是替换
这是个我改过一次的设计。
原来的做法是整体替换:全局 sandboxRoots 是 [/A],会话加 /B,最终只有 [/B]——/A 静默失效,界面看不出提示。
现在按类型区分:
| 字段 | 会话级行为 |
presets / userDirs / sandboxRoots | 叠加(合并去重,全局在前) |
env | 按 key 合并(同名 key 会话优先) |
mode / enabled / cacheRoot / autoDetect | 替换(标量无叠加概念) |
所以现在是 [/A, /B],两个都可写。
代价是会话没法"移除"某个全局项,想移除只能改全局配置。这个取舍写进文档了。
五、让它热更新,踩了个大坑
改 cordis.patch.yml 后不需要重启 DSH。
DSH 自带的 dsh-hmr 其实会监听 profile 的 patch 文件,但它在构造时有硬性检查:
if (!this.ctx.loader.internal) throw new Error("--expose-internals is required for HMR service")
而 dsh web 默认不带 --expose-internals,这个参数也不能经 NODE_OPTIONS 传入(Node 会拒绝)。实测即使手动用 node --expose-internals bin.js 启动,配置改动仍不触发重载。
所以改成插件自己监听:
-
fs.watch监听父目录而非文件——编辑器原子保存会 unlink 再 create,盯文件会丢事件
-
内容去重 + 300ms 防抖
-
失败保留旧配置:YAML 语法错或字段值非法时告警,不影响现有配置
配置写错了,旧的照常生效,沙箱不会因为一次手误就失守。
这里踩了个大坑:最初用 import { parse } from 'yaml' 解析配置。单元测试全过、从插件目录 import 也正常(有 node_modules symlink),但真跑起来,启动日志报了这么一句:
sandbox-tune-bash: failed to import: Cannot find package 'yaml'
因为 link: 安装的插件,Node 解析模块时不会回溯到 profile 的 node_modules。两个插件都彻底没加载,bash 工具直接消失。
最后改成零依赖方案:按缩进扫描 YAML,自己解析标量(字符串/布尔/数字/行内数组/行内对象)。config 段的取值形态有限,够用。
六、几处反复权衡的地方
1. 为什么不做前缀通配
直觉上"放行 go build 开头的命令"很方便。但 go build && rm -rf ~/x 也以它开头。
规则只能看开头,无法验证后续内容。所以要么精确匹配,要么按段校验,没有中间地带。
2. 为什么 in-place 要自动回退
in-place 依赖后端能追加写白名单:
| 后端 | 支持 | 原因 |
| macOS Seatbelt | ✅ | 追加 (allow file-write* (subpath ...)) |
| Linux bwrap | ✅ | 追加 --bind |
| Linux Landlock | ❌ | 授权由 launcher 内部拼装 |
| Windows ACL | ❌ | 授权是 SID,无法注入路径 |
不支持时自动回退 redirect,而不是停在"既不授权也不重定向"的中间状态——那种状态下缓存仍写不进去,构建直接失败,比弹窗更糟。
3. 为什么会话覆盖不持久化
存在进程内 Map,重启即回到全局配置。
考虑这个场景:你三天前在某个会话里为了调试,把命令授权设得很宽松。如果覆盖能持久化,重启后它还会悄悄生效,而你早就忘了。
现在的行为是:重启即回到全局。忘了的宽松设置不会自己复活。
4. 为什么界面显示值来自缓存
会话覆盖存在 Host 进程内存里,前端没有任何接口能读回来:
-
command()只返回
{matched},结果以 flow 节点渲染 -
settings.describe返回的是全局值
而组件切换会话时会重新挂载,状态重置为空。所以用 sessionStorage 按 sessionId 缓存。
这是界面缓存,不是真实状态同步。局限已经写进弹窗说明:
| 场景 | 界面显示 | 实际生效 |
| 同浏览器切会话 | ✅ 正确 | ✅ 正确 |
| 换设备/清缓存 | ❌ 空 | ✅ 仍生效 |
| 重启 DSH | ⚠️ 过期值 | ❌ 已失效 |
以「查看当前」打印的结果为准,那个值来自 Host,是真实的。
七、装上之后
| 场景 | 之前 | 之后 | |
go build ./... | 弹窗 | 无 | |
go test ./... | 弹窗 | 无 | |
npm install | 弹窗 | 无 | |
| `git push 2>&1 | tail` | 弹窗 | 无 |
cd /repo && git commit | 弹窗 | 无 |
现在我的配置是:
mode: in-place presets: [go, npm, bun, pip, cargo] autoApproveMode: safe-args autoApproveCommands: - go build / go vet / go test / go fmt - gh / git / curl / jq - cd / ls / grep / head / tail / cat / echo - lsof / ps / wc / npm / pnpm / bun / pip
日常开发基本不再被打断。
八、怎么装
dsh plugin --profile web add https://github.com/kennethfan/dsh-sandbox-tune/releases/latest/download/dsh-sandbox-tune.tgz
装完重启 DSH 即可。
项目已收录进 awesome-dsh-plugin(dev 分类)。
· · ·
最后
| 项 | 数量 |
| 代码行数 | 约 3700 行 |
| 单元测试 | 171 项 |
| 集成测试 | 10 项 |
| 支持的平台 | macOS / Linux / Windows |
写这个插件,我最大的收获是搞清了沙箱边界该画在哪儿,省点击只是顺带的:
- 缓存写不进去 → 不是权限问题,是路径问题
- 依赖仓库不能重定向 → 会造成比弹窗更严重的失败
- 放宽审批必须显式 → 不能靠"上次设置过"悄悄生效
这些判断没法拍脑袋,都是一条条实测出来的。比如"重定向一律拒绝"这条,我后来发现 2>&1 其实无害,才改成精确区分。安全设计不是越严越好,严得不准确,最后挡的是自己。
· · ·