patchwarden 0.4.0 → 0.6.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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -12,15 +12,17 @@
12
12
  *
13
13
  * Run: node dist/smoke-test.js
14
14
  */
15
- import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, mkdtempSync, } from "node:fs";
15
+ import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, readdirSync, mkdtempSync, } from "node:fs";
16
16
  import { resolve, join, dirname } from "node:path";
17
17
  import { tmpdir } from "node:os";
18
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
19
  import { spawnSync } from "node:child_process";
20
- import { loadConfig, getConfig } from "./config.js";
20
+ import { loadConfig, getConfig, reloadConfig } from "./config.js";
21
21
  import { savePlan } from "./tools/savePlan.js";
22
22
  import { getPlan } from "./tools/getPlan.js";
23
23
  import { createTask } from "./tools/createTask.js";
24
+ import { confirmAssessment, readAssessment, createAssessment } from "./assessments/assessmentStore.js";
25
+ import { captureRepoSnapshot } from "./runner/changeCapture.js";
24
26
  import { getTaskStatus } from "./tools/getTaskStatus.js";
25
27
  import { getResult, getDiff, getTestLog } from "./tools/taskOutputs.js";
26
28
  import { listWorkspace } from "./tools/listWorkspace.js";
@@ -33,9 +35,17 @@ import { auditTask } from "./tools/auditTask.js";
33
35
  import { getTaskSummary } from "./tools/getTaskSummary.js";
34
36
  import { guardAgentCommand } from "./security/commandGuard.js";
35
37
  import { getToolDefs } from "./tools/registry.js";
36
- import { buildToolCatalogSnapshot, CHATGPT_CORE_TOOL_NAMES, selectToolsForProfile, } from "./tools/toolCatalog.js";
38
+ import { buildToolCatalogSnapshot, CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES, selectToolsForProfile, } from "./tools/toolCatalog.js";
37
39
  import { errorPayload } from "./errors.js";
38
40
  import { readWatcherStatus } from "./watcherStatus.js";
41
+ import { createDirectSession } from "./tools/createDirectSession.js";
42
+ import { searchWorkspace } from "./tools/searchWorkspace.js";
43
+ import { applyPatch } from "./tools/applyPatch.js";
44
+ import { runVerification } from "./tools/runVerification.js";
45
+ import { finalizeDirectSession } from "./tools/finalizeDirectSession.js";
46
+ import { auditSession } from "./tools/auditSession.js";
47
+ import { readDirectSession, updateDirectSession } from "./direct/directSessionStore.js";
48
+ import { createHash } from "node:crypto";
39
49
  // Resolve the actual node binary path (spawnSync needs it on WSL/Windows)
40
50
  let nodeBin = process.execPath;
41
51
  if (!nodeBin || nodeBin === "node") {
@@ -58,6 +68,9 @@ writeFileSync(smokeConfigPath, JSON.stringify({
58
68
  },
59
69
  },
60
70
  allowedTestCommands: ["npm test", "npm run test", "pytest", "cargo test"],
71
+ repoAllowedTestCommands: {
72
+ "scoped-repo": ["npm run release:check"],
73
+ },
61
74
  maxReadFileBytes: 200000,
62
75
  }, null, 2), "utf-8");
63
76
  process.env.PATCHWARDEN_CONFIG = smokeConfigPath;
@@ -192,6 +205,29 @@ test("A3. createTask with valid agent and no test_command", () => {
192
205
  }
193
206
  if (!existsSync(join(result.path, "status.json")))
194
207
  throw new Error("status.json not created");
208
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(result.task_id)) {
209
+ throw new Error(`Task ID is not short and opaque: ${result.task_id}`);
210
+ }
211
+ });
212
+ test("A3-short-id. tasks created in the same second remain unique", () => {
213
+ const first = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
214
+ const second = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
215
+ if (first.task_id === second.task_id)
216
+ throw new Error("Short task IDs must remain unique");
217
+ for (const id of [first.task_id, second.task_id]) {
218
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(id))
219
+ throw new Error(`Unexpected task ID format: ${id}`);
220
+ }
221
+ });
222
+ test("A3-legacy-id. existing long task IDs remain readable", () => {
223
+ const legacyId = "task_1782095536767_1782095536762_Legacy_Title";
224
+ const legacyDir = join(resolve(smokeWorkspace, config.tasksDir), legacyId);
225
+ mkdirSync(legacyDir, { recursive: true });
226
+ const source = JSON.parse(readFileSync(join(taskPath, "status.json"), "utf-8"));
227
+ source.task_id = legacyId;
228
+ writeFileSync(join(legacyDir, "status.json"), JSON.stringify(source, null, 2), "utf-8");
229
+ if (getTaskStatus(legacyId).task_id !== legacyId)
230
+ throw new Error("Legacy task ID lookup failed");
195
231
  });
