patchwarden 1.1.0 → 1.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.
Files changed (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,40 @@
1
+ # PatchWarden User Feedback
2
+
3
+ This file tracks real feedback only. Do not add invented users, fake adoption,
4
+ or private conversations that cannot be shared.
5
+
6
+ ## Current Status
7
+
8
+ No third-party feedback has been imported into this repository yet.
9
+
10
+ Before using this file as application evidence, collect public feedback through
11
+ GitHub issues or discussions from real testers who have tried at least one of
12
+ the documented flows.
13
+
14
+ ## Feedback To Collect
15
+
16
+ Ask testers to include:
17
+
18
+ - operating system
19
+ - Node.js version
20
+ - MCP client used, such as ChatGPT, Codex, OpenCode, or another MCP client
21
+ - whether `npm.cmd run doctor` or the documented setup passed
22
+ - what task they attempted
23
+ - whether task evidence, safe summaries, or audit output helped
24
+ - confusing setup steps
25
+ - feature requests
26
+
27
+ ## Public Feedback Log
28
+
29
+ | Date | Source | Tester context | Result | Follow-up |
30
+ | --- | --- | --- | --- | --- |
31
+ | Pending | Pending GitHub issue or discussion | Pending | Pending | Pending |
32
+
33
+ ## Maintainer Response Rules
34
+
35
+ - Keep feedback public when possible.
36
+ - Redact local paths, logs, keys, tokens, cookies, `.env` contents, and account
37
+ identifiers.
38
+ - Convert recurring friction into issues with `documentation`, `good first
39
+ issue`, `help wanted`, or `security` labels when appropriate.
40
+ - Close the loop by linking fixes, docs, or release notes back to the feedback.
@@ -0,0 +1,110 @@
1
+ # 为什么需要 PatchWarden
2
+
3
+ > 本文基于 v1.5.1 源码编写;安装时请使用已验证发布的 <published-version>。
4
+ > 面向:使用 ChatGPT、Codex、OpenCode 等 MCP 客户端但需要安全边界的开发者
5
+
6
+ ## 背景:本地 Agent 的安全缺口
7
+
8
+ 当前主流的本地编程 Agent(如 ChatGPT 桌面端、Codex CLI、OpenCode、OpenHands)
9
+ 在带来生产力提升的同时,也引入了新的攻击面:
10
+
11
+ - Agent 可执行本地命令,一旦被 prompt injection 或上下文污染,可能执行越权操作。
12
+ - 通用远程 Shell 模式将完整命令执行能力暴露给 MCP 客户端,缺乏边界。
13
+ - 任务产物分散在 stdout / stderr / diff 中,难以审计与验收。
14
+ - `.env`、token、SSH key、cookie、浏览器状态等敏感文件缺乏强制隔离。
15
+
16
+ PatchWarden 不是另一个通用 Shell。它定位为本地优先的 MCP 安全与验证层
17
+ (local-first MCP safety and verification layer for AI coding agents),
18
+ 在不牺牲 Agent 能力的前提下提供可审计的安全边界。
19
+
20
+ ## 核心价值
21
+
22
+ ### 1. Workspace Confinement(工作区隔离)
23
+
24
+ 所有仓库路径必须位于配置的 `workspaceRoot` 之下。PatchWarden 会阻断:
25
+
26
+ - 越出 workspace 的文件读写。
27
+ - 对敏感文件名(如 `.env`、`id_rsa`、`cookies.db`)的访问。
28
+ - 跨仓库的非授权改动。
29
+
30
+ ### 2. Command Allowlists(命令允许列表)
31
+
32
+ PatchWarden 采用精确命令匹配,而非宽松的 glob 或前缀匹配:
33
+
34
+ - 只有显式注册的命令才能执行。
35
+ - 参数变化同样受约束,避免命令注入绕过。
36
+ - 不存在“允许 `git` 即允许任意 git 子命令”的隐式放大。
37
+
38
+ ### 3. Scope-Violation Detection(范围违规检测)
39
+
40
+ Agent 提出的任务若超出声明的 scope(例如修改了未授权文件、执行了未注册命令),
41
+ PatchWarden 会在执行前或执行中识别并阻断,而不是事后才在日志里发现。
42
+
43
+ ### 4. Auditable Task Evidence(可审计任务证据)
44
+
45
+ 每个任务通过 Evidence Pack v2 导出 8 个有界文件:
46
+
47
+ | 文件 | 用途 |
48
+ | --- | --- |
49
+ | `evidence.json` | 完整有界证据包(机器可读) |
50
+ | `EVIDENCE.md` | 人类可读的 Markdown 摘要 |
51
+ | `risk.json` | 聚合的风险项与严重度 |
52
+ | `verify.json` | 每轮迭代的结构化验证记录 |
53
+ | `diffstat.json` | 文件级增删统计(不含完整 diff) |
54
+ | `lineage.json` | 任务谱系与上下文链 |
55
+ | `attestation.json` | 任务签名与证明 |
56
+ | `redactions.json` | 已脱敏内容清单 |
57
+
58
+ 这些文件只包含有界摘要,**不**包含完整 stdout/stderr、完整 diff、
59
+ secrets、token、cookie 或凭据路径。
60
+
61
+ ## 与通用远程 Shell 的区别
62
+
63
+ | 维度 | 通用远程 Shell | PatchWarden |
64
+ | --- | --- | --- |
65
+ | 命令执行 | 任意命令 | 精确允许列表 |
66
+ | 路径访问 | 通常无约束 | 强制 workspace 隔离 |
67
+ | 敏感文件 | 取决于调用方自觉 | 强制阻断 |
68
+ | 任务证据 | 散落在日志 | 8 个有界文件可审计 |
69
+ | 范围违规 | 事后发现 | 执行前/中阻断 |
70
+ | MCP 客户端权限 | 全权委托 | 受约束代理 |
71
+
72
+ PatchWarden 假设上游模型或 MCP 客户端可能出错、过度宽泛或被 prompt injection。
73
+ 它不应被信任以无约束方式执行命令。
74
+
75
+ ## 适用场景
76
+
77
+ - 使用 ChatGPT / Codex / OpenCode / OpenHands 等 MCP 客户端进行本地代码维护。
78
+ - 需要对 Agent 任务进行可审计验收的团队或个人。
79
+ - 希望将 Agent 限制在特定 workspace 内的开发工作流。
80
+ - 需要生成 spec 验收证据的 spec-driven 开发流程。
81
+
82
+ ## 不适用场景
83
+
84
+ - 需要任意远程 shell 访问的场景(PatchWarden 刻意不提供)。
85
+ - 需要读取或修改 `.env`、SSH key、浏览器状态等敏感文件的工作流。
86
+ - 需要 Agent 跨多个不受信任 workspace 自由移动的场景。
87
+ - 不接受命令允许列表约束的通用任务执行需求。
88
+
89
+ ## 目标用户
90
+
91
+ - 使用 ChatGPT 桌面端、Codex CLI、OpenCode 等 MCP 客户端的开发者。
92
+ - 在本地维护多个仓库并希望统一安全边界的维护者。
93
+ - 需要为 Agent 任务生成可审计证据的团队。
94
+ - 对 prompt injection 风险敏感、希望有执行前阻断机制的安全工程师。
95
+
96
+ ## 安全契约要点
97
+
98
+ PatchWarden 的安全契约(详见 `docs/threat-model.md`)包括:
99
+
100
+ - 不暴露通用远程 shell,不弱化精确命令匹配。
101
+ - 所有仓库路径保持在 `workspaceRoot` 下,阻断敏感名与越界改动。
102
+ - 不读取或持久化 token、cookie、浏览器状态、`.env`、SSH key、凭据文件。
103
+ - 不无差别 kill watcher 或 tunnel,仅监督 launcher 拥有的进程。
104
+ - 保留结构化任务证据、心跳状态、Git before/after 快照、改动文件记录与脱敏。
105
+
106
+ ## 下一步
107
+
108
+ - 阅读 `docs/threat-model.md` 了解完整威胁模型。
109
+ - 阅读 `docs/evidence-pack-schema.md` 了解证据包文件结构。
110
+ - 阅读 `docs/dashboard-overview.md` 了解 Control Center 工作流。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "patchwarden",
3
- "version": "1.1.0",
4
- "description": "Secure local MCP bridge for ChatGPT and local agents, with workspace-scoped tasks, safe summaries, audits, and Direct editing.",
3
+ "version": "1.5.1",
4
+ "description": "Secure local MCP bridge for ChatGPT and local agents, with workspace-scoped task loops, safe summaries, audits, and Direct editing.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -62,7 +62,7 @@
62
62
  "@modelcontextprotocol/sdk": "^1.0.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@types/node": "^20.0.0",
65
+ "@types/node": "^26.1.0",
66
66
  "typescript": "^6.0.3"
67
67
  },
