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
package/src/smoke-test.ts CHANGED
@@ -19,16 +19,20 @@ import {
19
19
  rmSync,
20
20
  existsSync,
21
21
  readFileSync,
22
+ readdirSync,
22
23
  mkdtempSync,
23
24
  } from "node:fs";
24
25
  import { resolve, join, dirname } from "node:path";
25
26
  import { tmpdir } from "node:os";
26
- import { fileURLToPath } from "node:url";
27
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
28
  import { spawnSync } from "node:child_process";
28
- import { loadConfig, getConfig } from "./config.js";
29
+ import { loadConfig, getConfig, reloadConfig } from "./config.js";
29
30
  import { savePlan } from "./tools/savePlan.js";
30
31
  import { getPlan } from "./tools/getPlan.js";
31
32
  import { createTask } from "./tools/createTask.js";
33
+ import type { CreateTaskOutput, AssessOnlyOutput } from "./tools/createTask.js";
34
+ import { confirmAssessment, readAssessment, computeWorkspaceFingerprint, createAssessment, type AssessmentRecord } from "./assessments/assessmentStore.js";
35
+ import { captureRepoSnapshot } from "./runner/changeCapture.js";
32
36
  import { getTaskStatus } from "./tools/getTaskStatus.js";
33
37
  import { getResult, getDiff, getTestLog } from "./tools/taskOutputs.js";
34
38
  import { listWorkspace } from "./tools/listWorkspace.js";
@@ -44,10 +48,19 @@ import { getToolDefs } from "./tools/registry.js";
44
48
  import {
45
49
  buildToolCatalogSnapshot,
46
50
  CHATGPT_CORE_TOOL_NAMES,
51
+ CHATGPT_DIRECT_TOOL_NAMES,
47
52
  selectToolsForProfile,
48
53
  } from "./tools/toolCatalog.js";
49
54
  import { errorPayload } from "./errors.js";
50
55
  import { readWatcherStatus } from "./watcherStatus.js";
56
+ import { createDirectSession } from "./tools/createDirectSession.js";
57
+ import { searchWorkspace } from "./tools/searchWorkspace.js";
58
+ import { applyPatch } from "./tools/applyPatch.js";
59
+ import { runVerification } from "./tools/runVerification.js";
60
+ import { finalizeDirectSession } from "./tools/finalizeDirectSession.js";
61
+ import { auditSession } from "./tools/auditSession.js";
62
+ import { readDirectSession, updateDirectSession } from "./direct/directSessionStore.js";
63
+ import { createHash } from "node:crypto";
51
64
 
52
65
  // Resolve the actual node binary path (spawnSync needs it on WSL/Windows)
53
66
  let nodeBin = process.execPath;
@@ -76,6 +89,9 @@ writeFileSync(
76
89
  },
77
90
  },
78
91
  allowedTestCommands: ["npm test", "npm run test", "pytest", "cargo test"],
92
+ repoAllowedTestCommands: {
93
+ "scoped-repo": ["npm run release:check"],
94
+ },
79
95
  maxReadFileBytes: 200000,
80
96
  },
81
97
  null,
@@ -220,6 +236,28 @@ test("A3. createTask with valid agent and no test_command", () => {
220
236
  throw new Error(`Healthy watcher handoff mismatch: ${JSON.stringify(result)}`);
221
237
  }
222
238
  if (!existsSync(join(result.path, "status.json"))) throw new Error("status.json not created");
239
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(result.task_id)) {
240
+ throw new Error(`Task ID is not short and opaque: ${result.task_id}`);
241
+ }
242
+ });
243
+
244
+ test("A3-short-id. tasks created in the same second remain unique", () => {
245
+ const first = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
246
+ const second = createTask({ plan_id: planId, agent: "codex", repo_path: "." });
247
+ if (first.task_id === second.task_id) throw new Error("Short task IDs must remain unique");
248
+ for (const id of [first.task_id, second.task_id]) {
249
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(id)) throw new Error(`Unexpected task ID format: ${id}`);
250
+ }
251
+ });
252
+
253
+ test("A3-legacy-id. existing long task IDs remain readable", () => {
254
+ const legacyId = "task_1782095536767_1782095536762_Legacy_Title";
255
+ const legacyDir = join(resolve(smokeWorkspace, config.tasksDir), legacyId);
256
+ mkdirSync(legacyDir, { recursive: true });
257
+ const source = JSON.parse(readFileSync(join(taskPath, "status.json"), "utf-8"));
258
+ source.task_id = legacyId;
259
+ writeFileSync(join(legacyDir, "status.json"), JSON.stringify(source, null, 2), "utf-8");
260
+ if (getTaskStatus(legacyId).task_id !== legacyId) throw new Error("Legacy task ID lookup failed");
223
261
  });
224
262
 
