patchwarden 1.1.0 → 1.5.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 (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
package/src/httpServer.ts CHANGED
@@ -25,29 +25,30 @@ import { registerTools } from "./tools/registry.js";
25
25
  import { healthCheck } from "./tools/healthCheck.js";
26
26
  import { getToolCatalogSnapshot } from "./tools/registry.js";
27
27
  import { PATCHWARDEN_VERSION } from "./version.js";
28
+ import { logger } from "./logging.js";
28
29
 
29
30
  // ── Bootstrap ─────────────────────────────────────────────────────
30
31
 
31
32
  const config = loadConfig();
32
33
  const port = parseInt(process.env.PATCHWARDEN_HTTP_PORT || "") ||
33
- (config as any).httpPort ||
34
+ config.httpPort ||
34
35
  7331;
35
36
  const host = "127.0.0.1";
36
37
 
37
- console.error(`[patchwarden-http] Workspace: ${config.workspaceRoot}`);
38
- console.error(`[patchwarden-http] Listening: http://${host}:${port}/mcp`);
39
- console.error(`[patchwarden-http] ⚠️ Bound to 127.0.0.1 only — not exposed to network`);
38
+ logger.info(`[patchwarden-http] Workspace: ${config.workspaceRoot}`);
39
+ logger.info(`[patchwarden-http] Listening: http://${host}:${port}/mcp`);
40
+ logger.info(`[patchwarden-http] ⚠️ Bound to 127.0.0.1 only — not exposed to network`);
40
41
 
41
42
  // ── Owner token (optional) ────────────────────────────────────────
42
43
 
43
- const httpCfg = (config as any).http || {};
44
+ const httpCfg = config.http || {};
44
45
  const ownerTokenEnv = httpCfg.ownerTokenEnv || "PATCHWARDEN_OWNER_TOKEN";
45
46
  const ownerToken = process.env[ownerTokenEnv] || "";
46
47
 
47
48
  if (ownerToken) {
48
- console.error(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
49
+ logger.info(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
49
50
  } else {
50
- console.error(`[patchwarden-http] ⚠️ No owner token set — all local requests accepted`);
51
+ logger.info(`[patchwarden-http] ⚠️ No owner token set — all local requests accepted`);
51
52
  }
52
53
 
53
54
  function checkOwnerToken(req: IncomingMessage): boolean {
@@ -142,7 +143,9 @@ async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Prom
142
143
  await mcpServer.connect(transport);
143
144
  await transport.handleRequest(req, res);
144
145
  } catch (err) {
145
- console.error("[patchwarden-http] Request error:", err);
146
+ logger.error("[patchwarden-http] Request error", {
147
+ error: err instanceof Error ? err.message : String(err),
148
+ });
146
149
  if (!res.headersSent) {
147
150
  res.writeHead(500, { "Content-Type": "application/json" });
148
151
  res.end(JSON.stringify({ error: "Internal server error" }));
@@ -253,22 +256,22 @@ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse
253
256
 
254
257
  httpServer.on("error", (err: NodeJS.ErrnoException) => {
255
258
  if (err.code === "EADDRINUSE") {
256
- console.error(`[patchwarden-http] Fatal: port ${port} is already in use on ${host}.`);
257
- console.error("[patchwarden-http] Stop the other PatchWarden HTTP instance or change httpPort in patchwarden.config.json.");
259
+ logger.fatal(`[patchwarden-http] Fatal: port ${port} is already in use on ${host}.`);
260
+ logger.fatal("[patchwarden-http] Stop the other PatchWarden HTTP instance or change httpPort in patchwarden.config.json.");
258
261
  } else {
259
- console.error(`[patchwarden-http] Fatal: ${err.message}`);
262
+ logger.fatal(`[patchwarden-http] Fatal: ${err.message}`);
260
263
  }
261
264
  process.exit(1);
262
265
  });
263
266
 
264
267
  httpServer.listen(port, host, () => {
265
- console.error(`[patchwarden-http] ✅ Ready`);
266
- console.error(`[patchwarden-http] Admin: http://${host}:${port}/admin/tasks/:id/accept`);
268
+ logger.info(`[patchwarden-http] ✅ Ready`);
269
+ logger.info(`[patchwarden-http] Admin: http://${host}:${port}/admin/tasks/:id/accept`);
267
270
  });
268
271
 
269
272
  // Graceful shutdown
270
273
  process.on("SIGINT", () => {
271
- console.error("[patchwarden-http] Shutting down...");
274
+ logger.info("[patchwarden-http] Shutting down...");
272
275
  httpServer.close(() => process.exit(0));
273
276
  });
274
277
 
package/src/index.ts CHANGED
@@ -12,11 +12,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
  import { loadConfig } from "./config.js";
13
13
  import { registerTools } from "./tools/registry.js";
14
14
  import { PATCHWARDEN_VERSION } from "./version.js";
15
+ import { logger } from "./logging.js";
15
16
 
16
17
  const config = loadConfig();
17
18
 
18
- console.error(`[patchwarden] Workspace: ${config.workspaceRoot}`);
19
- console.error(`[patchwarden] Transport: stdio`);
19
+ logger.info(`[patchwarden] Workspace: ${config.workspaceRoot}`);
20
+ logger.info("[patchwarden] Transport: stdio");
20
21
 
21
22
  const server = new Server(
22
23
  { name: "patchwarden", version: PATCHWARDEN_VERSION },
@@ -27,8 +28,10 @@ registerTools(server);
27
28
 
28
29
  const transport = new StdioServerTransport();
29
30
  server.connect(transport).catch((err) => {
30
- console.error("[patchwarden] Fatal:", err);
31
+ logger.fatal("[patchwarden] Fatal", {
32
+ error: err instanceof Error ? err.message : String(err),
33
+ });
31
34
  process.exit(1);
32
35
  });
33
36
 
34
- console.error("[patchwarden] MCP server ready on stdio");
37
+ logger.info("[patchwarden] MCP server ready on stdio");
package/src/logging.ts CHANGED
@@ -7,7 +7,7 @@ import { getConfig } from "./config.js";
7
7
 
8
8
  // ── Types ─────────────────────────────────────────────────────────
9
9
 
10
- export type LogLevel = "info" | "warn" | "error" | "audit";
10
+ export type LogLevel = "info" | "warn" | "error" | "fatal" | "audit";
11
11
 
12
12
  export interface LogEntry {
13
13
  timestamp: string;
@@ -90,6 +90,15 @@ export class Logger {
90
90
  emit({ timestamp: new Date().toISOString(), level: "error", message, ...context });
91
91
  }
92
92
 
93
+ /**
94
+ * Emit a fatal-level log entry. Use this immediately before a process
95
+ * exits with a non-zero status, to record the unrecoverable condition.
96
+ * Level is higher than `error`.
97
+ */
98
+ fatal(message: string, context?: Record<string, unknown>): void {
99
+ emit({ timestamp: new Date().toISOString(), level: "fatal", message, ...context });
100
+ }
101
+
93
102
  /**
94
103
  * Emit a tool-call audit log entry.
95
104
  *
@@ -0,0 +1,344 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import {
4
+ getAllConfiguredDirectCommands,
5
+ getAllConfiguredTestCommands,
6
+ getConfig,
7
+ type PatchWardenConfig,
8
+ } from "../config.js";
9
+ import { guardTestCommand } from "../security/commandGuard.js";
10
+ import { guardWorkspacePath } from "../security/pathGuard.js";
11
+ import { isSensitivePath } from "../security/sensitiveGuard.js";
12
+
13
+ export interface ProjectPolicy {
14
+ allowed_commands: string[];
15
+ auto_cleanup: {
16
+ enabled: boolean;
17
+ patterns: string[];
18
+ exclude: string[];
19
+ };
20
+ high_risk_commands: string[];
21
+ protected_paths: string[];
22
+ release_mode: {
23
+ version_source: string;
24
+ required_commands: string[];
25
+ };
26
+ }
27
+
28
+ export interface ProjectPolicyIssue {
29
+ code: string;
30
+ severity: "error" | "warn";
31
+ field: string;
32
+ message: string;
33
+ }
34
+
35
+ export interface ReleaseReadinessSummary {
36
+ version_source: string;
37
+ version: string | null;
38
+ package_json_version: string | null;
39
+ package_name: string | null;
40
+ version_consistent: boolean | null;
41
+ required_commands: Array<{
42
+ command: string;
43
+ allowed: boolean;
44
+ reason: string | null;
45
+ }>;
46
+ }
47
+
48
+ export interface ProjectPolicySummary {
49
+ repo_path: string;
50
+ resolved_repo_path: string;
51
+ policy_path: string;
52
+ exists: boolean;
53
+ valid: boolean;
54
+ effective_policy: ProjectPolicy;
55
+ issues: ProjectPolicyIssue[];
56
+ release_readiness: ReleaseReadinessSummary;
57
+ }
58
+
59
+ const DEFAULT_POLICY: ProjectPolicy = {
60
+ allowed_commands: [],
61
+ auto_cleanup: {
62
+ enabled: true,
63
+ patterns: ["release-artifact-manifest.json", "frontend/dist", "release_packages"],
64
+ exclude: [".git", ".patchwarden", "node_modules", "docs", "samples"],
65
+ },
66
+ high_risk_commands: ["npm publish", "git push", "git tag", "gh release create"],
67
+ protected_paths: [".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json"],
68
+ release_mode: {
69
+ version_source: "package.json",
70
+ required_commands: ["npm run build", "npm test"],
71
+ },
72
+ };
73
+
74
+ const DANGEROUS_COMMAND_RE = /\b(?:publish|push|tag|release\s+create|deploy)\b/i;
75
+ const DANGEROUS_PATTERN_RE = /(^|[\\/])(?:\.git|node_modules)([\\/]|$)|^\*\*$|^\/|^[A-Za-z]:[\\/]/i;
76
+
77
+ export function getProjectPolicySummary(repoPathInput: string): ProjectPolicySummary {
78
+ const config = getConfig();
79
+ const repoPath = guardWorkspacePath(repoPathInput, config.workspaceRoot);
80
+ const policyPath = join(repoPath, ".patchwarden", "project-policy.json");
81
+ const issues: ProjectPolicyIssue[] = [];
82
+ let rawPolicy: Partial<ProjectPolicy> = {};
83
+ let exists = false;
84
+
85
+ if (existsSync(policyPath)) {
86
+ exists = true;
87
+ try {
88
+ const raw = readFileSync(policyPath, "utf-8").replace(/^\uFEFF/, "");
89
+ rawPolicy = JSON.parse(raw) as Partial<ProjectPolicy>;
90
+ } catch (err) {
91
+ issues.push({
92
+ code: "policy_json_invalid",
93
+ severity: "error",
94
+ field: ".patchwarden/project-policy.json",
95
+ message: `Project policy is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
96
+ });
97
+ }
98
+ }
99
+
100
+ const effective = normalizePolicy(rawPolicy, issues);
101
+ validateProjectPolicy(repoPath, effective, issues, config);
102
+
103
+ return {
104
+ repo_path: repoPathInput,
105
+ resolved_repo_path: repoPath,
106
+ policy_path: ".patchwarden/project-policy.json",
107
+ exists,
108
+ valid: !issues.some((issue) => issue.severity === "error"),
109
+ effective_policy: effective,
110
+ issues,
111
+ release_readiness: buildReleaseReadiness(repoPath, effective, config),
112
+ };
113
+ }
114
+
115
+ export function getDefaultProjectPolicy(): ProjectPolicy {
116
+ return clonePolicy(DEFAULT_POLICY);
117
+ }
118
+
119
+ export function commandAllowedByProjectPolicy(command: string, summary: ProjectPolicySummary): { allowed: boolean; reason: string | null } {
120
+ const trimmed = command.trim();
121
+ if (!trimmed) return { allowed: false, reason: "empty_command" };
122
+ if (summary.effective_policy.high_risk_commands.includes(trimmed) || DANGEROUS_COMMAND_RE.test(trimmed)) {
123
+ return { allowed: false, reason: "high_risk_command" };
124
+ }
125
+ if (
126
+ summary.effective_policy.allowed_commands.length > 0 &&
127
+ !summary.effective_policy.allowed_commands.includes(trimmed)
128
+ ) {
129
+ return { allowed: false, reason: "not_in_project_policy_allowed_commands" };
130
+ }
131
+ const readiness = summary.release_readiness.required_commands.find((entry) => entry.command === trimmed);
132
+ if (readiness && !readiness.allowed) {
133
+ return { allowed: false, reason: readiness.reason || "not_allowlisted_by_patchwarden_config" };
134
+ }
135
+ return { allowed: true, reason: null };
136
+ }
137
+
138
+ export function isProtectedByProjectPolicy(relPath: string, policy: ProjectPolicy): boolean {
139
+ const normalized = normalizeRelPath(relPath);
140
+ if (!normalized) return true;
141
+ if (isSensitivePath(normalized)) return true;
142
+ return policy.protected_paths.some((pattern) => pathMatchesPattern(normalized, pattern));
143
+ }
144
+
145
+ export function resolveVersionFromPolicy(repoPath: string, policy: ProjectPolicy): string | null {
146
+ const source = policy.release_mode.version_source || "package.json";
147
+ if (source === "package.json") return readPackageJson(repoPath).version;
148
+ if (source === "src/version.ts") {
149
+ const versionFile = safeRepoFile(repoPath, "src/version.ts");
150
+ if (!versionFile) return null;
151
+ const text = readFileSync(versionFile, "utf-8");
152
+ const match = text.match(/PATCHWARDEN_VERSION\s*=\s*["']([^"']+)["']/);
153
+ return match ? match[1] : null;
154
+ }
155
+ const file = safeRepoFile(repoPath, source);
156
+ if (!file) return null;
157
+ const text = readFileSync(file, "utf-8").replace(/^\uFEFF/, "").trim();
158
+ return text.split(/\r?\n/)[0]?.trim() || null;
159
+ }
160
+
161
+ export function readPackageJson(repoPath: string): { name: string | null; version: string | null; githubRepo: string | null } {
162
+ const packagePath = join(repoPath, "package.json");
163
+ if (!existsSync(packagePath)) return { name: null, version: null, githubRepo: null };
164
+ try {
165
+ const data = JSON.parse(readFileSync(packagePath, "utf-8").replace(/^\uFEFF/, ""));
166
+ return {
167
+ name: typeof data.name === "string" ? data.name : null,
168
+ version: typeof data.version === "string" ? data.version : null,
169
+ githubRepo: parseGithubRepo(data.repository),
170
+ };
171
+ } catch {
172
+ return { name: null, version: null, githubRepo: null };
173
+ }
174
+ }
175
+
176
+ function normalizePolicy(raw: Partial<ProjectPolicy>, issues: ProjectPolicyIssue[]): ProjectPolicy {
177
+ const policy = clonePolicy(DEFAULT_POLICY);
178
+ const object = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
179
+ policy.allowed_commands = stringArrayOrDefault(object.allowed_commands, policy.allowed_commands, "allowed_commands", issues);
180
+ policy.high_risk_commands = stringArrayOrDefault(object.high_risk_commands, policy.high_risk_commands, "high_risk_commands", issues);
181
+ policy.protected_paths = stringArrayOrDefault(object.protected_paths, policy.protected_paths, "protected_paths", issues);
182
+
183
+ if (object.auto_cleanup && typeof object.auto_cleanup === "object" && !Array.isArray(object.auto_cleanup)) {
184
+ const cleanup = object.auto_cleanup as Partial<ProjectPolicy["auto_cleanup"]>;
185
+ if (cleanup.enabled !== undefined && typeof cleanup.enabled !== "boolean") {
186
+ issues.push({ code: "invalid_type", severity: "error", field: "auto_cleanup.enabled", message: "auto_cleanup.enabled must be a boolean." });
187
+ } else if (cleanup.enabled !== undefined) {
188
+ policy.auto_cleanup.enabled = cleanup.enabled;
189
+ }
190
+ policy.auto_cleanup.patterns = stringArrayOrDefault(cleanup.patterns, policy.auto_cleanup.patterns, "auto_cleanup.patterns", issues);
191
+ policy.auto_cleanup.exclude = stringArrayOrDefault(cleanup.exclude, policy.auto_cleanup.exclude, "auto_cleanup.exclude", issues);
192
+ }
193
+
194
+ if (object.release_mode && typeof object.release_mode === "object" && !Array.isArray(object.release_mode)) {
195
+ const releaseMode = object.release_mode as Partial<ProjectPolicy["release_mode"]>;
196
+ if (releaseMode.version_source !== undefined && typeof releaseMode.version_source !== "string") {
197
+ issues.push({ code: "invalid_type", severity: "error", field: "release_mode.version_source", message: "release_mode.version_source must be a string." });
198
+ } else if (releaseMode.version_source) {
199
+ policy.release_mode.version_source = releaseMode.version_source.trim();
200
+ }
201
+ policy.release_mode.required_commands = stringArrayOrDefault(releaseMode.required_commands, policy.release_mode.required_commands, "release_mode.required_commands", issues);
202
+ }
203
+
204
+ return policy;
205
+ }
206
+
207
+ function validateProjectPolicy(repoPath: string, policy: ProjectPolicy, issues: ProjectPolicyIssue[], config: PatchWardenConfig): void {
208
+ for (const [field, commands] of [
209
+ ["allowed_commands", policy.allowed_commands],
210
+ ["high_risk_commands", policy.high_risk_commands],
211
+ ["release_mode.required_commands", policy.release_mode.required_commands],
212
+ ] as const) {
213
+ for (const command of commands) {
214
+ if (field !== "high_risk_commands" && DANGEROUS_COMMAND_RE.test(command)) {
215
+ issues.push({ code: "high_risk_command", severity: "error", field, message: `Command is release/destructive risk and cannot be auto-executed: ${command}` });
216
+ }
217
+ if (field !== "high_risk_commands") {
218
+ try {
219
+ guardTestCommand(command, config, repoPath);
220
+ } catch {
221
+ issues.push({ code: "command_not_allowlisted", severity: "warn", field, message: `Command is not allowed by existing PatchWarden config: ${command}` });
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ for (const field of ["auto_cleanup.patterns", "auto_cleanup.exclude", "protected_paths"] as const) {
228
+ const values = field === "auto_cleanup.patterns"
229
+ ? policy.auto_cleanup.patterns
230
+ : field === "auto_cleanup.exclude"
231
+ ? policy.auto_cleanup.exclude
232
+ : policy.protected_paths;
233
+ for (const value of values) {
234
+ if (!value.trim()) {
235
+ issues.push({ code: "empty_path_pattern", severity: "error", field, message: "Path patterns must be non-empty." });
236
+ continue;
237
+ }
238
+ const blocksCriticalDirectory = field === "auto_cleanup.patterns" && DANGEROUS_PATTERN_RE.test(value);
239
+ if (value.includes("\0") || value.includes("..") || isAbsolute(value) || blocksCriticalDirectory) {
240
+ issues.push({ code: "unsafe_path_pattern", severity: "error", field, message: `Unsafe path pattern rejected: ${value}` });
241
+ }
242
+ }
243
+ }
244
+
245
+ const versionSource = policy.release_mode.version_source;
246
+ if (versionSource && versionSource !== "package.json" && versionSource !== "src/version.ts") {
247
+ const resolved = safeRepoFile(repoPath, versionSource);
248
+ if (!resolved) {
249
+ issues.push({ code: "version_source_invalid", severity: "error", field: "release_mode.version_source", message: `Version source is outside repo, sensitive, or missing: ${versionSource}` });
250
+ }
251
+ }
252
+ }
253
+
254
+ function buildReleaseReadiness(repoPath: string, policy: ProjectPolicy, config: PatchWardenConfig): ReleaseReadinessSummary {
255
+ const packageJson = readPackageJson(repoPath);
256
+ let version: string | null = null;
257
+ try {
258
+ version = resolveVersionFromPolicy(repoPath, policy);
259
+ } catch {
260
+ version = null;
261
+ }
262
+ return {
263
+ version_source: policy.release_mode.version_source,
264
+ version,
265
+ package_json_version: packageJson.version,
266
+ package_name: packageJson.name,
267
+ version_consistent: version && packageJson.version ? version === packageJson.version : null,
268
+ required_commands: policy.release_mode.required_commands.map((command) => {
269
+ try {
270
+ guardTestCommand(command, config, repoPath);
271
+ return { command, allowed: true, reason: null };
272
+ } catch (err) {
273
+ return { command, allowed: false, reason: err instanceof Error ? err.message : String(err) };
274
+ }
275
+ }),
276
+ };
277
+ }
278
+
279
+ function safeRepoFile(repoPath: string, relPath: string): string | null {
280
+ const normalized = normalizeRelPath(relPath);
281
+ if (!normalized || isSensitivePath(normalized)) return null;
282
+ const candidate = resolve(repoPath, normalized);
283
+ const rel = relative(repoPath, candidate);
284
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) return null;
285
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return null;
286
+ const parent = dirname(candidate);
287
+ const parentRel = relative(repoPath, parent);
288
+ if (parentRel === ".." || parentRel.startsWith(`..${sep}`)) return null;
289
+ return candidate;
290
+ }
291
+
292
+ function stringArrayOrDefault(value: unknown, fallback: string[], field: string, issues: ProjectPolicyIssue[]): string[] {
293
+ if (value === undefined) return [...fallback];
294
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
295
+ issues.push({ code: "invalid_type", severity: "error", field, message: `${field} must be an array of strings.` });
296
+ return [...fallback];
297
+ }
298
+ return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
299
+ }
300
+
301
+ function clonePolicy(policy: ProjectPolicy): ProjectPolicy {
302
+ return {
303
+ allowed_commands: [...policy.allowed_commands],
304
+ auto_cleanup: {
305
+ enabled: policy.auto_cleanup.enabled,
306
+ patterns: [...policy.auto_cleanup.patterns],
307
+ exclude: [...policy.auto_cleanup.exclude],
308
+ },
309
+ high_risk_commands: [...policy.high_risk_commands],
310
+ protected_paths: [...policy.protected_paths],
311
+ release_mode: {
312
+ version_source: policy.release_mode.version_source,
313
+ required_commands: [...policy.release_mode.required_commands],
314
+ },
315
+ };
316
+ }
317
+
318
+ function normalizeRelPath(value: string): string {
319
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
320
+ }
321
+
322
+ function pathMatchesPattern(relPath: string, pattern: string): boolean {
323
+ const normalizedPattern = normalizeRelPath(pattern);
324
+ if (!normalizedPattern) return false;
325
+ if (normalizedPattern.includes("*")) {
326
+ const escaped = normalizedPattern
327
+ .split("*")
328
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
329
+ .join("[^/]*");
330
+ return new RegExp(`^${escaped}$`, "i").test(relPath);
331
+ }
332
+ return relPath.toLowerCase() === normalizedPattern.toLowerCase() ||
333
+ relPath.toLowerCase().startsWith(`${normalizedPattern.toLowerCase()}/`);
334
+ }
335
+
336
+ function parseGithubRepo(repository: unknown): string | null {
337
+ const raw = typeof repository === "string"
338
+ ? repository
339
+ : repository && typeof repository === "object" && typeof (repository as any).url === "string"
340
+ ? (repository as any).url
341
+ : "";
342
+ const match = raw.match(/github\.com[:/](.+?\/.+?)(?:\.git)?(?:[#?].*)?$/i);
343
+ return match ? match[1] : null;
344
+ }