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.
- package/README.en.md +83 -8
- package/README.md +78 -10
- package/dist/assessments/agentAssessor.d.ts +1 -1
- package/dist/assessments/agentAssessor.js +3 -3
- package/dist/assessments/assessmentStore.d.ts +1 -1
- package/dist/assessments/assessmentStore.js +2 -2
- package/dist/assessments/confirmCli.js +5 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +34 -0
- package/dist/control/middleware/auth.d.ts +10 -0
- package/dist/control/middleware/auth.js +8 -0
- package/dist/control/middleware/static.d.ts +3 -0
- package/dist/control/middleware/static.js +65 -0
- package/dist/control/routes/audit.d.ts +15 -0
- package/dist/control/routes/audit.js +277 -0
- package/dist/control/routes/evidence.d.ts +4 -0
- package/dist/control/routes/evidence.js +96 -0
- package/dist/control/routes/lineage.d.ts +3 -0
- package/dist/control/routes/lineage.js +71 -0
- package/dist/control/routes/policy.d.ts +3 -0
- package/dist/control/routes/policy.js +81 -0
- package/dist/control/routes/process.d.ts +5 -0
- package/dist/control/routes/process.js +200 -0
- package/dist/control/routes/sessions.d.ts +22 -0
- package/dist/control/routes/sessions.js +224 -0
- package/dist/control/routes/status.d.ts +6 -0
- package/dist/control/routes/status.js +250 -0
- package/dist/control/routes/taskActions.d.ts +21 -0
- package/dist/control/routes/taskActions.js +233 -0
- package/dist/control/routes/tasks.d.ts +20 -0
- package/dist/control/routes/tasks.js +310 -0
- package/dist/control/routes/workspace.d.ts +15 -0
- package/dist/control/routes/workspace.js +193 -0
- package/dist/control/runtime.d.ts +91 -0
- package/dist/control/runtime.js +392 -0
- package/dist/control/server.d.ts +13 -0
- package/dist/control/server.js +479 -0
- package/dist/control/shared.d.ts +35 -0
- package/dist/control/shared.js +288 -0
- package/dist/controlCenter.d.ts +6 -0
- package/dist/controlCenter.js +7 -2001
- package/dist/direct/directGuards.js +30 -8
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.d.ts +18 -1
- package/dist/doctor.js +579 -348
- package/dist/goal/goalReport.d.ts +54 -0
- package/dist/goal/goalReport.js +204 -0
- package/dist/goal/goalStatus.d.ts +6 -0
- package/dist/goal/specKitImport.d.ts +63 -0
- package/dist/goal/specKitImport.js +220 -0
- package/dist/goal/subgoalSync.js +2 -1
- package/dist/httpServer.js +15 -12
- package/dist/index.js +7 -4
- package/dist/logging.d.ts +7 -1
- package/dist/logging.js +8 -0
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/runner/changeCapture.d.ts +3 -3
- package/dist/runner/changeCapture.js +63 -39
- package/dist/runner/cli.js +7 -6
- package/dist/runner/postTaskCleanup.js +26 -2
- package/dist/runner/runTask.js +245 -221
- package/dist/runner/simpleProcess.js +4 -4
- package/dist/runner/watch.js +17 -14
- package/dist/security/contentRedaction.d.ts +6 -0
- package/dist/security/contentRedaction.js +22 -0
- package/dist/smoke-test.js +261 -255
- package/dist/test/unit/apply-patch.test.d.ts +1 -0
- package/dist/test/unit/apply-patch.test.js +225 -0
- package/dist/test/unit/create-task.test.d.ts +1 -0
- package/dist/test/unit/create-task.test.js +197 -0
- package/dist/test/unit/direct-guards.test.js +124 -9
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +224 -0
- package/dist/test/unit/get-task-status.test.d.ts +1 -0
- package/dist/test/unit/get-task-status.test.js +174 -0
- package/dist/test/unit/get-task-summary.test.d.ts +1 -0
- package/dist/test/unit/get-task-summary.test.js +146 -0
- package/dist/test/unit/goal-report.test.d.ts +1 -0
- package/dist/test/unit/goal-report.test.js +159 -0
- package/dist/test/unit/goal-subgoal-task.test.js +6 -6
- package/dist/test/unit/goal-tools-registry.test.js +6 -4
- package/dist/test/unit/path-guard.test.js +24 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/schema-drift-check.test.js +10 -9
- package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
- package/dist/test/unit/spec-kit-import.test.js +341 -0
- package/dist/test/unit/wait-for-task.test.d.ts +1 -0
- package/dist/test/unit/wait-for-task.test.js +144 -0
- package/dist/tools/auditTask.d.ts +8 -63
- package/dist/tools/auditTask.js +12 -8
- package/dist/tools/createDirectSession.d.ts +1 -1
- package/dist/tools/createDirectSession.js +2 -2
- package/dist/tools/createTask.d.ts +2 -2
- package/dist/tools/createTask.js +4 -4
- package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
- package/dist/tools/dispatch/coreDispatch.js +282 -0
- package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
- package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
- package/dist/tools/dispatch/directDispatch.d.ts +8 -0
- package/dist/tools/dispatch/directDispatch.js +115 -0
- package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
- package/dist/tools/dispatch/goalDispatch.js +91 -0
- package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
- package/dist/tools/dispatch/releaseDispatch.js +45 -0
- package/dist/tools/dispatch/types.d.ts +23 -0
- package/dist/tools/dispatch/types.js +15 -0
- package/dist/tools/evidencePack.d.ts +45 -0
- package/dist/tools/evidencePack.js +375 -0
- package/dist/tools/finalizeDirectSession.d.ts +1 -1
- package/dist/tools/finalizeDirectSession.js +3 -3
- package/dist/tools/goalSubgoalTask.d.ts +1 -1
- package/dist/tools/goalSubgoalTask.js +2 -2
- package/dist/tools/healthCheck.js +3 -3
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/registry.d.ts +3 -3
- package/dist/tools/registry.js +333 -402
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/retryTask.d.ts +2 -2
- package/dist/tools/retryTask.js +2 -2
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeViews.d.ts +8 -2
- package/dist/tools/safeViews.js +4 -2
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +6 -0
- package/dist/tools/toolRegistry.js +132 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/CODE_WIKI.md +983 -0
- package/docs/agentseal-integration.md +150 -0
- package/docs/architecture.md +63 -0
- package/docs/assets/patchwarden-oss-demo.gif +0 -0
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/docs/dashboard-overview.md +86 -0
- package/docs/demo.md +8 -0
- package/docs/direct-session-workflow.md +98 -0
- package/docs/evidence-pack-schema.md +215 -0
- package/docs/execution-plan-2026-07-09.md +315 -0
- package/docs/lineage-evidence-pack-workflow.md +127 -0
- package/docs/mcp-inspector-testing.md +200 -0
- package/docs/open-source-application.md +162 -0
- package/docs/opencode-worker.md +151 -0
- package/docs/openhands-worker.md +181 -0
- package/docs/release-evidence.md +72 -0
- package/docs/roadmap-execution-and-acceptance.md +365 -0
- package/docs/spec-kit-integration.md +131 -0
- package/docs/task-safe-review-workflow.md +98 -0
- package/docs/threat-model.md +79 -0
- package/docs/user-feedback.md +40 -0
- package/docs/why-patchwarden.md +110 -0
- package/package.json +3 -3
- package/scripts/checks/control-center-smoke.js +443 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/lifecycle-smoke.js +29 -23
- package/scripts/checks/mcp-manifest-check.js +12 -0
- package/scripts/checks/mcp-smoke.js +33 -7
- package/scripts/checks/watcher-supervisor-smoke.js +1 -1
- package/scripts/generate-demo-gif.py +320 -0
- package/src/assessments/agentAssessor.ts +3 -3
- package/src/assessments/assessmentStore.ts +2 -2
- package/src/assessments/confirmCli.ts +5 -4
- package/src/config.ts +37 -0
- package/src/control/middleware/auth.ts +17 -0
- package/src/control/middleware/static.ts +71 -0
- package/src/control/routes/audit.ts +321 -0
- package/src/control/routes/evidence.ts +107 -0
- package/src/control/routes/lineage.ts +92 -0
- package/src/control/routes/policy.ts +81 -0
- package/src/control/routes/process.ts +204 -0
- package/src/control/routes/sessions.ts +251 -0
- package/src/control/routes/status.ts +325 -0
- package/src/control/routes/taskActions.ts +248 -0
- package/src/control/routes/tasks.ts +323 -0
- package/src/control/routes/workspace.ts +203 -0
- package/src/control/runtime.ts +472 -0
- package/src/control/server.ts +471 -0
- package/src/control/shared.ts +294 -0
- package/src/controlCenter.ts +7 -2150
- package/src/direct/directGuards.ts +28 -7
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +741 -481
- package/src/goal/goalReport.ts +271 -0
- package/src/goal/goalStatus.ts +6 -0
- package/src/goal/specKitImport.ts +355 -0
- package/src/goal/subgoalSync.ts +4 -2
- package/src/httpServer.ts +17 -14
- package/src/index.ts +7 -4
- package/src/logging.ts +10 -1
- package/src/policy/projectPolicy.ts +344 -0
- package/src/runner/changeCapture.ts +70 -42
- package/src/runner/cli.ts +7 -6
- package/src/runner/postTaskCleanup.ts +26 -2
- package/src/runner/runTask.ts +325 -223
- package/src/runner/simpleProcess.ts +4 -4
- package/src/runner/watch.ts +17 -14
- package/src/security/contentRedaction.ts +29 -0
- package/src/smoke-test.ts +254 -252
- package/src/test/unit/apply-patch.test.ts +293 -0
- package/src/test/unit/create-task.test.ts +255 -0
- package/src/test/unit/direct-guards.test.ts +178 -8
- package/src/test/unit/evidence-pack.test.ts +251 -0
- package/src/test/unit/get-task-status.test.ts +203 -0
- package/src/test/unit/get-task-summary.test.ts +173 -0
- package/src/test/unit/goal-report.test.ts +189 -0
- package/src/test/unit/goal-subgoal-task.test.ts +6 -6
- package/src/test/unit/goal-tools-registry.test.ts +7 -5
- package/src/test/unit/path-guard.test.ts +30 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/schema-drift-check.test.ts +11 -9
- package/src/test/unit/spec-kit-import.test.ts +429 -0
- package/src/test/unit/wait-for-task.test.ts +176 -0
- package/src/tools/auditTask.ts +99 -59
- package/src/tools/createDirectSession.ts +3 -3
- package/src/tools/createTask.ts +7 -7
- package/src/tools/dispatch/coreDispatch.ts +374 -0
- package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
- package/src/tools/dispatch/directDispatch.ts +167 -0
- package/src/tools/dispatch/goalDispatch.ts +127 -0
- package/src/tools/dispatch/releaseDispatch.ts +65 -0
- package/src/tools/dispatch/types.ts +24 -0
- package/src/tools/evidencePack.ts +490 -0
- package/src/tools/finalizeDirectSession.ts +4 -4
- package/src/tools/goalSubgoalTask.ts +2 -2
- package/src/tools/healthCheck.ts +3 -3
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/registry.ts +352 -509
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/retryTask.ts +2 -2
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeViews.ts +4 -2
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +6 -0
- package/src/tools/toolRegistry.ts +132 -0
- package/src/version.ts +2 -2
- package/ui/pages/audit.html +192 -3
- package/ui/pages/dashboard.html +1070 -14
- package/ui/pages/direct-sessions.html +505 -53
- package/ui/pages/task-detail.html +456 -438
- 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
|
-
|
|
34
|
+
config.httpPort ||
|
|
34
35
|
7331;
|
|
35
36
|
const host = "127.0.0.1";
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
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
|
-
|
|
49
|
+
logger.info(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
|
|
49
50
|
} else {
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|