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.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +105 -7
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/unit-tests.js +36 -0
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
|
|
5
|
+
// ── Test helpers ──────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Capture everything written to stderr during `fn` and return it as an
|
|
9
|
+
* array of raw chunks (one per `process.stderr.write` call).
|
|
10
|
+
*/
|
|
11
|
+
function captureStderr(fn: () => void): string[] {
|
|
12
|
+
const chunks: string[] = [];
|
|
13
|
+
const original = process.stderr.write;
|
|
14
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
15
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
|
|
16
|
+
return true;
|
|
17
|
+
}) as typeof process.stderr.write;
|
|
18
|
+
try {
|
|
19
|
+
fn();
|
|
20
|
+
} finally {
|
|
21
|
+
process.stderr.write = original;
|
|
22
|
+
}
|
|
23
|
+
return chunks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Capture stderr and return the parsed JSON objects (one per log line).
|
|
28
|
+
*/
|
|
29
|
+
function captureJson(fn: () => void): Record<string, unknown>[] {
|
|
30
|
+
const chunks = captureStderr(fn);
|
|
31
|
+
return chunks
|
|
32
|
+
.flatMap((chunk) => chunk.split("\n"))
|
|
33
|
+
.map((line) => line.trim())
|
|
34
|
+
.filter((line) => line.length > 0)
|
|
35
|
+
.map((line) => JSON.parse(line));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Logger: stderr output ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("Logger — stderr output", () => {
|
|
41
|
+
it("writes JSON log entries to stderr (never stdout)", () => {
|
|
42
|
+
const log = new Logger();
|
|
43
|
+
const entries = captureJson(() => {
|
|
44
|
+
log.info("hello from test", { component: "unit-test" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.equal(entries.length, 1);
|
|
48
|
+
const entry = entries[0];
|
|
49
|
+
|
|
50
|
+
assert.equal(entry.level, "info");
|
|
51
|
+
assert.equal(entry.message, "hello from test");
|
|
52
|
+
assert.equal(entry.component, "unit-test");
|
|
53
|
+
assert.equal(typeof entry.timestamp, "string");
|
|
54
|
+
// timestamp must be a valid ISO date
|
|
55
|
+
assert.ok(!Number.isNaN(Date.parse(entry.timestamp as string)));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("supports warn and error levels", () => {
|
|
59
|
+
const log = new Logger();
|
|
60
|
+
const entries = captureJson(() => {
|
|
61
|
+
log.warn("careful");
|
|
62
|
+
log.error("broken");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(entries.length, 2);
|
|
66
|
+
assert.equal(entries[0].level, "warn");
|
|
67
|
+
assert.equal(entries[0].message, "careful");
|
|
68
|
+
assert.equal(entries[1].level, "error");
|
|
69
|
+
assert.equal(entries[1].message, "broken");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not write anything to stdout", () => {
|
|
73
|
+
const originalStdout = process.stdout.write;
|
|
74
|
+
let stdoutWritten = false;
|
|
75
|
+
process.stdout.write = (() => {
|
|
76
|
+
stdoutWritten = true;
|
|
77
|
+
return true;
|
|
78
|
+
}) as typeof process.stdout.write;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const log = new Logger();
|
|
82
|
+
log.info("should not touch stdout");
|
|
83
|
+
log.warn("nor this");
|
|
84
|
+
log.error("nor this");
|
|
85
|
+
} finally {
|
|
86
|
+
process.stdout.write = originalStdout;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assert.equal(stdoutWritten, false, "Logger must never write to stdout");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Logger: audit ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("Logger.audit — format", () => {
|
|
96
|
+
it("produces an audit log entry with all required fields", () => {
|
|
97
|
+
const log = new Logger();
|
|
98
|
+
const entries = captureJson(() => {
|
|
99
|
+
log.audit("createTask", true, 150, undefined, "task-001");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(entries.length, 1);
|
|
103
|
+
const entry = entries[0];
|
|
104
|
+
|
|
105
|
+
// Required core fields
|
|
106
|
+
assert.equal(entry.level, "audit");
|
|
107
|
+
assert.equal(typeof entry.timestamp, "string");
|
|
108
|
+
assert.ok(!Number.isNaN(Date.parse(entry.timestamp as string)));
|
|
109
|
+
assert.equal(typeof entry.message, "string");
|
|
110
|
+
|
|
111
|
+
// Audit-specific fields
|
|
112
|
+
assert.equal(entry.tool, "createTask");
|
|
113
|
+
assert.equal(entry.ok, true);
|
|
114
|
+
assert.equal(entry.duration_ms, 150);
|
|
115
|
+
assert.equal(entry.task_id, "task-001");
|
|
116
|
+
|
|
117
|
+
// error_reason should be absent when not provided
|
|
118
|
+
assert.equal(entry.error_reason, undefined);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("includes error_reason when the tool call failed", () => {
|
|
122
|
+
const log = new Logger();
|
|
123
|
+
const entries = captureJson(() => {
|
|
124
|
+
log.audit("applyPatch", false, 3000, "command_blocked", "task-002");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
assert.equal(entries.length, 1);
|
|
128
|
+
const entry = entries[0];
|
|
129
|
+
|
|
130
|
+
assert.equal(entry.ok, false);
|
|
131
|
+
assert.equal(entry.error_reason, "command_blocked");
|
|
132
|
+
assert.equal(entry.task_id, "task-002");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("omits task_id when not provided", () => {
|
|
136
|
+
const log = new Logger();
|
|
137
|
+
const entries = captureJson(() => {
|
|
138
|
+
log.audit("healthCheck", true, 5);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.equal(entries.length, 1);
|
|
142
|
+
const entry = entries[0];
|
|
143
|
+
assert.equal(entry.task_id, undefined);
|
|
144
|
+
assert.equal(entry.error_reason, undefined);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── Logger: verbose mode ──────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe("Logger — verbose mode (PATCHWARDEN_VERBOSE_LOG)", () => {
|
|
151
|
+
let savedEnv: string | undefined;
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
savedEnv = process.env.PATCHWARDEN_VERBOSE_LOG;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
if (savedEnv === undefined) {
|
|
159
|
+
delete process.env.PATCHWARDEN_VERBOSE_LOG;
|
|
160
|
+
} else {
|
|
161
|
+
process.env.PATCHWARDEN_VERBOSE_LOG = savedEnv;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not log args by default (verbose mode off)", () => {
|
|
166
|
+
delete process.env.PATCHWARDEN_VERBOSE_LOG;
|
|
167
|
+
|
|
168
|
+
const log = new Logger();
|
|
169
|
+
const sensitiveArgs = {
|
|
170
|
+
prompt: "fix the bug",
|
|
171
|
+
token: "ghp_" + "a".repeat(20),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const entries = captureJson(() => {
|
|
175
|
+
log.audit("createTask", true, 100, undefined, "task-003", sensitiveArgs);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.equal(entries.length, 1);
|
|
179
|
+
const entry = entries[0];
|
|
180
|
+
|
|
181
|
+
// args must NOT be present when verbose mode is off
|
|
182
|
+
assert.equal(entry.args, undefined);
|
|
183
|
+
// The raw token must not appear anywhere in the serialized entry
|
|
184
|
+
const raw = JSON.stringify(entry);
|
|
185
|
+
assert.ok(!raw.includes("ghp_"), "raw token must not leak into log output");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("logs sanitized args when PATCHWARDEN_VERBOSE_LOG=true", () => {
|
|
189
|
+
process.env.PATCHWARDEN_VERBOSE_LOG = "true";
|
|
190
|
+
|
|
191
|
+
const log = new Logger();
|
|
192
|
+
const sensitiveArgs = {
|
|
193
|
+
prompt: "fix the bug",
|
|
194
|
+
token: "ghp_" + "a".repeat(20),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const entries = captureJson(() => {
|
|
198
|
+
log.audit("createTask", true, 100, undefined, "task-004", sensitiveArgs);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
assert.equal(entries.length, 1);
|
|
202
|
+
const entry = entries[0];
|
|
203
|
+
|
|
204
|
+
// args must be present and redacted
|
|
205
|
+
assert.ok(entry.args !== undefined, "args should be logged in verbose mode");
|
|
206
|
+
const argsStr = String(entry.args);
|
|
207
|
+
assert.ok(argsStr.includes("[REDACTED TOKEN]"), "sensitive token must be redacted");
|
|
208
|
+
assert.ok(!argsStr.includes("ghp_"), "raw token must not appear in args");
|
|
209
|
+
// non-sensitive content is preserved
|
|
210
|
+
assert.ok(argsStr.includes("fix the bug"), "non-sensitive args content is preserved");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("does not log args when PATCHWARDEN_VERBOSE_LOG is set but not 'true'", () => {
|
|
214
|
+
process.env.PATCHWARDEN_VERBOSE_LOG = "false";
|
|
215
|
+
|
|
216
|
+
const log = new Logger();
|
|
217
|
+
const entries = captureJson(() => {
|
|
218
|
+
log.audit("createTask", true, 100, undefined, "task-005", { prompt: "hello" });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
assert.equal(entries.length, 1);
|
|
222
|
+
assert.equal(entries[0].args, undefined);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ── logUnhandledError ─────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe("logUnhandledError", () => {
|
|
229
|
+
it("produces structured JSON output for Error instances", () => {
|
|
230
|
+
const error = new Error("something went wrong");
|
|
231
|
+
error.name = "CustomError";
|
|
232
|
+
|
|
233
|
+
const entries = captureJson(() => {
|
|
234
|
+
logUnhandledError(error);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
assert.equal(entries.length, 1);
|
|
238
|
+
const entry = entries[0];
|
|
239
|
+
|
|
240
|
+
assert.equal(entry.level, "error");
|
|
241
|
+
assert.equal(entry.message, "unhandled_error");
|
|
242
|
+
assert.equal(entry.error, "something went wrong");
|
|
243
|
+
assert.equal(entry.error_name, "CustomError");
|
|
244
|
+
assert.equal(typeof entry.timestamp, "string");
|
|
245
|
+
assert.ok(!Number.isNaN(Date.parse(entry.timestamp as string)));
|
|
246
|
+
assert.equal(typeof entry.stack, "string");
|
|
247
|
+
assert.ok((entry.stack as string).includes("something went wrong"));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles non-Error values (string)", () => {
|
|
251
|
+
const entries = captureJson(() => {
|
|
252
|
+
logUnhandledError("a plain string rejection");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
assert.equal(entries.length, 1);
|
|
256
|
+
const entry = entries[0];
|
|
257
|
+
|
|
258
|
+
assert.equal(entry.level, "error");
|
|
259
|
+
assert.equal(entry.error, "a plain string rejection");
|
|
260
|
+
assert.equal(entry.error_name, "string");
|
|
261
|
+
assert.equal(entry.stack, undefined);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("handles non-Error values (object)", () => {
|
|
265
|
+
const entries = captureJson(() => {
|
|
266
|
+
logUnhandledError({ code: 42, detail: "weird" });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
assert.equal(entries.length, 1);
|
|
270
|
+
const entry = entries[0];
|
|
271
|
+
|
|
272
|
+
assert.equal(entry.level, "error");
|
|
273
|
+
assert.ok(String(entry.error).includes("42"));
|
|
274
|
+
assert.equal(entry.error_name, "object");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ── installGlobalHandlers ─────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe("installGlobalHandlers", () => {
|
|
281
|
+
it("does not throw when called", () => {
|
|
282
|
+
// Save existing listeners so we can restore them afterwards and avoid
|
|
283
|
+
// the fatal `uncaughtException` handler interfering with the test
|
|
284
|
+
// runner.
|
|
285
|
+
const origUnhandled = process.listeners("unhandledRejection");
|
|
286
|
+
const origUncaught = process.listeners("uncaughtException");
|
|
287
|
+
|
|
288
|
+
process.removeAllListeners("unhandledRejection");
|
|
289
|
+
process.removeAllListeners("uncaughtException");
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
assert.doesNotThrow(() => installGlobalHandlers());
|
|
293
|
+
|
|
294
|
+
// Verify the handlers were actually registered
|
|
295
|
+
assert.ok(process.listenerCount("unhandledRejection") >= 1);
|
|
296
|
+
assert.ok(process.listenerCount("uncaughtException") >= 1);
|
|
297
|
+
} finally {
|
|
298
|
+
// Restore original listeners
|
|
299
|
+
process.removeAllListeners("unhandledRejection");
|
|
300
|
+
process.removeAllListeners("uncaughtException");
|
|
301
|
+
for (const fn of origUnhandled) process.on("unhandledRejection", fn);
|
|
302
|
+
for (const fn of origUncaught) process.on("uncaughtException", fn);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("can be called multiple times without throwing", () => {
|
|
307
|
+
const origUnhandled = process.listeners("unhandledRejection");
|
|
308
|
+
const origUncaught = process.listeners("uncaughtException");
|
|
309
|
+
|
|
310
|
+
process.removeAllListeners("unhandledRejection");
|
|
311
|
+
process.removeAllListeners("uncaughtException");
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
assert.doesNotThrow(() => {
|
|
315
|
+
installGlobalHandlers();
|
|
316
|
+
installGlobalHandlers();
|
|
317
|
+
});
|
|
318
|
+
} finally {
|
|
319
|
+
process.removeAllListeners("unhandledRejection");
|
|
320
|
+
process.removeAllListeners("uncaughtException");
|
|
321
|
+
for (const fn of origUnhandled) process.on("unhandledRejection", fn);
|
|
322
|
+
for (const fn of origUncaught) process.on("uncaughtException", fn);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|