patchwarden 0.6.4 → 1.5.0

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 (254) hide show
  1. package/README.en.md +54 -20
  2. package/README.md +48 -21
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +212 -3
  8. package/dist/direct/directSessionStore.d.ts +2 -0
  9. package/dist/direct/directVerification.js +7 -0
  10. package/dist/doctor.js +32 -1
  11. package/dist/goal/acceptanceEngine.d.ts +62 -0
  12. package/dist/goal/acceptanceEngine.js +103 -0
  13. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  14. package/dist/goal/acceptanceTemplate.js +104 -0
  15. package/dist/goal/goalGraph.d.ts +58 -0
  16. package/dist/goal/goalGraph.js +189 -0
  17. package/dist/goal/goalProgress.d.ts +81 -0
  18. package/dist/goal/goalProgress.js +216 -0
  19. package/dist/goal/goalStatus.d.ts +60 -0
  20. package/dist/goal/goalStatus.js +137 -0
  21. package/dist/goal/goalStore.d.ts +79 -0
  22. package/dist/goal/goalStore.js +211 -0
  23. package/dist/goal/handoffExport.d.ts +34 -0
  24. package/dist/goal/handoffExport.js +183 -0
  25. package/dist/goal/subgoalSync.d.ts +40 -0
  26. package/dist/goal/subgoalSync.js +72 -0
  27. package/dist/goal/worktreeManager.d.ts +96 -0
  28. package/dist/goal/worktreeManager.js +292 -0
  29. package/dist/logging.d.ts +44 -0
  30. package/dist/logging.js +68 -0
  31. package/dist/policy/projectPolicy.d.ts +55 -0
  32. package/dist/policy/projectPolicy.js +286 -0
  33. package/dist/release/releaseGate.d.ts +99 -0
  34. package/dist/release/releaseGate.js +475 -0
  35. package/dist/runner/postTaskCleanup.d.ts +13 -0
  36. package/dist/runner/postTaskCleanup.js +147 -0
  37. package/dist/runner/runTask.js +50 -10
  38. package/dist/security/discoveryTokenStore.d.ts +62 -0
  39. package/dist/security/discoveryTokenStore.js +100 -0
  40. package/dist/security/toolInvocationGuard.d.ts +35 -0
  41. package/dist/security/toolInvocationGuard.js +153 -0
  42. package/dist/smoke-test.js +18 -10
  43. package/dist/taskRuntime.d.ts +17 -0
  44. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  45. package/dist/test/unit/acceptance-engine.test.js +228 -0
  46. package/dist/test/unit/agent-router.test.d.ts +1 -0
  47. package/dist/test/unit/agent-router.test.js +287 -0
  48. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  49. package/dist/test/unit/audit-checks.test.js +350 -0
  50. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  51. package/dist/test/unit/diagnose-task.test.js +457 -0
  52. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  53. package/dist/test/unit/discovery-token-store.test.js +139 -0
  54. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  55. package/dist/test/unit/evidence-pack.test.js +130 -0
  56. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  57. package/dist/test/unit/goal-graph.test.js +298 -0
  58. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  59. package/dist/test/unit/goal-progress.test.js +381 -0
  60. package/dist/test/unit/goal-status.test.d.ts +1 -0
  61. package/dist/test/unit/goal-status.test.js +215 -0
  62. package/dist/test/unit/goal-store.test.d.ts +1 -0
  63. package/dist/test/unit/goal-store.test.js +253 -0
  64. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  65. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  66. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  67. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  68. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  69. package/dist/test/unit/handoff-export.test.js +263 -0
  70. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  72. package/dist/test/unit/logging.test.js +127 -5
  73. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  74. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  75. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  76. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  77. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  78. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  79. package/dist/test/unit/release-gate.test.d.ts +1 -0
  80. package/dist/test/unit/release-gate.test.js +242 -0
  81. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  82. package/dist/test/unit/run-task-loop.test.js +380 -0
  83. package/dist/test/unit/safe-views.test.d.ts +1 -0
  84. package/dist/test/unit/safe-views.test.js +171 -0
  85. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  86. package/dist/test/unit/schema-drift-check.test.js +176 -0
  87. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  88. package/dist/test/unit/subgoal-sync.test.js +183 -0
  89. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  90. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  91. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  92. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  93. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  94. package/dist/test/unit/toolSearch.test.js +571 -0
  95. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  96. package/dist/test/unit/worktree-manager.test.js +176 -0
  97. package/dist/tools/auditTask.d.ts +103 -1
  98. package/dist/tools/auditTask.js +461 -8
  99. package/dist/tools/cancelTask.js +1 -1
  100. package/dist/tools/checkReleaseGate.d.ts +21 -0
  101. package/dist/tools/checkReleaseGate.js +22 -0
  102. package/dist/tools/createTask.d.ts +18 -2
  103. package/dist/tools/createTask.js +29 -1
  104. package/dist/tools/diagnoseTask.d.ts +45 -0
  105. package/dist/tools/diagnoseTask.js +347 -0
  106. package/dist/tools/discardWorktree.d.ts +24 -0
  107. package/dist/tools/discardWorktree.js +27 -0
  108. package/dist/tools/discoverTools.d.ts +11 -0
  109. package/dist/tools/discoverTools.js +39 -0
  110. package/dist/tools/evidencePack.d.ts +39 -0
  111. package/dist/tools/evidencePack.js +168 -0
  112. package/dist/tools/explainTool.d.ts +11 -0
  113. package/dist/tools/explainTool.js +21 -0
  114. package/dist/tools/getTaskSummary.js +2 -1
  115. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  116. package/dist/tools/goalSubgoalTask.js +110 -0
  117. package/dist/tools/healthCheck.d.ts +1 -0
  118. package/dist/tools/healthCheck.js +5 -1
  119. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  120. package/dist/tools/invokeDiscoveredTool.js +112 -0
  121. package/dist/tools/listTasks.d.ts +3 -1
  122. package/dist/tools/listTasks.js +14 -1
  123. package/dist/tools/mergeWorktree.d.ts +24 -0
  124. package/dist/tools/mergeWorktree.js +27 -0
  125. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  126. package/dist/tools/recommendAgentForTask.js +56 -0
  127. package/dist/tools/reconcileTasks.d.ts +41 -0
  128. package/dist/tools/reconcileTasks.js +352 -0
  129. package/dist/tools/registry.js +926 -2
  130. package/dist/tools/releaseMode.d.ts +50 -0
  131. package/dist/tools/releaseMode.js +370 -0
  132. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  133. package/dist/tools/runDirectVerificationBundle.js +64 -0
  134. package/dist/tools/runTaskLoop.d.ts +57 -0
  135. package/dist/tools/runTaskLoop.js +417 -0
  136. package/dist/tools/runVerification.d.ts +4 -0
  137. package/dist/tools/runVerification.js +4 -0
  138. package/dist/tools/safeStatus.d.ts +31 -1
  139. package/dist/tools/safeStatus.js +43 -1
  140. package/dist/tools/safeViews.d.ts +262 -0
  141. package/dist/tools/safeViews.js +252 -0
  142. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  143. package/dist/tools/schemaDriftCheck.js +80 -0
  144. package/dist/tools/taskLineage.d.ts +91 -0
  145. package/dist/tools/taskLineage.js +175 -0
  146. package/dist/tools/toolCatalog.d.ts +4 -3
  147. package/dist/tools/toolCatalog.js +39 -11
  148. package/dist/tools/toolRegistry.d.ts +61 -0
  149. package/dist/tools/toolRegistry.js +834 -0
  150. package/dist/tools/toolSearch.d.ts +110 -0
  151. package/dist/tools/toolSearch.js +331 -0
  152. package/dist/tools/toolUsageStats.d.ts +41 -0
  153. package/dist/tools/toolUsageStats.js +116 -0
  154. package/dist/tools/waitForTask.js +1 -0
  155. package/dist/version.d.ts +2 -2
  156. package/dist/version.js +2 -2
  157. package/dist/watcherStatus.d.ts +29 -0
  158. package/dist/watcherStatus.js +92 -1
  159. package/docs/chatgpt-usage.md +31 -0
  160. package/docs/control-center/README.md +9 -0
  161. package/package.json +3 -2
  162. package/scripts/checks/control-center-smoke.js +87 -0
  163. package/scripts/checks/control-smoke.js +2 -2
  164. package/scripts/checks/doctor-smoke.js +1 -0
  165. package/scripts/checks/lifecycle-smoke.js +13 -9
  166. package/scripts/checks/mcp-manifest-check.js +65 -6
  167. package/scripts/checks/mcp-smoke.js +60 -9
  168. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  169. package/src/agents/agentRouter.ts +149 -0
  170. package/src/config.ts +9 -3
  171. package/src/controlCenter.ts +215 -4
  172. package/src/direct/directSessionStore.ts +2 -0
  173. package/src/direct/directVerification.ts +7 -0
  174. package/src/doctor.ts +36 -1
  175. package/src/goal/acceptanceEngine.ts +160 -0
  176. package/src/goal/acceptanceTemplate.ts +121 -0
  177. package/src/goal/goalGraph.ts +225 -0
  178. package/src/goal/goalProgress.ts +301 -0
  179. package/src/goal/goalStatus.ts +234 -0
  180. package/src/goal/goalStore.ts +306 -0
  181. package/src/goal/handoffExport.ts +211 -0
  182. package/src/goal/subgoalSync.ts +82 -0
  183. package/src/goal/worktreeManager.ts +404 -0
  184. package/src/logging.ts +91 -0
  185. package/src/policy/projectPolicy.ts +344 -0
  186. package/src/release/releaseGate.ts +567 -0
  187. package/src/runner/postTaskCleanup.ts +154 -0
  188. package/src/runner/runTask.ts +49 -10
  189. package/src/security/discoveryTokenStore.ts +157 -0
  190. package/src/security/toolInvocationGuard.ts +251 -0
  191. package/src/smoke-test.ts +15 -7
  192. package/src/taskRuntime.ts +17 -0
  193. package/src/test/unit/acceptance-engine.test.ts +261 -0
  194. package/src/test/unit/agent-router.test.ts +342 -0
  195. package/src/test/unit/audit-checks.test.ts +567 -0
  196. package/src/test/unit/diagnose-task.test.ts +544 -0
  197. package/src/test/unit/discovery-token-store.test.ts +181 -0
  198. package/src/test/unit/evidence-pack.test.ts +142 -0
  199. package/src/test/unit/goal-graph.test.ts +347 -0
  200. package/src/test/unit/goal-progress.test.ts +538 -0
  201. package/src/test/unit/goal-status.test.ts +270 -0
  202. package/src/test/unit/goal-store.test.ts +318 -0
  203. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  204. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  205. package/src/test/unit/handoff-export.test.ts +295 -0
  206. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  207. package/src/test/unit/logging.test.ts +177 -5
  208. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  209. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  210. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  211. package/src/test/unit/release-gate.test.ts +314 -0
  212. package/src/test/unit/run-task-loop.test.ts +425 -0
  213. package/src/test/unit/safe-views.test.ts +184 -0
  214. package/src/test/unit/schema-drift-check.test.ts +260 -0
  215. package/src/test/unit/subgoal-sync.test.ts +236 -0
  216. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  217. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  218. package/src/test/unit/toolSearch.test.ts +756 -0
  219. package/src/test/unit/worktree-manager.test.ts +247 -0
  220. package/src/tools/auditTask.ts +831 -402
  221. package/src/tools/cancelTask.ts +1 -1
  222. package/src/tools/checkReleaseGate.ts +45 -0
  223. package/src/tools/createTask.ts +64 -6
  224. package/src/tools/diagnoseTask.ts +460 -0
  225. package/src/tools/discardWorktree.ts +42 -0
  226. package/src/tools/discoverTools.ts +51 -0
  227. package/src/tools/evidencePack.ts +205 -0
  228. package/src/tools/explainTool.ts +26 -0
  229. package/src/tools/getTaskSummary.ts +2 -1
  230. package/src/tools/goalSubgoalTask.ts +170 -0
  231. package/src/tools/healthCheck.ts +4 -1
  232. package/src/tools/invokeDiscoveredTool.ts +163 -0
  233. package/src/tools/listTasks.ts +16 -2
  234. package/src/tools/listWorkspace.ts +71 -71
  235. package/src/tools/mergeWorktree.ts +42 -0
  236. package/src/tools/recommendAgentForTask.ts +79 -0
  237. package/src/tools/reconcileTasks.ts +464 -0
  238. package/src/tools/registry.ts +1093 -73
  239. package/src/tools/releaseMode.ts +450 -0
  240. package/src/tools/runDirectVerificationBundle.ts +98 -0
  241. package/src/tools/runTaskLoop.ts +526 -0
  242. package/src/tools/runVerification.ts +8 -0
  243. package/src/tools/safeStatus.ts +73 -2
  244. package/src/tools/safeViews.ts +273 -0
  245. package/src/tools/schemaDriftCheck.ts +120 -0
  246. package/src/tools/taskLineage.ts +300 -0
  247. package/src/tools/toolCatalog.ts +40 -11
  248. package/src/tools/toolRegistry.ts +896 -0
  249. package/src/tools/toolSearch.ts +464 -0
  250. package/src/tools/toolUsageStats.ts +151 -0
  251. package/src/tools/waitForTask.ts +1 -0
  252. package/src/version.ts +2 -2
  253. package/src/watcherStatus.ts +97 -1
  254. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,251 @@
