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,345 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ existsSync,
5
+ readFileSync,
6
+ } from "node:fs";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { resolve, join } from "node:path";
9
+ import {
10
+ getDirectSessionsDir,
11
+ getConfig,
12
+ getRepoDirectAllowedCommands,
13
+ PatchWardenConfig,
14
+ } from "../config.js";
15
+ import { guardPath } from "../security/pathGuard.js";
16
+ import { PatchWardenError } from "../errors.js";
17
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
18
+ import { getLastToolCatalogSnapshot } from "../tools/toolCatalog.js";
19
+ import {
20
+ captureRepoSnapshot,
21
+ buildChangeArtifacts,
22
+ type RepoSnapshot,
23
+ type ChangeArtifacts,
24
+ } from "../runner/changeCapture.js";
25
+
26
+ // ── Types ──────────────────────────────────────────────────────────
27
+
28
+ export interface DirectSessionOperation {
29
+ index: number;
30
+ timestamp: string;
31
+ path: string;
32
+ before_sha256: string;
33
+ after_sha256: string;
34
+ operations_applied: number;
35
+ bytes_changed: number;
36
+ }
37
+
38
+ export interface DirectSessionVerificationRun {
39
+ command: string;
40
+ exit_code: number | null;
41
+ passed: boolean;
42
+ timed_out: boolean;
43
+ stdout_tail: string;
44
+ stderr_tail: string;
45
+ started_at: string;
46
+ finished_at: string;
47
+ log_path: string;
48
+ }
49
+
50
+ export interface DirectSessionRecord {
51
+ session_id: string;
52
+ title: string;
53
+ repo_path: string;
54
+ resolved_repo_path: string;
55
+ created_at: string;
56
+ expires_at: string;
57
+ server_version: string;
58
+ schema_epoch: string;
59
+ tool_manifest_sha256: string;
60
+ workspace_snapshot_before: RepoSnapshot;
61
+ workspace_fingerprint_before: string;
62
+ allowed_commands: string[];
63
+ operations: DirectSessionOperation[];
64
+ verification_runs: DirectSessionVerificationRun[];
65
+ finalized: boolean;
66
+ finalized_at: string | null;
67
+ audited: boolean;
68
+ change_artifacts: ChangeArtifacts | null;
69
+ }
70
+
71
+ export interface DirectSessionCreateInput {
72
+ repo_path: string;
73
+ resolved_repo_path: string;
74
+ title?: string;
75
+ snapshot: RepoSnapshot;
76
+ }
77
+
78
+ // ── ID generation ──────────────────────────────────────────────────
79
+
80
+ export function generateDirectSessionId(): string {
81
+ const timestamp = new Date()
82
+ .toISOString()
83
+ .replace(/[-:]/g, "")
84
+ .replace("T", "_")
85
+ .slice(0, 15);
86
+ const randomHex = randomBytes(16).toString("hex");
87
+ return `direct_${timestamp}_${randomHex}`;
88
+ }
89
+
90
+ // ── Directory management ───────────────────────────────────────────
91
+
92
+ export function createDirectSessionDir(sessionId: string): string {
93
+ const config = getConfig();
94
+ const sessionsDir = getDirectSessionsDir(config);
95
+ guardPath(sessionsDir, config.workspaceRoot, config.directSessionsDir);
96
+ mkdirSync(sessionsDir, { recursive: true });
97
+ const dir = resolve(sessionsDir, sessionId);
98
+ guardPath(dir, config.workspaceRoot, config.directSessionsDir);
99
+ mkdirSync(dir, { recursive: true });
100
+ return dir;
101
+ }
102
+
103
+ export function getDirectSessionDir(sessionId: string): string {
104
+ const config = getConfig();
105
+ const sessionsDir = getDirectSessionsDir(config);
106
+ return resolve(sessionsDir, sessionId);
107
+ }
108
+
109
+ // ── CRUD ───────────────────────────────────────────────────────────
110
+
111
+ export function createDirectSession(
112
+ input: DirectSessionCreateInput
113
+ ): DirectSessionRecord {
114
+ const config = getConfig();
115
+
116
+ const sessionId = generateDirectSessionId();
117
+ const dir = createDirectSessionDir(sessionId);
118
+
119
+ const toolManifest =
120
+ getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
121
+ computeFallbackManifestHash(config);
122
+
123
+ const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
124
+
125
+ const allowedCommands = [
126
+ ...(config.directAllowedCommands || []),
127
+ ...getRepoDirectAllowedCommands(config, input.resolved_repo_path),
128
+ ];
129
+
130
+ const now = new Date();
131
+ const expiresAt = new Date(
132
+ now.getTime() + config.directSessionTtlSeconds * 1000
133
+ ).toISOString();
134
+
135
+ const record: DirectSessionRecord = {
136
+ session_id: sessionId,
137
+ title: input.title || "",
138
+ repo_path: input.repo_path,
139
+ resolved_repo_path: input.resolved_repo_path,
140
+ created_at: now.toISOString(),
141
+ expires_at: expiresAt,
142
+ server_version: PATCHWARDEN_VERSION,
143
+ schema_epoch: TOOL_SCHEMA_EPOCH,
144
+ tool_manifest_sha256: toolManifest,
145
+ workspace_snapshot_before: input.snapshot,
146
+ workspace_fingerprint_before: workspaceFingerprint,
147
+ allowed_commands: allowedCommands,
148
+ operations: [],
149
+ verification_runs: [],
150
+ finalized: false,
151
+ finalized_at: null,
152
+ audited: false,
153
+ change_artifacts: null,
154
+ };
155
+
156
+ writeFileSync(
157
+ join(dir, "session.json"),
158
+ JSON.stringify(record, null, 2),
159
+ "utf-8"
160
+ );
161
+
162
+ return record;
163
+ }
164
+
165
+ export function readDirectSession(sessionId: string): DirectSessionRecord {
166
+ const config = getConfig();
167
+ const sessionsDir = getDirectSessionsDir(config);
168
+ const dir = resolve(sessionsDir, sessionId);
169
+ const file = join(dir, "session.json");
170
+ guardPath(file, config.workspaceRoot, config.directSessionsDir);
171
+
172
+ if (!existsSync(file)) {
173
+ throw new PatchWardenError(
174
+ "session_not_found",
175
+ `Direct session "${sessionId}" not found.`,
176
+ "Call create_direct_session first to get a valid session_id.",
177
+ true,
178
+ { session_id: sessionId }
179
+ );
180
+ }
181
+
182
+ return JSON.parse(readFileSync(file, "utf-8")) as DirectSessionRecord;
183
+ }
184
+
185
+ export function updateDirectSession(
186
+ sessionId: string,
187
+ updates: Partial<DirectSessionRecord>
188
+ ): DirectSessionRecord {
189
+ const session = readDirectSession(sessionId);
190
+ const updated: DirectSessionRecord = { ...session, ...updates };
191
+ const dir = getDirectSessionDir(sessionId);
192
+ writeFileSync(
193
+ join(dir, "session.json"),
194
+ JSON.stringify(updated, null, 2),
195
+ "utf-8"
196
+ );
197
+ return updated;
198
+ }
199
+
200
+ export function appendDirectSessionOperation(
201
+ sessionId: string,
202
+ operation: DirectSessionOperation
203
+ ): DirectSessionRecord {
204
+ const session = readDirectSession(sessionId);
205
+ const operations = [...session.operations, operation];
206
+ return updateDirectSession(sessionId, { operations });
207
+ }
208
+
209
+ export function appendDirectSessionVerificationRun(
210
+ sessionId: string,
211
+ run: DirectSessionVerificationRun
212
+ ): DirectSessionRecord {
213
+ const session = readDirectSession(sessionId);
214
+ const verification_runs = [...session.verification_runs, run];
215
+ return updateDirectSession(sessionId, { verification_runs });
216
+ }
217
+
218
+ // ── Validation ─────────────────────────────────────────────────────
219
+
220
+ export interface DirectSessionValidationResult {
221
+ valid: boolean;
222
+ failure_reason: string | null;
223
+ session: DirectSessionRecord | null;
224
+ }
225
+
226
+ export function validateDirectSessionFreshness(
227
+ sessionId: string
228
+ ): DirectSessionValidationResult {
229
+ let session: DirectSessionRecord;
230
+ try {
231
+ session = readDirectSession(sessionId);
232
+ } catch (e) {
233
+ return {
234
+ valid: false,
235
+ failure_reason: extractReason(e),
236
+ session: null,
237
+ };
238
+ }
239
+
240
+ if (new Date(session.expires_at) < new Date()) {
241
+ return { valid: false, failure_reason: "session_expired", session };
242
+ }
243
+
244
+ const currentManifest =
245
+ getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
246
+ computeFallbackManifestHash(getConfig());
247
+ if (currentManifest !== session.tool_manifest_sha256) {
248
+ return {
249
+ valid: false,
250
+ failure_reason: "session_stale_config",
251
+ session,
252
+ };
253
+ }
254
+
255
+ return { valid: true, failure_reason: null, session };
256
+ }
257
+
258
+ // ── Finalization ───────────────────────────────────────────────────
259
+
260
+ export function finalizeDirectSessionRecord(
261
+ sessionId: string,
262
+ afterSnapshot: RepoSnapshot,
263
+ changeArtifacts: ChangeArtifacts
264
+ ): DirectSessionRecord {
265
+ const dir = getDirectSessionDir(sessionId);
266
+
267
+ // Write change artifacts to session directory
268
+ writeFileSync(
269
+ join(dir, "changed-files.json"),
270
+ JSON.stringify(changeArtifacts.changed_files, null, 2),
271
+ "utf-8"
272
+ );
273
+ writeFileSync(join(dir, "diff.patch"), changeArtifacts.diff, "utf-8");
274
+
275
+ const summary = {
276
+ changed_files_total: changeArtifacts.changed_files.length,
277
+ additions: changeArtifacts.additions,
278
+ deletions: changeArtifacts.deletions,
279
+ patch_mode: changeArtifacts.patch_mode,
280
+ artifact_hygiene: changeArtifacts.artifact_hygiene,
281
+ };
282
+ writeFileSync(
283
+ join(dir, "summary.json"),
284
+ JSON.stringify(summary, null, 2),
285
+ "utf-8"
286
+ );
287
+ writeFileSync(join(dir, "summary.md"), formatSummaryMd(changeArtifacts), "utf-8");
288
+
289
+ return updateDirectSession(sessionId, {
290
+ finalized: true,
291
+ finalized_at: new Date().toISOString(),
292
+ change_artifacts: changeArtifacts,
293
+ });
294
+ }
295
+
296
+ // ── Helpers ────────────────────────────────────────────────────────
297
+
298
+ export function computeWorkspaceFingerprint(snapshot: RepoSnapshot): string {
299
+ const fileHashes = Object.entries(snapshot.files)
300
+ .sort(([a], [b]) => a.localeCompare(b))
301
+ .map(([path, fp]) => `${path}:${fp.sha256}`)
302
+ .join("\n");
303
+ return createHash("sha256")
304
+ .update(`${snapshot.head || "null"}\n${snapshot.status}\n${fileHashes}`)
305
+ .digest("hex");
306
+ }
307
+
308
+ function computeFallbackManifestHash(config: PatchWardenConfig): string {
309
+ return createHash("sha256")
310
+ .update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
311
+ .digest("hex");
312
+ }
313
+
314
+ function extractReason(error: unknown): string {
315
+ if (error instanceof PatchWardenError) return error.reason;
316
+ if (error instanceof Error) return error.message.slice(0, 80);
317
+ return String(error).slice(0, 80);
318
+ }
319
+
320
+ function formatSummaryMd(artifacts: ChangeArtifacts): string {
321
+ const lines: string[] = [
322
+ "# Direct Session Change Summary",
323
+ "",
324
+ `**Changed files:** ${artifacts.changed_files.length}`,
325
+ `**Additions:** ${artifacts.additions}`,
326
+ `**Deletions:** ${artifacts.deletions}`,
327
+ `**Patch mode:** ${artifacts.patch_mode}`,
328
+ "",
329
+ "## Artifact Hygiene",
330
+ "",
331
+ `- Source changes: ${artifacts.artifact_hygiene.counts.source_changes}`,
332
+ `- Tracked build artifacts: ${artifacts.artifact_hygiene.counts.tracked_build_artifacts}`,
333
+ `- Runtime generated files: ${artifacts.artifact_hygiene.counts.runtime_generated_files}`,
334
+ `- Suspicious changes: ${artifacts.artifact_hygiene.counts.suspicious_changes}`,
335
+ "",
336
+ "## Changed Files",
337
+ "",
338
+ ];
339
+
340
+ for (const file of artifacts.changed_files) {
341
+ lines.push(`- **${file.change}**: ${file.path}${file.old_path ? ` (from ${file.old_path})` : ""}`);
342
+ }
343
+
344
+ return lines.join("\n");
345
+ }
@@ -0,0 +1,138 @@
1
+ import { writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ import { guardDirectCommand } from "../security/commandGuard.js";
5
+ import { redactSensitiveContent } from "../security/contentRedaction.js";
6
+ import { runSimpleProcess } from "../runner/simpleProcess.js";
7
+ import { getDirectSessionDir } from "./directSessionStore.js";
8
+ import type { DirectSessionVerificationRun } from "./directSessionStore.js";
9
+
10
+ const MAX_TAIL_CHARS = 10_000;
11
+ const DEFAULT_TIMEOUT_SECONDS = 120;
12
+
13
+ export interface RunDirectVerificationInput {
14
+ command: string;
15
+ resolvedRepoPath: string;
16
+ sessionId: string;
17
+ timeoutSeconds?: number;
18
+ }
19
+
20
+ export interface RunDirectVerificationResult {
21
+ run: DirectSessionVerificationRun;
22
+ log_path: string;
23
+ }
24
+
25
+ export async function runDirectVerification(
26
+ input: RunDirectVerificationInput
27
+ ): Promise<RunDirectVerificationResult> {
28
+ const config = getConfig();
29
+ const allowedCommand = guardDirectCommand(
30
+ input.command,
31
+ config,
32
+ input.resolvedRepoPath
33
+ );
34
+
35
+ const timeoutSeconds = input.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
36
+ const timeoutMs = timeoutSeconds * 1000;
37
+
38
+ // Parse command into command + args
39
+ const parts = parseCommand(allowedCommand);
40
+ if (!parts) {
41
+ throw new Error(`Failed to parse command: "${allowedCommand}"`);
42
+ }
43
+
44
+ const sessionDir = getDirectSessionDir(input.sessionId);
45
+ const logPath = join(sessionDir, "verification.log");
46
+
47
+ const startedAt = new Date().toISOString();
48
+ const result = await runSimpleProcess({
49
+ command: parts.command,
50
+ args: parts.args,
51
+ cwd: input.resolvedRepoPath,
52
+ timeoutMs,
53
+ stdoutPath: logPath,
54
+ stderrPath: logPath,
55
+ });
56
+ const finishedAt = new Date().toISOString();
57
+
58
+ // Redact and truncate output
59
+ const stdoutRedacted = redactSensitiveContent(result.stdout || "");
60
+ const stderrRedacted = redactSensitiveContent(result.stderr || "");
61
+
62
+ const stdoutTail = truncateTail(stdoutRedacted.content, MAX_TAIL_CHARS);
63
+ const stderrTail = truncateTail(stderrRedacted.content, MAX_TAIL_CHARS);
64
+
65
+ const passed = result.exitCode === 0 && !result.timedOut;
66
+
67
+ const run: DirectSessionVerificationRun = {
68
+ command: allowedCommand,
69
+ exit_code: result.exitCode,
70
+ passed,
71
+ timed_out: result.timedOut,
72
+ stdout_tail: stdoutTail,
73
+ stderr_tail: stderrTail,
74
+ started_at: startedAt,
75
+ finished_at: finishedAt,
76
+ log_path: logPath,
77
+ };
78
+
79
+ // Write verification.json
80
+ const verificationJson = {
81
+ command: allowedCommand,
82
+ exit_code: result.exitCode,
83
+ passed,
84
+ timed_out: result.timedOut,
85
+ started_at: startedAt,
86
+ finished_at: finishedAt,
87
+ duration_ms: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
88
+ stdout_truncated: result.stdoutTruncated,
89
+ stderr_truncated: result.stderrTruncated,
90
+ redacted: stdoutRedacted.redacted || stderrRedacted.redacted,
91
+ redaction_categories: [
92
+ ...new Set([
93
+ ...stdoutRedacted.redaction_categories,
94
+ ...stderrRedacted.redaction_categories,
95
+ ]),
96
+ ],
97
+ spawn_error: result.spawnError,
98
+ };
99
+
100
+ writeFileSync(
101
+ join(sessionDir, "verification.json"),
102
+ JSON.stringify(verificationJson, null, 2),
103
+ "utf-8"
104
+ );
105
+
106
+ return { run, log_path: logPath };
107
+ }
108
+
109
+ // ── Helpers ────────────────────────────────────────────────────────
110
+
111
+ function parseCommand(
112
+ command: string
113
+ ): { command: string; args: string[] } | null {
114
+ const trimmed = command.trim();
115
+ if (!trimmed) return null;
116
+
117
+ // Simple split by spaces - sufficient for allowlisted commands like "npm test"
118
+ const parts = trimmed.split(/\s+/);
119
+
120
+ // On Windows, npm/npx are batch files (.cmd) and spawn() cannot find them
121
+ // without shell resolution. Wrap with cmd.exe /c for those commands.
122
+ if (process.platform === "win32" && (parts[0] === "npm" || parts[0] === "npx")) {
123
+ return {
124
+ command: "cmd",
125
+ args: ["/c", ...parts],
126
+ };
127
+ }
128
+
129
+ return {
130
+ command: parts[0],
131
+ args: parts.slice(1),
132
+ };
133
+ }
134
+
135
+ function truncateTail(content: string, maxChars: number): string {
136
+ if (content.length <= maxChars) return content;
137
+ return "...(truncated)\n" + content.slice(content.length - maxChars);
138
+ }
package/src/doctor.ts CHANGED
@@ -18,7 +18,7 @@ import { isSensitivePath } from "./security/sensitiveGuard.js";
18
18
  import { guardPlanContent } from "./security/planGuard.js";
19
19
  import { TASK_READ_ONLY_FILES } from "./tools/getTaskFile.js";
20
20
  import { getToolDefs } from "./tools/registry.js";
21
- import { CHATGPT_CORE_TOOL_NAMES, selectToolsForProfile } from "./tools/toolCatalog.js";
21
+ import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES, selectToolsForProfile } from "./tools/toolCatalog.js";
22
22
  import { PATCHWARDEN_VERSION } from "./version.js";
