dsh-any-agent 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] — 2026-11-04
11
+
12
+ ### Security
13
+
14
+ - **Path-safe ids before any state-file join.** `conversation_id` and `run_id`
15
+ are now validated as single path segments before building sentinel, review,
16
+ metadata or tombstone paths. Values containing `/`, `\`, `:`, `..` or other
17
+ separators are rejected instead of being passed to `path.join`; this closes
18
+ a traversal in `dsh_get`'s sentinel read-back and in `dsh_review`'s
19
+ `<state>/reviews/<conv>/<run>.review.json` write.
20
+ - **Filesystem-root `cwd` is rejected for writable tiers.** Choosing `/` or
21
+ `C:\` with `workspace-write` made the sandbox root the whole disk; symlinked
22
+ paths are resolved before the home/root guard, so a symlink to the root or
23
+ home cannot bypass it.
24
+ - **`title_hint` is capped at 200 characters** (schema + runtime). It is
25
+ persisted in the registry and sidecar on every turn; an unbounded value was
26
+ a stable disk/memory amplifier.
27
+ - **`dsh_doctor` checks `reviews/` writability**, alongside `runs/` and
28
+ `locks/`.
29
+ - **MCP tool dispatch only accepts own handler properties**, so names such as
30
+ `constructor`, `toString` or `__proto__` are reported as unknown tools
31
+ instead of reaching the prototype chain.
32
+
33
+ ### Features
34
+
35
+ - **Caller review (`dsh_review`).** Every completed turn now carries a
36
+ `review` ticket in its result, `structuredContent`, `dsh_get` output and
37
+ async sentinel: a checklist derived from the turn (workspace changed? normal
38
+ stop? which tools ran?), the evidence needed to check it, and a `dsh_review`
39
+ entry. The calling agent can record `pass` / `partial` / `fail` with notes
40
+ and findings; the verdict is written atomically to
41
+ `<state dir>/reviews/<conversation_id>/<run_id>.review.json` and written back
42
+ into the in-memory run and the sentinel (so an async caller no longer sees a
43
+ stale `pending`). `fail`/`partial` require a reason; running/unknown runs are
44
+ refused. Default `advisory`: the ticket is loud but **does not block** the
45
+ next `dsh_send`, and a failed review is not fed back to DSH automatically.
46
+ Set `DSH_MCP_REVIEW=off` (or pass `review: "off"`) to disable tickets.
47
+ Review records share the sentinel's 7-day TTL and credential redaction,
48
+ including checklist items interpolated from `result.error` (Codex review
49
+ finding on #2).
50
+
10
51
  ## [0.5.0] — 2026-11-03
11
52
 
12
53
  ### Process model
package/DESIGN.md CHANGED
@@ -238,6 +238,7 @@ Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundle
238
238
  | B8 | 上下文注入 | `contentBlocks` 支持文本与图片;支持 DSH 的 `@路径` 引用语法把文件带进会话 |
239
239
  | B9 | 异步完成哨兵 | `wait=false` 的 run 终止(done/error/cancelled)时,往 `<RUNS_DIR>/<conversation_id>/<run_id>.json` 原子写出含 `status`+`result` 的文件;调用方(Claude/Codex)用后台任务 `until [ -f ]` 等它出现即被唤醒,无需轮询、也不占轮次 |
240
240
  | B10 | 写锁抢占 | 写锁是跨进程内核信号量(持有者活着不过期、**无夺锁 API**),所以"抢"= 杀掉持有者。本服务登记自己拉起的子进程(`<状态目录>/locks/<会话id>.json`:pid + token + 心跳),据此把持有者分成四类,只对**判定为本服务残留的孤儿**自动动手;无法识别的一律不杀(见下) |
241
+ | B11 | 调用方审核 | 每个完成的回合在 `result.review` 带一张审核单(核验清单 + 证据摘要 + `dsh_review` 入口);调用方独立核验后提交 `pass`/`partial`/`fail`,裁决落盘 `<状态目录>/reviews/<会话id>/<run_id>.review.json`,并回写内存 run 与哨兵。默认 advisory:**不阻塞**后续派活,也不自动回灌给 DSH |
241
242
 
242
243
  **B9 设计要点(并发正确性)**:本服务在 MCP 协议上只应答请求、**不主动推通知**,"跑完通知"由哨兵文件 + 调用方后台等待任务共同实现。
243
244
  - **按会话分目录**:`run_id` 序号是每会话独立计数的,不同会话可能同毫秒生成同名 `run_id`;`<conversation_id>/` 前缀使其各写各的、不串台。会话内 `dsh_send` 忙时直接拒绝、序号单调,故会话内唯一。
@@ -249,6 +250,14 @@ Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundle
249
250
  - **给调用方的便利**:返回值同时给出 `sentinel_file_posix`(`D:\a\b` → `/d/a/b`)。手工转换 Windows 路径是等待任务白等到超时最常见的原因。
250
251
  - **落点**:`tools.dsh_send` 的 `wait=false` 分支在 `.then`/`.catch` 各调一次 `hub.writeRunSentinel()`(覆盖所有终止态);`createRun` 起跑前 `clearRunSentinel()` 防御历史残留。
251
252
 
253
+ **B11 设计要点(调用方审核)**
254
+ - **为什么放进返回载荷**:返回值是"另一个 agent 的自述"。调用方最常见的失败模式是直接转述"已完成/已修好"。审核要求必须和结果出现在同一条消息里(`content.text`、`structuredContent`、哨兵 `result.review`),才可能改变调用方的下一步动作;单独一个"记得审核"的约定会被忽略。
255
+ - **审核单形状**:`{review_id, mode, status, run_id, prompt_preview, checklist, evidence, submit:{tool:"dsh_review", arguments}, instructions}`。清单随回合形状变化(有没有改工作区、有没有错误、用了哪些工具);`evidence` 只放核对所需的最小事实(stop_reason、error、changed_files、工具名)。
256
+ - **默认 advisory,不阻塞**:这是刻意的产品选择。强制门禁会打断"审核失败 → 派修复回合"的自然流程,也会破坏既有调用方;advisory 只把审核请求显性化。`review=off` 可按会话/单次派活关闭;即使 off,`dsh_review` 仍可对旧 run 显式补审核。
257
+ - **裁决落盘与回写**:`reviews/<会话id>/<run_id>.review.json` 原子写入(tmp + rename,唯一 tmp 名),含裁决、理由/findings、清单与证据。`submitReview` 依次从内存 run → 哨兵 → 已有审核记录取事实,所以服务重启、run 被挤出内存后仍可审核。提交后 **原子重写哨兵里的 `result.review`**,避免异步调用方下次读文件看到与事实相反的 `pending`。
258
+ - **隐私**:审核单不含思考正文;理由、findings、任务预览、证据**与每个 checklist 项**在落盘前都走 `redact.sanitizeApprovalContext`(与审批上下文同一套规则)——checklist 会拼接 `result.error`,漏掉它就会成为第二个明文泄漏点。审核记录与哨兵共用 `DSH_MCP_SENTINEL_TTL_MS` 的 7 天清理。
259
+ - **失败语义**:fail/partial 必须有 notes 或 findings;对 `running`/`queued` 的 run 提交会被拒绝;写盘失败直接抛错(审核本身就是这次操作,静默失败比报错更糟)。
260
+
252
261
  **B10 设计要点(写锁与抢占)**
253
262
  - **为什么需要**:DSH 的 `SessionWriteLease` 是 Windows 命名内核信号量,**持有者活着就永不过期**,且**没有 API 能从活进程手里夺走** —— 唯一手段是杀掉持有者进程。既然要杀,就必须先判定那是谁。
254
263
  - **登记**:一个会话一个文件 `<状态目录>/locks/<会话id>.json`,内容 `{mcp_pid, mcp_token, child_pid, cwd, spawned_at, heartbeat_at}`。`mcp_token` 防 PID 复用;`heartbeat_at` 由 reaper 周期刷新。
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.4.0 offline suite green (memory controls, team mode and the dispatch surface are all covered) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
18
+ 🧪 0.5.1 offline suite green, including caller review and input-boundary hardening · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
19
19
 
20
20
  An MCP server that drives DeepSeek Harness as a **long-lived coding agent**. Hand it a goal, let it write code and run scripts, steer it mid-flight, and collect the result when you are ready.
21
21
 
@@ -48,6 +48,7 @@ DSH (DeepSeek Harness) can do all of that — it has file/shell tools, skills, s
48
48
  | 🤫 **Silent by default** | Not one byte on stderr, so nothing pollutes your host's logs. stdout carries the protocol and nothing else. |
49
49
  | 🧩 **Capabilities compose** | DSH can mount its own MCP servers (IDA Pro, browsers, your internal tooling), so this is a bridge to a whole toolbox. |
50
50
  | 🛡️ **Read-only that really is read-only** | `read-only` sandbox plus auto-denied approvals, for working on untrusted samples. |
51
+ | 🔎 **Caller review, not blind relay** | Every result carries a `review` ticket (checklist + evidence + `dsh_review` entry). The caller verifies with its own tools, then records `pass` / `partial` / `fail`. Advisory by default — it never blocks the next dispatch, and a failed review is not silently fed back to DSH. |
51
52
 
52
53
  ## 🧭 Positioning: a dispatcher, not a capability aggregator
53
54
 
@@ -76,7 +77,7 @@ multi-role work" line; simple tasks are not forced into a team. Turn it off with
76
77
  profile disables the legacy `subagent` tools and replaces them with nine team
77
78
  tools; teammates share one workspace.
78
79
 
79
- **Only dispatch semantics become tools**: start, send, read, get, interject,
80
+ **Only dispatch semantics become tools**: start, send, read, get, review, interject,
80
81
  interrupt, approve, queue, inbox, status, release, takeover, doctor. No file I/O, no
81
82
  shell, no editor, no task board.
82
83
 
@@ -119,7 +120,8 @@ The second command previews the changes; `--yes` applies them. Reconnect your cl
119
120
  | `dsh_send` | Hand it a task. **Defaults to `wait=false`: returns a `run_id` + `sentinel_file` immediately**, the turn runs in the background and the sentinel file notifies you when it's done (recommended). `wait=true` blocks until the turn ends; hitting `timeout_ms` (default `0` = wait forever) **only downgrades to background — the turn is never cancelled and the result is never lost** |
120
121
  | `dsh_list` | All conversations with live state: `running` / `idle` / `detached`, elapsed time, current tool, output so far. `only_running=true` for active ones |
121
122
  | `dsh_read` | **Cursor-based incremental read** of what a *running* conversation is producing right now. The cursor is a **revision number**: an entry whose text grew since your last read reappears — match it by `seq` and replace its text, don't append again |
122
- | `dsh_get` | Conversation details; with `run_id`, the **final result of that dispatch** (the "collect the result later" entry point). Run records live in memory (last 20) — use the sentinel file after a restart |
123
+ | `dsh_get` | Conversation details; with `run_id`, the **final result of that dispatch** (the "collect the result later" entry point) **plus its review ticket**. Run records live in memory (last 20) — use the sentinel file after a restart |
124
+ | `dsh_review` | **Caller review.** Submit `pass` / `partial` / `fail` for a completed run, with notes and optional findings. The verdict is written to `<state dir>/reviews/<conversation_id>/<run_id>.review.json` and back into the in-memory run and sentinel. Advisory by default: it never blocks the next `dsh_send` |
123
125
  | `dsh_interject` | Interject while it's busy: `interject` = stop and redirect, `queue` = wait for the current turn, then speak. **This round writes a sentinel too**, and the reply carries its `sentinel_file`. On an idle conversation it behaves like `wait=true` and **blocks until the turn ends** |
124
126
  | `dsh_takeover` | **Preempt the write lock.** The only way out when another DSH process holds it (DSH has no steal API — preempting means killing the holder). It classifies the holder first: our own orphaned child → taken automatically; another live instance → needs `force`; **GUI/unknown → never killed**, reported instead |
125
127
  | `dsh_interrupt` | Stop the current turn (the conversation stays healthy) |
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.4.0 本机离线套件全绿(内存回收、团队模式、派发面工具均有回归) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
18
+ 🧪 0.5.1 本机离线套件全绿,含调用方审核与输入边界加固 · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
19
19
 
20
20
  一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
21
21
 
@@ -48,6 +48,7 @@ DSH(DeepSeek Harness)本身**全都能做** —— 它有文件/shell 工具
48
48
  | 🤫 **默认完全静默** | 一个字节都不输出,不污染宿主日志;stdout 只走协议。 |
49
49
  | 🧩 **能力可叠加** | DSH 自己还能挂别的 MCP(IDA Pro、浏览器、内部工具)——这是一座通往整个工具箱的桥。 |
50
50
  | 🛡️ **只读档真能只读** | `read-only` 沙箱 + 审批一律拒绝,适合分析不信任样本。 |
51
+ | 🔎 **让调用方审核,而不是盲转述** | 每个结果都带 `review` 审核单(清单 + 证据 + `dsh_review` 入口)。调用方用自己的工具核验后记录 `pass` / `partial` / `fail`。默认 advisory:不阻塞下一次派活,审核不通过也不会被悄悄回灌给 DSH。 |
51
52
 
52
53
  ## 🧭 定位:本服务只负责"派活",能力复用 DSH
53
54
 
@@ -71,7 +72,7 @@ profile 切到 `dsh-team`(含实验性 Agent Teams),`dsh_send` 会提示"
71
72
  关闭:`dsh-any-agent team off`;单次关闭:`dsh_send(team=false)`。
72
73
  注意 team profile 会关闭旧的 `subagent` 工具,换成 9 个团队工具;成员共享一个工作区。
73
74
 
74
- **只有"派发语义"本身需要的才做成工具**:建会话、派活、读进度、验收、插话、中断、审批、
75
+ **只有"派发语义"本身需要的才做成工具**:建会话、派活、读进度、验收、审核、插话、中断、审批、
75
76
  队列、收件箱、状态、释放、抢占、体检。文件读写 / shell / 编辑器 / 任务板都不做。
76
77
 
77
78
  **什么时候需要外层再加东西**:只有当你需要"保证"而不是"行为"时——
@@ -114,7 +115,8 @@ dsh-any-agent-setup --yes
114
115
  | `dsh_send` | 派任务。**默认 `wait=false`:立刻返回 `run_id` + `sentinel_file`**,回合在后台跑,跑完由哨兵文件通知你(推荐)。`wait=true` 才原地阻塞到回合结束;到期(`timeout_ms`,默认 0 = 一直等)**只会转后台,不取消回合、不丢结果** |
115
116
  | `dsh_list` | 所有会话及运行状态:`running` / `idle` / `detached`,已跑时长、当前工具、本轮输出。`only_running=true` 只看活动会话 |
116
117
  | `dsh_read` | **游标式增量读**——看正在跑的会话此刻吐出了什么。cursor 是**修订号**:文本被追加过的条目会再次返回,按 `seq` 替换全文(不要重复累加) |
117
- | `dsh_get` | 会话详情;带 `run_id` 时返回**那一次派活的完整结果**("回头验收"的入口)。run 记录只在内存(最近 20 条),服务重启后请读哨兵文件 |
118
+ | `dsh_get` | 会话详情;带 `run_id` 时返回**那一次派活的完整结果 + 审核单**("回头验收"的入口)。run 记录只在内存(最近 20 条),服务重启后请读哨兵文件 |
119
+ | `dsh_review` | **调用方审核**。对已完成的 run 提交 `pass` / `partial` / `fail`,可带理由与 findings。裁决写入 `<状态目录>/reviews/<conversation_id>/<run_id>.review.json`,并回写内存 run 与哨兵。默认 advisory:**不阻塞**下一次 `dsh_send` |
118
120
  | `dsh_interject` | 会话在忙时插话:`interject` 打断并改口,`queue` 等本轮跑完接着说。**这一轮同样会写哨兵**,返回里给 `sentinel_file`。会话空闲时等价于 `wait=true`,**会阻塞到回合结束** |
119
121
  | `dsh_takeover` | **抢占写锁**。锁被别的 DSH 进程占着时的唯一出路(DSH 没有夺锁 API,抢 = 杀掉持有者)。先判定持有者:本服务残留的孤儿 → 自动抢;另一个活着的实例 → 需 `force`;**GUI/未知 → 绝不杀**,只报告 |
120
122
  | `dsh_interrupt` | 打断当前回合(会话不会损坏) |
package/USAGE-NOTES.en.md CHANGED
@@ -131,6 +131,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
131
131
  2. **One conversation per task** — mixing unrelated work pollutes history and costs more.
132
132
  3. **Split long work into dispatch + collect**: `wait=false` → do something else → `dsh_get(run_id)`.
133
133
  4. **Review output via workspace changes** — `workspace_changed` is true when the `git status` entry set changed across the turn; `diff_stat` lists the current dirty files (it is not a per-turn diff).
134
- 5. **Ask for a plan first** if you want to approve before execution (DSH has plan mode).
135
- 6. **Three cost knobs**: lower `reasoning_effort`, keep `reasoning=hide`, release and start fresh.
136
- 7. **Don't paste large code blocks into the prompt** — tell it which file to read instead; DSH has its own file tools.
134
+ 5. **Review the result, don't just relay it** — every result carries a `review` ticket. Check at least one claim with your own tools (read the changed file, inspect `git diff`, run the relevant test), then record `pass` / `partial` / `fail` with `dsh_review`. Default `advisory` does not block the next send; `fail`/`partial` need a reason, and a failed review is not fed back to DSH automatically.
135
+ 6. **Ask for a plan first** if you want to approve before execution (DSH has plan mode).
136
+ 7. **Three cost knobs**: lower `reasoning_effort`, keep `reasoning=hide`, release and start fresh.
137
+ 8. **Don't paste large code blocks into the prompt** — tell it which file to read instead; DSH has its own file tools.
package/USAGE-NOTES.md CHANGED
@@ -272,6 +272,7 @@
272
272
  2. **一个会话一件事**。别往一个会话里塞互不相关的任务,历史会互相污染且越来越贵。
273
273
  3. **长任务拆成"派活 + 验收"**:`wait=false` → 干别的 → `dsh_get(run_id)`。
274
274
  4. **要审查产出就用工作区变更**:`workspace_changed` 表示本回合前后 `git status` 的条目集合发生了变化;`diff_stat` 列出当前仍处于改动状态的文件(不是本回合的精确 diff)。
275
- 5. **要它先想清楚再动手**:让它先给方案、你确认后再执行(DSH 有计划模式)。
276
- 6. **省钱的三个旋钮**:`reasoning_effort` 调低、`reasoning` 保持 `hide`、及时 `dsh_release` 并开新会话。
277
- 7. **别在提示词里重复贴大段代码**——告诉它去看哪个文件更划算(DSH 自己有文件工具)。
275
+ 5. **审核结果,别直接转述**:每个结果都带 `review` 审核单。用自己的工具核验至少一项关键声明(读改动的文件、看 `git diff`、跑相关测试),再用 `dsh_review` 记录 `pass` / `partial` / `fail`。默认 `advisory` 不阻塞下一次派活;`fail`/`partial` 必须给理由,且审核不通过不会自动回灌给 DSH。
276
+ 6. **要它先想清楚再动手**:让它先给方案、你确认后再执行(DSH 有计划模式)。
277
+ 7. **省钱的三个旋钮**:`reasoning_effort` 调低、`reasoning` 保持 `hide`、及时 `dsh_release` 并开新会话。
278
+ 8. **别在提示词里重复贴大段代码**——告诉它去看哪个文件更划算(DSH 自己有文件工具)。
@@ -8,6 +8,8 @@
8
8
  * DSH_MCP_STATE 会话注册表路径(默认 $DSH_HOME/.dsh-any-agent/conversations.json)
9
9
  * DSH_MCP_PERMISSION 新建会话的默认权限档(默认 danger-full-access)
10
10
  * DSH_MCP_APPROVAL 默认审批策略(非只读档默认 auto-allow;ask = 挂起待裁决)
11
+ * DSH_MCP_REVIEW 调用方审核策略(默认 advisory;off 关闭自动审核单)
12
+ * DSH_MCP_REVIEWS_DIR 审核记录目录(默认 <状态文件同目录>/reviews)
11
13
  * DSH_MCP_IDLE_TTL_MS 空闲多久回收会话进程(默认 0 = 永不回收)
12
14
  * DSH_MCP_PROMPT_TIMEOUT_MS wait=true 时的等待上限(默认 0 = 不设超时)
13
15
  * DSH_MCP_LOCK_STALE_MS 写锁登记多久没心跳算"持有者失联"(默认 90 秒)
@@ -46,6 +48,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
46
48
  ' DSH_MCP_PROFILE DSH profile to drive (default dsh-mcp)\n' +
47
49
  ' DSH_MCP_PERMISSION danger-full-access | workspace-write | read-only\n' +
48
50
  ' DSH_MCP_APPROVAL auto-allow | auto-deny | ask\n' +
51
+ ' DSH_MCP_REVIEW advisory (default) | off — caller review ticket on results\n' +
49
52
  ' DSH_MCP_LOG silent (default) | info | debug\n\n' +
50
53
  'Docs: https://github.com/xmtlzzz/dsh-any-agent\n',
51
54
  );
@@ -122,9 +125,11 @@ await hub.load();
122
125
  hub.startReaper();
123
126
  // 启动时清一次过期哨兵(哨兵内含正文,长期不清理会无声堆积)
124
127
  const prunedSentinels = hub.pruneSentinels();
125
- if (prunedSentinels.pruned) {
128
+ if (prunedSentinels.pruned || prunedSentinels.reviews_pruned) {
126
129
  log.info(
127
- `[dsh-any-agent] 启动清理:删除 ${prunedSentinels.pruned} 个过期哨兵、${prunedSentinels.dirs_removed} 个空目录`,
130
+ `[dsh-any-agent] 启动清理:删除 ${prunedSentinels.pruned} 个过期哨兵、` +
131
+ `${prunedSentinels.reviews_pruned ?? 0} 条过期审核记录、` +
132
+ `${prunedSentinels.dirs_removed + (prunedSentinels.review_dirs_removed ?? 0)} 个空目录`,
128
133
  );
129
134
  }
130
135
  // 写锁登记清理:删掉"MCP 已死 + 子进程也死了"的记录;**保留**子进程还活着的那些 ——
@@ -25,8 +25,10 @@
25
25
  | `DSH_MCP_SHARED_PROCESS` | `1` (on) | **On by default**: sessions with the same `(cwd, permission)` share one ACP process. Large memory and cold-start win (measured: second `session/new` ≈300 ms vs ≈4 s standalone), and `dsh_doctor` de-dupes RSS by PID. Trade-off: a process crash takes down the whole group (error sentinels are written; each session resumes on the next call), so `dsh_takeover` requires `force=true` to kill such a process. Set `0` for one process per conversation |
26
26
  | `DSH_MCP_AUDIT_FILE` | empty (off) | Metadata audit JSONL path. Records events, conversation/run ids, decisions and counts; **never** prompts, answers, thinking or tool input. Rotates at 10 MB |
27
27
  | `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | Cache lifetime for `dsh_list`'s on-disk probe (the probe spawns a DSH process and takes ~1 s) |
