dsh-approve-prefix
按单命令前缀自动放行 DSH 沙箱提权请求, 含管道与链式的命令一律转人工审批.
- Stars
- 1
- Language
- TypeScript
- Created
- Sep 23, 2026
- Updated
- Sep 28, 2026
Introduction
dsh-approve-prefix
按 "单命令前缀" 自动放行 DeepSeek Harness 沙箱提权请求的插件, 附带一个会话级的夜间模式开关.
当 agent 因为沙箱拦下某条命令, 改带 sandbox_permissions + justification 重试时, 本插件只在命令确实是单条简单命令且 argv 前缀命中放行前缀时替你点 "允许一次". 前缀不符且模型在 bash / pwsh 参数里写了 approved: true 时直接拒绝, 不弹卡片; 其余情况 (管道, 分号, &&, 重定向, 命令替换, 前缀不符但没写该字段) 原样弹人工审批.
打开夜间模式后, 这个会话不再弹任何卡片: 交互类工具被直接拒绝, 未命中前缀的提权也直接拒绝, 其它插件发起的审批询问 (例如 dsh-plugin-chrome 首次开窗的同意) 同样直接拒绝. 见 夜间模式.
它解决的问题
- dsh 的提权审批请求只带
toolName和reason(形如escalate sandbox to danger-full-access: <理由>), 不带命令原文. - 写在
tools/pre-execute上的规则插件只能决定工具调用本身的 allow/deny/ask, 而提权审批发生在工具体内部, 规则放行不等于同意提权. - 挂在
approval/request上的模型审批插件能同意提权, 但靠模型分类, 不是确定性的前缀匹配.
本插件把两件事接起来: 在 tools/pre-execute 记下命令原文, 再以 { prepend: true } 插到审批瀑布最前面, 按 callId 取回命令做确定性判定.
它不从审批结果里学任何东西. dsh 的审批接缝只有 allowed-once 一个放行结果, 没有 "允许一次" 与 "始终允许" 的区分, 插件看到的每次人工放行都长一样, 所以从结果里归纳前缀等于让一次 "允许一次" 变成长期自动放行. 放行前缀只来自静态配置, 配置页面与会话视图 tab 这三条显式路径.
判定规则
自动放行需要同时满足:
- 审批请求来自
tools中配置的工具 (默认bash和pwsh). - 请求是沙箱提权, 且目标档位在
allowedEscalationModes内 (默认只允许danger-full-access). - 命令由
tools/pre-execute记录过, 并能按callId取回 (每条记录只消费一次). - 命令是恰好一条简单命令.
bash经 unbash 解析;pwsh/powershell调本机 pwsh 做ParseInput(找不到解释器就转人工). 没有管道, 链式, 调用运算符, 重定向, 也没有参数 / 命令替换这类展开. - 去掉命令行前缀形式的环境变量赋值 (以及
env包装) 后, argv 逐 token 命中某条放行前缀 (默认空表, 什么都不自动放行).
任一条不满足时默认交给下一个应答者 (人工卡片). 例外: 第 4 或第 5 条未通过 (命令不是可放行的单命令前缀), 且工具参数里的 approved 严格等于布尔 true, 则直接返回 rejected, 不再弹卡片. 没写该字段, 写了 "true" / 1 之类, 或失败原因是档位 / 没有记下命令, 仍转人工.
夜间模式打开时补上一条: 任一条不满足都直接返回 rejected, 不再转人工 — 人工已经不在, 弹卡片没有意义. 第 1 至第 5 条全满足时仍自动放行, 所以夜里能放行的正是你显式配过的那些前缀.
第 1 条的例外也由夜间模式接管: 请求的工具名不在 tools 里时, 插件白天照旧转人工 (不参与前缀判定), 但夜里一律直接 rejected, 不弹卡片. 这类请求来自别的插件, 不是沙箱提权而是工具运行时的提问, 例如 dsh-plugin-chrome 在会话首次开窗前发出的那张同意卡 (它在 tools/pre-execute 返回 { kind: 'ask' }, 由工具层转成一次审批请求), 以及 ptc / 插件管理器这类自己走提权的工具. 夜里它们能做的只有失败, 而不是把会话挂在 "等你批准" 上.
插件会给 tools 配置里的工具 (默认 bash 和 pwsh) 补一个可选的布尔参数 approved, 这样模型才能在 schema 里看见并填写它. 补丁打在 system-prompt/assemble 发给模型的 schema 副本上, 因为 bash / pwsh 注册在 agent preset 平面, 全局 tools.get('bash') 拿不到那份活定义. 注册表里没有的名字会跳过, 所以 macOS / Linux 上不会改到不存在的 pwsh, Windows 上不会改到不存在的 bash.
有 systemPrompt 时, 插件再注册一段系统提示词, 放在提示词尾部. 每次组装都重写这段, 列出当前放行前缀 (静态对每个配置工具生效, 持久按工具, 临时只含当前会话), 并简短说明匹配条件与 approved: 只有单条简单命令且 argv 从前缀开头才算命中; approved: true 只表示模型自称命中, 没命中就直接拒绝, 不写则仍弹人工. 前缀表为空, 或这次组装没有会话身份时, 对应位置写 none, 不会把别的会话的临时前缀漏进去. 提示词只列出当前生效的前缀, 不用具体命令当匹配示例, 避免模型把例子当成已放行.
下表假定静态前缀为 gh api:
| 命令 | 结果 |
|---|---|
gh api user --jq .login | 自动放行 |
gh api -f body='hello<换行>world' | 自动放行 (引号内换行仍是单命令) |
gh api user \\<换行> --jq .login | 自动放行 (反斜杠续行) |
gh api -f query='query($x: Int)' | 自动放行 (单引号内的 $ 是字面量) |
ENVA=aaa gh api user | 自动放行 (环境变量前缀被忽略) |
env ENVA=aaa gh api user | 自动放行 |
/usr/local/bin/gh api repos/{owner}/{repo} | 自动放行 (命令词取文件名部分) |
gh api user | jq .login | 人工 |
gh api user; rm -rf /tmp/x | 人工 |
gh api user && gh auth status | 人工 |
gh api user > /tmp/out.json | 人工 |
gh api $(echo user) | 人工 |
gh api user \\"<换行>true \\" | 人工 (bash 会另起命令) |
env -i gh api user | 人工 (env 带选项, 不做归约) |
ENVA=aaa | 人工 (只有赋值, 没有命令) |
gh auth status | 人工 |
放行前缀
前缀来自三处, 全部由你显式给出:
| 来源 | 存哪 | 生效范围 | 怎么改 |
|---|---|---|---|
静态 prefixes | profile 的 cordis.patch.yml | 本机所有会话, 每次启动 | 改配置后重启 dsh web |
| 持久前缀 | settings.yaml 的 approve-prefix 段 | 本机所有会话, 跨重启保留 | Settings 里的 放行前缀 配置页 |
| 临时前缀 | 进程内存 | 仅当前 GUI 会话 | 会话视图里的 放行前缀 tab |
前缀的 "程度" 由 token 数决定: gh api 放行所有以 gh api 开头的单命令, 写 gh api graphql 就只放行 graphql 子命令.
会话 tab
会话视图里的 放行前缀 tab 管理当前选中会话的临时前缀与夜间模式开关: 前缀可列出, 新增, 删除, 清空草稿, 原地改工具名 / 前缀; 开关排在前缀表下方. 两者都只改本地草稿, 点 应用 才一起写入 Host 内存; 未点应用之前审批仍用上一份已应用的表. 进程重启即清空.
新增行的默认工具名是当前注册表里第一个配置过的工具 (Windows 上通常是 pwsh, 其它系统是 bash). 非法行 (空工具名, 空前缀, 含 | ; & $) 不写 Host, 只在该行下报错. 达到 temporaryPrefixLimit` 时拒绝新增.
临时表以 session id 分组, 在 A 会话里写入不会让 B 会话放行同一条前缀; 审批请求取不到会话身份时按空表处理, 不会退化成全局放行. 夜间模式开关同样按会话分组.
TUI / 无 GUI / headless 没有这条管理面, 也就改不了临时表与开关; 静态与持久前缀, 以及已经写入内存的临时前缀, 审批自动放行照常.
夜间模式
night 是会话级开关, 打开后本会话不再找人: 交互类工具被宿主直接拒掉, 只有命中放行前缀的提权会放行, 其余提权请求与其它插件发起的审批询问连卡片都不弹, 直接返回 rejected. 你有事离开时打开它, agent 就不会停在 "等你批准" 或 "等你回答" 上.
两个入口写的是同一份状态, 效果完全一致:
| 入口 | 位置 | 怎么用 |
|---|---|---|
| 放行前缀 tab 里的开关 | 当前会话视图, 排在临时前缀表下方 | 勾选只改草稿, 与前缀表一起点应用才写给 Host; 状态行显示已应用的值 |
/night 命令 | 输入框 | 裸调用取反; /night on 与 /night off 写明确值, 立即生效 |
开关与临时前缀一样只存进程内存, 按 session id 分组, 不写 settings 也不写会话日志. dsh 重启后开关全部归零, 系统提示词里那段夜里规则随之消失, agent 因此能判断 night 已经结束; 关闭后审批回到人工卡片.
开关本身不产生任何模型可见的消息: /night 只写状态, 不往对话里补消息, 也不会唤醒 agent. 打开之后, agent 下一次请求会从系统提示词段里读到这些规则:
- 遇到的每个待定问题自己定, 把假设写进回复里继续干.
- 实在不能替你定的, 在回复里说明并结束那部分工作, 不要停住等.
- 被拦的工具调用只会得到一条错误结果, 不会送到你面前.
- 提权只有命中放行前缀才自动放行, 其余立刻拒绝, 不要反复重试.
- 其它插件发起的审批询问 (例如首次开窗前的同意卡) 同样立刻拒绝, 等不到对话框.
默认被拦的是需要你回答的那个工具: ask_user_question (计划模式与 dsh-reject-message 也走它背后的问答通道). 默认免拦 exit_plan_mode:
- 它走的是用户提问通道, 一并拦掉会让 agent 无法退出计划模式; 你要离开前如果没有先退出计划模式, 它就成了死结.
- dsh-reject-message 挂在计划审查卡上的拒绝入口也依赖这张卡, 拦掉它那个入口就永远见不到.
名单能配, 见下面的 nightBlockedTools / nightExemptTools. 拦截是确定性的: 命中就 { kind: 'deny' }, 不依赖模型是否读懂了提示词.
与 dsh-reject-message 的配合: 插件在 tools/pre-execute 直接拒绝, 那次调用不会有审批请求, 所以拒绝窗口不会出现, 也没有描述可填. 这是 night 的定义 (没有人可填), 不是冲突; 关掉 night 后提权卡片与拒绝窗口照旧.
配置页
Settings 里的 放行前缀 页用表格编辑持久前缀, 每行是 工具名 + 前缀, 可增行, 删行, 保存与重新载入; 保存后写入 settings, 对所有会话立即生效, 不需要重启:
approve-prefix:
persistentPrefixes:
- tool: bash
prefix: gh api
安装
插件同时有 Host 与 Client 半边 (后者提供配置页), 所以装完都要重启宿主进程, 让 client 产物被重新收取.
Web 端
装进 web profile:
dsh plugin --profile web add azazo1/dsh-approve-prefix
本地目录也能装, 把坐标换成目录路径即可 (dsh plugin --profile web add ./dsh-approve-prefix); 需要固定版本时写 azazo1/dsh-approve-prefix#<tag>.
装完重启 dsh web, 浏览器里刷新一次页面.
桌面端
桌面端装进 desktop profile. 它由 Electron 应用独占管理, dsh plugin 会拒绝 --profile desktop, 所以要用应用内的插件管理器: 在插件页的安装入口填上面命令里对应的包名或本地目录. 装上后重启应用, 窗口刷新一次.
引擎版本线
要求 @deepseek-ai/dsh-* 不低于 0.2.0-rc.1, 且仍在 0.2.x 上 (声明了 dsh 依赖时 peerDependencies 与 devDependencies 都写作 >=0.2.0-rc.1 <0.3.0). 更早的引擎线装不上这个版本.
web 与 desktop 两个 profile 跑的是同一套 Web 应用, 桌面端只是多起一个 Host 子进程并给 <html> 打上平台标记, 所以同一份包在两边通用, 不需要分别构建.
配置
配置写在 profile 的 cordis.patch.yml 中该行的 config 下, 全部字段都有代码默认值:
- insert:
- id: dsh-approve-prefix
name: dsh-approve-prefix
config:
prefixes: ['gh api', 'gh pr view']
debug: true
| 字段 | 默认值 | 说明 |
|---|---|---|
prefixes | [] | 静态放行前缀表, 每项是空格分隔的命令词序列; 默认空, 什么都不自动放行 |
tools | ['bash', 'pwsh'] | 参与记录与判定的工具名. 注册表里没有的名字跳过. pwsh 的判定要求本机能跑 pwsh |
allowedEscalationModes | ['danger-full-access'] | 允许自动放行的提权目标档位 |
extraDeniedCharacters | [] | 在 AST 判定之外额外拒绝的单字符, 对命令原文整串扫描 |
onlyEscalations | true | 为 false 时, 非提权来源的审批请求也按同一套命令规则应答 |
temporaryPrefixLimit | 32 | 每个会话的临时前缀条数上限 |
nightBlockedTools | ['ask_user_question'] | night 期间直接拒绝的工具名; 只有形状像工具名的条目参与判定 |
nightExemptTools | ['exit_plan_mode'] | 即使出现在被拦名单里也放行的例外 |
nightCommand | true | 是否注册 /night 命令, 让输入框也能切换 |
debug | false | 输出每次转人工的判定细节 |
pendingCapacity | 128 | 命令记录表容量, 超限淘汰最旧一条 |
未知键, 类型不符或越界都会在插件加载时抛错, 不会静默降级成放行.
安全边界
- fail-closed: 默认静态前缀为空, 安装后不会自动放行任何命令. 判定器拿不到命令, 配置非法时转人工, 不会变成放行. 前缀不符默认也转人工; 只有模型同时写了布尔
approved: true才改为直接拒绝. - night 期间的拒绝同样 fail-closed: 其它插件的审批询问只可能被拒, 没有因为 "不在 tools 里" 而被放行的路径; 取不到会话身份时按关处理, 不拦也不放行.
- 一次性: 同一个
callId的记录只消费一次, 重复请求转人工. - 单命令结构由 AST 白名单固定, 不能通过配置取消管道 / 重定向 / 命令替换等检查, 避免把判定配成可绕过.
- 审批结果不参与学习, 所以一次 "允许一次" 不会改变后续判定.
- night 只放宽不放严: 打开期间也不会多放行任何一条没配过的前缀, 未命中前缀的提权只是从 "弹卡片" 改成 "直接拒绝".
- Host 半边不写 settings: 持久前缀只在你于配置页保存或手工编辑 settings 时变化.
- 已放行的调用仍受 dsh 自身的文件沙箱与审批审计约束: 会话日志里的
approval/asked与approval/decided会记录本次询问与结果, 插件另在 info 级别打印判定依据.
局限
- 默认覆盖
bash和pwsh. 工具名是pwsh或powershell时, 判定走本机pwsh的ParseInput; 没装 pwsh, 解析失败或命令不是一条字面量命令, 都转人工. 系统提示词只列出当前注册表里有的那些工具. - 命令原文依赖
tools/pre-execute记录, 记录缺失时转人工; 记录表有容量上限, 长会话中极早的记录可能被淘汰. - 不判断命令的实际副作用, 只判断它是不是 "命名单命令".
- 因为审批接缝无法区分按钮, 想做到 "点卡片上的某个按钮才记住前缀" 需要额外实现审批卡片上的按钮; 目前这一步由会话 tab 与配置页代替.
- TUI / 无 GUI 不能改临时表与夜间模式开关;
/night命令在 TUI 里可用, 前提是组合里有命令注册表. - 夜间模式开关不跨重启: 重启后所有会话都回到普通模式, agent 从那段提示词消失即可判断 night 结束.
开发
just install
just typecheck
just test
just build
just verify
tests/ 覆盖命令判定, 会话级临时前缀表, 认证 HTTP 与插件接线 (假 Context 驱动 pre-execute 与审批瀑布), 含 "点击允许一次不得带来自动放行" 的回归用例.