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,150 @@
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, sep } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { guardPath, guardWorkspacePath } from "../../security/pathGuard.js";
7
+ import { PatchWardenError } from "../../errors.js";
8
+
9
+ describe("guardPath", () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(() => {
13
+ tempDir = mkdtempSync(join(tmpdir(), "pw-pathguard-"));
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ it("allows paths inside workspace", () => {
21
+ const result = guardPath("src/main.ts", tempDir);
22
+ assert.ok(result.startsWith(tempDir));
23
+ assert.ok(result.includes("src"));
24
+ assert.ok(result.includes("main.ts"));
25
+ });
26
+
27
+ it("allows root workspace path", () => {
28
+ const result = guardPath(".", tempDir);
29
+ assert.equal(result, resolve(tempDir));
30
+ });
31
+
32
+ it("allows empty path as workspace root", () => {
33
+ const result = guardPath("", tempDir);
34
+ assert.equal(result, resolve(tempDir));
35
+ });
36
+
37
+ it("rejects path traversal with ..", () => {
38
+ assert.throws(
39
+ () => guardPath("../../../etc/passwd", tempDir),
40
+ PatchWardenError
41
+ );
42
+ });
43
+
44
+ it("rejects path traversal nested with ..", () => {
45
+ assert.throws(
46
+ () => guardPath("src/../../../etc/passwd", tempDir),
47
+ PatchWardenError
48
+ );
49
+ });
50
+
51
+ it("rejects absolute path outside workspace", () => {
52
+ const outside = process.platform === "win32" ? "C:\\Windows\\System32" : "/etc";
53
+ assert.throws(
54
+ () => guardPath(outside, tempDir),
55
+ PatchWardenError
56
+ );
57
+ });
58
+
59
+ it("allows absolute path inside workspace", () => {
60
+ const insidePath = join(tempDir, "src", "main.ts");
61
+ mkdirSync(join(tempDir, "src"), { recursive: true });
62
+ writeFileSync(insidePath, "test", "utf-8");
63
+ const result = guardPath(insidePath, tempDir);
64
+ assert.equal(result, resolve(insidePath));
65
+ });
66
+
67
+ it("enforces allowedPrefix", () => {
68
+ mkdirSync(join(tempDir, "allowed"), { recursive: true });
69
+ mkdirSync(join(tempDir, "forbidden"), { recursive: true });
70
+ // Path inside allowed prefix works
71
+ const okResult = guardPath("allowed/file.ts", tempDir, "allowed");
72
+ assert.ok(okResult.includes("allowed"));
73
+ // Path outside allowed prefix fails
74
+ assert.throws(
75
+ () => guardPath("forbidden/file.ts", tempDir, "allowed"),
76
+ PatchWardenError
77
+ );
78
+ });
79
+
80
+ it("handles Windows mixed separators", () => {
81
+ const mixedPath = "src\\subdir/file.ts";
82
+ const result = guardPath(mixedPath, tempDir);
83
+ assert.ok(result.startsWith(tempDir));
84
+ });
85
+
86
+ it("rejects symlink escape", { skip: process.platform === "win32" ? "Windows symlink permissions unstable" : undefined }, () => {
87
+ const target = mkdtempSync(join(tmpdir(), "pw-symlink-target-"));
88
+ try {
89
+ const linkPath = join(tempDir, "escape-link");
90
+ symlinkSync(target, linkPath);
91
+ assert.throws(
92
+ () => guardPath("escape-link/secret.txt", tempDir),
93
+ PatchWardenError
94
+ );
95
+ } finally {
96
+ rmSync(target, { recursive: true, force: true });
97
+ }
98
+ });
99
+ });
100
+
101
+ describe("guardWorkspacePath", () => {
102
+ let tempDir: string;
103
+
104
+ beforeEach(() => {
105
+ tempDir = mkdtempSync(join(tmpdir(), "pw-wspath-"));
106
+ });
107
+
108
+ afterEach(() => {
109
+ rmSync(tempDir, { recursive: true, force: true });
110
+ });
111
+
112
+ it("allows relative path inside workspace", () => {
113
+ const result = guardWorkspacePath("my-repo", tempDir);
114
+ assert.equal(result, resolve(tempDir, "my-repo"));
115
+ });
116
+
117
+ it("allows absolute path inside workspace", () => {
118
+ const absPath = join(tempDir, "my-repo");
119
+ const result = guardWorkspacePath(absPath, tempDir);
120
+ assert.equal(result, resolve(absPath));
121
+ });
122
+
123
+ it("rejects path outside workspace", () => {
124
+ assert.throws(
125
+ () => guardWorkspacePath("../../../etc", tempDir),
126
+ PatchWardenError
127
+ );
128
+ });
129
+
130
+ it("rejects different drive letter on Windows", { skip: process.platform !== "win32" ? "Windows-only test" : undefined }, () => {
131
+ // If workspace is on C:, a D: path should be rejected
132
+ const wsDrive = tempDir.match(/^([A-Za-z]):/);
133
+ if (!wsDrive) return;
134
+ const otherDrive = wsDrive[1].toLowerCase() === "c" ? "D" : "C";
135
+ assert.throws(
136
+ () => guardWorkspacePath(`${otherDrive}:\\some\\repo`, tempDir),
137
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "workspace_path_escape"
138
+ );
139
+ });
140
+
141
+ it("defaults to workspace root for empty input", () => {
142
+ const result = guardWorkspacePath("", tempDir);
143
+ assert.equal(result, resolve(tempDir));
144
+ });
145
+
146
+ it("defaults to workspace root for dot input", () => {
147
+ const result = guardWorkspacePath(".", tempDir);
148
+ assert.equal(result, resolve(tempDir));
149
+ });
150
+ });
@@ -0,0 +1,187 @@
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, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { safeStatus } from "../../tools/safeStatus.js";
7
+ import type { PatchWardenConfig } from "../../config.js";
8
+
9
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
10
+ return {
11
+ workspaceRoot,
12
+ plansDir: ".patchwarden/plans",
13
+ tasksDir: ".patchwarden/tasks",
14
+ assessmentsDir: ".patchwarden/assessments",
15
+ assessmentTtlSeconds: 3600,
16
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
17
+ allowedTestCommands: ["npm test"],
18
+ repoAllowedTestCommands: {},
19
+ maxReadFileBytes: 200_000,
20
+ defaultTaskTimeoutSeconds: 900,
21
+ maxTaskTimeoutSeconds: 3600,
22
+ watcherStaleSeconds: 30,
23
+ directSessionsDir: ".patchwarden/direct-sessions",
24
+ directSessionTtlSeconds: 3600,
25
+ directMaxPatchBytes: 200_000,
26
+ directMaxFileBytes: 500_000,
27
+ };
28
+ }
29
+
30
+ describe("safeStatus", () => {
31
+ let tempDir: string;
32
+ let config: PatchWardenConfig;
33
+ let tasksDir: string;
34
+
35
+ beforeEach(() => {
36
+ tempDir = mkdtempSync(join(tmpdir(), "pw-safestatus-"));
37
+ config = makeConfig(tempDir);
38
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
39
+ mkdirSync(tasksDir, { recursive: true });
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(tempDir, { recursive: true, force: true });
44
+ });
45
+
46
+ function writeTaskStatus(taskId: string, status: Record<string, unknown>) {
47
+ const taskDir = join(tasksDir, taskId);
48
+ mkdirSync(taskDir, { recursive: true });
49
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
50
+ return taskDir;
51
+ }
52
+
53
+ function writeTaskRuntime(taskDir: string, runtime: Record<string, unknown>) {
54
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
55
+ }
56
+
57
+ it("returns not_found for non-existent task", () => {
58
+ const result = safeStatus("non-existent-task", config);
59
+ assert.equal(result.task_id, "non-existent-task");
60
+ assert.equal(result.status, "not_found");
61
+ assert.equal(result.phase, null);
62
+ assert.equal(result.error_code, null);
63
+ assert.equal(result.error_summary, null);
64
+ });
65
+
66
+ it("returns correct status for pending task", () => {
67
+ writeTaskStatus("task-pending-001", {
68
+ task_id: "task-pending-001",
69
+ status: "pending",
70
+ phase: "queued",
71
+ created_at: "2026-06-24T10:00:00Z",
72
+ updated_at: "2026-06-24T10:00:01Z",
73
+ });
74
+
75
+ const result = safeStatus("task-pending-001", config);
76
+ assert.equal(result.task_id, "task-pending-001");
77
+ assert.equal(result.status, "pending");
78
+ assert.equal(result.created_at, "2026-06-24T10:00:00Z");
79
+ assert.equal(result.error_code, null);
80
+ assert.equal(result.error_summary, null);
81
+ });
82
+
83
+ it("returns correct status for running task", () => {
84
+ const taskDir = writeTaskStatus("task-running-001", {
85
+ task_id: "task-running-001",
86
+ status: "running",
87
+ phase: "executing_agent",
88
+ created_at: "2026-06-24T10:00:00Z",
89
+ started_at: "2026-06-24T10:00:05Z",
90
+ updated_at: "2026-06-24T10:00:10Z",
91
+ });
92
+ writeTaskRuntime(taskDir, {
93
+ phase: "executing_agent",
94
+ last_heartbeat_at: "2026-06-24T10:00:12Z",
95
+ current_command: "codex exec",
96
+ });
97
+
98
+ const result = safeStatus("task-running-001", config);
99
+ assert.equal(result.status, "running");
100
+ assert.equal(result.phase, "executing_agent");
101
+ assert.equal(result.current_command, "codex exec");
102
+ assert.equal(result.last_heartbeat_at, "2026-06-24T10:00:12Z");
103
+ assert.equal(result.started_at, "2026-06-24T10:00:05Z");
104
+ });
105
+
106
+ it("returns correct status for done task", () => {
107
+ writeTaskStatus("task-done-001", {
108
+ task_id: "task-done-001",
109
+ status: "done",
110
+ phase: "completed",
111
+ created_at: "2026-06-24T10:00:00Z",
112
+ started_at: "2026-06-24T10:00:05Z",
113
+ finished_at: "2026-06-24T10:05:00Z",
114
+ verify_status: "passed",
115
+ });
116
+
117
+ const result = safeStatus("task-done-001", config);
118
+ assert.equal(result.status, "done");
119
+ assert.equal(result.phase, "completed");
120
+ assert.equal(result.finished_at, "2026-06-24T10:05:00Z");
121
+ assert.equal(result.verify_status, "passed");
122
+ assert.equal(result.error_code, null);
123
+ });
124
+
125
+ it("returns correct status for failed task", () => {
126
+ writeTaskStatus("task-failed-001", {
127
+ task_id: "task-failed-001",
128
+ status: "failed_verification",
129
+ phase: "failed_verification",
130
+ created_at: "2026-06-24T10:00:00Z",
131
+ started_at: "2026-06-24T10:00:05Z",
132
+ finished_at: "2026-06-24T10:05:00Z",
133
+ error: 'Verification command "npm test" exited with code 1.',
134
+ verify_status: "failed",
135
+ });
136
+
137
+ const result = safeStatus("task-failed-001", config);
138
+ assert.equal(result.status, "failed_verification");
139
+ assert.equal(result.error_code, "failed_verification");
140
+ assert.ok(result.error_summary);
141
+ assert.ok(result.error_summary!.includes("npm test"));
142
+ });
143
+
144
+ it("does not return diff or file content", () => {
145
+ const taskDir = writeTaskStatus("task-safe-001", {
146
+ task_id: "task-safe-001",
147
+ status: "done",
148
+ phase: "completed",
149
+ created_at: "2026-06-24T10:00:00Z",
150
+ });
151
+ writeFileSync(join(taskDir, "result.md"), "# Secret result content", "utf-8");
152
+ writeFileSync(join(taskDir, "git.diff"), "secret diff content", "utf-8");
153
+ writeFileSync(join(taskDir, "test.log"), "secret test log", "utf-8");
154
+
155
+ const result = safeStatus("task-safe-001", config);
156
+ const resultStr = JSON.stringify(result);
157
+ assert.ok(!resultStr.includes("Secret result content"));
158
+ assert.ok(!resultStr.includes("secret diff content"));
159
+ assert.ok(!resultStr.includes("secret test log"));
160
+ });
161
+
162
+ it("truncates long error messages", () => {
163
+ const longError = "x".repeat(300);
164
+ writeTaskStatus("task-longerror-001", {
165
+ task_id: "task-longerror-001",
166
+ status: "failed",
167
+ phase: "failed",
168
+ error: longError,
169
+ });
170
+
171
+ const result = safeStatus("task-longerror-001", config);
172
+ assert.ok(result.error_summary!.length < 300);
173
+ assert.ok(result.error_summary!.endsWith("..."));
174
+ });
175
+
176
+ it("returns artifact_status when present", () => {
177
+ writeTaskStatus("task-artifact-001", {
178
+ task_id: "task-artifact-001",
179
+ status: "done",
180
+ phase: "completed",
181
+ artifact_status: "collected",
182
+ });
183
+
184
+ const result = safeStatus("task-artifact-001", config);
185
+ assert.equal(result.artifact_status, "collected");
186
+ });
187
+ });
@@ -0,0 +1,124 @@
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
+
6
+ describe("isSensitivePath", () => {
7
+ it("blocks .env files", () => {
8
+ assert.equal(isSensitivePath(".env"), true);
9
+ assert.equal(isSensitivePath("path/to/.env"), true);
10
+ assert.equal(isSensitivePath(".env.production"), true);
11
+ assert.equal(isSensitivePath(".env.local"), true);
12
+ });
13
+
14
+ it("blocks .ENV case insensitive", () => {
15
+ assert.equal(isSensitivePath(".ENV"), true);
16
+ assert.equal(isSensitivePath("path/to/.ENV"), true);
17
+ assert.equal(isSensitivePath(".Env.Production"), true);
18
+ });
19
+
20
+ it("blocks config.json case insensitive", () => {
21
+ assert.equal(isSensitivePath("config.json"), true);
22
+ assert.equal(isSensitivePath("Config.json"), true);
23
+ assert.equal(isSensitivePath("CONFIG.JSON"), true);
24
+ assert.equal(isSensitivePath("path/to/Config.json"), true);
25
+ });
26
+
27
+ it("blocks SSH keys", () => {
28
+ assert.equal(isSensitivePath("id_rsa"), true);
29
+ assert.equal(isSensitivePath("id_ed25519"), true);
30
+ assert.equal(isSensitivePath("id_ecdsa"), true);
31
+ assert.equal(isSensitivePath("path/to/id_rsa"), true);
32
+ assert.equal(isSensitivePath(".ssh/id_rsa"), true);
33
+ });
34
+
35
+ it("blocks credentials and tokens", () => {
36
+ assert.equal(isSensitivePath("credentials"), true);
37
+ assert.equal(isSensitivePath("path/to/credentials.json"), true);
38
+ assert.equal(isSensitivePath("token.txt"), true);
39
+ // access_token does NOT match because the pattern requires token at start or after /
40
+ assert.equal(isSensitivePath("access_token"), false);
41
+ assert.equal(isSensitivePath("path/to/token"), true);
42
+ assert.equal(isSensitivePath(".netrc"), true);
43
+ assert.equal(isSensitivePath(".npmrc"), true);
44
+ assert.equal(isSensitivePath(".pypirc"), true);
45
+ });
46
+
47
+ it("blocks private key files", () => {
48
+ assert.equal(isSensitivePath("server.pem"), true);
49
+ assert.equal(isSensitivePath("private.key"), true);
50
+ assert.equal(isSensitivePath("cert.pfx"), true);
51
+ assert.equal(isSensitivePath("cert.p12"), true);
52
+ });
53
+
54
+ it("blocks browser data files", () => {
55
+ assert.equal(isSensitivePath("cookies"), true);
56
+ assert.equal(isSensitivePath("cookies.db"), true);
57
+ assert.equal(isSensitivePath("Web Data"), true);
58
+ assert.equal(isSensitivePath("Login Data"), true);
59
+ assert.equal(isSensitivePath("Local State"), true);
60
+ });
61
+
62
+ it("blocks .git-credentials", () => {
63
+ assert.equal(isSensitivePath(".git-credentials"), true);
64
+ assert.equal(isSensitivePath("path/to/.git-credentials"), true);
65
+ });
66
+
67
+ it("blocks docker and kube config", () => {
68
+ assert.equal(isSensitivePath(".docker/config.json"), true);
69
+ assert.equal(isSensitivePath(".kube/config"), true);
70
+ });
71
+
72
+ it("allows .patchwarden safe prefix", () => {
73
+ assert.equal(isSensitivePath(".patchwarden/tasks/task-001/status.json"), false);
74
+ assert.equal(isSensitivePath(".patchwarden/config.json"), false);
75
+ assert.equal(isSensitivePath(".patchwarden"), false);
76
+ });
77
+
78
+ it("allows non-sensitive files", () => {
79
+ assert.equal(isSensitivePath("src/main.ts"), false);
80
+ assert.equal(isSensitivePath("README.md"), false);
81
+ assert.equal(isSensitivePath("package.json"), false);
82
+ assert.equal(isSensitivePath("docs/guide.md"), false);
83
+ });
84
+
85
+ it("handles Windows backslash paths", () => {
86
+ assert.equal(isSensitivePath("path\\to\\.env"), true);
87
+ assert.equal(isSensitivePath("path\\to\\config.json"), true);
88
+ assert.equal(isSensitivePath(".patchwarden\\tasks\\status.json"), false);
89
+ });
90
+
91
+ it("handles null byte in path", () => {
92
+ // config.json with null byte appended — the $ anchor means this won't match
93
+ // because the string doesn't end with "config.json"
94
+ const nullPath = "config.json\x00.txt";
95
+ assert.equal(isSensitivePath(nullPath), false);
96
+ // But plain config.json still matches
97
+ assert.equal(isSensitivePath("config.json"), true);
98
+ });
99
+
100
+ it("handles Unicode lookalike characters", () => {
101
+ // Full-width dot (U+FF0E) should not match .env pattern
102
+ // This is expected behavior — Unicode lookalikes are NOT matched
103
+ const fullWidthPath = "\uFF0Eenv";
104
+ assert.equal(isSensitivePath(fullWidthPath), false);
105
+ });
106
+ });
107
+
108
+ describe("guardSensitivePath", () => {
109
+ it("throws PatchWardenError for sensitive paths", () => {
110
+ assert.throws(
111
+ () => guardSensitivePath(".env"),
112
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
113
+ );
114
+ });
115
+
116
+ it("does not throw for non-sensitive paths", () => {
117
+ assert.doesNotThrow(() => guardSensitivePath("src/main.ts"));
118
+ assert.doesNotThrow(() => guardSensitivePath("README.md"));
119
+ });
120
+
121
+ it("does not throw for .patchwarden paths", () => {
122
+ assert.doesNotThrow(() => guardSensitivePath(".patchwarden/tasks/status.json"));
123
+ });
124
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { syncFile } from "../../tools/syncFile.js";
7
+ import { computeFileSha256 } from "../../direct/directPatch.js";
8
+ import type { PatchWardenConfig } from "../../config.js";
9
+
10
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
11
+ return {
12
+ workspaceRoot,
13
+ plansDir: ".patchwarden/plans",
14
+ tasksDir: ".patchwarden/tasks",
15
+ assessmentsDir: ".patchwarden/assessments",
16
+ assessmentTtlSeconds: 3600,
17
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
18
+ allowedTestCommands: ["npm test"],
19
+ repoAllowedTestCommands: {},
20
+ maxReadFileBytes: 200_000,
21
+ defaultTaskTimeoutSeconds: 900,
22
+ maxTaskTimeoutSeconds: 3600,
23
+ watcherStaleSeconds: 30,
24
+ directSessionsDir: ".patchwarden/direct-sessions",
25
+ directSessionTtlSeconds: 3600,
26
+ directMaxPatchBytes: 200_000,
27
+ directMaxFileBytes: 500_000,
28
+ };
29
+ }
30
+
31
+ describe("syncFile", () => {
32
+ let tempDir: string;
33
+ let config: PatchWardenConfig;
34
+ let repoPath: string;
35
+ let sessionsDir: string;
36
+ let sessionId: string;
37
+
38
+ beforeEach(() => {
39
+ tempDir = mkdtempSync(join(tmpdir(), "pw-syncfile-"));
40
+ config = makeConfig(tempDir);
41
+ repoPath = join(tempDir, "my-repo");
42
+ sessionsDir = join(tempDir, ".patchwarden", "direct-sessions");
43
+ sessionId = "test-session-001";
44
+
45
+ mkdirSync(repoPath, { recursive: true });
46
+ mkdirSync(join(sessionsDir, sessionId), { recursive: true });
47
+
48
+ writeFileSync(
49
+ join(sessionsDir, sessionId, "session.json"),
50
+ JSON.stringify({
51
+ session_id: sessionId,
52
+ repo_path: repoPath,
53
+ status: "active",
54
+ created_at: new Date().toISOString(),
55
+ }),
56
+ "utf-8"
57
+ );
58
+ });
59
+
60
+ afterEach(() => {
61
+ rmSync(tempDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it("copies a file from source to target", () => {
65
+ const sourceContent = "console.log('hello world');";
66
+ mkdirSync(join(repoPath, "mobile_app"), { recursive: true });
67
+ writeFileSync(join(repoPath, "mobile_app", "app.js"), sourceContent, "utf-8");
68
+
69
+ const result = syncFile(sessionId, "mobile_app/app.js", "windows_app/app/app.js", undefined, config);
70
+
71
+ assert.equal(result.changed, true);
72
+ assert.equal(result.copied_bytes, sourceContent.length);
73
+ assert.equal(result.before_target_sha256, null);
74
+ assert.ok(result.after_target_sha256);
75
+
76
+ const targetContent = readFileSync(join(repoPath, "windows_app", "app", "app.js"), "utf-8");
77
+ assert.equal(targetContent, sourceContent);
78
+ });
79
+
80
+ it("returns changed=false when target already has same content", () => {
81
+ const sourceContent = "same content";
82
+ mkdirSync(join(repoPath, "src"), { recursive: true });
83
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
84
+ writeFileSync(join(repoPath, "src", "file.ts"), sourceContent, "utf-8");
85
+ writeFileSync(join(repoPath, "dst", "file.ts"), sourceContent, "utf-8");
86
+
87
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", undefined, config);
88
+
89
+ assert.equal(result.changed, false);
90
+ assert.ok(result.before_target_sha256);
91
+ assert.equal(result.before_target_sha256, result.after_target_sha256);
92
+ });
93
+
94
+ it("returns changed=true when target has different content", () => {
95
+ mkdirSync(join(repoPath, "src"), { recursive: true });
96
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
97
+ writeFileSync(join(repoPath, "src", "file.ts"), "new content", "utf-8");
98
+ writeFileSync(join(repoPath, "dst", "file.ts"), "old content", "utf-8");
99
+
100
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", undefined, config);
101
+
102
+ assert.equal(result.changed, true);
103
+ assert.notEqual(result.before_target_sha256, result.after_target_sha256);
104
+ });
105
+
106
+ it("rejects source path outside repo", () => {
107
+ mkdirSync(join(repoPath, "src"), { recursive: true });
108
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
109
+
110
+ assert.throws(
111
+ () => syncFile(sessionId, "../../../etc/passwd", "dst/file.ts", undefined, config),
112
+ Error
113
+ );
114
+ });
115
+
116
+ it("rejects target path outside repo", () => {
117
+ mkdirSync(join(repoPath, "src"), { recursive: true });
118
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
119
+
120
+ assert.throws(
121
+ () => syncFile(sessionId, "src/file.ts", "../../../etc/evil", undefined, config),
122
+ Error
123
+ );
124
+ });
125
+
126
+ it("rejects sensitive source files", () => {
127
+ writeFileSync(join(repoPath, ".env"), "SECRET=abc123", "utf-8");
128
+
129
+ assert.throws(
130
+ () => syncFile(sessionId, ".env", "dst/.env", undefined, config),
131
+ Error
132
+ );
133
+ });
134
+
135
+ it("rejects sensitive target files", () => {
136
+ mkdirSync(join(repoPath, "src"), { recursive: true });
137
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
138
+
139
+ assert.throws(
140
+ () => syncFile(sessionId, "src/file.ts", "config.json", undefined, config),
141
+ Error
142
+ );
143
+ });
144
+
145
+ it("rejects writing to node_modules", () => {
146
+ mkdirSync(join(repoPath, "src"), { recursive: true });
147
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
148
+
149
+ assert.throws(
150
+ () => syncFile(sessionId, "src/file.ts", "node_modules/evil/index.js", undefined, config),
151
+ Error
152
+ );
153
+ });
154
+
155
+ it("rejects writing to dist", () => {
156
+ mkdirSync(join(repoPath, "src"), { recursive: true });
157
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
158
+
159
+ assert.throws(
160
+ () => syncFile(sessionId, "src/file.ts", "dist/main.js", undefined, config),
161
+ Error
162
+ );
163
+ });
164
+
165
+ it("rejects non-existent session", () => {
166
+ mkdirSync(join(repoPath, "src"), { recursive: true });
167
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
168
+
169
+ assert.throws(
170
+ () => syncFile("non-existent-session", "src/file.ts", "dst/file.ts", undefined, config),
171
+ Error
172
+ );
173
+ });
174
+
175
+ it("rejects non-existent source file", () => {
176
+ assert.throws(
177
+ () => syncFile(sessionId, "non-existent/file.ts", "dst/file.ts", undefined, config),
178
+ Error
179
+ );
180
+ });
181
+
182
+ it("validates expected_source_sha256", () => {
183
+ mkdirSync(join(repoPath, "src"), { recursive: true });
184
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
185
+
186
+ const correctHash = computeFileSha256(join(repoPath, "src", "file.ts"));
187
+
188
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", {
189
+ expected_source_sha256: correctHash,
190
+ }, config);
191
+ assert.equal(result.source_sha256, correctHash);
192
+
193
+ assert.throws(
194
+ () => syncFile(sessionId, "src/file.ts", "dst2/file.ts", {
195
+ expected_source_sha256: "wronghash",
196
+ }, config),
197
+ Error
198
+ );
199
+ });
200
+
201
+ it("validates expected_target_sha256", () => {
202
+ mkdirSync(join(repoPath, "src"), { recursive: true });
203
+ mkdirSync(join(repoPath, "dst"), { recursive: true });
204
+ writeFileSync(join(repoPath, "src", "file.ts"), "new content", "utf-8");
205
+ writeFileSync(join(repoPath, "dst", "file.ts"), "old content", "utf-8");
206
+
207
+ const correctTargetHash = computeFileSha256(join(repoPath, "dst", "file.ts"));
208
+
209
+ const result = syncFile(sessionId, "src/file.ts", "dst/file.ts", {
210
+ expected_target_sha256: correctTargetHash,
211
+ }, config);
212
+ assert.equal(result.before_target_sha256, correctTargetHash);
213
+
214
+ assert.throws(
215
+ () => syncFile(sessionId, "src/file.ts", "dst/file.ts", {
216
+ expected_target_sha256: "wronghash",
217
+ }, config),
218
+ Error
219
+ );
220
+ });
221
+
222
+ it("creates target directory if it doesn't exist", () => {
223
+ mkdirSync(join(repoPath, "src"), { recursive: true });
224
+ writeFileSync(join(repoPath, "src", "file.ts"), "content", "utf-8");
225
+
226
+ const result = syncFile(sessionId, "src/file.ts", "deep/nested/path/file.ts", undefined, config);
227
+
228
+ assert.equal(result.changed, true);
229
+ assert.ok(existsSync(join(repoPath, "deep", "nested", "path", "file.ts")));
230
+ });
231
+ });