Bundle
@aiwayds/dsh-approval-policy
dsh plugin: unattended approval gate — subagent/scheduled/cron approval requests get a bounded answer window, then settle with a fail-closed default instead of hanging forever (per origin or per session)
- Source
- fan56
- License
- MIT
- Updated
- Updated 23 hours ago
Readme
# dsh-approval-policy — 无人值守审批门控
> dsh 插件:给声明为无人值守的审批请求(subagent / 定时 / cron)加一道**有界应答窗口**——
> 先把请求交给下游交互面,**窗口内有人答就用人的答案**,窗口耗尽则按 fail-closed 默认裁决落定,
> **turn 立即落地,不再无限挂起**。交互会话默认零改动。
**要求 dsh >= 0.1.7-rc.1**(`package.json` 以 `peerDependencies` 声明该地板——dsh 0.1.7 起官方安装预检读取插件 peerDependencies 做兼容性检查)。
> **简体中文** · [English](README.en.md)
## 为什么要做
rc.3 起的 dsh 有一个空档:**approval 可能永不落地**。没人盯着的时候(无浏览器、或浏览器
闲置),交互 answerer 注册着却永不应答;cordis waterfall 的 veto 语义下,链首 answerer 也
救不了一个「没有超时的等待」——`next()` 不被调用、裁决不返回,这个 approval 就一直挂着,
turn 也就永远停在原地。
dsh 0.1.7 已经把「**一个 answerer 都没有**」这一档做成 fail-closed(回 `'unavailable'`)。
本插件补的是剩下那一档:**answerer 注册着,但不答**。
真正无人值守的来源天然如此:subagent 会话、定时任务、cron 任务发起时,人根本不在电脑前。
等一个永远不会到来的答案没有意义。但也不能一律 auto-reject:共用会话里人可能恰好顺手在
(或者人刚插过一句),**窗口内的人工应答仍然算数**。
诉求出处:Nemuritor01 在 deepseek-ai/deepseek-harness discussion #2544 提出的
**unattended policy, per session or per origin, interactive default unchanged**——
本插件就是这三句话的落地形态。
## 机制
本插件挂在宿主的 `'approval/request'` cordis waterfall 上,以
`ctx.on('approval/request', …, { prepend: true })` **prepend 注册**,因此无论插件加载顺序如何,
门控器都排在交互 answerer(web 远程应答、dsh-feishu 卡片……)**之前**。无人值守的请求在被人
的界面认领之前就被圈进窗口;被门控的请求在窗口内**仍然通过 `next()` 到达那些界面**。
```
approval/request(宿主 waterfall)
│
└─ dsh-approval-policy(prepend 注册,链首)
│
├─ shouldGate(request)?
│ ├─ 否(交互会话)→ next() 立即透传 ──► 交互默认不变
│ │
│ └─ 是(无人值守)
│ │
│ └─ raceGate:Promise.race( next() , 窗口计时器 )
│ ├─ 下游在窗口内答了 ──► 用下游的答案
│ │ (空链瞬间 fail-closed 'unavailable' 原样透传,
│ │ 没有东西可等,不必吃掉窗口)
│ ├─ 窗口耗尽 ────────► defaultOutcome 落定
│ │ (turn 立即落地:被拒或 unavailable,绝不挂起)
│ │
│ └─ turn 中止 ───────► 宿主信号竞速收 'cancelled'
│ (迟到的窗口到期被直接忽略)
│
└─ 交互 answerer(web 远程应答 / dsh-feishu 卡片 / …)仍在链上,
只是排在门控之后:先 next() 才轮得到它们
```
### 判定粒度(v1)
| 来源 | 粒度 | 判定依据 |
|---|---|---|
| `sessions` 名单命中 | 会话级 | agent id 命中 glob → 该会话**所有** turn 都门控(与 `origins` 取并集) |
| `all` | 全局 | 所有审批请求都门控(opt-in 的全门控逃生舱) |
| `subagent` | 会话级 | `session.header.origin === 'subagent'`(子代理会话是专用于委派的) |
| `scheduled` | turn 级 | 会话**最后一条溯源 user 消息**的 `source.kind === 'schedule'`(宿主 dsh-schedule) |
| `cron` | turn 级 | 最后一条溯源 user 消息 `source.kind === 'cron'`(dsh-cron ≥ 本次补丁版) |
`scheduled` / `cron` 取的是**最后一条溯源** user 消息(`user` / `schedule` / `cron`),
**跳过合成注入**:宿主在 turn 启动后会往 user 消息流里注 `runtime-context` 快照、
`skill-catalog` 提醒这类 `role='user'` 的合成消息,它们永远压在触发消息后面——不跳过的话
每条 cron turn 都会被误判成交互(真机测试抓到的坑,回归单测钉死)。也不取「任一条」或
「首条」:这些会话和人是共用的,**人插过话就不门控**——「有没有人在看」正是无人值守判定的
语义。cron 任务跑完后人 steer 了一句澄清,最后一条溯源就是人的 → 交互默认保留。
`sessions` 名单里的非法 glob 按不命中处理(编译失败不抛),保证名单写错也不会打断审批。
### 与宿主既有机制的关系
- **审计白送**:宿主自己落 `approval/asked` + `approval/decided` 到会话日志,**本插件不写日志**。
无论哪个 answerer 做的裁决都在 transcript 里可追溯,**超时拒绝同样查得到**。
- **会话级 `approval/policy`(ask | never)短路在 waterfall 之前**:宿主自己的策略先生效,
`never` 会直接 auto-reject,与本插件**零冲突**——那是宿主既有机制,本插件是
「**先试 N 秒再拒**」的时间窗形态。
- **交互会话(未命中任何来源)立即 `next()` 透传**:交互默认一字不变。
- **零落盘状态**:没有数据文件,配置之外不额外占位(entry id 本身就是 settings 命名空间)。
## 配置
挂在 `cordis.patch.yml` 的 dsh-approval-policy 挂载块 `config:` 段(id = `dsh-approval-policy`,
entry id 同时就是 settings 命名空间)。四个键**全部 volatile,可在设置页热改**。
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `origins` | `(subagent\|scheduled\|cron\|all)[]` | `[subagent, scheduled]` | 判定为无人值守的来源;`all` 是 opt-in 的全门控 |
| `sessions` | `string[]`(glob) | `[]` | 按 agent id glob 点名会话,命中即门控该会话**所有** turn(与 `origins` 取并集) |
| `windowSeconds` | `number` 0..86400 | `60` | 应答窗口(秒);`0` = 立即拒绝且**不**问下游 |
| `defaultOutcome` | `rejected \| unavailable` | `rejected` | 超时落定值;**配置层没有 allowed-once**(fail-closed 红线,schema 枚举就不含) |
```yaml
# ~/.dsh/cordis.patch.yml(或某个 profile 的 cordis.patch.yml)
- insert:
- id: dsh-approval-policy
name: '@aiwayds/dsh-approval-policy'
config:
origins: [subagent, scheduled, cron] # cron 需 dsh-cron 的 source kind 补丁版
windowSeconds: 60
defaultOutcome: rejected
```
- `defaultOutcome` 只有 `rejected` 和 `unavailable` 两个值,**没有 `allowed-once`(本次放行)这一档**
——无人值守的默认裁决必须 fail-closed,超时就是拒。要「窗口内放行」请让窗口内真的有人答。
- 改完 `cordis.patch.yml` 后重启 dsh 生效;挂载块里的四键也可以在设置页直接热改。
- 调配置的完整向导见随包技能 `dsh-approval-policy-config`(`skills/dsh-approval-policy-config/SKILL.md`)。
## 安装
```bash
dsh plugin add @aiwayds/dsh-approval-policy # 激活门控,bundle 自动挂载
```
profile 级操作用 `dsh plugin --profile <name> add @aiwayds/dsh-approval-policy`。
装好即按上表默认生效(`origins: [subagent, scheduled]`、窗口 60 秒、超时 `rejected`),
无需先写 patch——要把 `cron` 也纳入门控请按上面示例补 `config:` 段。
本地开发用 link 时,bundles 里把它放在交互 UI 之前或之后都可以(`prepend` 保证门控永远在
链首,不依赖加载顺序):
```jsonc
{
"dsh": { "profile": { "bundles": [
"@deepseek-ai/dsh-base",
"@aiwayds/dsh-approval-policy",
"@aiwayds/dsh-tui-pi"
]}}
}
```
## 卸载
```bash
dsh plugin remove @aiwayds/dsh-approval-policy
```
宿主自动清掉 profile `bundles` 里对应的条目和插件的 patch 层。本插件**零落盘状态**——
没有数据文件,配置之外不额外占位——卸载后不留任何残留。
卸载后审批行为**回到宿主原生**(无窗口):会话级 `approval/policy` 的 `ask` / `never` 照旧
生效,交互 answerer 照旧应答;而「无人值守且没人答」那一档重新变回**可能永不落地**。
想保留拒绝保障又不装本插件,可以把相关会话设成 `approval/policy: never`(立即拒,无窗口)。
## 测试
```bash
npm test # 17 个纯逻辑单测(node --test):
# glob 编译 / 事实读取(最后一条溯源 user 消息、合成注入跳过)/ 判定粒度(subagent、
# scheduled、cron、all、sessions 名单与并集)
# + 竞速语义(窗口 0 立即拒且不委托、瞬间 unavailable 原样透传、
# 窗口内答案胜出、挂死 answerer 输给窗口)+ apply() 以 prepend 注册
# 单个 listener 并在 dispose 时释放
npm run smoke # scripts/smoke-boot.mjs:真宿主 dump-config + boot + 卸载腿
```
License: MIT. 作者 fan56。
Install
dsh plugin --profile web add github:fan56/dsh-approval-policy
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install aiwayds-dsh-approval-policy from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.