patchwarden 0.4.0 → 0.6.1

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