28
- | `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
28
+ | `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels **and review records**; `0` disables pruning |
29
29
  | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**); answer/error/title/tool inputs are still credential-redacted |
30
+ | `DSH_MCP_REVIEW` | `advisory` | Caller-review policy. `advisory` (default) attaches a review ticket (checklist + evidence + `dsh_review` entry) to every result and sentinel **without blocking later sends**; `off` disables automatic tickets. Per-conversation/per-send overrides use the `review` argument |
31
+ | `DSH_MCP_REVIEWS_DIR` | `<state file dir>/reviews` | Where review verdicts are written (`<this>/<conversation_id>/<run_id>.review.json`). Must be writable, or startup fails loudly |
30
32
  | `DSH_MCP_PERMISSION` | `workspace-write` | Default tier for `dsh_start`. `danger-full-access` additionally requires `DSH_MCP_ALLOW_FULL_ACCESS=1` |
31
33
  | `DSH_MCP_ALLOW_FULL_ACCESS` | `0` | Whether `permission=danger-full-access` is allowed. Denied by default: it hands the caller whole-machine write access |
32
34
  | `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
@@ -12,6 +12,12 @@
12
12
 
13
13
  **`read-only` is enforced, not merely a default**: it always runs with `auto-deny`; passing `auto-allow` or `ask` for a read-only conversation is rejected at `dsh_start`, and the approval layer refuses any permission request from a read-only conversation. After create/resume the server reads back the session's recorded tier; for read-only it **fails closed** if the projection cache has no permission record within the DSH flush window, rather than relying on the approval layer alone. Without that, a profile `defaultPreset` plus a permissive approval policy could let a write through (found by the adversarial audit).
14
14
 
15
+ ## 🧷 Input boundaries
16
+
17
+ - **`conversation_id` / `run_id` are path-safe ids.** They are used in sentinel, review, metadata and tombstone paths; the server accepts only a single segment (letters/digits/`._-`, max 200) and rejects separators or `..` before any `join()`. This covers `dsh_get`'s sentinel read-back and `dsh_review`'s `<state>/reviews/<conv>/<run>.review.json` write.
18
+ - **`cwd` cannot be the filesystem root for writable tiers.** `workspace-write` at `/` or `C:\` would make the sandbox root the whole disk; the home/root guard resolves symlinks first, so a symlink to the root or home is rejected too. `danger-full-access` remains an explicit opt-in.
19
+ - **`title_hint` is capped at 200 characters** (schema + runtime), because it is persisted in the registry and sidecar on every turn.
20
+
15
21
  ## 🪟 Workspace visibility in the DSH GUI
16
22
 
17
23
  Two different things — don't confuse them:
@@ -50,10 +56,11 @@ Silent by default — **not one byte is written**.
50
56
  | `debug` | more of our own events; still no DSH output |
51
57
  | `DSH_MCP_LOG_STDERR=1` | additionally forward DSH's raw stderr (**may contain reasoning**) |
52
58
 
53
- **This server never writes reasoning to disk** — with exactly one deliberate exception:
59
+ **This server never writes reasoning to disk** — it does persist the content a caller needs to collect or review a result offline, but never reasoning text:
54
60
 
55
61
  - Not in logs (silent by default). The state file and the per-conversation metadata sidecars under `<state dir>/meta/` hold metadata only (id, cwd, permissions, counters, title hint) — a smoke-test assertion guards the registry.
56
62
  - **The async completion sentinel persists the answer, usage and tool inputs** (it must, so a caller can collect offline) but **strips `result.thinking`** and always reports `thinking_omitted` (true when reasoning was removed, false when there was none). Tool inputs are passed through the same credential redaction as approvals, but the sentinel still contains commands and file contents — treat it as sensitive. A test asserts that even when `reasoning=full` is requested, no reasoning text appears in the sentinel — while `dsh_get(run_id)` still returns it from memory. Set `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` to break this on purpose.
57
- - Sentinels are pruned after 7 days by default (`DSH_MCP_SENTINEL_TTL_MS`; `0` keeps them forever).
63
+ - **Review records are the second intentional on-disk artifact.** `<state dir>/reviews/<conversation_id>/<run_id>.review.json` holds the verdict, the reviewer's `notes`/`findings`, the checklist and an evidence summary. `notes`/`findings`, the task preview, the evidence **and each checklist item** pass through the same credential redaction (the checklist can interpolate `result.error`); reasoning is never included. Treat review records as sensitive too (they can quote code or paths).
64
+ - Sentinels **and review records** are pruned after 7 days by default (`DSH_MCP_SENTINEL_TTL_MS`; `0` keeps them forever).
58
65
 
59
66
  > ⚠️ **DSH writes its own session log under `~/.dsh/sessions` and that log contains the full reasoning text** (even with `reasoning=hide`). It is what makes resume possible and is outside this server's control. The absolute "reasoning never reaches disk" claim applies to *this server's* artifacts only.
package/docs/en/tests.md CHANGED
@@ -16,10 +16,10 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
16
16
 
17
17
  | Suite | Checks | Covers |
18
18
  |---|---|---|
19
- | `smoke` | 29 | handshake, tool table, conversation creation, config, registry purity |
19
+ | `smoke` | 31 | handshake, tool table (15 tools), conversation creation, config, registry purity, prototype-name tool rejection |
20
20
  | `boundary` | 46 | protocol edges (double initialize, malformed lines, unknown method), argument validation (including read-only + permissive approval rejection, unknown provider/model, and no stale write-lock marker on a failed create), unknown ids, cursor edges, lifecycle idempotence, unicode |
21
21
  | `integration` | 30 | resume-with-memory across processes, interrupt, both interject modes |
22
- | `async` | 16 | fire-and-forget + later collection |
22
+ | `async` | 21 | fire-and-forget + later collection, including the review ticket in the receipt/result/sentinel and a real `dsh_review` round trip |
23
23
  | `sentinel` | 36 | completion sentinel: atomicity, latch semantics, per-conversation namespacing under concurrency, cancelled runs still land it, and **reasoning never reaching the file** |
24
24
  | `prune` | 11 | **sentinel retention**: prunes over-age and crash-leftover files, keeps fresh ones, removes empty shells, `TTL=0` disables pruning, and never touches the conversation registry beside it (no tokens, runs every time) |
25
25
  | `guards` | 40 | **environment-variable guards**: odd numbers always land on the safe side — `IDLE_TTL_MS=-1` means "never reap", garbage/empty falls back to the 10-minute default, a too-small reap interval falls back to the default (no busy loop), and the **lost-holder threshold is floored at 30 s** (so another instance cannot "legitimately" preempt and kill our turn); plus the team-mode default and precedence (`DSH_MCP_TEAM` > `settings.json` > off) (no tokens, runs every time) |
@@ -32,6 +32,7 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
32
32
  | `line-decoder` | 4 | **UTF-8 line framing**: multi-byte characters split across MCP/ACP stream chunks are preserved (no U+FFFD), including CRLF and EOF-without-newline (no tokens) |
33
33
  | `read-cursor` | 10 | **`dsh_read` cursor semantics**: text appended to an already-read entry is delivered again (revision cursor, match by `seq`), and `limit` is clamped to 1..500 so `0`/negative cannot spin or drop the tail (no tokens) |
34
34
  | `sentinel-redaction` | 19 | **sentinel disk hygiene**: `result.thinking` is stripped, `thinking_omitted` is always a boolean, and `answer`/`error`/`title`/`tools_used[].input` all pass the same credential redaction as approvals (no tokens) |
35
+ | `review` | 70 | **caller review**: default `advisory` tickets carry a checklist + evidence + `dsh_review` entry; `dsh_review` writes the verdict to disk and back into the run and sentinel; `fail`/`partial` require notes; running/unknown runs are refused; review still works after a restart (sentinel-backed); queue and `client_run_id` replay receipts carry the ticket; review records share the sentinel TTL; notes, previews, evidence **and checklist items** are credential-redacted; **path-traversal ids are rejected before any file I/O** (no tokens) |
35
36
  | `forget-tombstone` | 10 | **`forget` is terminal**: `release(forget=true)` writes a tombstone, by-id lookups fail with a recovery path, lists exclude it, and deleting the tombstone restores the normal unknown-session path (no tokens) |
36
37
  | `bootstrap-profile` | 24 | **first-start profile works in the published zero-dependency form**: copies routing with the built-in YAML subset parser, strips `apiKey`/`headers`/literal secrets, never writes `defaultPreset`, is idempotent, and leaves a user-maintained profile byte-identical (no tokens) |
37
38
  | `workspace-safety` | 23 | **data-loss boundaries**: `isTempPath` is segment-aware (`tmpdir()+'Suffix'` and `/tmpfoo` are *not* temp), a corrupt `workspace.json` is never overwritten, an unreadable session root never wipes `sessionIds`, and automatic cleanup deletes only test-created sessions (no tokens) |
@@ -43,7 +44,7 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
43
44
  | `real-configs` | 84 (66 with 7/9, 48 with 5/9 fixtures) | **round-trip on the real thing**: copies this machine's actual client configs (an 182693-byte `~/.claude.json` holding hundreds of `projects` entries and case-colliding keys, a 238-line Codex TOML, Zed's full `settings.json`) into a temp dir, registers, then removes, and requires every file back **byte for byte**; entry-presence is checked by key (not a bare substring), and missing fixtures are reported as skipped rather than green (no tokens) |
44
45
  | `commit-script` | 26 | commit messages go through stdin, never a temp file that `git add -A` could capture (no tokens) |
45
46
  | `roots` | 12 | `dsh_start` without `cwd` asks the client for MCP roots; a client without roots gets an actionable error (no tokens) |
46
- | `workspace-titles` | 15 | home-as-workspace is rejected before DSH starts; `title_hint` is returned separately from DSH's automatic title (no tokens) |
47
+ | `workspace-titles` | 18 | home-as-workspace **and filesystem-root cwd** are rejected before DSH starts; `title_hint` is returned separately from DSH's automatic title (no tokens) |
47
48
  | `package-check` | 14 | `files` whitelist, zero runtime dependencies, Node floor, and bin shebangs (no tokens) |
48
49
  | `timeout` | 38 | **timeout semantics**: `wait=false` is unaffected by `timeout_ms`; a `wait=true` expiry merely downgrades to background (turn not cancelled, result not lost, `busy` never lies); `timeout_ms<=0` waits forever; a failed resume invalidates the process instead of wedging the conversation; an empty prompt yields a clear error |
49
50
  | `lock` | 79 | **write lock and preemption**: four holder classifications, `writeMarker` never overwriting a live holder, malformed/missing registration edge cases; **end-to-end** with two real MCP instances fighting over one conversation → clear error → refusal → `force` takeover; **a crash releases the lock automatically**, **a wedged holder is preempted without `force`**; plus the "one turn, one sentinel" invariant (inline turns and idle interjects included) |
@@ -57,13 +58,13 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
57
58
  | `list` | 13 | **the cost of `dsh_list`** (measured): a default call with the on-disk probe takes ~1 s (it spawns a DSH process) while **repeated calls hit a cache and drop to single-digit milliseconds**; `only_running=true` and `include_closed=false` **skip the probe** with equivalent semantics (unopened on-disk sessions are simply excluded) |
58
59
  | `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
