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
@@ -0,0 +1,15 @@
1
+ import { PatchWardenConfig } from "../config.js";
2
+ import type { AgentAssessmentSummary } from "./assessmentStore.js";
3
+ export interface AgentAssessorInput {
4
+ assessmentId: string;
5
+ assessmentDir: string;
6
+ agentName: string;
7
+ repoPath: string;
8
+ workspaceRoot: string;
9
+ goal: string;
10
+ planContent: string;
11
+ timeoutSeconds: number;
12
+ maxOutputBytes: number;
13
+ config: PatchWardenConfig;
14
+ }
15
+ export declare function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSummary;
@@ -0,0 +1,293 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { resolve, relative, isAbsolute } from "node:path";
3
+ import { buildAgentInvocation, buildAssessmentPrompt } from "../runner/agentInvocation.js";
4
+ import { runSimpleProcessSync } from "../runner/simpleProcess.js";
5
+ import { captureRepoSnapshot, compareSnapshots } from "../runner/changeCapture.js";
6
+ const ASSESSMENT_MARKER = "===ASSESSMENT_JSON===";
7
+ export function runAgentAssessment(input) {
8
+ const logPaths = {
9
+ stdout: resolve(input.assessmentDir, "agent-assessment-stdout.log"),
10
+ stderr: resolve(input.assessmentDir, "agent-assessment-stderr.log"),
11
+ assessment: resolve(input.assessmentDir, "agent-assessment.json"),
12
+ };
13
+ const emptySummary = {
14
+ attempted: false,
15
+ status: "not_run",
16
+ output: null,
17
+ merged_risk: "low",
18
+ merged_decision: "allow",
19
+ merged_reason_codes: [],
20
+ timed_out: false,
21
+ exit_code: null,
22
+ read_only_violation: false,
23
+ violation_files: [],
24
+ stdout_truncated: false,
25
+ stderr_truncated: false,
26
+ log_paths: logPaths,
27
+ };
28
+ // ── 1. Build prompt and write to file for {prompt_file} support ──
29
+ const assessmentPrompt = buildAssessmentPrompt(input.goal, input.planContent, input.repoPath);
30
+ const promptFilePath = resolve(input.assessmentDir, "agent-assessment-prompt.md");
31
+ writeFileSync(promptFilePath, assessmentPrompt, "utf-8");
32
+ logPaths.prompt = promptFilePath;
33
+ // ── 2. Before snapshot (repo-scoped) ──
34
+ let repoBefore;
35
+ try {
36
+ repoBefore = captureRepoSnapshot(input.repoPath);
37
+ }
38
+ catch {
39
+ // If we can't capture before snapshot, conservatively skip agent assessment
40
+ emptySummary.status = "spawn_failed";
41
+ emptySummary.merged_risk = "medium";
42
+ emptySummary.merged_decision = "needs_confirm";
43
+ emptySummary.merged_reason_codes = ["agent_assessment_snapshot_failed"];
44
+ writeSummary(logPaths.assessment, emptySummary);
45
+ return emptySummary;
46
+ }
47
+ // ── 3. Build agent invocation ──
48
+ let invocation;
49
+ try {
50
+ invocation = buildAgentInvocation(input.agentName, input.repoPath, assessmentPrompt, input.config, promptFilePath);
51
+ }
52
+ catch (error) {
53
+ emptySummary.status = "spawn_failed";
54
+ emptySummary.merged_risk = "medium";
55
+ emptySummary.merged_decision = "needs_confirm";
56
+ emptySummary.merged_reason_codes = ["agent_assessment_invocation_failed"];
57
+ writeSummary(logPaths.assessment, emptySummary);
58
+ return emptySummary;
59
+ }
60
+ emptySummary.attempted = true;
61
+ // ── 4. Run agent ──
62
+ const result = runSimpleProcessSync({
63
+ command: invocation.command,
64
+ args: invocation.args,
65
+ cwd: input.repoPath,
66
+ timeoutMs: input.timeoutSeconds * 1000,
67
+ maxStdoutBytes: input.maxOutputBytes,
68
+ maxStderrBytes: Math.max(16384, Math.floor(input.maxOutputBytes / 4)),
69
+ stdoutPath: logPaths.stdout,
70
+ stderrPath: logPaths.stderr,
71
+ });
72
+ // ── 5. After snapshot + read-only violation check ──
73
+ let readOnlyViolation = false;
74
+ let violationFiles = [];
75
+ try {
76
+ const repoAfter = captureRepoSnapshot(input.repoPath);
77
+ const changes = compareSnapshots(repoBefore, repoAfter);
78
+ if (changes.length > 0) {
79
+ readOnlyViolation = true;
80
+ violationFiles = changes.map((c) => c.path);
81
+ }
82
+ }
83
+ catch {
84
+ // 修复 #4: after snapshot 捕获失败 → 保守变为 high / blocked
85
+ readOnlyViolation = true;
86
+ violationFiles = ["(after snapshot capture failed)"];
87
+ }
88
+ if (readOnlyViolation) {
89
+ const summary = {
90
+ attempted: true,
91
+ status: "read_only_violation",
92
+ output: null,
93
+ merged_risk: "high",
94
+ merged_decision: "blocked",
95
+ merged_reason_codes: ["agent_read_only_violation"],
96
+ timed_out: false,
97
+ exit_code: result.exitCode,
98
+ read_only_violation: true,
99
+ violation_files: violationFiles,
100
+ stdout_truncated: result.stdoutTruncated,
101
+ stderr_truncated: result.stderrTruncated,
102
+ log_paths: { ...logPaths, violation: resolve(input.assessmentDir, "agent-assessment-violation.json") },
103
+ };
104
+ writeFileSync(summary.log_paths.violation, JSON.stringify({
105
+ violation: "read_only_violation",
106
+ files: violationFiles,
107
+ exit_code: result.exitCode,
108
+ }, null, 2), "utf-8");
109
+ writeSummary(logPaths.assessment, summary);
110
+ return summary;
111
+ }
112
+ // ── 6. Handle spawn failure ──
113
+ if (result.spawnError) {
114
+ const summary = {
115
+ ...emptySummary,
116
+ attempted: true,
117
+ status: "spawn_failed",
118
+ merged_risk: "medium",
119
+ merged_decision: "needs_confirm",
120
+ merged_reason_codes: ["agent_assessment_spawn_failed"],
121
+ exit_code: result.exitCode,
122
+ stdout_truncated: result.stdoutTruncated,
123
+ stderr_truncated: result.stderrTruncated,
124
+ };
125
+ writeSummary(logPaths.assessment, summary);
126
+ return summary;
127
+ }
128
+ // ── 7. Handle timeout ──
129
+ if (result.timedOut) {
130
+ const summary = {
131
+ ...emptySummary,
132
+ attempted: true,
133
+ status: "timed_out",
134
+ merged_risk: "medium",
135
+ merged_decision: "needs_confirm",
136
+ merged_reason_codes: ["agent_assessment_timed_out"],
137
+ timed_out: true,
138
+ exit_code: result.exitCode,
139
+ stdout_truncated: result.stdoutTruncated,
140
+ stderr_truncated: result.stderrTruncated,
141
+ };
142
+ writeSummary(logPaths.assessment, summary);
143
+ return summary;
144
+ }
145
+ // ── 8. Handle non-zero exit ──
146
+ if (result.exitCode !== 0) {
147
+ const summary = {
148
+ ...emptySummary,
149
+ attempted: true,
150
+ status: "non_zero_exit",
151
+ merged_risk: "medium",
152
+ merged_decision: "needs_confirm",
153
+ merged_reason_codes: ["agent_assessment_non_zero_exit"],
154
+ exit_code: result.exitCode,
155
+ stdout_truncated: result.stdoutTruncated,
156
+ stderr_truncated: result.stderrTruncated,
157
+ };
158
+ writeSummary(logPaths.assessment, summary);
159
+ return summary;
160
+ }
161
+ // ── 9. Parse JSON from stdout ──
162
+ const parsed = parseAssessmentJson(result.stdout, input.repoPath);
163
+ if (!parsed.output) {
164
+ const summary = {
165
+ ...emptySummary,
166
+ attempted: true,
167
+ status: "parse_failed",
168
+ merged_risk: "medium",
169
+ merged_decision: "needs_confirm",
170
+ merged_reason_codes: ["agent_assessment_parse_failed", ...parsed.sanitized_reasons],
171
+ exit_code: result.exitCode,
172
+ stdout_truncated: result.stdoutTruncated,
173
+ stderr_truncated: result.stderrTruncated,
174
+ };
175
+ writeSummary(logPaths.assessment, summary);
176
+ return summary;
177
+ }
178
+ // ── 10. Merge risk (agent can only raise, not lower) ──
179
+ const agentRisk = parsed.output.risk_level;
180
+ let mergedRisk = agentRisk === "high" ? "high" : agentRisk === "medium" ? "medium" : "low";
181
+ let mergedDecision = mergedRisk === "high" ? "blocked" : mergedRisk === "medium" ? "needs_confirm" : "allow";
182
+ const mergedReasonCodes = ["agent_assessment_completed", ...parsed.sanitized_reasons];
183
+ // 修复 #5: agent requires_user_confirm=true 时至少升级为 medium / needs_confirm
184
+ if (parsed.output.requires_user_confirm && mergedRisk === "low") {
185
+ mergedRisk = "medium";
186
+ mergedDecision = "needs_confirm";
187
+ mergedReasonCodes.push("agent_requested_confirm");
188
+ }
189
+ const summary = {
190
+ attempted: true,
191
+ status: "completed",
192
+ output: { ...parsed.output, assessed_at: new Date().toISOString() },
193
+ merged_risk: mergedRisk,
194
+ merged_decision: mergedDecision,
195
+ merged_reason_codes: mergedReasonCodes,
196
+ timed_out: false,
197
+ exit_code: result.exitCode,
198
+ read_only_violation: false,
199
+ violation_files: [],
200
+ stdout_truncated: result.stdoutTruncated,
201
+ stderr_truncated: result.stderrTruncated,
202
+ log_paths: logPaths,
203
+ };
204
+ writeSummary(logPaths.assessment, summary);
205
+ return summary;
206
+ }
207
+ function parseAssessmentJson(stdout, repoPath) {
208
+ const sanitized_reasons = [];
209
+ // Use the LAST marker only
210
+ const lastMarkerIndex = stdout.lastIndexOf(ASSESSMENT_MARKER);
211
+ if (lastMarkerIndex < 0)
212
+ return { output: null, sanitized_reasons };
213
+ const jsonText = stdout.slice(lastMarkerIndex + ASSESSMENT_MARKER.length).trim();
214
+ // Find the JSON object (from first { to last })
215
+ const jsonStart = jsonText.indexOf("{");
216
+ const jsonEnd = jsonText.lastIndexOf("}");
217
+ if (jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart)
218
+ return { output: null, sanitized_reasons };
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(jsonText.slice(jsonStart, jsonEnd + 1));
222
+ }
223
+ catch {
224
+ return { output: null, sanitized_reasons };
225
+ }
226
+ // ── Strict validation ──
227
+ if (parsed.risk_level !== "low" && parsed.risk_level !== "medium" && parsed.risk_level !== "high") {
228
+ return { output: null, sanitized_reasons };
229
+ }
230
+ const confidence = Number(parsed.confidence);
231
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
232
+ return { output: null, sanitized_reasons };
233
+ }
234
+ if (!Array.isArray(parsed.reason_codes))
235
+ return { output: null, sanitized_reasons };
236
+ if (parsed.reason_codes.length > 50)
237
+ return { output: null, sanitized_reasons };
238
+ const reasonCodes = parsed.reason_codes.filter((r) => typeof r === "string" && r.length <= 100);
239
+ if (reasonCodes.length !== parsed.reason_codes.length) {
240
+ sanitized_reasons.push("reason_codes_filtered");
241
+ }
242
+ if (!Array.isArray(parsed.affected_paths))
243
+ return { output: null, sanitized_reasons };
244
+ if (parsed.affected_paths.length > 100)
245
+ return { output: null, sanitized_reasons };
246
+ const affectedPaths = [];
247
+ let pathsSanitized = false;
248
+ for (const p of parsed.affected_paths) {
249
+ if (typeof p !== "string") {
250
+ pathsSanitized = true;
251
+ continue;
252
+ }
253
+ // Must be relative, not absolute, and within repo
254
+ if (isAbsolute(p)) {
255
+ pathsSanitized = true;
256
+ continue;
257
+ }
258
+ const resolved = resolve(repoPath, p);
259
+ const rel = relative(repoPath, resolved);
260
+ if (rel.startsWith("..")) {
261
+ pathsSanitized = true;
262
+ continue;
263
+ }
264
+ affectedPaths.push(p);
265
+ }
266
+ if (pathsSanitized) {
267
+ sanitized_reasons.push("paths_sanitized");
268
+ }
269
+ if (!Array.isArray(parsed.destructive_actions))
270
+ return { output: null, sanitized_reasons };
271
+ if (parsed.destructive_actions.length > 20)
272
+ return { output: null, sanitized_reasons };
273
+ const destructiveActions = parsed.destructive_actions.filter((d) => typeof d === "string" && d.length <= 200);
274
+ if (typeof parsed.requires_user_confirm !== "boolean")
275
+ return { output: null, sanitized_reasons };
276
+ const notes = typeof parsed.notes === "string" ? parsed.notes.slice(0, 2000) : "";
277
+ return {
278
+ output: {
279
+ risk_level: parsed.risk_level,
280
+ reason_codes: reasonCodes,
281
+ affected_paths: affectedPaths,
282
+ destructive_actions: destructiveActions,
283
+ requires_user_confirm: parsed.requires_user_confirm,
284
+ confidence,
285
+ notes,
286
+ assessed_at: new Date().toISOString(),
287
+ },
288
+ sanitized_reasons,
289
+ };
290
+ }
291
+ function writeSummary(path, summary) {
292
+ writeFileSync(path, JSON.stringify(summary, null, 2), "utf-8");
293
+ }
@@ -0,0 +1,133 @@
1
+ import { type RepoSnapshot } from "../runner/changeCapture.js";
2
+ import type { RiskAssessmentResult } from "../security/riskEngine.js";
3
+ import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
4
+ export interface AgentAssessmentSummary {
5
+ attempted: boolean;
6
+ status: "completed" | "timed_out" | "non_zero_exit" | "parse_failed" | "read_only_violation" | "spawn_failed" | "not_run";
7
+ output: AgentAssessmentOutput | null;
8
+ merged_risk: "low" | "medium" | "high";
9
+ merged_decision: "allow" | "needs_confirm" | "blocked";
10
+ merged_reason_codes: string[];
11
+ timed_out: boolean;
12
+ exit_code: number | null;
13
+ read_only_violation: boolean;
14
+ violation_files: string[];
15
+ stdout_truncated: boolean;
16
+ stderr_truncated: boolean;
17
+ log_paths: {
18
+ stdout: string;
19
+ stderr: string;
20
+ assessment: string;
21
+ prompt?: string;
22
+ violation?: string;
23
+ };
24
+ }
25
+ export interface AgentAssessmentOutput {
26
+ risk_level: "low" | "medium" | "high";
27
+ reason_codes: string[];
28
+ affected_paths: string[];
29
+ destructive_actions: string[];
30
+ requires_user_confirm: boolean;
31
+ confidence: number;
32
+ notes: string;
33
+ assessed_at: string;
34
+ }
35
+ export interface AssessmentRecord {
36
+ assessment_id: string;
37
+ assessment_short_id: string;
38
+ decision: "allow" | "needs_confirm" | "blocked";
39
+ risk_level: "low" | "medium" | "high";
40
+ risk_hints: string[];
41
+ hard_rule_hits: string[];
42
+ reason_codes: string[];
43
+ plan_hash: string | null;
44
+ plan_id: string | null;
45
+ policy_hash: string;
46
+ tool_manifest_sha256: string;
47
+ workspace_fingerprint: string;
48
+ workspace_snapshot_summary: {
49
+ head: string | null;
50
+ file_count: number;
51
+ workspace_dirty: boolean;
52
+ snapshot_truncated: boolean;
53
+ };
54
+ expires_at: string;
55
+ created_at: string;
56
+ repo_path: string;
57
+ resolved_repo_path: string;
58
+ template?: TaskTemplateName | null;
59
+ goal?: string | null;
60
+ test_command?: string | null;
61
+ verify_commands?: string[];
62
+ agent: string;
63
+ timeout_seconds?: number;
64
+ change_policy?: ChangePolicy;
65
+ requires_confirm: boolean;
66
+ confirmed: boolean;
67
+ confirmed_at: string | null;
68
+ confirm_code: string | null;
69
+ agent_assessment_summary?: AgentAssessmentSummary | null;
70
+ }
71
+ export interface AssessmentCreateInput {
72
+ decision: RiskAssessmentResult["decision"];
73
+ risk_level: RiskAssessmentResult["risk_level"];
74
+ risk_hints: string[];
75
+ hard_rule_hits: string[];
76
+ reason_codes: string[];
77
+ repo_path: string;
78
+ resolved_repo_path: string;
79
+ plan_id: string | null;
80
+ plan_content: string | null;
81
+ template?: TaskTemplateName | null;
82
+ goal?: string | null;
83
+ test_command?: string | null;
84
+ verify_commands?: string[];
85
+ agent: string;
86
+ timeout_seconds?: number;
87
+ change_policy?: ChangePolicy;
88
+ snapshot: RepoSnapshot;
89
+ assessment_id?: string;
90
+ assessment_dir?: string;
91
+ agent_assessment_summary?: AgentAssessmentSummary | null;
92
+ }
93
+ export interface AssessmentValidationResult {
94
+ valid: boolean;
95
+ failure_reason: string | null;
96
+ assessment: AssessmentRecord | null;
97
+ }
98
+ export interface AssessmentConfirmationResult {
99
+ assessment_id: string;
100
+ decision: "needs_confirm";
101
+ confirmed: true;
102
+ confirmed_at: string;
103
+ expires_at: string;
104
+ next_action: string;
105
+ }
106
+ interface AssessmentValidationOptions {
107
+ allow_unconfirmed?: boolean;
108
+ skip_tool_manifest?: boolean;
109
+ }
110
+ export declare function generateAssessmentId(): string;
111
+ export declare function createAssessmentDir(assessmentId: string): string;
112
+ export declare function createAssessment(input: AssessmentCreateInput): AssessmentRecord;
113
+ export declare function readAssessment(assessmentId: string): AssessmentRecord;
114
+ export declare function validateAssessmentFreshness(assessmentId: string, currentSnapshot: RepoSnapshot, options?: AssessmentValidationOptions): AssessmentValidationResult;
115
+ /**
116
+ * Records an explicit confirmation performed from the local PatchWarden CLI.
117
+ * This is intentionally not registered as an MCP tool: a remote client may
118
+ * request confirmation, but it cannot grant confirmation to itself.
119
+ */
120
+ export declare function confirmAssessment(assessmentId: string): AssessmentConfirmationResult;
121
+ export declare function computeWorkspaceFingerprint(snapshot: RepoSnapshot): string;
122
+ interface PolicyHashInput {
123
+ change_policy: ChangePolicy;
124
+ template: TaskTemplateName | null;
125
+ verify_commands: string[];
126
+ allowed_test_commands: string[];
127
+ repo_allowed_test_commands: string[];
128
+ sensitive_path_rules: string;
129
+ artifact_rules: string;
130
+ schema_epoch: string;
131
+ }
132
+ export declare function computePolicyHash(input: PolicyHashInput): string;
133
+ export {};
@@ -0,0 +1,238 @@
1
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, renameSync, } from "node:fs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { resolve, join } from "node:path";
4
+ import { getAssessmentsDir, getConfig, getRepoAllowedTestCommands } from "../config.js";
5
+ import { guardPath } from "../security/pathGuard.js";
6
+ import { PatchWardenError } from "../errors.js";
7
+ import { TOOL_SCHEMA_EPOCH } from "../version.js";
8
+ import { getLastToolCatalogSnapshot } from "../tools/toolCatalog.js";
9
+ import { captureRepoSnapshot } from "../runner/changeCapture.js";
10
+ const SENSITIVE_PATH_RULES_SIGNATURE = "v1";
11
+ const ARTIFACT_RULES_SIGNATURE = "v1";
12
+ export function generateAssessmentId() {
13
+ const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "_").slice(0, 15);
14
+ const randomHex = randomBytes(16).toString("hex");
15
+ return `assessment_${timestamp}_${randomHex}`;
16
+ }
17
+ export function createAssessmentDir(assessmentId) {
18
+ const config = getConfig();
19
+ const assessmentsDir = getAssessmentsDir(config);
20
+ guardPath(assessmentsDir, config.workspaceRoot, config.assessmentsDir);
21
+ mkdirSync(assessmentsDir, { recursive: true });
22
+ const dir = resolve(assessmentsDir, assessmentId);
23
+ guardPath(dir, config.workspaceRoot, config.assessmentsDir);
24
+ mkdirSync(dir, { recursive: true });
25
+ return dir;
26
+ }
27
+ export function createAssessment(input) {
28
+ const config = getConfig();
29
+ const assessmentId = input.assessment_id || generateAssessmentId();
30
+ const randomHex = assessmentId.split("_").pop() || "";
31
+ const dir = input.assessment_dir || createAssessmentDir(assessmentId);
32
+ const planHash = input.plan_content
33
+ ? createHash("sha256").update(input.plan_content).digest("hex")
34
+ : null;
35
+ const toolManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 || computeFallbackManifestHash(config);
36
+ const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
37
+ const snapshotTruncated = input.snapshot.warnings.some((w) => w.includes("snapshot limited"));
38
+ const policyHash = computePolicyHash({
39
+ change_policy: input.change_policy || "repo_scoped_changes",
40
+ template: input.template || null,
41
+ verify_commands: input.verify_commands || [],
42
+ allowed_test_commands: config.allowedTestCommands,
43
+ repo_allowed_test_commands: getRepoAllowedCommands(config, input.resolved_repo_path),
44
+ sensitive_path_rules: SENSITIVE_PATH_RULES_SIGNATURE,
45
+ artifact_rules: ARTIFACT_RULES_SIGNATURE,
46
+ schema_epoch: TOOL_SCHEMA_EPOCH,
47
+ });
48
+ const now = new Date();
49
+ const expiresAt = new Date(now.getTime() + config.assessmentTtlSeconds * 1000).toISOString();
50
+ const record = {
51
+ assessment_id: assessmentId,
52
+ assessment_short_id: randomHex.slice(0, 12),
53
+ decision: input.decision,
54
+ risk_level: input.risk_level,
55
+ risk_hints: input.risk_hints,
56
+ hard_rule_hits: input.hard_rule_hits,
57
+ reason_codes: input.reason_codes,
58
+ plan_hash: planHash,
59
+ plan_id: input.plan_id,
60
+ policy_hash: policyHash,
61
+ tool_manifest_sha256: toolManifest,
62
+ workspace_fingerprint: workspaceFingerprint,
63
+ workspace_snapshot_summary: {
64
+ head: input.snapshot.head,
65
+ file_count: Object.keys(input.snapshot.files).length,
66
+ workspace_dirty: input.snapshot.workspace_dirty,
67
+ snapshot_truncated: snapshotTruncated,
68
+ },
69
+ expires_at: expiresAt,
70
+ created_at: now.toISOString(),
71
+ repo_path: input.repo_path,
72
+ resolved_repo_path: input.resolved_repo_path,
73
+ template: input.template || null,
74
+ goal: input.goal || null,
75
+ test_command: input.test_command || null,
76
+ verify_commands: input.verify_commands || [],
77
+ agent: input.agent,
78
+ timeout_seconds: input.timeout_seconds,
79
+ change_policy: input.change_policy || "repo_scoped_changes",
80
+ requires_confirm: input.decision === "needs_confirm",
81
+ confirmed: false,
82
+ confirmed_at: null,
83
+ confirm_code: null,
84
+ agent_assessment_summary: input.agent_assessment_summary || null,
85
+ };
86
+ writeFileSync(join(dir, "assessment.json"), JSON.stringify(record, null, 2), "utf-8");
87
+ return record;
88
+ }
89
+ export function readAssessment(assessmentId) {
90
+ const config = getConfig();
91
+ const assessmentsDir = getAssessmentsDir(config);
92
+ const dir = resolve(assessmentsDir, assessmentId);
93
+ const file = join(dir, "assessment.json");
94
+ guardPath(file, config.workspaceRoot, config.assessmentsDir);
95
+ if (!existsSync(file)) {
96
+ throw new PatchWardenError("assessment_not_found", `Assessment "${assessmentId}" not found.`, "Call create_task with execution_mode=assess_only first to get a valid assessment_id.", true, { assessment_id: assessmentId });
97
+ }
98
+ return JSON.parse(readFileSync(file, "utf-8"));
99
+ }
100
+ export function validateAssessmentFreshness(assessmentId, currentSnapshot, options = {}) {
101
+ let assessment;
102
+ try {
103
+ assessment = readAssessment(assessmentId);
104
+ }
105
+ catch (e) {
106
+ return { valid: false, failure_reason: extractReason(e), assessment: null };
107
+ }
108
+ if (new Date(assessment.expires_at) < new Date()) {
109
+ return { valid: false, failure_reason: "assessment_expired", assessment };
110
+ }
111
+ if (assessment.plan_id && assessment.plan_hash) {
112
+ const config = getConfig();
113
+ const planFile = resolve(config.workspaceRoot, config.plansDir, assessment.plan_id, "plan.md");
114
+ if (!existsSync(planFile)) {
115
+ return { valid: false, failure_reason: "assessment_plan_file_missing", assessment };
116
+ }
117
+ const currentPlanHash = createHash("sha256")
118
+ .update(readFileSync(planFile, "utf-8"))
119
+ .digest("hex");
120
+ if (currentPlanHash !== assessment.plan_hash) {
121
+ return { valid: false, failure_reason: "assessment_stale_plan", assessment };
122
+ }
123
+ }
124
+ if (!options.skip_tool_manifest) {
125
+ const currentManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
126
+ computeFallbackManifestHash(getConfig());
127
+ if (currentManifest !== assessment.tool_manifest_sha256) {
128
+ return { valid: false, failure_reason: "assessment_stale_config", assessment };
129
+ }
130
+ }
131
+ const currentFingerprint = computeWorkspaceFingerprint(currentSnapshot);
132
+ if (currentFingerprint !== assessment.workspace_fingerprint) {
133
+ return { valid: false, failure_reason: "assessment_workspace_changed", assessment };
134
+ }
135
+ const config = getConfig();
136
+ const currentPolicyHash = computePolicyHash({
137
+ change_policy: assessment.change_policy || "repo_scoped_changes",
138
+ template: assessment.template || null,
139
+ verify_commands: assessment.verify_commands || [],
140
+ allowed_test_commands: config.allowedTestCommands,
141
+ repo_allowed_test_commands: getRepoAllowedCommands(config, assessment.resolved_repo_path),
142
+ sensitive_path_rules: SENSITIVE_PATH_RULES_SIGNATURE,
143
+ artifact_rules: ARTIFACT_RULES_SIGNATURE,
144
+ schema_epoch: TOOL_SCHEMA_EPOCH,
145
+ });
146
+ if (currentPolicyHash !== assessment.policy_hash) {
147
+ return { valid: false, failure_reason: "assessment_stale_policy", assessment };
148
+ }
149
+ if (assessment.requires_confirm && !assessment.confirmed && !options.allow_unconfirmed) {
150
+ return { valid: false, failure_reason: "assessment_needs_confirm", assessment };
151
+ }
152
+ return { valid: true, failure_reason: null, assessment };
153
+ }
154
+ /**
155
+ * Records an explicit confirmation performed from the local PatchWarden CLI.
156
+ * This is intentionally not registered as an MCP tool: a remote client may
157
+ * request confirmation, but it cannot grant confirmation to itself.
158
+ */
159
+ export function confirmAssessment(assessmentId) {
160
+ if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
161
+ throw new PatchWardenError("assessment_id_invalid", "Local confirmation requires the full assessment_id (32 hexadecimal random characters).", "Copy the complete assessment_id from the assess_only response; assessment_short_id is display-only.");
162
+ }
163
+ const assessment = readAssessment(assessmentId);
164
+ if (assessment.decision !== "needs_confirm" || !assessment.requires_confirm) {
165
+ throw new PatchWardenError("assessment_confirmation_not_allowed", `Assessment "${assessmentId}" has decision "${assessment.decision}" and cannot be locally confirmed.`, assessment.decision === "blocked"
166
+ ? "Fix the hard-rule finding and run assess_only again."
167
+ : "This assessment does not require confirmation; execute it with the minimal assessment_id call.");
168
+ }
169
+ const snapshot = captureRepoSnapshot(assessment.resolved_repo_path);
170
+ const validation = validateAssessmentFreshness(assessmentId, snapshot, {
171
+ allow_unconfirmed: true,
172
+ // A standalone CLI has no active MCP profile snapshot. The execute path
173
+ // still performs the full tool-manifest check before creating the task.
174
+ skip_tool_manifest: true,
175
+ });
176
+ if (!validation.valid || !validation.assessment) {
177
+ throw new PatchWardenError(validation.failure_reason || "assessment_validation_failed", `Assessment "${assessmentId}" cannot be confirmed: ${validation.failure_reason || "validation_failed"}.`, "Run create_task with execution_mode=assess_only again, then confirm the new assessment locally.");
178
+ }
179
+ const confirmedAt = validation.assessment.confirmed_at || new Date().toISOString();
180
+ const confirmedRecord = {
181
+ ...validation.assessment,
182
+ confirmed: true,
183
+ confirmed_at: confirmedAt,
184
+ confirm_code: validation.assessment.confirm_code || randomBytes(8).toString("hex"),
185
+ };
186
+ const config = getConfig();
187
+ const assessmentFile = resolve(getAssessmentsDir(config), assessmentId, "assessment.json");
188
+ guardPath(assessmentFile, config.workspaceRoot, config.assessmentsDir);
189
+ const temporaryFile = `${assessmentFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
190
+ guardPath(temporaryFile, config.workspaceRoot, config.assessmentsDir);
191
+ writeFileSync(temporaryFile, JSON.stringify(confirmedRecord, null, 2), "utf-8");
192
+ renameSync(temporaryFile, assessmentFile);
193
+ return {
194
+ assessment_id: assessmentId,
195
+ decision: "needs_confirm",
196
+ confirmed: true,
197
+ confirmed_at: confirmedAt,
198
+ expires_at: confirmedRecord.expires_at,
199
+ next_action: "Call create_task with only execution_mode=execute and this full assessment_id.",
200
+ };
201
+ }
202
+ export function computeWorkspaceFingerprint(snapshot) {
203
+ const fileHashes = Object.entries(snapshot.files)
204
+ .sort(([a], [b]) => a.localeCompare(b))
205
+ .map(([path, fp]) => `${path}:${fp.sha256}`)
206
+ .join("\n");
207
+ return createHash("sha256")
208
+ .update(`${snapshot.head || "null"}\n${snapshot.status}\n${fileHashes}`)
209
+ .digest("hex");
210
+ }
211
+ export function computePolicyHash(input) {
212
+ const payload = JSON.stringify({
213
+ change_policy: input.change_policy,
214
+ template: input.template,
215
+ verify_commands: [...input.verify_commands].sort(),
216
+ allowed_test_commands: [...input.allowed_test_commands].sort(),
217
+ repo_allowed_test_commands: [...input.repo_allowed_test_commands].sort(),
218
+ sensitive_path_rules: input.sensitive_path_rules,
219
+ artifact_rules: input.artifact_rules,
220
+ schema_epoch: input.schema_epoch,
221
+ });
222
+ return createHash("sha256").update(payload).digest("hex");
223
+ }
224
+ function getRepoAllowedCommands(config, repoPath) {
225
+ return getRepoAllowedTestCommands(config, repoPath);
226
+ }
227
+ function computeFallbackManifestHash(config) {
228
+ return createHash("sha256")
229
+ .update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
230
+ .digest("hex");
231
+ }
232
+ function extractReason(error) {
233
+ if (error instanceof PatchWardenError)
234
+ return error.reason;
235
+ if (error instanceof Error)
236
+ return error.message.slice(0, 80);
237
+ return String(error).slice(0, 80);
238
+ }