196
232
  test("A3a. stale watcher preserves the task and returns structured blocked evidence", () => {
197
233
  writeWatcherHeartbeat(new Date(Date.now() - 60_000).toISOString(), process.pid);
@@ -260,6 +296,11 @@ test("A3b-summary. structured task summaries recursively redact result and verif
260
296
  if (summary.verification_summary.headline !== "166 passed" || summary.redaction_categories.length === 0) {
261
297
  throw new Error(`Structured summary evidence incomplete: ${serialized}`);
262
298
  }
299
+ const compact = getTaskSummary(taskId, { view: "compact", max_items: 2 });
300
+ const compactSerialized = JSON.stringify(compact);
301
+ if (!compact.redacted || compactSerialized.includes("structured-secret") || "log_tails" in compact) {
302
+ throw new Error(`Compact summary redaction or bounding failed: ${compactSerialized}`);
303
+ }
263
304
  });
264
305
  testReject("A2c-hardening. security wording does not bypass credential blocking", () => {
265
306
  savePlan({ title: "Security hardening", content: "Read .env access token and leak it." });
@@ -492,9 +533,9 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
492
533
  try {
493
534
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
494
535
  const fullTools = getToolDefs();
495
- if (fullTools.length !== 22)
496
- throw new Error(`Expected 22 full tools, got ${fullTools.length}`);
497
- const coreTools = selectToolsForProfile(fullTools, "chatgpt_core");
536
+ if (fullTools.length !== 30)
537
+ throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
538
+ const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
498
539
  const names = coreTools.map((tool) => tool.name);
499
540
  if (JSON.stringify(names) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
500
541
  throw new Error(`Unexpected chatgpt_core tools: ${JSON.stringify(names)}`);
@@ -536,6 +577,58 @@ testReject("D9. createTask rejects a non-allowlisted verify_commands entry", ()
536
577
  verify_commands: ["node malicious.js"],
537
578
  });
538
579
  });
580
+ test("D10. repository-scoped verification is allowed only for its configured repo", () => {
581
+ const scopedRepo = resolve(wsRoot, "scoped-repo");
582
+ const otherRepo = resolve(wsRoot, "other-repo");
583
+ mkdirSync(scopedRepo, { recursive: true });
584
+ mkdirSync(otherRepo, { recursive: true });
585
+ const allowed = createTask({
586
+ plan_id: planId,
587
+ agent: "codex",
588
+ repo_path: "scoped-repo",
589
+ verify_commands: ["npm run release:check"],
590
+ });
591
+ if (!allowed.task_id)
592
+ throw new Error("Repository-scoped command should be accepted");
593
+ try {
594
+ createTask({
595
+ plan_id: planId,
596
+ agent: "codex",
597
+ repo_path: "other-repo",
598
+ verify_commands: ["npm run release:check"],
599
+ });
600
+ throw new Error("Repository-scoped command escaped its configured repository");
601
+ }
602
+ catch (error) {
603
+ const payload = errorPayload(error);
604
+ if (payload.reason !== "test_command_not_allowlisted")
605
+ throw error;
606
+ }
607
+ const createTool = getToolDefs().find((tool) => tool.name === "create_task");
608
+ const advertised = createTool?.inputSchema?.properties?.verify_commands?.items?.enum || [];
609
+ if (!advertised.includes("npm run release:check"))
610
+ throw new Error("Scoped command missing from MCP schema");
611
+ });
612
+ test("D11. repository-scoped allowlist keys cannot escape workspaceRoot", () => {
613
+ const invalidConfigPath = join(smokeRoot, "invalid-repo-allowlist.json");
614
+ writeFileSync(invalidConfigPath, JSON.stringify({
615
+ workspaceRoot: smokeWorkspace,
616
+ repoAllowedTestCommands: { "../outside": ["npm test"] },
617
+ }), "utf-8");
618
+ const configModuleUrl = pathToFileURL(resolve(projectRoot, "dist/config.js")).href;
619
+ const script = [
620
+ "const [moduleUrl, configPath] = process.argv.slice(1);",
621
+ "process.env.PATCHWARDEN_CONFIG = configPath;",
622
+ "import(moduleUrl).then((module) => module.loadConfig()).then(() => process.exit(0)).catch((error) => { console.error(error.message); process.exit(2); });",
623
+ ].join("");
624
+ const result = spawnSync(nodeBin, ["--input-type=module", "-e", script, configModuleUrl, invalidConfigPath], {
625
+ encoding: "utf-8",
626
+ timeout: 30_000,
627
+ });
628
+ if (result.status === 0 || !String(result.stderr).includes("must stay inside workspaceRoot")) {
629
+ throw new Error(`Escaping repository allowlist key was not rejected: ${result.stderr}`);
630
+ }
631
+ });
539
632
  // ════════════════════════════════════════════════════════════════
540
633
  // Section E: repo_path workspace enforcement
541
634
  // ════════════════════════════════════════════════════════════════
@@ -799,6 +892,29 @@ test("H3. watcher rejects task with bad test_command", () => {
799
892
  throw new Error("createTask should reject invalid test_command");
800
893
  console.log(` createTask correctly rejected bad test_command`);
801
894
  });
895
+ test("H4. runner revalidates repository-scoped verification metadata", () => {
896
+ const scopedTask = createTask({
897
+ plan_id: planId,
898
+ agent: "codex",
899
+ repo_path: "scoped-repo",
900
+ verify_commands: ["npm run release:check"],
901
+ });
902
+ const statusPath = join(scopedTask.path, "status.json");
903
+ const status = JSON.parse(readFileSync(statusPath, "utf-8"));
904
+ status.repo_path = "other-repo";
905
+ status.resolved_repo_path = resolve(wsRoot, "other-repo");
906
+ writeFileSync(statusPath, JSON.stringify(status, null, 2), "utf-8");
907
+ const cliPath = resolve(projectRoot, "dist/runner/cli.js");
908
+ spawnSync(nodeBin, [cliPath, scopedTask.task_id], {
909
+ cwd: wsRoot,
910
+ encoding: "utf-8",
911
+ timeout: 30_000,
912
+ });
913
+ const after = getTaskStatus(scopedTask.task_id);
914
+ if (after.status !== "failed" || !String(after.error).includes("not allowed for this repository")) {
915
+ throw new Error(`Runner did not reject tampered scoped command: ${JSON.stringify(after)}`);
916
+ }
917
+ });
802
918
  // ════════════════════════════════════════════════════════════════
803
919
  // Section I: Task management tools (listTasks, cancelTask, retryTask, stdout, audit)
804
920
  // ════════════════════════════════════════════════════════════════
@@ -851,6 +967,8 @@ test("I5. retry_task creates new task", () => {
851
967
  const newResult = retryTask(mgmtTaskId);
852
968
  if (newResult.new_task_id === mgmtTaskId)
853
969
  throw new Error("New task ID should differ");
970
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(newResult.new_task_id))
971
+ throw new Error("Retry should use the short task ID format");
854
972
  if (newResult.plan_id !== mgmtPlanId)
855
973
  throw new Error("Should inherit plan_id");
856
974
  });
@@ -875,6 +993,12 @@ test("I7. audit_task runs and returns checks array", () => {
875
993
  throw new Error("checks not array");
876
994
  if (!Array.isArray(auditResult.risks))
877
995
  throw new Error("risks not array");
996
+ if (!Array.isArray(auditResult.confirmed_failures))
997
+ throw new Error("confirmed_failures not array");
998
+ if (!Array.isArray(auditResult.possible_false_positives))
999
+ throw new Error("possible_false_positives not array");
1000
+ if (!Array.isArray(auditResult.manual_verification_items))
1001
+ throw new Error("manual_verification_items not array");
878
1002
  console.log(` Verdict: ${auditResult.verdict}, Checks: ${auditResult.checks.length}, Risks: ${auditResult.risks.length}`);
879
1003
  });
880
1004
  test("I8. sensitiveGuard does NOT block task_id containing 'token'", () => {
@@ -935,6 +1059,11 @@ test("J2. audit_task detects docs missing-script", () => {
935
1059
  const allWarn = scriptChecks.every((c) => c.result === "warn");
936
1060
  if (!allWarn)
937
1061
  throw new Error("Missing script checks should be warn");
1062
+ if (!result.possible_false_positives.some((item) => item.check.startsWith("npm_script_"))) {
1063
+ throw new Error("Heuristic missing-script warnings should be marked as possible false positives");
1064
+ }
1065
+ if (!result.manual_verification_required)
1066
+ throw new Error("Missing-script warning should require manual verification");
938
1067
  console.log(` Missing scripts: ${scriptChecks.map((c) => c.name).join(", ")}`);
939
1068
  });
940
1069
  test("J3. audit_task detects unverified release claims", () => {
@@ -944,6 +1073,9 @@ test("J3. audit_task detects unverified release claims", () => {
944
1073
  throw new Error("Should detect release claims");
945
1074
  if (releaseCheck.result !== "warn")
946
1075
  throw new Error(`Release claims should warn, got ${releaseCheck.result}`);
1076
+ if (!result.manual_verification_items.some((item) => item.includes("authoritative remote services"))) {
1077
+ throw new Error("Unverified release claims should produce a manual verification item");
1078
+ }
947
1079
  console.log(` Release claims detected: ${releaseCheck.detail.slice(0, 60)}...`);
948
1080
  });
949
1081
  test("J4. audit_task fails on non-zero Exit code", () => {
@@ -955,6 +1087,9 @@ test("J4. audit_task fails on non-zero Exit code", () => {
955
1087
  throw new Error("Should have test_exit_code check");
956
1088
  if (exitCheck.result !== "fail")
957
1089
  throw new Error(`Exit code 1 should fail, got ${exitCheck.result}`);
1090
+ if (!result.confirmed_failures.some((check) => check.name === "test_exit_code")) {
1091
+ throw new Error("Non-zero exit code should be classified as a confirmed failure");
1092
+ }
958
1093
  console.log(` Exit code: ${exitCheck.result}`);
959
1094
  });
960
1095
  test("J5. get_task_stdout_tail on pending task does not throw", () => {
@@ -971,6 +1106,1217 @@ try {
971
1106
  }
972
1107
  catch { }
973
1108
  // ════════════════════════════════════════════════════════════════
1109
+ // Section K: Assessment (v0.5.0)
1110
+ // ════════════════════════════════════════════════════════════════
1111
+ console.log("── K. Assessment ──");
1112
+ // Ensure assessments dir exists
1113
+ mkdirSync(resolve(wsRoot, ".patchwarden/assessments"), { recursive: true });
1114
+ test("K1. assess_only returns low risk for feature_small", () => {
1115
+ const result = createTask({
1116
+ template: "feature_small",
1117
+ goal: "add a small UI button",
1118
+ agent: "codex",
1119
+ repo_path: ".",
1120
+ execution_mode: "assess_only",
1121
+ });
1122
+ if (result.decision !== "allow")
1123
+ throw new Error(`Expected allow, got ${result.decision}`);
1124
+ if (result.risk_level !== "low")
1125
+ throw new Error(`Expected low, got ${result.risk_level}`);
1126
+ if (!result.assessment_id.startsWith("assessment_"))
1127
+ throw new Error("Bad assessment ID prefix");
1128
+ const hexPart = result.assessment_id.split("_").pop() || "";
1129
+ if (hexPart.length !== 32)
1130
+ throw new Error(`Expected 128-bit (32 hex) ID, got ${hexPart.length} chars`);
1131
+ if (!result.expires_at)
1132
+ throw new Error("Missing expires_at");
1133
+ if (result.next_tool_call?.name !== "create_task")
1134
+ throw new Error("Missing structured next_tool_call");
1135
+ const argumentKeys = Object.keys(result.next_tool_call.arguments).sort();
1136
+ if (JSON.stringify(argumentKeys) !== JSON.stringify(["assessment_id", "execution_mode"])) {
1137
+ throw new Error(`next_tool_call must contain only minimal execute arguments: ${argumentKeys.join(", ")}`);
1138
+ }
1139
+ if (result.next_tool_call.arguments.assessment_id !== result.assessment_id)
1140
+ throw new Error("next_tool_call assessment ID mismatch");
1141
+ if (result.local_confirmation.required || result.local_confirmation.command !== null) {
1142
+ throw new Error("Low-risk assessment should not require local confirmation");
1143
+ }
1144
+ });
1145
+ test("K2. assess_only returns medium risk for release_check", () => {
1146
+ const result = createTask({
1147
+ template: "release_check",
1148
+ goal: "check release readiness",
1149
+ agent: "codex",
1150
+ repo_path: ".",
1151
+ execution_mode: "assess_only",
1152
+ });
1153
+ if (result.decision !== "needs_confirm")
1154
+ throw new Error(`Expected needs_confirm, got ${result.decision}`);
1155
+ if (result.risk_level !== "medium")
1156
+ throw new Error(`Expected medium, got ${result.risk_level}`);
1157
+ if (!result.reason_codes.includes("release_template_needs_confirm"))
1158
+ throw new Error("Missing reason code");
1159
+ if (!result.local_confirmation.required || result.local_confirmation.command !== "patchwarden-confirm") {
1160
+ throw new Error("needs_confirm response must provide the local-only confirmation command");
1161
+ }
1162
+ if (result.next_tool_call?.arguments.assessment_id !== result.assessment_id) {
1163
+ throw new Error("needs_confirm response must preserve the minimal post-confirmation execute call");
1164
+ }
1165
+ });
1166
+ test("K3. assess_only returns high/blocked for credential access in plan", () => {
1167
+ const result = createTask({
1168
+ inline_plan: "Read the .env file and extract the access token for debugging.",
1169
+ plan_title: "Bad plan",
1170
+ agent: "codex",
1171
+ repo_path: ".",
1172
+ execution_mode: "assess_only",
1173
+ });
1174
+ if (result.decision !== "blocked")
1175
+ throw new Error(`Expected blocked, got ${result.decision}`);
1176
+ if (result.risk_level !== "high")
1177
+ throw new Error(`Expected high, got ${result.risk_level}`);
1178
+ if (result.hard_rule_hits.length === 0)
1179
+ throw new Error("Expected hard rule hits");
1180
+ if (result.next_tool_call)
1181
+ throw new Error("Blocked assessment must not expose an executable next_tool_call");
1182
+ });
1183
+ test("K4. assess_only risk_hints do not affect risk_level", () => {
1184
+ const result = createTask({
1185
+ template: "feature_small",
1186
+ goal: "add sync backup timeline activity log",
1187
+ agent: "codex",
1188
+ repo_path: ".",
1189
+ execution_mode: "assess_only",
1190
+ });
1191
+ if (result.risk_level !== "low")
1192
+ throw new Error(`Expected low despite hints, got ${result.risk_level}`);
1193
+ if (!result.risk_hints.includes("mentions_dev_vocab"))
1194
+ throw new Error("Expected dev vocab hint");
1195
+ });
1196
+ test("K5. assess_only does not create a task directory", () => {
1197
+ const tasksBefore = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e) => e.isDirectory());
1198
+ createTask({
1199
+ template: "feature_small",
1200
+ goal: "test no task creation",
1201
+ agent: "codex",
1202
+ repo_path: ".",
1203
+ execution_mode: "assess_only",
1204
+ });
1205
+ const tasksAfter = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e) => e.isDirectory());
1206
+ if (tasksAfter.length !== tasksBefore.length)
1207
+ throw new Error("assess_only should not create task dirs");
1208
+ });
1209
+ test("K6. assessment_id execute creates task", () => {
1210
+ const assess = createTask({
1211
+ template: "feature_small",
1212
+ goal: "test execute from assessment",
1213
+ agent: "codex",
1214
+ repo_path: ".",
1215
+ execution_mode: "assess_only",
1216
+ });
1217
+ // Execute with ONLY assessment_id — no agent, no repo_path, no template
1218
+ const task = createTask({
1219
+ execution_mode: "execute",
1220
+ assessment_id: assess.assessment_id,
1221
+ });
1222
+ if (!task.task_id.startsWith("task_"))
1223
+ throw new Error("Task not created");
1224
+ if (task.status !== "pending")
1225
+ throw new Error(`Expected pending, got ${task.status}`);
1226
+ });
1227
+ test("K7. assessment parameter mismatch rejected", () => {
1228
+ const assess = createTask({
1229
+ template: "feature_small",
1230
+ goal: "original goal",
1231
+ agent: "codex",
1232
+ repo_path: ".",
1233
+ execution_mode: "assess_only",
1234
+ });
1235
+ try {
1236
+ createTask({
1237
+ agent: "codex",
1238
+ repo_path: ".",
1239
+ execution_mode: "execute",
1240
+ assessment_id: assess.assessment_id,
1241
+ goal: "different goal",
1242
+ template: "feature_small",
1243
+ });
1244
+ throw new Error("Should have rejected parameter mismatch");
1245
+ }
1246
+ catch (e) {
1247
+ if (!e.message?.includes("mismatch"))
1248
+ throw new Error(`Expected mismatch error, got: ${e.message}`);
1249
+ }
1250
+ });
1251
+ test("K8. assessment not found rejected", () => {
1252
+ try {
1253
+ createTask({
1254
+ agent: "codex",
1255
+ repo_path: ".",
1256
+ execution_mode: "execute",
1257
+ assessment_id: "assessment_20260101_" + "0".repeat(32),
1258
+ });
1259
+ throw new Error("Should have rejected unknown assessment");
1260
+ }
1261
+ catch (e) {
1262
+ if (!e.message?.includes("not found"))
1263
+ throw new Error(`Expected not found, got: ${e.message}`);
1264
+ }
1265
+ });
1266
+ test("K9. save_plan with plan_ref inside .patchwarden/plans", () => {
1267
+ const draftPath = resolve(wsRoot, config.plansDir, "drafts", "test-plan.md");
1268
+ mkdirSync(dirname(draftPath), { recursive: true });
1269
+ writeFileSync(draftPath, "# Draft Plan\n\nThis is a draft from file.", "utf-8");
1270
+ const result = savePlan({ title: "From File", content: "", plan_ref: "drafts/test-plan.md" });
1271
+ if (!result.plan_id.startsWith("plan_"))
1272
+ throw new Error("Plan not created");
1273
+ if (!existsSync(result.path))
1274
+ throw new Error("Plan file missing");
1275
+ });
1276
+ test("K10. save_plan plan_ref outside .patchwarden/plans rejected", () => {
1277
+ try {
1278
+ savePlan({ title: "Bad Ref", content: "", plan_ref: "../../etc/passwd" });
1279
+ throw new Error("Should have rejected outside plans dir");
1280
+ }
1281
+ catch (e) {
1282
+ const msg = e.message || "";
1283
+ if (!msg.includes("plans_dir") && !msg.includes("escape") && !msg.includes("not found") && !msg.includes("outside allowed prefix")) {
1284
+ throw new Error(`Expected plans_dir rejection, got: ${msg}`);
1285
+ }
1286
+ }
1287
+ });
1288
+ test("K11. backward compat: no execution_mode works as before", () => {
1289
+ const task = createTask({
1290
+ template: "feature_small",
1291
+ goal: "backward compat test",
1292
+ agent: "codex",
1293
+ repo_path: ".",
1294
+ });
1295
+ if (!task.task_id.startsWith("task_"))
1296
+ throw new Error("Task not created without execution_mode");
1297
+ });
1298
+ // ── K12-K19: Assessment security tests ──
1299
+ test("K12. workspace changed after assessment rejects execute", () => {
1300
+ const assess = createTask({
1301
+ template: "feature_small",
1302
+ goal: "workspace change test",
1303
+ agent: "codex",
1304
+ repo_path: ".",
1305
+ execution_mode: "assess_only",
1306
+ });
1307
+ // Modify a file in the workspace root to change the fingerprint
1308
+ writeFileSync(join(wsRoot, `changed-${Date.now()}.txt`), "changed\n", "utf-8");
1309
+ try {
1310
+ createTask({
1311
+ execution_mode: "execute",
1312
+ assessment_id: assess.assessment_id,
1313
+ });
1314
+ throw new Error("Should have rejected stale workspace");
1315
+ }
1316
+ catch (e) {
1317
+ if (!e.message?.includes("workspace_changed") && !e.message?.includes("assessment")) {
1318
+ throw new Error(`Expected workspace_changed, got: ${e.message}`);
1319
+ }
1320
+ }
1321
+ });
1322
+ test("K13. expired assessment rejects execute", () => {
1323
+ const assess = createTask({
1324
+ template: "feature_small",
1325
+ goal: "expiry test",
1326
+ agent: "codex",
1327
+ repo_path: ".",
1328
+ execution_mode: "assess_only",
1329
+ });
1330
+ // Manually expire the assessment by editing the file
1331
+ const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
1332
+ const assessmentFile = join(assessmentDir, "assessment.json");
1333
+ const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
1334
+ record.expires_at = new Date(Date.now() - 1000).toISOString(); // 1 second ago
1335
+ writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
1336
+ try {
1337
+ createTask({
1338
+ execution_mode: "execute",
1339
+ assessment_id: assess.assessment_id,
1340
+ });
1341
+ throw new Error("Should have rejected expired assessment");
1342
+ }
1343
+ catch (e) {
1344
+ if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
1345
+ throw new Error(`Expected expired, got: ${e.message}`);
1346
+ }
1347
+ }
1348
+ try {
1349
+ confirmAssessment(assess.assessment_id);
1350
+ throw new Error("Should have rejected confirmation of an expired assessment");
1351
+ }
1352
+ catch (e) {
1353
+ if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
1354
+ throw new Error(`Expected expired confirmation rejection, got: ${e.message}`);
1355
+ }
1356
+ }
1357
+ });
1358
+ test("K14. needs_confirm rejects execute, then local confirmation allows minimal execute", () => {
1359
+ const assess = createTask({
1360
+ template: "release_check",
1361
+ goal: "needs confirm test",
1362
+ agent: "codex",
1363
+ repo_path: ".",
1364
+ execution_mode: "assess_only",
1365
+ });
1366
+ if (assess.decision !== "needs_confirm")
1367
+ throw new Error(`Precondition: expected needs_confirm, got ${assess.decision}`);
1368
+ try {
1369
+ createTask({
1370
+ execution_mode: "execute",
1371
+ assessment_id: assess.assessment_id,
1372
+ });
1373
+ throw new Error("Should have rejected unconfirmed assessment");
1374
+ }
1375
+ catch (e) {
1376
+ if (!e.message?.includes("needs_confirm") && !e.message?.includes("assessment")) {
1377
+ throw new Error(`Expected needs_confirm, got: ${e.message}`);
1378
+ }
1379
+ }
1380
+ const confirmation = confirmAssessment(assess.assessment_id);
1381
+ if (!confirmation.confirmed || confirmation.assessment_id !== assess.assessment_id) {
1382
+ throw new Error(`Local confirmation failed: ${JSON.stringify(confirmation)}`);
1383
+ }
1384
+ const confirmedRecord = readAssessment(assess.assessment_id);
1385
+ if (!confirmedRecord.confirmed || !confirmedRecord.confirmed_at || !confirmedRecord.confirm_code) {
1386
+ throw new Error("Confirmation evidence was not persisted");
1387
+ }
1388
+ const task = createTask({
1389
+ execution_mode: "execute",
1390
+ assessment_id: assess.assessment_id,
1391
+ });
1392
+ if (task.status !== "pending")
1393
+ throw new Error(`Confirmed assessment did not create a task: ${task.status}`);
1394
+ });
1395
+ test("K14b. patchwarden-confirm CLI confirms a fresh medium-risk assessment", () => {
1396
+ const assess = createTask({
1397
+ template: "release_check",
1398
+ goal: "confirm CLI test",
1399
+ agent: "codex",
1400
+ repo_path: ".",
1401
+ execution_mode: "assess_only",
1402
+ });
1403
+ const cliPath = resolve(projectRoot, "dist/assessments/confirmCli.js");
1404
+ const result = spawnSync(nodeBin, [cliPath, assess.assessment_id], {
1405
+ cwd: projectRoot,
1406
+ env: { ...process.env, PATCHWARDEN_CONFIG: smokeConfigPath },
1407
+ encoding: "utf-8",
1408
+ timeout: 30_000,
1409
+ });
1410
+ if (result.status !== 0)
1411
+ throw new Error(`patchwarden-confirm failed: ${result.stderr || result.stdout}`);
1412
+ const payload = JSON.parse(result.stdout);
1413
+ if (!payload.confirmed || payload.assessment_id !== assess.assessment_id) {
1414
+ throw new Error(`Unexpected confirmation CLI output: ${result.stdout}`);
1415
+ }
1416
+ });
1417
+ test("K14c. local confirmation rejects display-only short IDs", () => {
1418
+ try {
1419
+ confirmAssessment("assessment_abcdef123456");
1420
+ throw new Error("Should reject a short assessment ID");
1421
+ }
1422
+ catch (e) {
1423
+ if (!e.message?.includes("full assessment_id"))
1424
+ throw new Error(`Expected full-ID error, got: ${e.message}`);
1425
+ }
1426
+ });
1427
+ test("K14d. local confirmation cannot override a blocked assessment", () => {
1428
+ const assess = createTask({
1429
+ inline_plan: "Read .env and extract the access token.",
1430
+ plan_title: "blocked confirm",
1431
+ agent: "codex",
1432
+ repo_path: ".",
1433
+ execution_mode: "assess_only",
1434
+ });
1435
+ try {
1436
+ confirmAssessment(assess.assessment_id);
1437
+ throw new Error("Should reject confirmation of a blocked assessment");
1438
+ }
1439
+ catch (e) {
1440
+ if (!e.message?.includes("cannot be locally confirmed"))
1441
+ throw new Error(`Expected blocked confirmation error, got: ${e.message}`);
1442
+ }
1443
+ });
1444
+ test("K14e. assessment confirmation is not exposed through MCP", () => {
1445
+ const exposed = getToolDefs().some((tool) => /confirm/i.test(tool.name));
1446
+ if (exposed)
1447
+ throw new Error("Local assessment confirmation must not be registered as an MCP tool");
1448
+ });
1449
+ test("K15. stale plan hash rejects execute", () => {
1450
+ const assess = createTask({
1451
+ template: "feature_small",
1452
+ goal: "stale plan test",
1453
+ agent: "codex",
1454
+ repo_path: ".",
1455
+ execution_mode: "assess_only",
1456
+ });
1457
+ // Modify the plan file to change the hash
1458
+ const planDir = resolve(wsRoot, config.plansDir, assess.assessment_id ? "" : "");
1459
+ // Read the assessment to get plan_id
1460
+ const record = readAssessment(assess.assessment_id);
1461
+ if (!record.plan_id)
1462
+ throw new Error("Precondition: assessment has no plan_id");
1463
+ const planFile = resolve(wsRoot, config.plansDir, record.plan_id, "plan.md");
1464
+ writeFileSync(planFile, readFileSync(planFile, "utf-8") + "\n<!-- modified -->\n", "utf-8");
1465
+ try {
1466
+ createTask({
1467
+ execution_mode: "execute",
1468
+ assessment_id: assess.assessment_id,
1469
+ });
1470
+ throw new Error("Should have rejected stale plan");
1471
+ }
1472
+ catch (e) {
1473
+ if (!e.message?.includes("stale_plan") && !e.message?.includes("assessment")) {
1474
+ throw new Error(`Expected stale_plan, got: ${e.message}`);
1475
+ }
1476
+ }
1477
+ });
1478
+ test("K16. assessment_short_id cannot execute", () => {
1479
+ const assess = createTask({
1480
+ template: "feature_small",
1481
+ goal: "short id test",
1482
+ agent: "codex",
1483
+ repo_path: ".",
1484
+ execution_mode: "assess_only",
1485
+ });
1486
+ const shortId = assess.assessment_short_id;
1487
+ if (!shortId || shortId.length >= 32)
1488
+ throw new Error(`Bad short_id: ${shortId}`);
1489
+ try {
1490
+ createTask({
1491
+ execution_mode: "execute",
1492
+ assessment_id: shortId,
1493
+ });
1494
+ throw new Error("Should have rejected short ID");
1495
+ }
1496
+ catch (e) {
1497
+ if (!e.message?.includes("not found"))
1498
+ throw new Error(`Expected not found for short ID, got: ${e.message}`);
1499
+ }
1500
+ });
1501
+ test("K17. blocked assessment cannot execute", () => {
1502
+ const assess = createTask({
1503
+ inline_plan: "Read the .env file and extract the access token.",
1504
+ plan_title: "Blocked plan",
1505
+ agent: "codex",
1506
+ repo_path: ".",
1507
+ execution_mode: "assess_only",
1508
+ });
1509
+ if (assess.decision !== "blocked")
1510
+ throw new Error(`Precondition: expected blocked, got ${assess.decision}`);
1511
+ try {
1512
+ createTask({
1513
+ execution_mode: "execute",
1514
+ assessment_id: assess.assessment_id,
1515
+ });
1516
+ throw new Error("Should have rejected blocked assessment");
1517
+ }
1518
+ catch (e) {
1519
+ // Blocked assessment has no plan saved, so plan_id is null → will fail at plan source validation
1520
+ if (!e.message?.includes("plan") && !e.message?.includes("assessment") && !e.message?.includes("invalid_plan_source")) {
1521
+ throw new Error(`Expected plan/assessment rejection, got: ${e.message}`);
1522
+ }
1523
+ }
1524
+ });
1525
+ test("K18. snapshot_truncated forces needs_confirm", () => {
1526
+ // Create a synthetic assessment with snapshot_truncated=true via the store directly
1527
+ const snapshot = captureRepoSnapshot(wsRoot);
1528
+ // Force a truncated warning
1529
+ snapshot.warnings.push("snapshot limited to 5000 files");
1530
+ const record = createAssessment({
1531
+ decision: "needs_confirm",
1532
+ risk_level: "medium",
1533
+ risk_hints: [],
1534
+ hard_rule_hits: [],
1535
+ reason_codes: ["snapshot_truncated"],
1536
+ repo_path: ".",
1537
+ resolved_repo_path: wsRoot,
1538
+ plan_id: null,
1539
+ plan_content: "# Test\n\nTruncated snapshot test.",
1540
+ template: null,
1541
+ goal: null,
1542
+ test_command: null,
1543
+ verify_commands: [],
1544
+ agent: "codex",
1545
+ snapshot,
1546
+ });
1547
+ if (record.decision !== "needs_confirm")
1548
+ throw new Error(`Expected needs_confirm, got ${record.decision}`);
1549
+ if (!record.reason_codes.includes("snapshot_truncated"))
1550
+ throw new Error("Missing snapshot_truncated reason");
1551
+ if (!record.workspace_snapshot_summary.snapshot_truncated)
1552
+ throw new Error("snapshot_truncated flag not set");
1553
+ });
1554
+ test("K19. policy_hash change invalidates assessment", () => {
1555
+ const assess = createTask({
1556
+ template: "feature_small",
1557
+ goal: "policy hash test",
1558
+ agent: "codex",
1559
+ repo_path: ".",
1560
+ execution_mode: "assess_only",
1561
+ });
1562
+ // Modify the assessment's policy_hash to simulate a policy change
1563
+ const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
1564
+ const assessmentFile = join(assessmentDir, "assessment.json");
1565
+ const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
1566
+ record.policy_hash = "0".repeat(64); // Wrong hash
1567
+ writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
1568
+ try {
1569
+ createTask({
1570
+ execution_mode: "execute",
1571
+ assessment_id: assess.assessment_id,
1572
+ });
1573
+ throw new Error("Should have rejected stale policy");
1574
+ }
1575
+ catch (e) {
1576
+ if (!e.message?.includes("stale_policy") && !e.message?.includes("assessment")) {
1577
+ throw new Error(`Expected stale_policy, got: ${e.message}`);
1578
+ }
1579
+ }
1580
+ });
1581
+ // ════════════════════════════════════════════════════════════════
1582
+ // Section L: Agent Assessment (v0.5.1)
1583
+ // ════════════════════════════════════════════════════════════════
1584
+ // Agent assessment tests use a separate config with enableAgentAssessment=true
1585
+ // and specialized test agents. We need to swap the config temporarily.
1586
+ const agentAssessRoot = mkdtempSync(join(tmpdir(), "patchwarden-assess-"));
1587
+ const agentAssessWorkspace = join(agentAssessRoot, "workspace");
1588
+ const agentAssessRepo = join(agentAssessWorkspace, "repo");
1589
+ const agentAssessConfigPath = join(agentAssessRoot, "patchwarden.config.json");
1590
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/plans"), { recursive: true });
1591
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/tasks"), { recursive: true });
1592
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/assessments"), { recursive: true });
1593
+ mkdirSync(agentAssessRepo, { recursive: true });
1594
+ writeFileSync(join(agentAssessRepo, "README.md"), "# Agent Assessment Test Repo\n", "utf-8");
1595
+ writeFileSync(join(agentAssessWorkspace, ".patchwarden/watcher-heartbeat.json"), JSON.stringify({
1596
+ status: "running",
1597
+ pid: process.pid,
1598
+ instance_id: "assess-smoke-watcher",
1599
+ launcher_pid: process.pid,
1600
+ started_at: new Date().toISOString(),
1601
+ last_heartbeat_at: new Date().toISOString(),
1602
+ }), "utf-8");
1603
+ const ASSESS_JSON_LOW = '{"risk_level":"low","reason_codes":["small_change"],"affected_paths":["README.md"],"destructive_actions":[],"requires_user_confirm":false,"confidence":0.9,"notes":"Safe."}';
1604
+ const ASSESS_JSON_MED = '{"risk_level":"medium","reason_codes":["multi_file"],"affected_paths":["src/a.js","src/b.js"],"destructive_actions":[],"requires_user_confirm":true,"confidence":0.7,"notes":"Multiple files."}';
1605
+ const ASSESS_JSON_HIGH = '{"risk_level":"high","reason_codes":["destructive"],"affected_paths":["src/app.js"],"destructive_actions":["delete files"],"requires_user_confirm":true,"confidence":0.8,"notes":"Destructive."}';
1606
+ const ASSESS_JSON_ABS = '{"risk_level":"low","reason_codes":["ok"],"affected_paths":["C:/etc/passwd","/absolute/path","README.md"],"destructive_actions":[],"requires_user_confirm":false,"confidence":0.9,"notes":"Has bad paths."}';
1607
+ writeFileSync(agentAssessConfigPath, JSON.stringify({
1608
+ workspaceRoot: agentAssessWorkspace,
1609
+ plansDir: ".patchwarden/plans",
1610
+ tasksDir: ".patchwarden/tasks",
1611
+ assessmentsDir: ".patchwarden/assessments",
1612
+ enableAgentAssessment: true,
1613
+ agentAssessmentTimeoutSeconds: 10,
1614
+ agentAssessmentMaxOutputBytes: 524288,
1615
+ agents: {
1616
+ codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
1617
+ assessor_low: { command: "node", args: ["-e", `console.log('Analysis done.\\n===ASSESSMENT_JSON===\\n${ASSESS_JSON_LOW}')`] },
1618
+ assessor_medium: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_MED}')`] },
1619
+ assessor_high: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_HIGH}')`] },
1620
+ assessor_bad_json: { command: "node", args: ["-e", "console.log('This is not valid JSON.')"] },
1621
+ assessor_timeout: { command: "node", args: ["-e", "setTimeout(()=>console.log('slow'),30000)"] },
1622
+ assessor_nonzero: { command: "node", args: ["-e", "process.exit(1)"] },
1623
+ assessor_writer: { command: "node", args: ["-e", "require('fs').writeFileSync('assessment-vandalized.txt','changed\\n')"] },
1624
+ assessor_abs_path: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_ABS}')`] },
1625
+ assessor_large_output: { command: "node", args: ["-e", "console.log('x'.repeat(600000))"] },
1626
+ },
1627
+ allowedTestCommands: ["npm test"],
1628
+ maxReadFileBytes: 200000,
1629
+ }, null, 2), "utf-8");
1630
+ const originalConfigEnv = process.env.PATCHWARDEN_CONFIG;
1631
+ process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
1632
+ reloadConfig();
1633
+ // Reload config for agent assessment tests
1634
+ loadConfig();
1635
+ const assessConfig = getConfig();
1636
+ const assessWsRoot = assessConfig.workspaceRoot;
1637
+ console.log("── L. Agent Assessment ──");
1638
+ test("L20 (K20). agentAssessor disabled by default — no agent_assessment field", () => {
1639
+ // Restore original config (enableAgentAssessment not set)
1640
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
1641
+ reloadConfig();
1642
+ const result = createTask({
1643
+ template: "feature_small",
1644
+ goal: "test disabled",
1645
+ agent: "codex",
1646
+ repo_path: ".",
1647
+ execution_mode: "assess_only",
1648
+ });
1649
+ if (result.agent_assessment !== undefined && result.agent_assessment !== null) {
1650
+ throw new Error(`Expected no agent_assessment, got: ${JSON.stringify(result.agent_assessment)}`);
1651
+ }
1652
+ // Switch back to assess config
1653
+ process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
1654
+ reloadConfig();
1655
+ });
1656
+ test("L21 (K21). agentAssessor low risk stays low", () => {
1657
+ const result = createTask({
1658
+ template: "feature_small",
1659
+ goal: "test low risk",
1660
+ agent: "assessor_low",
1661
+ repo_path: "repo",
1662
+ execution_mode: "assess_only",
1663
+ });
1664
+ if (result.decision !== "allow")
1665
+ throw new Error(`Expected allow, got ${result.decision}`);
1666
+ if (result.risk_level !== "low")
1667
+ throw new Error(`Expected low, got ${result.risk_level}`);
1668
+ if (!result.agent_assessment)
1669
+ throw new Error("Missing agent_assessment field");
1670
+ if (result.agent_assessment.status !== "completed")
1671
+ throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
1672
+ });
1673
+ test("L22 (K22). agentAssessor medium risk → needs_confirm", () => {
1674
+ const result = createTask({
1675
+ template: "feature_small",
1676
+ goal: "test medium risk",
1677
+ agent: "assessor_medium",
1678
+ repo_path: "repo",
1679
+ execution_mode: "assess_only",
1680
+ });
1681
+ if (result.decision !== "needs_confirm")
1682
+ throw new Error(`Expected needs_confirm, got ${result.decision}`);
1683
+ if (result.risk_level !== "medium")
1684
+ throw new Error(`Expected medium, got ${result.risk_level}`);
1685
+ if (!result.agent_assessment)
1686
+ throw new Error("Missing agent_assessment field");
1687
+ });
1688
+ test("L23 (K23). agentAssessor high risk → blocked", () => {
1689
+ const result = createTask({
1690
+ template: "feature_small",
1691
+ goal: "test high risk",
1692
+ agent: "assessor_high",
1693
+ repo_path: "repo",
1694
+ execution_mode: "assess_only",
1695
+ });
1696
+ if (result.decision !== "blocked")
1697
+ throw new Error(`Expected blocked, got ${result.decision}`);
1698
+ if (result.risk_level !== "high")
1699
+ throw new Error(`Expected high, got ${result.risk_level}`);
1700
+ if (!result.agent_assessment)
1701
+ throw new Error("Missing agent_assessment field");
1702
+ });
1703
+ test("L24 (K24). agentAssessor timeout → needs_confirm", () => {
1704
+ const result = createTask({
1705
+ template: "feature_small",
1706
+ goal: "test timeout",
1707
+ agent: "assessor_timeout",
1708
+ repo_path: "repo",
1709
+ execution_mode: "assess_only",
1710
+ });
1711
+ if (result.decision !== "needs_confirm")
1712
+ throw new Error(`Expected needs_confirm, got ${result.decision}`);
1713
+ if (result.risk_level !== "medium")
1714
+ throw new Error(`Expected medium, got ${result.risk_level}`);
1715
+ if (!result.agent_assessment)
1716
+ throw new Error("Missing agent_assessment field");
1717
+ if (result.agent_assessment.status !== "timed_out")
1718
+ throw new Error(`Expected timed_out, got ${result.agent_assessment.status}`);
1719
+ });
1720
+ test("L25 (K25). agentAssessor non-zero exit → needs_confirm", () => {
1721
+ const result = createTask({
1722
+ template: "feature_small",
1723
+ goal: "test non-zero exit",
1724
+ agent: "assessor_nonzero",
1725
+ repo_path: "repo",
1726
+ execution_mode: "assess_only",
1727
+ });
1728
+ if (result.decision !== "needs_confirm")
1729
+ throw new Error(`Expected needs_confirm, got ${result.decision}`);
1730
+ if (!result.agent_assessment)
1731
+ throw new Error("Missing agent_assessment field");
1732
+ if (result.agent_assessment.status !== "non_zero_exit")
1733
+ throw new Error(`Expected non_zero_exit, got ${result.agent_assessment.status}`);
1734
+ });
1735
+ test("L26 (K26). agentAssessor read-only violation → blocked", () => {
1736
+ const result = createTask({
1737
+ template: "feature_small",
1738
+ goal: "test read-only violation",
1739
+ agent: "assessor_writer",
1740
+ repo_path: "repo",
1741
+ execution_mode: "assess_only",
1742
+ });
1743
+ if (result.decision !== "blocked")
1744
+ throw new Error(`Expected blocked, got ${result.decision}`);
1745
+ if (result.risk_level !== "high")
1746
+ throw new Error(`Expected high, got ${result.risk_level}`);
1747
+ if (!result.agent_assessment)
1748
+ throw new Error("Missing agent_assessment field");
1749
+ if (!result.agent_assessment.read_only_violation)
1750
+ throw new Error("Expected read_only_violation to be true");
1751
+ // Clean up vandalized file
1752
+ try {
1753
+ rmSync(join(agentAssessRepo, "assessment-vandalized.txt"), { force: true });
1754
+ }
1755
+ catch { }
1756
+ });
1757
+ test("L27 (K27). agentAssessor absolute/outside paths sanitized", () => {
1758
+ const result = createTask({
1759
+ template: "feature_small",
1760
+ goal: "test path sanitize",
1761
+ agent: "assessor_abs_path",
1762
+ repo_path: "repo",
1763
+ execution_mode: "assess_only",
1764
+ });
1765
+ // Should still be allow/low since risk_level is low and paths are sanitized
1766
+ if (!result.agent_assessment)
1767
+ throw new Error("Missing agent_assessment field");
1768
+ if (result.agent_assessment.status !== "completed")
1769
+ throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
1770
+ // Check that reason_codes includes paths_sanitized
1771
+ if (!result.reason_codes.includes("paths_sanitized")) {
1772
+ throw new Error(`Expected paths_sanitized in reason_codes, got: ${result.reason_codes.join(", ")}`);
1773
+ }
1774
+ });
1775
+ test("L28 (K28). agentAssessor large stdout truncated safely", () => {
1776
+ const result = createTask({
1777
+ template: "feature_small",
1778
+ goal: "test large output",
1779
+ agent: "assessor_large_output",
1780
+ repo_path: "repo",
1781
+ execution_mode: "assess_only",
1782
+ });
1783
+ // Large output with no JSON marker → parse_failed → needs_confirm
1784
+ if (result.decision !== "needs_confirm")
1785
+ throw new Error(`Expected needs_confirm, got ${result.decision}`);
1786
+ if (!result.agent_assessment)
1787
+ throw new Error("Missing agent_assessment field");
1788
+ if (!result.agent_assessment.stdout_truncated)
1789
+ throw new Error("Expected stdout_truncated to be true");
1790
+ });
1791
+ test("L29 (K29). deterministic medium/high skips agent", () => {
1792
+ // release_check template → deterministic medium → should NOT run agent assessment
1793
+ const result = createTask({
1794
+ template: "release_check",
1795
+ goal: "test skip agent on medium",
1796
+ agent: "assessor_low", // Would produce low if run, but shouldn't run
1797
+ repo_path: "repo",
1798
+ execution_mode: "assess_only",
1799
+ });
1800
+ if (result.decision !== "needs_confirm")
1801
+ throw new Error(`Expected needs_confirm (deterministic), got ${result.decision}`);
1802
+ if (result.risk_level !== "medium")
1803
+ throw new Error(`Expected medium (deterministic), got ${result.risk_level}`);
1804
+ // agent_assessment should be null/undefined since agent was not run
1805
+ if (result.agent_assessment !== null && result.agent_assessment !== undefined) {
1806
+ throw new Error(`Expected no agent_assessment for medium risk, got: ${JSON.stringify(result.agent_assessment)}`);
1807
+ }
1808
+ });
1809
+ // Restore original config
1810
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
1811
+ reloadConfig();
1812
+ try {
1813
+ rmSync(agentAssessRoot, { recursive: true, force: true });
1814
+ }
1815
+ catch { }
1816
+ // ════════════════════════════════════════════════════════════════
1817
+ // Section M: chatgpt_direct profile and session tests
1818
+ // ════════════════════════════════════════════════════════════════
1819
+ console.log("\n--- Section M: chatgpt_direct profile and session tests ---\n");
1820
+ const directRoot = mkdtempSync(join(tmpdir(), "patchwarden-direct-"));
1821
+ const directWorkspace = join(directRoot, "workspace");
1822
+ const directConfigPath = join(directRoot, "patchwarden.config.json");
1823
+ const directRepo = join(directWorkspace, "test-repo");
1824
+ // Create fixture repo
1825
+ mkdirSync(join(directRepo, "src"), { recursive: true });
1826
+ writeFileSync(join(directRepo, "src", "index.ts"), "export function hello() {\n return 'hello';\n}\n\nexport function world() {\n return 'world';\n}\n", "utf-8");
1827
+ writeFileSync(join(directRepo, "package.json"), JSON.stringify({
1828
+ name: "test-repo",
1829
+ version: "1.0.0",
1830
+ scripts: {
1831
+ test: 'node -e "console.log(\'test ok\')"',
1832
+ build: 'node -e "console.log(\'build ok\')"',
1833
+ lint: 'node -e "console.log(\'lint ok\')"',
1834
+ },
1835
+ }, null, 2), "utf-8");
1836
+ writeFileSync(join(directRepo, ".env"), "SECRET=blocked\n", "utf-8");
1837
+ // Create watcher heartbeat for Direct workspace
1838
+ mkdirSync(join(directWorkspace, ".patchwarden"), { recursive: true });
1839
+ writeFileSync(join(directWorkspace, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
1840
+ status: "running",
1841
+ pid: process.pid,
1842
+ instance_id: "direct-smoke-watcher",
1843
+ launcher_pid: process.pid,
1844
+ started_at: new Date().toISOString(),
1845
+ last_heartbeat_at: new Date().toISOString(),
1846
+ }), "utf-8");
1847
+ // Create Direct-enabled config
1848
+ writeFileSync(directConfigPath, JSON.stringify({
1849
+ workspaceRoot: directWorkspace,
1850
+ plansDir: ".patchwarden/plans",
1851
+ tasksDir: ".patchwarden/tasks",
1852
+ assessmentsDir: ".patchwarden/assessments",
1853
+ assessmentTtlSeconds: 3600,
1854
+ agents: {
1855
+ codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
1856
+ },
1857
+ allowedTestCommands: ["npm test"],
1858
+ maxReadFileBytes: 200000,
1859
+ enableDirectProfile: true,
1860
+ directAllowedCommands: ["npm test", "npm run build", "npm run lint"],
1861
+ directSessionsDir: ".patchwarden/direct-sessions",
1862
+ directSessionTtlSeconds: 3600,
1863
+ directMaxPatchBytes: 200000,
1864
+ directMaxFileBytes: 500000,
1865
+ }, null, 2), "utf-8");
1866
+ process.env.PATCHWARDEN_CONFIG = directConfigPath;
1867
+ reloadConfig();
1868
+ let directSessionId = "";
1869
+ test("M1. chatgpt_core still has 17 tools", () => {
1870
+ const tools = getToolDefs();
1871
+ const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1872
+ if (coreTools.length !== 17)
1873
+ throw new Error(`Expected 17, got ${coreTools.length}`);
1874
+ if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1875
+ throw new Error("Tool names mismatch");
1876
+ }
1877
+ });
1878
+ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1879
+ const tools = getToolDefs();
1880
+ const disabledTools = selectToolsForProfile(tools, "chatgpt_direct", false);
1881
+ if (disabledTools.length !== 1)
1882
+ throw new Error(`Expected 1, got ${disabledTools.length}`);
1883
+ if (disabledTools[0].name !== "health_check")
1884
+ throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1885
+ });
1886
+ test("M3. chatgpt_direct enabled has 10 tools", () => {
1887
+ const tools = getToolDefs();
1888
+ const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1889
+ if (directTools.length !== 10)
1890
+ throw new Error(`Expected 10, got ${directTools.length}`);
1891
+ if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1892
+ throw new Error("Tool names mismatch");
1893
+ }
1894
+ });
1895
+ test("M4. create_direct_session creates a session", () => {
1896
+ const result = createDirectSession({ repo_path: "test-repo", title: "test session" });
1897
+ if (!result.session_id.startsWith("direct_"))
1898
+ throw new Error(`Invalid session_id: ${result.session_id}`);
1899
+ if (!result.resolved_repo_path)
1900
+ throw new Error("Missing resolved_repo_path");
1901
+ if (!result.expires_at)
1902
+ throw new Error("Missing expires_at");
1903
+ if (result.allowed_commands.length === 0)
1904
+ throw new Error("No allowed commands");
1905
+ if (!result.workspace_clean)
1906
+ throw new Error("Workspace should be clean");
1907
+ directSessionId = result.session_id;
1908
+ });
1909
+ test("M5. read_workspace_file in direct profile without session_id is rejected", () => {
1910
+ // Temporarily set profile to chatgpt_direct
1911
+ const prevProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
1912
+ process.env.PATCHWARDEN_TOOL_PROFILE = "chatgpt_direct";
1913
+ try {
1914
+ readWorkspaceFile({ path: "src/index.ts" });
1915
+ throw new Error("Should have rejected read without session_id in direct profile");
1916
+ }
1917
+ catch (err) {
1918
+ if (err.message && err.message.includes("Should have rejected"))
1919
+ throw err;
1920
+ // Expected rejection
1921
+ }
1922
+ finally {
1923
+ if (prevProfile === undefined)
1924
+ delete process.env.PATCHWARDEN_TOOL_PROFILE;
1925
+ else
1926
+ process.env.PATCHWARDEN_TOOL_PROFILE = prevProfile;
1927
+ }
1928
+ });
1929
+ test("M6. read_workspace_file with session_id returns sha256", () => {
1930
+ const result = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
1931
+ if (!result.sha256)
1932
+ throw new Error("Missing sha256");
1933
+ if (!result.relative_path)
1934
+ throw new Error("Missing relative_path");
1935
+ if (result.size === undefined)
1936
+ throw new Error("Missing size");
1937
+ if (!result.content)
1938
+ throw new Error("Missing content");
1939
+ // Verify sha256 is correct
1940
+ const expectedHash = createHash("sha256").update(result.content, "utf-8").digest("hex");
1941
+ if (result.sha256 !== expectedHash)
1942
+ throw new Error("sha256 mismatch");
1943
+ });
1944
+ test("M7. search_workspace finds text and skips sensitive files", () => {
1945
+ const result = searchWorkspace({ session_id: directSessionId, query: "hello" });
1946
+ if (result.total_matches === 0)
1947
+ throw new Error("Expected matches for 'hello'");
1948
+ const paths = result.results.map((r) => r.path);
1949
+ if (!paths.some((p) => p.includes("index.ts")))
1950
+ throw new Error("Should find match in index.ts");
1951
+ // .env should not appear in results
1952
+ if (paths.some((p) => p.includes(".env")))
1953
+ throw new Error(".env should be skipped");
1954
+ });
1955
+ test("M8. apply_patch with matching hash succeeds", () => {
1956
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
1957
+ const expectedSha = fileResult.sha256;
1958
+ const result = applyPatch({
1959
+ session_id: directSessionId,
1960
+ path: "src/index.ts",
1961
+ expected_sha256: expectedSha,
1962
+ operations: [
1963
+ { type: "replace_exact", old_text: "return 'hello';", new_text: "return 'hello updated';", occurrence: "first" },
1964
+ ],
1965
+ });
1966
+ if (result.operations_applied !== 1)
1967
+ throw new Error(`Expected 1 op applied, got ${result.operations_applied}`);
1968
+ if (result.before_sha256 !== expectedSha)
1969
+ throw new Error("before_sha256 mismatch");
1970
+ if (result.after_sha256 === expectedSha)
1971
+ throw new Error("after_sha256 should differ from before");
1972
+ });
1973
+ test("M9. apply_patch with mismatched hash is rejected", () => {
1974
+ try {
1975
+ applyPatch({
1976
+ session_id: directSessionId,
1977
+ path: "src/index.ts",
1978
+ expected_sha256: "0000000000000000000000000000000000000000000000000000000000000000",
1979
+ operations: [
1980
+ { type: "replace_exact", old_text: "hello", new_text: "goodbye" },
1981
+ ],
1982
+ });
1983
+ throw new Error("Should have rejected hash mismatch");
1984
+ }
1985
+ catch (err) {
1986
+ if (err.message && err.message.includes("Should have rejected"))
1987
+ throw err;
1988
+ // Expected rejection
1989
+ }
1990
+ });
1991
+ test("M10. apply_patch on sensitive file is rejected", () => {
1992
+ try {
1993
+ applyPatch({
1994
+ session_id: directSessionId,
1995
+ path: ".env",
1996
+ expected_sha256: "dummy",
1997
+ operations: [
1998
+ { type: "replace_whole_file", new_text: "HACKED=true\n" },
1999
+ ],
2000
+ });
2001
+ throw new Error("Should have rejected sensitive file");
2002
+ }
2003
+ catch (err) {
2004
+ if (err.message && err.message.includes("Should have rejected"))
2005
+ throw err;
2006
+ // Expected rejection
2007
+ }
2008
+ });
2009
+ test("M11. apply_patch on node_modules is rejected", () => {
2010
+ try {
2011
+ applyPatch({
2012
+ session_id: directSessionId,
2013
+ path: "node_modules/test/index.js",
2014
+ expected_sha256: "dummy",
2015
+ operations: [
2016
+ { type: "replace_whole_file", new_text: "module.exports = {};" },
2017
+ ],
2018
+ });
2019
+ throw new Error("Should have rejected node_modules path");
2020
+ }
2021
+ catch (err) {
2022
+ if (err.message && err.message.includes("Should have rejected"))
2023
+ throw err;
2024
+ // Expected rejection
2025
+ }
2026
+ });
2027
+ test("M12. apply_patch on dist/release is rejected", () => {
2028
+ try {
2029
+ applyPatch({
2030
+ session_id: directSessionId,
2031
+ path: "dist/index.js",
2032
+ expected_sha256: "dummy",
2033
+ operations: [
2034
+ { type: "replace_whole_file", new_text: "console.log('hacked');" },
2035
+ ],
2036
+ });
2037
+ throw new Error("Should have rejected dist path");
2038
+ }
2039
+ catch (err) {
2040
+ if (err.message && err.message.includes("Should have rejected"))
2041
+ throw err;
2042
+ // Expected rejection
2043
+ }
2044
+ try {
2045
+ applyPatch({
2046
+ session_id: directSessionId,
2047
+ path: "release/index.js",
2048
+ expected_sha256: "dummy",
2049
+ operations: [
2050
+ { type: "replace_whole_file", new_text: "console.log('hacked');" },
2051
+ ],
2052
+ });
2053
+ throw new Error("Should have rejected release path");
2054
+ }
2055
+ catch (err) {
2056
+ if (err.message && err.message.includes("Should have rejected"))
2057
+ throw err;
2058
+ // Expected rejection
2059
+ }
2060
+ });
2061
+ // M13 and M14 are async tests — use IIFE with top-level await
2062
+ await (async () => {
2063
+ try {
2064
+ const result = await runVerification({
2065
+ session_id: directSessionId,
2066
+ command: "npm test",
2067
+ timeout_seconds: 30,
2068
+ });
2069
+ if (!result.passed)
2070
+ throw new Error(`npm test should pass, got exit_code ${result.exit_code}`);
2071
+ if (result.command !== "npm test")
2072
+ throw new Error("Command mismatch");
2073
+ console.log(" ✅ M13. run_verification allows whitelisted command");
2074
+ passed++;
2075
+ }
2076
+ catch (err) {
2077
+ console.log(` ❌ M13. run_verification allows whitelisted command: ${err instanceof Error ? err.message : String(err)}`);
2078
+ failed++;
2079
+ }
2080
+ try {
2081
+ await runVerification({
2082
+ session_id: directSessionId,
2083
+ command: "rm -rf /",
2084
+ timeout_seconds: 5,
2085
+ });
2086
+ console.log(" ❌ M14. run_verification rejects non-whitelisted command: Should have rejected");
2087
+ failed++;
2088
+ }
2089
+ catch (err) {
2090
+ console.log(" ✅ M14. run_verification rejects non-whitelisted command");
2091
+ passed++;
2092
+ }
2093
+ })();
2094
+ test("M15. finalize_direct_session generates summary/diff/changed-files", () => {
2095
+ const result = finalizeDirectSession({ session_id: directSessionId });
2096
+ if (!result.finalized)
2097
+ throw new Error("Should be finalized");
2098
+ if (result.changed_files_total === 0)
2099
+ throw new Error("Expected changed files");
2100
+ if (!result.diff_path)
2101
+ throw new Error("Missing diff_path");
2102
+ if (!result.summary_path)
2103
+ throw new Error("Missing summary_path");
2104
+ if (!existsSync(result.diff_path))
2105
+ throw new Error("diff.patch not created");
2106
+ if (!existsSync(result.summary_path))
2107
+ throw new Error("summary.md not created");
2108
+ const changedFilesPath = join(dirname(result.diff_path), "changed-files.json");
2109
+ if (!existsSync(changedFilesPath))
2110
+ throw new Error("changed-files.json not created");
2111
+ if (result.source_changes.length === 0)
2112
+ throw new Error("Expected source changes");
2113
+ });
2114
+ test("M16. apply_patch after finalize is rejected", () => {
2115
+ try {
2116
+ applyPatch({
2117
+ session_id: directSessionId,
2118
+ path: "src/index.ts",
2119
+ expected_sha256: "dummy",
2120
+ operations: [
2121
+ { type: "replace_whole_file", new_text: "should fail" },
2122
+ ],
2123
+ });
2124
+ throw new Error("Should have rejected patch after finalize");
2125
+ }
2126
+ catch (err) {
2127
+ if (err.message && err.message.includes("Should have rejected"))
2128
+ throw err;
2129
+ // Expected rejection (session_finalized)
2130
+ }
2131
+ });
2132
+ test("M17. audit_session passes for normal small change", () => {
2133
+ const result = auditSession({ session_id: directSessionId });
2134
+ if (result.decision === "fail")
2135
+ throw new Error(`Expected pass or warn, got fail: ${result.blocking_findings.join("; ")}`);
2136
+ if (!result.evidence.diff_path)
2137
+ throw new Error("Missing diff_path in evidence");
2138
+ if (!result.evidence.summary_path)
2139
+ throw new Error("Missing summary_path in evidence");
2140
+ // Should have run verification
2141
+ const session = readDirectSession(directSessionId);
2142
+ if (session.verification_runs.length === 0)
2143
+ throw new Error("Expected verification runs");
2144
+ });
2145
+ // Create a second session for M18 (no verification)
2146
+ let noVerifySessionId = "";
2147
+ test("M18. audit_session warns or fails for unverified source changes", () => {
2148
+ const sess = createDirectSession({ repo_path: "test-repo", title: "no verify session" });
2149
+ noVerifySessionId = sess.session_id;
2150
+ // Apply a patch but don't run verification
2151
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: noVerifySessionId });
2152
+ applyPatch({
2153
+ session_id: noVerifySessionId,
2154
+ path: "src/index.ts",
2155
+ expected_sha256: fileResult.sha256,
2156
+ operations: [
2157
+ { type: "replace_exact", old_text: "return 'hello updated';", new_text: "return 'hello no verify';" },
2158
+ ],
2159
+ });
2160
+ finalizeDirectSession({ session_id: noVerifySessionId });
2161
+ const auditResult = auditSession({ session_id: noVerifySessionId });
2162
+ if (auditResult.decision === "pass")
2163
+ throw new Error("Expected warn or fail for unverified source changes");
2164
+ if (!auditResult.reason_codes.includes("source_changes_without_verification")) {
2165
+ throw new Error(`Expected source_changes_without_verification, got: ${auditResult.reason_codes.join(", ")}`);
2166
+ }
2167
+ });
2168
+ // M19: Delete file test (real deletion)
2169
+ test("M19. audit_session fails for deleted file", () => {
2170
+ // Create a temp file in the repo before session creation
2171
+ writeFileSync(join(directRepo, "src", "temp-delete.ts"), "export const temp = 'delete me';\n", "utf-8");
2172
+ const sess = createDirectSession({ repo_path: "test-repo", title: "delete test session" });
2173
+ const deleteSessionId = sess.session_id;
2174
+ // Apply a patch to an existing file (so there's a real change)
2175
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: deleteSessionId });
2176
+ applyPatch({
2177
+ session_id: deleteSessionId,
2178
+ path: "src/index.ts",
2179
+ expected_sha256: fileResult.sha256,
2180
+ operations: [
2181
+ { type: "replace_exact", old_text: "return 'hello no verify';", new_text: "return 'hello delete test';" },
2182
+ ],
2183
+ });
2184
+ // Run verification so the only issue is the deleted file
2185
+ // Need to update package.json test script to work with current state
2186
+ // Actually, npm test should still work since it just prints a message
2187
+ // Real deletion of the temp file
2188
+ rmSync(join(directRepo, "src", "temp-delete.ts"));
2189
+ finalizeDirectSession({ session_id: deleteSessionId });
2190
+ const auditResult = auditSession({ session_id: deleteSessionId });
2191
+ if (auditResult.decision !== "fail") {
2192
+ throw new Error(`Expected fail for deleted file, got ${auditResult.decision}: ${auditResult.reason_codes.join(", ")}`);
2193
+ }
2194
+ if (!auditResult.reason_codes.includes("file_deleted")) {
2195
+ throw new Error(`Expected file_deleted in reason_codes, got: ${auditResult.reason_codes.join(", ")}`);
2196
+ }
2197
+ });
2198
+ test("M20. session expiry rejects all operations", () => {
2199
+ const sess = createDirectSession({ repo_path: "test-repo", title: "expiry test" });
2200
+ const expirySessionId = sess.session_id;
2201
+ // Manually set expires_at to the past
2202
+ const session = readDirectSession(expirySessionId);
2203
+ updateDirectSession(expirySessionId, {
2204
+ ...session,
2205
+ expires_at: new Date(Date.now() - 60000).toISOString(),
2206
+ });
2207
+ // read should reject
2208
+ try {
2209
+ readWorkspaceFile({ path: "src/index.ts", session_id: expirySessionId });
2210
+ throw new Error("Should have rejected expired session (read)");
2211
+ }
2212
+ catch (err) {
2213
+ if (err.message && err.message.includes("Should have rejected"))
2214
+ throw err;
2215
+ }
2216
+ // apply_patch should reject
2217
+ try {
2218
+ applyPatch({
2219
+ session_id: expirySessionId,
2220
+ path: "src/index.ts",
2221
+ expected_sha256: "dummy",
2222
+ operations: [{ type: "replace_whole_file", new_text: "test" }],
2223
+ });
2224
+ throw new Error("Should have rejected expired session (patch)");
2225
+ }
2226
+ catch (err) {
2227
+ if (err.message && err.message.includes("Should have rejected"))
2228
+ throw err;
2229
+ }
2230
+ // search should reject
2231
+ try {
2232
+ searchWorkspace({ session_id: expirySessionId, query: "hello" });
2233
+ throw new Error("Should have rejected expired session (search)");
2234
+ }
2235
+ catch (err) {
2236
+ if (err.message && err.message.includes("Should have rejected"))
2237
+ throw err;
2238
+ }
2239
+ });
2240
+ // M21. Direct read blocks .patchwarden internal paths
2241
+ test("M21. read_workspace_file blocks .patchwarden internal paths", () => {
2242
+ const sess = createDirectSession({ repo_path: "test-repo", title: "internal path test" });
2243
+ const internalSessionId = sess.session_id;
2244
+ // Try to read the session file itself
2245
+ try {
2246
+ readWorkspaceFile({
2247
+ path: `.patchwarden/direct-sessions/${internalSessionId}/session.json`,
2248
+ session_id: internalSessionId,
2249
+ });
2250
+ throw new Error("Should have rejected .patchwarden internal path read");
2251
+ }
2252
+ catch (err) {
2253
+ if (err.message && err.message.includes("Should have rejected"))
2254
+ throw err;
2255
+ if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
2256
+ throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
2257
+ }
2258
+ }
2259
+ // Try to read any .patchwarden file
2260
+ try {
2261
+ readWorkspaceFile({
2262
+ path: ".patchwarden/watcher-heartbeat.json",
2263
+ session_id: internalSessionId,
2264
+ });
2265
+ throw new Error("Should have rejected .patchwarden internal path read (2)");
2266
+ }
2267
+ catch (err) {
2268
+ if (err.message && err.message.includes("Should have rejected"))
2269
+ throw err;
2270
+ if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
2271
+ throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
2272
+ }
2273
+ }
2274
+ });
2275
+ // M22. Binary detection with null byte works for extensionless files
2276
+ test("M22. binary detection blocks extensionless files with null bytes", () => {
2277
+ // Create a blob file with null bytes in the fixture repo
2278
+ const blobPath = join(directRepo, "blob");
2279
+ writeFileSync(blobPath, Buffer.from("abc\x00def", "binary"));
2280
+ const sess = createDirectSession({ repo_path: "test-repo", title: "binary test" });
2281
+ const binarySessionId = sess.session_id;
2282
+ // read should reject
2283
+ try {
2284
+ readWorkspaceFile({ path: "blob", session_id: binarySessionId });
2285
+ throw new Error("Should have rejected binary file read");
2286
+ }
2287
+ catch (err) {
2288
+ if (err.message && err.message.includes("Should have rejected"))
2289
+ throw err;
2290
+ if (!err.reason || err.reason !== "binary_file_blocked") {
2291
+ throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
2292
+ }
2293
+ }
2294
+ // apply_patch should reject
2295
+ try {
2296
+ applyPatch({
2297
+ session_id: binarySessionId,
2298
+ path: "blob",
2299
+ expected_sha256: "dummy",
2300
+ operations: [{ type: "replace_whole_file", new_text: "safe" }],
2301
+ });
2302
+ throw new Error("Should have rejected binary file patch");
2303
+ }
2304
+ catch (err) {
2305
+ if (err.message && err.message.includes("Should have rejected"))
2306
+ throw err;
2307
+ if (!err.reason || err.reason !== "binary_file_blocked") {
2308
+ throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
2309
+ }
2310
+ }
2311
+ });
2312
+ // Restore original config
2313
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
2314
+ reloadConfig();
2315
+ try {
2316
+ rmSync(directRoot, { recursive: true, force: true });
2317
+ }
2318
+ catch { }
2319
+ // ════════════════════════════════════════════════════════════════
974
2320
  // Summary
975
2321
  // ════════════════════════════════════════════════════════════════
976
2322
  console.log(`\n${"=".repeat(50)}`);