Saltar al contenido principal
A

dsh-write-protect

azazo1/dsh-write-protect

Mantén como solo lectura los subpaths declarados del workspace (p. ej. .git), respeta un archivo de reglas de solo lectura en la raíz del workspace, concede raíces adicionales de escritura en workspace-write y permite que el modelo solicite acceso de escritura limitado a la sesión; se aplica a comandos de CLI en sandbox y a las herramientas write/edit.

Instalar

dsh plugin --profile web add github:azazo1/dsh-write-protect

README

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.tsgitignore 语义的解析与逐路径匹配 (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.tsgitignore 语义的解析、编译与逐路径匹配 (含 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

Plugins relacionados