59
60
 
60
- **`node test/run.mjs` runs 937 checks on a machine with seven client fixtures (955 with all nine)** — smoke 29 + 908 offline checks (926 with nine). Every suite exited 0 on 0.5.0 (2026-11-03, Windows / Node 24.21.0); none needs a provider (smoke skips its session-creation sections when DSH is absent).
61
+ **`node test/run.mjs` runs 1012 checks on a machine with seven client fixtures (1030 with all nine)** — smoke 31 + 981 offline checks (999 with nine). Every suite exited 0 on the 0.5.0 tree plus caller review (2026-11-04, Windows / Node 22.19.0; the local five-fixture run reports 994); none needs a provider (smoke skips its session-creation sections when DSH is absent).
61
62
 
62
63
  That is **542 assertions in the client-registration suites alone** (`setup` 45, `removal-scope` 24, `config-safety` 407, `real-configs` 66; 560 with all nine fixtures), which is where the 0.3 work actually is.
63
64
 
64
- The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.9 — they need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`).
65
+ The model-backed suites (`smoke` 31, `boundary` 46, `integration` 30, `async` 21, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8, `shared-process` 8) total 489. Most were last measured in full on 0.3.9; the suites touched by caller review (`smoke`, `integration`, `async`, `sentinel`, `boundary`) were re-run on this tree. They need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`); `shared-process` also skips without DSH.
65
66
 
