patchwarden 0.4.0 → 0.6.0

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 (139) 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/runner/agentInvocation.d.ts +23 -0
  24. package/dist/runner/agentInvocation.js +106 -0
  25. package/dist/runner/changeCapture.d.ts +29 -0
  26. package/dist/runner/changeCapture.js +107 -5
  27. package/dist/runner/runTask.js +70 -44
  28. package/dist/runner/simpleProcess.d.ts +21 -0
  29. package/dist/runner/simpleProcess.js +206 -0
  30. package/dist/runner/watch.js +17 -2
  31. package/dist/security/commandGuard.d.ts +2 -1
  32. package/dist/security/commandGuard.js +25 -3
  33. package/dist/security/riskEngine.d.ts +27 -0
  34. package/dist/security/riskEngine.js +118 -0
  35. package/dist/security/runtimeGuard.d.ts +6 -0
  36. package/dist/security/runtimeGuard.js +28 -0
  37. package/dist/smoke-test.js +1353 -7
  38. package/dist/tools/applyPatch.d.ts +16 -0
  39. package/dist/tools/applyPatch.js +41 -0
  40. package/dist/tools/auditSession.d.ts +5 -0
  41. package/dist/tools/auditSession.js +12 -0
  42. package/dist/tools/auditTask.d.ts +7 -0
  43. package/dist/tools/auditTask.js +94 -2
  44. package/dist/tools/createDirectSession.d.ts +14 -0
  45. package/dist/tools/createDirectSession.js +54 -0
  46. package/dist/tools/createTask.d.ts +48 -1
  47. package/dist/tools/createTask.js +298 -47
  48. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  49. package/dist/tools/finalizeDirectSession.js +84 -0
  50. package/dist/tools/getTaskSummary.d.ts +47 -0
  51. package/dist/tools/getTaskSummary.js +64 -1
  52. package/dist/tools/healthCheck.d.ts +22 -12
  53. package/dist/tools/healthCheck.js +23 -6
  54. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  55. package/dist/tools/readWorkspaceFile.js +48 -2
  56. package/dist/tools/registry.js +217 -19
  57. package/dist/tools/runVerification.d.ts +16 -0
  58. package/dist/tools/runVerification.js +32 -0
  59. package/dist/tools/savePlan.d.ts +1 -0
  60. package/dist/tools/savePlan.js +38 -7
  61. package/dist/tools/searchWorkspace.d.ts +19 -0
  62. package/dist/tools/searchWorkspace.js +205 -0
  63. package/dist/tools/taskTemplates.js +2 -1
  64. package/dist/tools/toolCatalog.d.ts +3 -2
  65. package/dist/tools/toolCatalog.js +32 -5
  66. package/dist/tools/waitForTask.d.ts +2 -2
  67. package/dist/tools/waitForTask.js +1 -1
  68. package/dist/version.d.ts +2 -2
  69. package/dist/version.js +2 -2
  70. package/docs/chatgpt-usage.md +101 -0
  71. package/docs/release-checklist.md +14 -0
  72. package/docs/release-v0.6.0.md +71 -0
  73. package/examples/config.example.json +5 -0
  74. package/examples/openai-tunnel/README.md +11 -4
  75. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  76. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  77. package/package.json +12 -8
  78. package/scripts/brand-check.js +58 -12
  79. package/scripts/control-smoke.js +206 -0
  80. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  81. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  82. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  83. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  84. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  85. package/scripts/lifecycle-smoke.js +66 -6
  86. package/scripts/manage-patchwarden.ps1 +639 -0
  87. package/scripts/mcp-manifest-check.js +100 -58
  88. package/scripts/mcp-smoke.js +157 -2
  89. package/scripts/pack-clean.js +3 -4
  90. package/scripts/package-manifest-check.js +78 -0
  91. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  92. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  93. package/scripts/restart-patchwarden.ps1 +5 -4
  94. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  95. package/scripts/tunnel-supervisor-smoke.js +36 -4
  96. package/scripts/watcher-supervisor-smoke.js +10 -6
  97. package/src/assessments/agentAssessor.ts +324 -0
  98. package/src/assessments/assessmentStore.ts +426 -0
  99. package/src/assessments/confirmCli.ts +29 -0
  100. package/src/config.ts +178 -4
  101. package/src/direct/directAudit.ts +400 -0
  102. package/src/direct/directGuards.ts +279 -0
  103. package/src/direct/directPatch.ts +258 -0
  104. package/src/direct/directSessionStore.ts +345 -0
  105. package/src/direct/directVerification.ts +138 -0
  106. package/src/doctor.ts +103 -7
  107. package/src/runner/agentInvocation.ts +125 -0
  108. package/src/runner/changeCapture.ts +140 -5
  109. package/src/runner/runTask.ts +61 -43
  110. package/src/runner/simpleProcess.ts +223 -0
  111. package/src/runner/watch.ts +18 -2
  112. package/src/security/commandGuard.ts +46 -4
  113. package/src/security/riskEngine.ts +160 -0
  114. package/src/security/runtimeGuard.ts +41 -0
  115. package/src/smoke-test.ts +1291 -4
  116. package/src/tools/applyPatch.ts +86 -0
  117. package/src/tools/auditSession.ts +28 -0
  118. package/src/tools/auditTask.ts +100 -2
  119. package/src/tools/createDirectSession.ts +113 -0
  120. package/src/tools/createTask.ts +405 -55
  121. package/src/tools/finalizeDirectSession.ts +144 -0
  122. package/src/tools/getTaskSummary.ts +111 -1
  123. package/src/tools/healthCheck.ts +23 -6
  124. package/src/tools/readWorkspaceFile.ts +85 -2
  125. package/src/tools/registry.ts +242 -19
  126. package/src/tools/runVerification.ts +58 -0
  127. package/src/tools/savePlan.ts +57 -7
  128. package/src/tools/searchWorkspace.ts +275 -0
  129. package/src/tools/taskTemplates.ts +2 -1
  130. package/src/tools/toolCatalog.ts +35 -6
  131. package/src/tools/waitForTask.ts +3 -3
  132. package/src/version.ts +2 -2
  133. package/tsconfig.json +18 -17
  134. package/Check-PatchWarden-Health.cmd +0 -6
  135. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  136. package/Restart-PatchWarden.cmd +0 -19
  137. package/Start-PatchWarden-Tunnel.cmd +0 -7
  138. package/docs/release-v0.3.0.md +0 -43
  139. package/docs/release-v0.4.0.md +0 -74
