patchwarden 1.6.1 → 1.6.2

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 (91) hide show
  1. package/README.en.md +17 -12
  2. package/README.md +14 -11
  3. package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
  4. package/dist/assessments/assessmentDiagnostics.js +132 -0
  5. package/dist/assessments/assessmentStore.d.ts +39 -0
  6. package/dist/assessments/assessmentStore.js +166 -12
  7. package/dist/assessments/securitySnapshot.d.ts +50 -0
  8. package/dist/assessments/securitySnapshot.js +158 -0
  9. package/dist/control/routes/status.d.ts +14 -0
  10. package/dist/control/routes/status.js +22 -0
  11. package/dist/control/runtime.js +2 -10
  12. package/dist/control/server.js +1 -0
  13. package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
  14. package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
  15. package/dist/direct/directSessionStore.js +5 -4
  16. package/dist/doctor.js +3 -5
  17. package/dist/runner/runTask.d.ts +1 -0
  18. package/dist/runner/runTask.js +47 -6
  19. package/dist/runner/taskRuntime.d.ts +2 -0
  20. package/dist/runner/taskStatusStore.js +12 -1
  21. package/dist/runner/watch.js +64 -1
  22. package/dist/smoke-test.js +3 -3
  23. package/dist/tools/definitions/toolDefs.js +3 -3
  24. package/dist/tools/diagnostics/auditTask.d.ts +1 -0
  25. package/dist/tools/diagnostics/auditTask.js +35 -7
  26. package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
  27. package/dist/tools/diagnostics/healthCheck.js +29 -1
  28. package/dist/tools/tasks/cancelTask.js +2 -1
  29. package/dist/tools/tasks/createTask.d.ts +2 -2
  30. package/dist/tools/tasks/createTask.js +70 -9
  31. package/dist/tools/tasks/diagnoseTask.js +4 -8
  32. package/dist/tools/tasks/getTaskStatus.js +6 -1
  33. package/dist/tools/tasks/getTaskSummary.js +2 -10
  34. package/dist/tools/tasks/listTasks.js +2 -1
  35. package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
  36. package/dist/tools/tasks/reconcileTasks.js +185 -39
  37. package/dist/tools/tasks/runTaskLoop.js +5 -11
  38. package/dist/tools/tasks/taskOutputs.d.ts +2 -2
  39. package/dist/tools/tasks/taskStates.d.ts +6 -0
  40. package/dist/tools/tasks/taskStates.js +52 -0
  41. package/dist/tools/tasks/waitForTask.js +3 -11
  42. package/dist/tools/workspace/listAgents.d.ts +6 -0
  43. package/dist/tools/workspace/listAgents.js +12 -2
  44. package/dist/utils/atomicFile.js +20 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CODE_WIKI.md +4 -4
  48. package/docs/open-source-application.md +11 -12
  49. package/docs/release-evidence.md +33 -47
  50. package/package.json +1 -1
  51. package/scripts/checks/control-center-smoke.js +11 -4
  52. package/scripts/checks/lifecycle-smoke.js +5 -2
  53. package/scripts/checks/mcp-smoke.js +31 -1
  54. package/scripts/checks/watcher-supervisor-smoke.js +29 -1
  55. package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
  56. package/scripts/e2e/demo-runtime.mjs +153 -0
  57. package/scripts/release/desktop-preflight.js +1 -1
  58. package/src/assessments/assessmentDiagnostics.ts +172 -0
  59. package/src/assessments/assessmentStore.ts +237 -12
  60. package/src/assessments/securitySnapshot.ts +231 -0
  61. package/src/control/routes/status.ts +37 -0
  62. package/src/control/runtime.ts +2 -10
  63. package/src/control/server.ts +1 -0
  64. package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
  65. package/src/direct/directSessionStore.ts +6 -4
  66. package/src/doctor.ts +3 -5
  67. package/src/runner/runTask.ts +59 -6
  68. package/src/runner/taskRuntime.ts +2 -0
  69. package/src/runner/taskStatusStore.ts +16 -1
  70. package/src/runner/watch.ts +67 -1
  71. package/src/smoke-test.ts +2 -2
  72. package/src/tools/definitions/toolDefs.ts +3 -3
  73. package/src/tools/diagnostics/auditTask.ts +37 -7
  74. package/src/tools/diagnostics/healthCheck.ts +29 -1
  75. package/src/tools/tasks/cancelTask.ts +2 -1
  76. package/src/tools/tasks/createTask.ts +113 -8
  77. package/src/tools/tasks/diagnoseTask.ts +4 -8
  78. package/src/tools/tasks/getTaskStatus.ts +6 -1
  79. package/src/tools/tasks/getTaskSummary.ts +2 -11
  80. package/src/tools/tasks/listTasks.ts +2 -1
  81. package/src/tools/tasks/reconcileTasks.ts +162 -13
  82. package/src/tools/tasks/runTaskLoop.ts +4 -12
  83. package/src/tools/tasks/taskStates.ts +54 -0
  84. package/src/tools/tasks/waitForTask.ts +3 -12
  85. package/src/tools/workspace/listAgents.ts +17 -3
  86. package/src/utils/atomicFile.ts +17 -1
  87. package/src/version.ts +1 -1
  88. package/ui/pages/dashboard.html +4 -0
  89. package/ui/pages/logs.html +29 -0
  90. package/ui/pages/settings.html +3 -3
  91. package/ui/settings.js +26 -17