66
- > Three different totals are intentional: `node test/run.mjs` (937/955), the CI offline job (nominal 908/926, no smoke; hosted runners without client fixtures skip `real-configs` entirely), and the model-backed set (474 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
67
+ > Three different totals are intentional: `node test/run.mjs` (1012/1030), the CI offline job (nominal 981/999, no smoke; hosted runners without client fixtures skip `real-configs` entirely), and the model-backed set (489 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
67
68
 
68
69
  ### 🧯 Why `sandbox-guard` exists
69
70
 
package/docs/en/usage.md CHANGED
@@ -17,14 +17,20 @@ MCP tool calls **block**, so `dsh_send` **does not block by default** (`wait=fal
17
17
  → [me] … | [turn] turn 1 started | [tool] pwsh [in_progress]
18
18
 
19
19
  5. dsh_get(conversation_id, run_id) # collect the result
20
- → status=done, elapsed=20.3s, full answer
20
+ → status=done, elapsed=20.3s, full answer,
21
+ review: { status: "pending", checklist: [...], submit: { tool: "dsh_review", ... } }
22
+
23
+ 6. dsh_review(conversation_id, run_id, verdict="pass", notes="checked the diff and ran the tests")
24
+ → review: { status: "pass", reviewed_at: "...", review_file: "..." }
21
25
  ```
22
26
 
23
- `run_id` is the receipt. Run records are **memory-only, last 20 kept**; under many concurrent runs old ones get evicted and `dsh_get` will fail — which is exactly why the completion sentinel below carries the result.
27
+ **A returned result is not "done" until the caller has reviewed it.** Every result carries a `review` ticket: a checklist derived from the turn (did it change files? did it end normally? which tools ran?) plus the evidence needed to check it. Use your own tools to verify at least one claim — read the changed files, inspect `git diff`, run the relevant test — then submit a verdict with `dsh_review`. The default `advisory` mode does **not** block the next `dsh_send`; it only makes the review request part of the returned payload instead of something the caller has to remember. `fail` / `partial` require `notes` (or `findings`). A failed review is **not** fed back to DSH automatically — dispatch a fix with `dsh_send` when you decide to.
28
+
29
+ `run_id` is the receipt. Run records are **memory-only, last 20 kept**; under many concurrent runs old ones get evicted and `dsh_get` will fail — which is exactly why the completion sentinel below carries the result (and why `dsh_review` can still read the run from the sentinel).
24
30
 
25
31
  ## 🔔 Workflow: get notified on completion (no polling)
26
32
 
27
- Besides `run_id`, `wait=false` returns a **`sentinel_file`** path plus **`sentinel_file_posix`** (the Bash/MSYS form — use it directly; hand-converting Windows paths is the easiest way to make your waiter hang until timeout). When the turn ends — **whether it succeeds, fails, or is cancelled** — the service **atomically writes** a small file there containing the final `status` and `result`. So you neither poll nor block: **wait for that file with a background task in your own host (Claude/Codex); the moment it appears, you're woken up.**
33
+ Besides `run_id`, `wait=false` returns a **`sentinel_file`** path plus **`sentinel_file_posix`** (the Bash/MSYS form — use it directly; hand-converting Windows paths is the easiest way to make your waiter hang until timeout). When the turn ends — **whether it succeeds, fails, or is cancelled** — the service **atomically writes** a small file there containing the final `status`, `result` and the **review ticket** (`result.review`). So you neither poll nor block: **wait for that file with a background task in your own host (Claude/Codex); the moment it appears, you're woken up.**
28
34
 
29
35
  ```bash
30
36
  # sent="$sentinel_file_posix" ← copy it straight from the tool result
@@ -45,9 +51,10 @@ Design notes (especially under **concurrent multi-session** use):
45
51
  - **Latch semantics**: the file is written once and kept. Even if the run finishes *before* you arm the waiter, `[ -f ]` is immediately true — you never miss it.
46
52
  - **Atomic write**: `tmp` + `rename`; if the file exists its contents are complete. Each run uses its own tmp, so concurrent writes never clobber.
47
53
  - **No server push**: the service only answers requests over MCP (it sends no notifications). "Completion notification" is entirely the sentinel file plus your background waiter.
54
+ - **Review survives the sentinel**: `result.review` is written with the result, so a caller that only reads the file still sees the checklist. `dsh_review` then **atomically rewrites** that review block (`status` → `pass`/`partial`/`fail`, plus `notes`, `reviewed_at`, `review_file`); the latch itself never disappears. Review records live under `<state dir>/reviews/` and are pruned on the same 7-day TTL as sentinels.
48
55
  - **★ No reasoning in the sentinel**: the payload carries the answer, thinking *statistics*, usage and (credential-redacted) tool inputs, but never reasoning text — `result.thinking` is stripped and replaced by a boolean `thinking_omitted` (`true` when reasoning was removed, `false` when there was none), because this server's rule is that reasoning never reaches its own disk artifacts. `dsh_get(run_id)` still returns it from memory while the record lives. `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` breaks that guarantee deliberately. Note that **DSH's own session log does contain reasoning** — see [Logging and privacy](security.md#-logging-and-privacy).
49
56
 
50
- > Cleanup: sentinels contain full answers, so the service **prunes sentinels older than 7 days at startup** (tune with `DSH_MCP_SENTINEL_TTL_MS`; `0` disables). Your waiter should still `rm` the file after consuming it.
57
+ > Cleanup: sentinels and review records contain full answers/notes, so the service **prunes them once they are older than 7 days at startup** (tune with `DSH_MCP_SENTINEL_TTL_MS`; `0` disables). Your waiter should still `rm` the file after consuming it.
51
58
 
52
59
  ## 🧵 Workflow: many conversations at once
53
60
 
@@ -25,8 +25,10 @@
25
25
  | `DSH_MCP_SHARED_PROCESS` | `1`(开启) | **默认开启**:同 `(cwd, permission)` 的会话共享一个 ACP 进程。显著省内存与冷启动(实测第二个会话 `session/new` 约 300ms,独立进程约 4s),`dsh_doctor` 的 RSS 也按 PID 去重。代价:进程崩溃会连坐同组会话(统一写 error 哨兵,之后各自 resume),所以 `dsh_takeover` 杀这类进程时要求 `force=true`。设 `0` 回到"一会话一进程" |
26
26
  | `DSH_MCP_AUDIT_FILE` | 空(关闭) | 元数据审计 JSONL 路径。只记事件、会话/回合 ID、决定与计数;**不记** prompt、正文、思考、工具输入。10MB 滚动到 `.1` |
27
27
  | `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | `dsh_list` 磁盘探测结果的缓存时长(探测要 spawn 一个 DSH 进程,实测约 1 秒) |
28
- | `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵;`0` 关闭清理 |
28
+ | `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵**与审核记录**;`0` 关闭清理 |
29
29
  | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**);answer/error/title/工具输入仍会脱敏 |
30
+ | `DSH_MCP_REVIEW` | `advisory` | 调用方审核策略。`advisory`(默认)给每个结果/哨兵附审核单(清单 + 证据 + `dsh_review` 入口),**但不阻塞**后续派活;`off` 关闭自动审核单。单会话/单次派活可用 `review` 参数覆盖 |
31
+ | `DSH_MCP_REVIEWS_DIR` | `<状态文件同目录>/reviews` | 审核裁决的落盘目录(`<此目录>/<会话id>/<run_id>.review.json`)。不可写会让服务启动时明确失败 |
30
32
  | `DSH_MCP_PERMISSION` | `workspace-write` | `dsh_start` 未指定时的权限档。设为 `danger-full-access` 还需要 `DSH_MCP_ALLOW_FULL_ACCESS=1` |
31
33
  | `DSH_MCP_ALLOW_FULL_ACCESS` | `0` | 是否允许 `permission=danger-full-access`。默认拒绝:该档把整机写权限交给调用方 |
32
34
  | `DSH_MCP_REASONING_EFFORT` | `max` | 默认思考深度 |
@@ -12,6 +12,12 @@
12
12
 
13
13
  **`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start` 被直接拒绝,审批层也会拒绝来自只读会话的任何权限请求。建会话/resume 后还会回读会话自己记录的权限档;只读档如果在 DSH 投影缓存刷盘窗口内仍读不到记录,会 **fail-closed 拒绝继续**,而不是只依赖审批层兜底。否则 profile 的 `defaultPreset` 加宽松审批策略可能放行写操作(对抗审计实测过)。
14
14
 
15
+ ## 🧷 输入边界
16
+
17
+ - **`conversation_id` / `run_id` 是路径安全的单段 id**:它们会参与哨兵、审核、元数据与墓碑的路径拼接;服务只接受字母/数字/`._-` 组成的单段(最长 200),带分隔符或 `..` 的值在 `join()` 之前就被拒绝。覆盖 `dsh_get` 的哨兵回读与 `dsh_review` 的 `<状态目录>/reviews/<会话>/<run>.review.json` 写入。
18
+ - **可写档位下 `cwd` 不能是文件系统根目录**:`workspace-write` 选 `/` 或 `C:\` 会让沙箱根变成整个磁盘;家目录/根目录守卫会先解析 symlink,所以指向根目录或家目录的软链也会被拒绝。`danger-full-access` 仍需显式开启。
19
+ - **`title_hint` 最长 200 字**(schema + 运行时):它会写进注册表与侧车,且每轮都会重复落盘。
20
+
15
21
  ## 🪟 会话在 DSH GUI 里的可见性
16
22
 
17
23
  两件事,别混淆:
@@ -50,10 +56,11 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
50
56
  | `debug` | 更细的本服务事件;仍不打印 DSH 输出 |
51
57
  | `DSH_MCP_LOG_STDERR=1` | 额外转发 DSH 原始 stderr(**可能含思考内容**) |
52
58
 
53
- **本服务不把思考内容写进自己的落盘物**,只有一个**刻意留下**的例外:
59
+ **本服务不把思考内容写进自己的落盘物**;它只会落盘调用方离线收活/审核所需的正文,但从不落盘思考文本:
54
60
 
55
61
  - 不写日志(默认静默);状态文件与 `<状态目录>/meta/` 下的会话元数据侧车都只有元数据(id、cwd、权限、计数、标题提示),冒烟测试里有一条断言专门守注册表。
56
62
  - **异步完成哨兵需要把正文写进文件**(否则调用方无法离线收活),载荷还包含用量与(已按审批规则脱敏的)工具输入,但 `result.thinking` 会被**剥掉**,并始终给出布尔 `thinking_omitted`。测试里专门验证了:即使请求 `reasoning=full`,哨兵里也不出现任何思考文本——而同一 run 在内存(`dsh_get`)里仍能看到。设 `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` 可刻意打破这条。
57
- - 哨兵默认 7 天后由启动清理删除(`DSH_MCP_SENTINEL_TTL_MS`,`0` 表示永久保留)。
63
+ - **审核记录是第二个刻意落盘的产物**:`<状态目录>/reviews/<会话id>/<run_id>.review.json` 保存裁决、理由/findings、清单与证据摘要。理由、findings、任务预览、证据**以及每个清单项**都走同一套凭证脱敏(清单会拼接 `result.error`),且**不含思考正文**;但它可能引用代码或路径,同样按敏感数据对待。
64
+ - 哨兵**与审核记录**默认 7 天后由启动清理删除(`DSH_MCP_SENTINEL_TTL_MS`,`0` 表示永久保留)。
58
65
 
59
66
  > ⚠️ **DSH 自己会在 `~/.dsh/sessions` 写会话日志,而且其中包含完整 reasoning 文本**(即使 `reasoning=hide` 也照存)。那是 resume 的依据,不在本服务控制范围内;“思考不落盘”的绝对表述只适用于**本服务自己的**日志/哨兵/注册表。
package/docs/zh/tests.md CHANGED
@@ -15,10 +15,10 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
15
15
 
16
16
  | 套件 | 检查数 | 覆盖 |
17
17
  |---|---|---|
18
- | `smoke` | 29 | 握手、工具表、建会话、配置、注册表纯净性 |
18
+ | `smoke` | 31 | 握手、工具表(15 个工具)、建会话、配置、注册表纯净性、原型链工具名拒绝 |
19
19
  | `boundary` | 46 | 协议边界(重复 initialize、脏行、未知方法)、参数校验(含 read-only + 宽松审批必须拒绝、未知 provider/model、失败创建不留下写锁登记)、未知 id、游标边界、生命周期幂等、unicode |
20
20
  | `integration` | 30 | 跨进程 resume 与记忆、中断、两种插话 |
21
- | `async` | 16 | fire-and-forget + 事后验收 |
21
+ | `async` | 21 | fire-and-forget + 事后验收,含收据/结果/哨兵里的审核单,以及一次真实 `dsh_review` 往返 |
22
22
  | `sentinel` | 36 | 完成哨兵:原子性、闩锁语义、多会话并发不串台、被取消也落地、**思考不进文件** |
23
23
  | `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
24
24
  | `guards` | 40 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1` 当"永不回收"、非法值/空串回落默认 10 分钟、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合);以及团队模式的默认值与优先级(`DSH_MCP_TEAM` > `settings.json` > 默认关)(不花 token,每次必跑) |
@@ -31,6 +31,7 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
31
31
  | `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
32
32
  | `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
33
33
  | `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
34
+ | `review` | 70 | **调用方审核**:默认 `advisory` 审核单带清单 + 证据 + `dsh_review` 入口;`dsh_review` 把裁决落盘并回写内存 run 与哨兵;`fail`/`partial` 必须给理由;未完成/未知 run 被拒绝;重启后仍可凭哨兵审核;队列与 `client_run_id` 重放收据也带审核单;审核记录与哨兵共享 TTL;理由、预览、证据**与清单项**都凭据脱敏;**路径穿越 id 在触盘前就被拒绝**(不花 token) |
34
35
  | `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
35
36
  | `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
36
37
  | `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
@@ -42,7 +43,7 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
42
43
  | `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
43
44
  | `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
44
45
  | `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
45
- | `workspace-titles` | 15 | 家目录当工作区会被提前拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
46
+ | `workspace-titles` | 18 | 家目录**与文件系统根目录**当工作区都会在 DSH 启动前被拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
46
47
  | `package-check` | 14 | `files` 白名单、零运行时依赖、Node 下限、bin shebang(不花 token) |
47
48
  | `timeout` | 38 | **超时语义**:`wait=false` 不受 `timeout_ms` 影响、`wait=true` 到期只转后台(不取消/不丢结果、`busy` 不说谎)、`timeout_ms<=0` 一直等、resume 失败会作废进程而不永久卡死、空 prompt 给清晰错误 |
48
49
  | `lock` | 79 | **写锁与抢占**:四类持有者判定、`writeMarker` 不覆盖活着的持有者、登记文件损坏/目录缺失等边界;**端到端**两实例抢锁 → 清晰报错 → 拒绝 → `force` 夺锁;**崩溃自动释放锁**、**卡死持有者无 force 自动抢占**;以及"一个回合 = 一个哨兵"的不变量(含内联回合与空闲插话) |
@@ -56,13 +57,13 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
56
57
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
57
58
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
58
59
 
59
- **`node test/run.mjs` 在本机 7/9 客户端配置时跑 937 项(九种齐全时 955 项)** —— smoke 29 + 离线套件 908 项(九种齐全时 926 项)。**0.5.0 上全部退出码 0**(2026-11-03,Windows / Node 24.21.0);都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
60
+ **`node test/run.mjs` 在本机 7/9 客户端配置时跑 1012 项(九种齐全时 1030 项)** —— smoke 31 + 离线套件 981 项(九种齐全时 999 项)。**0.5.0 加调用方审核后全部退出码 0**(2026-11-04,Windows / Node 22.19.0;本机 5/9 配置时实测 994 项);都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
60
61
 
61
62
  其中 **542 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 66;九种齐全时 560 项)—— 0.3 的活其实都在这里。
62
63
 
63
- 需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.9** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
64
+ 需要模型的套件(`smoke` 31、`boundary` 46、`integration` 30、`async` 21、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8、`shared-process` 8)合计 489 项。多数最近一次完整实测在 0.3.9;与调用方审核相关的 `smoke`/`integration`/`async`/`sentinel`/`boundary` 已在本树重跑。它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**;`shared-process` 没有 DSH 时也会 skip。
64
65
 
65
- > 三个总数是刻意分开的:`node test/run.mjs`(937/955)、CI 离线 job(名义 908/926,不含 smoke;hosted runner 没有客户端 fixture 时 `real-configs` 会整体跳过)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
66
+ > 三个总数是刻意分开的:`node test/run.mjs`(1012/1030)、CI 离线 job(名义 981/999,不含 smoke;hosted runner 没有客户端 fixture 时 `real-configs` 会整体跳过)、需要模型的集合(名义 489)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
66
67
 
67
68
  ### 🧯 为什么会有 `sandbox-guard`
68
69
 
package/docs/zh/usage.md CHANGED
@@ -17,14 +17,20 @@ MCP 工具调用是**阻塞**的,所以 `dsh_send` **默认就不阻塞**(`w
17
17
  → [我] … | [轮次] 第 1 轮开始 | [工具] pwsh [in_progress]
18
18
 
19
19
  5. dsh_get(conversation_id, run_id) # 验收
20
- → status=done, elapsed=20.3s, 完整答复
20
+ → status=done, elapsed=20.3s, 完整答复,
21
+ review: { status: "pending", checklist: [...], submit: { tool: "dsh_review", ... } }
22
+
23
+ 6. dsh_review(conversation_id, run_id, verdict="pass", notes="已看 diff 并跑过测试")
24
+ → review: { status: "pass", reviewed_at: "...", review_file: "..." }
21
25
  ```
22
26
 
23
- `run_id` 是验收凭据。回合记录**只在内存、只留最近 20 条**;并发跑很多 run 时旧记录会被挤掉,`dsh_get` 就会失败——这正是下面"完成哨兵"自带结果的原因。
27
+ **结果返回 ≠ 已完成。** 每个结果都带一张 `review` 审核单:根据这一轮的情况生成核验清单(有没有改文件、回合是否正常结束、用了哪些工具),并附上证据摘要。调用方应该用自己的工具独立核验至少一项关键声明(读改动的文件、看 `git diff`、跑相关测试),再用 `dsh_review` 提交裁决。默认 `advisory` **不阻塞**下一次 `dsh_send`,它的作用是把"先审核"放进返回载荷本身,而不是依赖调用方记得。`fail` / `partial` 必须给 `notes` 或 `findings`。审核不通过**不会**自动回灌给 DSH——要不要再派修复回合由调用方自己决定。
28
+
29
+ `run_id` 是验收凭据。回合记录**只在内存、只留最近 20 条**;并发跑很多 run 时旧记录会被挤掉,`dsh_get` 就会失败——这正是下面"完成哨兵"自带结果(以及 `dsh_review` 仍能凭哨兵审核)的原因。
24
30
 
25
31
  ## 🔔 用法:跑完自动通知(不轮询)
26
32
 
27
- `wait=false` 除了 `run_id`,还会返回 **`sentinel_file`**(哨兵文件路径)以及 **`sentinel_file_posix`**(Bash/MSYS 形式,**直接用它,别无手工转换**——转错了等待任务会一直挂到超时)。回合结束时——**无论成功、失败还是被取消**——本服务都会往这个路径**原子写出**一个含最终 `status` 与 `result` 的小文件。于是你不用轮询、也不用傻等:**用你自己宿主(Claude/Codex)的后台任务等这个文件出现即可,文件一到就会唤醒你。**
33
+ `wait=false` 除了 `run_id`,还会返回 **`sentinel_file`**(哨兵文件路径)以及 **`sentinel_file_posix`**(Bash/MSYS 形式,**直接用它,别无手工转换**——转错了等待任务会一直挂到超时)。回合结束时——**无论成功、失败还是被取消**——本服务都会往这个路径**原子写出**一个含最终 `status`、`result` 与**审核单**(`result.review`)的小文件。于是你不用轮询、也不用傻等:**用你自己宿主(Claude/Codex)的后台任务等这个文件出现即可,文件一到就会唤醒你。**
28
34
 
29
35
  ```bash
30
36
  # sent="$sentinel_file_posix" ← 直接用工具返回里那个字段
@@ -45,9 +51,10 @@ echo "DONE" # 后台任务退出 → 宿主唤醒你
45
51
  - **闩锁语义**:文件写一次并保留。即便 run 在你启动等待任务**之前**就已完成,`[ -f ]` 也立刻为真,不会漏。
46
52
  - **原子落地**:`tmp` + `rename`,文件一旦存在内容必然完整;每个 run 用独立 tmp,并发写互不干扰。
47
53
  - **无主动推送**:本服务在 MCP 协议上只应答请求、不发通知;"跑完通知"完全由这个哨兵文件 + 你的后台等待任务实现。
54
+ - **审核不随哨兵消失**:`result.review` 随结果一起写入,所以只读文件的调用方也能看到清单。之后 `dsh_review` 会**原子重写**这个审核块(`status` → `pass`/`partial`/`fail`,并写入 `notes`、`reviewed_at`、`review_file`),闩锁文件本身始终存在。审核记录在 `<状态目录>/reviews/` 下,和哨兵一样按 7 天 TTL 清理。
48
55
  - **★ 哨兵里没有思考内容**:载荷含正文、思考**统计**、用量与(已脱敏的)工具输入,但没有思考正文;`result.thinking` 会被剥掉,并始终给出布尔 `thinking_omitted`(剥离了为 `true`,本来没有为 `false`)——本服务自己的落盘物里不写思考。`dsh_get(run_id)` 在内存里仍能看到它(记录还在时)。设 `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` 可以故意打破这条保证。注意:**DSH 自己的会话日志仍含思考**,见“日志与隐私”。
49
56
 
50
- > 清理:哨兵内含完整正文,所以服务**启动时会清掉超过 7 天的哨兵**(用 `DSH_MCP_SENTINEL_TTL_MS` 调;`0` 关闭)。等待任务消费后仍建议自行 `rm`。
57
+ > 清理:哨兵与审核记录内含完整正文/理由,所以服务**启动时会清掉超过 7 天的哨兵与审核记录**(用 `DSH_MCP_SENTINEL_TTL_MS` 调;`0` 关闭)。等待任务消费后仍建议自行 `rm`。
51
58
 
52
59
  ## 🧵 用法:同时控制多个会话
53
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -278,6 +278,35 @@ export function assertWritableDir(dir, label = '目录') {
278
278
  */
279
279
  export const RUNS_DIR = process.env.DSH_MCP_RUNS_DIR ?? join(dirname(STATE_FILE), 'runs');
280
280
 
281
+ /**
282
+ * 调用方审核记录的落盘目录(默认与状态文件同目录下的 reviews/)。
283
+ *
284
+ * 每个 run 一份 `<REVIEWS_DIR>/<conversation_id>/<run_id>.review.json`,
285
+ * 里面是调用方的裁决(pass/partial/fail)、理由、审核清单和证据摘要。
286
+ * 审核记录与哨兵分开存放:哨兵是"回合完成"的闩锁,审核是"调用方已验收"的独立事实,
287
+ * 后者可能在服务重启、run 记录被挤出内存之后才发生。
288
+ */
289
+ export const REVIEWS_DIR = process.env.DSH_MCP_REVIEWS_DIR ?? join(dirname(STATE_FILE), 'reviews');
290
+
291
+ /**
292
+ * 调用方审核策略:
293
+ * advisory(默认)—— 每个回合结果都附审核单(清单 + dsh_review 入口 + 证据),
294
+ * 但**不阻塞**后续 dsh_send;调用方仍可自己决定先做什么。
295
+ * off —— 不生成审核单;dsh_review 仍可对旧 run 显式提交。
296
+ *
297
+ * 默认开着的原因:返回值是"另一个 agent 的自述",调用方最容易犯的错就是直接转述。
298
+ * 审核单把"先核验"放进返回载荷本身,而不是依赖调用方记得要审核。
299
+ */
300
+ export const REVIEW_MODES = ['off', 'advisory'];
301
+ export const DEFAULT_REVIEW = (() => {
302
+ const raw = process.env.DSH_MCP_REVIEW;
303
+ if (raw === undefined || String(raw).trim() === '') return 'advisory';
304
+ const v = String(raw).trim().toLowerCase();
305
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return 'off';
306
+ if (v === '1' || v === 'true' || v === 'on' || v === 'advisory' || v === 'yes') return 'advisory';
307
+ return 'advisory';
308
+ })();
309
+
281
310
  /**
282
311
  * 哨兵文件里是否带上**思考内容**。
283
312
  *