patchwarden 1.1.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,225 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { applyPatch } from "../../tools/applyPatch.js";
8
+ import { createDirectSession } from "../../direct/directSessionStore.js";
9
+ import { reloadConfig } from "../../config.js";
10
+ import { PatchWardenError } from "../../errors.js";
11
+ // ── Helpers ────────────────────────────────────────────────────────
12
+ function sha256(content) {
13
+ return createHash("sha256").update(content, "utf-8").digest("hex");
14
+ }
15
+ function makeSnapshot() {
16
+ return {
17
+ captured_at: new Date().toISOString(),
18
+ is_git: false,
19
+ head: null,
20
+ status: "",
21
+ workspace_dirty: false,
22
+ files: {},
23
+ dirty_paths: [],
24
+ warnings: [],
25
+ };
26
+ }
27
+ function bootstrapWorkspace(prefix, maxPatchBytes) {
28
+ const tempDir = mkdtempSync(join(tmpdir(), prefix));
29
+ const repoPath = join(tempDir, "my-repo");
30
+ mkdirSync(repoPath, { recursive: true });
31
+ mkdirSync(join(repoPath, "src"), { recursive: true });
32
+ const configPath = join(tempDir, "patchwarden.config.json");
33
+ writeFileSync(configPath, JSON.stringify({
34
+ workspaceRoot: tempDir,
35
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
36
+ allowedTestCommands: ["npm test"],
37
+ directMaxPatchBytes: maxPatchBytes,
38
+ directMaxFileBytes: 500_000,
39
+ }), "utf-8");
40
+ process.env.PATCHWARDEN_CONFIG = configPath;
41
+ reloadConfig();
42
+ const session = createDirectSession({
43
+ repo_path: "my-repo",
44
+ resolved_repo_path: repoPath,
45
+ title: "applyPatch test",
46
+ snapshot: makeSnapshot(),
47
+ });
48
+ return { tempDir, repoPath, sessionId: session.session_id };
49
+ }
50
+ function teardownWorkspace(tempDir) {
51
+ rmSync(tempDir, { recursive: true, force: true });
52
+ delete process.env.PATCHWARDEN_CONFIG;
53
+ reloadConfig();
54
+ }
55
+ // ── Success cases ──────────────────────────────────────────────────
56
+ describe("applyPatch success cases", () => {
57
+ let tempDir;
58
+ let repoPath;
59
+ let sessionId;
60
+ beforeEach(() => {
61
+ const ws = bootstrapWorkspace("pw-applypatch-ok-", 200_000);
62
+ tempDir = ws.tempDir;
63
+ repoPath = ws.repoPath;
64
+ sessionId = ws.sessionId;
65
+ });
66
+ afterEach(() => {
67
+ teardownWorkspace(tempDir);
68
+ });
69
+ it("applies replace_exact operation", () => {
70
+ const filePath = join(repoPath, "src", "main.ts");
71
+ const original = "hello world";
72
+ writeFileSync(filePath, original, "utf-8");
73
+ const operations = [
74
+ { type: "replace_exact", old_text: "world", new_text: "there" },
75
+ ];
76
+ const result = applyPatch({
77
+ session_id: sessionId,
78
+ path: "src/main.ts",
79
+ expected_sha256: sha256(original),
80
+ operations,
81
+ });
82
+ assert.equal(result.path, "src/main.ts");
83
+ assert.equal(result.before_sha256, sha256(original));
84
+ assert.equal(result.after_sha256, sha256("hello there"));
85
+ assert.equal(result.operations_applied, 1);
86
+ assert.equal(result.bytes_changed, 0);
87
+ assert.ok(result.next_action.length > 0);
88
+ assert.equal(readFileSync(filePath, "utf-8"), "hello there");
89
+ });
90
+ it("applies insert_before operation", () => {
91
+ const filePath = join(repoPath, "src", "main.ts");
92
+ const original = "hello world";
93
+ writeFileSync(filePath, original, "utf-8");
94
+ const operations = [
95
+ { type: "insert_before", old_text: "world", new_text: "beautiful " },
96
+ ];
97
+ const result = applyPatch({
98
+ session_id: sessionId,
99
+ path: "src/main.ts",
100
+ expected_sha256: sha256(original),
101
+ operations,
102
+ });
103
+ assert.equal(readFileSync(filePath, "utf-8"), "hello beautiful world");
104
+ assert.equal(result.after_sha256, sha256("hello beautiful world"));
105
+ assert.equal(result.operations_applied, 1);
106
+ assert.equal(result.bytes_changed, 10);
107
+ });
108
+ it("applies insert_after operation", () => {
109
+ const filePath = join(repoPath, "src", "main.ts");
110
+ const original = "hello world";
111
+ writeFileSync(filePath, original, "utf-8");
112
+ const operations = [
113
+ { type: "insert_after", old_text: "hello", new_text: " beautiful" },
114
+ ];
115
+ const result = applyPatch({
116
+ session_id: sessionId,
117
+ path: "src/main.ts",
118
+ expected_sha256: sha256(original),
119
+ operations,
120
+ });
121
+ assert.equal(readFileSync(filePath, "utf-8"), "hello beautiful world");
122
+ assert.equal(result.after_sha256, sha256("hello beautiful world"));
123
+ assert.equal(result.operations_applied, 1);
124
+ assert.equal(result.bytes_changed, 10);
125
+ });
126
+ it("applies replace_whole_file operation", () => {
127
+ const filePath = join(repoPath, "src", "main.ts");
128
+ const original = "hello world";
129
+ writeFileSync(filePath, original, "utf-8");
130
+ const newContent = "completely new content";
131
+ const operations = [
132
+ { type: "replace_whole_file", new_text: newContent },
133
+ ];
134
+ const result = applyPatch({
135
+ session_id: sessionId,
136
+ path: "src/main.ts",
137
+ expected_sha256: sha256(original),
138
+ operations,
139
+ });
140
+ assert.equal(readFileSync(filePath, "utf-8"), newContent);
141
+ assert.equal(result.after_sha256, sha256(newContent));
142
+ assert.equal(result.operations_applied, 1);
143
+ assert.equal(result.bytes_changed, 11);
144
+ });
145
+ });
146
+ // ── Rejection cases ────────────────────────────────────────────────
147
+ describe("applyPatch rejection cases", () => {
148
+ let tempDir;
149
+ let repoPath;
150
+ let sessionId;
151
+ beforeEach(() => {
152
+ const ws = bootstrapWorkspace("pw-applypatch-rej-", 200_000);
153
+ tempDir = ws.tempDir;
154
+ repoPath = ws.repoPath;
155
+ sessionId = ws.sessionId;
156
+ });
157
+ afterEach(() => {
158
+ teardownWorkspace(tempDir);
159
+ });
160
+ it("rejects when expected_sha256 does not match", () => {
161
+ const filePath = join(repoPath, "src", "main.ts");
162
+ writeFileSync(filePath, "hello world", "utf-8");
163
+ assert.throws(() => applyPatch({
164
+ session_id: sessionId,
165
+ path: "src/main.ts",
166
+ expected_sha256: "0".repeat(64),
167
+ operations: [{ type: "replace_whole_file", new_text: "new" }],
168
+ }), (err) => err instanceof PatchWardenError && err.reason === "file_hash_mismatch");
169
+ // File content must remain unchanged after rejection
170
+ assert.equal(readFileSync(filePath, "utf-8"), "hello world");
171
+ });
172
+ it("rejects binary files (e.g. .png)", () => {
173
+ const filePath = join(repoPath, "src", "image.png");
174
+ writeFileSync(filePath, "fake png content", "utf-8");
175
+ assert.throws(() => applyPatch({
176
+ session_id: sessionId,
177
+ path: "src/image.png",
178
+ expected_sha256: sha256("fake png content"),
179
+ operations: [{ type: "replace_whole_file", new_text: "new" }],
180
+ }), (err) => err instanceof PatchWardenError && err.reason === "binary_file_blocked");
181
+ // File content must remain unchanged after rejection
182
+ assert.equal(readFileSync(filePath, "utf-8"), "fake png content");
183
+ });
184
+ it("rejects paths outside workspaceRoot", () => {
185
+ assert.throws(() => applyPatch({
186
+ session_id: sessionId,
187
+ path: "../../../etc/passwd",
188
+ expected_sha256: sha256("dummy"),
189
+ operations: [{ type: "replace_whole_file", new_text: "new" }],
190
+ }), PatchWardenError);
191
+ });
192
+ });
193
+ // ── Patch size limit ───────────────────────────────────────────────
194
+ describe("applyPatch patch size limit", () => {
195
+ let tempDir;
196
+ let repoPath;
197
+ let sessionId;
198
+ beforeEach(() => {
199
+ const ws = bootstrapWorkspace("pw-applypatch-size-", 100);
200
+ tempDir = ws.tempDir;
201
+ repoPath = ws.repoPath;
202
+ sessionId = ws.sessionId;
203
+ });
204
+ afterEach(() => {
205
+ teardownWorkspace(tempDir);
206
+ });
207
+ it("rejects patches exceeding directMaxPatchBytes", () => {
208
+ const filePath = join(repoPath, "src", "main.ts");
209
+ writeFileSync(filePath, "hello world", "utf-8");
210
+ // Build a patch whose JSON.stringify length exceeds the 100-byte limit
211
+ const bigText = "x".repeat(80);
212
+ const operations = [
213
+ { type: "replace_whole_file", new_text: bigText },
214
+ ];
215
+ assert.ok(JSON.stringify(operations).length > 100);
216
+ assert.throws(() => applyPatch({
217
+ session_id: sessionId,
218
+ path: "src/main.ts",
219
+ expected_sha256: sha256("hello world"),
220
+ operations,
221
+ }), (err) => err instanceof PatchWardenError && err.reason === "patch_too_large");
222
+ // File content must remain unchanged after rejection
223
+ assert.equal(readFileSync(filePath, "utf-8"), "hello world");
224
+ });
225
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,197 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { createTask } from "../../tools/createTask.js";
8
+ import { savePlan } from "../../tools/savePlan.js";
9
+ import { PatchWardenError } from "../../errors.js";
10
+ // ── Helpers ───────────────────────────────────────────────────────
11
+ let tempDir;
12
+ let prevConfigEnv;
13
+ function writeConfig(workspaceRoot) {
14
+ const configPath = join(tempDir, "patchwarden.config.json");
15
+ writeFileSync(configPath, JSON.stringify({
16
+ workspaceRoot,
17
+ tasksDir: ".patchwarden/tasks",
18
+ plansDir: ".patchwarden/plans",
19
+ assessmentsDir: ".patchwarden/assessments",
20
+ directSessionsDir: ".patchwarden/direct-sessions",
21
+ agents: {
22
+ codex: { command: "codex", args: ["exec", "{prompt}"] },
23
+ },
24
+ allowedTestCommands: ["npm test", "npm run build"],
25
+ defaultTaskTimeoutSeconds: 60,
26
+ maxTaskTimeoutSeconds: 300,
27
+ watcherStaleSeconds: 30,
28
+ }), "utf-8");
29
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
30
+ process.env.PATCHWARDEN_CONFIG = configPath;
31
+ reloadConfig();
32
+ }
33
+ /** Write a fresh watcher heartbeat so readWatcherStatus reports healthy. */
34
+ function writeFreshHeartbeat(workspaceRoot) {
35
+ const heartbeatPath = join(workspaceRoot, ".patchwarden", "watcher-heartbeat.json");
36
+ mkdirSync(dirname(heartbeatPath), { recursive: true });
37
+ writeFileSync(heartbeatPath, JSON.stringify({
38
+ status: "running",
39
+ pid: process.pid,
40
+ last_heartbeat_at: new Date().toISOString(),
41
+ instance_id: "test-instance",
42
+ }), "utf-8");
43
+ }
44
+ describe("createTask", () => {
45
+ beforeEach(() => {
46
+ tempDir = mkdtempSync(join(tmpdir(), "pw-createtask-"));
47
+ });
48
+ afterEach(() => {
49
+ if (prevConfigEnv === undefined)
50
+ delete process.env.PATCHWARDEN_CONFIG;
51
+ else
52
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
53
+ reloadConfig();
54
+ rmSync(tempDir, { recursive: true, force: true });
55
+ });
56
+ // ── 1. inline_plan source ──────────────────────────────────────
57
+ it("creates a task from inline_plan, writes status.json, and allocates a task directory", async () => {
58
+ writeConfig(tempDir);
59
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
60
+ writeFreshHeartbeat(tempDir);
61
+ const result = await createTask({
62
+ inline_plan: "## Goal\nAdd a hello-world print statement to main.ts.",
63
+ agent: "codex",
64
+ repo_path: "my-repo",
65
+ });
66
+ const out = result;
67
+ assert.ok(out.task_id.startsWith("task_"), `task_id should start with "task_", got: ${out.task_id}`);
68
+ assert.equal(out.status, "pending");
69
+ assert.equal(out.plan_source, "inline");
70
+ assert.equal(out.agent, "codex");
71
+ assert.equal(out.execution_blocked, false);
72
+ assert.ok(existsSync(out.path), "task directory should exist");
73
+ assert.ok(existsSync(join(out.path, "status.json")), "status.json should exist");
74
+ const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
75
+ assert.equal(status.task_id, out.task_id);
76
+ assert.equal(status.status, "pending");
77
+ assert.equal(status.phase, "queued");
78
+ assert.equal(status.agent, "codex");
79
+ assert.equal(status.plan_source, "inline");
80
+ assert.equal(status.repo_path, "my-repo");
81
+ });
82
+ // ── 2. template source ─────────────────────────────────────────
83
+ it("creates a task from a built-in template (feature_small)", async () => {
84
+ writeConfig(tempDir);
85
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
86
+ writeFreshHeartbeat(tempDir);
87
+ const result = await createTask({
88
+ template: "feature_small",
89
+ goal: "Add a utility function for string truncation.",
90
+ agent: "codex",
91
+ repo_path: "my-repo",
92
+ });
93
+ const out = result;
94
+ assert.ok(out.task_id.startsWith("task_"));
95
+ assert.equal(out.plan_source, "template");
96
+ assert.equal(out.template, "feature_small");
97
+ assert.ok(existsSync(join(out.path, "status.json")));
98
+ const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
99
+ assert.equal(status.template, "feature_small");
100
+ assert.equal(status.plan_source, "template");
101
+ });
102
+ // ── 3. plan_id source ──────────────────────────────────────────
103
+ it("creates a task from a previously saved plan_id", async () => {
104
+ writeConfig(tempDir);
105
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
106
+ writeFreshHeartbeat(tempDir);
107
+ const saved = savePlan({
108
+ title: "Pre-saved plan",
109
+ content: "## Goal\nDo something safe and repo-local.",
110
+ });
111
+ const result = await createTask({
112
+ plan_id: saved.plan_id,
113
+ agent: "codex",
114
+ repo_path: "my-repo",
115
+ });
116
+ const out = result;
117
+ assert.equal(out.plan_id, saved.plan_id);
118
+ assert.equal(out.plan_source, "saved");
119
+ assert.ok(existsSync(join(out.path, "status.json")));
120
+ const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
121
+ assert.equal(status.plan_id, saved.plan_id);
122
+ assert.equal(status.plan_source, "saved");
123
+ });
124
+ // ── 4. assess_only mode ────────────────────────────────────────
125
+ it("returns a risk assessment instead of creating a task in assess_only mode", async () => {
126
+ writeConfig(tempDir);
127
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
128
+ const result = await createTask({
129
+ inline_plan: "## Goal\nAdd a print statement to main.ts.",
130
+ agent: "codex",
131
+ repo_path: "my-repo",
132
+ execution_mode: "assess_only",
133
+ });
134
+ const out = result;
135
+ assert.ok(out.assessment_id, "assessment_id should be present");
136
+ assert.ok(out.assessment_short_id, "assessment_short_id should be present");
137
+ assert.equal(out.decision, "allow");
138
+ assert.equal(out.risk_level, "low");
139
+ assert.ok(out.next_tool_call, "next_tool_call should be present for allow decision");
140
+ assert.equal(out.next_tool_call.name, "create_task");
141
+ assert.equal(out.next_tool_call.arguments.execution_mode, "execute");
142
+ assert.equal(out.next_tool_call.arguments.assessment_id, out.assessment_id);
143
+ // No task directory should be created in assess_only mode
144
+ assert.equal("task_id" in out, false, "assess_only output should not contain task_id");
145
+ const tasksDir = join(tempDir, ".patchwarden", "tasks");
146
+ if (existsSync(tasksDir)) {
147
+ const taskEntries = readdirSync(tasksDir).filter((name) => name.startsWith("task_"));
148
+ assert.equal(taskEntries.length, 0, "no task directory should be created in assess_only mode");
149
+ }
150
+ });
151
+ // ── 5. agent not configured ────────────────────────────────────
152
+ it("throws PatchWardenError (agent_not_configured) when the agent is not registered", async () => {
153
+ writeConfig(tempDir);
154
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
155
+ await assert.rejects(() => createTask({
156
+ inline_plan: "## Goal\nDo something.",
157
+ agent: "ghost-agent",
158
+ repo_path: "my-repo",
159
+ }), (err) => {
160
+ assert.ok(err instanceof PatchWardenError, "should throw PatchWardenError");
161
+ assert.equal(err.reason, "agent_not_configured");
162
+ return true;
163
+ });
164
+ });
165
+ // ── 6. repo_path escapes workspace ─────────────────────────────
166
+ it("throws workspace_path_escape when repo_path is outside workspaceRoot", async () => {
167
+ writeConfig(tempDir);
168
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
169
+ await assert.rejects(() => createTask({
170
+ inline_plan: "## Goal\nDo something.",
171
+ agent: "codex",
172
+ repo_path: "../outside-workspace",
173
+ }), (err) => {
174
+ assert.ok(err instanceof PatchWardenError, "should throw PatchWardenError");
175
+ assert.equal(err.reason, "workspace_path_escape");
176
+ return true;
177
+ });
178
+ });
179
+ // ── 7. watcher not running ─────────────────────────────────────
180
+ it("returns execution_blocked: true when watcher is not running", async () => {
181
+ writeConfig(tempDir);
182
+ mkdirSync(join(tempDir, "my-repo"), { recursive: true });
183
+ // No watcher heartbeat file → watcher status is "missing"
184
+ const result = await createTask({
185
+ inline_plan: "## Goal\nDo something.",
186
+ agent: "codex",
187
+ repo_path: "my-repo",
188
+ });
189
+ const out = result;
190
+ assert.equal(out.execution_blocked, true);
191
+ assert.equal(out.watcher.available, false);
192
+ assert.equal(out.watcher.status, "missing");
193
+ assert.equal(out.next_tool_call.name, "health_check");
194
+ // Task is still created (queued) despite being blocked
195
+ assert.ok(existsSync(join(out.path, "status.json")));
196
+ });
197
+ });
@@ -3,8 +3,9 @@ import { strict as assert } from "node:assert";
3
3
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import { guardDirectPath, guardDirectWritePath, guardDirectReadPath, isBinaryFile } from "../../direct/directGuards.js";
6
+ import { guardDirectPath, guardDirectWritePath, guardDirectReadPath, guardDirectPatchSize, guardDirectFileSize, isBinaryFile } from "../../direct/directGuards.js";
7
7
  import { PatchWardenError } from "../../errors.js";
8
+ import { reloadConfig } from "../../config.js";
8
9
  function makeConfig(workspaceRoot) {
9
10
  return {
10
11
  workspaceRoot,
@@ -130,26 +131,65 @@ describe("guardDirectPatchSize", () => {
130
131
  let tempDir;
131
132
  beforeEach(() => {
132
133
  tempDir = mkdtempSync(join(tmpdir(), "pw-patchsize-"));
133
- process.env.PATCHWARDEN_CONFIG = "";
134
+ // Write a real config file so getConfig() picks it up
135
+ const configPath = join(tempDir, "patchwarden.config.json");
136
+ writeFileSync(configPath, JSON.stringify({
137
+ workspaceRoot: tempDir,
138
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
139
+ allowedTestCommands: ["npm test"],
140
+ directMaxPatchBytes: 1000,
141
+ directMaxFileBytes: 5000,
142
+ }), "utf-8");
143
+ process.env.PATCHWARDEN_CONFIG = configPath;
144
+ reloadConfig();
134
145
  });
135
146
  afterEach(() => {
136
147
  rmSync(tempDir, { recursive: true, force: true });
137
148
  delete process.env.PATCHWARDEN_CONFIG;
149
+ reloadConfig();
138
150
  });
139
151
  it("allows patches within size limit", () => {
140
- // We can't easily call this without a config, so just test the logic
141
- // guardDirectPatchSize calls getConfig() internally
142
- // We'll test it indirectly by checking it doesn't throw for small sizes
143
- // when config is loaded with defaults
144
- // Skip if no config available
152
+ assert.doesNotThrow(() => guardDirectPatchSize(500));
153
+ assert.doesNotThrow(() => guardDirectPatchSize(1000));
145
154
  });
146
155
  it("rejects patches exceeding size limit", () => {
147
- // This test would need config setup; skip for now
156
+ assert.throws(() => guardDirectPatchSize(1001), (err) => err instanceof PatchWardenError && err.reason === "patch_too_large");
157
+ assert.throws(() => guardDirectPatchSize(999999), (err) => err instanceof PatchWardenError && err.reason === "patch_too_large");
158
+ });
159
+ it("allows zero-size patch", () => {
160
+ assert.doesNotThrow(() => guardDirectPatchSize(0));
148
161
  });
149
162
  });
150
163
  describe("guardDirectFileSize", () => {
164
+ let tempDir;
165
+ beforeEach(() => {
166
+ tempDir = mkdtempSync(join(tmpdir(), "pw-filesize-"));
167
+ const configPath = join(tempDir, "patchwarden.config.json");
168
+ writeFileSync(configPath, JSON.stringify({
169
+ workspaceRoot: tempDir,
170
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
171
+ allowedTestCommands: ["npm test"],
172
+ directMaxPatchBytes: 1000,
173
+ directMaxFileBytes: 5000,
174
+ }), "utf-8");
175
+ process.env.PATCHWARDEN_CONFIG = configPath;
176
+ reloadConfig();
177
+ });
178
+ afterEach(() => {
179
+ rmSync(tempDir, { recursive: true, force: true });
180
+ delete process.env.PATCHWARDEN_CONFIG;
181
+ reloadConfig();
182
+ });
151
183
  it("allows files within size limit", () => {
152
- // Similar to above, needs config
184
+ assert.doesNotThrow(() => guardDirectFileSize(100));
185
+ assert.doesNotThrow(() => guardDirectFileSize(5000));
186
+ });
187
+ it("rejects files exceeding size limit", () => {
188
+ assert.throws(() => guardDirectFileSize(5001), (err) => err instanceof PatchWardenError && err.reason === "file_too_large");
189
+ assert.throws(() => guardDirectFileSize(9999999), (err) => err instanceof PatchWardenError && err.reason === "file_too_large");
190
+ });
191
+ it("allows zero-size file", () => {
192
+ assert.doesNotThrow(() => guardDirectFileSize(0));
153
193
  });
154
194
  });
155
195
  describe("isBinaryFile", () => {
@@ -210,4 +250,79 @@ describe("isBinaryFile", () => {
210
250
  it("returns false for non-existent files without binary extension", () => {
211
251
  assert.equal(isBinaryFile(join(tempDir, "nonexistent.txt")), false);
212
252
  });
253
+ // ── Adversarial: null byte beyond 8KB read window ──
254
+ it("detects null byte at position 8200 (beyond old 8KB window)", () => {
255
+ // Previously isBinaryFile only read the first 8192 bytes, so a null byte
256
+ // at position 8200 would bypass detection. Now we scan up to 1 MB.
257
+ const stealthBinary = join(tempDir, "stealth.txt");
258
+ const buffer = Buffer.alloc(8200, 0x41); // 8200 bytes of 'A'
259
+ buffer[8199] = 0; // null byte just beyond the old 8KB read window
260
+ writeFileSync(stealthBinary, buffer);
261
+ assert.equal(isBinaryFile(stealthBinary), true);
262
+ });
263
+ it("detects null byte at exactly position 8192 (first byte beyond old window)", () => {
264
+ const stealthBinary = join(tempDir, "boundary-plus.txt");
265
+ const buffer = Buffer.alloc(8193, 0x41);
266
+ buffer[8192] = 0; // first byte beyond the old 8KB read window
267
+ writeFileSync(stealthBinary, buffer);
268
+ assert.equal(isBinaryFile(stealthBinary), true);
269
+ });
270
+ it("detects null byte at 100KB offset", () => {
271
+ const deepBinary = join(tempDir, "deep.txt");
272
+ const buffer = Buffer.alloc(102400, 0x41);
273
+ buffer[102399] = 0;
274
+ writeFileSync(deepBinary, buffer);
275
+ assert.equal(isBinaryFile(deepBinary), true);
276
+ });
277
+ it("detects null byte at 1MB offset (scan limit boundary)", () => {
278
+ const limitBinary = join(tempDir, "limit.txt");
279
+ const buffer = Buffer.alloc(1_048_576, 0x41);
280
+ buffer[1_048_575] = 0;
281
+ writeFileSync(limitBinary, buffer);
282
+ assert.equal(isBinaryFile(limitBinary), true);
283
+ });
284
+ it("does not scan beyond 1MB limit", () => {
285
+ // A null byte just past the 1MB scan limit should NOT be detected.
286
+ // This documents the remaining scan boundary.
287
+ const overLimit = join(tempDir, "over-limit.txt");
288
+ const buffer = Buffer.alloc(1_048_577, 0x41);
289
+ buffer[1_048_576] = 0; // first byte beyond scan limit
290
+ writeFileSync(overLimit, buffer);
291
+ assert.equal(isBinaryFile(overLimit), false);
292
+ });
293
+ });
294
+ // ── Adversarial: Windows backslash prefix bypass attempts ──
295
+ describe("guardDirectWritePath adversarial separator tests", () => {
296
+ let tempDir;
297
+ let repoPath;
298
+ beforeEach(() => {
299
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directsep-"));
300
+ repoPath = join(tempDir, "my-repo");
301
+ mkdirSync(repoPath, { recursive: true });
302
+ mkdirSync(join(repoPath, "src"), { recursive: true });
303
+ });
304
+ afterEach(() => {
305
+ rmSync(tempDir, { recursive: true, force: true });
306
+ });
307
+ it("blocks node_modules with backslash prefix", () => {
308
+ assert.throws(() => guardDirectWritePath("node_modules\\evil\\index.js", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path");
309
+ });
310
+ it("blocks dist with backslash prefix", () => {
311
+ assert.throws(() => guardDirectWritePath("dist\\main.js", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path");
312
+ });
313
+ it("blocks release with backslash prefix", () => {
314
+ assert.throws(() => guardDirectWritePath("release\\app.exe", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path");
315
+ });
316
+ it("blocks nested node_modules with mixed separators", () => {
317
+ assert.throws(() => guardDirectWritePath("src\\node_modules/evil\\index.js", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path");
318
+ });
319
+ it("blocks .patchwarden with backslash prefix", () => {
320
+ assert.throws(() => guardDirectWritePath(".patchwarden\\tasks\\evil.json", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "internal_patchwarden_path_blocked");
321
+ });
322
+ it("blocks .env with backslash prefix", () => {
323
+ assert.throws(() => guardDirectWritePath("path\\to\\.env", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked");
324
+ });
325
+ it("blocks config.json with backslash path", () => {
326
+ assert.throws(() => guardDirectWritePath("subdir\\config.json", repoPath, tempDir), (err) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked");
327
+ });
213
328
  });
@@ -0,0 +1 @@
1
+ export {};