patchwarden 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +105 -7
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/unit-tests.js +36 -0
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createWriteStream, writeFileSync, readFileSync, existsSync, type WriteStream } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const GRACEFUL_KILL_MS = 2000;
|
|
5
|
+
|
|
6
|
+
export interface SimpleProcessOptions {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
cwd: string;
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
maxStdoutBytes?: number;
|
|
12
|
+
maxStderrBytes?: number;
|
|
13
|
+
stdoutPath?: string;
|
|
14
|
+
stderrPath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SimpleProcessResult {
|
|
18
|
+
exitCode: number | null;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
spawnError: string | null;
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
stdoutTruncated: boolean;
|
|
24
|
+
stderrTruncated: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MAX_STDOUT = 524288;
|
|
28
|
+
const DEFAULT_MAX_STDERR = 131072;
|
|
29
|
+
|
|
30
|
+
export function runSimpleProcessSync(options: SimpleProcessOptions): SimpleProcessResult {
|
|
31
|
+
const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
|
|
32
|
+
const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
|
|
33
|
+
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = spawnSync(options.command, options.args, {
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
timeout: options.timeoutMs,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
maxBuffer: maxStdout + 1024,
|
|
42
|
+
encoding: "utf-8",
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
exitCode: null,
|
|
47
|
+
stdout: "",
|
|
48
|
+
stderr: "",
|
|
49
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
50
|
+
timedOut: false,
|
|
51
|
+
stdoutTruncated: false,
|
|
52
|
+
stderrTruncated: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stdout = result.stdout || "";
|
|
57
|
+
const stderr = result.stderr || "";
|
|
58
|
+
const timedOut: boolean = Boolean(
|
|
59
|
+
result.signal === "SIGTERM" || result.signal === "SIGKILL" ||
|
|
60
|
+
(result.signal !== null && result.status === null) ||
|
|
61
|
+
(result.error && (result.error as any).code === "ETIMEDOUT")
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (options.stdoutPath && stdout) {
|
|
65
|
+
try { writeFileSyncAppend(options.stdoutPath, stdout); } catch {}
|
|
66
|
+
}
|
|
67
|
+
if (options.stderrPath && stderr) {
|
|
68
|
+
try { writeFileSyncAppend(options.stderrPath, stderr); } catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
exitCode: result.status,
|
|
73
|
+
stdout: stdout.length > maxStdout ? stdout.slice(0, maxStdout) : stdout,
|
|
74
|
+
stderr: stderr.length > maxStderr ? stderr.slice(0, maxStderr) : stderr,
|
|
75
|
+
// If timed out, don't report spawnError — it's a timeout, not a spawn failure
|
|
76
|
+
spawnError: timedOut ? null : (result.error ? result.error.message : null),
|
|
77
|
+
timedOut,
|
|
78
|
+
stdoutTruncated: stdout.length > maxStdout,
|
|
79
|
+
stderrTruncated: stderr.length > maxStderr,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runSimpleProcess(options: SimpleProcessOptions): Promise<SimpleProcessResult> {
|
|
84
|
+
const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
|
|
85
|
+
const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
|
|
86
|
+
const deadlineMs = Date.now() + options.timeoutMs;
|
|
87
|
+
|
|
88
|
+
let child: ChildProcess;
|
|
89
|
+
try {
|
|
90
|
+
child = spawn(options.command, options.args, {
|
|
91
|
+
cwd: options.cwd,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
detached: process.platform !== "win32",
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return {
|
|
98
|
+
exitCode: null,
|
|
99
|
+
stdout: "",
|
|
100
|
+
stderr: "",
|
|
101
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
102
|
+
timedOut: false,
|
|
103
|
+
stdoutTruncated: false,
|
|
104
|
+
stderrTruncated: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stdoutStream = options.stdoutPath ? createWriteStream(options.stdoutPath, { flags: "a" }) : null;
|
|
109
|
+
const stderrStream = options.stderrPath ? createWriteStream(options.stderrPath, { flags: "a" }) : null;
|
|
110
|
+
|
|
111
|
+
let stdoutBuf = Buffer.alloc(0);
|
|
112
|
+
let stderrBuf = Buffer.alloc(0);
|
|
113
|
+
let stdoutTruncated = false;
|
|
114
|
+
let stderrTruncated = false;
|
|
115
|
+
let spawnError: string | null = null;
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
let terminationStarted = false;
|
|
119
|
+
|
|
120
|
+
const requestTermination = (reason: "timeout" | "force", force: boolean) => {
|
|
121
|
+
if (terminationStarted) return;
|
|
122
|
+
terminationStarted = true;
|
|
123
|
+
if (reason === "timeout") timedOut = true;
|
|
124
|
+
if (force) {
|
|
125
|
+
forceKill(child);
|
|
126
|
+
} else {
|
|
127
|
+
gracefulKill(child);
|
|
128
|
+
forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
133
|
+
stdoutStream?.write(chunk);
|
|
134
|
+
if (stdoutBuf.length < maxStdout) {
|
|
135
|
+
const remaining = maxStdout - stdoutBuf.length;
|
|
136
|
+
if (chunk.length <= remaining) {
|
|
137
|
+
stdoutBuf = Buffer.concat([stdoutBuf, chunk]);
|
|
138
|
+
} else {
|
|
139
|
+
stdoutBuf = Buffer.concat([stdoutBuf, chunk.slice(0, remaining)]);
|
|
140
|
+
stdoutTruncated = true;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
stdoutTruncated = true;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
148
|
+
stderrStream?.write(chunk);
|
|
149
|
+
if (stderrBuf.length < maxStderr) {
|
|
150
|
+
const remaining = maxStderr - stderrBuf.length;
|
|
151
|
+
if (chunk.length <= remaining) {
|
|
152
|
+
stderrBuf = Buffer.concat([stderrBuf, chunk]);
|
|
153
|
+
} else {
|
|
154
|
+
stderrBuf = Buffer.concat([stderrBuf, chunk.slice(0, remaining)]);
|
|
155
|
+
stderrTruncated = true;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
stderrTruncated = true;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const timeoutTimer = setTimeout(() => requestTermination("timeout", true), options.timeoutMs);
|
|
163
|
+
|
|
164
|
+
const exitCode = await new Promise<number | null>((resolveExit) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
const finish = (code: number | null) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
resolveExit(code);
|
|
170
|
+
};
|
|
171
|
+
child.once("close", (code) => finish(code));
|
|
172
|
+
child.once("exit", (code) => finish(code));
|
|
173
|
+
child.once("error", (error) => {
|
|
174
|
+
spawnError = error.message;
|
|
175
|
+
finish(null);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
clearTimeout(timeoutTimer);
|
|
180
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
181
|
+
stdoutStream?.destroy();
|
|
182
|
+
stderrStream?.destroy();
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
exitCode,
|
|
186
|
+
stdout: stdoutBuf.toString("utf-8"),
|
|
187
|
+
stderr: stderrBuf.toString("utf-8"),
|
|
188
|
+
spawnError,
|
|
189
|
+
timedOut,
|
|
190
|
+
stdoutTruncated,
|
|
191
|
+
stderrTruncated,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function gracefulKill(child: ChildProcess): void {
|
|
196
|
+
try {
|
|
197
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM");
|
|
198
|
+
else child.kill("SIGTERM");
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function forceKill(child: ChildProcess): void {
|
|
203
|
+
if (!child.pid) return;
|
|
204
|
+
try {
|
|
205
|
+
if (process.platform === "win32") {
|
|
206
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
|
207
|
+
stdio: "ignore",
|
|
208
|
+
timeout: 5000,
|
|
209
|
+
windowsHide: true,
|
|
210
|
+
});
|
|
211
|
+
if (result.status !== 0) child.kill("SIGKILL");
|
|
212
|
+
} else {
|
|
213
|
+
process.kill(-child.pid, "SIGKILL");
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function writeFileSyncAppend(path: string, content: string): void {
|
|
221
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
222
|
+
writeFileSync(path, existing + content, "utf-8");
|
|
223
|
+
}
|
package/src/runner/watch.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { resolve, join, dirname } from "node:path";
|
|
|
22
22
|
import { loadConfig, getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
|
|
23
23
|
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
24
24
|
import { guardAgentCommand, guardTestCommand } from "../security/commandGuard.js";
|
|
25
|
+
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
26
|
+
import { captureRepoSnapshot } from "./changeCapture.js";
|
|
25
27
|
import { runTask } from "./runTask.js";
|
|
26
28
|
|
|
27
29
|
// ── Bootstrap ─────────────────────────────────────────────────────
|
|
@@ -91,14 +93,28 @@ async function tick() {
|
|
|
91
93
|
// ── Pre-flight safety checks ──
|
|
92
94
|
try {
|
|
93
95
|
// Check repo_path
|
|
94
|
-
guardWorkspacePath(statusData.repo_path || wsRoot, wsRoot);
|
|
96
|
+
const resolvedRepoPath = guardWorkspacePath(statusData.resolved_repo_path || statusData.repo_path || wsRoot, wsRoot);
|
|
95
97
|
|
|
96
98
|
// Check agent
|
|
97
99
|
guardAgentCommand(statusData.agent, config);
|
|
98
100
|
|
|
99
101
|
// Check test_command
|
|
100
102
|
if (statusData.test_command) {
|
|
101
|
-
guardTestCommand(statusData.test_command, config);
|
|
103
|
+
guardTestCommand(statusData.test_command, config, resolvedRepoPath);
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(statusData.verify_commands)) {
|
|
106
|
+
for (const command of statusData.verify_commands) {
|
|
107
|
+
guardTestCommand(String(command), config, resolvedRepoPath);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Assessment freshness revalidation
|
|
112
|
+
if (statusData.assessment_id) {
|
|
113
|
+
const preExecSnapshot = captureRepoSnapshot(resolvedRepoPath);
|
|
114
|
+
const validation = validateAssessmentFreshness(String(statusData.assessment_id), preExecSnapshot);
|
|
115
|
+
if (!validation.valid) {
|
|
116
|
+
throw new Error(`assessment validation failed: ${validation.failure_reason}`);
|
|
117
|
+
}
|
|
102
118
|
}
|
|
103
119
|
} catch (err) {
|
|
104
120
|
const errMsg = `[watcher] Safety check failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PatchWardenConfig } from "../config.js";
|
|
1
|
+
import { getRepoAllowedTestCommands, getRepoDirectAllowedCommands, PatchWardenConfig } from "../config.js";
|
|
2
2
|
import { PatchWardenError } from "../errors.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -65,7 +65,8 @@ function isSafeConfiguredCommand(command: string): boolean {
|
|
|
65
65
|
|
|
66
66
|
export function guardTestCommand(
|
|
67
67
|
testCommand: string,
|
|
68
|
-
config: PatchWardenConfig
|
|
68
|
+
config: PatchWardenConfig,
|
|
69
|
+
repoPath?: string
|
|
69
70
|
): string {
|
|
70
71
|
if (!testCommand || typeof testCommand !== "string") {
|
|
71
72
|
// If no test command specified, that's ok — skip tests
|
|
@@ -75,10 +76,14 @@ export function guardTestCommand(
|
|
|
75
76
|
const trimmed = testCommand.trim();
|
|
76
77
|
if (trimmed === "") return "";
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
const allowedCommands = [
|
|
80
|
+
...config.allowedTestCommands,
|
|
81
|
+
...(repoPath ? getRepoAllowedTestCommands(config, repoPath) : []),
|
|
82
|
+
];
|
|
83
|
+
if (!allowedCommands.includes(trimmed)) {
|
|
79
84
|
throw new PatchWardenError(
|
|
80
85
|
"test_command_not_allowlisted",
|
|
81
|
-
`Test command "${trimmed}" is not
|
|
86
|
+
`Test command "${trimmed}" is not allowed for this repository. Allowed: ${allowedCommands.join(", ")}`,
|
|
82
87
|
"Use an exact allowed command shown by create_task, or omit test_command."
|
|
83
88
|
);
|
|
84
89
|
}
|
|
@@ -86,6 +91,43 @@ export function guardTestCommand(
|
|
|
86
91
|
return trimmed;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
export function guardDirectCommand(
|
|
95
|
+
command: string,
|
|
96
|
+
config: PatchWardenConfig,
|
|
97
|
+
repoPath?: string
|
|
98
|
+
): string {
|
|
99
|
+
if (!command || typeof command !== "string") {
|
|
100
|
+
throw new PatchWardenError(
|
|
101
|
+
"direct_command_required",
|
|
102
|
+
"A command string is required for run_verification.",
|
|
103
|
+
"Provide one of the allowed Direct verification commands."
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const trimmed = command.trim();
|
|
108
|
+
if (trimmed === "") {
|
|
109
|
+
throw new PatchWardenError(
|
|
110
|
+
"direct_command_required",
|
|
111
|
+
"A command string is required for run_verification.",
|
|
112
|
+
"Provide one of the allowed Direct verification commands."
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const allowedCommands = [
|
|
117
|
+
...(config.directAllowedCommands || []),
|
|
118
|
+
...(repoPath ? getRepoDirectAllowedCommands(config, repoPath) : []),
|
|
119
|
+
];
|
|
120
|
+
if (!allowedCommands.includes(trimmed)) {
|
|
121
|
+
throw new PatchWardenError(
|
|
122
|
+
"direct_command_not_allowlisted",
|
|
123
|
+
`Direct command "${trimmed}" is not allowed. Allowed: ${allowedCommands.join(", ")}`,
|
|
124
|
+
"Use an exact allowed command from the Direct allowlist."
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return trimmed;
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
/**
|
|
90
132
|
* Escape a user-provided string for safe use in shell arguments.
|
|
91
133
|
* We prevent injection by refusing to pass arbitrary strings to shell.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
statSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
PatchWardenConfig,
|
|
8
|
+
getRepoAllowedTestCommands,
|
|
9
|
+
} from "../config.js";
|
|
10
|
+
import { guardWorkspacePath } from "./pathGuard.js";
|
|
11
|
+
import { guardPlanContent } from "./planGuard.js";
|
|
12
|
+
import { guardTestCommand } from "./commandGuard.js";
|
|
13
|
+
import { isSensitivePath } from "./sensitiveGuard.js";
|
|
14
|
+
import { guardRuntimeSelfModification } from "./runtimeGuard.js";
|
|
15
|
+
import { PatchWardenError } from "../errors.js";
|
|
16
|
+
import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
|
|
17
|
+
|
|
18
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
19
|
+
export type RiskDecision = "allow" | "needs_confirm" | "blocked";
|
|
20
|
+
|
|
21
|
+
export interface RiskAssessmentInput {
|
|
22
|
+
repoPath: string;
|
|
23
|
+
resolvedRepoPath: string;
|
|
24
|
+
planContent: string;
|
|
25
|
+
planTitle: string;
|
|
26
|
+
testCommand: string;
|
|
27
|
+
verifyCommands: string[];
|
|
28
|
+
template?: TaskTemplateName;
|
|
29
|
+
goal?: string;
|
|
30
|
+
agent: string;
|
|
31
|
+
config: PatchWardenConfig;
|
|
32
|
+
snapshotTruncated: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RiskAssessmentResult {
|
|
36
|
+
risk_level: RiskLevel;
|
|
37
|
+
decision: RiskDecision;
|
|
38
|
+
reason_codes: string[];
|
|
39
|
+
risk_hints: string[];
|
|
40
|
+
hard_rule_hits: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DIST_COMMANDS = new Set(["npm run dist", "npm run pack"]);
|
|
44
|
+
|
|
45
|
+
export function assessRisk(input: RiskAssessmentInput): RiskAssessmentResult {
|
|
46
|
+
const hardRuleHits: string[] = [];
|
|
47
|
+
const reasonCodes: string[] = [];
|
|
48
|
+
|
|
49
|
+
// ── Hard rules (guard functions). A hit means high → blocked. ──
|
|
50
|
+
let resolvedRepoPath = input.resolvedRepoPath;
|
|
51
|
+
try {
|
|
52
|
+
resolvedRepoPath = guardWorkspacePath(input.repoPath, input.config.workspaceRoot);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
hardRuleHits.push(extractReason(e));
|
|
55
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!existsSync(resolvedRepoPath)) {
|
|
59
|
+
hardRuleHits.push("repo_path_not_found");
|
|
60
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
61
|
+
}
|
|
62
|
+
if (!statSync(resolvedRepoPath).isDirectory()) {
|
|
63
|
+
hardRuleHits.push("repo_path_not_directory");
|
|
64
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
guardRuntimeSelfModification(resolvedRepoPath);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
hardRuleHits.push(extractReason(e));
|
|
71
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!input.config.agents[input.agent]) {
|
|
75
|
+
hardRuleHits.push("agent_not_configured");
|
|
76
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
guardPlanContent(input.planTitle, input.planContent);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
hardRuleHits.push(extractReason(e));
|
|
83
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const cmd of [input.testCommand, ...input.verifyCommands]) {
|
|
87
|
+
if (!cmd || cmd.trim() === "") continue;
|
|
88
|
+
try {
|
|
89
|
+
guardTestCommand(cmd, input.config, resolvedRepoPath);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
hardRuleHits.push(extractReason(e));
|
|
92
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.goal) {
|
|
97
|
+
const goalPath = resolve(resolvedRepoPath, input.goal);
|
|
98
|
+
if (isSensitivePath(input.goal) || isSensitivePath(goalPath)) {
|
|
99
|
+
hardRuleHits.push("sensitive_path_in_goal");
|
|
100
|
+
return finalize("high", "blocked", reasonCodes, hardRuleHits, input);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Hard rules passed. Reason codes for passing. ──
|
|
105
|
+
reasonCodes.push("repo_scoped", "no_sensitive_paths", "allowlisted_commands");
|
|
106
|
+
|
|
107
|
+
// ── Snapshot truncation (微调 #2): force needs_confirm. ──
|
|
108
|
+
if (input.snapshotTruncated) {
|
|
109
|
+
reasonCodes.push("snapshot_truncated");
|
|
110
|
+
return finalize("medium", "needs_confirm", reasonCodes, hardRuleHits, input);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Medium-risk policy decisions. ──
|
|
114
|
+
if (input.template === "release_check") {
|
|
115
|
+
reasonCodes.push("release_template_needs_confirm");
|
|
116
|
+
return finalize("medium", "needs_confirm", reasonCodes, hardRuleHits, input);
|
|
117
|
+
}
|
|
118
|
+
if (input.verifyCommands.some((c) => DIST_COMMANDS.has(c.trim()))) {
|
|
119
|
+
reasonCodes.push("dist_command_needs_confirm");
|
|
120
|
+
return finalize("medium", "needs_confirm", reasonCodes, hardRuleHits, input);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Low risk. ──
|
|
124
|
+
if (input.template === "inspect_only") reasonCodes.push("inspect_only_no_changes");
|
|
125
|
+
else if (input.template === "feature_small") reasonCodes.push("feature_small_scoped");
|
|
126
|
+
else if (input.template === "fix_tests") reasonCodes.push("fix_tests_scoped");
|
|
127
|
+
return finalize("low", "allow", reasonCodes, hardRuleHits, input);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Risk hints — keyword detection only, never affects risk_level (收缩 #4). */
|
|
131
|
+
export function collectRiskHints(input: RiskAssessmentInput): string[] {
|
|
132
|
+
const hints: string[] = [];
|
|
133
|
+
const text = `${input.goal || ""} ${input.planContent || ""}`.toLowerCase();
|
|
134
|
+
if (/\bpackage-lock\b/.test(text)) hints.push("mentions_package_lock");
|
|
135
|
+
if (/\brelease\b|\bdist\b/.test(text)) hints.push("mentions_artifact_dir");
|
|
136
|
+
if (/\bsync\b|\bbackup\b|\bpayload\b|\bpersistence\b/.test(text)) hints.push("mentions_dev_vocab");
|
|
137
|
+
return hints;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function finalize(
|
|
141
|
+
risk_level: RiskLevel,
|
|
142
|
+
decision: RiskDecision,
|
|
143
|
+
reason_codes: string[],
|
|
144
|
+
hard_rule_hits: string[],
|
|
145
|
+
input: RiskAssessmentInput
|
|
146
|
+
): RiskAssessmentResult {
|
|
147
|
+
return {
|
|
148
|
+
risk_level,
|
|
149
|
+
decision,
|
|
150
|
+
reason_codes,
|
|
151
|
+
risk_hints: collectRiskHints(input),
|
|
152
|
+
hard_rule_hits,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractReason(error: unknown): string {
|
|
157
|
+
if (error instanceof PatchWardenError) return error.reason;
|
|
158
|
+
if (error instanceof Error) return error.message.slice(0, 80);
|
|
159
|
+
return String(error).slice(0, 80);
|
|
160
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolve, join, dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { PatchWardenError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
const CRITICAL_RUNTIME_DIRS = ["dist", "src", "scripts", "release"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Refuse operations against the active PatchWarden runtime directory or its
|
|
9
|
+
* critical subdirectories. Extracted from createTask.ts so assess-only flows
|
|
10
|
+
* and direct-session flows share the same protection.
|
|
11
|
+
*/
|
|
12
|
+
export function guardRuntimeSelfModification(resolvedRepoPath: string): void {
|
|
13
|
+
const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
if (
|
|
15
|
+
resolvedRepoPath === runtimeRoot ||
|
|
16
|
+
resolvedRepoPath.startsWith(runtimeRoot + resolve("/")[0])
|
|
17
|
+
) {
|
|
18
|
+
const isCritical = CRITICAL_RUNTIME_DIRS.some((dir) => {
|
|
19
|
+
const full = join(runtimeRoot, dir);
|
|
20
|
+
return (
|
|
21
|
+
resolvedRepoPath === full ||
|
|
22
|
+
resolvedRepoPath.startsWith(full + resolve("/")[0])
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
if (resolvedRepoPath === runtimeRoot || isCritical) {
|
|
26
|
+
throw new PatchWardenError(
|
|
27
|
+
"runtime_self_modification_blocked",
|
|
28
|
+
`repo_path points to the active PatchWarden runtime or its critical subdirectories.`,
|
|
29
|
+
"Use a dev copy or git worktree for PatchWarden development. The running MCP server must not be modified by a task.",
|
|
30
|
+
true,
|
|
31
|
+
{
|
|
32
|
+
operation: "runtime_guard",
|
|
33
|
+
path: resolvedRepoPath,
|
|
34
|
+
runtime_root: runtimeRoot,
|
|
35
|
+
safe_alternative:
|
|
36
|
+
"Clone or copy PatchWarden to a separate directory for development tasks.",
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|