patchwarden 1.5.0 → 1.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 (597) hide show
  1. package/PatchWarden.cmd +32 -2
  2. package/README.en.md +194 -26
  3. package/README.md +160 -22
  4. package/dist/assessments/agentAssessor.d.ts +1 -1
  5. package/dist/assessments/agentAssessor.js +24 -15
  6. package/dist/assessments/assessmentStore.d.ts +1 -1
  7. package/dist/assessments/assessmentStore.js +44 -36
  8. package/dist/assessments/confirmCli.js +5 -4
  9. package/dist/config.d.ts +22 -0
  10. package/dist/config.js +98 -11
  11. package/dist/control/fileManager.d.ts +2 -0
  12. package/dist/control/fileManager.js +39 -0
  13. package/dist/control/middleware/auth.d.ts +16 -0
  14. package/dist/control/middleware/auth.js +17 -0
  15. package/dist/control/middleware/static.d.ts +3 -0
  16. package/dist/control/middleware/static.js +78 -0
  17. package/dist/control/routeTable.d.ts +24 -0
  18. package/dist/control/routeTable.js +263 -0
  19. package/dist/control/routes/audit.d.ts +15 -0
  20. package/dist/control/routes/audit.js +287 -0
  21. package/dist/control/routes/evidence.d.ts +4 -0
  22. package/dist/control/routes/evidence.js +96 -0
  23. package/dist/control/routes/lineage.d.ts +3 -0
  24. package/dist/control/routes/lineage.js +71 -0
  25. package/dist/control/routes/policy.d.ts +3 -0
  26. package/dist/control/routes/policy.js +81 -0
  27. package/dist/control/routes/process.d.ts +12 -0
  28. package/dist/control/routes/process.js +487 -0
  29. package/dist/control/routes/sessions.d.ts +30 -0
  30. package/dist/control/routes/sessions.js +287 -0
  31. package/dist/control/routes/status.d.ts +51 -0
  32. package/dist/control/routes/status.js +307 -0
  33. package/dist/control/routes/taskActions.d.ts +21 -0
  34. package/dist/control/routes/taskActions.js +197 -0
  35. package/dist/control/routes/tasks.d.ts +24 -0
  36. package/dist/control/routes/tasks.js +309 -0
  37. package/dist/control/routes/workspace.d.ts +15 -0
  38. package/dist/control/routes/workspace.js +220 -0
  39. package/dist/control/runtime.d.ts +92 -0
  40. package/dist/control/runtime.js +425 -0
  41. package/dist/control/server.d.ts +13 -0
  42. package/dist/control/server.js +150 -0
  43. package/dist/control/shared.d.ts +59 -0
  44. package/dist/control/shared.js +341 -0
  45. package/dist/controlCenter.d.ts +6 -0
  46. package/dist/controlCenter.js +7 -2197
  47. package/dist/direct/directAudit.js +234 -225
  48. package/dist/direct/directGuards.d.ts +2 -1
  49. package/dist/direct/directGuards.js +72 -37
  50. package/dist/direct/directPatch.d.ts +6 -1
  51. package/dist/direct/directPatch.js +55 -8
  52. package/dist/direct/directSessionStore.d.ts +16 -6
  53. package/dist/direct/directSessionStore.js +127 -35
  54. package/dist/direct/directVerification.js +8 -16
  55. package/dist/doctor.d.ts +18 -1
  56. package/dist/doctor.js +614 -373
  57. package/dist/goal/acceptanceEngine.d.ts +1 -1
  58. package/dist/goal/goalProgress.js +75 -69
  59. package/dist/goal/goalReport.d.ts +54 -0
  60. package/dist/goal/goalReport.js +197 -0
  61. package/dist/goal/goalStatus.d.ts +8 -0
  62. package/dist/goal/goalStatus.js +10 -3
  63. package/dist/goal/goalStore.d.ts +9 -2
  64. package/dist/goal/goalStore.js +152 -45
  65. package/dist/goal/handoffExport.js +6 -6
  66. package/dist/goal/specKitImport.d.ts +63 -0
  67. package/dist/goal/specKitImport.js +221 -0
  68. package/dist/goal/subgoalSync.js +13 -10
  69. package/dist/goal/worktreeManager.d.ts +2 -0
  70. package/dist/goal/worktreeManager.js +209 -136
  71. package/dist/httpServer.js +126 -44
  72. package/dist/index.js +7 -4
  73. package/dist/logging.d.ts +7 -1
  74. package/dist/logging.js +13 -3
  75. package/dist/policy/projectPolicy.js +5 -2
  76. package/dist/release/releaseGate.d.ts +1 -1
  77. package/dist/release/releaseGate.js +78 -25
  78. package/dist/runner/agentInvocation.d.ts +14 -0
  79. package/dist/runner/agentInvocation.js +96 -4
  80. package/dist/runner/changeCapture.d.ts +14 -6
  81. package/dist/runner/changeCapture.js +287 -81
  82. package/dist/runner/cli.js +7 -6
  83. package/dist/runner/postTaskCleanup.js +60 -7
  84. package/dist/runner/processSecurity.d.ts +56 -0
  85. package/dist/runner/processSecurity.js +373 -0
  86. package/dist/runner/runTask.d.ts +1 -1
  87. package/dist/runner/runTask.js +339 -287
  88. package/dist/runner/simpleProcess.d.ts +3 -0
  89. package/dist/runner/simpleProcess.js +52 -38
  90. package/dist/{taskProgress.d.ts → runner/taskProgress.d.ts} +1 -1
  91. package/dist/{taskProgress.js → runner/taskProgress.js} +2 -2
  92. package/dist/{taskRuntime.d.ts → runner/taskRuntime.d.ts} +1 -1
  93. package/dist/{taskRuntime.js → runner/taskRuntime.js} +3 -2
  94. package/dist/runner/taskStatusStore.d.ts +11 -0
  95. package/dist/runner/taskStatusStore.js +24 -0
  96. package/dist/runner/watch.d.ts +14 -1
  97. package/dist/runner/watch.js +265 -54
  98. package/dist/security/contentRedaction.d.ts +6 -0
  99. package/dist/security/contentRedaction.js +24 -2
  100. package/dist/security/discoveryTokenStore.d.ts +3 -2
  101. package/dist/security/discoveryTokenStore.js +17 -2
  102. package/dist/security/loopbackHost.d.ts +2 -0
  103. package/dist/security/loopbackHost.js +16 -0
  104. package/dist/security/pathGuard.js +32 -17
  105. package/dist/security/planGuard.js +2 -1
  106. package/dist/security/runtimeGuard.js +9 -6
  107. package/dist/security/secretComparison.d.ts +3 -0
  108. package/dist/security/secretComparison.js +11 -0
  109. package/dist/security/sensitiveGuard.d.ts +5 -0
  110. package/dist/security/sensitiveGuard.js +27 -8
  111. package/dist/security/toolInvocationGuard.d.ts +2 -2
  112. package/dist/security/toolInvocationGuard.js +1 -1
  113. package/dist/security/workspaceRootGuard.d.ts +8 -0
  114. package/dist/security/workspaceRootGuard.js +47 -0
  115. package/dist/smoke-test.js +280 -273
  116. package/dist/tools/catalog/index.d.ts +4 -0
  117. package/dist/tools/catalog/index.js +4 -0
  118. package/dist/tools/{toolCatalog.js → catalog/toolCatalog.js} +3 -12
  119. package/dist/tools/{toolRegistry.d.ts → catalog/toolRegistry.d.ts} +3 -3
  120. package/dist/tools/{toolRegistry.js → catalog/toolRegistry.js} +28 -15
  121. package/dist/tools/definitions/toolDefs.d.ts +18 -0
  122. package/dist/tools/definitions/toolDefs.js +1177 -0
  123. package/dist/tools/{auditSession.d.ts → diagnostics/auditSession.d.ts} +1 -1
  124. package/dist/tools/{auditSession.js → diagnostics/auditSession.js} +2 -2
  125. package/dist/tools/{auditTask.d.ts → diagnostics/auditTask.d.ts} +8 -63
  126. package/dist/tools/{auditTask.js → diagnostics/auditTask.js} +111 -37
  127. package/dist/tools/{healthCheck.d.ts → diagnostics/healthCheck.d.ts} +6 -6
  128. package/dist/tools/{healthCheck.js → diagnostics/healthCheck.js} +10 -10
  129. package/dist/tools/diagnostics/index.d.ts +6 -0
  130. package/dist/tools/diagnostics/index.js +6 -0
  131. package/dist/tools/{safeStatus.d.ts → diagnostics/safeStatus.d.ts} +3 -3
  132. package/dist/tools/{safeStatus.js → diagnostics/safeStatus.js} +6 -6
  133. package/dist/tools/{safeViews.d.ts → diagnostics/safeViews.d.ts} +14 -10
  134. package/dist/tools/{safeViews.js → diagnostics/safeViews.js} +25 -15
  135. package/dist/tools/{schemaDriftCheck.d.ts → diagnostics/schemaDriftCheck.d.ts} +1 -1
  136. package/dist/tools/{schemaDriftCheck.js → diagnostics/schemaDriftCheck.js} +2 -2
  137. package/dist/tools/{createDirectSession.d.ts → direct/createDirectSession.d.ts} +1 -1
  138. package/dist/tools/{createDirectSession.js → direct/createDirectSession.js} +8 -8
  139. package/dist/tools/{finalizeDirectSession.d.ts → direct/finalizeDirectSession.d.ts} +2 -2
  140. package/dist/tools/direct/finalizeDirectSession.js +86 -0
  141. package/dist/tools/direct/index.d.ts +3 -0
  142. package/dist/tools/direct/index.js +3 -0
  143. package/dist/tools/{runDirectVerificationBundle.js → direct/runDirectVerificationBundle.js} +2 -2
  144. package/dist/tools/{discoverTools.d.ts → discovery/discoverTools.d.ts} +2 -2
  145. package/dist/tools/{discoverTools.js → discovery/discoverTools.js} +5 -5
  146. package/dist/tools/{explainTool.d.ts → discovery/explainTool.d.ts} +2 -2
  147. package/dist/tools/{explainTool.js → discovery/explainTool.js} +2 -2
  148. package/dist/tools/discovery/index.d.ts +3 -0
  149. package/dist/tools/discovery/index.js +3 -0
  150. package/dist/tools/{invokeDiscoveredTool.d.ts → discovery/invokeDiscoveredTool.d.ts} +2 -2
  151. package/dist/tools/{invokeDiscoveredTool.js → discovery/invokeDiscoveredTool.js} +5 -5
  152. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  153. package/dist/tools/dispatch/coreDispatch.js +283 -0
  154. package/dist/tools/dispatch/diagnosticDispatch.d.ts +12 -0
  155. package/dist/tools/dispatch/diagnosticDispatch.js +80 -0
  156. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  157. package/dist/tools/dispatch/directDispatch.js +116 -0
  158. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  159. package/dist/tools/dispatch/goalDispatch.js +92 -0
  160. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  161. package/dist/tools/dispatch/releaseDispatch.js +46 -0
  162. package/dist/tools/dispatch/types.d.ts +23 -0
  163. package/dist/tools/dispatch/types.js +15 -0
  164. package/dist/tools/dispatch/validation.d.ts +7 -0
  165. package/dist/tools/dispatch/validation.js +71 -0
  166. package/dist/tools/{getPlan.js → goals/getPlan.js} +4 -4
  167. package/dist/tools/{goalSubgoalTask.d.ts → goals/goalSubgoalTask.d.ts} +2 -2
  168. package/dist/tools/goals/goalSubgoalTask.js +118 -0
  169. package/dist/tools/goals/index.d.ts +3 -0
  170. package/dist/tools/goals/index.js +3 -0
  171. package/dist/tools/{savePlan.js → goals/savePlan.js} +28 -8
  172. package/dist/tools/registry.d.ts +10 -14
  173. package/dist/tools/registry.js +47 -1651
  174. package/dist/tools/{checkReleaseGate.d.ts → release/checkReleaseGate.d.ts} +1 -1
  175. package/dist/tools/{checkReleaseGate.js → release/checkReleaseGate.js} +3 -3
  176. package/dist/tools/release/index.d.ts +2 -0
  177. package/dist/tools/release/index.js +2 -0
  178. package/dist/tools/{releaseMode.d.ts → release/releaseMode.d.ts} +2 -2
  179. package/dist/tools/{releaseMode.js → release/releaseMode.js} +46 -26
  180. package/dist/tools/tasks/cancelTask.d.ts +11 -0
  181. package/dist/tools/tasks/cancelTask.js +85 -0
  182. package/dist/tools/{createTask.d.ts → tasks/createTask.d.ts} +12 -4
  183. package/dist/tools/{createTask.js → tasks/createTask.js} +46 -31
  184. package/dist/tools/{diagnoseTask.d.ts → tasks/diagnoseTask.d.ts} +1 -1
  185. package/dist/tools/{diagnoseTask.js → tasks/diagnoseTask.js} +8 -8
  186. package/dist/tools/{evidencePack.d.ts → tasks/evidencePack.d.ts} +6 -0
  187. package/dist/tools/tasks/evidencePack.js +387 -0
  188. package/dist/tools/{getTaskFile.js → tasks/getTaskFile.js} +4 -4
  189. package/dist/tools/{getTaskStatus.d.ts → tasks/getTaskStatus.d.ts} +6 -1
  190. package/dist/tools/{getTaskStatus.js → tasks/getTaskStatus.js} +5 -5
  191. package/dist/tools/{getTaskStdoutTail.js → tasks/getTaskStdoutTail.js} +13 -11
  192. package/dist/tools/{getTaskSummary.js → tasks/getTaskSummary.js} +33 -26
  193. package/dist/tools/tasks/index.d.ts +18 -0
  194. package/dist/tools/tasks/index.js +18 -0
  195. package/dist/tools/tasks/killTask.d.ts +1 -0
  196. package/dist/tools/{listTasks.d.ts → tasks/listTasks.d.ts} +1 -1
  197. package/dist/tools/{listTasks.js → tasks/listTasks.js} +3 -3
  198. package/dist/tools/{reconcileTasks.d.ts → tasks/reconcileTasks.d.ts} +1 -1
  199. package/dist/tools/{reconcileTasks.js → tasks/reconcileTasks.js} +64 -66
  200. package/dist/tools/{retryTask.d.ts → tasks/retryTask.d.ts} +2 -2
  201. package/dist/tools/tasks/retryTask.js +60 -0
  202. package/dist/tools/{runTaskLoop.d.ts → tasks/runTaskLoop.d.ts} +6 -6
  203. package/dist/tools/{runTaskLoop.js → tasks/runTaskLoop.js} +59 -31
  204. package/dist/tools/tasks/runVerification.js +38 -0
  205. package/dist/tools/{taskLineage.js → tasks/taskLineage.js} +8 -7
  206. package/dist/tools/{taskOutputs.js → tasks/taskOutputs.js} +17 -16
  207. package/dist/tools/{androidDoctor.js → workspace/androidDoctor.js} +16 -10
  208. package/dist/tools/{applyPatch.d.ts → workspace/applyPatch.d.ts} +1 -1
  209. package/dist/tools/workspace/applyPatch.js +53 -0
  210. package/dist/tools/{discardWorktree.js → workspace/discardWorktree.js} +3 -3
  211. package/dist/tools/workspace/index.d.ts +10 -0
  212. package/dist/tools/workspace/index.js +10 -0
  213. package/dist/tools/{listAgents.d.ts → workspace/listAgents.d.ts} +5 -0
  214. package/dist/tools/{listAgents.js → workspace/listAgents.js} +8 -4
  215. package/dist/tools/{listWorkspace.js → workspace/listWorkspace.js} +3 -3
  216. package/dist/tools/{mergeWorktree.js → workspace/mergeWorktree.js} +3 -3
  217. package/dist/tools/{readWorkspaceFile.js → workspace/readWorkspaceFile.js} +8 -8
  218. package/dist/tools/{recommendAgentForTask.js → workspace/recommendAgentForTask.js} +3 -3
  219. package/dist/tools/{searchWorkspace.js → workspace/searchWorkspace.js} +5 -5
  220. package/dist/tools/{syncFile.d.ts → workspace/syncFile.d.ts} +1 -1
  221. package/dist/tools/workspace/syncFile.js +134 -0
  222. package/dist/utils/atomicFile.d.ts +7 -0
  223. package/dist/utils/atomicFile.js +48 -0
  224. package/dist/utils/boundedFile.d.ts +8 -0
  225. package/dist/utils/boundedFile.js +93 -0
  226. package/dist/utils/lockedJsonFile.d.ts +15 -0
  227. package/dist/utils/lockedJsonFile.js +224 -0
  228. package/dist/utils/platform.d.ts +11 -0
  229. package/dist/utils/platform.js +26 -0
  230. package/dist/utils/stableJson.d.ts +2 -0
  231. package/dist/utils/stableJson.js +12 -0
  232. package/dist/version.d.ts +3 -2
  233. package/dist/version.js +3 -2
  234. package/dist/watcherStatus.d.ts +2 -2
  235. package/dist/watcherStatus.js +18 -0
  236. package/docs/CODE_WIKI.md +1648 -0
  237. package/docs/agentseal-integration.md +150 -0
  238. package/docs/architecture.md +23 -0
  239. package/docs/assets/patchwarden-desktop-onboarding.png +0 -0
  240. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  241. package/docs/control-center/README.md +4 -4
  242. package/docs/dashboard-overview.md +86 -0
  243. package/docs/demo.md +8 -0
  244. package/docs/desktop-app.md +175 -0
  245. package/docs/direct-session-workflow.md +98 -0
  246. package/docs/evidence-pack-schema.md +215 -0
  247. package/docs/lineage-evidence-pack-workflow.md +127 -0
  248. package/docs/mcp-inspector-testing.md +200 -0
  249. package/docs/open-source-application.md +168 -0
  250. package/docs/opencode-worker.md +151 -0
  251. package/docs/openhands-worker.md +181 -0
  252. package/docs/release-checklist.md +1 -1
  253. package/docs/release-evidence.md +82 -0
  254. package/docs/spec-kit-integration.md +131 -0
  255. package/docs/task-safe-review-workflow.md +98 -0
  256. package/docs/threat-model.md +97 -0
  257. package/docs/user-feedback.md +40 -0
  258. package/docs/why-patchwarden.md +110 -0
  259. package/examples/config.example.json +4 -2
  260. package/package.json +28 -11
  261. package/scripts/README.md +15 -6
  262. package/scripts/build.js +11 -0
  263. package/scripts/checks/brand-check.js +3 -0
  264. package/scripts/checks/build-output-check.js +37 -0
  265. package/scripts/checks/control-center-smoke.js +447 -6
  266. package/scripts/checks/control-smoke.js +19 -8
  267. package/scripts/checks/http-mcp-smoke.js +82 -4
  268. package/scripts/checks/lifecycle-smoke.js +39 -33
  269. package/scripts/checks/mcp-manifest-check.js +1 -1
  270. package/scripts/checks/mcp-smoke.js +30 -4
  271. package/scripts/checks/package-manifest-check.js +20 -7
  272. package/scripts/checks/unit-tests.js +3 -5
  273. package/scripts/checks/watcher-supervisor-smoke.js +9 -0
  274. package/scripts/control/get-patchwarden-health.ps1 +1 -1
  275. package/scripts/control/manage-patchwarden.ps1 +69 -4
  276. package/scripts/control/provision-patchwarden-tunnel.ps1 +127 -0
  277. package/scripts/control/run-background-supervisor.ps1 +41 -0
  278. package/scripts/control/start-patchwarden-tunnel.ps1 +126 -32
  279. package/scripts/generate-demo-gif.py +320 -0
  280. package/{PatchWarden-Control-Tray.cmd → scripts/launchers/PatchWarden-Control-Tray.cmd} +3 -3
  281. package/scripts/launchers/PatchWarden-Control.cmd +6 -0
  282. package/{PatchWarden-Desktop.cmd → scripts/launchers/PatchWarden-Desktop.cmd} +2 -2
  283. package/scripts/launchers/Restart-PatchWarden-Control.cmd +6 -0
  284. package/{Stop-PatchWarden.cmd → scripts/launchers/Stop-PatchWarden.cmd} +2 -2
  285. package/scripts/lib/clean-generated-output.js +11 -0
  286. package/scripts/lib/file-discovery.js +12 -0
  287. package/scripts/release/desktop-preflight.js +211 -0
  288. package/scripts/release/pack-clean.js +59 -14
  289. package/src/assessments/agentAssessor.ts +29 -15
  290. package/src/assessments/assessmentStore.ts +55 -43
  291. package/src/assessments/confirmCli.ts +5 -4
  292. package/src/config.ts +105 -11
  293. package/src/control/fileManager.ts +40 -0
  294. package/src/control/middleware/auth.ts +35 -0
  295. package/src/control/middleware/static.ts +84 -0
  296. package/src/control/routeTable.ts +301 -0
  297. package/src/control/routes/audit.ts +329 -0
  298. package/src/control/routes/evidence.ts +107 -0
  299. package/src/control/routes/lineage.ts +92 -0
  300. package/src/control/routes/policy.ts +81 -0
  301. package/src/control/routes/process.ts +505 -0
  302. package/src/control/routes/sessions.ts +314 -0
  303. package/src/control/routes/status.ts +405 -0
  304. package/src/control/routes/taskActions.ts +214 -0
  305. package/src/control/routes/tasks.ts +331 -0
  306. package/src/control/routes/workspace.ts +234 -0
  307. package/src/control/runtime.ts +508 -0
  308. package/src/control/server.ts +165 -0
  309. package/src/control/shared.ts +360 -0
  310. package/src/controlCenter.ts +7 -2347
  311. package/src/direct/directAudit.ts +17 -9
  312. package/src/direct/directGuards.ts +100 -51
  313. package/src/direct/directPatch.ts +95 -8
  314. package/src/direct/directSessionStore.ts +211 -55
  315. package/src/direct/directVerification.ts +9 -23
  316. package/src/doctor.ts +766 -492
  317. package/src/goal/acceptanceEngine.ts +1 -1
  318. package/src/goal/goalProgress.ts +17 -13
  319. package/src/goal/goalReport.ts +263 -0
  320. package/src/goal/goalStatus.ts +25 -3
  321. package/src/goal/goalStore.ts +241 -54
  322. package/src/goal/handoffExport.ts +6 -6
  323. package/src/goal/specKitImport.ts +355 -0
  324. package/src/goal/subgoalSync.ts +15 -10
  325. package/src/goal/worktreeManager.ts +139 -22
  326. package/src/httpServer.ts +130 -44
  327. package/src/index.ts +7 -4
  328. package/src/logging.ts +20 -4
  329. package/src/policy/projectPolicy.ts +5 -2
  330. package/src/release/releaseGate.ts +74 -27
  331. package/src/runner/agentInvocation.ts +126 -4
  332. package/src/runner/changeCapture.ts +342 -80
  333. package/src/runner/cli.ts +7 -6
  334. package/src/runner/postTaskCleanup.ts +55 -7
  335. package/src/runner/processSecurity.ts +433 -0
  336. package/src/runner/runTask.ts +441 -292
  337. package/src/runner/simpleProcess.ts +68 -33
  338. package/src/{taskProgress.ts → runner/taskProgress.ts} +3 -3
  339. package/src/{taskRuntime.ts → runner/taskRuntime.ts} +4 -3
  340. package/src/runner/taskStatusStore.ts +54 -0
  341. package/src/runner/watch.ts +290 -57
  342. package/src/security/contentRedaction.ts +31 -2
  343. package/src/security/discoveryTokenStore.ts +18 -4
  344. package/src/security/loopbackHost.ts +19 -0
  345. package/src/security/pathGuard.ts +35 -16
  346. package/src/security/planGuard.ts +2 -1
  347. package/src/security/runtimeGuard.ts +9 -6
  348. package/src/security/secretComparison.ts +13 -0
  349. package/src/security/sensitiveGuard.ts +27 -8
  350. package/src/security/toolInvocationGuard.ts +2 -2
  351. package/src/security/workspaceRootGuard.ts +53 -0
  352. package/src/smoke-test.ts +276 -273
  353. package/src/tools/catalog/index.ts +4 -0
  354. package/src/tools/{toolCatalog.ts → catalog/toolCatalog.ts} +3 -12
  355. package/src/tools/{toolRegistry.ts → catalog/toolRegistry.ts} +29 -16
  356. package/src/tools/definitions/toolDefs.ts +1264 -0
  357. package/src/tools/{auditSession.ts → diagnostics/auditSession.ts} +2 -2
  358. package/src/tools/{auditTask.ts → diagnostics/auditTask.ts} +196 -89
  359. package/src/tools/{healthCheck.ts → diagnostics/healthCheck.ts} +11 -11
  360. package/src/tools/diagnostics/index.ts +6 -0
  361. package/src/tools/{safeStatus.ts → diagnostics/safeStatus.ts} +7 -7
  362. package/src/tools/{safeViews.ts → diagnostics/safeViews.ts} +29 -19
  363. package/src/tools/{schemaDriftCheck.ts → diagnostics/schemaDriftCheck.ts} +3 -3
  364. package/src/tools/{createDirectSession.ts → direct/createDirectSession.ts} +9 -9
  365. package/src/tools/{finalizeDirectSession.ts → direct/finalizeDirectSession.ts} +11 -9
  366. package/src/tools/direct/index.ts +3 -0
  367. package/src/tools/{runDirectVerificationBundle.ts → direct/runDirectVerificationBundle.ts} +2 -2
  368. package/src/tools/{discoverTools.ts → discovery/discoverTools.ts} +6 -6
  369. package/src/tools/{explainTool.ts → discovery/explainTool.ts} +3 -3
  370. package/src/tools/discovery/index.ts +3 -0
  371. package/src/tools/{invokeDiscoveredTool.ts → discovery/invokeDiscoveredTool.ts} +7 -7
  372. package/src/tools/dispatch/coreDispatch.ts +379 -0
  373. package/src/tools/dispatch/diagnosticDispatch.ts +106 -0
  374. package/src/tools/dispatch/directDispatch.ts +168 -0
  375. package/src/tools/dispatch/goalDispatch.ts +128 -0
  376. package/src/tools/dispatch/releaseDispatch.ts +66 -0
  377. package/src/tools/dispatch/types.ts +24 -0
  378. package/src/tools/dispatch/validation.ts +78 -0
  379. package/src/tools/{getPlan.ts → goals/getPlan.ts} +4 -4
  380. package/src/tools/{goalSubgoalTask.ts → goals/goalSubgoalTask.ts} +43 -28
  381. package/src/tools/goals/index.ts +3 -0
  382. package/src/tools/{savePlan.ts → goals/savePlan.ts} +38 -8
  383. package/src/tools/registry.ts +54 -1858
  384. package/src/tools/{checkReleaseGate.ts → release/checkReleaseGate.ts} +3 -3
  385. package/src/tools/release/index.ts +2 -0
  386. package/src/tools/{releaseMode.ts → release/releaseMode.ts} +42 -24
  387. package/src/tools/tasks/cancelTask.ts +109 -0
  388. package/src/tools/{createTask.ts → tasks/createTask.ts} +56 -33
  389. package/src/tools/{diagnoseTask.ts → tasks/diagnoseTask.ts} +8 -8
  390. package/src/tools/tasks/evidencePack.ts +504 -0
  391. package/src/tools/{getTaskFile.ts → tasks/getTaskFile.ts} +4 -4
  392. package/src/tools/{getTaskStatus.ts → tasks/getTaskStatus.ts} +7 -5
  393. package/src/tools/{getTaskStdoutTail.ts → tasks/getTaskStdoutTail.ts} +13 -11
  394. package/src/tools/{getTaskSummary.ts → tasks/getTaskSummary.ts} +44 -33
  395. package/src/tools/tasks/index.ts +18 -0
  396. package/src/tools/{listTasks.ts → tasks/listTasks.ts} +4 -4
  397. package/src/tools/{reconcileTasks.ts → tasks/reconcileTasks.ts} +68 -66
  398. package/src/tools/tasks/retryTask.ts +69 -0
  399. package/src/tools/{runTaskLoop.ts → tasks/runTaskLoop.ts} +70 -35
  400. package/src/tools/{runVerification.ts → tasks/runVerification.ts} +6 -3
  401. package/src/tools/{taskLineage.ts → tasks/taskLineage.ts} +8 -7
  402. package/src/tools/{taskOutputs.ts → tasks/taskOutputs.ts} +27 -20
  403. package/src/tools/{androidDoctor.ts → workspace/androidDoctor.ts} +15 -10
  404. package/src/tools/{applyPatch.ts → workspace/applyPatch.ts} +38 -16
  405. package/src/tools/{discardWorktree.ts → workspace/discardWorktree.ts} +3 -3
  406. package/src/tools/workspace/index.ts +10 -0
  407. package/src/tools/{listAgents.ts → workspace/listAgents.ts} +11 -4
  408. package/src/tools/{listWorkspace.ts → workspace/listWorkspace.ts} +3 -3
  409. package/src/tools/{mergeWorktree.ts → workspace/mergeWorktree.ts} +3 -3
  410. package/src/tools/{readWorkspaceFile.ts → workspace/readWorkspaceFile.ts} +8 -8
  411. package/src/tools/{recommendAgentForTask.ts → workspace/recommendAgentForTask.ts} +3 -3
  412. package/src/tools/{searchWorkspace.ts → workspace/searchWorkspace.ts} +5 -5
  413. package/src/tools/workspace/syncFile.ts +292 -0
  414. package/src/utils/atomicFile.ts +58 -0
  415. package/src/utils/boundedFile.ts +116 -0
  416. package/src/utils/lockedJsonFile.ts +251 -0
  417. package/src/utils/platform.ts +31 -0
  418. package/src/utils/stableJson.ts +12 -0
  419. package/src/version.ts +3 -2
  420. package/src/watcherStatus.ts +19 -1
  421. package/ui/desktop-bootstrap.js +11 -0
  422. package/ui/desktop-bridge.js +36 -0
  423. package/ui/desktop.css +160 -0
  424. package/ui/getting-started.js +93 -0
  425. package/ui/i18n.js +484 -0
  426. package/ui/log-parser.js +30 -0
  427. package/ui/pages/audit.html +204 -3
  428. package/ui/pages/dashboard.html +937 -79
  429. package/ui/pages/direct-sessions.html +609 -60
  430. package/ui/pages/getting-started.html +43 -0
  431. package/ui/pages/logs.html +105 -22
  432. package/ui/pages/settings.html +101 -0
  433. package/ui/pages/task-detail.html +468 -438
  434. package/ui/pages/tasks.html +648 -65
  435. package/ui/pages/workspace.html +38 -1
  436. package/ui/settings.js +245 -0
  437. package/PatchWarden-Control.cmd +0 -6
  438. package/Restart-PatchWarden-Control.cmd +0 -6
  439. package/dist/test/unit/acceptance-engine.test.d.ts +0 -1
  440. package/dist/test/unit/acceptance-engine.test.js +0 -228
  441. package/dist/test/unit/agent-router.test.d.ts +0 -1
  442. package/dist/test/unit/agent-router.test.js +0 -287
  443. package/dist/test/unit/android-doctor.test.d.ts +0 -1
  444. package/dist/test/unit/android-doctor.test.js +0 -118
  445. package/dist/test/unit/audit-checks.test.d.ts +0 -1
  446. package/dist/test/unit/audit-checks.test.js +0 -350
  447. package/dist/test/unit/chinese-path.test.d.ts +0 -1
  448. package/dist/test/unit/chinese-path.test.js +0 -91
  449. package/dist/test/unit/command-guard.test.d.ts +0 -1
  450. package/dist/test/unit/command-guard.test.js +0 -160
  451. package/dist/test/unit/diagnose-task.test.d.ts +0 -1
  452. package/dist/test/unit/diagnose-task.test.js +0 -457
  453. package/dist/test/unit/direct-guards.test.d.ts +0 -1
  454. package/dist/test/unit/direct-guards.test.js +0 -213
  455. package/dist/test/unit/discovery-token-store.test.d.ts +0 -1
  456. package/dist/test/unit/discovery-token-store.test.js +0 -139
  457. package/dist/test/unit/evidence-pack.test.d.ts +0 -1
  458. package/dist/test/unit/evidence-pack.test.js +0 -130
  459. package/dist/test/unit/goal-graph.test.d.ts +0 -1
  460. package/dist/test/unit/goal-graph.test.js +0 -298
  461. package/dist/test/unit/goal-progress.test.d.ts +0 -1
  462. package/dist/test/unit/goal-progress.test.js +0 -381
  463. package/dist/test/unit/goal-status.test.d.ts +0 -1
  464. package/dist/test/unit/goal-status.test.js +0 -215
  465. package/dist/test/unit/goal-store.test.d.ts +0 -1
  466. package/dist/test/unit/goal-store.test.js +0 -253
  467. package/dist/test/unit/goal-subgoal-task.test.d.ts +0 -1
  468. package/dist/test/unit/goal-subgoal-task.test.js +0 -55
  469. package/dist/test/unit/goal-tools-registry.test.d.ts +0 -1
  470. package/dist/test/unit/goal-tools-registry.test.js +0 -190
  471. package/dist/test/unit/handoff-export.test.d.ts +0 -1
  472. package/dist/test/unit/handoff-export.test.js +0 -263
  473. package/dist/test/unit/invoke-discovered-tool.test.d.ts +0 -1
  474. package/dist/test/unit/invoke-discovered-tool.test.js +0 -167
  475. package/dist/test/unit/logging.test.d.ts +0 -1
  476. package/dist/test/unit/logging.test.js +0 -397
  477. package/dist/test/unit/path-guard.test.d.ts +0 -1
  478. package/dist/test/unit/path-guard.test.js +0 -109
  479. package/dist/test/unit/post-task-cleanup.test.d.ts +0 -1
  480. package/dist/test/unit/post-task-cleanup.test.js +0 -48
  481. package/dist/test/unit/project-policy-release-mode.test.d.ts +0 -1
  482. package/dist/test/unit/project-policy-release-mode.test.js +0 -125
  483. package/dist/test/unit/reconcile-tasks.test.d.ts +0 -1
  484. package/dist/test/unit/reconcile-tasks.test.js +0 -456
  485. package/dist/test/unit/release-gate.test.d.ts +0 -1
  486. package/dist/test/unit/release-gate.test.js +0 -242
  487. package/dist/test/unit/run-task-loop.test.d.ts +0 -1
  488. package/dist/test/unit/run-task-loop.test.js +0 -380
  489. package/dist/test/unit/safe-status.test.d.ts +0 -1
  490. package/dist/test/unit/safe-status.test.js +0 -165
  491. package/dist/test/unit/safe-views.test.d.ts +0 -1
  492. package/dist/test/unit/safe-views.test.js +0 -171
  493. package/dist/test/unit/schema-drift-check.test.d.ts +0 -1
  494. package/dist/test/unit/schema-drift-check.test.js +0 -176
  495. package/dist/test/unit/sensitive-guard.test.d.ts +0 -1
  496. package/dist/test/unit/sensitive-guard.test.js +0 -104
  497. package/dist/test/unit/subgoal-sync.test.d.ts +0 -1
  498. package/dist/test/unit/subgoal-sync.test.js +0 -183
  499. package/dist/test/unit/sync-file.test.d.ts +0 -1
  500. package/dist/test/unit/sync-file.test.js +0 -154
  501. package/dist/test/unit/tool-invocation-guard.test.d.ts +0 -1
  502. package/dist/test/unit/tool-invocation-guard.test.js +0 -432
  503. package/dist/test/unit/tool-usage-stats.test.d.ts +0 -1
  504. package/dist/test/unit/tool-usage-stats.test.js +0 -300
  505. package/dist/test/unit/toolSearch.test.d.ts +0 -1
  506. package/dist/test/unit/toolSearch.test.js +0 -571
  507. package/dist/test/unit/watcher-status.test.d.ts +0 -1
  508. package/dist/test/unit/watcher-status.test.js +0 -169
  509. package/dist/test/unit/worktree-manager.test.d.ts +0 -1
  510. package/dist/test/unit/worktree-manager.test.js +0 -176
  511. package/dist/tools/applyPatch.js +0 -41
  512. package/dist/tools/cancelTask.d.ts +0 -30
  513. package/dist/tools/cancelTask.js +0 -64
  514. package/dist/tools/evidencePack.js +0 -168
  515. package/dist/tools/finalizeDirectSession.js +0 -84
  516. package/dist/tools/goalSubgoalTask.js +0 -110
  517. package/dist/tools/killTask.d.ts +0 -15
  518. package/dist/tools/retryTask.js +0 -45
  519. package/dist/tools/runVerification.js +0 -36
  520. package/dist/tools/syncFile.js +0 -65
  521. package/docs/control-center/control-center-daily-driver.md +0 -211
  522. package/docs/control-center/control-center-mvp.md +0 -205
  523. package/docs/control-center/control-center-phase2.md +0 -159
  524. package/docs/release-v0.6.0.md +0 -71
  525. package/docs/release-v0.6.1.md +0 -75
  526. package/docs/release-v0.6.4.md +0 -45
  527. package/src/test/unit/acceptance-engine.test.ts +0 -261
  528. package/src/test/unit/agent-router.test.ts +0 -342
  529. package/src/test/unit/android-doctor.test.ts +0 -158
  530. package/src/test/unit/audit-checks.test.ts +0 -567
  531. package/src/test/unit/chinese-path.test.ts +0 -106
  532. package/src/test/unit/command-guard.test.ts +0 -221
  533. package/src/test/unit/diagnose-task.test.ts +0 -544
  534. package/src/test/unit/direct-guards.test.ts +0 -297
  535. package/src/test/unit/discovery-token-store.test.ts +0 -181
  536. package/src/test/unit/evidence-pack.test.ts +0 -142
  537. package/src/test/unit/goal-graph.test.ts +0 -347
  538. package/src/test/unit/goal-progress.test.ts +0 -538
  539. package/src/test/unit/goal-status.test.ts +0 -270
  540. package/src/test/unit/goal-store.test.ts +0 -318
  541. package/src/test/unit/goal-subgoal-task.test.ts +0 -72
  542. package/src/test/unit/goal-tools-registry.test.ts +0 -243
  543. package/src/test/unit/handoff-export.test.ts +0 -295
  544. package/src/test/unit/invoke-discovered-tool.test.ts +0 -216
  545. package/src/test/unit/logging.test.ts +0 -497
  546. package/src/test/unit/path-guard.test.ts +0 -150
  547. package/src/test/unit/post-task-cleanup.test.ts +0 -53
  548. package/src/test/unit/project-policy-release-mode.test.ts +0 -156
  549. package/src/test/unit/reconcile-tasks.test.ts +0 -551
  550. package/src/test/unit/release-gate.test.ts +0 -314
  551. package/src/test/unit/run-task-loop.test.ts +0 -425
  552. package/src/test/unit/safe-status.test.ts +0 -187
  553. package/src/test/unit/safe-views.test.ts +0 -184
  554. package/src/test/unit/schema-drift-check.test.ts +0 -260
  555. package/src/test/unit/sensitive-guard.test.ts +0 -124
  556. package/src/test/unit/subgoal-sync.test.ts +0 -236
  557. package/src/test/unit/sync-file.test.ts +0 -231
  558. package/src/test/unit/tool-invocation-guard.test.ts +0 -542
  559. package/src/test/unit/tool-usage-stats.test.ts +0 -384
  560. package/src/test/unit/toolSearch.test.ts +0 -756
  561. package/src/test/unit/watcher-status.test.ts +0 -190
  562. package/src/test/unit/worktree-manager.test.ts +0 -247
  563. package/src/tools/cancelTask.ts +0 -70
  564. package/src/tools/evidencePack.ts +0 -205
  565. package/src/tools/retryTask.ts +0 -53
  566. package/src/tools/syncFile.ts +0 -122
  567. /package/dist/tools/{toolCatalog.d.ts → catalog/toolCatalog.d.ts} +0 -0
  568. /package/dist/tools/{toolSearch.d.ts → catalog/toolSearch.d.ts} +0 -0
  569. /package/dist/tools/{toolSearch.js → catalog/toolSearch.js} +0 -0
  570. /package/dist/tools/{toolUsageStats.d.ts → catalog/toolUsageStats.d.ts} +0 -0
  571. /package/dist/tools/{toolUsageStats.js → catalog/toolUsageStats.js} +0 -0
  572. /package/dist/tools/{runDirectVerificationBundle.d.ts → direct/runDirectVerificationBundle.d.ts} +0 -0
  573. /package/dist/tools/{getPlan.d.ts → goals/getPlan.d.ts} +0 -0
  574. /package/dist/tools/{savePlan.d.ts → goals/savePlan.d.ts} +0 -0
  575. /package/dist/tools/{getTaskFile.d.ts → tasks/getTaskFile.d.ts} +0 -0
  576. /package/dist/tools/{getTaskProgress.d.ts → tasks/getTaskProgress.d.ts} +0 -0
  577. /package/dist/tools/{getTaskProgress.js → tasks/getTaskProgress.js} +0 -0
  578. /package/dist/tools/{getTaskStdoutTail.d.ts → tasks/getTaskStdoutTail.d.ts} +0 -0
  579. /package/dist/tools/{getTaskSummary.d.ts → tasks/getTaskSummary.d.ts} +0 -0
  580. /package/dist/tools/{killTask.js → tasks/killTask.js} +0 -0
  581. /package/dist/tools/{runVerification.d.ts → tasks/runVerification.d.ts} +0 -0
  582. /package/dist/tools/{taskLineage.d.ts → tasks/taskLineage.d.ts} +0 -0
  583. /package/dist/tools/{taskOutputs.d.ts → tasks/taskOutputs.d.ts} +0 -0
  584. /package/dist/tools/{waitForTask.d.ts → tasks/waitForTask.d.ts} +0 -0
  585. /package/dist/tools/{waitForTask.js → tasks/waitForTask.js} +0 -0
  586. /package/dist/tools/{androidDoctor.d.ts → workspace/androidDoctor.d.ts} +0 -0
  587. /package/dist/tools/{discardWorktree.d.ts → workspace/discardWorktree.d.ts} +0 -0
  588. /package/dist/tools/{listWorkspace.d.ts → workspace/listWorkspace.d.ts} +0 -0
  589. /package/dist/tools/{mergeWorktree.d.ts → workspace/mergeWorktree.d.ts} +0 -0
  590. /package/dist/tools/{readWorkspaceFile.d.ts → workspace/readWorkspaceFile.d.ts} +0 -0
  591. /package/dist/tools/{recommendAgentForTask.d.ts → workspace/recommendAgentForTask.d.ts} +0 -0
  592. /package/dist/tools/{searchWorkspace.d.ts → workspace/searchWorkspace.d.ts} +0 -0
  593. /package/src/tools/{toolSearch.ts → catalog/toolSearch.ts} +0 -0
  594. /package/src/tools/{toolUsageStats.ts → catalog/toolUsageStats.ts} +0 -0
  595. /package/src/tools/{getTaskProgress.ts → tasks/getTaskProgress.ts} +0 -0
  596. /package/src/tools/{killTask.ts → tasks/killTask.ts} +0 -0
  597. /package/src/tools/{waitForTask.ts → tasks/waitForTask.ts} +0 -0
