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

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 模式)只允许写三个地方:

bash 复制代码
工作区目录  +  /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 拿到拒绝结果,只能申请提权------弹窗就是这么来的。

同一沙箱里实测:

go 复制代码
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 程序:

bash 复制代码
go build ./...# 你想放行的 go build ./... && rm -rf ~/Documents# 危险,但前缀一模一样

所以没有做前缀通配。只提供两种模式:

模式 判定 安全性 实用性
off(默认) 一律不放行 --- ---
exact 逐字符相同 最高 低
safe-args 按段校验(见下) 中高 较高

safe-args 的核心是拆分检测:把命令按 &&、||、;、| 拆开,每一段都得在规则里,整条才放行。

go 复制代码
规则:go build, go test  go build && go test          → ✅ 放行(两段都命中) go build && rm -rf /         → ❌ 拒绝(第二段未命中) go build | sh                → ❌ 拒绝(sh 未命中)

拒绝时会告诉你哪一段没过:

arduino 复制代码
分段 "rm -rf /" 不匹配任何规则(共 2 段,前 1 段已匹配)

这样能立刻判断是"漏配了规则"还是"命令本身有问题"。

规则用「输入框 + 添加按钮」维护,已添加的显示成表格行,每行右侧 × 可删除:

命令授权:三档模式 + 规则列表

图中 safe-args 高亮,下方一行实时说明当前模式的含义:

当前: safe-args ------ 命令以规则开头且不含 shell 组合构造时放行。

规则列表很长也不怕,超长会自动省略(悬停看全文)。模式切到 off 时输入框禁用。

重定向的处理

这里有个坑。

>``< 拆不开,一律拒绝------即使目标目录可写:

go 复制代码
go build > ~/.zshrc# 拆开看 go build 是放行的,但实际改写配置 go build > /dev/sda# 破坏磁盘

为什么不判断目标路径?因为重定向路径可以写成任意形式:

bash 复制代码
go build > ./out.log go build > ../out.log go build > /tmp/../etc/passwd go build > $(echo /tmp)/out.log

静态分析无法可靠判断,判错就是放行任意文件写入。

不过 2>&1 和 /dev/null 是例外,它们不碰文件系统:

perl 复制代码
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 ...),方便对照配置文件。

来源标注是这里最重要的信息:

  • 蓝色「本会话」= 该字段被会话覆盖
  • 灰色「继承」= 该字段用全局值

没有这个标注,用户会以为配置丢了。之前确实出过这个问题:弹窗只渲染会话显式覆盖的值,没覆盖时整片空白,看起来像全没了。现在每个字段都显示「当前生效值 + 来源」。

继承来的长列表默认折叠,避免把弹窗撑爆:

go 复制代码
工具预设  presets                    继承 ┌────────────────────────────────────────┐ │ go, npm, bun, pip, cargo               │ └────────────────────────────────────────┘ 展开 5 项

点开后可「收起」。会话自己覆盖的列表不折叠------那是你刚设的,需要看得见。

底部两个按钮:

按钮 作用
查看当前 在会话流里打印真实值(来自 Host,可核对界面缓存是否过期)
清除覆盖 清空本会话全部覆盖,回到全局配置

弹窗底部还有一条「仅全局」提示,说明命令授权两项不支持会话级设置------原因见下文。

斜杠命令。

bash 复制代码
/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 文件,但它在构造时有硬性检查:

csharp 复制代码
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),但真跑起来,启动日志报了这么一句:

arduino 复制代码
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 弹窗 无

现在我的配置是:

bash 复制代码
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

日常开发基本不再被打断。

八、怎么装

ruby 复制代码
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/...

相关推荐
风骏时光牛马1 小时前
生成一个AI稳定性治理及疑难问题分析标题,只需要一个标题,不需要其他的任何文本。
前端
莫西干式秃头2 小时前
用 AI + threejs 写了个浏览器里的 3D 动作游戏《武来》:砍怪、放技能、开 SU7
前端·人工智能·three.js
kyriewen2 小时前
我测了 6 种防请求竞态的写法:只有 1 种真能打
前端·javascript·程序员
孟健2 小时前
用 WorkBuddy 做个人名片网页,挂上开源设计技能后效果完全不同了
前端·人工智能·ai编程
一点一木2 小时前
🚀 2026 年 10 月 GitHub 十大热门项目排行榜 🔥
人工智能·github
IT_陈寒2 小时前
SpringBoot自动配置的坑,这次终于爬出来了
前端·人工智能·后端
OpsEye2 小时前
企业大模型压测,需要重点关注哪些指标?
javascript·ai编程
WANGXIN5673 小时前
攻克微信读书 Web 端「漏抓 40% 内容」顽疾:我开源了一个全书无损导出精排 PDF / EPUB / Markdown 工具
github
LuHang3 小时前
从天地图切换到高德地图:一次真实的底图迁移实战
前端·vue.js·cesium