1
+ /**
2
+ * v0.8.1: toolInvocationGuard — invoke_discovered_tool 调用前强制校验。
3
+ *
4
+ * consumeToken 已完成 token 存在性与过期校验,guard 收到的是已消费的 record。
5
+ * guard 依次执行 8 项校验,任一失败抛出对应错误码:
6
+ * 1. token_tool_mismatch — toolName 与 record.toolName 一致性
7
+ * 2. profile_not_allowed — toolMeta.profiles 必须包含当前 profile
8
+ * 3. risk_exceeded — toolMeta.risk 不能超过 record.risk(schema 漂移拦截)
9
+ * 4. sensitive_path_blocked — workspace_read_sensitive 路径敏感校验
10
+ * 5. assessment_required — workspace_write 需要 assessmentId
11
+ * 6. command_not_allowed — command 工具 args.command 元字符预检
12
+ * 7. release_confirmation_required — release 工具需要 assessmentId
13
+ * 8. credential_sensitive_blocked — credential_sensitive 总是拒绝
14
+ *
15
+ * 加上 consumeToken 内的 token_not_found / token_expired,共 10 项调用前强制校验。
16
+ */
17
+
18
+ import { PatchWardenError } from "../errors.js";
19
+ import { guardSensitivePath } from "./sensitiveGuard.js";
20
+ import { TOOL_RISK_RANK, type PatchWardenToolMeta } from "../tools/toolRegistry.js";
21
+ import type { ToolProfile } from "../tools/toolCatalog.js";
22
+ import type { DiscoveryTokenRecord } from "./discoveryTokenStore.js";
23
+
24
+ // ── 类型定义 ──────────────────────────────────────────────────────
25
+
26
+ export interface GuardInput {
27
+ toolName: string; // 要调用的工具名
28
+ toolMeta: PatchWardenToolMeta; // 工具元数据
29
+ args: Record<string, unknown>; // 调用参数
30
+ discoveryTokenRecord: DiscoveryTokenRecord; // token 记录(已 consume)
31
+ profile: ToolProfile; // 当前 profile
32
+ assessmentId?: string; // 可选 assessmentId
33
+ }
34
+
35
+ export interface GuardResult {
36
+ allowed: boolean;
37
+ }
38
+
39
+ // 错误码常量
40
+ export type InvocationErrorCode =
41
+ | "token_tool_mismatch"
42
+ | "profile_not_allowed"
43
+ | "risk_exceeded"
44
+ | "sensitive_path_blocked"
45
+ | "assessment_required"
46
+ | "command_not_allowed"
47
+ | "release_confirmation_required"
48
+ | "credential_sensitive_blocked";
49
+
50
+ // ── 内部常量 ──────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * 常见路径参数字段名,用于 workspace_read_sensitive 校验时扫描 args。
54
+ * 任一字段为字符串路径都会触发 guardSensitivePath。
55
+ */
56
+ const PATH_ARG_FIELDS = [
57
+ "path",
58
+ "file",
59
+ "filePath",
60
+ "file_path",
61
+ "filename",
62
+ "repo_path",
63
+ "repoPath",
64
+ "target_path",
65
+ "targetPath",
66
+ "destination",
67
+ "dest",
68
+ ] as const;
69
+
70
+ /**
71
+ * shell 元字符黑名单,用于 command 风险工具 args.command 预检。
72
+ * 真正的白名单校验在 handler 内通过 commandGuard 完成,guard 只做元字符预检。
73
+ */
74
+ const SHELL_METACHAR_PATTERN = /[|&;`$()<>]/;
75
+
76
+ // ── 公共 API ──────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * 调用前强制校验。任一校验失败抛 PatchWardenError,全部通过返回 { allowed: true }。
80
+ */
81
+ export function checkInvocation(input: GuardInput): GuardResult {
82
+ const { toolName, toolMeta, args, discoveryTokenRecord, profile, assessmentId } = input;
83
+
84
+ // ① toolName 一致性:toolName 必须等于 record.toolName
85
+ if (toolName !== discoveryTokenRecord.toolName) {
86
+ throw new PatchWardenError(
87
+ "token_tool_mismatch",
88
+ `Token tool mismatch: token was issued for "${discoveryTokenRecord.toolName}" but invocation targets "${toolName}".`,
89
+ "Call discover_tools again and use a token issued for the intended tool name.",
90
+ true,
91
+ {
92
+ token: discoveryTokenRecord.token,
93
+ expected_tool: discoveryTokenRecord.toolName,
94
+ actual_tool: toolName,
95
+ }
96
+ );
97
+ }
98
+
99
+ // ② profile 允许:toolMeta.profiles 必须包含当前 profile
100
+ if (!toolMeta.profiles.includes(profile)) {
101
+ throw new PatchWardenError(
102
+ "profile_not_allowed",
103
+ `Profile "${profile}" is not allowed for tool "${toolName}". Allowed profiles: ${toolMeta.profiles.join(", ")}.`,
104
+ "Switch to a profile that exposes this tool, or call discover_tools under a supported profile.",
105
+ true,
106
+ {
107
+ tool: toolName,
108
+ current_profile: profile,
109
+ allowed_profiles: toolMeta.profiles,
110
+ }
111
+ );
112
+ }
113
+
114
+ // ③ 风险等级校验:toolMeta.risk 不能超过 record.risk
115
+ // token record 的 risk 是 discover 时记录的工具 risk,应与 toolMeta.risk 一致。
116
+ // 如果工具 schema 漂移导致 risk 变了,这里会拦截。
117
+ const toolRiskRank = TOOL_RISK_RANK[toolMeta.risk];
118
+ const tokenRiskRank = TOOL_RISK_RANK[discoveryTokenRecord.risk];
119
+ if (toolRiskRank > tokenRiskRank) {
120
+ throw new PatchWardenError(
121
+ "risk_exceeded",
122
+ `Risk exceeded: tool "${toolName}" risk is "${toolMeta.risk}" (rank ${toolRiskRank}) but discovery token was issued for risk "${discoveryTokenRecord.risk}" (rank ${tokenRiskRank}). Tool schema may have drifted.`,
123
+ "Call discover_tools again to obtain a fresh token reflecting the current tool risk.",
124
+ true,
125
+ {
126
+ tool: toolName,
127
+ tool_risk: toolMeta.risk,
128
+ token_risk: discoveryTokenRecord.risk,
129
+ tool_risk_rank: toolRiskRank,
130
+ token_risk_rank: tokenRiskRank,
131
+ }
132
+ );
133
+ }
134
+
135
+ // ④ workspace_read_sensitive:检查 args 中的路径参数
136
+ checkSensitivePaths(toolMeta, toolName, args);
137
+
138
+ // ⑤ workspace_write:要求 assessmentId 非空
139
+ if (toolMeta.risk === "workspace_write") {
140
+ if (!assessmentId || assessmentId.trim() === "") {
141
+ throw new PatchWardenError(
142
+ "assessment_required",
143
+ `Assessment required: tool "${toolName}" has workspace_write risk and requires a non-empty assessmentId.`,
144
+ "Run the assessment flow first and pass the resulting assessmentId to invoke_discovered_tool.",
145
+ true,
146
+ {
147
+ tool: toolName,
148
+ risk: toolMeta.risk,
149
+ }
150
+ );
151
+ }
152
+ }
153
+
154
+ // ⑥ command:args.command 元字符预检
155
+ if (toolMeta.risk === "command") {
156
+ checkCommandMetachars(toolMeta, toolName, args);
157
+ }
158
+
159
+ // ⑦ release:要求 assessmentId 非空(二次确认语义)
160
+ if (toolMeta.risk === "release") {
161
+ if (!assessmentId || assessmentId.trim() === "") {
162
+ throw new PatchWardenError(
163
+ "release_confirmation_required",
164
+ `Release confirmation required: tool "${toolName}" has release risk and requires a non-empty assessmentId as explicit confirmation.`,
165
+ "Run the release assessment flow first and pass the resulting assessmentId to confirm the release.",
166
+ true,
167
+ {
168
+ tool: toolName,
169
+ risk: toolMeta.risk,
170
+ }
171
+ );
172
+ }
173
+ }
174
+
175
+ // ⑧ credential_sensitive:总是拒绝
176
+ if (toolMeta.risk === "credential_sensitive") {
177
+ throw new PatchWardenError(
178
+ "credential_sensitive_blocked",
179
+ `Credential-sensitive tool "${toolName}" cannot be invoked through invoke_discovered_tool.`,
180
+ "Use the dedicated credential-handling tooling outside the dynamic invoke path.",
181
+ true,
182
+ {
183
+ tool: toolName,
184
+ risk: toolMeta.risk,
185
+ }
186
+ );
187
+ }
188
+
189
+ return { allowed: true };
190
+ }
191
+
192
+ // ── 内部校验函数 ──────────────────────────────────────────────────
193
+
194
+ /**
195
+ * workspace_read_sensitive 风险工具:扫描 args 中的路径参数字段,
196
+ * 对每个字符串路径调用 guardSensitivePath(敏感路径会抛 sensitive_path_blocked)。
197
+ * 没有路径参数则跳过。
198
+ */
199
+ function checkSensitivePaths(
200
+ toolMeta: PatchWardenToolMeta,
201
+ toolName: string,
202
+ args: Record<string, unknown>
203
+ ): void {
204
+ if (toolMeta.risk !== "workspace_read_sensitive") {
205
+ return;
206
+ }
207
+
208
+ for (const field of PATH_ARG_FIELDS) {
209
+ const value = args[field];
210
+ if (typeof value === "string" && value.length > 0) {
211
+ // guardSensitivePath 内部会在敏感时抛 PatchWardenError("sensitive_path_blocked", ...)
212
+ guardSensitivePath(value);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * command 风险工具:args.command 元字符预检。
219
+ * - args.command 不存在或为空 → 跳过(handler 内部会再校验)
220
+ * - 含 shell 元字符 → 抛 command_not_allowed
221
+ * 真正的白名单校验在 handler 内通过 commandGuard 完成。
222
+ */
223
+ function checkCommandMetachars(
224
+ _toolMeta: PatchWardenToolMeta,
225
+ toolName: string,
226
+ args: Record<string, unknown>
227
+ ): void {
228
+ const command = args.command;
229
+ if (command === undefined || command === null) {
230
+ return;
231
+ }
232
+ if (typeof command !== "string") {
233
+ return;
234
+ }
235
+ if (command.trim() === "") {
236
+ return;
237
+ }
238
+
239
+ if (SHELL_METACHAR_PATTERN.test(command)) {
240
+ throw new PatchWardenError(
241
+ "command_not_allowed",
242
+ `Command not allowed: tool "${toolName}" args.command contains shell metacharacters. Detected pattern in "${command}".`,
243
+ "Provide a plain command without shell metacharacters (| & ; ` $ ( ) < >). The handler will validate against the command allowlist.",
244
+ true,
245
+ {
246
+ tool: toolName,
247
+ command,
248
+ }
249
+ );
250
+ }
251
+ }
package/src/smoke-test.ts CHANGED
@@ -26,7 +26,7 @@ import { resolve, join, dirname } from "node:path";
26
26
  import { tmpdir } from "node:os";