225
263
  test("A3a. stale watcher preserves the task and returns structured blocked evidence", () => {
@@ -292,6 +330,11 @@ test("A3b-summary. structured task summaries recursively redact result and verif
292
330
  if (summary.verification_summary.headline !== "166 passed" || summary.redaction_categories.length === 0) {
293
331
  throw new Error(`Structured summary evidence incomplete: ${serialized}`);
294
332
  }
333
+ const compact = getTaskSummary(taskId, { view: "compact", max_items: 2 });
334
+ const compactSerialized = JSON.stringify(compact);
335
+ if (!compact.redacted || compactSerialized.includes("structured-secret") || "log_tails" in compact) {
336
+ throw new Error(`Compact summary redaction or bounding failed: ${compactSerialized}`);
337
+ }
295
338
  });
296
339
 
297
340
  testReject("A2c-hardening. security wording does not bypass credential blocking", () => {
@@ -549,9 +592,9 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
549
592
  try {
550
593
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
551
594
  const fullTools = getToolDefs();
552
- if (fullTools.length !== 22) throw new Error(`Expected 22 full tools, got ${fullTools.length}`);
595
+ if (fullTools.length !== 30) throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
553
596
 
554
- const coreTools = selectToolsForProfile(fullTools, "chatgpt_core");
597
+ const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
555
598
  const names = coreTools.map((tool) => tool.name);
556
599
  if (JSON.stringify(names) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
557
600
  throw new Error(`Unexpected chatgpt_core tools: ${JSON.stringify(names)}`);
@@ -592,6 +635,56 @@ testReject("D9. createTask rejects a non-allowlisted verify_commands entry", ()
592
635
  });
593
636
  });
594
637
 
638
+ test("D10. repository-scoped verification is allowed only for its configured repo", () => {
639
+ const scopedRepo = resolve(wsRoot, "scoped-repo");
640
+ const otherRepo = resolve(wsRoot, "other-repo");
641
+ mkdirSync(scopedRepo, { recursive: true });
642
+ mkdirSync(otherRepo, { recursive: true });
643
+ const allowed = createTask({
644
+ plan_id: planId,
645
+ agent: "codex",
646
+ repo_path: "scoped-repo",
647
+ verify_commands: ["npm run release:check"],
648
+ });
649
+ if (!allowed.task_id) throw new Error("Repository-scoped command should be accepted");
650
+ try {
651
+ createTask({
652
+ plan_id: planId,
653
+ agent: "codex",
654
+ repo_path: "other-repo",
655
+ verify_commands: ["npm run release:check"],
656
+ });
657
+ throw new Error("Repository-scoped command escaped its configured repository");
658
+ } catch (error) {
659
+ const payload: any = errorPayload(error);
660
+ if (payload.reason !== "test_command_not_allowlisted") throw error;
661
+ }
662
+ const createTool: any = getToolDefs().find((tool) => tool.name === "create_task");
663
+ const advertised = createTool?.inputSchema?.properties?.verify_commands?.items?.enum || [];
664
+ if (!advertised.includes("npm run release:check")) throw new Error("Scoped command missing from MCP schema");
665
+ });
666
+
667
+ test("D11. repository-scoped allowlist keys cannot escape workspaceRoot", () => {
668
+ const invalidConfigPath = join(smokeRoot, "invalid-repo-allowlist.json");
669
+ writeFileSync(invalidConfigPath, JSON.stringify({
670
+ workspaceRoot: smokeWorkspace,
671
+ repoAllowedTestCommands: { "../outside": ["npm test"] },
672
+ }), "utf-8");
673
+ const configModuleUrl = pathToFileURL(resolve(projectRoot, "dist/config.js")).href;
674
+ const script = [
675
+ "const [moduleUrl, configPath] = process.argv.slice(1);",
676
+ "process.env.PATCHWARDEN_CONFIG = configPath;",
677
+ "import(moduleUrl).then((module) => module.loadConfig()).then(() => process.exit(0)).catch((error) => { console.error(error.message); process.exit(2); });",
678
+ ].join("");
679
+ const result = spawnSync(nodeBin, ["--input-type=module", "-e", script, configModuleUrl, invalidConfigPath], {
680
+ encoding: "utf-8",
681
+ timeout: 30_000,
682
+ });
683
+ if (result.status === 0 || !String(result.stderr).includes("must stay inside workspaceRoot")) {
684
+ throw new Error(`Escaping repository allowlist key was not rejected: ${result.stderr}`);
685
+ }
686
+ });
687
+
595
688
  // ════════════════════════════════════════════════════════════════
596
689
  // Section E: repo_path workspace enforcement
597
690
  // ════════════════════════════════════════════════════════════════
@@ -892,6 +985,30 @@ test("H3. watcher rejects task with bad test_command", () => {
892
985
  console.log(` createTask correctly rejected bad test_command`);
893
986
  });
894
987
 
988
+ test("H4. runner revalidates repository-scoped verification metadata", () => {
989
+ const scopedTask = createTask({
990
+ plan_id: planId,
991
+ agent: "codex",
992
+ repo_path: "scoped-repo",
993
+ verify_commands: ["npm run release:check"],
994
+ });
995
+ const statusPath = join(scopedTask.path, "status.json");
996
+ const status = JSON.parse(readFileSync(statusPath, "utf-8"));
997
+ status.repo_path = "other-repo";
998
+ status.resolved_repo_path = resolve(wsRoot, "other-repo");
999
+ writeFileSync(statusPath, JSON.stringify(status, null, 2), "utf-8");
1000
+ const cliPath = resolve(projectRoot, "dist/runner/cli.js");
1001
+ spawnSync(nodeBin, [cliPath, scopedTask.task_id], {
1002
+ cwd: wsRoot,
1003
+ encoding: "utf-8",
1004
+ timeout: 30_000,
1005
+ });
1006
+ const after = getTaskStatus(scopedTask.task_id);
1007
+ if (after.status !== "failed" || !String(after.error).includes("not allowed for this repository")) {
1008
+ throw new Error(`Runner did not reject tampered scoped command: ${JSON.stringify(after)}`);
1009
+ }
1010
+ });
1011
+
895
1012
  // ════════════════════════════════════════════════════════════════
896
1013
  // Section I: Task management tools (listTasks, cancelTask, retryTask, stdout, audit)
897
1014
  // ════════════════════════════════════════════════════════════════
@@ -945,6 +1062,7 @@ test("I4. cancel_task on done/failed returns unchanged", () => {
945
1062
  test("I5. retry_task creates new task", () => {
946
1063
  const newResult = retryTask(mgmtTaskId);
947
1064
  if (newResult.new_task_id === mgmtTaskId) throw new Error("New task ID should differ");
1065
+ if (!/^task_\d{8}_\d{6}_[0-9a-f]{6}$/.test(newResult.new_task_id)) throw new Error("Retry should use the short task ID format");
948
1066
  if (newResult.plan_id !== mgmtPlanId) throw new Error("Should inherit plan_id");
949
1067
  });
950
1068
 
@@ -966,6 +1084,9 @@ test("I7. audit_task runs and returns checks array", () => {
966
1084
  if (!auditResult.verdict) throw new Error("Missing verdict");
967
1085
  if (!Array.isArray(auditResult.checks)) throw new Error("checks not array");
968
1086
  if (!Array.isArray(auditResult.risks)) throw new Error("risks not array");
1087
+ if (!Array.isArray(auditResult.confirmed_failures)) throw new Error("confirmed_failures not array");
1088
+ if (!Array.isArray(auditResult.possible_false_positives)) throw new Error("possible_false_positives not array");
1089
+ if (!Array.isArray(auditResult.manual_verification_items)) throw new Error("manual_verification_items not array");
969
1090
  console.log(` Verdict: ${auditResult.verdict}, Checks: ${auditResult.checks.length}, Risks: ${auditResult.risks.length}`);
970
1091
  });
971
1092
 
@@ -1027,6 +1148,10 @@ test("J2. audit_task detects docs missing-script", () => {
1027
1148
  if (scriptChecks.length === 0) throw new Error("Should detect missing npm scripts from docs");
1028
1149
  const allWarn = scriptChecks.every((c: any) => c.result === "warn");
1029
1150
  if (!allWarn) throw new Error("Missing script checks should be warn");
1151
+ if (!result.possible_false_positives.some((item) => item.check.startsWith("npm_script_"))) {
1152
+ throw new Error("Heuristic missing-script warnings should be marked as possible false positives");
1153
+ }
1154
+ if (!result.manual_verification_required) throw new Error("Missing-script warning should require manual verification");
1030
1155
  console.log(` Missing scripts: ${scriptChecks.map((c: any) => c.name).join(", ")}`);
1031
1156
  });
1032
1157
 
@@ -1035,6 +1160,9 @@ test("J3. audit_task detects unverified release claims", () => {
1035
1160
  const releaseCheck = result.checks.find((c: any) => c.name === "release_claims_unverified");
1036
1161
  if (!releaseCheck) throw new Error("Should detect release claims");
1037
1162
  if (releaseCheck.result !== "warn") throw new Error(`Release claims should warn, got ${releaseCheck.result}`);
1163
+ if (!result.manual_verification_items.some((item) => item.includes("authoritative remote services"))) {
1164
+ throw new Error("Unverified release claims should produce a manual verification item");
1165
+ }
1038
1166
  console.log(` Release claims detected: ${releaseCheck.detail.slice(0, 60)}...`);
1039
1167
  });
1040
1168
 
@@ -1045,6 +1173,9 @@ test("J4. audit_task fails on non-zero Exit code", () => {
1045
1173
  const exitCheck = result.checks.find((c: any) => c.name === "test_exit_code");
1046
1174
  if (!exitCheck) throw new Error("Should have test_exit_code check");
1047
1175
  if (exitCheck.result !== "fail") throw new Error(`Exit code 1 should fail, got ${exitCheck.result}`);
1176
+ if (!result.confirmed_failures.some((check) => check.name === "test_exit_code")) {
1177
+ throw new Error("Non-zero exit code should be classified as a confirmed failure");
1178
+ }
1048
1179
  console.log(` Exit code: ${exitCheck.result}`);
1049
1180
  });
1050
1181
 
@@ -1058,6 +1189,1162 @@ test("J5. get_task_stdout_tail on pending task does not throw", () => {
1058
1189
 
1059
1190
  try { rmSync(testProjDir, { recursive: true }); } catch {}
1060
1191
 
1192
+ // ════════════════════════════════════════════════════════════════
1193
+ // Section K: Assessment (v0.5.0)
1194
+ // ════════════════════════════════════════════════════════════════
1195
+
1196
+ console.log("── K. Assessment ──");
1197
+
1198
+ // Ensure assessments dir exists
1199
+ mkdirSync(resolve(wsRoot, ".patchwarden/assessments"), { recursive: true });
1200
+
1201
+ test("K1. assess_only returns low risk for feature_small", () => {
1202
+ const result = createTask({
1203
+ template: "feature_small",
1204
+ goal: "add a small UI button",
1205
+ agent: "codex",
1206
+ repo_path: ".",
1207
+ execution_mode: "assess_only",
1208
+ }) as AssessOnlyOutput;
1209
+ if (result.decision !== "allow") throw new Error(`Expected allow, got ${result.decision}`);
1210
+ if (result.risk_level !== "low") throw new Error(`Expected low, got ${result.risk_level}`);
1211
+ if (!result.assessment_id.startsWith("assessment_")) throw new Error("Bad assessment ID prefix");
1212
+ const hexPart = result.assessment_id.split("_").pop() || "";
1213
+ if (hexPart.length !== 32) throw new Error(`Expected 128-bit (32 hex) ID, got ${hexPart.length} chars`);
1214
+ if (!result.expires_at) throw new Error("Missing expires_at");
1215
+ if (result.next_tool_call?.name !== "create_task") throw new Error("Missing structured next_tool_call");
1216
+ const argumentKeys = Object.keys(result.next_tool_call.arguments).sort();
1217
+ if (JSON.stringify(argumentKeys) !== JSON.stringify(["assessment_id", "execution_mode"])) {
1218
+ throw new Error(`next_tool_call must contain only minimal execute arguments: ${argumentKeys.join(", ")}`);
1219
+ }
1220
+ if (result.next_tool_call.arguments.assessment_id !== result.assessment_id) throw new Error("next_tool_call assessment ID mismatch");
1221
+ if (result.local_confirmation.required || result.local_confirmation.command !== null) {
1222
+ throw new Error("Low-risk assessment should not require local confirmation");
1223
+ }
1224
+ });
1225
+
1226
+ test("K2. assess_only returns medium risk for release_check", () => {
1227
+ const result = createTask({
1228
+ template: "release_check",
1229
+ goal: "check release readiness",
1230
+ agent: "codex",
1231
+ repo_path: ".",
1232
+ execution_mode: "assess_only",
1233
+ }) as AssessOnlyOutput;
1234
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
1235
+ if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
1236
+ if (!result.reason_codes.includes("release_template_needs_confirm")) throw new Error("Missing reason code");
1237
+ if (!result.local_confirmation.required || result.local_confirmation.command !== "patchwarden-confirm") {
1238
+ throw new Error("needs_confirm response must provide the local-only confirmation command");
1239
+ }
1240
+ if (result.next_tool_call?.arguments.assessment_id !== result.assessment_id) {
1241
+ throw new Error("needs_confirm response must preserve the minimal post-confirmation execute call");
1242
+ }
1243
+ });
1244
+
1245
+ test("K3. assess_only returns high/blocked for credential access in plan", () => {
1246
+ const result = createTask({
1247
+ inline_plan: "Read the .env file and extract the access token for debugging.",
1248
+ plan_title: "Bad plan",
1249
+ agent: "codex",
1250
+ repo_path: ".",
1251
+ execution_mode: "assess_only",
1252
+ }) as AssessOnlyOutput;
1253
+ if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
1254
+ if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
1255
+ if (result.hard_rule_hits.length === 0) throw new Error("Expected hard rule hits");
1256
+ if (result.next_tool_call) throw new Error("Blocked assessment must not expose an executable next_tool_call");
1257
+ });
1258
+
1259
+ test("K4. assess_only risk_hints do not affect risk_level", () => {
1260
+ const result = createTask({
1261
+ template: "feature_small",
1262
+ goal: "add sync backup timeline activity log",
1263
+ agent: "codex",
1264
+ repo_path: ".",
1265
+ execution_mode: "assess_only",
1266
+ }) as AssessOnlyOutput;
1267
+ if (result.risk_level !== "low") throw new Error(`Expected low despite hints, got ${result.risk_level}`);
1268
+ if (!result.risk_hints.includes("mentions_dev_vocab")) throw new Error("Expected dev vocab hint");
1269
+ });
1270
+
1271
+ test("K5. assess_only does not create a task directory", () => {
1272
+ const tasksBefore = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e: any) => e.isDirectory());
1273
+ createTask({
1274
+ template: "feature_small",
1275
+ goal: "test no task creation",
1276
+ agent: "codex",
1277
+ repo_path: ".",
1278
+ execution_mode: "assess_only",
1279
+ });
1280
+ const tasksAfter = readdirSync(resolve(wsRoot, config.tasksDir), { withFileTypes: true }).filter((e: any) => e.isDirectory());
1281
+ if (tasksAfter.length !== tasksBefore.length) throw new Error("assess_only should not create task dirs");
1282
+ });
1283
+
1284
+ test("K6. assessment_id execute creates task", () => {
1285
+ const assess = createTask({
1286
+ template: "feature_small",
1287
+ goal: "test execute from assessment",
1288
+ agent: "codex",
1289
+ repo_path: ".",
1290
+ execution_mode: "assess_only",
1291
+ }) as AssessOnlyOutput;
1292
+ // Execute with ONLY assessment_id — no agent, no repo_path, no template
1293
+ const task = createTask({
1294
+ execution_mode: "execute",
1295
+ assessment_id: assess.assessment_id,
1296
+ }) as CreateTaskOutput;
1297
+ if (!task.task_id.startsWith("task_")) throw new Error("Task not created");
1298
+ if (task.status !== "pending") throw new Error(`Expected pending, got ${task.status}`);
1299
+ });
1300
+
1301
+ test("K7. assessment parameter mismatch rejected", () => {
1302
+ const assess = createTask({
1303
+ template: "feature_small",
1304
+ goal: "original goal",
1305
+ agent: "codex",
1306
+ repo_path: ".",
1307
+ execution_mode: "assess_only",
1308
+ }) as AssessOnlyOutput;
1309
+ try {
1310
+ createTask({
1311
+ agent: "codex",
1312
+ repo_path: ".",
1313
+ execution_mode: "execute",
1314
+ assessment_id: assess.assessment_id,
1315
+ goal: "different goal",
1316
+ template: "feature_small",
1317
+ });
1318
+ throw new Error("Should have rejected parameter mismatch");
1319
+ } catch (e: any) {
1320
+ if (!e.message?.includes("mismatch")) throw new Error(`Expected mismatch error, got: ${e.message}`);
1321
+ }
1322
+ });
1323
+
1324
+ test("K8. assessment not found rejected", () => {
1325
+ try {
1326
+ createTask({
1327
+ agent: "codex",
1328
+ repo_path: ".",
1329
+ execution_mode: "execute",
1330
+ assessment_id: "assessment_20260101_" + "0".repeat(32),
1331
+ });
1332
+ throw new Error("Should have rejected unknown assessment");
1333
+ } catch (e: any) {
1334
+ if (!e.message?.includes("not found")) throw new Error(`Expected not found, got: ${e.message}`);
1335
+ }
1336
+ });
1337
+
1338
+ test("K9. save_plan with plan_ref inside .patchwarden/plans", () => {
1339
+ const draftPath = resolve(wsRoot, config.plansDir, "drafts", "test-plan.md");
1340
+ mkdirSync(dirname(draftPath), { recursive: true });
1341
+ writeFileSync(draftPath, "# Draft Plan\n\nThis is a draft from file.", "utf-8");
1342
+ const result = savePlan({ title: "From File", content: "", plan_ref: "drafts/test-plan.md" });
1343
+ if (!result.plan_id.startsWith("plan_")) throw new Error("Plan not created");
1344
+ if (!existsSync(result.path)) throw new Error("Plan file missing");
1345
+ });
1346
+
1347
+ test("K10. save_plan plan_ref outside .patchwarden/plans rejected", () => {
1348
+ try {
1349
+ savePlan({ title: "Bad Ref", content: "", plan_ref: "../../etc/passwd" });
1350
+ throw new Error("Should have rejected outside plans dir");
1351
+ } catch (e: any) {
1352
+ const msg = e.message || "";
1353
+ if (!msg.includes("plans_dir") && !msg.includes("escape") && !msg.includes("not found") && !msg.includes("outside allowed prefix")) {
1354
+ throw new Error(`Expected plans_dir rejection, got: ${msg}`);
1355
+ }
1356
+ }
1357
+ });
1358
+
1359
+ test("K11. backward compat: no execution_mode works as before", () => {
1360
+ const task = createTask({
1361
+ template: "feature_small",
1362
+ goal: "backward compat test",
1363
+ agent: "codex",
1364
+ repo_path: ".",
1365
+ }) as CreateTaskOutput;
1366
+ if (!task.task_id.startsWith("task_")) throw new Error("Task not created without execution_mode");
1367
+ });
1368
+
1369
+ // ── K12-K19: Assessment security tests ──
1370
+
1371
+ test("K12. workspace changed after assessment rejects execute", () => {
1372
+ const assess = createTask({
1373
+ template: "feature_small",
1374
+ goal: "workspace change test",
1375
+ agent: "codex",
1376
+ repo_path: ".",
1377
+ execution_mode: "assess_only",
1378
+ }) as AssessOnlyOutput;
1379
+ // Modify a file in the workspace root to change the fingerprint
1380
+ writeFileSync(join(wsRoot, `changed-${Date.now()}.txt`), "changed\n", "utf-8");
1381
+ try {
1382
+ createTask({
1383
+ execution_mode: "execute",
1384
+ assessment_id: assess.assessment_id,
1385
+ });
1386
+ throw new Error("Should have rejected stale workspace");
1387
+ } catch (e: any) {
1388
+ if (!e.message?.includes("workspace_changed") && !e.message?.includes("assessment")) {
1389
+ throw new Error(`Expected workspace_changed, got: ${e.message}`);
1390
+ }
1391
+ }
1392
+ });
1393
+
1394
+ test("K13. expired assessment rejects execute", () => {
1395
+ const assess = createTask({
1396
+ template: "feature_small",
1397
+ goal: "expiry test",
1398
+ agent: "codex",
1399
+ repo_path: ".",
1400
+ execution_mode: "assess_only",
1401
+ }) as AssessOnlyOutput;
1402
+ // Manually expire the assessment by editing the file
1403
+ const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
1404
+ const assessmentFile = join(assessmentDir, "assessment.json");
1405
+ const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
1406
+ record.expires_at = new Date(Date.now() - 1000).toISOString(); // 1 second ago
1407
+ writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
1408
+ try {
1409
+ createTask({
1410
+ execution_mode: "execute",
1411
+ assessment_id: assess.assessment_id,
1412
+ });
1413
+ throw new Error("Should have rejected expired assessment");
1414
+ } catch (e: any) {
1415
+ if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
1416
+ throw new Error(`Expected expired, got: ${e.message}`);
1417
+ }
1418
+ }
1419
+ try {
1420
+ confirmAssessment(assess.assessment_id);
1421
+ throw new Error("Should have rejected confirmation of an expired assessment");
1422
+ } catch (e: any) {
1423
+ if (!e.message?.includes("expired") && !e.message?.includes("assessment")) {
1424
+ throw new Error(`Expected expired confirmation rejection, got: ${e.message}`);
1425
+ }
1426
+ }
1427
+ });
1428
+
1429
+ test("K14. needs_confirm rejects execute, then local confirmation allows minimal execute", () => {
1430
+ const assess = createTask({
1431
+ template: "release_check",
1432
+ goal: "needs confirm test",
1433
+ agent: "codex",
1434
+ repo_path: ".",
1435
+ execution_mode: "assess_only",
1436
+ }) as AssessOnlyOutput;
1437
+ if (assess.decision !== "needs_confirm") throw new Error(`Precondition: expected needs_confirm, got ${assess.decision}`);
1438
+ try {
1439
+ createTask({
1440
+ execution_mode: "execute",
1441
+ assessment_id: assess.assessment_id,
1442
+ });
1443
+ throw new Error("Should have rejected unconfirmed assessment");
1444
+ } catch (e: any) {
1445
+ if (!e.message?.includes("needs_confirm") && !e.message?.includes("assessment")) {
1446
+ throw new Error(`Expected needs_confirm, got: ${e.message}`);
1447
+ }
1448
+ }
1449
+ const confirmation = confirmAssessment(assess.assessment_id);
1450
+ if (!confirmation.confirmed || confirmation.assessment_id !== assess.assessment_id) {
1451
+ throw new Error(`Local confirmation failed: ${JSON.stringify(confirmation)}`);
1452
+ }
1453
+ const confirmedRecord = readAssessment(assess.assessment_id);
1454
+ if (!confirmedRecord.confirmed || !confirmedRecord.confirmed_at || !confirmedRecord.confirm_code) {
1455
+ throw new Error("Confirmation evidence was not persisted");
1456
+ }
1457
+ const task = createTask({
1458
+ execution_mode: "execute",
1459
+ assessment_id: assess.assessment_id,
1460
+ }) as CreateTaskOutput;
1461
+ if (task.status !== "pending") throw new Error(`Confirmed assessment did not create a task: ${task.status}`);
1462
+ });
1463
+
1464
+ test("K14b. patchwarden-confirm CLI confirms a fresh medium-risk assessment", () => {
1465
+ const assess = createTask({
1466
+ template: "release_check",
1467
+ goal: "confirm CLI test",
1468
+ agent: "codex",
1469
+ repo_path: ".",
1470
+ execution_mode: "assess_only",
1471
+ }) as AssessOnlyOutput;
1472
+ const cliPath = resolve(projectRoot, "dist/assessments/confirmCli.js");
1473
+ const result = spawnSync(nodeBin, [cliPath, assess.assessment_id], {
1474
+ cwd: projectRoot,
1475
+ env: { ...process.env, PATCHWARDEN_CONFIG: smokeConfigPath },
1476
+ encoding: "utf-8",
1477
+ timeout: 30_000,
1478
+ });
1479
+ if (result.status !== 0) throw new Error(`patchwarden-confirm failed: ${result.stderr || result.stdout}`);
1480
+ const payload = JSON.parse(result.stdout);
1481
+ if (!payload.confirmed || payload.assessment_id !== assess.assessment_id) {
1482
+ throw new Error(`Unexpected confirmation CLI output: ${result.stdout}`);
1483
+ }
1484
+ });
1485
+
1486
+ test("K14c. local confirmation rejects display-only short IDs", () => {
1487
+ try {
1488
+ confirmAssessment("assessment_abcdef123456");
1489
+ throw new Error("Should reject a short assessment ID");
1490
+ } catch (e: any) {
1491
+ if (!e.message?.includes("full assessment_id")) throw new Error(`Expected full-ID error, got: ${e.message}`);
1492
+ }
1493
+ });
1494
+
1495
+ test("K14d. local confirmation cannot override a blocked assessment", () => {
1496
+ const assess = createTask({
1497
+ inline_plan: "Read .env and extract the access token.",
1498
+ plan_title: "blocked confirm",
1499
+ agent: "codex",
1500
+ repo_path: ".",
1501
+ execution_mode: "assess_only",
1502
+ }) as AssessOnlyOutput;
1503
+ try {
1504
+ confirmAssessment(assess.assessment_id);
1505
+ throw new Error("Should reject confirmation of a blocked assessment");
1506
+ } catch (e: any) {
1507
+ if (!e.message?.includes("cannot be locally confirmed")) throw new Error(`Expected blocked confirmation error, got: ${e.message}`);
1508
+ }
1509
+ });
1510
+
1511
+ test("K14e. assessment confirmation is not exposed through MCP", () => {
1512
+ const exposed = getToolDefs().some((tool) => /confirm/i.test(tool.name));
1513
+ if (exposed) throw new Error("Local assessment confirmation must not be registered as an MCP tool");
1514
+ });
1515
+
1516
+ test("K15. stale plan hash rejects execute", () => {
1517
+ const assess = createTask({
1518
+ template: "feature_small",
1519
+ goal: "stale plan test",
1520
+ agent: "codex",
1521
+ repo_path: ".",
1522
+ execution_mode: "assess_only",
1523
+ }) as AssessOnlyOutput;
1524
+ // Modify the plan file to change the hash
1525
+ const planDir = resolve(wsRoot, config.plansDir, assess.assessment_id ? "" : "");
1526
+ // Read the assessment to get plan_id
1527
+ const record = readAssessment(assess.assessment_id);
1528
+ if (!record.plan_id) throw new Error("Precondition: assessment has no plan_id");
1529
+ const planFile = resolve(wsRoot, config.plansDir, record.plan_id, "plan.md");
1530
+ writeFileSync(planFile, readFileSync(planFile, "utf-8") + "\n<!-- modified -->\n", "utf-8");
1531
+ try {
1532
+ createTask({
1533
+ execution_mode: "execute",
1534
+ assessment_id: assess.assessment_id,
1535
+ });
1536
+ throw new Error("Should have rejected stale plan");
1537
+ } catch (e: any) {
1538
+ if (!e.message?.includes("stale_plan") && !e.message?.includes("assessment")) {
1539
+ throw new Error(`Expected stale_plan, got: ${e.message}`);
1540
+ }
1541
+ }
1542
+ });
1543
+
1544
+ test("K16. assessment_short_id cannot execute", () => {
1545
+ const assess = createTask({
1546
+ template: "feature_small",
1547
+ goal: "short id test",
1548
+ agent: "codex",
1549
+ repo_path: ".",
1550
+ execution_mode: "assess_only",
1551
+ }) as AssessOnlyOutput;
1552
+ const shortId = assess.assessment_short_id;
1553
+ if (!shortId || shortId.length >= 32) throw new Error(`Bad short_id: ${shortId}`);
1554
+ try {
1555
+ createTask({
1556
+ execution_mode: "execute",
1557
+ assessment_id: shortId,
1558
+ });
1559
+ throw new Error("Should have rejected short ID");
1560
+ } catch (e: any) {
1561
+ if (!e.message?.includes("not found")) throw new Error(`Expected not found for short ID, got: ${e.message}`);
1562
+ }
1563
+ });
1564
+
1565
+ test("K17. blocked assessment cannot execute", () => {
1566
+ const assess = createTask({
1567
+ inline_plan: "Read the .env file and extract the access token.",
1568
+ plan_title: "Blocked plan",
1569
+ agent: "codex",
1570
+ repo_path: ".",
1571
+ execution_mode: "assess_only",
1572
+ }) as AssessOnlyOutput;
1573
+ if (assess.decision !== "blocked") throw new Error(`Precondition: expected blocked, got ${assess.decision}`);
1574
+ try {
1575
+ createTask({
1576
+ execution_mode: "execute",
1577
+ assessment_id: assess.assessment_id,
1578
+ });
1579
+ throw new Error("Should have rejected blocked assessment");
1580
+ } catch (e: any) {
1581
+ // Blocked assessment has no plan saved, so plan_id is null → will fail at plan source validation
1582
+ if (!e.message?.includes("plan") && !e.message?.includes("assessment") && !e.message?.includes("invalid_plan_source")) {
1583
+ throw new Error(`Expected plan/assessment rejection, got: ${e.message}`);
1584
+ }
1585
+ }
1586
+ });
1587
+
1588
+ test("K18. snapshot_truncated forces needs_confirm", () => {
1589
+ // Create a synthetic assessment with snapshot_truncated=true via the store directly
1590
+ const snapshot = captureRepoSnapshot(wsRoot);
1591
+ // Force a truncated warning
1592
+ snapshot.warnings.push("snapshot limited to 5000 files");
1593
+ const record = createAssessment({
1594
+ decision: "needs_confirm",
1595
+ risk_level: "medium",
1596
+ risk_hints: [],
1597
+ hard_rule_hits: [],
1598
+ reason_codes: ["snapshot_truncated"],
1599
+ repo_path: ".",
1600
+ resolved_repo_path: wsRoot,
1601
+ plan_id: null,
1602
+ plan_content: "# Test\n\nTruncated snapshot test.",
1603
+ template: null,
1604
+ goal: null,
1605
+ test_command: null,
1606
+ verify_commands: [],
1607
+ agent: "codex",
1608
+ snapshot,
1609
+ });
1610
+ if (record.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${record.decision}`);
1611
+ if (!record.reason_codes.includes("snapshot_truncated")) throw new Error("Missing snapshot_truncated reason");
1612
+ if (!record.workspace_snapshot_summary.snapshot_truncated) throw new Error("snapshot_truncated flag not set");
1613
+ });
1614
+
1615
+ test("K19. policy_hash change invalidates assessment", () => {
1616
+ const assess = createTask({
1617
+ template: "feature_small",
1618
+ goal: "policy hash test",
1619
+ agent: "codex",
1620
+ repo_path: ".",
1621
+ execution_mode: "assess_only",
1622
+ }) as AssessOnlyOutput;
1623
+ // Modify the assessment's policy_hash to simulate a policy change
1624
+ const assessmentDir = resolve(wsRoot, config.assessmentsDir, assess.assessment_id);
1625
+ const assessmentFile = join(assessmentDir, "assessment.json");
1626
+ const record = JSON.parse(readFileSync(assessmentFile, "utf-8"));
1627
+ record.policy_hash = "0".repeat(64); // Wrong hash
1628
+ writeFileSync(assessmentFile, JSON.stringify(record, null, 2), "utf-8");
1629
+ try {
1630
+ createTask({
1631
+ execution_mode: "execute",
1632
+ assessment_id: assess.assessment_id,
1633
+ });
1634
+ throw new Error("Should have rejected stale policy");
1635
+ } catch (e: any) {
1636
+ if (!e.message?.includes("stale_policy") && !e.message?.includes("assessment")) {
1637
+ throw new Error(`Expected stale_policy, got: ${e.message}`);
1638
+ }
1639
+ }
1640
+ });
1641
+
1642
+ // ════════════════════════════════════════════════════════════════
1643
+ // Section L: Agent Assessment (v0.5.1)
1644
+ // ════════════════════════════════════════════════════════════════
1645
+
1646
+ // Agent assessment tests use a separate config with enableAgentAssessment=true
1647
+ // and specialized test agents. We need to swap the config temporarily.
1648
+
1649
+ const agentAssessRoot = mkdtempSync(join(tmpdir(), "patchwarden-assess-"));
1650
+ const agentAssessWorkspace = join(agentAssessRoot, "workspace");
1651
+ const agentAssessRepo = join(agentAssessWorkspace, "repo");
1652
+ const agentAssessConfigPath = join(agentAssessRoot, "patchwarden.config.json");
1653
+
1654
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/plans"), { recursive: true });
1655
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/tasks"), { recursive: true });
1656
+ mkdirSync(join(agentAssessWorkspace, ".patchwarden/assessments"), { recursive: true });
1657
+ mkdirSync(agentAssessRepo, { recursive: true });
1658
+ writeFileSync(join(agentAssessRepo, "README.md"), "# Agent Assessment Test Repo\n", "utf-8");
1659
+ writeFileSync(join(agentAssessWorkspace, ".patchwarden/watcher-heartbeat.json"), JSON.stringify({
1660
+ status: "running",
1661
+ pid: process.pid,
1662
+ instance_id: "assess-smoke-watcher",
1663
+ launcher_pid: process.pid,
1664
+ started_at: new Date().toISOString(),
1665
+ last_heartbeat_at: new Date().toISOString(),
1666
+ }), "utf-8");
1667
+
1668
+ 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."}';
1669
+ 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."}';
1670
+ 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."}';
1671
+ 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."}';
1672
+
1673
+ writeFileSync(
1674
+ agentAssessConfigPath,
1675
+ JSON.stringify({
1676
+ workspaceRoot: agentAssessWorkspace,
1677
+ plansDir: ".patchwarden/plans",
1678
+ tasksDir: ".patchwarden/tasks",
1679
+ assessmentsDir: ".patchwarden/assessments",
1680
+ enableAgentAssessment: true,
1681
+ agentAssessmentTimeoutSeconds: 10,
1682
+ agentAssessmentMaxOutputBytes: 524288,
1683
+ agents: {
1684
+ codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
1685
+ assessor_low: { command: "node", args: ["-e", `console.log('Analysis done.\\n===ASSESSMENT_JSON===\\n${ASSESS_JSON_LOW}')`] },
1686
+ assessor_medium: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_MED}')`] },
1687
+ assessor_high: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_HIGH}')`] },
1688
+ assessor_bad_json: { command: "node", args: ["-e", "console.log('This is not valid JSON.')"] },
1689
+ assessor_timeout: { command: "node", args: ["-e", "setTimeout(()=>console.log('slow'),30000)"] },
1690
+ assessor_nonzero: { command: "node", args: ["-e", "process.exit(1)"] },
1691
+ assessor_writer: { command: "node", args: ["-e", "require('fs').writeFileSync('assessment-vandalized.txt','changed\\n')"] },
1692
+ assessor_abs_path: { command: "node", args: ["-e", `console.log('===ASSESSMENT_JSON===\\n${ASSESS_JSON_ABS}')`] },
1693
+ assessor_large_output: { command: "node", args: ["-e", "console.log('x'.repeat(600000))"] },
1694
+ },
1695
+ allowedTestCommands: ["npm test"],
1696
+ maxReadFileBytes: 200000,
1697
+ }, null, 2),
1698
+ "utf-8"
1699
+ );
1700
+
1701
+ const originalConfigEnv = process.env.PATCHWARDEN_CONFIG;
1702
+ process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
1703
+ reloadConfig();
1704
+
1705
+ // Reload config for agent assessment tests
1706
+ loadConfig();
1707
+ const assessConfig = getConfig();
1708
+ const assessWsRoot = assessConfig.workspaceRoot;
1709
+
1710
+ console.log("── L. Agent Assessment ──");
1711
+
1712
+ test("L20 (K20). agentAssessor disabled by default — no agent_assessment field", () => {
1713
+ // Restore original config (enableAgentAssessment not set)
1714
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
1715
+ reloadConfig();
1716
+ const result = createTask({
1717
+ template: "feature_small",
1718
+ goal: "test disabled",
1719
+ agent: "codex",
1720
+ repo_path: ".",
1721
+ execution_mode: "assess_only",
1722
+ }) as AssessOnlyOutput;
1723
+ if (result.agent_assessment !== undefined && result.agent_assessment !== null) {
1724
+ throw new Error(`Expected no agent_assessment, got: ${JSON.stringify(result.agent_assessment)}`);
1725
+ }
1726
+ // Switch back to assess config
1727
+ process.env.PATCHWARDEN_CONFIG = agentAssessConfigPath;
1728
+ reloadConfig();
1729
+ });
1730
+
1731
+ test("L21 (K21). agentAssessor low risk stays low", () => {
1732
+ const result = createTask({
1733
+ template: "feature_small",
1734
+ goal: "test low risk",
1735
+ agent: "assessor_low",
1736
+ repo_path: "repo",
1737
+ execution_mode: "assess_only",
1738
+ }) as AssessOnlyOutput;
1739
+ if (result.decision !== "allow") throw new Error(`Expected allow, got ${result.decision}`);
1740
+ if (result.risk_level !== "low") throw new Error(`Expected low, got ${result.risk_level}`);
1741
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1742
+ if (result.agent_assessment.status !== "completed") throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
1743
+ });
1744
+
1745
+ test("L22 (K22). agentAssessor medium risk → needs_confirm", () => {
1746
+ const result = createTask({
1747
+ template: "feature_small",
1748
+ goal: "test medium risk",
1749
+ agent: "assessor_medium",
1750
+ repo_path: "repo",
1751
+ execution_mode: "assess_only",
1752
+ }) as AssessOnlyOutput;
1753
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
1754
+ if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
1755
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1756
+ });
1757
+
1758
+ test("L23 (K23). agentAssessor high risk → blocked", () => {
1759
+ const result = createTask({
1760
+ template: "feature_small",
1761
+ goal: "test high risk",
1762
+ agent: "assessor_high",
1763
+ repo_path: "repo",
1764
+ execution_mode: "assess_only",
1765
+ }) as AssessOnlyOutput;
1766
+ if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
1767
+ if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
1768
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1769
+ });
1770
+
1771
+ test("L24 (K24). agentAssessor timeout → needs_confirm", () => {
1772
+ const result = createTask({
1773
+ template: "feature_small",
1774
+ goal: "test timeout",
1775
+ agent: "assessor_timeout",
1776
+ repo_path: "repo",
1777
+ execution_mode: "assess_only",
1778
+ }) as AssessOnlyOutput;
1779
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
1780
+ if (result.risk_level !== "medium") throw new Error(`Expected medium, got ${result.risk_level}`);
1781
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1782
+ if (result.agent_assessment.status !== "timed_out") throw new Error(`Expected timed_out, got ${result.agent_assessment.status}`);
1783
+ });
1784
+
1785
+ test("L25 (K25). agentAssessor non-zero exit → needs_confirm", () => {
1786
+ const result = createTask({
1787
+ template: "feature_small",
1788
+ goal: "test non-zero exit",
1789
+ agent: "assessor_nonzero",
1790
+ repo_path: "repo",
1791
+ execution_mode: "assess_only",
1792
+ }) as AssessOnlyOutput;
1793
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
1794
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1795
+ if (result.agent_assessment.status !== "non_zero_exit") throw new Error(`Expected non_zero_exit, got ${result.agent_assessment.status}`);
1796
+ });
1797
+
1798
+ test("L26 (K26). agentAssessor read-only violation → blocked", () => {
1799
+ const result = createTask({
1800
+ template: "feature_small",
1801
+ goal: "test read-only violation",
1802
+ agent: "assessor_writer",
1803
+ repo_path: "repo",
1804
+ execution_mode: "assess_only",
1805
+ }) as AssessOnlyOutput;
1806
+ if (result.decision !== "blocked") throw new Error(`Expected blocked, got ${result.decision}`);
1807
+ if (result.risk_level !== "high") throw new Error(`Expected high, got ${result.risk_level}`);
1808
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1809
+ if (!result.agent_assessment.read_only_violation) throw new Error("Expected read_only_violation to be true");
1810
+ // Clean up vandalized file
1811
+ try { rmSync(join(agentAssessRepo, "assessment-vandalized.txt"), { force: true }); } catch {}
1812
+ });
1813
+
1814
+ test("L27 (K27). agentAssessor absolute/outside paths sanitized", () => {
1815
+ const result = createTask({
1816
+ template: "feature_small",
1817
+ goal: "test path sanitize",
1818
+ agent: "assessor_abs_path",
1819
+ repo_path: "repo",
1820
+ execution_mode: "assess_only",
1821
+ }) as AssessOnlyOutput;
1822
+ // Should still be allow/low since risk_level is low and paths are sanitized
1823
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1824
+ if (result.agent_assessment.status !== "completed") throw new Error(`Expected completed, got ${result.agent_assessment.status}`);
1825
+ // Check that reason_codes includes paths_sanitized
1826
+ if (!result.reason_codes.includes("paths_sanitized")) {
1827
+ throw new Error(`Expected paths_sanitized in reason_codes, got: ${result.reason_codes.join(", ")}`);
1828
+ }
1829
+ });
1830
+
1831
+ test("L28 (K28). agentAssessor large stdout truncated safely", () => {
1832
+ const result = createTask({
1833
+ template: "feature_small",
1834
+ goal: "test large output",
1835
+ agent: "assessor_large_output",
1836
+ repo_path: "repo",
1837
+ execution_mode: "assess_only",
1838
+ }) as AssessOnlyOutput;
1839
+ // Large output with no JSON marker → parse_failed → needs_confirm
1840
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm, got ${result.decision}`);
1841
+ if (!result.agent_assessment) throw new Error("Missing agent_assessment field");
1842
+ if (!result.agent_assessment.stdout_truncated) throw new Error("Expected stdout_truncated to be true");
1843
+ });
1844
+
1845
+ test("L29 (K29). deterministic medium/high skips agent", () => {
1846
+ // release_check template → deterministic medium → should NOT run agent assessment
1847
+ const result = createTask({
1848
+ template: "release_check",
1849
+ goal: "test skip agent on medium",
1850
+ agent: "assessor_low", // Would produce low if run, but shouldn't run
1851
+ repo_path: "repo",
1852
+ execution_mode: "assess_only",
1853
+ }) as AssessOnlyOutput;
1854
+ if (result.decision !== "needs_confirm") throw new Error(`Expected needs_confirm (deterministic), got ${result.decision}`);
1855
+ if (result.risk_level !== "medium") throw new Error(`Expected medium (deterministic), got ${result.risk_level}`);
1856
+ // agent_assessment should be null/undefined since agent was not run
1857
+ if (result.agent_assessment !== null && result.agent_assessment !== undefined) {
1858
+ throw new Error(`Expected no agent_assessment for medium risk, got: ${JSON.stringify(result.agent_assessment)}`);
1859
+ }
1860
+ });
1861
+
1862
+ // Restore original config
1863
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
1864
+ reloadConfig();
1865
+
1866
+ try { rmSync(agentAssessRoot, { recursive: true, force: true }); } catch {}
1867
+
1868
+ // ════════════════════════════════════════════════════════════════
1869
+ // Section M: chatgpt_direct profile and session tests
1870
+ // ════════════════════════════════════════════════════════════════
1871
+
1872
+ console.log("\n--- Section M: chatgpt_direct profile and session tests ---\n");
1873
+
1874
+ const directRoot = mkdtempSync(join(tmpdir(), "patchwarden-direct-"));
1875
+ const directWorkspace = join(directRoot, "workspace");
1876
+ const directConfigPath = join(directRoot, "patchwarden.config.json");
1877
+ const directRepo = join(directWorkspace, "test-repo");
1878
+
1879
+ // Create fixture repo
1880
+ mkdirSync(join(directRepo, "src"), { recursive: true });
1881
+ writeFileSync(join(directRepo, "src", "index.ts"), "export function hello() {\n return 'hello';\n}\n\nexport function world() {\n return 'world';\n}\n", "utf-8");
1882
+ writeFileSync(join(directRepo, "package.json"), JSON.stringify({
1883
+ name: "test-repo",
1884
+ version: "1.0.0",
1885
+ scripts: {
1886
+ test: 'node -e "console.log(\'test ok\')"',
1887
+ build: 'node -e "console.log(\'build ok\')"',
1888
+ lint: 'node -e "console.log(\'lint ok\')"',
1889
+ },
1890
+ }, null, 2), "utf-8");
1891
+ writeFileSync(join(directRepo, ".env"), "SECRET=blocked\n", "utf-8");
1892
+
1893
+ // Create watcher heartbeat for Direct workspace
1894
+ mkdirSync(join(directWorkspace, ".patchwarden"), { recursive: true });
1895
+ writeFileSync(join(directWorkspace, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
1896
+ status: "running",
1897
+ pid: process.pid,
1898
+ instance_id: "direct-smoke-watcher",
1899
+ launcher_pid: process.pid,
1900
+ started_at: new Date().toISOString(),
1901
+ last_heartbeat_at: new Date().toISOString(),
1902
+ }), "utf-8");
1903
+
1904
+ // Create Direct-enabled config
1905
+ writeFileSync(directConfigPath, JSON.stringify({
1906
+ workspaceRoot: directWorkspace,
1907
+ plansDir: ".patchwarden/plans",
1908
+ tasksDir: ".patchwarden/tasks",
1909
+ assessmentsDir: ".patchwarden/assessments",
1910
+ assessmentTtlSeconds: 3600,
1911
+ agents: {
1912
+ codex: { command: "node", args: ["-e", "console.log('agent placeholder')"] },
1913
+ },
1914
+ allowedTestCommands: ["npm test"],
1915
+ maxReadFileBytes: 200000,
1916
+ enableDirectProfile: true,
1917
+ directAllowedCommands: ["npm test", "npm run build", "npm run lint"],
1918
+ directSessionsDir: ".patchwarden/direct-sessions",
1919
+ directSessionTtlSeconds: 3600,
1920
+ directMaxPatchBytes: 200000,
1921
+ directMaxFileBytes: 500000,
1922
+ }, null, 2), "utf-8");
1923
+
1924
+ process.env.PATCHWARDEN_CONFIG = directConfigPath;
1925
+ reloadConfig();
1926
+
1927
+ let directSessionId = "";
1928
+
1929
+ test("M1. chatgpt_core still has 17 tools", () => {
1930
+ const tools = getToolDefs();
1931
+ const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1932
+ if (coreTools.length !== 17) throw new Error(`Expected 17, got ${coreTools.length}`);
1933
+ if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1934
+ throw new Error("Tool names mismatch");
1935
+ }
1936
+ });
1937
+
1938
+ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1939
+ const tools = getToolDefs();
1940
+ const disabledTools = selectToolsForProfile(tools, "chatgpt_direct", false);
1941
+ if (disabledTools.length !== 1) throw new Error(`Expected 1, got ${disabledTools.length}`);
1942
+ if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1943
+ });
1944
+
1945
+ test("M3. chatgpt_direct enabled has 10 tools", () => {
1946
+ const tools = getToolDefs();
1947
+ const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1948
+ if (directTools.length !== 10) throw new Error(`Expected 10, got ${directTools.length}`);
1949
+ if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1950
+ throw new Error("Tool names mismatch");
1951
+ }
1952
+ });
1953
+
1954
+ test("M4. create_direct_session creates a session", () => {
1955
+ const result = createDirectSession({ repo_path: "test-repo", title: "test session" });
1956
+ if (!result.session_id.startsWith("direct_")) throw new Error(`Invalid session_id: ${result.session_id}`);
1957
+ if (!result.resolved_repo_path) throw new Error("Missing resolved_repo_path");
1958
+ if (!result.expires_at) throw new Error("Missing expires_at");
1959
+ if (result.allowed_commands.length === 0) throw new Error("No allowed commands");
1960
+ if (!result.workspace_clean) throw new Error("Workspace should be clean");
1961
+ directSessionId = result.session_id;
1962
+ });
1963
+
1964
+ test("M5. read_workspace_file in direct profile without session_id is rejected", () => {
1965
+ // Temporarily set profile to chatgpt_direct
1966
+ const prevProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
1967
+ process.env.PATCHWARDEN_TOOL_PROFILE = "chatgpt_direct";
1968
+ try {
1969
+ readWorkspaceFile({ path: "src/index.ts" });
1970
+ throw new Error("Should have rejected read without session_id in direct profile");
1971
+ } catch (err: any) {
1972
+ if (err.message && err.message.includes("Should have rejected")) throw err;
1973
+ // Expected rejection
1974
+ } finally {
1975
+ if (prevProfile === undefined) delete process.env.PATCHWARDEN_TOOL_PROFILE;
1976
+ else process.env.PATCHWARDEN_TOOL_PROFILE = prevProfile;
1977
+ }
1978
+ });
1979
+
1980
+ test("M6. read_workspace_file with session_id returns sha256", () => {
1981
+ const result = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
1982
+ if (!result.sha256) throw new Error("Missing sha256");
1983
+ if (!result.relative_path) throw new Error("Missing relative_path");
1984
+ if (result.size === undefined) throw new Error("Missing size");
1985
+ if (!result.content) throw new Error("Missing content");
1986
+ // Verify sha256 is correct
1987
+ const expectedHash = createHash("sha256").update(result.content, "utf-8").digest("hex");
1988
+ if (result.sha256 !== expectedHash) throw new Error("sha256 mismatch");
1989
+ });
1990
+
1991
+ test("M7. search_workspace finds text and skips sensitive files", () => {
1992
+ const result = searchWorkspace({ session_id: directSessionId, query: "hello" });
1993
+ if (result.total_matches === 0) throw new Error("Expected matches for 'hello'");
1994
+ const paths = result.results.map((r) => r.path);
1995
+ if (!paths.some((p) => p.includes("index.ts"))) throw new Error("Should find match in index.ts");
1996
+ // .env should not appear in results
1997
+ if (paths.some((p) => p.includes(".env"))) throw new Error(".env should be skipped");
1998
+ });
1999
+
2000
+ test("M8. apply_patch with matching hash succeeds", () => {
2001
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: directSessionId });
2002
+ const expectedSha = fileResult.sha256!;
2003
+ const result = applyPatch({
2004
+ session_id: directSessionId,
2005
+ path: "src/index.ts",
2006
+ expected_sha256: expectedSha,
2007
+ operations: [
2008
+ { type: "replace_exact", old_text: "return 'hello';", new_text: "return 'hello updated';", occurrence: "first" },
2009
+ ],
2010
+ });
2011
+ if (result.operations_applied !== 1) throw new Error(`Expected 1 op applied, got ${result.operations_applied}`);
2012
+ if (result.before_sha256 !== expectedSha) throw new Error("before_sha256 mismatch");
2013
+ if (result.after_sha256 === expectedSha) throw new Error("after_sha256 should differ from before");
2014
+ });
2015
+
2016
+ test("M9. apply_patch with mismatched hash is rejected", () => {
2017
+ try {
2018
+ applyPatch({
2019
+ session_id: directSessionId,
2020
+ path: "src/index.ts",
2021
+ expected_sha256: "0000000000000000000000000000000000000000000000000000000000000000",
2022
+ operations: [
2023
+ { type: "replace_exact", old_text: "hello", new_text: "goodbye" },
2024
+ ],
2025
+ });
2026
+ throw new Error("Should have rejected hash mismatch");
2027
+ } catch (err: any) {
2028
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2029
+ // Expected rejection
2030
+ }
2031
+ });
2032
+
2033
+ test("M10. apply_patch on sensitive file is rejected", () => {
2034
+ try {
2035
+ applyPatch({
2036
+ session_id: directSessionId,
2037
+ path: ".env",
2038
+ expected_sha256: "dummy",
2039
+ operations: [
2040
+ { type: "replace_whole_file", new_text: "HACKED=true\n" },
2041
+ ],
2042
+ });
2043
+ throw new Error("Should have rejected sensitive file");
2044
+ } catch (err: any) {
2045
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2046
+ // Expected rejection
2047
+ }
2048
+ });
2049
+
2050
+ test("M11. apply_patch on node_modules is rejected", () => {
2051
+ try {
2052
+ applyPatch({
2053
+ session_id: directSessionId,
2054
+ path: "node_modules/test/index.js",
2055
+ expected_sha256: "dummy",
2056
+ operations: [
2057
+ { type: "replace_whole_file", new_text: "module.exports = {};" },
2058
+ ],
2059
+ });
2060
+ throw new Error("Should have rejected node_modules path");
2061
+ } catch (err: any) {
2062
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2063
+ // Expected rejection
2064
+ }
2065
+ });
2066
+
2067
+ test("M12. apply_patch on dist/release is rejected", () => {
2068
+ try {
2069
+ applyPatch({
2070
+ session_id: directSessionId,
2071
+ path: "dist/index.js",
2072
+ expected_sha256: "dummy",
2073
+ operations: [
2074
+ { type: "replace_whole_file", new_text: "console.log('hacked');" },
2075
+ ],
2076
+ });
2077
+ throw new Error("Should have rejected dist path");
2078
+ } catch (err: any) {
2079
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2080
+ // Expected rejection
2081
+ }
2082
+ try {
2083
+ applyPatch({
2084
+ session_id: directSessionId,
2085
+ path: "release/index.js",
2086
+ expected_sha256: "dummy",
2087
+ operations: [
2088
+ { type: "replace_whole_file", new_text: "console.log('hacked');" },
2089
+ ],
2090
+ });
2091
+ throw new Error("Should have rejected release path");
2092
+ } catch (err: any) {
2093
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2094
+ // Expected rejection
2095
+ }
2096
+ });
2097
+
2098
+ // M13 and M14 are async tests — use IIFE with top-level await
2099
+ await (async () => {
2100
+ try {
2101
+ const result = await runVerification({
2102
+ session_id: directSessionId,
2103
+ command: "npm test",
2104
+ timeout_seconds: 30,
2105
+ });
2106
+ if (!result.passed) throw new Error(`npm test should pass, got exit_code ${result.exit_code}`);
2107
+ if (result.command !== "npm test") throw new Error("Command mismatch");
2108
+ console.log(" ✅ M13. run_verification allows whitelisted command");
2109
+ passed++;
2110
+ } catch (err) {
2111
+ console.log(` ❌ M13. run_verification allows whitelisted command: ${err instanceof Error ? err.message : String(err)}`);
2112
+ failed++;
2113
+ }
2114
+
2115
+ try {
2116
+ await runVerification({
2117
+ session_id: directSessionId,
2118
+ command: "rm -rf /",
2119
+ timeout_seconds: 5,
2120
+ });
2121
+ console.log(" ❌ M14. run_verification rejects non-whitelisted command: Should have rejected");
2122
+ failed++;
2123
+ } catch (err) {
2124
+ console.log(" ✅ M14. run_verification rejects non-whitelisted command");
2125
+ passed++;
2126
+ }
2127
+ })();
2128
+
2129
+ test("M15. finalize_direct_session generates summary/diff/changed-files", () => {
2130
+ const result = finalizeDirectSession({ session_id: directSessionId });
2131
+ if (!result.finalized) throw new Error("Should be finalized");
2132
+ if (result.changed_files_total === 0) throw new Error("Expected changed files");
2133
+ if (!result.diff_path) throw new Error("Missing diff_path");
2134
+ if (!result.summary_path) throw new Error("Missing summary_path");
2135
+ if (!existsSync(result.diff_path)) throw new Error("diff.patch not created");
2136
+ if (!existsSync(result.summary_path)) throw new Error("summary.md not created");
2137
+ const changedFilesPath = join(dirname(result.diff_path), "changed-files.json");
2138
+ if (!existsSync(changedFilesPath)) throw new Error("changed-files.json not created");
2139
+ if (result.source_changes.length === 0) throw new Error("Expected source changes");
2140
+ });
2141
+
2142
+ test("M16. apply_patch after finalize is rejected", () => {
2143
+ try {
2144
+ applyPatch({
2145
+ session_id: directSessionId,
2146
+ path: "src/index.ts",
2147
+ expected_sha256: "dummy",
2148
+ operations: [
2149
+ { type: "replace_whole_file", new_text: "should fail" },
2150
+ ],
2151
+ });
2152
+ throw new Error("Should have rejected patch after finalize");
2153
+ } catch (err: any) {
2154
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2155
+ // Expected rejection (session_finalized)
2156
+ }
2157
+ });
2158
+
2159
+ test("M17. audit_session passes for normal small change", () => {
2160
+ const result = auditSession({ session_id: directSessionId });
2161
+ if (result.decision === "fail") throw new Error(`Expected pass or warn, got fail: ${result.blocking_findings.join("; ")}`);
2162
+ if (!result.evidence.diff_path) throw new Error("Missing diff_path in evidence");
2163
+ if (!result.evidence.summary_path) throw new Error("Missing summary_path in evidence");
2164
+ // Should have run verification
2165
+ const session = readDirectSession(directSessionId);
2166
+ if (session.verification_runs.length === 0) throw new Error("Expected verification runs");
2167
+ });
2168
+
2169
+ // Create a second session for M18 (no verification)
2170
+ let noVerifySessionId = "";
2171
+ test("M18. audit_session warns or fails for unverified source changes", () => {
2172
+ const sess = createDirectSession({ repo_path: "test-repo", title: "no verify session" });
2173
+ noVerifySessionId = sess.session_id;
2174
+
2175
+ // Apply a patch but don't run verification
2176
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: noVerifySessionId });
2177
+ applyPatch({
2178
+ session_id: noVerifySessionId,
2179
+ path: "src/index.ts",
2180
+ expected_sha256: fileResult.sha256!,
2181
+ operations: [
2182
+ { type: "replace_exact", old_text: "return 'hello updated';", new_text: "return 'hello no verify';" },
2183
+ ],
2184
+ });
2185
+
2186
+ finalizeDirectSession({ session_id: noVerifySessionId });
2187
+ const auditResult = auditSession({ session_id: noVerifySessionId });
2188
+ if (auditResult.decision === "pass") throw new Error("Expected warn or fail for unverified source changes");
2189
+ if (!auditResult.reason_codes.includes("source_changes_without_verification")) {
2190
+ throw new Error(`Expected source_changes_without_verification, got: ${auditResult.reason_codes.join(", ")}`);
2191
+ }
2192
+ });
2193
+
2194
+ // M19: Delete file test (real deletion)
2195
+ test("M19. audit_session fails for deleted file", () => {
2196
+ // Create a temp file in the repo before session creation
2197
+ writeFileSync(join(directRepo, "src", "temp-delete.ts"), "export const temp = 'delete me';\n", "utf-8");
2198
+
2199
+ const sess = createDirectSession({ repo_path: "test-repo", title: "delete test session" });
2200
+ const deleteSessionId = sess.session_id;
2201
+
2202
+ // Apply a patch to an existing file (so there's a real change)
2203
+ const fileResult = readWorkspaceFile({ path: "src/index.ts", session_id: deleteSessionId });
2204
+ applyPatch({
2205
+ session_id: deleteSessionId,
2206
+ path: "src/index.ts",
2207
+ expected_sha256: fileResult.sha256!,
2208
+ operations: [
2209
+ { type: "replace_exact", old_text: "return 'hello no verify';", new_text: "return 'hello delete test';" },
2210
+ ],
2211
+ });
2212
+
2213
+ // Run verification so the only issue is the deleted file
2214
+ // Need to update package.json test script to work with current state
2215
+ // Actually, npm test should still work since it just prints a message
2216
+
2217
+ // Real deletion of the temp file
2218
+ rmSync(join(directRepo, "src", "temp-delete.ts"));
2219
+
2220
+ finalizeDirectSession({ session_id: deleteSessionId });
2221
+ const auditResult = auditSession({ session_id: deleteSessionId });
2222
+ if (auditResult.decision !== "fail") {
2223
+ throw new Error(`Expected fail for deleted file, got ${auditResult.decision}: ${auditResult.reason_codes.join(", ")}`);
2224
+ }
2225
+ if (!auditResult.reason_codes.includes("file_deleted")) {
2226
+ throw new Error(`Expected file_deleted in reason_codes, got: ${auditResult.reason_codes.join(", ")}`);
2227
+ }
2228
+ });
2229
+
2230
+ test("M20. session expiry rejects all operations", () => {
2231
+ const sess = createDirectSession({ repo_path: "test-repo", title: "expiry test" });
2232
+ const expirySessionId = sess.session_id;
2233
+
2234
+ // Manually set expires_at to the past
2235
+ const session = readDirectSession(expirySessionId);
2236
+ updateDirectSession(expirySessionId, {
2237
+ ...session,
2238
+ expires_at: new Date(Date.now() - 60000).toISOString(),
2239
+ });
2240
+
2241
+ // read should reject
2242
+ try {
2243
+ readWorkspaceFile({ path: "src/index.ts", session_id: expirySessionId });
2244
+ throw new Error("Should have rejected expired session (read)");
2245
+ } catch (err: any) {
2246
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2247
+ }
2248
+
2249
+ // apply_patch should reject
2250
+ try {
2251
+ applyPatch({
2252
+ session_id: expirySessionId,
2253
+ path: "src/index.ts",
2254
+ expected_sha256: "dummy",
2255
+ operations: [{ type: "replace_whole_file", new_text: "test" }],
2256
+ });
2257
+ throw new Error("Should have rejected expired session (patch)");
2258
+ } catch (err: any) {
2259
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2260
+ }
2261
+
2262
+ // search should reject
2263
+ try {
2264
+ searchWorkspace({ session_id: expirySessionId, query: "hello" });
2265
+ throw new Error("Should have rejected expired session (search)");
2266
+ } catch (err: any) {
2267
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2268
+ }
2269
+ });
2270
+
2271
+ // M21. Direct read blocks .patchwarden internal paths
2272
+ test("M21. read_workspace_file blocks .patchwarden internal paths", () => {
2273
+ const sess = createDirectSession({ repo_path: "test-repo", title: "internal path test" });
2274
+ const internalSessionId = sess.session_id;
2275
+
2276
+ // Try to read the session file itself
2277
+ try {
2278
+ readWorkspaceFile({
2279
+ path: `.patchwarden/direct-sessions/${internalSessionId}/session.json`,
2280
+ session_id: internalSessionId,
2281
+ });
2282
+ throw new Error("Should have rejected .patchwarden internal path read");
2283
+ } catch (err: any) {
2284
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2285
+ if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
2286
+ throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
2287
+ }
2288
+ }
2289
+
2290
+ // Try to read any .patchwarden file
2291
+ try {
2292
+ readWorkspaceFile({
2293
+ path: ".patchwarden/watcher-heartbeat.json",
2294
+ session_id: internalSessionId,
2295
+ });
2296
+ throw new Error("Should have rejected .patchwarden internal path read (2)");
2297
+ } catch (err: any) {
2298
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2299
+ if (!err.reason || err.reason !== "internal_patchwarden_path_blocked") {
2300
+ throw new Error(`Expected internal_patchwarden_path_blocked, got: ${err.reason || err.message}`);
2301
+ }
2302
+ }
2303
+ });
2304
+
2305
+ // M22. Binary detection with null byte works for extensionless files
2306
+ test("M22. binary detection blocks extensionless files with null bytes", () => {
2307
+ // Create a blob file with null bytes in the fixture repo
2308
+ const blobPath = join(directRepo, "blob");
2309
+ writeFileSync(blobPath, Buffer.from("abc\x00def", "binary"));
2310
+
2311
+ const sess = createDirectSession({ repo_path: "test-repo", title: "binary test" });
2312
+ const binarySessionId = sess.session_id;
2313
+
2314
+ // read should reject
2315
+ try {
2316
+ readWorkspaceFile({ path: "blob", session_id: binarySessionId });
2317
+ throw new Error("Should have rejected binary file read");
2318
+ } catch (err: any) {
2319
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2320
+ if (!err.reason || err.reason !== "binary_file_blocked") {
2321
+ throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
2322
+ }
2323
+ }
2324
+
2325
+ // apply_patch should reject
2326
+ try {
2327
+ applyPatch({
2328
+ session_id: binarySessionId,
2329
+ path: "blob",
2330
+ expected_sha256: "dummy",
2331
+ operations: [{ type: "replace_whole_file", new_text: "safe" }],
2332
+ });
2333
+ throw new Error("Should have rejected binary file patch");
2334
+ } catch (err: any) {
2335
+ if (err.message && err.message.includes("Should have rejected")) throw err;
2336
+ if (!err.reason || err.reason !== "binary_file_blocked") {
2337
+ throw new Error(`Expected binary_file_blocked, got: ${err.reason || err.message}`);
2338
+ }
2339
+ }
2340
+ });
2341
+
2342
+ // Restore original config
2343
+ process.env.PATCHWARDEN_CONFIG = originalConfigEnv;
2344
+ reloadConfig();
2345
+
2346
+ try { rmSync(directRoot, { recursive: true, force: true }); } catch {}
2347
+
1061
2348
  // ════════════════════════════════════════════════════════════════
1062
2349
  // Summary
1063
2350
  // ════════════════════════════════════════════════════════════════