68
68
  "engines": {
@@ -229,6 +229,7 @@ async function testStaticFiles() {
229
229
  if (!rootCt.includes("text/html")) checks.push(`GET / -> Content-Type ${rootCt} (expected text/html)`);
230
230
  if (!root.body.includes("setup-checklist-card")) checks.push("dashboard missing setup checklist card");
231
231
  if (!root.body.includes("Show Core / Direct log tails")) checks.push("dashboard activity log is not collapsed behind a summary");
232
+ if (!root.body.includes("evidence-pack-card")) checks.push("dashboard missing v1.5 evidence pack card");
232
233
 
233
234
  if (vendor.status !== 200) checks.push(`GET /vendor/tailwindcss-browser.js -> status ${vendor.status}`);
234
235
  const vendorCt = vendor.headers["content-type"] || "";
@@ -667,6 +668,92 @@ async function testOtherGetApis() {
667
668
  problems.push(`/api/audit error: ${err.message}`);
668
669
  }
669
670
 
671
+ // /api/lineages
672
+ try {
673
+ const res = await httpGet(`${BASE_URL}/api/lineages`);
674
+ if (res.status !== 200) problems.push(`/api/lineages -> ${res.status} (expected 200)`);
675
+ else {
676
+ const json = tryJson(res.body);
677
+ if (!json || !Array.isArray(json.lineages) || typeof json.total !== "number") {
678
+ problems.push(`/api/lineages missing bounded lineage summary fields: ${res.body.slice(0, 120)}`);
679
+ }
680
+ }
681
+ } catch (err) {
682
+ problems.push(`/api/lineages error: ${err.message}`);
683
+ }
684
+
685
+ // /api/project-policy
686
+ try {
687
+ const res = await httpGet(`${BASE_URL}/api/project-policy?repo_path=.`);
688
+ if (res.status !== 200) problems.push(`/api/project-policy -> ${res.status} (expected 200)`);
689
+ else {
690
+ const json = tryJson(res.body);
691
+ if (!json || !("effective_policy" in json) || !Array.isArray(json.issues)) {
692
+ problems.push(`/api/project-policy missing policy summary fields: ${res.body.slice(0, 120)}`);
693
+ }
694
+ const text = res.body.toLowerCase();
695
+ if (text.includes("stdout") || text.includes("stderr") || text.includes("diff_patch")) {
696
+ problems.push("/api/project-policy leaked log/diff-shaped fields");
697
+ }
698
+ }
699
+ } catch (err) {
700
+ problems.push(`/api/project-policy error: ${err.message}`);
701
+ }
702
+
703
+ // /api/release/status
704
+ try {
705
+ const res = await httpGet(`${BASE_URL}/api/release/status?repo_path=.`);
706
+ if (res.status !== 200) problems.push(`/api/release/status -> ${res.status} (expected 200)`);
707
+ else {
708
+ const json = tryJson(res.body);
709
+ if (!json || !("release_readiness" in json) || json.remote_write_performed !== false) {
710
+ problems.push(`/api/release/status missing read-only release status fields: ${res.body.slice(0, 120)}`);
711
+ }
712
+ const text = res.body.toLowerCase();
713
+ if (text.includes("stdout") || text.includes("stderr") || text.includes("diff_patch")) {
714
+ problems.push("/api/release/status leaked log/diff-shaped fields");
715
+ }
716
+ }
717
+ } catch (err) {
718
+ problems.push(`/api/release/status error: ${err.message}`);
719
+ }
720
+
721
+ // /api/evidence-packs
722
+ try {
723
+ const res = await httpGet(`${BASE_URL}/api/evidence-packs`);
724
+ if (res.status !== 200) problems.push(`/api/evidence-packs -> ${res.status} (expected 200)`);
725
+ else {
726
+ const json = tryJson(res.body);
727
+ if (!json || !Array.isArray(json.evidence_packs) || typeof json.total !== "number") {
728
+ problems.push(`/api/evidence-packs missing bounded evidence summary fields: ${res.body.slice(0, 120)}`);
729
+ }
730
+ const text = res.body.toLowerCase();
731
+ if (text.includes("stdout_tail") || text.includes("stderr_tail") || text.includes("diff.patch") || text.includes("verification.log")) {
732
+ problems.push("/api/evidence-packs leaked log/diff-shaped fields");
733
+ }
734
+ }
735
+ } catch (err) {
736
+ problems.push(`/api/evidence-packs error: ${err.message}`);
737
+ }
738
+
739
+ // /api/direct-sessions/:id/summary (safe, bounded route; missing id is still non-leaky)
740
+ try {
741
+ const res = await httpGet(`${BASE_URL}/api/direct-sessions/direct_missing_summary/summary`);
742
+ if (res.status !== 200) problems.push(`/api/direct-sessions/:id/summary -> ${res.status} (expected 200)`);
743
+ else {
744
+ const json = tryJson(res.body);
745
+ if (!json || json.session_id !== "direct_missing_summary") {
746
+ problems.push(`/api/direct-sessions/:id/summary missing safe summary envelope: ${res.body.slice(0, 120)}`);
747
+ }
748
+ const text = res.body.toLowerCase();
749
+ if (text.includes("stdout_tail") || text.includes("stderr_tail") || text.includes("diff_patch") || text.includes("verification.log")) {
750
+ problems.push("/api/direct-sessions/:id/summary leaked log/diff-shaped fields");
751
+ }
752
+ }
753
+ } catch (err) {
754
+ problems.push(`/api/direct-sessions/:id/summary error: ${err.message}`);
755
+ }
756
+
670
757
  // /api/logs/core
671
758
  try {
672
759
  const res = await httpGet(`${BASE_URL}/api/logs/core`);
@@ -1001,6 +1088,351 @@ async function testControlCenterStatusApi() {
1001
1088
  }
1002
1089
  }
1003
1090
 
1091
+ // ── Test 21: GET /api/workspace/repos returns valid JSON with repos array ──
1092
+ async function testWorkspaceReposApi() {
1093
+ const name = "Test 21: /api/workspace/repos returns valid JSON with repos array";
1094
+ try {
1095
+ const res = await httpGet(`${BASE_URL}/api/workspace/repos`);
1096
+ if (res.status !== 200) {
1097
+ record(name, false, `status ${res.status} (expected 200)`);
1098
+ return;
1099
+ }
1100
+ const json = tryJson(res.body);
1101
+ if (!json || !isObject(json)) {
1102
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1103
+ return;
1104
+ }
1105
+ const problems = [];
1106
+ if (!Array.isArray(json.repos)) problems.push("'repos' is not an array");
1107
+ if (typeof json.workspace_root !== "string" && json.workspace_root !== null) {
1108
+ problems.push("'workspace_root' is neither string nor null");
1109
+ }
1110
+ if (Array.isArray(json.repos)) {
1111
+ for (let i = 0; i < json.repos.length; i++) {
1112
+ const repo = json.repos[i];
1113
+ if (!isObject(repo)) {
1114
+ problems.push(`repos[${i}] is not an object`);
1115
+ continue;
1116
+ }
1117
+ if (typeof repo.name !== "string") problems.push(`repos[${i}].name not string`);
1118
+ if (typeof repo.path !== "string") problems.push(`repos[${i}].path not string`);
1119
+ if (typeof repo.has_package_json !== "boolean") problems.push(`repos[${i}].has_package_json not boolean`);
1120
+ }
1121
+ }
1122
+ if (problems.length === 0) {
1123
+ record(name, true);
1124
+ } else {
1125
+ record(name, false, problems.join("; "));
1126
+ }
1127
+ } catch (err) {
1128
+ record(name, false, `error: ${err.message}`);
1129
+ }
1130
+ }
1131
+
1132
+ // ── Test 22: /api/release/status no longer mentions release_prepare and has ready_state ──
1133
+ async function testReleaseStatusFields() {
1134
+ const name = "Test 22: /api/release/status has ready_state and no release_prepare text";
1135
+ try {
1136
+ const res = await httpGet(`${BASE_URL}/api/release/status?repo_path=.`);
1137
+ if (res.status !== 200) {
1138
+ record(name, false, `status ${res.status} (expected 200)`);
1139
+ return;
1140
+ }
1141
+ const json = tryJson(res.body);
1142
+ if (!json || !isObject(json)) {
1143
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1144
+ return;
1145
+ }
1146
+ const problems = [];
1147
+ if (!("ready_state" in json)) problems.push("missing 'ready_state' field");
1148
+ if (typeof json.ready_state !== "string") problems.push("'ready_state' is not a string");
1149
+ if (!("package_name" in json)) problems.push("missing 'package_name' field");
1150
+ if (!("version_source" in json)) problems.push("missing 'version_source' field");
1151
+ if (!("version_consistent" in json)) problems.push("missing 'version_consistent' field");
1152
+ if (!Array.isArray(json.required_commands)) problems.push("'required_commands' is not an array");
1153
+ if (typeof json.commands_blocked_count !== "number") problems.push("'commands_blocked_count' is not a number");
1154
+ // The old misleading "release_prepare" text must no longer appear.
1155
+ if (res.body.includes("release_prepare")) {
1156
+ problems.push("response still contains 'release_prepare' text");
1157
+ }
1158
+ if (Array.isArray(json.required_commands)) {
1159
+ for (let i = 0; i < json.required_commands.length; i++) {
1160
+ const cmd = json.required_commands[i];
1161
+ if (!isObject(cmd)) {
1162
+ problems.push(`required_commands[${i}] is not an object`);
1163
+ continue;
1164
+ }
1165
+ if (typeof cmd.command !== "string") problems.push(`required_commands[${i}].command not string`);
1166
+ if (typeof cmd.allowed !== "boolean") problems.push(`required_commands[${i}].allowed not boolean`);
1167
+ if (!("blocked_reason" in cmd)) problems.push(`required_commands[${i}] missing 'blocked_reason'`);
1168
+ }
1169
+ }
1170
+ if (problems.length === 0) {
1171
+ record(name, true);
1172
+ } else {
1173
+ record(name, false, problems.join("; "));
1174
+ }
1175
+ } catch (err) {
1176
+ record(name, false, `error: ${err.message}`);
1177
+ }
1178
+ }
1179
+
1180
+ // ── Test 23: Safe task endpoints return valid JSON without full content ──
1181
+ async function testSafeTaskEndpoints() {
1182
+ const name = "Test 23: safe task endpoints return valid JSON without stdout/stderr/diff";
1183
+ const endpoints = [
1184
+ "safe-result",
1185
+ "safe-audit",
1186
+ "safe-test-summary",
1187
+ "safe-diff-summary",
1188
+ ];
1189
+ const problems = [];
1190
+ for (const ep of endpoints) {
1191
+ try {
1192
+ const res = await httpGet(`${BASE_URL}/api/tasks/nonexistent-smoke-task/${ep}`);
1193
+ if (res.status !== 200) {
1194
+ problems.push(`/api/tasks/:id/${ep} -> status ${res.status} (expected 200, not 500)`);
1195
+ continue;
1196
+ }
1197
+ const json = tryJson(res.body);
1198
+ if (!json || !isObject(json)) {
1199
+ problems.push(`/api/tasks/:id/${ep} body is not a JSON object: ${res.body.slice(0, 120)}`);
1200
+ continue;
1201
+ }
1202
+ // Safe endpoints must NOT expose full stdout/stderr/diff content.
1203
+ const text = res.body.toLowerCase();
1204
+ if (text.includes('"stdout"') && text.includes('"stderr"')) {
1205
+ // Some safe endpoints may include a "stdout" key as a bounded field; the
1206
+ // invariant we care about is that they don't return the full raw logs.
1207
+ // We only flag if both stdout AND stderr appear together (log-shaped).
1208
+ problems.push(`/api/tasks/:id/${ep} may leak stdout+stderr shaped fields`);
1209
+ }
1210
+ if (text.includes('"diff_patch"')) {
1211
+ problems.push(`/api/tasks/:id/${ep} leaks 'diff_patch' field`);
1212
+ }
1213
+ } catch (err) {
1214
+ problems.push(`/api/tasks/:id/${ep} error: ${err.message}`);
1215
+ }
1216
+ }
1217
+ if (problems.length === 0) {
1218
+ record(name, true);
1219
+ } else {
1220
+ record(name, false, problems.join("; "));
1221
+ }
1222
+ }
1223
+
1224
+ // ── Test 24: /api/tasks?status=running returns valid filtered JSON ──
1225
+ async function testTasksFilter() {
1226
+ const name = "Test 24: /api/tasks?status=running returns valid filtered JSON";
1227
+ try {
1228
+ const res = await httpGet(`${BASE_URL}/api/tasks?status=running`);
1229
+ if (res.status !== 200) {
1230
+ record(name, false, `status ${res.status} (expected 200)`);
1231
+ return;
1232
+ }
1233
+ const json = tryJson(res.body);
1234
+ if (!json || !isObject(json)) {
1235
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1236
+ return;
1237
+ }
1238
+ const problems = [];
1239
+ if (!Array.isArray(json.tasks)) problems.push("'tasks' is not an array");
1240
+ if (typeof json.total !== "number") problems.push("'total' is not a number");
1241
+ if (typeof json.returned !== "number") problems.push("'returned' is not a number");
1242
+ // Every returned task must match the filter (if any tasks exist).
1243
+ if (Array.isArray(json.tasks)) {
1244
+ for (let i = 0; i < json.tasks.length; i++) {
1245
+ if (json.tasks[i].status !== "running") {
1246
+ problems.push(`tasks[${i}].status is '${json.tasks[i].status}' (expected 'running')`);
1247
+ }
1248
+ }
1249
+ if (json.tasks.length !== json.total) {
1250
+ problems.push(`tasks.length (${json.tasks.length}) != total (${json.total})`);
1251
+ }
1252
+ }
1253
+ if (problems.length === 0) {
1254
+ record(name, true);
1255
+ } else {
1256
+ record(name, false, problems.join("; "));
1257
+ }
1258
+ } catch (err) {
1259
+ record(name, false, `error: ${err.message}`);
1260
+ }
1261
+ }
1262
+
1263
+ // ── Test 25: /api/tasks/stale has explanation and next_action on stale tasks ──
1264
+ async function testStaleTasksExplanation() {
1265
+ const name = "Test 25: /api/tasks/stale stale tasks have explanation and next_action";
1266
+ try {
1267
+ const res = await httpGet(`${BASE_URL}/api/tasks/stale`);
1268
+ if (res.status !== 200) {
1269
+ record(name, false, `status ${res.status} (expected 200)`);
1270
+ return;
1271
+ }
1272
+ const json = tryJson(res.body);
1273
+ if (!json || !isObject(json)) {
1274
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1275
+ return;
1276
+ }
1277
+ const problems = [];
1278
+ if (!Array.isArray(json.stale_tasks)) problems.push("'stale_tasks' is not an array");
1279
+ if (Array.isArray(json.stale_tasks) && json.stale_tasks.length > 0) {
1280
+ for (let i = 0; i < json.stale_tasks.length; i++) {
1281
+ const t = json.stale_tasks[i];
1282
+ if (!isObject(t)) {
1283
+ problems.push(`stale_tasks[${i}] is not an object`);
1284
+ continue;
1285
+ }
1286
+ if (typeof t.explanation !== "string" || t.explanation.length === 0) {
1287
+ problems.push(`stale_tasks[${i}] missing 'explanation' string`);
1288
+ }
1289
+ if (typeof t.next_action !== "string" || t.next_action.length === 0) {
1290
+ problems.push(`stale_tasks[${i}] missing 'next_action' string`);
1291
+ }
1292
+ if (typeof t.reason_code !== "string") {
1293
+ problems.push(`stale_tasks[${i}] missing 'reason_code' string`);
1294
+ }
1295
+ }
1296
+ }
1297
+ if (problems.length === 0) {
1298
+ record(name, true);
1299
+ } else {
1300
+ record(name, false, problems.join("; "));
1301
+ }
1302
+ } catch (err) {
1303
+ record(name, false, `error: ${err.message}`);
1304
+ }
1305
+ }
1306
+
1307
+ // ── Test 26: POST /api/tasks/:taskId/hide-stale token gate + valid response ──
1308
+ async function testHideStaleNoToken() {
1309
+ const name = "Test 26: POST /api/tasks/:taskId/hide-stale without token -> 403";
1310
+ try {
1311
+ const res = await httpPost(`${BASE_URL}/api/tasks/nonexistent/hide-stale`, {});
1312
+ if (res.status !== 403) {
1313
+ record(name, false, `status ${res.status} (expected 403 — token gate must fire before hide-stale runs)`);
1314
+ return;
1315
+ }
1316
+ const json = tryJson(res.body);
1317
+ if (!json || typeof json.error !== "string") {
1318
+ record(name, false, `response body missing 'error' field: ${res.body.slice(0, 200)}`);
1319
+ return;
1320
+ }
1321
+ record(name, true);
1322
+ } catch (err) {
1323
+ record(name, false, `error: ${err.message}`);
1324
+ }
1325
+ }
1326
+
1327
+ async function testHideStaleWithToken(token) {
1328
+ const name = "Test 27: POST /api/tasks/:taskId/hide-stale with token returns valid JSON";
1329
+ try {
1330
+ const res = await httpPost(`${BASE_URL}/api/tasks/nonexistent/hide-stale`, {
1331
+ "X-PatchWarden-Control-Token": token,
1332
+ });
1333
+ if (res.status !== 200) {
1334
+ record(name, false, `status ${res.status} (expected 200)`);
1335
+ return;
1336
+ }
1337
+ const json = tryJson(res.body);
1338
+ if (!json || !isObject(json)) {
1339
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1340
+ return;
1341
+ }
1342
+ if (json.ok !== true) {
1343
+ record(name, false, `expected ok=true, got: ${res.body.slice(0, 200)}`);
1344
+ return;
1345
+ }
1346
+ if (json.hidden !== "nonexistent") {
1347
+ record(name, false, `expected hidden='nonexistent', got: ${json.hidden}`);
1348
+ return;
1349
+ }
1350
+ record(name, true);
1351
+ } catch (err) {
1352
+ record(name, false, `error: ${err.message}`);
1353
+ }
1354
+ }
1355
+
1356
+ // ── Test 28: GET /api/warnings returns valid JSON ───────────────
1357
+ async function testWarningsApi() {
1358
+ const name = "Test 28: /api/warnings returns valid JSON with warnings array";
1359
+ try {
1360
+ const res = await httpGet(`${BASE_URL}/api/warnings`);
1361
+ if (res.status !== 200) {
1362
+ record(name, false, `status ${res.status} (expected 200)`);
1363
+ return;
1364
+ }
1365
+ const json = tryJson(res.body);
1366
+ if (!json || !isObject(json)) {
1367
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1368
+ return;
1369
+ }
1370
+ const problems = [];
1371
+ if (!Array.isArray(json.warnings)) problems.push("'warnings' is not an array");
1372
+ if (typeof json.total !== "number") problems.push("'total' is not a number");
1373
+ if (Array.isArray(json.warnings)) {
1374
+ for (let i = 0; i < json.warnings.length; i++) {
1375
+ const w = json.warnings[i];
1376
+ if (!isObject(w)) {
1377
+ problems.push(`warnings[${i}] is not an object`);
1378
+ continue;
1379
+ }
1380
+ if (typeof w.type !== "string") problems.push(`warnings[${i}].type is not a string`);
1381
+ if (typeof w.severity !== "string") problems.push(`warnings[${i}].severity is not a string`);
1382
+ if (typeof w.affected_tasks_count !== "number") problems.push(`warnings[${i}].affected_tasks_count is not a number`);
1383
+ if (typeof w.recommended_action !== "string") problems.push(`warnings[${i}].recommended_action is not a string`);
1384
+ }
1385
+ }
1386
+ if (problems.length === 0) {
1387
+ record(name, true);
1388
+ } else {
1389
+ record(name, false, problems.join("; "));
1390
+ }
1391
+ } catch (err) {
1392
+ record(name, false, `error: ${err.message}`);
1393
+ }
1394
+ }
1395
+
1396
+ // ── Test 29: GET /api/diagnostics returns valid JSON with redaction ─
1397
+ async function testDiagnosticsApi() {
1398
+ const name = "Test 29: /api/diagnostics returns valid JSON with required fields and redaction";
1399
+ try {
1400
+ const res = await httpGet(`${BASE_URL}/api/diagnostics`);
1401
+ if (res.status !== 200) {
1402
+ record(name, false, `status ${res.status} (expected 200)`);
1403
+ return;
1404
+ }
1405
+ const json = tryJson(res.body);
1406
+ if (!json || !isObject(json)) {
1407
+ record(name, false, `body is not a JSON object: ${res.body.slice(0, 200)}`);
1408
+ return;
1409
+ }
1410
+ const problems = [];
1411
+ if (typeof json.server_version !== "string") problems.push("'server_version' is not a string");
1412
+ if (typeof json.schema_epoch !== "string") problems.push("'schema_epoch' is not a string");
1413
+ if (typeof json.watcher_status !== "string") problems.push("'watcher_status' is not a string");
1414
+ if (typeof json.workspace_root !== "string" && json.workspace_root !== null) {
1415
+ problems.push("'workspace_root' is neither string nor null");
1416
+ }
1417
+ if (typeof json.direct_profile_enabled !== "boolean") problems.push("'direct_profile_enabled' is not a boolean");
1418
+ // Redaction: response body must not contain sensitive keywords
1419
+ const lower = res.body.toLowerCase();
1420
+ const forbidden = ["token", "password", "secret", "credential", "api_key"];
1421
+ for (const word of forbidden) {
1422
+ if (lower.includes(word)) {
1423
+ problems.push(`response body contains forbidden word '${word}' (redaction failure)`);
1424
+ }
1425
+ }
1426
+ if (problems.length === 0) {
1427
+ record(name, true);
1428
+ } else {
1429
+ record(name, false, problems.join("; "));
1430
+ }
1431
+ } catch (err) {
1432
+ record(name, false, `error: ${err.message}`);
1433
+ }
1434
+ }
1435
+
1004
1436
  async function main() {
1005
1437
  // Sanity: ensure the compiled server exists before spawning.
1006
1438
  if (!existsSync(serverPath) || !statSync(serverPath).isFile()) {
@@ -1072,6 +1504,17 @@ async function main() {
1072
1504
  await testControlCenterLogApi();
1073
1505
  await testRestartAllNoToken();
1074
1506
  await testControlCenterStatusApi();
1507
+ // v1.5.1 Dashboard UI optimization coverage
1508
+ await testWorkspaceReposApi();
1509
+ await testReleaseStatusFields();
1510
+ await testSafeTaskEndpoints();
1511
+ await testTasksFilter();
1512
+ await testStaleTasksExplanation();
1513
+ await testHideStaleNoToken();
1514
+ if (token) await testHideStaleWithToken(token);
1515
+ // P1 Task 10/11 coverage
1516
+ await testWarningsApi();
1517
+ await testDiagnosticsApi();
1075
1518
 
1076
1519
  // Use token to silence unused-var linters and confirm we actually fetched it.
1077
1520
  if (token) {
@@ -76,7 +76,7 @@ try {
76
76
  ready: false,
77
77
  pid: null,
78
78
  tool_profile: "chatgpt_direct",
79
- tool_count: 13,
79
+ tool_count: 14,
80
80
  tools_ready: true,
81
81
  }), "utf8");
82
82
  writeFileSync(join(directRuntime, "tunnel-client.pid"), String(fakeTunnel.pid), "utf8");
@@ -114,7 +114,7 @@ try {
114
114
  reason_code: "stale_fixture",
115
115
  last_error: "stale failure",
116
116
  tool_profile: "chatgpt_core",
117
- tool_count: 21,
117
+ tool_count: 26,
118
118
  tools_ready: true,
119
119
  }), "utf8");
120
120
  healthServer = spawn(