@@ -1,12 +1,14 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { createWriteStream, existsSync, readFileSync, writeFileSync, } from "node:fs";
3
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
5
5
  import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
6
- import { guardAgentCommand, guardTestCommand, sanitizePromptArg, } from "../security/commandGuard.js";
6
+ import { guardTestCommand, } from "../security/commandGuard.js";
7
7
  import { writeTaskProgress } from "../taskProgress.js";
8
8
  import { writeTaskRuntime } from "../taskRuntime.js";
9
- import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, writeSnapshot, } from "./changeCapture.js";
9
+ import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
10
+ import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
11
+ import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, } from "./changeCapture.js";
10
12
  const HEARTBEAT_INTERVAL_MS = 2000;
11
13
  const GRACEFUL_KILL_MS = 2000;
12
14
  const MAX_CAPTURE_CHARS = 100_000;
@@ -29,7 +31,6 @@ export async function runTask(taskId) {
29
31
  let verifyCommands;
30
32
  let timeoutSeconds;
31
33
  try {
32
- verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
33
34
  timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
34
35
  }
35
36
  catch (error) {
@@ -45,6 +46,29 @@ export async function runTask(taskId) {
45
46
  const message = `repo_path validation failed: ${errorMessage(error)}`;
46
47
  return failBeforeExecution(taskId, taskDir, message);
47
48
  }
49
+ try {
50
+ verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
51
+ }
52
+ catch (error) {
53
+ const message = `verification metadata validation failed: ${errorMessage(error)}`;
54
+ return failBeforeExecution(taskId, taskDir, message);
55
+ }
56
+ // ── Assessment freshness revalidation before execution ──
57
+ const assessmentId = String(initialStatus.assessment_id || "");
58
+ if (assessmentId) {
59
+ try {
60
+ const preExecSnapshot = captureRepoSnapshot(repoPath);
61
+ const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
62
+ if (!validation.valid) {
63
+ const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
64
+ return failBeforeExecution(taskId, taskDir, message);
65
+ }
66
+ }
67
+ catch (error) {
68
+ const message = `assessment validation error: ${errorMessage(error)}`;
69
+ return failBeforeExecution(taskId, taskDir, message);
70
+ }
71
+ }
48
72
  updateStatus(taskDir, {
49
73
  status: "running",
50
74
  phase: "preparing",
@@ -77,20 +101,13 @@ export async function runTask(taskId) {
77
101
  if (!existsSync(planFile))
78
102
  throw new Error(`Plan not found: "${planId}". Save the plan first.`);
79
103
  const planContent = readFileSync(planFile, "utf-8");
80
- const agentCmd = guardAgentCommand(agentName, config);
81
- const prompt = sanitizePromptArg(buildExecutionPrompt(planContent, repoPath, testCommand));
82
- const resolvedArgs = agentCmd.args.map((arg) => {
83
- if (arg === "{repo}")
84
- return repoPath;
85
- if (arg === "{prompt}")
86
- return prompt;
87
- return arg;
88
- });
89
- const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
104
+ const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
105
+ const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
106
+ const agentCommandLabel = invocation.commandLabel;
90
107
  setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
91
108
  agentResult = await runManagedProcess({
92
- command: agentCmd.command,
93
- args: resolvedArgs,
109
+ command: invocation.command,
110
+ args: invocation.args,
94
111
  cwd: repoPath,
95
112
  taskDir,
96
113
  statusFile,
@@ -177,6 +194,7 @@ export async function runTask(taskId) {
177
194
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
178
195
  patch_mode: "hash_only",
179
196
  unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
197
+ artifact_hygiene: emptyArtifactHygiene(),
180
198
  };
181
199
  finalError ||= `Change capture failed: ${errorMessage(error)}`;
182
200
  finalStatus = "failed";
@@ -270,6 +288,7 @@ export async function runTask(taskId) {
270
288
  change_policy: changePolicy,
271
289
  summary: finalError || "Agent execution and configured verification completed successfully.",
272
290
  changed_files: changes.changed_files,
291
+ artifact_hygiene: changes.artifact_hygiene,
273
292
  out_of_scope_changes: outOfScopeChanges,
274
293
  verify_status: verifyJson.status,
275
294
  verify_commands: verifyJson.commands,
@@ -309,6 +328,7 @@ export async function runTask(taskId) {
309
328
  finished_at: finishedAt,
310
329
  error: finalError,
311
330
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
331
+ artifact_hygiene_counts: changes.artifact_hygiene.counts,
312
332
  out_of_scope_changes: outOfScopeChanges,
313
333
  verify_status: verifyJson.status,
314
334
  verify_commands: verifyCommands,
@@ -403,6 +423,8 @@ async function runManagedProcess(options) {
403
423
  let spawnError = null;
404
424
  let terminationReason = null;
405
425
  let forceTimer = null;
426
+ let fallbackTimer = null;
427
+ let childExitFinish = null;
406
428
  let terminationStarted = false;
407
429
  const heartbeat = () => {
408
430
  const now = new Date().toISOString();
@@ -442,6 +464,22 @@ async function runManagedProcess(options) {
442
464
  gracefulKill(child);
443
465
  forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
444
466
  }
467
+ // Fallback: if neither close nor exit fires within 10s after taskkill,
468
+ // force-resolve to prevent the runner from hanging indefinitely.
469
+ // Only starts AFTER termination is requested — normal long tasks are unaffected.
470
+ fallbackTimer = setTimeout(() => {
471
+ try {
472
+ forceKill(child);
473
+ }
474
+ catch { }
475
+ try {
476
+ child.kill("SIGKILL");
477
+ }
478
+ catch { }
479
+ // Resolve the exit promise via the shared finish handle
480
+ if (childExitFinish)
481
+ childExitFinish(null);
482
+ }, 10000);
445
483
  };
446
484
  child.stdout?.on("data", (chunk) => {
447
485
  const text = chunk.toString("utf-8");
@@ -461,9 +499,19 @@ async function runManagedProcess(options) {
461
499
  if (settled)
462
500
  return;
463
501
  settled = true;
502
+ if (fallbackTimer)
503
+ clearTimeout(fallbackTimer);
504
+ childExitFinish = null;
464
505
  resolveExit(code);
465
506
  };
507
+ childExitFinish = finish;
466
508
  child.once("close", (code) => finish(code));
509
+ child.once("exit", (code) => {
510
+ // On Windows, "close" can lag behind "exit" when stdio pipes drain
511
+ // after taskkill. Resolve on exit to avoid hanging the runner, but
512
+ // still let "close" fire if it arrives first.
513
+ finish(code);
514
+ });
467
515
  child.once("error", (error) => {
468
516
  spawnError = error.message;
469
517
  finish(null);
@@ -472,8 +520,8 @@ async function runManagedProcess(options) {
472
520
  clearInterval(heartbeatTimer);
473
521
  if (forceTimer)
474
522
  clearTimeout(forceTimer);
475
- stdoutStream?.end();
476
- stderrStream?.end();
523
+ stdoutStream?.destroy();
524
+ stderrStream?.destroy();
477
525
  writeTaskRuntime(options.taskDir, {
478
526
  phase: options.phase,
479
527
  last_heartbeat_at: new Date().toISOString(),
@@ -485,7 +533,7 @@ async function runManagedProcess(options) {
485
533
  }
486
534
  async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs) {
487
535
  const config = getConfig();
488
- const trusted = guardTestCommand(testCommand, config);
536
+ const trusted = guardTestCommand(testCommand, config, repoPath);
489
537
  const startedAtMs = Date.now();
490
538
  const startedAt = new Date(startedAtMs).toISOString();
491
539
  const parts = trusted.split(/\s+/).filter(Boolean);
@@ -518,28 +566,6 @@ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile,
518
566
  duration_ms: finishedAtMs - startedAtMs,
519
567
  };
520
568
  }
521
- function buildExecutionPrompt(plan, repoPath, testCommand) {
522
- let prompt = `You are executing a pre-written plan in a local repository.
523
-
524
- ## Repository
525
- ${repoPath}
526
-
527
- ## Plan
528
- ${plan}
529
-
530
- ## Instructions
531
- 1. Read the plan carefully.
532
- 2. Implement the changes in this repository only.
533
- 3. Do NOT modify files outside this repository.
534
- 4. Do NOT commit or push changes.
535
- 5. After implementing, describe what you changed.
536
- 6. Output a summary with what was done, files modified, and issues encountered.
537
- `;
538
- if (testCommand) {
539
- prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
540
- }
541
- return prompt;
542
- }
543
569
  function buildTestLog(result) {
544
570
  if (result.skipped)
545
571
  return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
@@ -671,13 +697,13 @@ function buildVerifyLog(commands) {
671
697
  entry.stderr_tail || "(empty)",
672
698
  ].join("\n")).join("\n\n");
673
699
  }
674
- function normalizeVerifyCommands(value, legacyTestCommand, config) {
700
+ function normalizeVerifyCommands(value, legacyTestCommand, config, repoPath) {
675
701
  if (value !== undefined && !Array.isArray(value)) {
676
702
  throw new Error("Invalid task verify_commands metadata; expected an array.");
677
703
  }
678
704
  return [...new Set([
679
- ...(value || []).map((command) => guardTestCommand(String(command), config)),
680
- ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
705
+ ...(value || []).map((command) => guardTestCommand(String(command), config, repoPath)),
706
+ ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
681
707
  ])];
682
708
  }
683
709
  function summarizeOutput(value) {
@@ -0,0 +1,21 @@
1
+ export interface SimpleProcessOptions {
2
+ command: string;
3
+ args: string[];
4
+ cwd: string;
5
+ timeoutMs: number;
6
+ maxStdoutBytes?: number;
7
+ maxStderrBytes?: number;
8
+ stdoutPath?: string;
9
+ stderrPath?: string;
10
+ }
11
+ export interface SimpleProcessResult {
12
+ exitCode: number | null;
13
+ stdout: string;
14
+ stderr: string;
15
+ spawnError: string | null;
16
+ timedOut: boolean;
17
+ stdoutTruncated: boolean;
18
+ stderrTruncated: boolean;
19
+ }
20
+ export declare function runSimpleProcessSync(options: SimpleProcessOptions): SimpleProcessResult;
21
+ export declare function runSimpleProcess(options: SimpleProcessOptions): Promise<SimpleProcessResult>;
@@ -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[];