patchwarden 0.6.0 → 0.6.4

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 (134) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +106 -18
  6. package/README.md +30 -19
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/controlCenter.d.ts +14 -0
  10. package/dist/controlCenter.js +2002 -0
  11. package/dist/doctor.js +4 -4
  12. package/dist/logging.d.ts +52 -0
  13. package/dist/logging.js +123 -0
  14. package/dist/runner/changeCapture.d.ts +41 -0
  15. package/dist/runner/changeCapture.js +171 -1
  16. package/dist/runner/runTask.js +204 -24
  17. package/dist/smoke-test.js +8 -8
  18. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  19. package/dist/test/unit/android-doctor.test.js +118 -0
  20. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  21. package/dist/test/unit/chinese-path.test.js +91 -0
  22. package/dist/test/unit/command-guard.test.d.ts +1 -0
  23. package/dist/test/unit/command-guard.test.js +160 -0
  24. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  25. package/dist/test/unit/direct-guards.test.js +213 -0
  26. package/dist/test/unit/logging.test.d.ts +1 -0
  27. package/dist/test/unit/logging.test.js +275 -0
  28. package/dist/test/unit/path-guard.test.d.ts +1 -0
  29. package/dist/test/unit/path-guard.test.js +109 -0
  30. package/dist/test/unit/safe-status.test.d.ts +1 -0
  31. package/dist/test/unit/safe-status.test.js +165 -0
  32. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  33. package/dist/test/unit/sensitive-guard.test.js +104 -0
  34. package/dist/test/unit/sync-file.test.d.ts +1 -0
  35. package/dist/test/unit/sync-file.test.js +154 -0
  36. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  37. package/dist/test/unit/watcher-status.test.js +169 -0
  38. package/dist/tools/androidDoctor.d.ts +38 -0
  39. package/dist/tools/androidDoctor.js +391 -0
  40. package/dist/tools/auditTask.js +11 -5
  41. package/dist/tools/getTaskSummary.d.ts +3 -0
  42. package/dist/tools/getTaskSummary.js +15 -1
  43. package/dist/tools/healthCheck.d.ts +5 -0
  44. package/dist/tools/healthCheck.js +21 -0
  45. package/dist/tools/registry.js +53 -0
  46. package/dist/tools/safeStatus.d.ts +19 -0
  47. package/dist/tools/safeStatus.js +72 -0
  48. package/dist/tools/syncFile.d.ts +18 -0
  49. package/dist/tools/syncFile.js +65 -0
  50. package/dist/tools/taskOutputs.d.ts +2 -2
  51. package/dist/tools/toolCatalog.d.ts +2 -2
  52. package/dist/tools/toolCatalog.js +2 -0
  53. package/dist/version.d.ts +2 -2
  54. package/dist/version.js +2 -2
  55. package/dist/watcherStatus.d.ts +1 -0
  56. package/dist/watcherStatus.js +96 -4
  57. package/docs/control-center/README.md +33 -0
  58. package/docs/control-center/control-center-daily-driver.md +211 -0
  59. package/docs/control-center/control-center-mvp.md +205 -0
  60. package/docs/control-center/control-center-phase2.md +159 -0
  61. package/docs/demo.md +3 -0
  62. package/docs/performance-notes.md +55 -0
  63. package/docs/release-v0.6.1.md +75 -0
  64. package/docs/release-v0.6.4.md +45 -0
  65. package/examples/openai-tunnel/README.md +5 -5
  66. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  67. package/package.json +23 -14
  68. package/scripts/README.md +47 -0
  69. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  70. package/scripts/checks/control-center-smoke.js +1098 -0
  71. package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
  72. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
  73. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +12 -4
  74. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +347 -13
  75. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +32 -9
  76. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +14 -9
  77. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  78. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  79. package/scripts/checks/unit-tests.js +36 -0
  80. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
  81. package/scripts/control/control-center-tray.ps1 +281 -0
  82. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  83. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  84. package/scripts/control/restart-control-center.ps1 +173 -0
  85. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  86. package/scripts/control/start-control-center.ps1 +263 -0
  87. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  88. package/scripts/control/stop-patchwarden.ps1 +114 -0
  89. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  90. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  91. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  92. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  93. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  94. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  95. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  96. package/scripts/release/pack-clean.js +305 -0
  97. package/src/controlCenter.ts +2152 -0
  98. package/src/doctor.ts +5 -5
  99. package/src/logging.ts +152 -0
  100. package/src/runner/changeCapture.ts +212 -1
  101. package/src/runner/runTask.ts +220 -22
  102. package/src/smoke-test.ts +5 -5
  103. package/src/test/unit/android-doctor.test.ts +158 -0
  104. package/src/test/unit/chinese-path.test.ts +106 -0
  105. package/src/test/unit/command-guard.test.ts +221 -0
  106. package/src/test/unit/direct-guards.test.ts +297 -0
  107. package/src/test/unit/logging.test.ts +325 -0
  108. package/src/test/unit/path-guard.test.ts +150 -0
  109. package/src/test/unit/safe-status.test.ts +187 -0
  110. package/src/test/unit/sensitive-guard.test.ts +124 -0
  111. package/src/test/unit/sync-file.test.ts +231 -0
  112. package/src/test/unit/watcher-status.test.ts +190 -0
  113. package/src/tools/androidDoctor.ts +424 -0
  114. package/src/tools/auditTask.ts +11 -5
  115. package/src/tools/getTaskSummary.ts +22 -1
  116. package/src/tools/healthCheck.ts +22 -0
  117. package/src/tools/registry.ts +63 -0
  118. package/src/tools/safeStatus.ts +96 -0
  119. package/src/tools/syncFile.ts +122 -0
  120. package/src/tools/toolCatalog.ts +2 -0
  121. package/src/version.ts +2 -2
  122. package/src/watcherStatus.ts +101 -4
  123. package/ui/colors_and_type.css +141 -0
  124. package/ui/pages/audit.html +743 -0
  125. package/ui/pages/dashboard.html +1154 -0
  126. package/ui/pages/direct-sessions.html +652 -0
  127. package/ui/pages/logs.html +502 -0
  128. package/ui/pages/task-detail.html +1229 -0
  129. package/ui/pages/tasks.html +702 -0
  130. package/ui/pages/workspace.html +947 -0
  131. package/ui/partials/project-shell.html +362 -0
  132. package/ui/vendor/lucide.js +12 -0
  133. package/ui/vendor/tailwindcss-browser.js +947 -0
  134. package/scripts/pack-clean.js +0 -141