27
27
  import { fileURLToPath, pathToFileURL } from "node:url";
28
28
  import { spawnSync } from "node:child_process";
29
- import { loadConfig, getConfig, reloadConfig } from "./config.js";
29
+ import { loadConfig, getConfig, getTasksDir, reloadConfig } from "./config.js";
30
30
  import { savePlan } from "./tools/savePlan.js";
31
31
  import { getPlan } from "./tools/getPlan.js";
32
32
  import { createTask } from "./tools/createTask.js";
@@ -592,7 +592,7 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
592
592
  try {
593
593
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
594
594
  const fullTools = getToolDefs();
595
- if (fullTools.length !== 30) throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
595
+ if (fullTools.length !== 64) throw new Error(`Expected 64 full tools, got ${fullTools.length}`);
596
596
 
597
597
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
598
598
  const names = coreTools.map((tool) => tool.name);
@@ -1023,6 +1023,11 @@ test("I1. list_tasks returns tasks array", () => {
1023
1023
  mgmtPlanId = savePlan({ title: "Mgmt Test", content: "# Test" }).plan_id;
1024
1024
  mgmtTaskId = createTask({ plan_id: mgmtPlanId, agent: "codex", repo_path: "." }).task_id;
1025
1025
  mgmtTaskId2 = createTask({ plan_id: mgmtPlanId, agent: "codex", repo_path: "." }).task_id;
1026
+ const collectingStatusFile = join(getTasksDir(getConfig()), mgmtTaskId2, "status.json");
1027
+ const collectingStatus = JSON.parse(readFileSync(collectingStatusFile, "utf-8"));
1028
+ collectingStatus.status = "collecting_artifacts";
1029
+ collectingStatus.phase = "collecting_artifacts";
1030
+ writeFileSync(collectingStatusFile, JSON.stringify(collectingStatus, null, 2), "utf-8");
1026
1031
  const result = listTasks({ limit: 5 });
1027
1032
  if (!Array.isArray(result.tasks)) throw new Error("tasks not array");
1028
1033
  if (result.tasks.length < 2) throw new Error(`Expected >=2 tasks, got ${result.tasks.length}`);
@@ -1039,9 +1044,12 @@ test("I2b. list_tasks filters by repo and active status with watcher evidence",
1039
1044
  if (result.returned !== result.tasks.length || !result.watcher?.status) {
1040
1045
  throw new Error(`Missing list_tasks pagination or watcher evidence: ${JSON.stringify(result)}`);
1041
1046
  }
1042
- if (result.tasks.some((task) => !["pending", "running"].includes(task.status) || task.repo_path !== ".")) {
1047
+ if (result.tasks.some((task) => !["pending", "running", "collecting_artifacts"].includes(task.status) || task.repo_path !== ".")) {
1043
1048
  throw new Error(`list_tasks active/repo filter mismatch: ${JSON.stringify(result.tasks)}`);
1044
1049
  }
1050
+ if (!result.tasks.some((task) => task.task_id === mgmtTaskId2 && task.status === "collecting_artifacts")) {
1051
+ throw new Error(`list_tasks active_only omitted collecting_artifacts task: ${JSON.stringify(result.tasks)}`);
1052
+ }
1045
1053
  });
1046
1054
 
1047
1055
  test("I3. cancel_task cancels pending task", () => {
@@ -1926,10 +1934,10 @@ reloadConfig();
1926
1934
 
1927
1935
  let directSessionId = "";
1928
1936
 
1929
- test("M1. chatgpt_core still has 17 tools", () => {
1937
+ test("M1. chatgpt_core still has the expected tool manifest", () => {
1930
1938
  const tools = getToolDefs();
1931
1939
  const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1932
- if (coreTools.length !== 17) throw new Error(`Expected 17, got ${coreTools.length}`);
1940
+ if (coreTools.length !== CHATGPT_CORE_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_CORE_TOOL_NAMES.length}, got ${coreTools.length}`);
1933
1941
  if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1934
1942
  throw new Error("Tool names mismatch");
1935
1943
  }
@@ -1942,10 +1950,10 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1942
1950
  if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1943
1951
  });
1944
1952
 
1945
- test("M3. chatgpt_direct enabled has 10 tools", () => {
1953
+ test("M3. chatgpt_direct enabled has 14 tools", () => {
1946
1954
  const tools = getToolDefs();
1947
1955
  const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1948
- if (directTools.length !== 10) throw new Error(`Expected 10, got ${directTools.length}`);
1956
+ if (directTools.length !== CHATGPT_DIRECT_TOOL_NAMES.length) throw new Error(`Expected ${CHATGPT_DIRECT_TOOL_NAMES.length}, got ${directTools.length}`);
1949
1957
  if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1950
1958
  throw new Error("Tool names mismatch");
1951
1959
  }
@@ -8,6 +8,23 @@ export interface TaskRuntimeData {
8
8
  current_command: string | null;
9
9
  runner_pid?: number;
10
10
  child_pid?: number;
11
+ /**
12
+ * v0.7.0: ISO timestamp when the child process was spawned.
13
+ * Used by diagnose_task to detect PID reuse: if a live PID exists but
14
+ * its start time does not match child_started_at, the OS likely reused the PID.
15
+ */
16
+ child_started_at?: string;
17
+ /**
18
+ * v0.7.0: ISO timestamp when the task itself started (runner began executing).
19
+ * Distinct from child_started_at, which records when the agent child process spawned.
20
+ */
21
+ task_started_at?: string;
22
+ /**
23
+ * v0.7.0: Watcher instance ID that picked up and is executing this task.
24
+ * Used by diagnose_task to detect orphaned tasks when the current watcher
25
+ * instance_id differs from the one recorded at task start.
26
+ */
27
+ watcher_instance_id?: string;
11
28
  }
12
29
 
13
30
  export function readTaskRuntime(taskDir: string): Partial<TaskRuntimeData> {
@@ -0,0 +1,261 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import {
4
+ evaluateAcceptance,
5
+ verdictToStatus,
6
+ type AcceptanceEvidence,
7
+ } from "../../goal/acceptanceEngine.js";
8
+ import { renderAcceptanceMarkdown } from "../../goal/acceptanceTemplate.js";
9
+
10
+ // ── Helpers ───────────────────────────────────────────────────────
11
+
12
+ function makeEvidence(overrides: Partial<AcceptanceEvidence> = {}): AcceptanceEvidence {
13
+ return {
14
+ task_id: "task_test_001",
15
+ task_status: "done_by_agent",
16
+ result_md_exists: true,
17
+ result_json_exists: true,
18
+ verify_json_exists: true,
19
+ test_log_exists: true,
20
+ git_diff_exists: true,
21
+ verify_status: "passed",
22
+ new_out_of_scope_changes: [],
23
+ goal: null,
24
+ scope: null,
25
+ forbidden: null,
26
+ verification: null,
27
+ done_evidence: null,
28
+ artifact_status: "collected",
29
+ release_claims_unverified: false,
30
+ checks: [
31
+ { name: "task_status", result: "pass", detail: "Task status is done_by_agent." },
32
+ { name: "result_md_exists", result: "pass", detail: "result.md found." },
33
+ { name: "verify_status", result: "pass", detail: "Verification passed." },
34
+ ],
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ // ── Tests ─────────────────────────────────────────────────────────
40
+
41
+ describe("acceptanceEngine", () => {
42
+
43
+ describe("evaluateAcceptance — 基础场景", () => {
44
+
45
+ it("所有检查通过 → ACCEPTED", () => {
46
+ const evidence = makeEvidence();
47
+ const result = evaluateAcceptance(evidence);
48
+ assert.equal(result.verdict, "ACCEPTED");
49
+ assert.equal(result.acceptance_status, "accepted");
50
+ assert.equal(result.fail_checks.length, 0);
51
+ assert.equal(result.warn_checks.length, 0);
52
+ assert.ok(result.reason.includes("All acceptance checks passed"));
53
+ });
54
+
55
+ it("存在 fail 级检查项 → REJECTED", () => {
56
+ const evidence = makeEvidence({
57
+ checks: [
58
+ { name: "task_status", result: "pass", detail: "ok" },
59
+ { name: "result_md_exists", result: "fail", detail: "result.md is missing." },
60
+ { name: "verify_status", result: "pass", detail: "ok" },
61
+ ],
62
+ });
63
+ const result = evaluateAcceptance(evidence);
64
+ assert.equal(result.verdict, "REJECTED");
65
+ assert.equal(result.acceptance_status, "rejected");
66
+ assert.equal(result.fail_checks.length, 1);
67
+ assert.equal(result.fail_checks[0].name, "result_md_exists");
68
+ assert.ok(result.reason.includes("fail-level"));
69
+ });
70
+
71
+ it("存在 warn 级检查项(无 fail)→ NEEDS_FIX", () => {
72
+ const evidence = makeEvidence({
73
+ checks: [
74
+ { name: "task_status", result: "pass", detail: "ok" },
75
+ { name: "git_diff_exists", result: "warn", detail: "git.diff is missing." },
76
+ { name: "test_log_exists", result: "warn", detail: "test.log is missing." },
77
+ ],
78
+ });
79
+ const result = evaluateAcceptance(evidence);
80
+ assert.equal(result.verdict, "NEEDS_FIX");
81
+ assert.equal(result.acceptance_status, "needs_fix");
82
+ assert.equal(result.warn_checks.length, 2);
83
+ assert.ok(result.reason.includes("warn-level"));
84
+ });
85
+
86
+ it("存在 release claims 但无法验证 → BLOCKED_BY_APPROVAL(即使有 warn)", () => {
87
+ const evidence = makeEvidence({
88
+ release_claims_unverified: true,
89
+ checks: [
90
+ { name: "task_status", result: "pass", detail: "ok" },
91
+ { name: "release_claims_unverified", result: "warn", detail: "Found release claims." },
92
+ ],
93
+ });
94
+ const result = evaluateAcceptance(evidence);
95
+ assert.equal(result.verdict, "BLOCKED_BY_APPROVAL");
96
+ assert.equal(result.acceptance_status, "blocked");
97
+ assert.ok(result.reason.includes("Release claims"));
98
+ assert.ok(result.required_evidence.some((e) => e.includes("Verify remote")));
99
+ });
100
+
101
+ it("fail 级检查项优先于 release claims", () => {
102
+ const evidence = makeEvidence({
103
+ release_claims_unverified: true,
104
+ checks: [
105
+ { name: "task_status", result: "fail", detail: "Task failed." },
106
+ { name: "release_claims_unverified", result: "warn", detail: "Found release claims." },
107
+ ],
108
+ });
109
+ const result = evaluateAcceptance(evidence);
110
+ assert.equal(result.verdict, "REJECTED");
111
+ assert.equal(result.acceptance_status, "rejected");
112
+ });
113
+
114
+ it("多个 fail 级检查项全部收集", () => {
115
+ const evidence = makeEvidence({
116
+ checks: [
117
+ { name: "result_md_exists", result: "fail", detail: "missing" },
118
+ { name: "verify_status", result: "fail", detail: "failed" },
119
+ { name: "scope_changes", result: "fail", detail: "out of scope" },
120
+ ],
121
+ });
122
+ const result = evaluateAcceptance(evidence);
123
+ assert.equal(result.verdict, "REJECTED");
124
+ assert.equal(result.fail_checks.length, 3);
125
+ });
126
+ });
127
+
128
+ describe("verdictToStatus", () => {
129
+ it("ACCEPTED → accepted", () => {
130
+ assert.equal(verdictToStatus("ACCEPTED"), "accepted");
131
+ });
132
+
133
+ it("REJECTED → rejected", () => {
134
+ assert.equal(verdictToStatus("REJECTED"), "rejected");
135
+ });
136
+
137
+ it("NEEDS_FIX → needs_fix", () => {
138
+ assert.equal(verdictToStatus("NEEDS_FIX"), "needs_fix");
139
+ });
140
+
141
+ it("BLOCKED_BY_APPROVAL → blocked", () => {
142
+ assert.equal(verdictToStatus("BLOCKED_BY_APPROVAL"), "blocked");
143
+ });
144
+ });
145
+
146
+ describe("evaluateAcceptance — next_suggested_task", () => {
147
+ it("ACCEPTED 的 next_suggested_task 包含 'accepted'", () => {
148
+ const result = evaluateAcceptance(makeEvidence());
149
+ assert.ok(result.next_suggested_task.includes("accepted"));
150
+ });
151
+
152
+ it("REJECTED 的 next_suggested_task 包含 fail 检查名", () => {
153
+ const result = evaluateAcceptance(makeEvidence({
154
+ checks: [{ name: "result_md_exists", result: "fail", detail: "missing" }],
155
+ }));
156
+ assert.ok(result.next_suggested_task.includes("result_md_exists"));
157
+ });
158
+
159
+ it("NEEDS_FIX 的 next_suggested_task 包含 warn 检查名", () => {
160
+ const result = evaluateAcceptance(makeEvidence({
161
+ checks: [{ name: "git_diff_exists", result: "warn", detail: "missing" }],
162
+ }));
163
+ assert.ok(result.next_suggested_task.includes("git_diff_exists"));
164
+ });
165
+
166
+ it("BLOCKED_BY_APPROVAL 的 next_suggested_task 包含 'verify'", () => {
167
+ const result = evaluateAcceptance(makeEvidence({ release_claims_unverified: true }));
168
+ assert.ok(result.next_suggested_task.toLowerCase().includes("verify"));
169
+ });
170
+ });
171
+
172
+ describe("evaluateAcceptance — reasons 可追溯", () => {
173
+ it("REJECTED 的 reasons 包含 [FAIL] 前缀", () => {
174
+ const result = evaluateAcceptance(makeEvidence({
175
+ checks: [{ name: "result_md_exists", result: "fail", detail: "missing" }],
176
+ }));
177
+ assert.ok(result.reasons.some((r) => r.includes("[FAIL]")));
178
+ });
179
+
180
+ it("NEEDS_FIX 的 reasons 包含 [WARN] 前缀", () => {
181
+ const result = evaluateAcceptance(makeEvidence({
182
+ checks: [{ name: "git_diff_exists", result: "warn", detail: "missing" }],
183
+ }));
184
+ assert.ok(result.reasons.some((r) => r.includes("[WARN]")));
185
+ });
186
+
187
+ it("BLOCKED_BY_APPROVAL 的 reasons 包含 [BLOCKED] 前缀", () => {
188
+ const result = evaluateAcceptance(makeEvidence({ release_claims_unverified: true }));
189
+ assert.ok(result.reasons.some((r) => r.includes("[BLOCKED]")));
190
+ });
191
+
192
+ it("ACCEPTED 的 reasons 非空", () => {
193
+ const result = evaluateAcceptance(makeEvidence());
194
+ assert.ok(result.reasons.length > 0);
195
+ });
196
+ });
197
+
198
+ describe("renderAcceptanceMarkdown", () => {
199
+ it("生成包含 verdict 的 ACCEPTANCE.md 内容", () => {
200
+ const evidence = makeEvidence();
201
+ const result = evaluateAcceptance(evidence);
202
+ const md = renderAcceptanceMarkdown("task_test_001", result, evidence);
203
+ assert.ok(md.includes("# Acceptance Report — task_test_001"));
204
+ assert.ok(md.includes("**ACCEPTED**"));
205
+ assert.ok(md.includes("## Evidence Summary"));
206
+ assert.ok(md.includes("## Verdict"));
207
+ });
208
+
209
+ it("REJECTED 的 Markdown 包含 fail 检查", () => {
210
+ const evidence = makeEvidence({
211
+ checks: [{ name: "result_md_exists", result: "fail", detail: "missing" }],
212
+ });
213
+ const result = evaluateAcceptance(evidence);
214
+ const md = renderAcceptanceMarkdown("task_test_002", result, evidence);
215
+ assert.ok(md.includes("**REJECTED**"));
216
+ assert.ok(md.includes("## Fail-Level Checks"));
217
+ assert.ok(md.includes("result_md_exists"));
218
+ });
219
+
220
+ it("NEEDS_FIX 的 Markdown 包含 warn 检查", () => {
221
+ const evidence = makeEvidence({
222
+ checks: [{ name: "git_diff_exists", result: "warn", detail: "missing" }],
223
+ });
224
+ const result = evaluateAcceptance(evidence);
225
+ const md = renderAcceptanceMarkdown("task_test_003", result, evidence);
226
+ assert.ok(md.includes("**NEEDS_FIX**"));
227
+ assert.ok(md.includes("## Warn-Level Checks"));
228
+ });
229
+
230
+ it("BLOCKED 的 Markdown 包含 required_evidence", () => {
231
+ const evidence = makeEvidence({ release_claims_unverified: true });
232
+ const result = evaluateAcceptance(evidence);
233
+ const md = renderAcceptanceMarkdown("task_test_004", result, evidence);
234
+ assert.ok(md.includes("**BLOCKED_BY_APPROVAL**"));
235
+ assert.ok(md.includes("## Required Evidence"));
236
+ });
237
+
238
+ it("有 goal/scope 时显示 Task Acceptance Criteria", () => {
239
+ const evidence = makeEvidence({
240
+ goal: "Implement feature X",
241
+ scope: ["src/feature.ts"],
242
+ forbidden: [".env"],
243
+ verification: ["npm test"],
244
+ done_evidence: ["result.md", "test.log"],
245
+ });
246
+ const result = evaluateAcceptance(evidence);
247
+ const md = renderAcceptanceMarkdown("task_test_005", result, evidence);
248
+ assert.ok(md.includes("## Task Acceptance Criteria"));
249
+ assert.ok(md.includes("Implement feature X"));
250
+ assert.ok(md.includes("src/feature.ts"));
251
+ assert.ok(md.includes(".env"));
252
+ });
253
+
254
+ it("无 goal/scope 时不显示 Task Acceptance Criteria section", () => {
255
+ const evidence = makeEvidence();
256
+ const result = evaluateAcceptance(evidence);
257
+ const md = renderAcceptanceMarkdown("task_test_006", result, evidence);
258
+ assert.ok(!md.includes("## Task Acceptance Criteria"));
259
+ });
260
+ });
261
+ });