@@ -1,7 +1,8 @@
1
- import { resolve, relative, isAbsolute } from "node:path";
1
+ import { relative } from "node:path";
2
2
  import { existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
3
3
  import { PatchWardenError } from "../errors.js";
4
- import { isSensitivePath } from "../security/sensitiveGuard.js";
4
+ import { hasWindowsAlternateDataStream, isSensitivePath, } from "../security/sensitiveGuard.js";
5
+ import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
5
6
  import { getConfig } from "../config.js";
6
7
  // ── Session state guards ───────────────────────────────────────────
7
8
  export function guardDirectSessionActive(session) {
@@ -23,34 +24,34 @@ export function guardDirectSessionFinalized(session) {
23
24
  * inside both the repo and the workspace root.
24
25
  */
25
26
  export function guardDirectPath(filePath, resolvedRepoPath, workspaceRoot) {
26
- const config = getConfig();
27
- const resolved = resolve(resolvedRepoPath, filePath);
28
- const normalizedRepo = resolve(resolvedRepoPath);
29
- const normalizedWs = resolve(workspaceRoot);
30
- // Check path is inside workspace root
31
- const relToWs = relative(normalizedWs, resolved);
32
- if (isAbsolute(relToWs) || relToWs.startsWith("..")) {
33
- throw new PatchWardenError("path_outside_repo", `Path "${filePath}" resolves outside the session repo.`, "Use a relative path inside the session's repo_path.", true, { path: filePath, operation: "direct_path_access" });
34
- }
35
- // Check path is inside repo_path
36
- const relToRepo = relative(normalizedRepo, resolved);
37
- if (isAbsolute(relToRepo) || relToRepo.startsWith("..")) {
38
- throw new PatchWardenError("path_outside_repo", `Path "${filePath}" is outside the session repo_path "${resolvedRepoPath}".`, "Use a relative path inside the session's repo_path.", true, { path: filePath, operation: "direct_path_access" });
27
+ if (hasWindowsAlternateDataStream(filePath)) {
28
+ throw new PatchWardenError("windows_ads_path_blocked", `Path "${filePath}" contains a Windows alternate data stream suffix.`, "Use a normal relative file path without a colon or NTFS stream name.", true, { path: filePath, operation: "direct_path_access" });
29
+ }
30
+ // Resolve the repo first so a repo-level symlink/junction cannot escape the
31
+ // workspace. guardPath then resolves the candidate or its nearest existing
32
+ // parent, closing intermediate symlink/junction escapes for new files too.
33
+ try {
34
+ const guardedRepo = guardWorkspacePath(resolvedRepoPath, workspaceRoot);
35
+ return guardPath(filePath, guardedRepo);
36
+ }
37
+ catch (error) {
38
+ if (error instanceof PatchWardenError && error.reason === "workspace_path_escape") {
39
+ throw new PatchWardenError("path_outside_repo", `Path "${filePath}" resolves outside the session repo.`, "Use a relative path inside the session's repo_path.", true, { path: filePath, operation: "direct_path_access" });
40
+ }
41
+ throw error;
39
42
  }
40
- return resolved;
41
43
  }
42
44
  /**
43
45
  * Guard a read path: must be inside repo, not sensitive, not binary.
44
46
  */
45
47
  export function guardDirectReadPath(filePath, resolvedRepoPath, workspaceRoot) {
46
48
  const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
47
- const normalized = filePath.replace(/\\/g, "/");
49
+ const policyPaths = directPolicyPaths(filePath, resolved, resolvedRepoPath, workspaceRoot);
48
50
  // Block internal PatchWarden paths (sessions, tasks, plans, assessments)
49
- if (normalized.startsWith(".patchwarden/") ||
50
- normalized.includes("/.patchwarden/")) {
51
+ if (policyPaths.some((path) => hasPathSegment(path, ".patchwarden"))) {
51
52
  throw new PatchWardenError("internal_patchwarden_path_blocked", `Access denied: "${filePath}" is inside the internal .patchwarden directory.`, "Internal PatchWarden files cannot be accessed through Direct mode.", true, { path: filePath, operation: "direct_read" });
52
53
  }
53
- if (isSensitivePath(filePath)) {
54
+ if (policyPaths.some((path) => isSensitivePath(path))) {
54
55
  throw new PatchWardenError("sensitive_path_blocked", `Access denied: "${filePath}" matches a sensitive file pattern.`, "Read only non-sensitive files within the session repo.", true, { path: filePath, operation: "direct_read" });
55
56
  }
56
57
  if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
@@ -64,25 +65,24 @@ export function guardDirectReadPath(filePath, resolvedRepoPath, workspaceRoot) {
64
65
  */
65
66
  export function guardDirectWritePath(filePath, resolvedRepoPath, workspaceRoot) {
66
67
  const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
67
- const normalized = filePath.replace(/\\/g, "/");
68
+ const policyPaths = directPolicyPaths(filePath, resolved, resolvedRepoPath, workspaceRoot);
68
69
  // Block internal PatchWarden paths
69
- if (normalized.startsWith(".patchwarden/") ||
70
- normalized.includes("/.patchwarden/")) {
70
+ if (policyPaths.some((path) => hasPathSegment(path, ".patchwarden"))) {
71
71
  throw new PatchWardenError("internal_patchwarden_path_blocked", `Modification denied: "${filePath}" is inside the internal .patchwarden directory.`, "Internal PatchWarden files cannot be modified through Direct mode.", true, { path: filePath, operation: "direct_write" });
72
72
  }
73
- if (isSensitivePath(normalized)) {
73
+ if (policyPaths.some((path) => isSensitivePath(path))) {
74
74
  throw new PatchWardenError("sensitive_path_blocked", `Modification denied: "${filePath}" matches a sensitive file pattern.`, "Do not modify .env, credentials, keys, or other sensitive files.", true, { path: filePath, operation: "direct_write" });
75
75
  }
76
76
  // Block node_modules
77
- if (normalized.startsWith("node_modules/") || normalized.includes("/node_modules/")) {
77
+ if (policyPaths.some((path) => hasPathSegment(path, "node_modules"))) {
78
78
  throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside node_modules.`, "Do not modify dependencies directly. Use package management tools instead.", true, { path: filePath, operation: "direct_write" });
79
79
  }
80
80
  // Block release/
81
- if (normalized.startsWith("release/") || normalized.includes("/release/")) {
81
+ if (policyPaths.some((path) => hasPathSegment(path, "release"))) {
82
82
  throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside the release directory.`, "Do not manually modify release artifacts.", true, { path: filePath, operation: "direct_write" });
83
83
  }
84
84
  // Block dist/
85
- if (normalized.startsWith("dist/") || normalized.includes("/dist/")) {
85
+ if (policyPaths.some((path) => hasPathSegment(path, "dist"))) {
86
86
  throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside the dist directory.`, "Do not manually modify build output. Modify source files instead.", true, { path: filePath, operation: "direct_write" });
87
87
  }
88
88
  if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
@@ -90,6 +90,20 @@ export function guardDirectWritePath(filePath, resolvedRepoPath, workspaceRoot)
90
90
  }
91
91
  return resolved;
92
92
  }
93
+ function directPolicyPaths(requestedPath, resolvedPath, resolvedRepoPath, workspaceRoot) {
94
+ const guardedRepo = guardWorkspacePath(resolvedRepoPath, workspaceRoot);
95
+ return [
96
+ requestedPath.replace(/\\/g, "/"),
97
+ relative(guardedRepo, resolvedPath).replace(/\\/g, "/"),
98
+ ];
99
+ }
100
+ function hasPathSegment(filePath, expectedSegment) {
101
+ const expected = expectedSegment.toLowerCase();
102
+ return filePath
103
+ .split("/")
104
+ .filter(Boolean)
105
+ .some((segment) => segment.toLowerCase() === expected);
106
+ }
93
107
  // ── Size guards ────────────────────────────────────────────────────
94
108
  export function guardDirectPatchSize(patchBytes) {
95
109
  const config = getConfig();
@@ -97,8 +111,7 @@ export function guardDirectPatchSize(patchBytes) {
97
111
  throw new PatchWardenError("patch_too_large", `Patch size ${patchBytes} bytes exceeds maximum ${config.directMaxPatchBytes} bytes.`, "Split the patch into smaller operations or reduce the patch size.", true, { patch_bytes: patchBytes, max_bytes: config.directMaxPatchBytes });
98
112
  }
99
113
  }
100
- export function guardDirectFileSize(fileBytes) {
101
- const config = getConfig();
114
+ export function guardDirectFileSize(fileBytes, config = getConfig()) {
102
115
  if (fileBytes > config.directMaxFileBytes) {
103
116
  throw new PatchWardenError("file_too_large", `File size ${fileBytes} bytes exceeds maximum ${config.directMaxFileBytes} bytes.`, "Use search_workspace to find specific content, or split the read into smaller portions.", true, { file_bytes: fileBytes, max_bytes: config.directMaxFileBytes });
104
117
  }
@@ -113,23 +126,45 @@ const BINARY_EXTENSIONS = new Set([
113
126
  ".bin", ".dat", ".pak", ".pfx", ".p12", ".class", ".jar", ".war",
114
127
  ".wasm", ".node",
115
128
  ]);
129
+ /**
130
+ * Maximum bytes to scan for null-byte detection when the extension is not
131
+ * a known binary type. 1 MB catches binary content embedded deep in files
132
+ * (e.g., null bytes at offset 8200) without reading excessively large files.
133
+ */
134
+ const BINARY_SCAN_LIMIT = 1_048_576; // 1 MB
135
+ const BINARY_SCAN_CHUNK = 65536; // 64 KB per read
116
136
  export function isBinaryFile(filePath) {
117
137
  const normalized = filePath.replace(/\\/g, "/").toLowerCase();
118
138
  for (const ext of BINARY_EXTENSIONS) {
119
139
  if (normalized.endsWith(ext))
120
140
  return true;
121
141
  }
122
- // Check file content for null bytes (simple heuristic)
142
+ // Scan file content for null bytes in chunks up to BINARY_SCAN_LIMIT.
143
+ // Previously only the first 8 KB were checked, allowing null bytes at
144
+ // offset 8193+ to bypass detection.
123
145
  try {
146
+ const stat = statSync(filePath);
147
+ const scanSize = Math.min(stat.size, BINARY_SCAN_LIMIT);
124
148
  const fd = openSync(filePath, "r");
125
- const buffer = Buffer.alloc(8192);
126
- const bytesRead = readSync(fd, buffer, 0, 8192, 0);
127
- closeSync(fd);
128
- for (let i = 0; i < bytesRead; i++) {
129
- if (buffer[i] === 0)
130
- return true;
149
+ try {
150
+ const chunk = Buffer.alloc(BINARY_SCAN_CHUNK);
151
+ let scanned = 0;
152
+ while (scanned < scanSize) {
153
+ const toRead = Math.min(BINARY_SCAN_CHUNK, scanSize - scanned);
154
+ const bytesRead = readSync(fd, chunk, 0, toRead, scanned);
155
+ if (bytesRead === 0)
156
+ break;
157
+ for (let i = 0; i < bytesRead; i++) {
158
+ if (chunk[i] === 0)
159
+ return true;
160
+ }
161
+ scanned += bytesRead;
162
+ }
163
+ return false;
164
+ }
165
+ finally {
166
+ closeSync(fd);
131
167
  }
132
- return false;
133
168
  }
134
169
  catch {
135
170
  return false;
@@ -13,5 +13,10 @@ export interface ApplyPatchResult {
13
13
  }
14
14
  export declare function computeFileSha256(filePath: string): string;
15
15
  export declare function computeContentSha256(content: string): string;
16
+ export interface ApplyPatchOptions {
17
+ expectedSha256?: string;
18
+ maxFileBytes?: number;
19
+ revalidatePath?: () => string;
20
+ }
16
21
  export declare function validateExpectedSha256(filePath: string, expectedSha256: string): string;
17
- export declare function applyPatchOperations(filePath: string, operations: PatchOperation[]): ApplyPatchResult;
22
+ export declare function applyPatchOperations(filePath: string, operations: PatchOperation[], options?: ApplyPatchOptions): ApplyPatchResult;
@@ -1,7 +1,8 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { dirname } from "node:path";
4
3
  import { PatchWardenError } from "../errors.js";
4
+ import { atomicWriteFileSync } from "../utils/atomicFile.js";
5
+ import { redactSensitiveContent } from "../security/contentRedaction.js";
5
6
  // ── Hash helpers ───────────────────────────────────────────────────
6
7
  export function computeFileSha256(filePath) {
7
8
  const content = readFileSync(filePath);
@@ -21,12 +22,36 @@ export function validateExpectedSha256(filePath, expectedSha256) {
21
22
  return currentHash;
22
23
  }
23
24
  // ── Patch application ──────────────────────────────────────────────
24
- export function applyPatchOperations(filePath, operations) {
25
+ export function applyPatchOperations(filePath, operations, options = {}) {
25
26
  if (!existsSync(filePath)) {
26
27
  throw new PatchWardenError("patch_anchor_not_found", `File does not exist: "${filePath}".`, "Ensure the file exists before applying a patch. Direct mode does not support creating new files.", true, { path: filePath });
27
28
  }
28
- const beforeContent = readFileSync(filePath, "utf-8");
29
- const beforeSha256 = computeContentSha256(beforeContent);
29
+ let descriptor = null;
30
+ let beforeBytes;
31
+ let existingMode;
32
+ try {
33
+ descriptor = openSync(filePath, "r");
34
+ const metadata = fstatSync(descriptor);
35
+ if (!metadata.isFile()) {
36
+ throw new PatchWardenError("direct_target_not_file", `Patch target is not a regular file: "${filePath}".`, "Choose an existing text file inside the Direct session repository.");
37
+ }
38
+ guardFileByteLimit(metadata.size, options.maxFileBytes);
39
+ existingMode = metadata.mode;
40
+ beforeBytes = readFileSync(descriptor);
41
+ }
42
+ finally {
43
+ if (descriptor !== null)
44
+ closeSync(descriptor);
45
+ }
46
+ guardFileByteLimit(beforeBytes.length, options.maxFileBytes);
47
+ const beforeSha256 = createHash("sha256").update(beforeBytes).digest("hex");
48
+ if (options.expectedSha256 && beforeSha256 !== options.expectedSha256) {
49
+ throw fileHashMismatch(options.expectedSha256, beforeSha256);
50
+ }
51
+ const beforeContent = beforeBytes.toString("utf-8");
52
+ if (!Buffer.from(beforeContent, "utf-8").equals(beforeBytes)) {
53
+ throw new PatchWardenError("unsupported_text_encoding", `File "${filePath}" is not valid UTF-8 text.`, "Use a normal task or convert the file to UTF-8 before Direct editing.", true, { path: filePath });
54
+ }
30
55
  let content = beforeContent;
31
56
  let operationsApplied = 0;
32
57
  for (const op of operations) {
@@ -34,10 +59,19 @@ export function applyPatchOperations(filePath, operations) {
34
59
  operationsApplied++;
35
60
  }
36
61
  const afterSha256 = computeContentSha256(content);
62
+ guardFileByteLimit(Buffer.byteLength(content, "utf-8"), options.maxFileBytes);
63
+ const sensitive = redactSensitiveContent(content);
64
+ if (sensitive.redacted) {
65
+ throw new PatchWardenError("sensitive_content_blocked", `Patch result contains credential-like content (${sensitive.redaction_categories.join(", ")}).`, "Remove the sensitive value and retry with placeholders or environment-variable references.", true, { path: filePath, redaction_categories: sensitive.redaction_categories });
66
+ }
37
67
  const bytesChanged = Math.abs(Buffer.byteLength(content, "utf-8") - Buffer.byteLength(beforeContent, "utf-8"));
38
- // Ensure parent directory exists (should already exist for existing files)
39
- mkdirSync(dirname(filePath), { recursive: true });
40
- writeFileSync(filePath, content, "utf-8");
68
+ if (options.revalidatePath && !samePath(options.revalidatePath(), filePath)) {
69
+ throw new PatchWardenError("direct_path_changed_during_write", `Patch target path changed while preparing "${filePath}".`, "Retry only after concurrent filesystem changes stop.", true, { path: filePath, operation: "apply_patch" });
70
+ }
71
+ const currentSha256 = computeFileSha256(filePath);
72
+ if (currentSha256 !== beforeSha256)
73
+ throw fileHashMismatch(beforeSha256, currentSha256);
74
+ atomicWriteFileSync(filePath, content, { mode: existingMode });
41
75
  return {
42
76
  before_sha256: beforeSha256,
43
77
  after_sha256: afterSha256,
@@ -45,6 +79,19 @@ export function applyPatchOperations(filePath, operations) {
45
79
  bytes_changed: bytesChanged,
46
80
  };
47
81
  }
82
+ function guardFileByteLimit(fileBytes, maxFileBytes) {
83
+ if (maxFileBytes === undefined || fileBytes <= maxFileBytes)
84
+ return;
85
+ throw new PatchWardenError("file_too_large", `File size ${fileBytes} bytes exceeds maximum ${maxFileBytes} bytes.`, "Use a normal task for larger files.", true, { file_bytes: fileBytes, max_bytes: maxFileBytes });
86
+ }
87
+ function fileHashMismatch(expectedSha256, actualSha256) {
88
+ return new PatchWardenError("file_hash_mismatch", `File hash mismatch. Expected "${expectedSha256}" but got "${actualSha256}".`, "Re-read the file to get the current sha256, then retry the patch.", true, { expected_sha256: expectedSha256, actual_sha256: actualSha256 });
89
+ }
90
+ function samePath(left, right) {
91
+ return process.platform === "win32"
92
+ ? left.toLowerCase() === right.toLowerCase()
93
+ : left === right;
94
+ }
48
95
  function applySingleOperation(content, op) {
49
96
  switch (op.type) {
50
97
  case "replace_exact":
@@ -1,9 +1,12 @@
1
+ import { PatchWardenConfig } from "../config.js";
1
2
  import { type RepoSnapshot, type ChangeArtifacts } from "../runner/changeCapture.js";
2
3
  export interface DirectSessionOperation {
3
4
  index: number;
4
5
  timestamp: string;
5
6
  path: string;
6
- before_sha256: string;
7
+ operation_type?: "patch" | "sync";
8
+ source_path?: string;
9
+ before_sha256: string | null;
7
10
  after_sha256: string;
8
11
  operations_applied: number;
9
12
  bytes_changed: number;
@@ -48,12 +51,19 @@ export interface DirectSessionCreateInput {
48
51
  snapshot: RepoSnapshot;
49
52
  }
50
53
  export declare function generateDirectSessionId(): string;
51
- export declare function createDirectSessionDir(sessionId: string): string;
52
- export declare function getDirectSessionDir(sessionId: string): string;
54
+ /**
55
+ * Direct session IDs are directory names. Keep one shared, bounded grammar so
56
+ * MCP tools and Control Center routes cannot disagree about traversal input.
57
+ */
58
+ export declare function isValidDirectSessionId(sessionId: string): boolean;
59
+ export declare function createDirectSessionDir(sessionId: string, config?: PatchWardenConfig): string;
60
+ export declare function getDirectSessionDir(sessionId: string, config?: PatchWardenConfig): string;
53
61
  export declare function createDirectSession(input: DirectSessionCreateInput): DirectSessionRecord;
54
- export declare function readDirectSession(sessionId: string): DirectSessionRecord;
62
+ export declare function readDirectSession(sessionId: string, config?: PatchWardenConfig): DirectSessionRecord;
55
63
  export declare function updateDirectSession(sessionId: string, updates: Partial<DirectSessionRecord>): DirectSessionRecord;
56
- export declare function appendDirectSessionOperation(sessionId: string, operation: DirectSessionOperation): DirectSessionRecord;
64
+ export declare function appendDirectSessionOperation(sessionId: string, operation: DirectSessionOperation, config?: PatchWardenConfig): DirectSessionRecord;
65
+ export declare function withDirectSessionMutationLock<R>(sessionId: string, action: () => R, config?: PatchWardenConfig): R;
66
+ export declare function withDirectSessionMutationLockAsync<R>(sessionId: string, action: () => Promise<R>, config?: PatchWardenConfig): Promise<R>;
57
67
  export declare function appendDirectSessionVerificationRun(sessionId: string, run: DirectSessionVerificationRun): DirectSessionRecord;
58
68
  export interface DirectSessionValidationResult {
59
69
  valid: boolean;
@@ -61,5 +71,5 @@ export interface DirectSessionValidationResult {
61
71
  session: DirectSessionRecord | null;
62
72
  }
63
73
  export declare function validateDirectSessionFreshness(sessionId: string): DirectSessionValidationResult;
64
- export declare function finalizeDirectSessionRecord(sessionId: string, afterSnapshot: RepoSnapshot, changeArtifacts: ChangeArtifacts): DirectSessionRecord;
74
+ export declare function finalizeDirectSessionRecord(sessionId: string, changeArtifacts: ChangeArtifacts): DirectSessionRecord;
65
75
  export declare function computeWorkspaceFingerprint(snapshot: RepoSnapshot): string;
@@ -1,11 +1,14 @@
1
- import { mkdirSync, writeFileSync, existsSync, readFileSync, } from "node:fs";
1
+ import { mkdirSync, existsSync, } from "node:fs";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { resolve, join } from "node:path";
4
4
  import { getDirectSessionsDir, getConfig, getRepoDirectAllowedCommands, } from "../config.js";
5
5
  import { guardPath } from "../security/pathGuard.js";
6
6
  import { PatchWardenError } from "../errors.js";
7
7
  import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
8
- import { getLastToolCatalogSnapshot } from "../tools/toolCatalog.js";
8
+ import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../utils/atomicFile.js";
9
+ import { redactSensitiveContent } from "../security/contentRedaction.js";
10
+ import { mutateLockedJsonFileSync, readJsonObjectFileSync, withFileLock, withFileLockSync, } from "../utils/lockedJsonFile.js";
11
+ import { getLastToolCatalogSnapshot } from "../tools/catalog/toolCatalog.js";
9
12
  // ── ID generation ──────────────────────────────────────────────────
10
13
  export function generateDirectSessionId() {
11
14
  const timestamp = new Date()
@@ -16,21 +19,32 @@ export function generateDirectSessionId() {
16
19
  const randomHex = randomBytes(16).toString("hex");
17
20
  return `direct_${timestamp}_${randomHex}`;
18
21
  }
22
+ /**
23
+ * Direct session IDs are directory names. Keep one shared, bounded grammar so
24
+ * MCP tools and Control Center routes cannot disagree about traversal input.
25
+ */
26
+ export function isValidDirectSessionId(sessionId) {
27
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sessionId);
28
+ }
29
+ function guardDirectSessionId(sessionId) {
30
+ if (!isValidDirectSessionId(sessionId)) {
31
+ throw new PatchWardenError("invalid_session_id", `Direct session ID "${sessionId}" is invalid.`, "Use the session_id returned by create_direct_session.", true, { session_id: sessionId });
32
+ }
33
+ }
19
34
  // ── Directory management ───────────────────────────────────────────
20
- export function createDirectSessionDir(sessionId) {
21
- const config = getConfig();
35
+ export function createDirectSessionDir(sessionId, config = getConfig()) {
36
+ guardDirectSessionId(sessionId);
22
37
  const sessionsDir = getDirectSessionsDir(config);
23
38
  guardPath(sessionsDir, config.workspaceRoot, config.directSessionsDir);
24
39
  mkdirSync(sessionsDir, { recursive: true });
25
- const dir = resolve(sessionsDir, sessionId);
26
- guardPath(dir, config.workspaceRoot, config.directSessionsDir);
40
+ const dir = getDirectSessionDir(sessionId, config);
27
41
  mkdirSync(dir, { recursive: true });
28
42
  return dir;
29
43
  }
30
- export function getDirectSessionDir(sessionId) {
31
- const config = getConfig();
44
+ export function getDirectSessionDir(sessionId, config = getConfig()) {
45
+ guardDirectSessionId(sessionId);
32
46
  const sessionsDir = getDirectSessionsDir(config);
33
- return resolve(sessionsDir, sessionId);
47
+ return guardPath(resolve(sessionsDir, sessionId), config.workspaceRoot, config.directSessionsDir);
34
48
  }
35
49
  // ── CRUD ───────────────────────────────────────────────────────────
36
50
  export function createDirectSession(input) {
@@ -48,7 +62,11 @@ export function createDirectSession(input) {
48
62
  const expiresAt = new Date(now.getTime() + config.directSessionTtlSeconds * 1000).toISOString();
49
63
  const record = {
50
64
  session_id: sessionId,
51
- title: input.title || "",
65
+ title: redactSensitiveContent(input.title || "")
66
+ .content
67
+ .replace(/[\r\n\t]+/g, " ")
68
+ .trim()
69
+ .slice(0, 500),
52
70
  repo_path: input.repo_path,
53
71
  resolved_repo_path: input.resolved_repo_path,
54
72
  created_at: now.toISOString(),
@@ -66,36 +84,55 @@ export function createDirectSession(input) {
66
84
  audited: false,
67
85
  change_artifacts: null,
68
86
  };
69
- writeFileSync(join(dir, "session.json"), JSON.stringify(record, null, 2), "utf-8");
87
+ atomicWriteJsonFileSync(join(dir, "session.json"), record);
70
88
  return record;
71
89
  }
72
- export function readDirectSession(sessionId) {
73
- const config = getConfig();
74
- const sessionsDir = getDirectSessionsDir(config);
75
- const dir = resolve(sessionsDir, sessionId);
76
- const file = join(dir, "session.json");
77
- guardPath(file, config.workspaceRoot, config.directSessionsDir);
90
+ export function readDirectSession(sessionId, config = getConfig()) {
91
+ const file = getDirectSessionFile(sessionId, config);
78
92
  if (!existsSync(file)) {
79
93
  throw new PatchWardenError("session_not_found", `Direct session "${sessionId}" not found.`, "Call create_direct_session first to get a valid session_id.", true, { session_id: sessionId });
80
94
  }
81
- return JSON.parse(readFileSync(file, "utf-8"));
95
+ let value;
96
+ try {
97
+ value = readJsonObjectFileSync(file);
98
+ }
99
+ catch {
100
+ throw invalidDirectSessionRecord(sessionId, "session.json is not valid JSON");
101
+ }
102
+ return validateDirectSessionRecord(sessionId, value);
82
103
  }
83
104
  export function updateDirectSession(sessionId, updates) {
84
- const session = readDirectSession(sessionId);
85
- const updated = { ...session, ...updates };
86
- const dir = getDirectSessionDir(sessionId);
87
- writeFileSync(join(dir, "session.json"), JSON.stringify(updated, null, 2), "utf-8");
88
- return updated;
105
+ return mutateDirectSessionRecord(sessionId, (session) => ({
106
+ ...session,
107
+ ...updates,
108
+ session_id: session.session_id,
109
+ }));
110
+ }
111
+ export function appendDirectSessionOperation(sessionId, operation, config = getConfig()) {
112
+ return mutateDirectSessionRecord(sessionId, (session) => ({
113
+ ...session,
114
+ operations: [
115
+ ...session.operations,
116
+ {
117
+ ...operation,
118
+ index: session.operations.reduce((max, entry) => Math.max(max, entry.index), -1) + 1,
119
+ },
120
+ ],
121
+ }), config);
89
122
  }
90
- export function appendDirectSessionOperation(sessionId, operation) {
91
- const session = readDirectSession(sessionId);
92
- const operations = [...session.operations, operation];
93
- return updateDirectSession(sessionId, { operations });
123
+ export function withDirectSessionMutationLock(sessionId, action, config = getConfig()) {
124
+ const lockTarget = join(getDirectSessionDir(sessionId, config), "workspace-mutation");
125
+ return withFileLockSync(lockTarget, action, directSessionBusyOptions(sessionId));
126
+ }
127
+ export function withDirectSessionMutationLockAsync(sessionId, action, config = getConfig()) {
128
+ const lockTarget = join(getDirectSessionDir(sessionId, config), "workspace-mutation");
129
+ return withFileLock(lockTarget, action, directSessionBusyOptions(sessionId));
94
130
  }
95
131
  export function appendDirectSessionVerificationRun(sessionId, run) {
96
- const session = readDirectSession(sessionId);
97
- const verification_runs = [...session.verification_runs, run];
98
- return updateDirectSession(sessionId, { verification_runs });
132
+ return mutateDirectSessionRecord(sessionId, (session) => ({
133
+ ...session,
134
+ verification_runs: [...session.verification_runs, run],
135
+ }));
99
136
  }
100
137
  export function validateDirectSessionFreshness(sessionId) {
101
138
  let session;
@@ -124,20 +161,22 @@ export function validateDirectSessionFreshness(sessionId) {
124
161
  return { valid: true, failure_reason: null, session };
125
162
  }
126
163
  // ── Finalization ───────────────────────────────────────────────────
127
- export function finalizeDirectSessionRecord(sessionId, afterSnapshot, changeArtifacts) {
164
+ export function finalizeDirectSessionRecord(sessionId, changeArtifacts) {
128
165
  const dir = getDirectSessionDir(sessionId);
129
166
  // Write change artifacts to session directory
130
- writeFileSync(join(dir, "changed-files.json"), JSON.stringify(changeArtifacts.changed_files, null, 2), "utf-8");
131
- writeFileSync(join(dir, "diff.patch"), changeArtifacts.diff, "utf-8");
167
+ atomicWriteJsonFileSync(join(dir, "changed-files.json"), changeArtifacts.changed_files);
168
+ atomicWriteFileSync(join(dir, "diff.patch"), changeArtifacts.diff);
132
169
  const summary = {
133
170
  changed_files_total: changeArtifacts.changed_files.length,
134
171
  additions: changeArtifacts.additions,
135
172
  deletions: changeArtifacts.deletions,
136
173
  patch_mode: changeArtifacts.patch_mode,
174
+ diff_redacted: changeArtifacts.diff_redacted === true,
175
+ diff_redaction_categories: changeArtifacts.diff_redaction_categories ?? [],
137
176
  artifact_hygiene: changeArtifacts.artifact_hygiene,
138
177
  };
139
- writeFileSync(join(dir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
140
- writeFileSync(join(dir, "summary.md"), formatSummaryMd(changeArtifacts), "utf-8");
178
+ atomicWriteJsonFileSync(join(dir, "summary.json"), summary);
179
+ atomicWriteFileSync(join(dir, "summary.md"), formatSummaryMd(changeArtifacts));
141
180
  return updateDirectSession(sessionId, {
142
181
  finalized: true,
143
182
  finalized_at: new Date().toISOString(),
@@ -166,6 +205,58 @@ function extractReason(error) {
166
205
  return error.message.slice(0, 80);
167
206
  return String(error).slice(0, 80);
168
207
  }
208
+ function invalidDirectSessionRecord(sessionId, detail) {
209
+ return new PatchWardenError("invalid_session_record", `Direct session "${sessionId}" is invalid: ${detail}.`, "Create a new direct session and do not modify its internal session.json file.", true, { session_id: sessionId });
210
+ }
211
+ function getDirectSessionFile(sessionId, config = getConfig()) {
212
+ const dir = getDirectSessionDir(sessionId, config);
213
+ return guardPath(join(dir, "session.json"), config.workspaceRoot, config.directSessionsDir);
214
+ }
215
+ function mutateDirectSessionRecord(sessionId, mutation, config = getConfig()) {
216
+ const file = getDirectSessionFile(sessionId, config);
217
+ if (!existsSync(file)) {
218
+ throw new PatchWardenError("session_not_found", `Direct session "${sessionId}" not found.`, "Call create_direct_session first to get a valid session_id.", true, { session_id: sessionId });
219
+ }
220
+ return mutateLockedJsonFileSync(file, (current) => {
221
+ const session = validateDirectSessionRecord(sessionId, current);
222
+ const next = mutation(session);
223
+ return { next, result: next };
224
+ }, {
225
+ // Session-record appends are short serialized writes. Give Windows
226
+ // antivirus-delayed lock detachment enough time to finish instead of
227
+ // dropping concurrent operation or verification evidence.
228
+ waitMs: 10_000,
229
+ busyError: () => new PatchWardenError("direct_session_busy", `Direct session "${sessionId}" is currently being updated.`, "Retry after the current Direct operation completes.", true, { session_id: sessionId }),
230
+ });
231
+ }
232
+ function directSessionBusyOptions(sessionId) {
233
+ return {
234
+ waitMs: 0,
235
+ busyError: () => new PatchWardenError("direct_session_busy", `Direct session "${sessionId}" already has a workspace operation in progress.`, "Wait for the active patch, sync, verification, or finalization to finish, then retry.", true, { session_id: sessionId }),
236
+ };
237
+ }
238
+ function validateDirectSessionRecord(sessionId, value) {
239
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
240
+ throw invalidDirectSessionRecord(sessionId, "session.json must contain an object");
241
+ }
242
+ const record = value;
243
+ if (record.session_id !== sessionId) {
244
+ throw invalidDirectSessionRecord(sessionId, "session_id does not match its directory");
245
+ }
246
+ if (typeof record.resolved_repo_path !== "string" || record.resolved_repo_path.trim() === "") {
247
+ throw invalidDirectSessionRecord(sessionId, "resolved_repo_path is missing");
248
+ }
249
+ if (typeof record.expires_at !== "string" || !Number.isFinite(Date.parse(record.expires_at))) {
250
+ throw invalidDirectSessionRecord(sessionId, "expires_at is invalid");
251
+ }
252
+ if (typeof record.finalized !== "boolean") {
253
+ throw invalidDirectSessionRecord(sessionId, "finalized must be a boolean");
254
+ }
255
+ if (!Array.isArray(record.operations) || !Array.isArray(record.verification_runs)) {
256
+ throw invalidDirectSessionRecord(sessionId, "operation or verification history is invalid");
257
+ }
258
+ return record;
259
+ }
169
260
  function formatSummaryMd(artifacts) {
170
261
  const lines = [
171
262
  "# Direct Session Change Summary",
@@ -174,6 +265,7 @@ function formatSummaryMd(artifacts) {
174
265
  `**Additions:** ${artifacts.additions}`,
175
266
  `**Deletions:** ${artifacts.deletions}`,
176
267
  `**Patch mode:** ${artifacts.patch_mode}`,
268
+ `**Diff redacted:** ${artifacts.diff_redacted === true ? "yes" : "no"}`,
177
269
  "",
178
270
  "## Artifact Hygiene",
179
271
  "",
@@ -1,10 +1,11 @@
1
- import { writeFileSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import { getConfig } from "../config.js";
4
3
  import { guardDirectCommand } from "../security/commandGuard.js";
5
4
  import { redactSensitiveContent } from "../security/contentRedaction.js";
6
5
  import { runSimpleProcess } from "../runner/simpleProcess.js";
6
+ import { resolveTrustedCommandLine } from "../runner/processSecurity.js";
7
7
  import { getDirectSessionDir } from "./directSessionStore.js";
8
+ import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
8
9
  const MAX_TAIL_CHARS = 10_000;
9
10
  const DEFAULT_TIMEOUT_SECONDS = 120;
10
11
  export async function runDirectVerification(input) {
@@ -13,7 +14,7 @@ export async function runDirectVerification(input) {
13
14
  const timeoutSeconds = input.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
14
15
  const timeoutMs = timeoutSeconds * 1000;
15
16
  // Parse command into command + args
16
- const parts = parseCommand(allowedCommand);
17
+ const parts = parseCommand(allowedCommand, input.resolvedRepoPath);
17
18
  if (!parts) {
18
19
  throw new Error(`Failed to parse command: "${allowedCommand}"`);
19
20
  }
@@ -73,27 +74,18 @@ export async function runDirectVerification(input) {
73
74
  ],
74
75
  spawn_error: result.spawnError,
75
76
  };
76
- writeFileSync(join(sessionDir, "verification.json"), JSON.stringify(verificationJson, null, 2), "utf-8");
77
+ atomicWriteJsonFileSync(join(sessionDir, "verification.json"), verificationJson);
77
78
  return { run, log_path: logPath };
78
79
  }
79
80
  // ── Helpers ────────────────────────────────────────────────────────
80
- function parseCommand(command) {
81
+ function parseCommand(command, cwd) {
81
82
  const trimmed = command.trim();
82
83
  if (!trimmed)
83
84
  return null;
84
- // Simple split by spaces - sufficient for allowlisted commands like "npm test"
85
- const parts = trimmed.split(/\s+/);
86
- // On Windows, npm/npx are batch files (.cmd) and spawn() cannot find them
87
- // without shell resolution. Wrap with cmd.exe /c for those commands.
88
- if (process.platform === "win32" && (parts[0] === "npm" || parts[0] === "npx")) {
89
- return {
90
- command: "cmd",
91
- args: ["/c", ...parts],
92
- };
93
- }
85
+ const invocation = resolveTrustedCommandLine(trimmed, cwd);
94
86
  return {
95
- command: parts[0],
96
- args: parts.slice(1),
87
+ command: invocation.command,
88
+ args: invocation.argsPrefix,
97
89
  };
98
90
  }
99
91
  function truncateTail(content, maxChars) {