@@ -0,0 +1,172 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAssessmentsDir, getConfig } from "../config.js";
4
+ import { guardPath } from "../security/pathGuard.js";
5
+ import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
6
+ import { readJsonObjectFileSync } from "../utils/lockedJsonFile.js";
7
+ import type { AssessmentValidationResult } from "./assessmentStore.js";
8
+ import { ASSESSMENT_SECURITY_SNAPSHOT_VERSION } from "./securitySnapshot.js";
9
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
10
+
11
+ const DIAGNOSTIC_VERSION = "assessment-validation-diagnostic-v2";
12
+ const DIAGNOSTIC_FILE = "last-validation-failure.json";
13
+ const VALIDATOR_MODULE_PATH = "dist/assessments/assessmentStore.js";
14
+ const VALIDATOR_BUILD_ID = `patchwarden-${PATCHWARDEN_VERSION}:${TOOL_SCHEMA_EPOCH}:${ASSESSMENT_SECURITY_SNAPSHOT_VERSION}`;
15
+
16
+ export interface AssessmentValidationFailureDiagnostic {
17
+ diagnostic_version: string;
18
+ error_code: string;
19
+ reason_code: string;
20
+ assessment_id: string | null;
21
+ assessment_config_sha256: string | null;
22
+ current_config_sha256: string | null;
23
+ assessment_config_hash: string | null;
24
+ current_config_hash: string | null;
25
+ snapshot_version: string | null;
26
+ assessment_fingerprint_version: string | null;
27
+ current_fingerprint_version: string;
28
+ assessment_schema_epoch: string | null;
29
+ current_schema_epoch: string;
30
+ assessment_created_at: string | null;
31
+ assessment_expires_at: string | null;
32
+ config_changed: boolean;
33
+ changed_field_names: string[];
34
+ changed_config_sections: string[];
35
+ validator_module_path: string;
36
+ validator_build_id: string;
37
+ watcher_instance_id: string | null;
38
+ observed_at: string;
39
+ }
40
+
41
+ export function buildAssessmentValidationFailureDiagnostic(
42
+ result: AssessmentValidationResult,
43
+ ): AssessmentValidationFailureDiagnostic | null {
44
+ if (result.valid || !result.failure_reason) return null;
45
+ const reasonCode = safeCode(result.failure_reason);
46
+ const assessmentHash = safeHash(result.expected_hash);
47
+ const currentHash = safeHash(result.actual_hash);
48
+ const changedSections = [...new Set((result.config_change_categories || [])
49
+ .map((name) => safeCode(name))
50
+ .filter(Boolean))]
51
+ .sort()
52
+ .slice(0, 32);
53
+ const assessmentVersion = safeText(result.assessment?.assessment_security_snapshot_version, 80);
54
+ return {
55
+ diagnostic_version: DIAGNOSTIC_VERSION,
56
+ error_code: reasonCode,
57
+ reason_code: reasonCode,
58
+ assessment_id: safeAssessmentId(result.assessment?.assessment_id),
59
+ assessment_config_sha256: assessmentHash,
60
+ current_config_sha256: currentHash,
61
+ assessment_config_hash: assessmentHash,
62
+ current_config_hash: currentHash,
63
+ snapshot_version: assessmentVersion,
64
+ assessment_fingerprint_version: assessmentVersion,
65
+ current_fingerprint_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
66
+ assessment_schema_epoch: safeText(result.assessment?.assessment_schema_epoch, 80),
67
+ current_schema_epoch: TOOL_SCHEMA_EPOCH,
68
+ assessment_created_at: safeTimestamp(result.assessment?.created_at),
69
+ assessment_expires_at: safeTimestamp(result.assessment?.expires_at),
70
+ config_changed: result.failure_reason === "assessment_stale_config",
71
+ changed_field_names: changedSections,
72
+ changed_config_sections: changedSections,
73
+ validator_module_path: VALIDATOR_MODULE_PATH,
74
+ validator_build_id: VALIDATOR_BUILD_ID,
75
+ watcher_instance_id: safeInstanceId(process.env.PATCHWARDEN_WATCHER_INSTANCE_ID),
76
+ observed_at: new Date().toISOString(),
77
+ };
78
+ }
79
+
80
+ /** Persist a bounded value-free failure summary without changing validation behavior. */
81
+ export function recordAssessmentValidationFailure(result: AssessmentValidationResult): boolean {
82
+ const diagnostic = buildAssessmentValidationFailureDiagnostic(result);
83
+ if (!diagnostic) return false;
84
+ try {
85
+ const config = getConfig();
86
+ const assessmentsDir = getAssessmentsDir(config);
87
+ const file = guardPath(
88
+ join(assessmentsDir, DIAGNOSTIC_FILE),
89
+ config.workspaceRoot,
90
+ config.assessmentsDir,
91
+ );
92
+ mkdirSync(dirname(file), { recursive: true });
93
+ atomicWriteJsonFileSync(file, diagnostic);
94
+ return true;
95
+ } catch {
96
+ // Diagnostics must never replace the original fail-closed validation error.
97
+ return false;
98
+ }
99
+ }
100
+
101
+ export function readLastAssessmentValidationFailure(): AssessmentValidationFailureDiagnostic | null {
102
+ try {
103
+ const config = getConfig();
104
+ const file = guardPath(
105
+ join(getAssessmentsDir(config), DIAGNOSTIC_FILE),
106
+ config.workspaceRoot,
107
+ config.assessmentsDir,
108
+ );
109
+ if (!existsSync(file)) return null;
110
+ const raw = readJsonObjectFileSync<Record<string, unknown>>(file);
111
+ if (raw.diagnostic_version !== DIAGNOSTIC_VERSION) return null;
112
+ const changedSections = Array.isArray(raw.changed_config_sections)
113
+ ? [...new Set(raw.changed_config_sections.map(safeCode).filter(Boolean))].sort().slice(0, 32)
114
+ : [];
115
+ return {
116
+ diagnostic_version: DIAGNOSTIC_VERSION,
117
+ error_code: safeCode(raw.error_code),
118
+ reason_code: safeCode(raw.reason_code),
119
+ assessment_id: safeAssessmentId(raw.assessment_id),
120
+ assessment_config_sha256: safeHash(raw.assessment_config_sha256),
121
+ current_config_sha256: safeHash(raw.current_config_sha256),
122
+ assessment_config_hash: safeHash(raw.assessment_config_hash),
123
+ current_config_hash: safeHash(raw.current_config_hash),
124
+ snapshot_version: safeText(raw.snapshot_version, 80),
125
+ assessment_fingerprint_version: safeText(raw.assessment_fingerprint_version, 80),
126
+ current_fingerprint_version: safeText(raw.current_fingerprint_version, 80) || ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
127
+ assessment_schema_epoch: safeText(raw.assessment_schema_epoch, 80),
128
+ current_schema_epoch: safeText(raw.current_schema_epoch, 80) || TOOL_SCHEMA_EPOCH,
129
+ assessment_created_at: safeTimestamp(raw.assessment_created_at),
130
+ assessment_expires_at: safeTimestamp(raw.assessment_expires_at),
131
+ config_changed: raw.config_changed === true,
132
+ changed_field_names: Array.isArray(raw.changed_field_names)
133
+ ? [...new Set(raw.changed_field_names.map(safeCode).filter(Boolean))].sort().slice(0, 32)
134
+ : [],
135
+ changed_config_sections: changedSections,
136
+ validator_module_path: safeText(raw.validator_module_path, 120) || VALIDATOR_MODULE_PATH,
137
+ validator_build_id: safeText(raw.validator_build_id, 160) || VALIDATOR_BUILD_ID,
138
+ watcher_instance_id: safeInstanceId(raw.watcher_instance_id),
139
+ observed_at: safeTimestamp(raw.observed_at) || new Date(0).toISOString(),
140
+ };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function safeAssessmentId(value: unknown): string | null {
147
+ return typeof value === "string" && /^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(value) ? value : null;
148
+ }
149
+
150
+ function safeInstanceId(value: unknown): string | null {
151
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : null;
152
+ }
153
+
154
+ function safeCode(value: unknown): string {
155
+ return typeof value === "string" && /^[a-z0-9_]{1,80}$/.test(value) ? value : "";
156
+ }
157
+
158
+ function safeHash(value: unknown): string | null {
159
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : null;
160
+ }
161
+
162
+ function safeText(value: unknown, maxLength: number): string | null {
163
+ return typeof value === "string" && value.length > 0
164
+ ? value.replace(/[\r\n]+/g, " ").slice(0, maxLength)
165
+ : null;
166
+ }
167
+
168
+ function safeTimestamp(value: unknown): string | null {
169
+ return typeof value === "string" && Number.isFinite(Date.parse(value))
170
+ ? new Date(value).toISOString()
171
+ : null;
172
+ }
@@ -12,10 +12,22 @@ import { redactSensitiveContent } from "../security/contentRedaction.js";
12
12
  import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
13
13
  import { withFileLockSync } from "../utils/lockedJsonFile.js";
14
14
  import { TOOL_SCHEMA_EPOCH } from "../version.js";
15
- import { getLastToolCatalogSnapshot } from "../tools/catalog/toolCatalog.js";
15
+ import {
16
+ buildToolCatalogSnapshot,
17
+ resolveToolProfile,
18
+ } from "../tools/catalog/toolCatalog.js";
19
+ import { getToolDefs } from "../tools/definitions/toolDefs.js";
16
20
  import { captureRepoSnapshot, type RepoSnapshot } from "../runner/changeCapture.js";
17
21
  import type { RiskAssessmentResult } from "../security/riskEngine.js";
18
22
  import type { TaskTemplateName, ChangePolicy } from "../tools/taskTemplates.js";
23
+ import { getProjectPolicySummary, type ProjectPolicy } from "../policy/projectPolicy.js";
24
+ import {
25
+ ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
26
+ buildAssessmentSecuritySnapshot,
27
+ getAssessmentSecuritySnapshotComponentHashes,
28
+ hashAssessmentSecuritySnapshot,
29
+ type SecuritySnapshotCategory,
30
+ } from "./securitySnapshot.js";
19
31
 
20
32
  const SENSITIVE_PATH_RULES_SIGNATURE = "v1";
21
33
  const ARTIFACT_RULES_SIGNATURE = "v1";
@@ -65,6 +77,12 @@ export interface AssessmentRecord {
65
77
  plan_id: string | null;
66
78
  policy_hash: string;
67
79
  tool_manifest_sha256: string;
80
+ execution_config_hash?: string;
81
+ execution_config_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
82
+ assessment_security_snapshot_version?: string;
83
+ assessment_security_snapshot_sha256?: string;
84
+ assessment_security_snapshot_component_hashes?: Partial<Record<ExecutionConfigChangeCategory, string>>;
85
+ assessment_schema_epoch?: string;
68
86
  workspace_fingerprint: string;
69
87
  workspace_snapshot_summary: {
70
88
  head: string | null;
@@ -83,10 +101,15 @@ export interface AssessmentRecord {
83
101
  agent: string;
84
102
  timeout_seconds?: number;
85
103
  change_policy?: ChangePolicy;
104
+ scope?: string[];
105
+ forbidden?: string[];
106
+ verification?: string[];
107
+ done_evidence?: string[];
86
108
  requires_confirm: boolean;
87
109
  confirmed: boolean;
88
110
  confirmed_at: string | null;
89
111
  confirm_code: string | null;
112
+ used_at?: string | null;
90
113
  agent_assessment_summary?: AgentAssessmentSummary | null;
91
114
  }
92
115
 
@@ -107,6 +130,10 @@ export interface AssessmentCreateInput {
107
130
  agent: string;
108
131
  timeout_seconds?: number;
109
132
  change_policy?: ChangePolicy;
133
+ scope?: string[];
134
+ forbidden?: string[];
135
+ verification?: string[];
136
+ done_evidence?: string[];
110
137
  snapshot: RepoSnapshot;
111
138
  assessment_id?: string;
112
139
  assessment_dir?: string;
@@ -117,8 +144,13 @@ export interface AssessmentValidationResult {
117
144
  valid: boolean;
118
145
  failure_reason: string | null;
119
146
  assessment: AssessmentRecord | null;
147
+ expected_hash?: string;
148
+ actual_hash?: string;
149
+ config_change_categories?: ExecutionConfigChangeCategory[];
120
150
  }
121
151
 
152
+ export type ExecutionConfigChangeCategory = SecuritySnapshotCategory | "allowed_test_commands" | "execution_config";
153
+
122
154
  export interface AssessmentConfirmationResult {
123
155
  assessment_id: string;
124
156
  decision: "needs_confirm";
@@ -161,7 +193,7 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
161
193
  ? createHash("sha256").update(input.plan_content).digest("hex")
162
194
  : null;
163
195
 
164
- const toolManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 || computeFallbackManifestHash(config);
196
+ const toolManifest = getCurrentToolManifest(config);
165
197
 
166
198
  const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
167
199
  const snapshotTruncated = input.snapshot.warnings.some((w) => w.includes("snapshot limited"));
@@ -176,6 +208,21 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
176
208
  artifact_rules: ARTIFACT_RULES_SIGNATURE,
177
209
  schema_epoch: TOOL_SCHEMA_EPOCH,
178
210
  });
211
+ const executionConfig = computeExecutionConfigFingerprint(config, {
212
+ agent: input.agent,
213
+ tool_manifest_sha256: toolManifest,
214
+ tool_profile: resolveToolProfile(config.toolProfile),
215
+ repo_path: input.resolved_repo_path,
216
+ change_policy: input.change_policy || "repo_scoped_changes",
217
+ template: input.template || null,
218
+ verify_commands: input.verify_commands || [],
219
+ test_command: input.test_command || null,
220
+ task_timeout_seconds: input.timeout_seconds,
221
+ scope: input.scope,
222
+ forbidden: input.forbidden,
223
+ verification: input.verification,
224
+ done_evidence: input.done_evidence,
225
+ });
179
226
 
180
227
  const now = new Date();
181
228
  const expiresAt = new Date(now.getTime() + config.assessmentTtlSeconds * 1000).toISOString();
@@ -192,6 +239,12 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
192
239
  plan_id: input.plan_id,
193
240
  policy_hash: policyHash,
194
241
  tool_manifest_sha256: toolManifest,
242
+ execution_config_hash: executionConfig.hash,
243
+ execution_config_component_hashes: executionConfig.components,
244
+ assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
245
+ assessment_security_snapshot_sha256: executionConfig.hash,
246
+ assessment_security_snapshot_component_hashes: executionConfig.components,
247
+ assessment_schema_epoch: TOOL_SCHEMA_EPOCH,
195
248
  workspace_fingerprint: workspaceFingerprint,
196
249
  workspace_snapshot_summary: {
197
250
  head: input.snapshot.head,
@@ -210,10 +263,15 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
210
263
  agent: input.agent,
211
264
  timeout_seconds: input.timeout_seconds,
212
265
  change_policy: input.change_policy || "repo_scoped_changes",
266
+ scope: input.scope || [],
267
+ forbidden: input.forbidden || [],
268
+ verification: input.verification || [],
269
+ done_evidence: input.done_evidence || [],
213
270
  requires_confirm: input.decision === "needs_confirm",
214
271
  confirmed: false,
215
272
  confirmed_at: null,
216
273
  confirm_code: null,
274
+ used_at: null,
217
275
  agent_assessment_summary: input.agent_assessment_summary || null,
218
276
  };
219
277
 
@@ -223,6 +281,13 @@ export function createAssessment(input: AssessmentCreateInput): AssessmentRecord
223
281
  }
224
282
 
225
283
  export function readAssessment(assessmentId: string): AssessmentRecord {
284
+ if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
285
+ throw new PatchWardenError(
286
+ "assessment_id_invalid",
287
+ "A full assessment_id with 32 hexadecimal random characters is required.",
288
+ "Copy the complete assessment_id from the assess_only response; assessment_short_id is display-only.",
289
+ );
290
+ }
226
291
  const config = getConfig();
227
292
  const assessmentsDir = getAssessmentsDir(config);
228
293
  const dir = resolve(assessmentsDir, assessmentId);
@@ -237,7 +302,43 @@ export function readAssessment(assessmentId: string): AssessmentRecord {
237
302
  { assessment_id: assessmentId }
238
303
  );
239
304
  }
240
- return JSON.parse(readFileSync(file, "utf-8")) as AssessmentRecord;
305
+ try {
306
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as AssessmentRecord;
307
+ if (!parsed || typeof parsed !== "object" || parsed.assessment_id !== assessmentId) {
308
+ throw new Error("record identity mismatch");
309
+ }
310
+ return parsed;
311
+ } catch (error) {
312
+ throw new PatchWardenError(
313
+ "assessment_corrupted",
314
+ `Assessment "${assessmentId}" cannot be read as a valid record.`,
315
+ "Run create_task with execution_mode=assess_only again.",
316
+ true,
317
+ { assessment_id: assessmentId, failure_category: "invalid_assessment_record" },
318
+ );
319
+ }
320
+ }
321
+
322
+ /** Atomically consume an Assessment before creating its one allowed task. */
323
+ export function markAssessmentUsed(assessmentId: string): AssessmentRecord {
324
+ const config = getConfig();
325
+ const assessmentFile = resolve(getAssessmentsDir(config), assessmentId, "assessment.json");
326
+ guardPath(assessmentFile, config.workspaceRoot, config.assessmentsDir);
327
+ return withFileLockSync(assessmentFile, () => {
328
+ const assessment = readAssessment(assessmentId);
329
+ if (assessment.used_at) {
330
+ throw new PatchWardenError(
331
+ "assessment_used",
332
+ `Assessment "${assessmentId}" has already been used.`,
333
+ "Run create_task with execution_mode=assess_only again to obtain a new assessment_id.",
334
+ true,
335
+ { assessment_id: assessmentId, used_at: assessment.used_at },
336
+ );
337
+ }
338
+ const consumed: AssessmentRecord = { ...assessment, used_at: new Date().toISOString() };
339
+ atomicWriteJsonFileSync(assessmentFile, consumed);
340
+ return consumed;
341
+ });
241
342
  }
242
343
 
243
344
  export function validateAssessmentFreshness(
@@ -245,6 +346,9 @@ export function validateAssessmentFreshness(
245
346
  currentSnapshot: RepoSnapshot,
246
347
  options: AssessmentValidationOptions = {}
247
348
  ): AssessmentValidationResult {
349
+ if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
350
+ return { valid: false, failure_reason: "assessment_id_invalid", assessment: null };
351
+ }
248
352
  let assessment: AssessmentRecord;
249
353
  try {
250
354
  assessment = readAssessment(assessmentId);
@@ -275,11 +379,21 @@ export function validateAssessmentFreshness(
275
379
  }
276
380
  }
277
381
 
382
+ const config = getConfig();
383
+ const currentManifest = getCurrentToolManifest(config);
278
384
  if (!options.skip_tool_manifest) {
279
- const currentManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
280
- computeFallbackManifestHash(getConfig());
281
- if (currentManifest !== assessment.tool_manifest_sha256) {
282
- return { valid: false, failure_reason: "assessment_stale_config", assessment };
385
+ // Legacy records need the standalone manifest check. Versioned security
386
+ // snapshots compare the manifest together with the source config, so the
387
+ // diagnostic can report both "tool_manifest" and e.g. "allowed_commands".
388
+ if (currentManifest !== assessment.tool_manifest_sha256 && !assessment.assessment_security_snapshot_sha256) {
389
+ return {
390
+ valid: false,
391
+ failure_reason: "assessment_stale_config",
392
+ assessment,
393
+ expected_hash: assessment.tool_manifest_sha256,
394
+ actual_hash: currentManifest,
395
+ config_change_categories: ["tool_manifest"],
396
+ };
283
397
  }
284
398
  }
285
399
 
@@ -288,7 +402,52 @@ export function validateAssessmentFreshness(
288
402
  return { valid: false, failure_reason: "assessment_workspace_changed", assessment };
289
403
  }
290
404
 
291
- const config = getConfig();
405
+ if (assessment.execution_config_hash) {
406
+ const currentExecutionConfig = computeExecutionConfigFingerprint(config, {
407
+ agent: assessment.agent,
408
+ tool_manifest_sha256: currentManifest,
409
+ tool_profile: resolveToolProfile(config.toolProfile),
410
+ repo_path: assessment.resolved_repo_path,
411
+ change_policy: assessment.change_policy || "repo_scoped_changes",
412
+ template: assessment.template || null,
413
+ verify_commands: assessment.verify_commands || [],
414
+ test_command: assessment.test_command || null,
415
+ task_timeout_seconds: assessment.timeout_seconds,
416
+ scope: assessment.scope,
417
+ forbidden: assessment.forbidden,
418
+ verification: assessment.verification,
419
+ done_evidence: assessment.done_evidence,
420
+ });
421
+ if (
422
+ assessment.assessment_security_snapshot_version
423
+ && assessment.assessment_security_snapshot_version !== ASSESSMENT_SECURITY_SNAPSHOT_VERSION
424
+ ) {
425
+ return {
426
+ valid: false,
427
+ failure_reason: "assessment_snapshot_version_incompatible",
428
+ assessment,
429
+ expected_hash: assessment.execution_config_hash,
430
+ actual_hash: currentExecutionConfig.hash,
431
+ config_change_categories: ["schema_epoch"],
432
+ };
433
+ }
434
+ if (currentExecutionConfig.hash !== assessment.execution_config_hash) {
435
+ const previousComponents = assessment.execution_config_component_hashes;
436
+ const changedCategories = previousComponents
437
+ ? (Object.keys(currentExecutionConfig.components) as ExecutionConfigChangeCategory[])
438
+ .filter((category) => previousComponents[category] !== currentExecutionConfig.components[category])
439
+ : ["execution_config" as const];
440
+ return {
441
+ valid: false,
442
+ failure_reason: "assessment_stale_config",
443
+ assessment,
444
+ expected_hash: assessment.execution_config_hash,
445
+ actual_hash: currentExecutionConfig.hash,
446
+ config_change_categories: changedCategories.length > 0 ? changedCategories : ["execution_config"],
447
+ };
448
+ }
449
+ }
450
+
292
451
  const currentPolicyHash = computePolicyHash({
293
452
  change_policy: assessment.change_policy || "repo_scoped_changes",
294
453
  template: assessment.template || null,
@@ -425,10 +584,76 @@ function getRepoAllowedCommands(config: PatchWardenConfig, repoPath: string): st
425
584
  return getRepoAllowedTestCommands(config, repoPath);
426
585
  }
427
586
 
428
- function computeFallbackManifestHash(config: PatchWardenConfig): string {
429
- return createHash("sha256")
430
- .update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
431
- .digest("hex");
587
+ function getCurrentToolManifest(config: PatchWardenConfig): string {
588
+ // The catalog cache is process-local. Rebuild the canonical catalog here so
589
+ // MCP and the independent Watcher derive the same manifest across processes.
590
+ const profile = resolveToolProfile(config.toolProfile);
591
+ return buildToolCatalogSnapshot(getToolDefs(), profile).tool_manifest_sha256;
592
+ }
593
+
594
+ interface ExecutionConfigHashInput {
595
+ agent: string;
596
+ tool_manifest_sha256: string;
597
+ tool_profile: string;
598
+ repo_path: string;
599
+ change_policy?: string | null;
600
+ template?: string | null;
601
+ verify_commands?: string[];
602
+ test_command?: string | null;
603
+ task_timeout_seconds?: number | null;
604
+ scope?: string[];
605
+ forbidden?: string[];
606
+ verification?: string[];
607
+ done_evidence?: string[];
608
+ }
609
+
610
+ export function computeExecutionConfigHash(
611
+ config: PatchWardenConfig,
612
+ input: ExecutionConfigHashInput,
613
+ ): string {
614
+ return computeExecutionConfigFingerprint(config, input).hash;
615
+ }
616
+
617
+ function computeExecutionConfigFingerprint(
618
+ config: PatchWardenConfig,
619
+ input: ExecutionConfigHashInput,
620
+ ): { hash: string; components: Partial<Record<ExecutionConfigChangeCategory, string>> } {
621
+ let projectPolicy: ProjectPolicy | null = null;
622
+ let projectPolicyValid: boolean | null = null;
623
+ let projectPolicyIssues: Array<{ code: string; severity: string; field: string }> = [];
624
+ try {
625
+ const summary = getProjectPolicySummary(input.repo_path);
626
+ projectPolicy = summary.effective_policy;
627
+ projectPolicyValid = summary.valid;
628
+ projectPolicyIssues = summary.issues.map(({ code, severity, field }) => ({ code, severity, field }));
629
+ } catch {
630
+ // A workspace-root change can make the old repository inaccessible. The
631
+ // workspace_root component still fails closed without exposing the path.
632
+ }
633
+ const snapshot = buildAssessmentSecuritySnapshot({
634
+ config,
635
+ schemaEpoch: TOOL_SCHEMA_EPOCH,
636
+ toolProfile: input.tool_profile,
637
+ toolManifestSha256: input.tool_manifest_sha256,
638
+ agent: input.agent,
639
+ repoPath: input.repo_path,
640
+ changePolicy: input.change_policy,
641
+ template: input.template,
642
+ verifyCommands: input.verify_commands,
643
+ testCommand: input.test_command,
644
+ taskTimeoutSeconds: input.task_timeout_seconds,
645
+ scope: input.scope,
646
+ forbidden: input.forbidden,
647
+ verification: input.verification,
648
+ doneEvidence: input.done_evidence,
649
+ projectPolicy,
650
+ projectPolicyValid,
651
+ projectPolicyIssues,
652
+ });
653
+ return {
654
+ hash: hashAssessmentSecuritySnapshot(snapshot),
655
+ components: getAssessmentSecuritySnapshotComponentHashes(snapshot),
656
+ };
432
657
  }
433
658
 
434
659
  function extractReason(error: unknown): string {