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
@@ -0,0 +1,275 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { Logger, logUnhandledError, installGlobalHandlers } from "../../logging.js";
4
+ // ── Test helpers ──────────────────────────────────────────────────
5
+ /**
6
+ * Capture everything written to stderr during `fn` and return it as an
7
+ * array of raw chunks (one per `process.stderr.write` call).
8
+ */
9
+ function captureStderr(fn) {
10
+ const chunks = [];
11
+ const original = process.stderr.write;
12
+ process.stderr.write = ((chunk) => {
13
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
14
+ return true;
15
+ });
16
+ try {
17
+ fn();
18
+ }
19
+ finally {
20
+ process.stderr.write = original;
21
+ }
22
+ return chunks;
23
+ }
24
+ /**
25
+ * Capture stderr and return the parsed JSON objects (one per log line).
26
+ */
27
+ function captureJson(fn) {
28
+ const chunks = captureStderr(fn);
29
+ return chunks
30
+ .flatMap((chunk) => chunk.split("\n"))
31
+ .map((line) => line.trim())
32
+ .filter((line) => line.length > 0)
33
+ .map((line) => JSON.parse(line));
34
+ }
35
+ // ── Logger: stderr output ─────────────────────────────────────────
36
+ describe("Logger — stderr output", () => {
37
+ it("writes JSON log entries to stderr (never stdout)", () => {
38
+ const log = new Logger();
39
+ const entries = captureJson(() => {
40
+ log.info("hello from test", { component: "unit-test" });
41
+ });
42
+ assert.equal(entries.length, 1);
43
+ const entry = entries[0];
44
+ assert.equal(entry.level, "info");
45
+ assert.equal(entry.message, "hello from test");
46
+ assert.equal(entry.component, "unit-test");
47
+ assert.equal(typeof entry.timestamp, "string");
48
+ // timestamp must be a valid ISO date
49
+ assert.ok(!Number.isNaN(Date.parse(entry.timestamp)));
50
+ });
51
+ it("supports warn and error levels", () => {
52
+ const log = new Logger();
53
+ const entries = captureJson(() => {
54
+ log.warn("careful");
55
+ log.error("broken");
56
+ });
57
+ assert.equal(entries.length, 2);
58
+ assert.equal(entries[0].level, "warn");
59
+ assert.equal(entries[0].message, "careful");
60
+ assert.equal(entries[1].level, "error");
61
+ assert.equal(entries[1].message, "broken");
62
+ });
63
+ it("does not write anything to stdout", () => {
64
+ const originalStdout = process.stdout.write;
65
+ let stdoutWritten = false;
66
+ process.stdout.write = (() => {
67
+ stdoutWritten = true;
68
+ return true;
69
+ });
70
+ try {
71
+ const log = new Logger();
72
+ log.info("should not touch stdout");
73
+ log.warn("nor this");
74
+ log.error("nor this");
75
+ }
76
+ finally {
77
+ process.stdout.write = originalStdout;
78
+ }
79
+ assert.equal(stdoutWritten, false, "Logger must never write to stdout");
80
+ });
81
+ });
82
+ // ── Logger: audit ─────────────────────────────────────────────────
83
+ describe("Logger.audit — format", () => {
84
+ it("produces an audit log entry with all required fields", () => {
85
+ const log = new Logger();
86
+ const entries = captureJson(() => {
87
+ log.audit("createTask", true, 150, undefined, "task-001");
88
+ });
89
+ assert.equal(entries.length, 1);
90
+ const entry = entries[0];
91
+ // Required core fields
92
+ assert.equal(entry.level, "audit");
93
+ assert.equal(typeof entry.timestamp, "string");
94
+ assert.ok(!Number.isNaN(Date.parse(entry.timestamp)));
95
+ assert.equal(typeof entry.message, "string");
96
+ // Audit-specific fields
97
+ assert.equal(entry.tool, "createTask");
98
+ assert.equal(entry.ok, true);
99
+ assert.equal(entry.duration_ms, 150);
100
+ assert.equal(entry.task_id, "task-001");
101
+ // error_reason should be absent when not provided
102
+ assert.equal(entry.error_reason, undefined);
103
+ });
104
+ it("includes error_reason when the tool call failed", () => {
105
+ const log = new Logger();
106
+ const entries = captureJson(() => {
107
+ log.audit("applyPatch", false, 3000, "command_blocked", "task-002");
108
+ });
109
+ assert.equal(entries.length, 1);
110
+ const entry = entries[0];
111
+ assert.equal(entry.ok, false);
112
+ assert.equal(entry.error_reason, "command_blocked");
113
+ assert.equal(entry.task_id, "task-002");
114
+ });
115
+ it("omits task_id when not provided", () => {
116
+ const log = new Logger();
117
+ const entries = captureJson(() => {
118
+ log.audit("healthCheck", true, 5);
119
+ });
120
+ assert.equal(entries.length, 1);
121
+ const entry = entries[0];
122
+ assert.equal(entry.task_id, undefined);
123
+ assert.equal(entry.error_reason, undefined);
124
+ });
125
+ });
126
+ // ── Logger: verbose mode ──────────────────────────────────────────
127
+ describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
128
+ let savedEnv;
129
+ beforeEach(() => {
130
+ savedEnv = process.env.PATCHWARDEN_VERBOSE_LOG;
131
+ });
132
+ afterEach(() => {
133
+ if (savedEnv === undefined) {
134
+ delete process.env.PATCHWARDEN_VERBOSE_LOG;
135
+ }
136
+ else {
137
+ process.env.PATCHWARDEN_VERBOSE_LOG = savedEnv;
138
+ }
139
+ });
140
+ it("does not log args by default (verbose mode off)", () => {
141
+ delete process.env.PATCHWARDEN_VERBOSE_LOG;
142
+ const log = new Logger();
143
+ const sensitiveArgs = {
144
+ prompt: "fix the bug",
145
+ token: "ghp_" + "a".repeat(20),
146
+ };
147
+ const entries = captureJson(() => {
148
+ log.audit("createTask", true, 100, undefined, "task-003", sensitiveArgs);
149
+ });
150
+ assert.equal(entries.length, 1);
151
+ const entry = entries[0];
152
+ // args must NOT be present when verbose mode is off
153
+ assert.equal(entry.args, undefined);
154
+ // The raw token must not appear anywhere in the serialized entry
155
+ const raw = JSON.stringify(entry);
156
+ assert.ok(!raw.includes("ghp_"), "raw token must not leak into log output");
157
+ });
158
+ it("logs sanitized args when PATCHWARDEN_VERBOSE_LOG=true", () => {
159
+ process.env.PATCHWARDEN_VERBOSE_LOG = "true";
160
+ const log = new Logger();
161
+ const sensitiveArgs = {
162
+ prompt: "fix the bug",
163
+ token: "ghp_" + "a".repeat(20),
164
+ };
165
+ const entries = captureJson(() => {
166
+ log.audit("createTask", true, 100, undefined, "task-004", sensitiveArgs);
167
+ });
168
+ assert.equal(entries.length, 1);
169
+ const entry = entries[0];
170
+ // args must be present and redacted
171
+ assert.ok(entry.args !== undefined, "args should be logged in verbose mode");
172
+ const argsStr = String(entry.args);
173
+ assert.ok(argsStr.includes("[REDACTED TOKEN]"), "sensitive token must be redacted");
174
+ assert.ok(!argsStr.includes("ghp_"), "raw token must not appear in args");
175
+ // non-sensitive content is preserved
176
+ assert.ok(argsStr.includes("fix the bug"), "non-sensitive args content is preserved");
177
+ });
178
+ it("does not log args when PATCHWARDEN_VERBOSE_LOG is set but not 'true'", () => {
179
+ process.env.PATCHWARDEN_VERBOSE_LOG = "false";
180
+ const log = new Logger();
181
+ const entries = captureJson(() => {
182
+ log.audit("createTask", true, 100, undefined, "task-005", { prompt: "hello" });
183
+ });
184
+ assert.equal(entries.length, 1);
185
+ assert.equal(entries[0].args, undefined);
186
+ });
187
+ });
188
+ // ── logUnhandledError ─────────────────────────────────────────────
189
+ describe("logUnhandledError", () => {
190
+ it("produces structured JSON output for Error instances", () => {
191
+ const error = new Error("something went wrong");
192
+ error.name = "CustomError";
193
+ const entries = captureJson(() => {
194
+ logUnhandledError(error);
195
+ });
196
+ assert.equal(entries.length, 1);
197
+ const entry = entries[0];
198
+ assert.equal(entry.level, "error");
199
+ assert.equal(entry.message, "unhandled_error");
200
+ assert.equal(entry.error, "something went wrong");
201
+ assert.equal(entry.error_name, "CustomError");
202
+ assert.equal(typeof entry.timestamp, "string");
203
+ assert.ok(!Number.isNaN(Date.parse(entry.timestamp)));
204
+ assert.equal(typeof entry.stack, "string");
205
+ assert.ok(entry.stack.includes("something went wrong"));
206
+ });
207
+ it("handles non-Error values (string)", () => {
208
+ const entries = captureJson(() => {
209
+ logUnhandledError("a plain string rejection");
210
+ });
211
+ assert.equal(entries.length, 1);
212
+ const entry = entries[0];
213
+ assert.equal(entry.level, "error");
214
+ assert.equal(entry.error, "a plain string rejection");
215
+ assert.equal(entry.error_name, "string");
216
+ assert.equal(entry.stack, undefined);
217
+ });
218
+ it("handles non-Error values (object)", () => {
219
+ const entries = captureJson(() => {
220
+ logUnhandledError({ code: 42, detail: "weird" });
221
+ });
222
+ assert.equal(entries.length, 1);
223
+ const entry = entries[0];
224
+ assert.equal(entry.level, "error");
225
+ assert.ok(String(entry.error).includes("42"));
226
+ assert.equal(entry.error_name, "object");
227
+ });
228
+ });
229
+ // ── installGlobalHandlers ─────────────────────────────────────────
230
+ describe("installGlobalHandlers", () => {
231
+ it("does not throw when called", () => {
232
+ // Save existing listeners so we can restore them afterwards and avoid
233
+ // the fatal `uncaughtException` handler interfering with the test
234
+ // runner.
235
+ const origUnhandled = process.listeners("unhandledRejection");
236
+ const origUncaught = process.listeners("uncaughtException");
237
+ process.removeAllListeners("unhandledRejection");
238
+ process.removeAllListeners("uncaughtException");
239
+ try {
240
+ assert.doesNotThrow(() => installGlobalHandlers());
241
+ // Verify the handlers were actually registered
242
+ assert.ok(process.listenerCount("unhandledRejection") >= 1);
243
+ assert.ok(process.listenerCount("uncaughtException") >= 1);
244
+ }
245
+ finally {
246
+ // Restore original listeners
247
+ process.removeAllListeners("unhandledRejection");
248
+ process.removeAllListeners("uncaughtException");
249
+ for (const fn of origUnhandled)
250
+ process.on("unhandledRejection", fn);
251
+ for (const fn of origUncaught)
252
+ process.on("uncaughtException", fn);
253
+ }
254
+ });
255
+ it("can be called multiple times without throwing", () => {
256
+ const origUnhandled = process.listeners("unhandledRejection");
257
+ const origUncaught = process.listeners("uncaughtException");
258
+ process.removeAllListeners("unhandledRejection");
259
+ process.removeAllListeners("uncaughtException");
260
+ try {
261
+ assert.doesNotThrow(() => {
262
+ installGlobalHandlers();
263
+ installGlobalHandlers();
264
+ });
265
+ }
266
+ finally {
267
+ process.removeAllListeners("unhandledRejection");
268
+ process.removeAllListeners("uncaughtException");
269
+ for (const fn of origUnhandled)
270
+ process.on("unhandledRejection", fn);
271
+ for (const fn of origUncaught)
272
+ process.on("uncaughtException", fn);
273
+ }
274
+ });
275
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { guardPath, guardWorkspacePath } from "../../security/pathGuard.js";
7
+ import { PatchWardenError } from "../../errors.js";
8
+ describe("guardPath", () => {
9
+ let tempDir;
10
+ beforeEach(() => {
11
+ tempDir = mkdtempSync(join(tmpdir(), "pw-pathguard-"));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(tempDir, { recursive: true, force: true });
15
+ });
16
+ it("allows paths inside workspace", () => {
17
+ const result = guardPath("src/main.ts", tempDir);
18
+ assert.ok(result.startsWith(tempDir));
19
+ assert.ok(result.includes("src"));
20
+ assert.ok(result.includes("main.ts"));
21
+ });
22
+ it("allows root workspace path", () => {
23
+ const result = guardPath(".", tempDir);
24
+ assert.equal(result, resolve(tempDir));
25
+ });
26
+ it("allows empty path as workspace root", () => {
27
+ const result = guardPath("", tempDir);
28
+ assert.equal(result, resolve(tempDir));
29
+ });
30
+ it("rejects path traversal with ..", () => {
31
+ assert.throws(() => guardPath("../../../etc/passwd", tempDir), PatchWardenError);
32
+ });
33
+ it("rejects path traversal nested with ..", () => {
34
+ assert.throws(() => guardPath("src/../../../etc/passwd", tempDir), PatchWardenError);
35
+ });
36
+ it("rejects absolute path outside workspace", () => {
37
+ const outside = process.platform === "win32" ? "C:\\Windows\\System32" : "/etc";
38
+ assert.throws(() => guardPath(outside, tempDir), PatchWardenError);
39
+ });
40
+ it("allows absolute path inside workspace", () => {
41
+ const insidePath = join(tempDir, "src", "main.ts");
42
+ mkdirSync(join(tempDir, "src"), { recursive: true });
43
+ writeFileSync(insidePath, "test", "utf-8");
44
+ const result = guardPath(insidePath, tempDir);
45
+ assert.equal(result, resolve(insidePath));
46
+ });
47
+ it("enforces allowedPrefix", () => {
48
+ mkdirSync(join(tempDir, "allowed"), { recursive: true });
49
+ mkdirSync(join(tempDir, "forbidden"), { recursive: true });
50
+ // Path inside allowed prefix works
51
+ const okResult = guardPath("allowed/file.ts", tempDir, "allowed");
52
+ assert.ok(okResult.includes("allowed"));
53
+ // Path outside allowed prefix fails
54
+ assert.throws(() => guardPath("forbidden/file.ts", tempDir, "allowed"), PatchWardenError);
55
+ });
56
+ it("handles Windows mixed separators", () => {
57
+ const mixedPath = "src\\subdir/file.ts";
58
+ const result = guardPath(mixedPath, tempDir);
59
+ assert.ok(result.startsWith(tempDir));
60
+ });
61
+ it("rejects symlink escape", { skip: process.platform === "win32" ? "Windows symlink permissions unstable" : undefined }, () => {
62
+ const target = mkdtempSync(join(tmpdir(), "pw-symlink-target-"));
63
+ try {
64
+ const linkPath = join(tempDir, "escape-link");
65
+ symlinkSync(target, linkPath);
66
+ assert.throws(() => guardPath("escape-link/secret.txt", tempDir), PatchWardenError);
67
+ }
68
+ finally {
69
+ rmSync(target, { recursive: true, force: true });
70
+ }
71
+ });
72
+ });
73
+ describe("guardWorkspacePath", () => {
74
+ let tempDir;
75
+ beforeEach(() => {
76
+ tempDir = mkdtempSync(join(tmpdir(), "pw-wspath-"));
77
+ });
78
+ afterEach(() => {
79
+ rmSync(tempDir, { recursive: true, force: true });
80
+ });
81
+ it("allows relative path inside workspace", () => {
82
+ const result = guardWorkspacePath("my-repo", tempDir);
83
+ assert.equal(result, resolve(tempDir, "my-repo"));
84
+ });
85
+ it("allows absolute path inside workspace", () => {
86
+ const absPath = join(tempDir, "my-repo");
87
+ const result = guardWorkspacePath(absPath, tempDir);
88
+ assert.equal(result, resolve(absPath));
89
+ });
90
+ it("rejects path outside workspace", () => {
91
+ assert.throws(() => guardWorkspacePath("../../../etc", tempDir), PatchWardenError);
92
+ });
93
+ it("rejects different drive letter on Windows", { skip: process.platform !== "win32" ? "Windows-only test" : undefined }, () => {
94
+ // If workspace is on C:, a D: path should be rejected
95
+ const wsDrive = tempDir.match(/^([A-Za-z]):/);
96
+ if (!wsDrive)
97
+ return;
98
+ const otherDrive = wsDrive[1].toLowerCase() === "c" ? "D" : "C";
99
+ assert.throws(() => guardWorkspacePath(`${otherDrive}:\\some\\repo`, tempDir), (err) => err instanceof PatchWardenError && err.reason === "workspace_path_escape");
100
+ });
101
+ it("defaults to workspace root for empty input", () => {
102
+ const result = guardWorkspacePath("", tempDir);
103
+ assert.equal(result, resolve(tempDir));
104
+ });
105
+ it("defaults to workspace root for dot input", () => {
106
+ const result = guardWorkspacePath(".", tempDir);
107
+ assert.equal(result, resolve(tempDir));
108
+ });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { safeStatus } from "../../tools/safeStatus.js";
7
+ function makeConfig(workspaceRoot) {
8
+ return {
9
+ workspaceRoot,
10
+ plansDir: ".patchwarden/plans",
11
+ tasksDir: ".patchwarden/tasks",
12
+ assessmentsDir: ".patchwarden/assessments",
13
+ assessmentTtlSeconds: 3600,
14
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
15
+ allowedTestCommands: ["npm test"],
16
+ repoAllowedTestCommands: {},
17
+ maxReadFileBytes: 200_000,
18
+ defaultTaskTimeoutSeconds: 900,
19
+ maxTaskTimeoutSeconds: 3600,
20
+ watcherStaleSeconds: 30,
21
+ directSessionsDir: ".patchwarden/direct-sessions",
22
+ directSessionTtlSeconds: 3600,
23
+ directMaxPatchBytes: 200_000,
24
+ directMaxFileBytes: 500_000,
25
+ };
26
+ }
27
+ describe("safeStatus", () => {
28
+ let tempDir;
29
+ let config;
30
+ let tasksDir;
31
+ beforeEach(() => {
32
+ tempDir = mkdtempSync(join(tmpdir(), "pw-safestatus-"));
33
+ config = makeConfig(tempDir);
34
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
35
+ mkdirSync(tasksDir, { recursive: true });
36
+ });
37
+ afterEach(() => {
38
+ rmSync(tempDir, { recursive: true, force: true });
39
+ });
40
+ function writeTaskStatus(taskId, status) {
41
+ const taskDir = join(tasksDir, taskId);
42
+ mkdirSync(taskDir, { recursive: true });
43
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
44
+ return taskDir;
45
+ }
46
+ function writeTaskRuntime(taskDir, runtime) {
47
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
48
+ }
49
+ it("returns not_found for non-existent task", () => {
50
+ const result = safeStatus("non-existent-task", config);
51
+ assert.equal(result.task_id, "non-existent-task");
52
+ assert.equal(result.status, "not_found");
53
+ assert.equal(result.phase, null);
54
+ assert.equal(result.error_code, null);
55
+ assert.equal(result.error_summary, null);
56
+ });
57
+ it("returns correct status for pending task", () => {
58
+ writeTaskStatus("task-pending-001", {
59
+ task_id: "task-pending-001",
60
+ status: "pending",
61
+ phase: "queued",
62
+ created_at: "2026-06-24T10:00:00Z",
63
+ updated_at: "2026-06-24T10:00:01Z",
64
+ });
65
+ const result = safeStatus("task-pending-001", config);
66
+ assert.equal(result.task_id, "task-pending-001");
67
+ assert.equal(result.status, "pending");
68
+ assert.equal(result.created_at, "2026-06-24T10:00:00Z");
69
+ assert.equal(result.error_code, null);
70
+ assert.equal(result.error_summary, null);
71
+ });
72
+ it("returns correct status for running task", () => {
73
+ const taskDir = writeTaskStatus("task-running-001", {
74
+ task_id: "task-running-001",
75
+ status: "running",
76
+ phase: "executing_agent",
77
+ created_at: "2026-06-24T10:00:00Z",
78
+ started_at: "2026-06-24T10:00:05Z",
79
+ updated_at: "2026-06-24T10:00:10Z",
80
+ });
81
+ writeTaskRuntime(taskDir, {
82
+ phase: "executing_agent",
83
+ last_heartbeat_at: "2026-06-24T10:00:12Z",
84
+ current_command: "codex exec",
85
+ });
86
+ const result = safeStatus("task-running-001", config);
87
+ assert.equal(result.status, "running");
88
+ assert.equal(result.phase, "executing_agent");
89
+ assert.equal(result.current_command, "codex exec");
90
+ assert.equal(result.last_heartbeat_at, "2026-06-24T10:00:12Z");
91
+ assert.equal(result.started_at, "2026-06-24T10:00:05Z");
92
+ });
93
+ it("returns correct status for done task", () => {
94
+ writeTaskStatus("task-done-001", {
95
+ task_id: "task-done-001",
96
+ status: "done",
97
+ phase: "completed",
98
+ created_at: "2026-06-24T10:00:00Z",
99
+ started_at: "2026-06-24T10:00:05Z",
100
+ finished_at: "2026-06-24T10:05:00Z",
101
+ verify_status: "passed",
102
+ });
103
+ const result = safeStatus("task-done-001", config);
104
+ assert.equal(result.status, "done");
105
+ assert.equal(result.phase, "completed");
106
+ assert.equal(result.finished_at, "2026-06-24T10:05:00Z");
107
+ assert.equal(result.verify_status, "passed");
108
+ assert.equal(result.error_code, null);
109
+ });
110
+ it("returns correct status for failed task", () => {
111
+ writeTaskStatus("task-failed-001", {
112
+ task_id: "task-failed-001",
113
+ status: "failed_verification",
114
+ phase: "failed_verification",
115
+ created_at: "2026-06-24T10:00:00Z",
116
+ started_at: "2026-06-24T10:00:05Z",
117
+ finished_at: "2026-06-24T10:05:00Z",
118
+ error: 'Verification command "npm test" exited with code 1.',
119
+ verify_status: "failed",
120
+ });
121
+ const result = safeStatus("task-failed-001", config);
122
+ assert.equal(result.status, "failed_verification");
123
+ assert.equal(result.error_code, "failed_verification");
124
+ assert.ok(result.error_summary);
125
+ assert.ok(result.error_summary.includes("npm test"));
126
+ });
127
+ it("does not return diff or file content", () => {
128
+ const taskDir = writeTaskStatus("task-safe-001", {
129
+ task_id: "task-safe-001",
130
+ status: "done",
131
+ phase: "completed",
132
+ created_at: "2026-06-24T10:00:00Z",
133
+ });
134
+ writeFileSync(join(taskDir, "result.md"), "# Secret result content", "utf-8");
135
+ writeFileSync(join(taskDir, "git.diff"), "secret diff content", "utf-8");
136
+ writeFileSync(join(taskDir, "test.log"), "secret test log", "utf-8");
137
+ const result = safeStatus("task-safe-001", config);
138
+ const resultStr = JSON.stringify(result);
139
+ assert.ok(!resultStr.includes("Secret result content"));
140
+ assert.ok(!resultStr.includes("secret diff content"));
141
+ assert.ok(!resultStr.includes("secret test log"));
142
+ });
143
+ it("truncates long error messages", () => {
144
+ const longError = "x".repeat(300);
145
+ writeTaskStatus("task-longerror-001", {
146
+ task_id: "task-longerror-001",
147
+ status: "failed",
148
+ phase: "failed",
149
+ error: longError,
150
+ });
151
+ const result = safeStatus("task-longerror-001", config);
152
+ assert.ok(result.error_summary.length < 300);
153
+ assert.ok(result.error_summary.endsWith("..."));
154
+ });
155
+ it("returns artifact_status when present", () => {
156
+ writeTaskStatus("task-artifact-001", {
157
+ task_id: "task-artifact-001",
158
+ status: "done",
159
+ phase: "completed",
160
+ artifact_status: "collected",
161
+ });
162
+ const result = safeStatus("task-artifact-001", config);
163
+ assert.equal(result.artifact_status, "collected");
164
+ });
165
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { isSensitivePath, guardSensitivePath } from "../../security/sensitiveGuard.js";
4
+ import { PatchWardenError } from "../../errors.js";
5
+ describe("isSensitivePath", () => {
6
+ it("blocks .env files", () => {
7
+ assert.equal(isSensitivePath(".env"), true);
8
+ assert.equal(isSensitivePath("path/to/.env"), true);
9
+ assert.equal(isSensitivePath(".env.production"), true);
10
+ assert.equal(isSensitivePath(".env.local"), true);
11
+ });
12
+ it("blocks .ENV case insensitive", () => {
13
+ assert.equal(isSensitivePath(".ENV"), true);
14
+ assert.equal(isSensitivePath("path/to/.ENV"), true);
15
+ assert.equal(isSensitivePath(".Env.Production"), true);
16
+ });
17
+ it("blocks config.json case insensitive", () => {
18
+ assert.equal(isSensitivePath("config.json"), true);
19
+ assert.equal(isSensitivePath("Config.json"), true);
20
+ assert.equal(isSensitivePath("CONFIG.JSON"), true);
21
+ assert.equal(isSensitivePath("path/to/Config.json"), true);
22
+ });
23
+ it("blocks SSH keys", () => {
24
+ assert.equal(isSensitivePath("id_rsa"), true);
25
+ assert.equal(isSensitivePath("id_ed25519"), true);
26
+ assert.equal(isSensitivePath("id_ecdsa"), true);
27
+ assert.equal(isSensitivePath("path/to/id_rsa"), true);
28
+ assert.equal(isSensitivePath(".ssh/id_rsa"), true);
29
+ });
30
+ it("blocks credentials and tokens", () => {
31
+ assert.equal(isSensitivePath("credentials"), true);
32
+ assert.equal(isSensitivePath("path/to/credentials.json"), true);
33
+ assert.equal(isSensitivePath("token.txt"), true);
34
+ // access_token does NOT match because the pattern requires token at start or after /
35
+ assert.equal(isSensitivePath("access_token"), false);
36
+ assert.equal(isSensitivePath("path/to/token"), true);
37
+ assert.equal(isSensitivePath(".netrc"), true);
38
+ assert.equal(isSensitivePath(".npmrc"), true);
39
+ assert.equal(isSensitivePath(".pypirc"), true);
40
+ });
41
+ it("blocks private key files", () => {
42
+ assert.equal(isSensitivePath("server.pem"), true);
43
+ assert.equal(isSensitivePath("private.key"), true);
44
+ assert.equal(isSensitivePath("cert.pfx"), true);
45
+ assert.equal(isSensitivePath("cert.p12"), true);
46
+ });
47
+ it("blocks browser data files", () => {
48
+ assert.equal(isSensitivePath("cookies"), true);
49
+ assert.equal(isSensitivePath("cookies.db"), true);
50
+ assert.equal(isSensitivePath("Web Data"), true);
51
+ assert.equal(isSensitivePath("Login Data"), true);
52
+ assert.equal(isSensitivePath("Local State"), true);
53
+ });
54
+ it("blocks .git-credentials", () => {
55
+ assert.equal(isSensitivePath(".git-credentials"), true);
56
+ assert.equal(isSensitivePath("path/to/.git-credentials"), true);
57
+ });
58
+ it("blocks docker and kube config", () => {
59
+ assert.equal(isSensitivePath(".docker/config.json"), true);
60
+ assert.equal(isSensitivePath(".kube/config"), true);
61
+ });
62
+ it("allows .patchwarden safe prefix", () => {
63
+ assert.equal(isSensitivePath(".patchwarden/tasks/task-001/status.json"), false);
64
+ assert.equal(isSensitivePath(".patchwarden/config.json"), false);
65
+ assert.equal(isSensitivePath(".patchwarden"), false);
66
+ });
67
+ it("allows non-sensitive files", () => {
68
+ assert.equal(isSensitivePath("src/main.ts"), false);
69
+ assert.equal(isSensitivePath("README.md"), false);
70
+ assert.equal(isSensitivePath("package.json"), false);
71
+ assert.equal(isSensitivePath("docs/guide.md"), false);
72
+ });
73
+ it("handles Windows backslash paths", () => {
74
+ assert.equal(isSensitivePath("path\\to\\.env"), true);
75
+ assert.equal(isSensitivePath("path\\to\\config.json"), true);
76
+ assert.equal(isSensitivePath(".patchwarden\\tasks\\status.json"), false);
77
+ });
78
+ it("handles null byte in path", () => {
79
+ // config.json with null byte appended — the $ anchor means this won't match
80
+ // because the string doesn't end with "config.json"
81
+ const nullPath = "config.json\x00.txt";
82
+ assert.equal(isSensitivePath(nullPath), false);
83
+ // But plain config.json still matches
84
+ assert.equal(isSensitivePath("config.json"), true);
85
+ });
86
+ it("handles Unicode lookalike characters", () => {
87
+ // Full-width dot (U+FF0E) should not match .env pattern
88
+ // This is expected behavior — Unicode lookalikes are NOT matched
89
+ const fullWidthPath = "\uFF0Eenv";
90
+ assert.equal(isSensitivePath(fullWidthPath), false);
91
+ });
92
+ });
93
+ describe("guardSensitivePath", () => {
94
+ it("throws PatchWardenError for sensitive paths", () => {
95
+ assert.throws(() => guardSensitivePath(".env"), (err) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked");
96
+ });
97
+ it("does not throw for non-sensitive paths", () => {
98
+ assert.doesNotThrow(() => guardSensitivePath("src/main.ts"));
99
+ assert.doesNotThrow(() => guardSensitivePath("README.md"));
100
+ });
101
+ it("does not throw for .patchwarden paths", () => {
102
+ assert.doesNotThrow(() => guardSensitivePath(".patchwarden/tasks/status.json"));
103
+ });
104
+ });
@@ -0,0 +1 @@
1
+ export {};