一天被问几十次"允许",我统计了 517 次,然后写了个插件

0 阅读8分钟

DSH 插件 / 沙箱

一天被问几十次"允许",我统计了 517 次,然后写了个插件

翻完记录才发现,一半以上的弹窗根本不需要弹

一、从一次 go build 说起

用 DSH 写 Go 代码的人大概都经历过这个循环:

  1. 让 AI 帮我改代码
  2. 它跑 go build ./... 验证
  3. 弹窗:需要完全访问权限
  4. 点允许
  5. 它跑 go test ./...
  6. 又弹窗
  7. 点允许
  8. ……

一天下来,点几十次"允许",手指都麻了。

更烦的是,我不敢把权限改成 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~/.configCLI 配置目录,最常见可读写所有 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>&1tail`弹窗无
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 其实无害,才改成精确区分。安全设计不是越严越好,严得不准确,最后挡的是自己。

· · ·

插件地址:github.com/kennethfan/…