23
23
 
24
24
  // ── State ──────────────────────────────────────────────────────────
@@ -65,6 +65,7 @@ function cmd(cmdStr: string): string {
65
65
  async function main() {
66
66
 
67
67
  console.log("PatchWarden Doctor\n");
68
+ const allowDefaultConfig = process.argv.includes("--allow-default-config");
68
69
 
69
70
  // 1. Node version
70
71
  const nodeVer = process.version;
@@ -76,6 +77,24 @@ check("Node.js version", nodeMajor >= 18,
76
77
  const npmVer = cmd("npm --version");
77
78
  check("npm available", npmVer !== "", npmVer || "npm not found in PATH");
78
79
 
80
+ // Local-only medium-risk confirmation entrypoint. It must exist as a package
81
+ // binary but must not be exposed as an MCP tool.
82
+ try {
83
+ const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
84
+ check(
85
+ "patchwarden-confirm package binary",
86
+ packageJson.bin?.["patchwarden-confirm"] === "dist/assessments/confirmCli.js",
87
+ packageJson.bin?.["patchwarden-confirm"] || "missing"
88
+ );
89
+ check(
90
+ "patchwarden-confirm compiled entrypoint",
91
+ existsSync(resolve(process.cwd(), "dist/assessments/confirmCli.js")),
92
+ "run npm.cmd run build"
93
+ );
94
+ } catch (error) {
95
+ check("patchwarden-confirm package binary", false, error instanceof Error ? error.message : String(error));
96
+ }
97
+
79
98
  // 3. Git
80
99
  const gitVer = cmd("git --version");
81
100
  warnCheck("Git available", gitVer !== "",
@@ -92,10 +111,14 @@ for (const p of configPaths) {
92
111
  if (existsSync(p)) { configPathUsed = p; break; }
93
112
  }
94
113
 
95
- check("Config file exists", configPathUsed !== "",
96
- configPathUsed
97
- ? configPathUsed
98
- : 'Create one: cp examples/config.example.json patchwarden.config.json');
114
+ const configDetail = configPathUsed
115
+ ? configPathUsed
116
+ : 'Create one: cp examples/config.example.json patchwarden.config.json';
117
+ if (allowDefaultConfig) {
118
+ warnCheck("Config file exists", configPathUsed !== "", configDetail);
119
+ } else {
120
+ check("Config file exists", configPathUsed !== "", configDetail);
121
+ }
99
122
 
100
123
  // 5. PATCHWARDEN_CONFIG env
101
124
  if (process.env.PATCHWARDEN_CONFIG) {
@@ -219,10 +242,10 @@ const previousProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
219
242
  try {
220
243
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
221
244
  const fullTools = getToolDefs();
222
- const coreTools = selectToolsForProfile(fullTools, "chatgpt_core");
245
+ const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", config?.enableDirectProfile);
223
246
  const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema as any;
224
247
  const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema as any;
225
- check("Full tool profile exposes 22 tools", fullTools.length === 22, `${fullTools.length} tools`);
248
+ check("Full tool profile exposes 30 tools", fullTools.length === 30, `${fullTools.length} tools`);
226
249
  check(
227
250
  `chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`,
228
251
  JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES),
@@ -237,6 +260,26 @@ try {
237
260
  waitSchema?.properties?.wait_seconds
238
261
  )
239
262
  );
263
+
264
+ // chatgpt_direct profile checks
265
+ const directDisabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", false);
266
+ check(
267
+ "chatgpt_direct disabled exposes only health_check (degraded mode)",
268
+ directDisabledTools.length === 1 && directDisabledTools[0].name === "health_check",
269
+ `${directDisabledTools.map((t) => t.name).join(", ")}`
270
+ );
271
+
272
+ if (config?.enableDirectProfile) {
273
+ const directEnabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", true);
274
+ check(
275
+ `chatgpt_direct enabled exposes the exact ${CHATGPT_DIRECT_TOOL_NAMES.length}-tool manifest`,
276
+ JSON.stringify(directEnabledTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES),
277
+ directEnabledTools.map((tool) => tool.name).join(", ")
278
+ );
279
+ } else {
280
+ results.push(`[OK] chatgpt_direct enabled check skipped (enableDirectProfile=false)`);
281
+ ok++;
282
+ }
240
283
  } finally {
241
284
  if (previousProfile === undefined) delete process.env.PATCHWARDEN_TOOL_PROFILE;
242
285
  else process.env.PATCHWARDEN_TOOL_PROFILE = previousProfile;
@@ -290,6 +333,33 @@ for (const t of newTools) {
290
333
  check(`Tool module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
291
334
  }
292
335
 
336
+ // Direct tool module checks
337
+ const directToolModules = [
338
+ "createDirectSession",
339
+ "searchWorkspace",
340
+ "applyPatch",
341
+ "runVerification",
342
+ "finalizeDirectSession",
343
+ "auditSession",
344
+ ];
345
+ for (const t of directToolModules) {
346
+ const compiled = resolve(process.cwd(), "dist/tools", `${t}.js`);
347
+ check(`Direct tool module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
348
+ }
349
+
350
+ // Direct support module checks
351
+ const directSupportModules = [
352
+ "directSessionStore",
353
+ "directGuards",
354
+ "directPatch",
355
+ "directVerification",
356
+ "directAudit",
357
+ ];
358
+ for (const t of directSupportModules) {
359
+ const compiled = resolve(process.cwd(), "dist/direct", `${t}.js`);
360
+ check(`Direct support module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
361
+ }
362
+
293
363
  // Task directory writable
294
364
  if (config) {
295
365
  const tasksDir = resolve(config.workspaceRoot, config.tasksDir);
@@ -380,11 +450,37 @@ if (config) {
380
450
  }
381
451
  }
382
452
 
453
+ // 14b. Direct profile config checks
454
+ if (config) {
455
+ const directCmds = config.directAllowedCommands || [];
456
+ warnCheck("directAllowedCommands is non-empty", directCmds.length > 0,
457
+ directCmds.length > 0 ? `${directCmds.length} commands` : "No Direct commands configured");
458
+
459
+ // npm run doctor should NOT be in default Direct whitelist
460
+ const hasDoctor = directCmds.some((c: string) => c === "npm run doctor");
461
+ check("directAllowedCommands does not include npm run doctor", !hasDoctor,
462
+ hasDoctor ? "npm run doctor found in Direct whitelist — remove it for tighter security" : "not present");
463
+
464
+ check("directSessionTtlSeconds is valid",
465
+ config.directSessionTtlSeconds >= 60 && config.directSessionTtlSeconds <= 86400,
466
+ `${config.directSessionTtlSeconds}s`);
467
+
468
+ check("directMaxPatchBytes is positive",
469
+ config.directMaxPatchBytes > 0, `${config.directMaxPatchBytes}`);
470
+
471
+ check("directMaxFileBytes is positive",
472
+ config.directMaxFileBytes > 0, `${config.directMaxFileBytes}`);
473
+ }
474
+
383
475
  // 15. Tunnel example files check
384
476
  const tunnelFiles = [
385
477
  "examples/openai-tunnel/README.md",
386
478
  "examples/openai-tunnel/tunnel-client.example.yaml",
387
479
  "examples/openai-tunnel/chatgpt-test-prompt.md",
480
+ "scripts/patchwarden-mcp-direct.cmd",
481
+ "PatchWarden.cmd",
482
+ "scripts/manage-patchwarden.ps1",
483
+ "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
388
484
  ];
389
485
  for (const tf of tunnelFiles) {
390
486
  const full = resolve(process.cwd(), tf);