dsh-sandbox-approval-modal
English summary — Native + in-page sandbox-permission approval for DeepSeek Harness (DSH). A Task Scheduler–launched WinForms window is the primary surface for
decidemode (so it can appear while DSH is in the background), with a fixed Web modal fallback, FIFO queue, one-shotallowed-oncegrants, reject-with-comment, host-enforced timeout, and audit-friendly decisions. It never grants persistent permissions.
沙盒外权限请求的置顶审批弹窗,并提供 Windows 原生后台提醒。它不新增授权通道,而是复用 DSH 现有的审批链路:
approval 服务(Host)
→ approval/request waterfall
→ 本插件 Host 侧(decide 原生审批 / notify 并行提醒;默认 decide)
→ api-remotes 转发到 Web Client
→ shipped ui-approval 的 PendingApproval(capability 不变)
→ 本插件的 fixed overlay(只负责呈现)
→ PendingApproval.answer('allowed-once' | 'rejected')
→ Host 追加 approval/decided,工具按结果放行/拒绝
notify 模式下原生提醒不参与决策,Web 弹窗始终是决策入口;decide 模式下原生窗口先回答,
只有 delegate/unavailable 才进入 Web 弹窗。因此没有“另一条提权路径”:任何 UI 都只能回答宿主
已经发出的那一次审批请求;allow 的唯一结果是 allowed-once,不会写全局策略,也不会创建持久授权。
触发条件
- 只由权限审批事件驱动:
approval/request经宿主审批服务发出后,ui-approval生成PendingApproval待处理交互;本插件的 overlay 只读取这些kind === "approval"的待处理 交互。普通 assistant 文本、普通 tool 消息、聊天正文都不会触发弹窗。 - 同一时刻只渲染一个弹窗;其余请求按
PendingApproval.key的创建序号 FIFO 排队,弹窗顶部 显示“还有 N 个请求按顺序等待”。 - 文件写入/编辑的沙盒越权来自
write/edit的sandbox_permissions+justification;pwsh/bash的越权同理。没有越权参数时不会出现弹窗。
呈现
- 挂在框架级
shell.overlay,并通过 React Portal 固定到document.body:position: fixed; inset: 0; z-index: 2147483000- 高于会话视图、输入区、侧栏和既有浮层;滚动聊天、侧栏或窗口时位置不变
- 长理由、长命令和 diff 预览放在弹窗内部的滚动区;底部“拒绝 / 允许(仅本次)”按钮始终可见
- 标题固定为 “需要沙盒外权限”;正文完整展示模型理由;目标工具、权限模式、路径/命令
使用等宽字体;文件写入显示加入行数预览,
edit显示真实的删除/新增行 diff 摘要。 - 授权范围固定显示 “仅本次”,并明确写出: “本次授权仅对当前操作有效,执行完成后自动收回。” “本弹窗不提供‘始终允许’或会话级持久授权。”
- 无障碍与键盘:
role="alertdialog"、aria-modal="true"、aria-labelledby/aria-describedby- 打开时焦点落在安全默认项“拒绝”;Tab 焦点陷阱;
Esc拒绝(在“拒绝并附理由”编辑态下 先返回);关闭后恢复之前的焦点 - 文案经
ctx.locale命名空间sandboxApproval提供中/英双语,随当前界面语言切换
跳出浏览器:原生窗口(Windows)
Host 侧新增一个原生 WinForms 窗口(不是浏览器标签),通过 Task Scheduler 交互式令牌
在登录用户桌面启动,TopMost、任务栏可见、闪烁 + 提示音,位于所有程序之上。nativeMode 配置:
| 模式 | 行为 |
|---|---|
decide(默认) |
原生窗口提供“允许(仅本次)/ 拒绝 / 在浏览器中处理”,先于 Web answerer;只有原生不可用或用户选择“在浏览器中处理”时才调用 Web 弹窗。窗口通过 Task Scheduler 交互式令牌 启动,因此即使 DSH Host 在后台/隐藏桌面,也会出现在当前登录用户桌面。 |
notify |
Web 弹窗仍是唯一决策入口;同时弹出原生后台提醒,展示理由 / 工具 / 目标 / 倒计时 / 排队数,按钮为“打开浏览器处理”和“关闭提醒”。它不会允许或拒绝,因此不会出现两个决策入口。 |
off |
仅 Web 弹窗。 |
- 原生窗口在上层程序前台时仍然可见;decide/notify 的“打开浏览器处理”会 restore/focus 标题含
DeepSeek的浏览器窗口并闪烁任务栏。 - 浏览器后台通知(跨平台兜底):Web 弹窗出现时,如果
Notification.permission仍是默认值, 弹窗底部会显示“开启后台通知”。授权后,当页面处于后台/最小化(visibilityState === 'hidden') 且有活动审批,浏览器会发一条requireInteraction的系统通知;点击通知会聚焦窗口。这样即使 原生窗口在当前桌面不可见(或在非 Windows 上),用户仍能收到系统级提醒。 - 失败降级:非 Windows、PowerShell/WinForms 不可用、启动失败、窗口返回
unavailable→ 回退 Web 弹窗;绝不 fail-open 到 allow。 - 原生超时默认 120s(Host guard 130s 兜底):decide 模式超时 = 拒绝;notify 模式超时只关闭提醒, Web 弹窗继续等待 / 由自身超时拒绝。
- 原生窗口同样只有
allowed-once,没有“始终允许”或持久授权。
决策回传
| 用户动作 | 回传 | 结果 |
|---|---|---|
| 允许(仅本次) | PendingApproval.answer('allowed-once') |
只放行被询问的这一次操作 |
| 拒绝 | PendingApproval.answer('rejected') |
工具收到拒绝错误,不会执行 |
| 拒绝并附理由 | 先 rejected,再把理由作为审批备注注入会话 |
理由进入下一轮模型上下文 |
| 超时 | PendingApproval.answer('rejected') |
自动拒绝,绝不静默放行 |
- 审核备注(实现见
lib/client.js的sendReviewerReason与reviewHeader/reviewBody): DSH 的ApprovalOutcome只有四个值,没有 reason 字段;本插件用一个最小适配层,把用户填写的 理由作为一条明确标注的用户消息queue进该会话,例如:【沙盒外权限审批备注】... 理由:<用户理由> ... 请勿重试或绕过...它只影响下一轮模型上下文,不改变“已拒绝”的决定。 - 超时有两层:
- Client:默认 120s;超时后自动
reject,并显示“审批已超时,已自动拒绝(不会静默放行)。” - Host:
apply(ctx, config)以最高优先级预置一个approval/request监听器。notify模式先调用next()打开 Web 弹窗并并行启动原生提醒;decide模式先询问原生窗口,再在需要时调用next()。整个 answerer 链统一受timeoutMs(默认 130s)约束:超时取消原生窗口并返回rejected。这样即使 Web 页面关闭/断连,也不会永久挂起。
- Client:默认 120s;超时后自动
- 审计:审计对由宿主
approval服务写入会话日志,本插件不伪造、不跳过:approval/asked:id、toolName、callId、reasonapproval/decided:id、outcome
审计查询
会话日志是分帧 zstd。随包脚本会解出全部帧,打印 asked/decided 对:
node scripts/read-approval-audit.mjs <session-id>
# 或
node scripts/read-approval-audit.mjs "C:\Users\<you>\.dsh\sessions\--F-project-xxx--\session-<id>\session.v4.jsonl.zstd" --json
实际输出示例(来自本仓库的 E2E 验证):
ASKED 2026-11-13T12:06:41.668Z pwsh call_00_...
reason: escalate sandbox to danger-full-access: 第五次审批弹窗:聊天滚动固定性测试
DECIDED 2026-11-13T12:06:42.943Z rejected
安装
本包既可以作为 profile bundle 安装,也可以在开发环境用 dsh-super-injector 运行时注入。
从 GitHub 安装(推荐)
git clone https://github.com/LondonMoon/dsh-sandbox-approval-modal.git <path-to-clone>
在 DSH 中把克隆目录作为 bundle 安装:
plugin_manager install_bundle target=<absolute path to the clone>
也可以从 Releases
下载 *.tgz,解压后以其目录作为同一安装命令的 target。
开发态运行时注入(需要 dsh-super-injector):
dev_inject_plugin <path-to-clone>
安装后重启/刷新 DSH;默认 Web profile 的 shipped @deepseek-ai/dsh-client-ui-approval 必须保持启用。
本插件消费它生成的 PendingApproval;它不替代审批服务,也不关闭任何 shipped 插件。
本插件的 composer 影子条目(priority 0)只负责阻止旧的 composer 卡片重复显示,审批能力本身
仍由 ui-approval 和宿主 approval 服务提供。
Host 侧超时与原生模式可通过 bundle patch 覆盖:
- id: sandbox-approval-modal
name: '@dsh-external/dsh-sandbox-approval-modal'
config:
timeoutMs: 130000
nativeMode: decide # decide(默认)| notify | off
nativeTimeoutMs: 120000
测试
npm test # 直接运行全部 node:test 文件(沙盒下避免 node --test 的子进程)
npm run check # 四份 JS 的 node --check
test/core.test.mjs:从实际发布的lib/client.js载入工厂(而不是复制逻辑)并测试:- 只有 approval 交互会触发,prose 不会
- FIFO 排序与排队计数
- allow →
allowed-once,reject →rejected,没有第三种授权 - 超时到期、已应答不重复、状态同步清理
- 拒绝理由文本包含工具/目标并禁止绕过
- 写文件/编辑的路径、请求模式、diff 摘要
- fixed/z-index/内部滚动的 CSS 契约
- Tab 焦点环绕
- 注册面:
shell.overlay+ priority 0 的 composer 影子 +sandboxApproval字典 + 样式/清理 - 浏览器后台通知门控:只有
hidden + granted才发通知;reason 截断到 ≤240 字符
test/host.test.mjs:Host 超时守卫对正常决定透传、对无人应答按截止时间rejected;notify模式始终打开 Web 决策并在其结束时关闭原生提醒;decide模式的原生决定优先; 监听器{ prepend: true }且随 effect 清理。test/native-broker.test.mjs:原生窗口 FIFO 排队、队首唯一、排队计数、delegate 放行、 取消 active/queued、dispose、异常窗口降级。test/native-window.test.mjs:原生展示元数据(理由/权限/路径/命令/diff 计数)、 当前 turn 边界内的 tool/call 查找、平台安全、PowerShell 文件 ASCII-only 与无 auto-allow 路径。test合计 13 + 9 + 7 + 6 = 35 个用例。
原生窗口实测
scripts/native-approval.ps1经 Windows PowerShell 语法解析通过。windowMode: notify+ 6s 超时:脚本正常结束并写出{"decision":"dismissed"}。windowMode: decide+ 6s 超时:脚本正常结束并写出{"decision":"rejected"}。- 在 Web 弹窗打开时把
document.visibilityState临时置为hidden并注入 Fake Notification: 客户端创建了title="需要沙盒外权限"、body=<完整理由>、requireInteraction=true的通知, 验证了“后台页通知”路径(真实系统 toast 的视觉呈现取决于操作系统通知设置)。 - 交互式任务启动实测:Host 用
schtasks /Create /XML(LogonType=InteractiveToken)创建 一次性任务后,原生窗口出现在登录用户桌面;用户点击“打开浏览器处理”后,starter 收到{"decision":"delegate"}。Host 自身的窗口站不可见并不影响这条路径——这正是解决 “弹窗还在网页里”的关键:窗口由交互式任务在用户桌面创建,而不是直接在 Host 进程里创建。 - 如果任务计划程序不可用,会回退到直接 PowerShell 子进程 / Web 弹窗;
notify/off模式 始终保留 Web 弹窗。 - 浏览器通知的权限/隐藏页触发路径已由单测和 Fake Notification 实测覆盖;浏览器权限提示 本身是用户手势行为,未在无头测试中自动授权。
真实 GUI E2E(本机已执行)
在独立的 Electron 浏览器会话中打开当前 DSH Web GUI,创建一个由该 Client 拥有的新会话,
让 agent 真实调用带 sandbox_permissions: danger-full-access 的 pwsh:
- 弹窗 DOM:
[data-sandbox-approval-modal]存在,标题“需要沙盒外权限”,position: fixed、z-index: 2147483000 - 滚动
[data-conversation-scroll](3758 → 3258)与侧栏(0 → 250)后, 弹窗 rect 完全一致:x=351, y=138.5, w=682, h=520 - 点击“拒绝”后弹窗消失;审计为
approval/decided: rejected,工具返回用户拒绝错误 - 点击“拒绝并附理由…”填入理由后发送:
审计为
rejected,下一轮用户消息中出现“【沙盒外权限审批备注】…理由:<理由>…”, agent 明确表示不重试、不绕过 - 把 client 超时设为 2s/5s 且不操作:弹窗先显示倒计时,随后自动消失;
审计为
rejected,toast 显示“审批已超时,已自动拒绝(不会静默放行)。” - 打开时焦点在“拒绝”按钮(安全默认);
Esc/Tab 行为由useDialogFocus与单测覆盖 - 一次被允许的
pwsh只执行一次;后续同类请求仍会重新询问(没有“始终允许”)
限制与边界
- 跨会话并发:弹窗汇总当前 Client 的
uiSession.sessionStatus中所有approval待处理 交互,并按approval:<monotonic>排序。DSH 的单个 agent 工具循环天然串行;同一会话内 同时出现多个 approval 不是常规路径。FIFO 计数与排序逻辑由core.test.mjs覆盖。 - 审核备注走
session.prompt(..., 'queue'),因此会以“用户消息”的形式进入下一轮上下文; 这是 DSH 当前审批结果词表没有 reason 字段时的最小适配。若会话已不可用,拒绝仍生效, 只会在 console 记录理由发送失败。 - 本插件不改变
approval/policy;如果会话策略是never,宿主在分发前就自动拒绝,不会 经过本弹窗。 - 同一页面只应有一个本插件实例;重复注册会由 slot 的 id/priority 规则暴露冲突,而不会 产生两个授权通道。
- 原生窗口可见性:它通过 Task Scheduler 的
InteractiveToken在登录用户桌面启动;需要任务 计划服务可用且用户已登录。任务创建/运行失败时会回退到直接子进程或 Web;notify不会因此 阻断 Web 弹窗;decide模式下窗口不可见时只能等待窗口超时自动拒绝。需要完全避免这种依赖时 用nativeMode: off。 notify模式不在原生窗口里允许/拒绝,这是刻意的:避免和 Web 弹窗形成两个决策入口。 需要在浏览器外直接决定时设nativeMode: decide;该模式已在单测覆盖原生优先与 delegate。- 系统通知依赖浏览器仍在运行且站点通知权限为 granted;浏览器完全退出时不会有通知,审批仍按
Host 130s 兜底超时拒绝。需要“完全关掉浏览器也能处理”的部署请使用
nativeMode: decide(前提是 Host 桌面对用户可见)。
文件
lib/host.js:Host 侧原生模式选择 + 超时守卫(approval/requestprepend;notify 并行提醒, decide 原生优先,正常链路保留next(),超时rejected)lib/native-broker.js:单窗口 FIFO 队列、取消、disposelib/native-window-task.js:Windows 原生窗口启动(Task Scheduler 交互式令牌优先,直接子进程回退)、 展示元数据、tool/call 参数提取scripts/native-approval.ps1:ASCII-only WinForms 窗口;notify 提醒 / decide 审批两种模式lib/client.js:fixed overlay、composer 影子、FIFO/超时注册表、diff 摘要、焦点陷阱、中英字典、 可查询的window.__dshSandboxApproval检查面(不含授权能力)cordis.patch.yml:bundle 装配行locale/{zh,en}.json、assets/icon.svg:展示元数据scripts/read-approval-audit.mjs:从.zstd会话日志查询审批审计test/*.test.mjs:客户端、Host、原生队列、原生窗口四组测试
发布
推送 v* tag 会触发 .github/workflows/release.yml:
在 Ubuntu runner 上执行 npm run check、npm test、npm pack,然后把生成的 .tgz
附加到同 tag 的 GitHub Release。日常 push/PR 由
.github/workflows/ci.yml 做语法、测试与打包内容校验。
git tag -a v0.1.0 -m "v0.1.0"
git push origin v0.1.0
许可证与安全
BSD-3-Clause,见 LICENSE。如果发现可能导致审批绕过、伪造
allowed-once、重放或隐藏拒绝的问题,请通过仓库的 Security → Report a vulnerability
私密报告;不要用公开 issue 披露可利用细节,见 SECURITY.md。
No comments yet. Be the first to write one.