@@ -0,0 +1,106 @@
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
+
7
+ describe("Chinese path handling", () => {
8
+ let tempDir: string;
9
+
10
+ beforeEach(() => {
11
+ tempDir = mkdtempSync(join(tmpdir(), "pw-chinese-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(tempDir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("writes and reads status.json with Chinese repo_path", () => {
19
+ const chinesePath = "念念小伴_release";
20
+ const status = {
21
+ task_id: "test-001",
22
+ status: "pending",
23
+ repo_path: chinesePath,
24
+ resolved_repo_path: resolve(tempDir, chinesePath),
25
+ };
26
+ const statusFile = join(tempDir, "status.json");
27
+ writeFileSync(statusFile, JSON.stringify(status, null, 2), "utf-8");
28
+
29
+ const raw = readFileSync(statusFile, "utf-8");
30
+ const parsed = JSON.parse(raw);
31
+ assert.equal(parsed.repo_path, chinesePath);
32
+ assert.equal(parsed.resolved_repo_path, resolve(tempDir, chinesePath));
33
+ });
34
+
35
+ it("preserves Chinese characters through JSON stringify/parse", () => {
36
+ const testPaths = [
37
+ "念念小伴_release",
38
+ "测试项目",
39
+ "项目目录/子目录",
40
+ "日本語プロジェクト",
41
+ "한국어_프로젝트",
42
+ ];
43
+ for (const path of testPaths) {
44
+ const json = JSON.stringify({ repo_path: path });
45
+ const parsed = JSON.parse(json);
46
+ assert.equal(parsed.repo_path, path, `Path "${path}" was corrupted`);
47
+ }
48
+ });
49
+
50
+ it("creates and reads directories with Chinese names", () => {
51
+ const chineseDir = join(tempDir, "念念小伴_release");
52
+ mkdirSync(chineseDir, { recursive: true });
53
+ assert.ok(existsSync(chineseDir));
54
+
55
+ const filePath = join(chineseDir, "status.json");
56
+ const data = { repo_path: "念念小伴_release", status: "running" };
57
+ writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
58
+
59
+ const raw = readFileSync(filePath, "utf-8");
60
+ const parsed = JSON.parse(raw);
61
+ assert.equal(parsed.repo_path, "念念小伴_release");
62
+ });
63
+
64
+ it("handles Chinese characters in result.md", () => {
65
+ const resultMd = [
66
+ "# PatchWarden Task Result",
67
+ "",
68
+ "## Status",
69
+ "done",
70
+ "",
71
+ "## Files changed",
72
+ "- modified: 念念小伴_release/src/main.ts",
73
+ "- added: 测试项目/config.json",
74
+ "",
75
+ ].join("\n");
76
+ const resultFile = join(tempDir, "result.md");
77
+ writeFileSync(resultFile, resultMd, "utf-8");
78
+
79
+ const content = readFileSync(resultFile, "utf-8");
80
+ assert.ok(content.includes("念念小伴_release"));
81
+ assert.ok(content.includes("测试项目"));
82
+ });
83
+
84
+ it("handles mixed Chinese and ASCII paths", () => {
85
+ const mixedPath = "src/组件/Button.tsx";
86
+ const data = { path: mixedPath };
87
+ const json = JSON.stringify(data);
88
+ const parsed = JSON.parse(json);
89
+ assert.equal(parsed.path, mixedPath);
90
+ });
91
+
92
+ it("handles Chinese characters in error messages", () => {
93
+ const errorMsg = 'repo_path "念念小伴_release" is outside workspace';
94
+ const errorFile = join(tempDir, "error.log");
95
+ writeFileSync(errorFile, errorMsg, "utf-8");
96
+
97
+ const content = readFileSync(errorFile, "utf-8");
98
+ assert.ok(content.includes("念念小伴_release"));
99
+ });
100
+
101
+ it("handles Windows backslash paths with Chinese characters", () => {
102
+ const winPath = "念念小伴_release\\src\\main.ts";
103
+ const normalized = winPath.replace(/\\/g, "/");
104
+ assert.equal(normalized, "念念小伴_release/src/main.ts");
105
+ });
106
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { guardAgentCommand, guardTestCommand, guardDirectCommand, sanitizePromptArg } from "../../security/commandGuard.js";
7
+ import { PatchWardenError } from "../../errors.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: {
18
+ codex: { command: "codex", args: ["exec", "{repo}", "{prompt}"] },
19
+ opencode: { command: "opencode", args: ["run", "{prompt}"] },
20
+ },
21
+ allowedTestCommands: ["npm test", "npm run build", "npm run lint"],
22
+ repoAllowedTestCommands: {},
23
+ maxReadFileBytes: 200_000,
24
+ defaultTaskTimeoutSeconds: 900,
25
+ maxTaskTimeoutSeconds: 3600,
26
+ watcherStaleSeconds: 30,
27
+ directSessionsDir: ".patchwarden/direct-sessions",
28
+ directSessionTtlSeconds: 3600,
29
+ directMaxPatchBytes: 200_000,
30
+ directMaxFileBytes: 500_000,
31
+ directAllowedCommands: ["npm test", "npm run build"],
32
+ repoDirectAllowedCommands: {},
33
+ };
34
+ }
35
+
36
+ describe("guardTestCommand", () => {
37
+ let tempDir: string;
38
+ let config: PatchWardenConfig;
39
+
40
+ beforeEach(() => {
41
+ tempDir = mkdtempSync(join(tmpdir(), "pw-cmdguard-"));
42
+ config = makeConfig(tempDir);
43
+ });
44
+
45
+ afterEach(() => {
46
+ rmSync(tempDir, { recursive: true, force: true });
47
+ });
48
+
49
+ it("allows exact match from allowlist", () => {
50
+ assert.equal(guardTestCommand("npm test", config), "npm test");
51
+ assert.equal(guardTestCommand("npm run build", config), "npm run build");
52
+ assert.equal(guardTestCommand("npm run lint", config), "npm run lint");
53
+ });
54
+
55
+ it("trims leading spaces and matches", () => {
56
+ assert.equal(guardTestCommand(" npm test", config), "npm test");
57
+ });
58
+
59
+ it("trims trailing spaces and matches", () => {
60
+ assert.equal(guardTestCommand("npm test ", config), "npm test");
61
+ });
62
+
63
+ it("trims and matches with double spaces in middle (trim only affects ends)", () => {
64
+ // "npm test" has double space — trim() only trims ends, so "npm test" != "npm test"
65
+ assert.throws(
66
+ () => guardTestCommand("npm test", config),
67
+ PatchWardenError
68
+ );
69
+ });
70
+
71
+ it("rejects non-allowlisted commands", () => {
72
+ assert.throws(
73
+ () => guardTestCommand("rm -rf /", config),
74
+ PatchWardenError
75
+ );
76
+ assert.throws(
77
+ () => guardTestCommand("curl http://evil.com", config),
78
+ PatchWardenError
79
+ );
80
+ });
81
+
82
+ it("returns empty string for empty or undefined command", () => {
83
+ assert.equal(guardTestCommand("", config), "");
84
+ assert.equal(guardTestCommand(undefined as unknown as string, config), "");
85
+ });
86
+
87
+ it("returns empty string for whitespace-only command", () => {
88
+ assert.equal(guardTestCommand(" ", config), "");
89
+ });
90
+
91
+ it("cannot be bypassed by parameter concatenation", () => {
92
+ // "npm test && rm -rf /" is not in the allowlist
93
+ assert.throws(
94
+ () => guardTestCommand("npm test && rm -rf /", config),
95
+ PatchWardenError
96
+ );
97
+ // "npm test; evil" is not in the allowlist
98
+ assert.throws(
99
+ () => guardTestCommand("npm test; evil", config),
100
+ PatchWardenError
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("guardAgentCommand", () => {
106
+ let tempDir: string;
107
+ let config: PatchWardenConfig;
108
+
109
+ beforeEach(() => {
110
+ tempDir = mkdtempSync(join(tmpdir(), "pw-agentguard-"));
111
+ config = makeConfig(tempDir);
112
+ });
113
+
114
+ afterEach(() => {
115
+ rmSync(tempDir, { recursive: true, force: true });
116
+ });
117
+
118
+ it("allows configured agents", () => {
119
+ const result = guardAgentCommand("codex", config);
120
+ assert.equal(result.command, "codex");
121
+ assert.deepEqual(result.args, ["exec", "{repo}", "{prompt}"]);
122
+
123
+ const result2 = guardAgentCommand("opencode", config);
124
+ assert.equal(result2.command, "opencode");
125
+ });
126
+
127
+ it("rejects unconfigured agents", () => {
128
+ assert.throws(
129
+ () => guardAgentCommand("evil-agent", config),
130
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "agent_not_configured"
131
+ );
132
+ });
133
+
134
+ it("rejects empty agent name", () => {
135
+ assert.throws(
136
+ () => guardAgentCommand("", config),
137
+ PatchWardenError
138
+ );
139
+ });
140
+ });
141
+
142
+ describe("guardDirectCommand", () => {
143
+ let tempDir: string;
144
+ let config: PatchWardenConfig;
145
+
146
+ beforeEach(() => {
147
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directcmd-"));
148
+ config = makeConfig(tempDir);
149
+ });
150
+
151
+ afterEach(() => {
152
+ rmSync(tempDir, { recursive: true, force: true });
153
+ });
154
+
155
+ it("allows commands from direct allowlist", () => {
156
+ assert.equal(guardDirectCommand("npm test", config), "npm test");
157
+ assert.equal(guardDirectCommand("npm run build", config), "npm run build");
158
+ });
159
+
160
+ it("rejects non-allowlisted direct commands", () => {
161
+ assert.throws(
162
+ () => guardDirectCommand("rm -rf /", config),
163
+ PatchWardenError
164
+ );
165
+ });
166
+
167
+ it("rejects empty command", () => {
168
+ assert.throws(
169
+ () => guardDirectCommand("", config),
170
+ PatchWardenError
171
+ );
172
+ });
173
+ });
174
+
175
+ describe("sanitizePromptArg", () => {
176
+ it("removes null bytes", () => {
177
+ const input = "hello\x00world";
178
+ const result = sanitizePromptArg(input);
179
+ assert.equal(result, "helloworld");
180
+ });
181
+
182
+ it("removes control characters (except tab and newline)", () => {
183
+ const input = "hello\x01\x02\x03world";
184
+ const result = sanitizePromptArg(input);
185
+ assert.equal(result, "helloworld");
186
+ });
187
+
188
+ it("preserves tab characters", () => {
189
+ const input = "hello\tworld";
190
+ const result = sanitizePromptArg(input);
191
+ assert.equal(result, "hello\tworld");
192
+ });
193
+
194
+ it("preserves newline characters", () => {
195
+ const input = "hello\nworld";
196
+ const result = sanitizePromptArg(input);
197
+ assert.equal(result, "hello\nworld");
198
+ });
199
+
200
+ it("preserves carriage return", () => {
201
+ const input = "hello\rworld";
202
+ const result = sanitizePromptArg(input);
203
+ assert.equal(result, "hello\rworld");
204
+ });
205
+
206
+ it("preserves normal text", () => {
207
+ const input = "Fix the bug in main.ts";
208
+ const result = sanitizePromptArg(input);
209
+ assert.equal(result, input);
210
+ });
211
+
212
+ it("removes vertical tab and form feed", () => {
213
+ const input = "hello\x0B\x0Cworld";
214
+ const result = sanitizePromptArg(input);
215
+ assert.equal(result, "helloworld");
216
+ });
217
+
218
+ it("handles empty string", () => {
219
+ assert.equal(sanitizePromptArg(""), "");
220
+ });
221
+ });
@@ -0,0 +1,297 @@
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 { guardDirectPath, guardDirectWritePath, guardDirectReadPath, guardDirectPatchSize, guardDirectFileSize, isBinaryFile } from "../../direct/directGuards.js";
7
+ import { PatchWardenError } from "../../errors.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
+ directAllowedCommands: ["npm test"],
29
+ repoDirectAllowedCommands: {},
30
+ };
31
+ }
32
+
33
+ describe("guardDirectPath", () => {
34
+ let tempDir: string;
35
+ let repoPath: string;
36
+
37
+ beforeEach(() => {
38
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directpath-"));
39
+ repoPath = join(tempDir, "my-repo");
40
+ mkdirSync(repoPath, { recursive: true });
41
+ // Set up config so getConfig() works in guardDirectPath
42
+ process.env.PATCHWARDEN_CONFIG = "";
43
+ });
44
+
45
+ afterEach(() => {
46
+ rmSync(tempDir, { recursive: true, force: true });
47
+ delete process.env.PATCHWARDEN_CONFIG;
48
+ });
49
+
50
+ it("allows paths inside repo", () => {
51
+ const result = guardDirectPath("src/main.ts", repoPath, tempDir);
52
+ assert.equal(result, resolve(repoPath, "src/main.ts"));
53
+ });
54
+
55
+ it("allows nested paths inside repo", () => {
56
+ const result = guardDirectPath("src/components/Button.tsx", repoPath, tempDir);
57
+ assert.equal(result, resolve(repoPath, "src/components/Button.tsx"));
58
+ });
59
+
60
+ it("rejects paths outside repo but inside workspace", () => {
61
+ assert.throws(
62
+ () => guardDirectPath("../other-repo/file.ts", repoPath, tempDir),
63
+ PatchWardenError
64
+ );
65
+ });
66
+
67
+ it("rejects paths outside workspace", () => {
68
+ assert.throws(
69
+ () => guardDirectPath("../../../etc/passwd", repoPath, tempDir),
70
+ PatchWardenError
71
+ );
72
+ });
73
+
74
+ it("handles Windows backslash paths", () => {
75
+ const result = guardDirectPath("src\\subdir\\file.ts", repoPath, tempDir);
76
+ assert.ok(result.startsWith(repoPath));
77
+ });
78
+
79
+ it("rejects path traversal with ..", () => {
80
+ assert.throws(
81
+ () => guardDirectPath("src/../../etc/passwd", repoPath, tempDir),
82
+ PatchWardenError
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("guardDirectWritePath", () => {
88
+ let tempDir: string;
89
+ let repoPath: string;
90
+
91
+ beforeEach(() => {
92
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directwrite-"));
93
+ repoPath = join(tempDir, "my-repo");
94
+ mkdirSync(repoPath, { recursive: true });
95
+ mkdirSync(join(repoPath, "src"), { recursive: true });
96
+ });
97
+
98
+ afterEach(() => {
99
+ rmSync(tempDir, { recursive: true, force: true });
100
+ });
101
+
102
+ it("allows writing to source files in repo", () => {
103
+ const result = guardDirectWritePath("src/main.ts", repoPath, tempDir);
104
+ assert.ok(result.startsWith(repoPath));
105
+ });
106
+
107
+ it("blocks node_modules paths", () => {
108
+ assert.throws(
109
+ () => guardDirectWritePath("node_modules/evil/index.js", repoPath, tempDir),
110
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
111
+ );
112
+ });
113
+
114
+ it("blocks nested node_modules paths", () => {
115
+ assert.throws(
116
+ () => guardDirectWritePath("src/node_modules/evil/index.js", repoPath, tempDir),
117
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
118
+ );
119
+ });
120
+
121
+ it("blocks dist paths", () => {
122
+ assert.throws(
123
+ () => guardDirectWritePath("dist/main.js", repoPath, tempDir),
124
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
125
+ );
126
+ });
127
+
128
+ it("blocks release paths", () => {
129
+ assert.throws(
130
+ () => guardDirectWritePath("release/app.exe", repoPath, tempDir),
131
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
132
+ );
133
+ });
134
+
135
+ it("blocks .patchwarden internal paths", () => {
136
+ assert.throws(
137
+ () => guardDirectWritePath(".patchwarden/tasks/evil.json", repoPath, tempDir),
138
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "internal_patchwarden_path_blocked"
139
+ );
140
+ });
141
+
142
+ it("blocks sensitive files", () => {
143
+ assert.throws(
144
+ () => guardDirectWritePath(".env", repoPath, tempDir),
145
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
146
+ );
147
+ assert.throws(
148
+ () => guardDirectWritePath("config.json", repoPath, tempDir),
149
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
150
+ );
151
+ });
152
+
153
+ it("blocks paths outside repo", () => {
154
+ assert.throws(
155
+ () => guardDirectWritePath("../other-repo/file.ts", repoPath, tempDir),
156
+ PatchWardenError
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("guardDirectReadPath", () => {
162
+ let tempDir: string;
163
+ let repoPath: string;
164
+
165
+ beforeEach(() => {
166
+ tempDir = mkdtempSync(join(tmpdir(), "pw-directread-"));
167
+ repoPath = join(tempDir, "my-repo");
168
+ mkdirSync(repoPath, { recursive: true });
169
+ mkdirSync(join(repoPath, "src"), { recursive: true });
170
+ writeFileSync(join(repoPath, "src", "main.ts"), "console.log('hello');", "utf-8");
171
+ });
172
+
173
+ afterEach(() => {
174
+ rmSync(tempDir, { recursive: true, force: true });
175
+ });
176
+
177
+ it("allows reading source files in repo", () => {
178
+ const result = guardDirectReadPath("src/main.ts", repoPath, tempDir);
179
+ assert.ok(result.startsWith(repoPath));
180
+ });
181
+
182
+ it("blocks .patchwarden internal paths", () => {
183
+ assert.throws(
184
+ () => guardDirectReadPath(".patchwarden/tasks/status.json", repoPath, tempDir),
185
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "internal_patchwarden_path_blocked"
186
+ );
187
+ });
188
+
189
+ it("blocks sensitive files", () => {
190
+ assert.throws(
191
+ () => guardDirectReadPath(".env", repoPath, tempDir),
192
+ (err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
193
+ );
194
+ });
195
+ });
196
+
197
+ describe("guardDirectPatchSize", () => {
198
+ let tempDir: string;
199
+
200
+ beforeEach(() => {
201
+ tempDir = mkdtempSync(join(tmpdir(), "pw-patchsize-"));
202
+ process.env.PATCHWARDEN_CONFIG = "";
203
+ });
204
+
205
+ afterEach(() => {
206
+ rmSync(tempDir, { recursive: true, force: true });
207
+ delete process.env.PATCHWARDEN_CONFIG;
208
+ });
209
+
210
+ it("allows patches within size limit", () => {
211
+ // We can't easily call this without a config, so just test the logic
212
+ // guardDirectPatchSize calls getConfig() internally
213
+ // We'll test it indirectly by checking it doesn't throw for small sizes
214
+ // when config is loaded with defaults
215
+ // Skip if no config available
216
+ });
217
+
218
+ it("rejects patches exceeding size limit", () => {
219
+ // This test would need config setup; skip for now
220
+ });
221
+ });
222
+
223
+ describe("guardDirectFileSize", () => {
224
+ it("allows files within size limit", () => {
225
+ // Similar to above, needs config
226
+ });
227
+ });
228
+
229
+ describe("isBinaryFile", () => {
230
+ let tempDir: string;
231
+
232
+ beforeEach(() => {
233
+ tempDir = mkdtempSync(join(tmpdir(), "pw-binary-"));
234
+ });
235
+
236
+ afterEach(() => {
237
+ rmSync(tempDir, { recursive: true, force: true });
238
+ });
239
+
240
+ it("detects binary files by extension", () => {
241
+ assert.equal(isBinaryFile("test.exe"), true);
242
+ assert.equal(isBinaryFile("test.dll"), true);
243
+ assert.equal(isBinaryFile("test.zip"), true);
244
+ assert.equal(isBinaryFile("test.png"), true);
245
+ assert.equal(isBinaryFile("test.pdf"), true);
246
+ assert.equal(isBinaryFile("test.jar"), true);
247
+ assert.equal(isBinaryFile("test.pak"), true);
248
+ });
249
+
250
+ it("detects binary files with Windows backslash paths", () => {
251
+ assert.equal(isBinaryFile("path\\to\\test.exe"), true);
252
+ assert.equal(isBinaryFile("path\\to\\test.dll"), true);
253
+ });
254
+
255
+ it("does not flag text files as binary by extension", () => {
256
+ assert.equal(isBinaryFile("test.ts"), false);
257
+ assert.equal(isBinaryFile("test.js"), false);
258
+ assert.equal(isBinaryFile("test.md"), false);
259
+ assert.equal(isBinaryFile("test.json"), false);
260
+ assert.equal(isBinaryFile("test.txt"), false);
261
+ });
262
+
263
+ it("detects binary content by null bytes", () => {
264
+ const binaryFile = join(tempDir, "test.dat");
265
+ // Write a file with null bytes in the first 8KB
266
+ const buffer = Buffer.alloc(100, 0x41); // 'A' characters
267
+ buffer[50] = 0; // null byte
268
+ writeFileSync(binaryFile, buffer);
269
+ assert.equal(isBinaryFile(binaryFile), true);
270
+ });
271
+
272
+ it("does not flag text content as binary", () => {
273
+ const textFile = join(tempDir, "test.txt");
274
+ writeFileSync(textFile, "This is a text file with no null bytes.", "utf-8");
275
+ assert.equal(isBinaryFile(textFile), false);
276
+ });
277
+
278
+ it("handles 8KB boundary — text file just under 8KB", () => {
279
+ const textFile = join(tempDir, "boundary.txt");
280
+ // Write exactly 8192 bytes of text (no null bytes)
281
+ const content = "A".repeat(8192);
282
+ writeFileSync(textFile, content, "utf-8");
283
+ assert.equal(isBinaryFile(textFile), false);
284
+ });
285
+
286
+ it("handles 8KB boundary — null byte at position 8191", () => {
287
+ const binaryFile = join(tempDir, "boundary.dat");
288
+ const buffer = Buffer.alloc(8192, 0x41);
289
+ buffer[8191] = 0;
290
+ writeFileSync(binaryFile, buffer);
291
+ assert.equal(isBinaryFile(binaryFile), true);
292
+ });
293
+
294
+ it("returns false for non-existent files without binary extension", () => {
295
+ assert.equal(isBinaryFile(join(tempDir, "nonexistent.txt")), false);
296
+ });
297
+ });