dsh-write-protect
防止模型改写工作区里的指定路径. 同时支持放开某些外部目录的写入而不必放开沙箱.
- Stars
- 4
- Language
- TypeScript
- Created
- Sep 7, 2026
- Updated
- Oct 19, 2026
Introduction
dsh-write-protect
给 DSH 沙箱补上工作区里某一段路径的只读保护, 典型用途是不让模型改 .git. 也可以在 workspace-write 下声明工作区外的额外可写根, 让 bash 与 write / edit 写到相邻目录, 而不必切到 danger-full-access. 还能声明读拒绝路径: 命中的位置 read / grep / glob 这些工具与沙箱命令都读不到, 需要时由模型申请本会话授权, 同一条授权同时放开那条路径的读与写.
工作区根还能放一份只读规则文件 (默认 .readonly), 与插件页配置里的保护路径同语义; 任务要反复写同一片受保护区域时, 模型也可以申请本会话的可写授权 (见下文两节). 会话区还有一个 "写入权限" tab: 在那里可以手动加临时可写根, 也可以把某条授权撤回, 撤回会通知模型.
write / edit 工具在所有平台都会挡住保护路径, 并放行额外可写根. bash 等命令在 Linux / macOS 上同样生效; Windows 上 bash / pwsh 既挡不住 .git, 也拿不到额外可写根. 读取默认不受影响, 只有配了读拒绝路径才会被挡 (见 "读拒绝路径" 一节).
官方沙箱只有 "整个工作区可写" 和 "全只读" 两档, 管不到工作区内部的某一段, 也不能把工作区外的个别目录并进 allow-list; Codex 一类实现默认会保护 .git, 本插件补这一块.
安装
Web 端装进 web profile:
dsh plugin --profile web add azazo1/dsh-write-protect
固定版本:
dsh plugin --profile web add azazo1/dsh-write-protect#v0.3.0
GitHub Release 同时挂不带版本号的预构建包, 安装时跳过 allowBuilds:
dsh plugin --profile web add https://github.com/azazo1/dsh-write-protect/releases/latest/download/dsh-write-protect.tgz
桌面端装进 desktop profile. 它由 Electron 应用独占管理, dsh plugin 会拒绝 --profile desktop, 所以要用应用内的插件管理器: 在插件页的安装入口填上面命令里对应的包名或 tarball 地址. 装上后重启应用, 窗口刷新一次.
安装后会接管沙箱策略, write / edit 围栏与读拒绝的工具层围栏, Linux / macOS 上还会接管命令沙箱. 改配置即时生效, 不用重启 dsh web. web 与 desktop 两个 profile 跑的是同一套 Web 应用, 桌面端只是多起一个 Host 子进程并给 <html> 打上平台标记, 所以同一份包在两边通用, 不需要分别构建.
引擎版本线要求 @deepseek-ai/dsh-* 不低于 0.2.0-rc.1, 且仍在 0.2.x 上 (peerDependencies 与 devDependencies 都写作 >=0.2.0-rc.1 <0.3.0). 已在 0.2.0-rc.2 上日常验证. 官方 SandboxProvider.confine() 自 0.1.6-alpha.1 起改为异步 (Promise<ConfinedArgv> 加 signal 参数), 插件配置自 0.1.7 起走 volatile Config (插件页的配置卡片经 configForms 读写); 本插件的覆写与配置面都按这条线走. 仍在 0.1.7 引擎线上的部署请继续用 v0.2.0, 更早的部署请继续用 v0.1.1.
配置
保护路径, 额外可写根, 规则文件名, 各项上限与三个开关的默认值都定义在 src/constants.ts 的 DEFAULT_*, patch 的 policy 行与插件页保存的值都由它们兜底; 需要部署级覆盖时在 patch 行显式给出字段 (整行替换, mode / workspaceRoot 必须带上):
- id: dsh-write-protect-policy
name: dsh-write-protect
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
# 部署级覆盖示例.
# readOnlyPaths: ['.git', '//etc/pki']
# writablePaths: ['../shared-scratch', '//tmp/dsh-extra']
# readDenyPaths: ['secret', '//etc/pki']
# hardenBroker: false
# hardenWsl: false
# readonlyFileName: '.readonly' # 置空即关闭工作区规则文件识别
# maxReadOnlyEntries: 200
# maxGrants: 8
# allowWritableRequests: true # 置 false 即不许模型申请可写路径
# watchProtectedPaths: true # 置 false 即不监听工作区变化, 只按时间兜底
# watchTtlMinMs: 2000
# watchTtlMaxMs: 30000
readOnlyPaths 的每一项是一行 gitignore 语义的模式, 数组逐行合并为生效文本:
- 不含
/的条目 (如.git,vendor) 在工作区内任意层级匹配, 覆盖嵌套仓库等场景. - 以
/开头或含中间/的条目锚定到工作区根 (如/.git,dist/a.txt); 字面条目即使尚不存在也保留保护, 例如git init之前的/.git. //开头的条目是文件系统绝对路径 (如//etc/pki), 这是本插件额外支持的写法, gitignore 没有这种形态.- 行首
~/~/...展开为当前用户家目录, 行内$NAME/${NAME}展开为环境变量, 与 "额外可写根" 同一套规则: 展开出的绝对路径写成//形态, 未设置或空值的变量整行丢弃并告警,\$保留字面$,~user不支持. 因此~/.ssh与$HOME/.ssh都等价于//<家目录>/.ssh. - 尾部
/表示只匹配目录 (如build/). - 通配:
*匹配单段内任意字符,?匹配单字符,[...]字符类 (含[:alpha:]等 POSIX 类),**独立成段时递归 (如a/**/b);\转义下一字符 (\#,\!, 尾部空格用\保留). !开头剔除匹配项, 按 gitignore 的 last-match-wins 顺序解释; 受保护目录内部无法通过取反重新放行后代.- write / edit 按模式逐路径判定, 与路径是否存在无关; bash / pwsh 侧的保护路径是枚举出来的, 只覆盖展开时刻已存在的路径. 解析时会解开符号链接并去重.
- 置为空列表
[]即停用保护 (插件仍在, 只是不再多挡任何路径).
工作区只读规则文件 (.readonly) 的条目不参与 ~ 与环境变量展开: 那份文件是工作区自带的, 只认工作区内的相对条目 (绝对条目与 .. 越界条目会被拒绝并告警). 需要家目录或环境变量的写法请放在插件页文本或 patch 配置里.
writablePaths 的每一项是一行字面路径, 不是 gitignore glob:
- 行首
~或~/...展开为当前用户家目录;~other不支持. $NAME与${NAME}展开为环境变量; 未设置或空值的变量整行丢弃并告警.\$保留字面$.- 宿主绝对路径 (
/tmp/extra) 或//前缀 (//tmp/extra) 按文件系统解析. - 其余相对当前会话工作区, 含
..(如../sibling-project). - 工作区内的路径本来就可写, 展开时忽略并告警; 文件系统根 (
/或盘符根) 拒绝, 避免把只读宿主根整棵翻成可写. - 不支持通配与
!取反. 不存在的路径仍保留词法形态: write / edit 与 Seatbelt 可按前缀放行, bwrap / Landlock 在叠加时跳过并告警. - 只在
workspace-write下并进 allow-list, 不打穿read-only. 保护路径优先: 额外根内部仍可被保护. - 置为空列表即不额外放行.
readDenyPaths 的每一项与 readOnlyPaths 同一套语义 (gitignore 模式, 含 // 绝对条目, ~ 与环境变量展开, ! 取反), 但作用在读上: 命中的路径 read / read_image / grep / glob / str_replace_editor(仅 view) 会被拒绝, 沙箱命令同样读不到. 默认空列表, 即默认不禁止任何读取.
- 会话授权同时解锁读:
request_writable_path批准过的路径连同其后代既可写也可读, 工具侧与命令侧一致. read-only下同样生效 (read-only限制的是写, 读本来是全开的);danger-full-access下不介入.- 它不读工作区的只读规则文件:
.readonly那份文件只参与写保护. - 用户在插件页保存过读拒绝文本后, 这个数组不再生效 (与另外两份文本同一套覆盖规则).
hardenBroker 是 macOS broker 逃逸加固的部署 base, 布尔值, 缺省 true:
- 开启时在 Seatbelt profile 末尾追加 broker 拒绝形式 (见 "保护范围").
- 关掉后命令按官方 profile 运行, 只影响 macOS, 只影响这一个加固; 保护路径与额外可写根照常.
- 用户在插件页拨动开关后该值不再生效.
hardenWsl 是 WSL 互操作逃逸加固的部署 base, 布尔值, 缺省 true:
- 开启且宿主是 WSL 时, 在 bwrap profile 上叠加 tmpfs
/mnt与/run/WSL等 (见 "保护范围"). - 关掉后命令按官方 profile 运行, 只影响 WSL, 只影响这一个加固; 保护路径与额外可写根照常.
- 非 WSL 宿主上即使开启也不改 argv.
- 用户在插件页拨动开关后该值不再生效.
readonlyFileName / maxReadOnlyEntries / maxGrants / allowWritableRequests / watchProtectedPaths / watchTtlMinMs / watchTtlMaxMs 同理, 是只读规则文件, 可写申请与命令侧刷新的部署 base (见后两节), 用户保存过对应字段后该值不再生效.
源码分三块, 边界是"有没有文件系统依赖":
| 文件 | 职责 | 依赖 |
|---|---|---|
src/gitignore.ts | gitignore 语义的解析与逐路径匹配 (PatternSet.match), 规则文件校验与可写申请的保护判定共用 | 纯字符串/正则, 零运行时依赖 |
src/patterns.ts | 把模式枚举成具体路径, 供保护路径清单与命令沙箱使用 | node:fs、canonicalPath |
src/fs.ts / src/policy.ts / src/provider.ts | 三个挂载点: write/edit 围栏、沙箱策略、进程沙箱 argv 叠加 | DSH 引擎 |
src/read-fence.ts | 读拒绝的工具层围栏 (tools/pre-execute 判定与 grep / glob 结果过滤) | DSH 引擎 |
src/readonly-file.ts 负责工作区只读规则文件的读取与缓存, src/path-expand.ts 负责额外可写根的字面路径展开, src/request-writable-path.ts 是可写申请的授权表与 request_writable_path 工具, src/grants-route.ts 与 src/grant-notice.ts 分别是会话面板的 /api 路由与撤回通知, src/seatbelt.ts 与 src/wsl.ts 分别是 macOS broker 加固, Seatbelt 形式的生成与 WSL 互操作加固.
插件页配置
插件页里 dsh-write-protect 卡片上的配置表单, 从上到下是: 保护路径与额外可写根两个多行文本, 读拒绝路径, 工作区只读规则文件名与最多条目数, 单会话可写授权上限, macOS broker 逃逸加固, WSL 互操作加固, 模型申请可写路径, 监听工作区变化, 以及刷新下界与上界两个毫秒值. 草稿只留在卡片里, 点 "保存" 才写进 profile 的 patch 层并实时生效; 预览面板按当前草稿展开, 不必先保存:
# 保护路径
.git
secrets/*.pem
!secrets/example.pem
# 额外可写根
../shared-scratch
~/scratch
$HOME/scratch
/tmp/dsh-extra
# 读拒绝路径
secret
~/.ssh
~/.aws/**
!secret/public.pem
- 保护路径:
#开头是注释, 空行忽略;!排除, 按最后匹配生效; 不能在仍受保护的目录内部重新放行后代. 行首~/~/...与行内$NAME/${NAME}会展开 (展开出的绝对路径按//形态参与匹配), 未设置或空值的变量整行丢弃并出现在 "未生效" 里. 通配与锚定语义同 "配置" 一节. Windows 上的绝对条目写作//C:/Users/me/secret: gitignore 语义里\是转义符,/才是分隔符. - 额外可写根: 每行一条字面路径, 不要通配.
~/~/...为家目录,$NAME/${NAME}为环境变量; 绝对路径按文件系统解析, 相对路径 (含..) 相对当前会话工作区. Windows 上\是分隔符而不是转义符,C:\Users\me\caches与~\caches都按字面解析; 盘符相对路径 (C:caches) 的落点取决于进程当前目录, 会被拒绝并出现在 "未生效" 里. - 读拒绝路径: 语法与保护路径完全一致 (含
~与环境变量展开,!排除与//绝对条目), 但作用在读上; 留空即默认不禁止任何读取. 读拒绝允许用!在受保护区域之外单独放行某条路径, 但同样不能在仍受读拒绝的目录内部重新放行后代. - 三份文本都按当前会话的工作区根解析, 每个会话各自生效. 开关是全局的, 与工作区无关.
- 预览按钮把当前草稿交给 Host 展开, 不必先保存: 列出生效的保护路径, 读拒绝路径与额外可写根, 工作区规则文件的条目, 本会话已批准的可写授权, 以及被忽略或拒绝的行. 展开使用当前选中会话的 cwd (即被主视图持有的那条会话记录); 没有选中会话时预览直接报错, 不回退部署工作区根 —— 部署根是进程 cwd, 可能是一棵极大的树, 在那里同步展开会堵住 Host 事件循环. 这一页只列不改: 加临时可写根与撤回授权在会话区的 "写入权限" tab 上 (见下节).
mode 与 workspaceRoot 是官方 policy 行字段的复述 (patch 对整行配置做替换, 必须带上), 取值语义与 base bundle 一致.
只读规则文件
工作区根可以放一份与 "保护路径" 同语义的规则文件 (默认 .readonly, 可在插件页配置里改名或置空关闭), 逐行追加在保护路径文本之后:
# <工作区根>/.readonly
secrets/
/vendor
!vendor/public/**
**
- 只认工作区根这一份, 每个会话各读一份; 整段保护工作区写
**或/**, 根下第一层用/*;/与.匹配不到任何路径, 等于什么都不保护. - 只接受普通文件 (符号链接拒绝);
//绝对条目与越出工作区的..条目拒绝, 超限条目丢弃, 都只告警不生效. - 这份文件本身是唯一的硬保护: 在
read-only与workspace-write下任何授权都不放行它, 也没有request_writable_path可受理它, 会话面板的手动添加同样拒绝它; 要改只能换文件名或由你在 DSH 之外编辑. (整个danger-full-access模式下本插件不介入, 见 "边界与已知限制".)
读拒绝路径
读拒绝让模型与沙箱命令都拿不到某些位置的内容, 典型用途是把密钥, 凭据, 别的租户的数据挡在 agent 之外:
# 插件页 "读拒绝路径"
secret
~/.ssh
~/.aws/**
//etc/pki
!secret/public.pem
- 判定与写保护同源: 同一套 gitignore 语义的模式表, 逐路径匹配 (含前缀围栏: 命中的目录连同其后代一起挡), 不依赖任何扫盘结果,
!按最后匹配生效; 行首~与行内$NAME/${NAME}与保护路径一样会展开, 因此~/.ssh等价于//<家目录>/.ssh. - 工具侧走引擎的
tools/pre-execute钩子:read/read_image/grep/glob的路径参数与str_replace_editor的view命令命中即被拒绝, 拒绝信息里写明命中的是哪条模式, 并提示可以申请本会话授权. grep/glob的结果还要再过滤一次: 它们默认搜索整个会话工作区, 请求的 scope 没命中并不等于结果里没有命中, 因此落在读拒绝范围内的命中会被逐条丢掉; 一次调用的命中若全部被丢掉, 这条结果会变成一条说明性的错误.- 命令侧: macOS 上追加
(deny file-read* ...)(字面条目走正则, 含通配的走枚举, 与写保护同一套两条通道); Linux bwrap 用tmpfs盖目录,--ro-bind /dev/null盖文件. Windows 上只挡工具侧, 与写保护的限制一致. - 读拒绝比写保护更容易误伤 (提示词, 依赖, 构建脚本常常要读同一棵子树), 因此没有默认值, 需要时显式声明.
模型申请可写路径
提示词会引导模型: 任务要反复写同一片受保护区域时 (一个目录里的多个文件, 构建产物树, 若干次写入都依赖的工作区外路径), 调 request_writable_path 申请本会话的可写授权, 参数是 path (字面路径, 支持 ~ / $VAR / // / 相对含 ..) 与 justification (给你看的一句话理由); 单个文件照旧用 write / edit 工具, 那次被拒就算了. 需要读某个被读拒绝的位置时走同一个工具: 一条授权同时放开那条路径的读与写, 不额外引入第二个申请入口.
审批理由会写明在放开什么: 工作区外的路径批准后成为本会话的额外可写根 (bash 与 write / edit 都能写; Windows 上只到 write / edit), 工作区内被保护路径则放开那棵子树 —— write / edit 与命令沙箱 (macOS Seatbelt 的 allow, Linux bwrap 的 bind) 都认这份授权. 授权按路径生效且覆盖其下全部后代, 所以批过父目录之后再写子目录、子文件不会再次弹窗. 授权只在本会话内存里, 不写 settings, 条数上限 maxGrants (默认 8); 关掉 allowWritableRequests 后工具的任何调用都被拒, 提示词也不再引导. 保护路径与规则文件条目都在可申请范围内, 因此可以配成 "整个工作区只读 + 逐个目录批准".
批准过的授权可以在会话区撤回, 撤回之后模型会收到一条通知 (见下节).
会话写入权限面板
会话区 (与 "对话" / "轨迹" 并排) 有一个 "写入权限" tab, 它只处理本会话内存里的授权, 也就是 request_writable_path 批准过的那几条:
- 列出本会话当前的授权: 路径 + 类型 (保护旁路 / 额外可写根), 以及当前用掉了几条 (
maxGrants上限). - 每条后面一个 "撤回": 删掉这条授权, 那片路径立刻回到保护判定里 —— 后续的 write / edit 与命令沙箱都按没批过处理. 已经跑起来的命令不受影响, 命令侧的挂载与 profile 在启动时就定好了.
- 下面的输入框手动加临时可写根: 支持
~/$VAR/../ 相对会话工作区, 落点在工作区内时记作保护旁路 (放开命中它的保护条目), 在工作区外时记作额外可写根. 记录与模型申请批准后的完全同质: 同样只在本会话内存里, 同样受maxGrants限制, 不写配置文件 —— 所谓 "临时" 就是这个意思, 会话结束或重启 dsh 之后自动消失. - 当前会话是
read-only时面板会提示: 这时加进来的授权要等会话切到workspace-write或danger-full-access之后才生效.
撤回之后会往该会话投一条模型可见的消息, 说明用户撤掉的是哪条路径, 那里已经重新受保护, 需要时应当重新申请而不是硬写. 提示词里那份授权清单是状态 (撤回之后那一行自己就没了), 这条消息补的是事件 (谁撤的, 撤的是哪条), 两者不是重复: 只看清单的话, 模型可能正按旧快照继续往那里写, 撞上拒绝之后开始瞎试.
投递走 agent.inject, 也就是排进模型下一步而不唤醒空闲会话 (不会因为一次撤回就替用户烧掉一轮 token); 消息本身按 user/message 写进会话日志, 因此 dsh 重启、同一会话恢复之后模型照样看得到. 这条通知没有落盘记录, 也不做跨会话的 "撤回名单": 授权本来就是会话级的, 新建会话没有历史授权可谈, 需要长期不写的位置请写插件页配置或工作区规则文件.
面板本身是 Host 的 /api 路由 (/api/dsh-write-protect.grants), 调用者就是用户本人, 因此不经过审批弹窗; 路径解析与保护判定复用模型申请那一套, 规则文件本身同样不接受授权.
保护范围
保护路径与额外可写根会同时作用在下面几个入口:
| 入口 | 哪些系统 | 效果 |
|---|---|---|
| write / edit 工具 | 全平台 | 按保护模式逐路径判定, 命中即拒绝; workspace-write 下额外根放行; danger-full-access 下全部放行 |
| read / read_image / grep / glob / str_replace_editor(view) | 全平台 | 按读拒绝模式逐路径判定, 命中即拒绝; grep / glob 的结果再逐条过滤; 会话授权放行 |
| bash 等命令 | Linux, macOS | 内核级只读 / 额外可写; 读拒绝用 deny file-read* (macOS) 或 tmpfs / /dev/null 覆盖 (bwrap); macOS 上字面条目按正则拒绝, 与路径当前是否存在无关; Windows 做不到, 见下方限制 |
| 提示词 | 全平台 | 先告诉模型哪些不能写, 哪些不能读, 哪些额外根可写, 需要反复写或确实要读时怎么申请; Windows 上另有一段说明命令侧不受这些条目约束 |
本会话授权 (request_writable_path) | 全平台 | 工作区外路径按额外根生效; 保护旁路与读放行对写入工具, 读工具与命令沙箱 (bwrap / Seatbelt) 都生效 |
| 会话写入权限面板 | 全平台 | 列出并撤回本会话授权, 手动加临时可写根; 撤回后向会话投一条通知消息 |
| macOS broker 加固 | macOS | 堵住 open 经 launchd 把命令挪到沙箱外执行 |
| WSL 互操作加固 | WSL + bwrap | 堵住 Linux 侧 exec Windows PE 把命令挪到沙箱外执行 |
两类入口的判定方式不同, 这是有意的: write / edit 拿得到目标路径, 因此直接按 gitignore 模式判定 —— 深层嵌套, 尚未存在的匹配一样挡得住, 每条写入只做几次正则; bash 的沙箱 (mount / profile) 只能吃具体路径, 所以那一侧才需要枚举展开. 读拒绝沿用同一套判定: 读工具走引擎的 tools/pre-execute 钩子按模式逐路径判定 (那里拿得到会话, 因此能认会话授权), 命令侧则与其他写保护一样靠枚举与正则. 枚举走 fs.promises, 每次 readdir / lstat 让出事件循环; 同步的 resolve() 只注入模式原文和缓存里已有的路径, 不在会话加载时扫盘. 提示词同样只陈述模式, 不枚举绝对路径.
读拒绝没有放在 ctx.fs 那一层, 原因是读接口 (readText / streamText / listDir / stat) 的签名里既没有 policy 也没有会话, 在那一层判定既拿不到会话工作区根, 也认不了本会话授权; 而 grep / glob 的 ripgrep 是 ctx.subprocess 直接拉起的, 既不经过 ctx.fs 也不经过命令沙箱, 只有工具派发层挡得住.
macOS 上命令侧还有第二条通道: Seatbelt profile 支持按正则匹配路径, 因此字面条目会被直接翻译成 (deny file-write* (regex ...)), 不经过枚举 —— 展开当时还不存在的路径 (会话中途 git init 出来的 .git) 同样挡得住, 也不受展开缓存窗口影响. 读拒绝同样有这条通道, 生成的是 (deny file-read* (regex ...)). 含通配或转义的条目仍走枚举清单, 原因见下方限制.
命令侧清单的保鲜
bwrap 只能挂载真实存在的目录, 所以命令侧的保护清单来自一次"展开", 而展开结果会缓存. 会话中途才出现的受保护路径 (例如刚 git init 出来的 .git) 若不在清单里, 那条命令就写得进去. 为此有两层保障:
- 正则拒绝 (macOS): 字面条目不经过展开, 见上一节.
- 监听 + 自适应刷新 (全平台): 只给"正在运行 agent 的会话"的工作区根装递归监听; agent 跑完或会话销毁就摘掉, 空闲不占资源. 监听一报变化就立即在后台重算清单 (短时间内的多次事件合并成一次), 所以下一条命令直接用新清单, 不必等缓存过期.
- 时间兜底: 没有事件时, 按"上次展开耗时 × 10"兜底重算, 并夹在
watchTtlMinMs(默认 2 秒) 与watchTtlMaxMs(默认 30 秒) 之间; 监听装不上或漏事件时不会一直陈旧. 关掉watchProtectedPaths后不装监听, 只剩这一层. - 命令遇到已过期的缓存时会等这次扫描完成再执行, 因此刷新不会变成"先放行、事后补救".
这一步只影响命令侧. write / edit 围栏始终按模式原文判定, 与展开、监听、缓存都无关.
主场景是 workspace-write. read-only 下官方已挡住全部文件写入, 额外可写根不打穿; 但官方 profile 的 (allow default) 在两种模式下都一样, 所以 broker 加固不区分模式, 读拒绝在 read-only 下照常叠加 (那个模式限制的是写). read-only 下会话授权只以只读方式把路径重新露出来, 不会打穿只读. WSL 互操作同样不区分模式: Windows 进程写的是真实磁盘, read-only 的 --ro-bind / / 一样挡不住.
macOS broker 逃逸加固
官方 macOS profile 是 (version 1) (allow default) (deny file-write*) ..., mach-lookup 与 process-exec 全开. 而经 launchd 代理启动的进程不继承 Seatbelt profile, 于是沙箱内一条 open x.app 就能让启动的进程在沙箱外任意读写, deny file-write* 被整条绕开 —— read-only 同样会被打穿. 本插件在 profile 末尾追加:
(deny mach-lookup (global-name-prefix "com.apple.coreservices"))
(deny appleevent-send)
(deny mach-priv-task-port)
SBPL 按 last-match-wins 解释, 追加在末尾才能盖过 (allow default). com.apple.coreservices 是 LaunchServices 的服务名段, open / NSWorkspace 靠它把请求交给 launchd; 名称过滤器按 reverse-DNS 分段匹配, 所以只能整段拒绝, 收窄到子服务无效. appleevent-send 关掉让别的 app 代劳那条路, mach-priv-task-port 关掉注入已运行进程的 task port.
加固只做收紧, 不放宽任何位置; 常规命令 (node, git, pnpm, python, curl, tar, rsync 等) 不受影响.
插件页的 "macOS broker 逃逸加固" 开关与 patch 的 hardenBroker 控制这一个加固是否生效, 缺省开启. 关掉后 provider 原样返回官方 argv, 适合确实需要从沙箱内驱动宿主 GUI 的场景; 关掉即恢复可以被 open 打穿的状态. 保护路径与额外可写根的叠加不受这个开关影响.
WSL 互操作逃逸加固
官方 Linux bwrap 是 --ro-bind / / 加工作区 --bind, 没有 seccomp, 也不拦 execve. WSL2 会把 Linux 侧启动的 Windows PE (典型路径在 /mnt/c) 交给 Windows 宿主创建进程; 那个进程不继承 bwrap 的挂载, 可经 UNC 写回真实磁盘, 于是保护路径的 --ro-bind 与 write / edit 围栏都被绕开 —— read-only 同样会被打穿. 本插件在探测到 WSL 时, 在官方 profile 之后叠加:
--tmpfs /mnt
--tmpfs /run/WSL
--tmpfs /proc/sys/fs/binfmt_misc
--bind /dev/null /init
后挂载覆盖早挂载. /mnt 藏掉 Windows 盘与 WSLg; /run/WSL 藏掉 interop 套接字, 工作区里即便有 PE 也连不上 Windows; /init 是 WSL 的互操作翻译器. 宿主上没有的路径会跳过 (bwrap 的 --tmpfs 要求挂载点已存在), 避免整条命令被拒. 工作区本身若在 /mnt 下 (Windows 盘上的项目), tmpfs 之后会把工作区重新 bind 回去; 额外可写根若在 /mnt 下, 同样在 tmpfs 之后重新露出来.
加固只做收紧, 不放宽任何位置; 常规 Linux 命令 (node, git, pnpm, python, curl, tar, rsync 等) 不受影响, 只是沙箱内看不到 /mnt/c 这一类 Windows 盘. 挂在 /mnt/wsl 下的东西 (Docker Desktop 的跨发行版挂载一类) 同样会看不见, 需要在沙箱内用它们时关掉这个开关.
插件页的 "WSL 互操作加固" 开关与 patch 的 hardenWsl 控制这一个加固是否生效, 缺省开启. 关掉后 provider 不叠加上述参数, 适合确实需要从沙箱内读 Windows 盘或跑 Windows 程序的场景; 关掉即恢复可被互操作打穿的状态. 保护路径与额外可写根的叠加不受这个开关影响. 非 WSL 宿主上即使开启也不改 argv.
patch 配置和插件页文本走同一套解析.
边界与已知限制
- Windows 上 bash 挡不住, 也放不宽: write / edit 能挡保护路径、能放行额外根; bash / pwsh 两者都不行. Windows 沙箱只能把整个工作区设成可写或不可写. 这条差别同时写进模型可见的系统提示词与
request_writable_path的工具描述 (只在 Windows 宿主上追加), 免得模型把"命令跑通了"当成"模式没命中". - broker 加固只在 macOS 生效; WSL 互操作加固只在 WSL + bwrap 生效: 官方 macOS profile 的
(allow default)让open能把命令交给 launchd 在沙箱外跑, 本插件追加的拒绝形式堵住这条路. WSL 上对应的代理通道是 PE 互操作, 由hardenWsl堵住. 两边都只收紧不放宽. Linux 的网络命名空间仍未隔离, 沙箱内仍可连宿主守护进程 (Docker socket, ssh-agent 一类) 让外面代劳, 这类问题本插件不处理. - 工作区或额外可写根是整块 Windows 盘时: tmpfs
/mnt之后会把那条路径重新 bind 回去, PE 文件重新可见, 但仍没有/run/WSL套接字. 真正要在沙箱内用 Windows 程序, 请关掉hardenWsl. hardenWsl会连带藏掉/mnt/wsl: WSLg 与 Docker Desktop 的跨发行版挂载都在那里, 沙箱内会看不见.- Linux 没有 bwrap, 落到 Landlock 时: 没法单独保护子路径, 也没法藏 WSL 互操作入口, 命令按官方沙箱跑并告警一次; 额外可写根可以加
--rw. write / edit 两者都生效. - 完全放开沙箱时 (
danger-full-access): 本插件整体不介入 —— bash 不进沙箱, write / edit 的保护路径与规则文件判定也跳过. 该模式是用户显式选择的"不设限", 保护只在read-only与workspace-write下生效. - Linux bwrap 要求路径真实存在: 通配扫出来的保护路径如果当时还不在磁盘上, 会跳过这条只读挂载并告警. 需要无条件保护的工作区根路径请用字面条目 (如
/.git); 字面条目即使还不存在, write / edit 也会拒绝. - 命令侧的枚举窗口 (macOS 字面条目除外): Linux bwrap / Landlock 与 macOS 上的通配条目只能按枚举出来的路径保护. 有 watcher 时, 变化之后的下一条命令就会吃到新清单; watcher 不可用时退回时间兜底 (最多
watchTtlMaxMs, 默认 30 秒). macOS 上的字面条目没有这个窗口 (按正则拒绝, 见上节), 因此git init也建不出.git. 已经要保护的目录不会再往里扫, 里面的匹配项不再单独列出; 被!放行的目录还会继续找. 目录符号链接不跟随, 避免扫到工作区外. write / edit 不受这条限制: 它直接按模式判定. - 正则拒绝只覆盖字面条目, 且遇到
!就整体放弃: 含*?[或\的条目仍走枚举清单; 文本里只要出现!取反, 命令侧就退回纯枚举 (纯 deny 表达不了 last-match-wins). 目录标记条目在正则通道上不区分目标是文件还是目录, 同名文件也会被一并挡住, 属于收紧. - 尾部
/**按那个目录本身保护: 和保护其下全部后代等价, 同时避免枚举全部后代, 代价是该目录自己也写不了. - 规则文件的条目同样受缓存窗口影响: 规则文件内容按 1 秒 TTL 重读, 改完最迟 1 秒后按新内容判定; 新增的匹配路径要等下一次枚举 (监听报变化后立即重算, 没有事件时最迟
watchTtlMaxMs, 默认 30 秒) 才会进命令沙箱的清单, write / edit 侧立刻按新条目判定. - 授权是会话级内存态:
request_writable_path得到的授权, 以及会话面板里手动加的临时可写根, 都不跨会话, 也不写配置文件; 撤回只是删掉这条内存记录, 不产生任何持久状态. 需要长期生效的额外可写根请写插件页配置或 patch. - 撤回通知依赖该会话有活着的 agent: 投递对象是会话的 agent runtime; 该会话当前没有活动 agent 时授权照常撤回, 但通知投不出去, 面板会把这件事如实写出来 (不假装模型已经知道).
- 授权在 bwrap 上要求路径真实存在: 命令侧要把授权子树 bind 回可写, bwrap 要求挂载源存在; 还不存在的授权路径在命令侧要等它被创建出来 (write / edit 与 Seatbelt 不受这条限制).
- Landlock 上没有命令侧的授权: 纯 allow-list 无法表达"父目录只读, 其中一棵子树可写", 强行加
--rw会连上方一起放开, 因此这种 runner 下授权只作用于 write / edit (告警一次). - Landlock 上也没有命令侧的读拒绝: 同一个 allow-list 结构在放行的父目录里挖不掉一棵子树, 因此读拒绝在这个 runner 上只作用于读工具, 命令仍读得到, 会告警一次. 它同时挡住读拒绝的授权那一路 (读本来就没被挡).
- Windows 上读拒绝只挡工具侧: 官方 Windows 沙箱只能把整个工作区设成可写或不可写, 没有子路径的读拒绝形态 (与写保护的限制同源).
- 读拒绝的枚举窗口与写保护相同: bwrap 的
tmpfs与 Seatbelt 的通配条目都按枚举出来的路径生效, 新出现的匹配路径要等下一次展开 (watcher 立即重算, 没有事件时最迟watchTtlMaxMs). macOS 上的字面条目同样走正则通道, 没有这个窗口. - 读拒绝拦不住别的东西读: 它只管本插件覆盖的那几条通道 (读工具与
bash/pwsh沙箱). 别的插件或工具自己开文件读 (MCP 工具, 浏览器, 远端会话一类) 不在这条围栏里. - 读拒绝挡住的是内容, 也顺带挡住执行: macOS 上
(deny file-read*)会连exec一起挡住 (执行也要求读), 这是收紧. - 指向保护目录内部的符号链接会被拒绝, 指向外部的不受影响.
本地开发
just install # 安装依赖
just typecheck # TypeScript 类型检查
just build # 构建 lib/
just test # 测试套件 (Seatbelt e2e 仅 macOS; bwrap e2e 仅 Linux; WSL 加固 e2e 仅 WSL)
just verify # 以上全流程 + 打包预览
源码分三块, 边界是"有没有文件系统依赖":
| 文件 | 职责 | 依赖 |
|---|---|---|
src/gitignore.ts | gitignore 语义的解析、编译与逐路径匹配 (含 PatternSet.match), write / edit 围栏与可写申请的保护判定共用 | 纯字符串/正则, 零运行时依赖 |
src/patterns.ts | 把模式异步枚举成具体路径, 供命令沙箱与预览面板使用 (另有额外可写根的字面路径解析) | node:fs/promises、canonicalPath |
src/fs.ts / src/policy.ts / src/provider.ts | 三个挂载点: write/edit 围栏、沙箱 policy (含规则文件, 读拒绝与授权)、进程沙箱 argv 叠加 | DSH 引擎 |
src/read-fence.ts | 读拒绝的工具层围栏: 派发前按会话 policy 判定, 结果侧过滤 grep / glob 的命中 | DSH 引擎 |
src/readonly-file.ts 读取并校验工作区只读规则文件, src/request-writable-path.ts 是 request_writable_path 工具与会话授权表 (含撤回), src/path-expand.ts 负责额外可写根的字面路径展开 (~ / 环境变量 / 平台差异), src/grants-route.ts 是会话写入权限面板的 /api 路由, src/grant-notice.ts 负责撤回之后投给会话的那条通知, src/seatbelt.ts 与 src/wsl.ts 分别是 macOS broker 加固与 WSL 互操作加固的参数生成.
测试覆盖: 纯匹配器语义 (锚定, **, 字符类, 取反, 前缀围栏, 目录标记, 大小写, 工作区外不match; 不需要任何临时目录), 路径解析语义 (相对锚定, 解开符号链接, 去重, 通配枚举与取反, 额外可写字面路径), 只读规则文件的解析与校验 (符号链接拒绝, 绝对与越界条目, 条目上限, 缓存与重读), 可写申请的判定矩阵 (直通, 规则文件硬保护, 工作区内旁路, 超限, 四种未获同意的结果) 与授权落到 policy 的通道, 会话面板路由的三个动作 (清单, 手动添加的内外分类与拒绝, 撤回与通知投递), 撤回通知消息的来源标记, 面板数据面的请求体与失败解读, write / edit 按模式判定 (启动后才出现的深层路径、尾部 / 与同名文件、工作区边界), 读拒绝的判定与结果过滤 (路径参数的相对解析, 授权放行, danger-full-access 不介入, str_replace_editor 只挡 view, grep / glob 命中的逐条过滤与全挡时的错误反馈), 读拒绝文本与枚举清单落到 policy 的通道 (与写保护清单各自独立), bwrap / Seatbelt / Landlock 的命令行叠加 (含 WSL 加固的插入位置与读拒绝的挂载顺序), write / edit 工具的拒绝与额外根放行矩阵, 配置通道的 base 与用户覆盖分层, client bundle 的 loader 注册, macOS 上真实 sandbox-exec 的内核级端到端 (包括 open broker 逃逸的对照组与加固后的拦截验证), 以及 Linux 上真实 bwrap 的内核级端到端 (保护路径写入 EROFS, 读取照常, 额外可写根可写; WSL 上再断言 Windows 盘与 interop 套接字被藏掉, 关掉开关后盘仍可见; 本机 bwrap 不可用时整组跳过).
License
MIT