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
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { guardDirectPath, guardDirectWritePath, guardDirectReadPath, guardDirectPatchSize, guardDirectFileSize, isBinaryFile } from "../../direct/directGuards.js";
7
7
  import { PatchWardenError } from "../../errors.js";
8
+ import { reloadConfig } from "../../config.js";
8
9
  import type { PatchWardenConfig } from "../../config.js";
9
10
 
10
11
  function makeConfig(workspaceRoot: string): PatchWardenConfig {
@@ -199,30 +200,87 @@ describe("guardDirectPatchSize", () => {
199
200
 
200
201
  beforeEach(() => {
201
202
  tempDir = mkdtempSync(join(tmpdir(), "pw-patchsize-"));
202
- process.env.PATCHWARDEN_CONFIG = "";
203
+ // Write a real config file so getConfig() picks it up
204
+ const configPath = join(tempDir, "patchwarden.config.json");
205
+ writeFileSync(configPath, JSON.stringify({
206
+ workspaceRoot: tempDir,
207
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
208
+ allowedTestCommands: ["npm test"],
209
+ directMaxPatchBytes: 1000,
210
+ directMaxFileBytes: 5000,
211
+ }), "utf-8");
212
+ process.env.PATCHWARDEN_CONFIG = configPath;
213
+ reloadConfig();
203
214
  });
204
215
 
205
216
  afterEach(() => {
206
217
  rmSync(tempDir, { recursive: true, force: true });
207
218
  delete process.env.PATCHWARDEN_CONFIG;
219
+ reloadConfig();
208
220
  });
209
221
 
210
222
  it("allows patches within size limit", () => {
211
- // We can't easily call this without a config, so just test the logic
212
- // guardDirectPatchSize calls getConfig() internally
213
- // We'll test it indirectly by checking it doesn't throw for small sizes
214
- // when config is loaded with defaults
215
- // Skip if no config available
223
+ assert.doesNotThrow(() => guardDirectPatchSize(500));
224
+ assert.doesNotThrow(() => guardDirectPatchSize(1000));
216
225
  });
217
226
 
218
227
  it("rejects patches exceeding size limit", () => {
219
- // This test would need config setup; skip for now
228
+ assert.throws(
229
+ () => guardDirectPatchSize(1001),
230
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "patch_too_large"
231
+ );
232
+ assert.throws(
233
+ () => guardDirectPatchSize(999999),
234
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "patch_too_large"
235
+ );
236
+ });
237
+
238
+ it("allows zero-size patch", () => {
239
+ assert.doesNotThrow(() => guardDirectPatchSize(0));
220
240
  });
221
241
  });
222
242
 
223
243
  describe("guardDirectFileSize", () => {
244
+ let tempDir: string;
245
+
246
+ beforeEach(() => {
247
+ tempDir = mkdtempSync(join(tmpdir(), "pw-filesize-"));
248
+ const configPath = join(tempDir, "patchwarden.config.json");
249
+ writeFileSync(configPath, JSON.stringify({
250
+ workspaceRoot: tempDir,
251
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
252
+ allowedTestCommands: ["npm test"],
253
+ directMaxPatchBytes: 1000,
254
+ directMaxFileBytes: 5000,
255
+ }), "utf-8");
256
+ process.env.PATCHWARDEN_CONFIG = configPath;
257
+ reloadConfig();
258
+ });
259
+
260
+ afterEach(() => {
261
+ rmSync(tempDir, { recursive: true, force: true });
262
+ delete process.env.PATCHWARDEN_CONFIG;
263
+ reloadConfig();
264
+ });
265
+
224
266
  it("allows files within size limit", () => {
225
- // Similar to above, needs config
267
+ assert.doesNotThrow(() => guardDirectFileSize(100));
268
+ assert.doesNotThrow(() => guardDirectFileSize(5000));
269
+ });
270
+
271
+ it("rejects files exceeding size limit", () => {
272
+ assert.throws(
273
+ () => guardDirectFileSize(5001),
274
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "file_too_large"
275
+ );
276
+ assert.throws(
277
+ () => guardDirectFileSize(9999999),
278
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "file_too_large"
279
+ );
280
+ });
281
+
282
+ it("allows zero-size file", () => {
283
+ assert.doesNotThrow(() => guardDirectFileSize(0));
226
284
  });
227
285
  });
228
286
 
@@ -294,4 +352,116 @@ describe("isBinaryFile", () => {
294
352
  it("returns false for non-existent files without binary extension", () => {
295
353
  assert.equal(isBinaryFile(join(tempDir, "nonexistent.txt")), false);
296
354
  });
355
+
356
+ // ── Adversarial: null byte beyond 8KB read window ──
357
+ it("detects null byte at position 8200 (beyond old 8KB window)", () => {
358
+ // Previously isBinaryFile only read the first 8192 bytes, so a null byte
359
+ // at position 8200 would bypass detection. Now we scan up to 1 MB.
360
+ const stealthBinary = join(tempDir, "stealth.txt");
361
+ const buffer = Buffer.alloc(8200, 0x41); // 8200 bytes of 'A'
362
+ buffer[8199] = 0; // null byte just beyond the old 8KB read window
363
+ writeFileSync(stealthBinary, buffer);
364
+ assert.equal(isBinaryFile(stealthBinary), true);
365
+ });
366
+
367
+ it("detects null byte at exactly position 8192 (first byte beyond old window)", () => {
368
+ const stealthBinary = join(tempDir, "boundary-plus.txt");
369
+ const buffer = Buffer.alloc(8193, 0x41);
370
+ buffer[8192] = 0; // first byte beyond the old 8KB read window
371
+ writeFileSync(stealthBinary, buffer);
372
+ assert.equal(isBinaryFile(stealthBinary), true);
373
+ });
374
+
375
+ it("detects null byte at 100KB offset", () => {
376
+ const deepBinary = join(tempDir, "deep.txt");
377
+ const buffer = Buffer.alloc(102400, 0x41);
378
+ buffer[102399] = 0;
379
+ writeFileSync(deepBinary, buffer);
380
+ assert.equal(isBinaryFile(deepBinary), true);
381
+ });
382
+
383
+ it("detects null byte at 1MB offset (scan limit boundary)", () => {
384
+ const limitBinary = join(tempDir, "limit.txt");
385
+ const buffer = Buffer.alloc(1_048_576, 0x41);
386
+ buffer[1_048_575] = 0;
387
+ writeFileSync(limitBinary, buffer);
388
+ assert.equal(isBinaryFile(limitBinary), true);
389
+ });
390
+
391
+ it("does not scan beyond 1MB limit", () => {
392
+ // A null byte just past the 1MB scan limit should NOT be detected.
393
+ // This documents the remaining scan boundary.
394
+ const overLimit = join(tempDir, "over-limit.txt");
395
+ const buffer = Buffer.alloc(1_048_577, 0x41);
396
+ buffer[1_048_576] = 0; // first byte beyond scan limit
397
+ writeFileSync(overLimit, buffer);
398
+ assert.equal(isBinaryFile(overLimit), false);
399
+ });
400
+ });
401
+
402
+ // ── Adversarial: Windows backslash prefix bypass attempts ──
403
+
404
+ describe("guardDirectWritePath adversarial separator tests", () => {
405
+ let tempDir: string;
406
+ let repoPath: string;
407
+
408
+ beforeEach(() => {
409
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directsep-"));
410
+ repoPath = join(tempDir, "my-repo");
411
+ mkdirSync(repoPath, { recursive: true });
412
+ mkdirSync(join(repoPath, "src"), { recursive: true });
413
+ });
414
+
415
+ afterEach(() => {
416
+ rmSync(tempDir, { recursive: true, force: true });
417
+ });
418
+
419
+ it("blocks node_modules with backslash prefix", () => {
420
+ assert.throws(
421
+ () => guardDirectWritePath("node_modules\\evil\\index.js", repoPath, tempDir),
422
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
423
+ );
424
+ });
425
+
426
+ it("blocks dist with backslash prefix", () => {
427
+ assert.throws(
428
+ () => guardDirectWritePath("dist\\main.js", repoPath, tempDir),
429
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
430
+ );
431
+ });
432
+
433
+ it("blocks release with backslash prefix", () => {
434
+ assert.throws(
435
+ () => guardDirectWritePath("release\\app.exe", repoPath, tempDir),
436
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
437
+ );
438
+ });
439
+
440
+ it("blocks nested node_modules with mixed separators", () => {
441
+ assert.throws(
442
+ () => guardDirectWritePath("src\\node_modules/evil\\index.js", repoPath, tempDir),
443
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
444
+ );
445
+ });
446
+
447
+ it("blocks .patchwarden with backslash prefix", () => {
448
+ assert.throws(
449
+ () => guardDirectWritePath(".patchwarden\\tasks\\evil.json", repoPath, tempDir),
450
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "internal_patchwarden_path_blocked"
451
+ );
452
+ });
453
+
454
+ it("blocks .env with backslash prefix", () => {
455
+ assert.throws(
456
+ () => guardDirectWritePath("path\\to\\.env", repoPath, tempDir),
457
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
458
+ );
459
+ });
460
+
461
+ it("blocks config.json with backslash path", () => {
462
+ assert.throws(
463
+ () => guardDirectWritePath("subdir\\config.json", repoPath, tempDir),
464
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
465
+ );
466
+ });
297
467
  });
@@ -0,0 +1,251 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { recommendAgentForTask } from "../../tools/recommendAgentForTask.js";
8
+ import { exportTaskEvidencePack, listEvidencePacks, readEvidencePack } from "../../tools/evidencePack.js";
9
+ import { writeTaskLineage } from "../../tools/taskLineage.js";
10
+
11
+ let tempDir: string;
12
+ let prevConfigEnv: string | undefined;
13
+
14
+ function writeConfig() {
15
+ const configPath = join(tempDir, "patchwarden.config.json");
16
+ writeFileSync(
17
+ configPath,
18
+ JSON.stringify({
19
+ workspaceRoot: tempDir,
20
+ tasksDir: ".patchwarden/tasks",
21
+ plansDir: ".patchwarden/plans",
22
+ assessmentsDir: ".patchwarden/assessments",
23
+ agents: {
24
+ codex: { command: "codex", args: [] },
25
+ opencode: { command: "opencode", args: [] },
26
+ },
27
+ allowedTestCommands: ["npm test", "npm run build"],
28
+ defaultTaskTimeoutSeconds: 30,
29
+ maxTaskTimeoutSeconds: 120,
30
+ }),
31
+ "utf-8"
32
+ );
33
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
34
+ process.env.PATCHWARDEN_CONFIG = configPath;
35
+ reloadConfig();
36
+ }
37
+
38
+ describe("v1.5 evidence packs and agent recommendations", () => {
39
+ beforeEach(() => {
40
+ tempDir = mkdtempSync(join(tmpdir(), "pw-evidence-"));
41
+ writeConfig();
42
+ });
43
+
44
+ afterEach(() => {
45
+ if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
46
+ else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
47
+ reloadConfig();
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ });
50
+
51
+ it("recommends an agent without creating task evidence", () => {
52
+ const recommendation = recommendAgentForTask({
53
+ repo_path: ".",
54
+ goal: "Refactor two modules safely",
55
+ scope_files: ["src/a.ts", "src/b.ts"],
56
+ });
57
+
58
+ assert.equal(recommendation.bounded, true);
59
+ assert.ok(["codex", "opencode"].includes(recommendation.recommended_agent));
60
+ assert.ok(Array.isArray(recommendation.suggested_verify_commands));
61
+ const payload = JSON.stringify(recommendation);
62
+ assert.ok(!payload.includes("stdout"));
63
+ assert.ok(!payload.includes("stderr"));
64
+ assert.ok(!payload.includes("diff"));
65
+ });
66
+
67
+ it("exports bounded BOM-free evidence pack from lineage", () => {
68
+ writeTaskLineage({
69
+ lineage_id: "lineage_v15_pack",
70
+ goal: "Export evidence",
71
+ repo_path: ".",
72
+ created_at: "2026-07-05T12:00:00.000Z",
73
+ updated_at: "2026-07-05T12:01:00.000Z",
74
+ final_status: "accepted",
75
+ stop_reason: "success",
76
+ next_action: "accept",
77
+ main_task: "task-main",
78
+ fix_tasks: [],
79
+ cleanup_tasks: [],
80
+ direct_sessions: [{
81
+ session_id: "direct-one",
82
+ status: "passed",
83
+ command_count: 1,
84
+ passed_commands: 1,
85
+ failed_commands: 0,
86
+ timed_out_commands: 0,
87
+ audit_decision: "pass",
88
+ changed_files_total: 0,
89
+ next_action: "accept",
90
+ }],
91
+ rounds: [{
92
+ iteration: 1,
93
+ task_id: "task-main",
94
+ role: "main",
95
+ status: "done_by_agent",
96
+ terminal: true,
97
+ verification_status: "passed",
98
+ audit_verdict: "pass",
99
+ fail_checks: [],
100
+ warn_checks: [],
101
+ next_action: "accept",
102
+ }],
103
+ warnings: [],
104
+ errors: [],
105
+ worktree: {
106
+ isolation_mode: "worktree",
107
+ worktree_id: "wt-one",
108
+ worktree_path: join(tempDir, "_workspacetrees", "wt-one"),
109
+ branch: "pw-test",
110
+ cleanup: "keep",
111
+ status: "active",
112
+ next_action: "merge or discard explicitly",
113
+ },
114
+ agent_routing: {
115
+ requested_agent: "auto",
116
+ selected_agent: "codex",
117
+ reason: "test route",
118
+ fallback: false,
119
+ },
120
+ });
121
+
122
+ const pack = exportTaskEvidencePack({ lineage_id: "lineage_v15_pack" });
123
+ assert.equal(pack.bounded, true);
124
+ assert.equal(pack.lineage.worktree.worktree_id, "wt-one");
125
+ assert.equal(pack.lineage.agent_routing?.selected_agent, "codex");
126
+ const raw = readFileSync(pack.files.json);
127
+ assert.notEqual(raw[0], 0xef);
128
+ JSON.parse(raw.toString("utf-8"));
129
+
130
+ const payload = JSON.stringify(pack);
131
+ assert.ok(!payload.includes("stdout_tail"));
132
+ assert.ok(!payload.includes("stderr_tail"));
133
+ assert.ok(!payload.includes("diff.patch"));
134
+ assert.ok(!payload.includes("verification.log"));
135
+
136
+ const readBack = readEvidencePack("lineage_v15_pack");
137
+ assert.equal(readBack?.lineage_id, "lineage_v15_pack");
138
+ const listed = listEvidencePacks();
139
+ assert.equal(listed.total, 1);
140
+ assert.equal(listed.evidence_packs[0].lineage_id, "lineage_v15_pack");
141
+
142
+ // v2: all six structured artifact files should exist.
143
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
144
+ assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
145
+ }
146
+
147
+ // v2: attestation.json must carry the required provenance fields.
148
+ const attestation = JSON.parse(readFileSync(pack.files.attestation, "utf-8"));
149
+ assert.ok(attestation.patchwarden_version, "attestation should have patchwarden_version");
150
+ assert.ok(attestation.package_version, "attestation should have package_version");
151
+ assert.ok(attestation.commit, "attestation should have commit");
152
+ assert.ok(attestation.node_version, "attestation should have node_version");
153
+ assert.ok(attestation.os, "attestation should have os");
154
+ assert.ok(attestation.schema_epoch, "attestation should have schema_epoch");
155
+
156
+ // v2: redactions.json must have the audit structure even when empty.
157
+ const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
158
+ assert.ok(Array.isArray(redactions.redactions), "redactions should be an array");
159
+ assert.equal(typeof redactions.total_redacted, "number", "total_redacted should be a number");
160
+ });
161
+
162
+ it("exports v2 structured artifacts and redacts secrets in diffstat", () => {
163
+ const secretValue = "ghp_1234567890abcdefghijklmnop";
164
+ // Create a task directory with file-stats.json containing a secret in a file path.
165
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "task-v2-secret");
166
+ mkdirSync(taskDir, { recursive: true });
167
+ writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
168
+ task_id: "task-v2-secret",
169
+ additions: 5,
170
+ deletions: 2,
171
+ files: [{
172
+ path: `secrets/${secretValue}.json`,
173
+ status: "modified",
174
+ additions: 5,
175
+ deletions: 2,
176
+ }],
177
+ }), "utf-8");
178
+
179
+ writeTaskLineage({
180
+ lineage_id: "lineage_v2_secret",
181
+ goal: "Export v2 evidence with diffstat",
182
+ repo_path: ".",
183
+ created_at: "2026-07-09T12:00:00.000Z",
184
+ updated_at: "2026-07-09T12:01:00.000Z",
185
+ final_status: "accepted",
186
+ stop_reason: "success",
187
+ next_action: "accept",
188
+ main_task: "task-v2-secret",
189
+ fix_tasks: [],
190
+ cleanup_tasks: [],
191
+ direct_sessions: [],
192
+ rounds: [{
193
+ iteration: 1,
194
+ task_id: "task-v2-secret",
195
+ role: "main",
196
+ status: "done_by_agent",
197
+ terminal: true,
198
+ verification_status: "passed",
199
+ audit_verdict: "pass",
200
+ fail_checks: [],
201
+ warn_checks: ["minor scope drift"],
202
+ next_action: "accept",
203
+ }],
204
+ warnings: [],
205
+ errors: [],
206
+ worktree: {
207
+ isolation_mode: "current_repo",
208
+ cleanup: "keep",
209
+ status: "not_used",
210
+ next_action: "none",
211
+ },
212
+ });
213
+
214
+ const pack = exportTaskEvidencePack({ lineage_id: "lineage_v2_secret" });
215
+
216
+ // All six v2 files should exist.
217
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
218
+ assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
219
+ }
220
+
221
+ // The original secret must not appear in any v2 file.
222
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
223
+ const content = readFileSync(pack.files[key], "utf-8");
224
+ assert.ok(!content.includes(secretValue), `${key} should not contain the original secret value`);
225
+ }
226
+
227
+ // redactions.json should have detected the secret via diffstat aggregation.
228
+ const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
229
+ assert.ok(redactions.total_redacted > 0, "redactions.json should have detected the secret");
230
+ assert.ok(
231
+ !JSON.stringify(redactions).includes(secretValue),
232
+ "redactions.json should not contain the original secret value"
233
+ );
234
+
235
+ // risk.json should carry the warn_check as a medium-severity risk.
236
+ const risk = JSON.parse(readFileSync(pack.files.risk, "utf-8"));
237
+ assert.ok(risk.count > 0, "risk.json should have at least one risk item");
238
+ assert.equal(risk.by_severity.medium, 1, "should have one medium-severity risk");
239
+
240
+ // verify.json should carry the round verification record.
241
+ const verify = JSON.parse(readFileSync(pack.files.verify, "utf-8"));
242
+ assert.ok(verify.count > 0, "verify.json should have at least one record");
243
+ assert.equal(verify.records[0].verification_status, "passed");
244
+
245
+ // lineage.json should carry the bounded summary.
246
+ const lineageSummary = JSON.parse(readFileSync(pack.files.lineage, "utf-8"));
247
+ assert.equal(lineageSummary.lineage_id, "lineage_v2_secret");
248
+ assert.equal(lineageSummary.iterations_count, 1);
249
+ assert.equal(lineageSummary.final_status, "accepted");
250
+ });
251
+ });
@@ -0,0 +1,203 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { getTaskStatus } from "../../tools/getTaskStatus.js";
8
+
9
+ let tempDir: string;
10
+ let tasksDir: string;
11
+ let prevConfigEnv: string | undefined;
12
+
13
+ function writeConfig(): void {
14
+ const configPath = join(tempDir, "patchwarden.config.json");
15
+ writeFileSync(
16
+ configPath,
17
+ JSON.stringify({
18
+ workspaceRoot: tempDir,
19
+ tasksDir: ".patchwarden/tasks",
20
+ plansDir: ".patchwarden/plans",
21
+ assessmentsDir: ".patchwarden/assessments",
22
+ directSessionsDir: ".patchwarden/direct-sessions",
23
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
24
+ allowedTestCommands: ["npm test"],
25
+ }),
26
+ "utf-8"
27
+ );
28
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
29
+ process.env.PATCHWARDEN_CONFIG = configPath;
30
+ reloadConfig();
31
+ }
32
+
33
+ function writeTaskStatus(taskId: string, status: Record<string, unknown>): string {
34
+ const taskDir = join(tasksDir, taskId);
35
+ mkdirSync(taskDir, { recursive: true });
36
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
37
+ return taskDir;
38
+ }
39
+
40
+ function writeTaskRuntime(taskDir: string, runtime: Record<string, unknown>): void {
41
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
42
+ }
43
+
44
+ describe("getTaskStatus", () => {
45
+ beforeEach(() => {
46
+ tempDir = mkdtempSync(join(tmpdir(), "pw-getstatus-"));
47
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
48
+ mkdirSync(tasksDir, { recursive: true });
49
+ writeConfig();
50
+ });
51
+
52
+ afterEach(() => {
53
+ if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
54
+ else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
55
+ reloadConfig();
56
+ rmSync(tempDir, { recursive: true, force: true });
57
+ });
58
+
59
+ // ── pending ──
60
+ it("reads pending status task", () => {
61
+ writeTaskStatus("task-pending-001", {
62
+ task_id: "task-pending-001",
63
+ plan_id: "plan-001",
64
+ agent: "codex",
65
+ workspace_root: tempDir,
66
+ repo_path: "repo",
67
+ resolved_repo_path: join(tempDir, "repo"),
68
+ status: "pending",
69
+ phase: "queued",
70
+ created_at: "2026-07-12T10:00:00Z",
71
+ updated_at: "2026-07-12T10:00:01Z",
72
+ timeout_seconds: 900,
73
+ error: null,
74
+ });
75
+
76
+ const result = getTaskStatus("task-pending-001");
77
+
78
+ assert.equal(result.task_id, "task-pending-001");
79
+ assert.equal(result.status, "pending");
80
+ assert.equal(result.phase, "queued");
81
+ assert.equal(result.agent, "codex");
82
+ assert.equal(result.plan_id, "plan-001");
83
+ assert.equal(result.error, null);
84
+ // No watcher heartbeat → watcher missing → pending task is execution_blocked
85
+ assert.equal(result.execution_blocked, true);
86
+ assert.equal(result.watcher_status, "missing");
87
+ assert.equal(result.pending_reason, "queued_but_watcher_missing");
88
+ });
89
+
90
+ // ── running ──
91
+ it("reads running status task and merges runtime.json", () => {
92
+ const taskDir = writeTaskStatus("task-running-001", {
93
+ task_id: "task-running-001",
94
+ plan_id: "plan-001",
95
+ agent: "codex",
96
+ workspace_root: tempDir,
97
+ repo_path: "repo",
98
+ resolved_repo_path: join(tempDir, "repo"),
99
+ status: "running",
100
+ phase: "executing_agent",
101
+ created_at: "2026-07-12T10:00:00Z",
102
+ started_at: "2026-07-12T10:00:05Z",
103
+ updated_at: "2026-07-12T10:00:10Z",
104
+ timeout_seconds: 900,
105
+ error: null,
106
+ });
107
+ writeTaskRuntime(taskDir, {
108
+ phase: "executing_agent",
109
+ last_heartbeat_at: "2026-07-12T10:00:12Z",
110
+ current_command: "codex exec --prompt",
111
+ });
112
+
113
+ const result = getTaskStatus("task-running-001");
114
+
115
+ assert.equal(result.status, "running");
116
+ assert.equal(result.phase, "executing_agent");
117
+ assert.equal(result.current_command, "codex exec --prompt");
118
+ assert.equal(result.last_heartbeat_at, "2026-07-12T10:00:12Z");
119
+ assert.equal(result.started_at, "2026-07-12T10:00:05Z");
120
+ // running (not pending) → execution_blocked is false
121
+ assert.equal(result.execution_blocked, false);
122
+ assert.equal(result.pending_reason, "agent_running");
123
+ });
124
+
125
+ // ── done ──
126
+ it("reads done status task", () => {
127
+ writeTaskStatus("task-done-001", {
128
+ task_id: "task-done-001",
129
+ plan_id: "plan-001",
130
+ agent: "codex",
131
+ workspace_root: tempDir,
132
+ repo_path: "repo",
133
+ resolved_repo_path: join(tempDir, "repo"),
134
+ status: "done",
135
+ phase: "completed",
136
+ created_at: "2026-07-12T10:00:00Z",
137
+ started_at: "2026-07-12T10:00:05Z",
138
+ finished_at: "2026-07-12T10:05:00Z",
139
+ updated_at: "2026-07-12T10:05:00Z",
140
+ timeout_seconds: 900,
141
+ verify_status: "passed",
142
+ error: null,
143
+ });
144
+
145
+ const result = getTaskStatus("task-done-001");
146
+
147
+ assert.equal(result.status, "done");
148
+ assert.equal(result.phase, "completed");
149
+ assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
150
+ assert.equal(result.verify_status, "passed");
151
+ assert.equal(result.execution_blocked, false);
152
+ assert.equal(result.pending_reason, null);
153
+ });
154
+
155
+ // ── failed ──
156
+ it("reads failed status task", () => {
157
+ writeTaskStatus("task-failed-001", {
158
+ task_id: "task-failed-001",
159
+ plan_id: "plan-001",
160
+ agent: "codex",
161
+ workspace_root: tempDir,
162
+ repo_path: "repo",
163
+ resolved_repo_path: join(tempDir, "repo"),
164
+ status: "failed",
165
+ phase: "failed",
166
+ created_at: "2026-07-12T10:00:00Z",
167
+ started_at: "2026-07-12T10:00:05Z",
168
+ finished_at: "2026-07-12T10:05:00Z",
169
+ updated_at: "2026-07-12T10:05:00Z",
170
+ timeout_seconds: 900,
171
+ error: "Agent exited with code 1",
172
+ });
173
+
174
+ const result = getTaskStatus("task-failed-001");
175
+
176
+ assert.equal(result.status, "failed");
177
+ assert.equal(result.phase, "failed");
178
+ assert.equal(result.error, "Agent exited with code 1");
179
+ assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
180
+ assert.equal(result.execution_blocked, false);
181
+ });
182
+
183
+ // ── status.json missing ──
184
+ it("throws when status.json is missing (task not found)", () => {
185
+ // guardReadPath throws "File not found" before the existsSync "Task not found" check
186
+ assert.throws(
187
+ () => getTaskStatus("non-existent-task"),
188
+ /(File not found|Task not found)/
189
+ );
190
+ });
191
+
192
+ // ── status.json corrupt ──
193
+ it("throws when status.json is corrupt", () => {
194
+ const taskDir = join(tasksDir, "task-corrupt-001");
195
+ mkdirSync(taskDir, { recursive: true });
196
+ writeFileSync(join(taskDir, "status.json"), "{not valid json", "utf-8");
197
+
198
+ assert.throws(
199
+ () => getTaskStatus("task-corrupt-001"),
200
+ SyntaxError
201
+ );
202
+ });
203
+ });