@dzhechkov/harness-core 0.8.5 → 0.8.10

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 (134) hide show
  1. package/.dz-manifest.json +286 -82
  2. package/README.md +54 -2
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.d.ts +11 -2
  15. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  16. package/dist/feature-adr-checkpoints.js +38 -3
  17. package/dist/feature-adr-checkpoints.js.map +1 -1
  18. package/dist/feature-adr-decision-recall.d.ts +167 -0
  19. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  20. package/dist/feature-adr-decision-recall.js +519 -0
  21. package/dist/feature-adr-decision-recall.js.map +1 -0
  22. package/dist/feature-adr-landing.d.ts +37 -0
  23. package/dist/feature-adr-landing.d.ts.map +1 -0
  24. package/dist/feature-adr-landing.js +59 -0
  25. package/dist/feature-adr-landing.js.map +1 -0
  26. package/dist/feature-adr-routing.d.ts +58 -23
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +194 -69
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard-promotion.d.ts +41 -0
  31. package/dist/guard-promotion.d.ts.map +1 -1
  32. package/dist/guard-promotion.js +218 -4
  33. package/dist/guard-promotion.js.map +1 -1
  34. package/dist/guard-volume.d.ts +108 -0
  35. package/dist/guard-volume.d.ts.map +1 -0
  36. package/dist/guard-volume.js +536 -0
  37. package/dist/guard-volume.js.map +1 -0
  38. package/dist/guard.d.ts +34 -0
  39. package/dist/guard.d.ts.map +1 -1
  40. package/dist/guard.js +113 -4
  41. package/dist/guard.js.map +1 -1
  42. package/dist/index.d.ts +18 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +12 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/integration-apply.d.ts +25 -0
  47. package/dist/integration-apply.d.ts.map +1 -0
  48. package/dist/integration-apply.js +299 -0
  49. package/dist/integration-apply.js.map +1 -0
  50. package/dist/integration-evidence.d.ts +46 -0
  51. package/dist/integration-evidence.d.ts.map +1 -0
  52. package/dist/integration-evidence.js +44 -0
  53. package/dist/integration-evidence.js.map +1 -0
  54. package/dist/integration-probe-worker.d.ts +22 -0
  55. package/dist/integration-probe-worker.d.ts.map +1 -0
  56. package/dist/integration-probe-worker.js +334 -0
  57. package/dist/integration-probe-worker.js.map +1 -0
  58. package/dist/integrations-verify.d.ts +60 -0
  59. package/dist/integrations-verify.d.ts.map +1 -0
  60. package/dist/integrations-verify.js +194 -0
  61. package/dist/integrations-verify.js.map +1 -0
  62. package/dist/lesson-generalization.d.ts +29 -0
  63. package/dist/lesson-generalization.d.ts.map +1 -0
  64. package/dist/lesson-generalization.js +84 -0
  65. package/dist/lesson-generalization.js.map +1 -0
  66. package/dist/loop-blobs.generated.js +8 -8
  67. package/dist/loop-blobs.generated.js.map +1 -1
  68. package/dist/loop-plan.d.ts +13 -1
  69. package/dist/loop-plan.d.ts.map +1 -1
  70. package/dist/loop-plan.js +15 -1
  71. package/dist/loop-plan.js.map +1 -1
  72. package/dist/loop-render.d.ts.map +1 -1
  73. package/dist/loop-render.js +51 -6
  74. package/dist/loop-render.js.map +1 -1
  75. package/dist/loop-trace.d.ts +20 -1
  76. package/dist/loop-trace.d.ts.map +1 -1
  77. package/dist/loop-trace.js +83 -1
  78. package/dist/loop-trace.js.map +1 -1
  79. package/dist/model-recommender.d.ts +8 -0
  80. package/dist/model-recommender.d.ts.map +1 -1
  81. package/dist/model-recommender.js +31 -4
  82. package/dist/model-recommender.js.map +1 -1
  83. package/dist/operations.d.ts +27 -0
  84. package/dist/operations.d.ts.map +1 -1
  85. package/dist/operations.js +178 -2
  86. package/dist/operations.js.map +1 -1
  87. package/dist/patterns.d.ts +27 -1
  88. package/dist/patterns.d.ts.map +1 -1
  89. package/dist/patterns.js +211 -45
  90. package/dist/patterns.js.map +1 -1
  91. package/dist/setup.d.ts.map +1 -1
  92. package/dist/setup.js +20 -17
  93. package/dist/setup.js.map +1 -1
  94. package/dist/target-integrations.d.ts +65 -0
  95. package/dist/target-integrations.d.ts.map +1 -0
  96. package/dist/target-integrations.js +152 -0
  97. package/dist/target-integrations.js.map +1 -0
  98. package/dist/vector-tier.d.ts +6 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +32 -7
  101. package/dist/vector-tier.js.map +1 -1
  102. package/dist/workflow-run.d.ts.map +1 -1
  103. package/dist/workflow-run.js +18 -12
  104. package/dist/workflow-run.js.map +1 -1
  105. package/package.json +19 -15
  106. package/sbom.json +591 -81
  107. package/src/amendment-trace.ts +12 -1
  108. package/src/compounding.ts +300 -1
  109. package/src/eta.ts +590 -0
  110. package/src/feature-adr-checkpoints.ts +39 -3
  111. package/src/feature-adr-decision-recall.ts +652 -0
  112. package/src/feature-adr-landing.ts +109 -0
  113. package/src/feature-adr-routing.ts +224 -65
  114. package/src/guard-promotion.ts +245 -4
  115. package/src/guard-volume.ts +752 -0
  116. package/src/guard.ts +148 -4
  117. package/src/index.ts +65 -3
  118. package/src/integration-apply.ts +332 -0
  119. package/src/integration-evidence.ts +89 -0
  120. package/src/integration-probe-worker.ts +310 -0
  121. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  122. package/src/integrations-verify.ts +258 -0
  123. package/src/lesson-generalization.ts +115 -0
  124. package/src/loop-blobs.generated.ts +8 -8
  125. package/src/loop-plan.ts +36 -3
  126. package/src/loop-render.ts +50 -6
  127. package/src/loop-trace.ts +91 -2
  128. package/src/model-recommender.ts +35 -4
  129. package/src/operations.ts +199 -2
  130. package/src/patterns.ts +252 -43
  131. package/src/setup.ts +20 -17
  132. package/src/target-integrations.ts +225 -0
  133. package/src/vector-tier.ts +44 -14
  134. package/src/workflow-run.ts +18 -11
@@ -0,0 +1,310 @@
1
+ /** Isolated bounded subprocess runner used by integration registration probes. */
2
+
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
+ import { realpathSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { resolve } from 'node:path';
7
+
8
+ export const PROBE_REDACTION_MARKER = '[REDACTED]';
9
+ export const PROBE_TRUNCATION_MARKER = '[TRUNCATED]';
10
+ const WORKER_INPUT_MAX_BYTES = 64 * 1024;
11
+ const REDACTION_WORDS = ['bearer', 'token', 'secret', 'password', 'apikey', 'api_key', 'api-key'] as const;
12
+ const ASSIGNMENT_WORDS = new Set(REDACTION_WORDS.slice(1));
13
+
14
+ export interface ProbeWorkerRequest {
15
+ readonly command: string;
16
+ readonly args: readonly string[];
17
+ readonly cwd: string;
18
+ readonly timeoutMs: number;
19
+ readonly streamMaxBytes: number;
20
+ readonly aggregateMaxBytes: number;
21
+ }
22
+
23
+ export interface ProbeWorkerResult {
24
+ readonly status: number | null;
25
+ readonly signal: string | null;
26
+ readonly stdoutBase64: string;
27
+ readonly stderrBase64: string;
28
+ readonly errorCode?: string;
29
+ readonly truncated: boolean;
30
+ }
31
+
32
+ type RedactionState = 'normal' | 'word' | 'passthrough-word' | 'bearer-space' | 'bearer-value' | 'key-space' | 'key-value-space' | 'key-value';
33
+
34
+ /** Stateful UTF-8 redactor: raw chunks are transformed immediately and are never retained. */
35
+ class StreamingProbeRedactor {
36
+ readonly #decoder = new TextDecoder('utf-8', { fatal: true });
37
+ readonly #sanitized: string[] = [];
38
+ #state: RedactionState = 'normal';
39
+ #word = '';
40
+ #keyBuffer = '';
41
+ #invalidUtf8 = false;
42
+ #finished = false;
43
+
44
+ write(chunk: Uint8Array): boolean {
45
+ if (this.#finished || this.#invalidUtf8) return false;
46
+ try {
47
+ this.#consume(this.#decoder.decode(chunk, { stream: true }));
48
+ return true;
49
+ } catch {
50
+ this.#invalidUtf8 = true;
51
+ this.#sanitized.length = 0;
52
+ return false;
53
+ }
54
+ }
55
+
56
+ finish(): { bytes: Buffer; invalidUtf8: boolean } {
57
+ if (!this.#finished && !this.#invalidUtf8) {
58
+ try {
59
+ this.#consume(this.#decoder.decode());
60
+ if (this.#state === 'word') this.#emit(this.#word);
61
+ else if (this.#state === 'key-space') this.#emit(this.#keyBuffer);
62
+ } catch {
63
+ this.#invalidUtf8 = true;
64
+ this.#sanitized.length = 0;
65
+ }
66
+ }
67
+ this.#finished = true;
68
+ return {
69
+ bytes: this.#invalidUtf8 ? Buffer.alloc(0) : Buffer.from(this.#sanitized.join(''), 'utf8'),
70
+ invalidUtf8: this.#invalidUtf8,
71
+ };
72
+ }
73
+
74
+ #emit(value: string): void { this.#sanitized.push(value); }
75
+ #wordChar(value: string): boolean { return /^[A-Za-z0-9_-]$/.test(value); }
76
+ #secretEnd(value: string, comma: boolean): boolean {
77
+ return /\s/.test(value) || value === '"' || value === "'" || (comma && value === ',');
78
+ }
79
+
80
+ #consume(text: string): void {
81
+ for (const value of text) this.#character(value);
82
+ }
83
+
84
+ #character(value: string): void {
85
+ if (this.#state === 'normal') {
86
+ if (this.#wordChar(value)) { this.#word = value; this.#state = 'word'; }
87
+ else this.#emit(value);
88
+ return;
89
+ }
90
+ if (this.#state === 'word') {
91
+ if (this.#wordChar(value)) {
92
+ this.#word += value;
93
+ if (!REDACTION_WORDS.some((word) => word.startsWith(this.#word.toLowerCase()))) {
94
+ this.#emit(this.#word); this.#word = ''; this.#state = 'passthrough-word';
95
+ }
96
+ return;
97
+ }
98
+ const word = this.#word;
99
+ const lower = word.toLowerCase();
100
+ this.#word = '';
101
+ if (lower === 'bearer' && /\s/.test(value)) {
102
+ this.#emit(word + value); this.#state = 'bearer-space'; return;
103
+ }
104
+ if (ASSIGNMENT_WORDS.has(lower as typeof REDACTION_WORDS[number]) && (/\s/.test(value) || value === ':' || value === '=')) {
105
+ if (value === ':' || value === '=') { this.#emit(word + value); this.#state = 'key-value-space'; }
106
+ else { this.#keyBuffer = word + value; this.#state = 'key-space'; }
107
+ return;
108
+ }
109
+ this.#emit(word); this.#state = 'normal'; this.#character(value); return;
110
+ }
111
+ if (this.#state === 'passthrough-word') {
112
+ if (this.#wordChar(value)) this.#emit(value);
113
+ else { this.#state = 'normal'; this.#character(value); }
114
+ return;
115
+ }
116
+ if (this.#state === 'bearer-space') {
117
+ if (/\s/.test(value)) this.#emit(value);
118
+ else if (this.#secretEnd(value, false)) { this.#state = 'normal'; this.#character(value); }
119
+ else { this.#emit(PROBE_REDACTION_MARKER); this.#state = 'bearer-value'; }
120
+ return;
121
+ }
122
+ if (this.#state === 'bearer-value') {
123
+ if (this.#secretEnd(value, false)) { this.#state = 'normal'; this.#character(value); }
124
+ return;
125
+ }
126
+ if (this.#state === 'key-space') {
127
+ if (/\s/.test(value)) this.#keyBuffer += value;
128
+ else if (value === ':' || value === '=') { this.#emit(this.#keyBuffer + value); this.#keyBuffer = ''; this.#state = 'key-value-space'; }
129
+ else { this.#emit(this.#keyBuffer); this.#keyBuffer = ''; this.#state = 'normal'; this.#character(value); }
130
+ return;
131
+ }
132
+ if (this.#state === 'key-value-space') {
133
+ if (/\s/.test(value)) this.#emit(value);
134
+ else if (this.#secretEnd(value, true)) { this.#state = 'normal'; this.#character(value); }
135
+ else { this.#emit(PROBE_REDACTION_MARKER); this.#state = 'key-value'; }
136
+ return;
137
+ }
138
+ if (this.#secretEnd(value, true)) { this.#state = 'normal'; this.#character(value); }
139
+ }
140
+ }
141
+
142
+ export function redactProbeText(input: string): string {
143
+ const redactor = new StreamingProbeRedactor();
144
+ redactor.write(Buffer.from(input, 'utf8'));
145
+ return redactor.finish().bytes.toString('utf8');
146
+ }
147
+
148
+ function killProcessTree(child: ChildProcess): void {
149
+ if (child.pid === undefined) return;
150
+ if (process.platform !== 'win32') {
151
+ try {
152
+ process.kill(-child.pid, 'SIGKILL');
153
+ return;
154
+ } catch {
155
+ child.kill('SIGKILL');
156
+ return;
157
+ }
158
+ }
159
+ // Windows has no negative-PID process groups. taskkill /T is the native tree
160
+ // primitive; the direct kill remains a fail-safe if taskkill itself is absent.
161
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
162
+ stdio: 'ignore',
163
+ windowsHide: true,
164
+ });
165
+ killer.once('error', () => child.kill('SIGKILL'));
166
+ }
167
+
168
+ function validateRequest(value: unknown): ProbeWorkerRequest {
169
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid worker request');
170
+ const row = value as Record<string, unknown>;
171
+ if (typeof row['command'] !== 'string' || typeof row['cwd'] !== 'string' ||
172
+ !Array.isArray(row['args']) || !row['args'].every((arg) => typeof arg === 'string')) {
173
+ throw new Error('invalid worker command');
174
+ }
175
+ for (const key of ['timeoutMs', 'streamMaxBytes', 'aggregateMaxBytes'] as const) {
176
+ if (!Number.isSafeInteger(row[key]) || (row[key] as number) <= 0) throw new Error(`invalid worker ${key}`);
177
+ }
178
+ return {
179
+ command: row['command'], cwd: row['cwd'], args: row['args'] as string[],
180
+ timeoutMs: row['timeoutMs'] as number,
181
+ streamMaxBytes: row['streamMaxBytes'] as number,
182
+ aggregateMaxBytes: row['aggregateMaxBytes'] as number,
183
+ };
184
+ }
185
+
186
+ function withTruncationMarker(bytes: Buffer, maxBytes: number): Buffer {
187
+ const marker = Buffer.from(PROBE_TRUNCATION_MARKER, 'utf8');
188
+ if (marker.length >= maxBytes) return marker.subarray(0, maxBytes);
189
+ const keep = Math.min(bytes.length, maxBytes - marker.length);
190
+ let prefix = bytes.subarray(0, keep);
191
+ while (prefix.length > 0) {
192
+ try {
193
+ new TextDecoder('utf-8', { fatal: true }).decode(prefix);
194
+ break;
195
+ } catch {
196
+ prefix = prefix.subarray(0, prefix.length - 1);
197
+ }
198
+ }
199
+ return Buffer.concat([prefix, marker]);
200
+ }
201
+
202
+ export function runProbeWorker(request: ProbeWorkerRequest): Promise<ProbeWorkerResult> {
203
+ return new Promise((resolveResult) => {
204
+ const stdout = new StreamingProbeRedactor();
205
+ const stderr = new StreamingProbeRedactor();
206
+ let stdoutBytes = 0;
207
+ let stderrBytes = 0;
208
+ let truncated = false;
209
+ let truncatedStream: 'stdout' | 'stderr' | undefined;
210
+ let timedOut = false;
211
+ let settled = false;
212
+ let invalidUtf8 = false;
213
+ let spawnError: string | undefined;
214
+ const child = spawn(request.command, [...request.args], {
215
+ cwd: request.cwd,
216
+ detached: process.platform !== 'win32',
217
+ env: process.env,
218
+ stdio: ['ignore', 'pipe', 'pipe'],
219
+ windowsHide: true,
220
+ });
221
+
222
+ const killOnce = (): void => {
223
+ if (!child.killed) killProcessTree(child);
224
+ };
225
+ const capture = (stream: 'stdout' | 'stderr', chunk: Buffer): void => {
226
+ if (settled || truncated) return;
227
+ const ownBytes = stream === 'stdout' ? stdoutBytes : stderrBytes;
228
+ const remaining = Math.max(0, Math.min(
229
+ request.streamMaxBytes - ownBytes,
230
+ request.aggregateMaxBytes - stdoutBytes - stderrBytes,
231
+ ));
232
+ const accepted = chunk.subarray(0, Math.min(chunk.length, remaining));
233
+ if (accepted.length > 0 && !(stream === 'stdout' ? stdout : stderr).write(accepted)) {
234
+ invalidUtf8 = true;
235
+ killOnce();
236
+ return;
237
+ }
238
+ if (stream === 'stdout') stdoutBytes += accepted.length;
239
+ else stderrBytes += accepted.length;
240
+ if (accepted.length < chunk.length) {
241
+ truncated = true;
242
+ truncatedStream = stream;
243
+ killOnce();
244
+ }
245
+ };
246
+ child.stdout?.on('data', (chunk: Buffer) => capture('stdout', chunk));
247
+ child.stderr?.on('data', (chunk: Buffer) => capture('stderr', chunk));
248
+ child.once('error', (error: NodeJS.ErrnoException) => {
249
+ spawnError = error.code ?? 'PROCESS_ERROR';
250
+ });
251
+ const timer = setTimeout(() => {
252
+ timedOut = true;
253
+ killOnce();
254
+ }, request.timeoutMs);
255
+ timer.unref();
256
+ child.once('close', (status, signal) => {
257
+ settled = true;
258
+ clearTimeout(timer);
259
+ const cleanOut = stdout.finish();
260
+ const cleanErr = stderr.finish();
261
+ invalidUtf8 ||= cleanOut.invalidUtf8 || cleanErr.invalidUtf8;
262
+ const stdoutLimit = Math.min(
263
+ request.streamMaxBytes,
264
+ Math.max(0, request.aggregateMaxBytes - cleanErr.bytes.length),
265
+ );
266
+ const stderrLimit = Math.min(
267
+ request.streamMaxBytes,
268
+ Math.max(0, request.aggregateMaxBytes - cleanOut.bytes.length),
269
+ );
270
+ const stdoutBytesOut = truncatedStream === 'stdout'
271
+ ? withTruncationMarker(cleanOut.bytes, stdoutLimit)
272
+ : cleanOut.bytes;
273
+ const stderrBytesOut = truncatedStream === 'stderr'
274
+ ? withTruncationMarker(cleanErr.bytes, stderrLimit)
275
+ : cleanErr.bytes;
276
+ resolveResult({
277
+ status,
278
+ signal,
279
+ stdoutBase64: stdoutBytesOut.toString('base64'),
280
+ stderrBase64: stderrBytesOut.toString('base64'),
281
+ ...(timedOut ? { errorCode: 'ETIMEDOUT' }
282
+ : truncated ? { errorCode: 'OUTPUT_TRUNCATED' }
283
+ : invalidUtf8 ? { errorCode: 'INVALID_UTF8' }
284
+ : spawnError !== undefined ? { errorCode: spawnError } : {}),
285
+ truncated,
286
+ });
287
+ });
288
+ });
289
+ }
290
+
291
+ async function main(): Promise<void> {
292
+ const chunks: Buffer[] = [];
293
+ let size = 0;
294
+ for await (const chunk of process.stdin) {
295
+ const bytes = Buffer.from(chunk as Uint8Array);
296
+ size += bytes.length;
297
+ if (size > WORKER_INPUT_MAX_BYTES) throw new Error('worker request too large');
298
+ chunks.push(bytes);
299
+ }
300
+ const request = validateRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')));
301
+ process.stdout.write(`${JSON.stringify(await runProbeWorker(request))}\n`);
302
+ }
303
+
304
+ const invokedPath = process.argv[1];
305
+ if (invokedPath !== undefined && realpathSync(resolve(invokedPath)) === realpathSync(fileURLToPath(import.meta.url))) {
306
+ main().catch((error) => {
307
+ process.stderr.write(`integration probe worker failed: ${error instanceof Error ? error.message : String(error)}\n`);
308
+ process.exitCode = 1;
309
+ });
310
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "target": "claude-code",
4
+ "component": "mcp",
5
+ "productSurface": "claude-code-cli",
6
+ "runtimeVersion": "2.1.235",
7
+ "scope": "project",
8
+ "carrierPath": ".mcp.json",
9
+ "serializerId": "claude-project-mcp-json-v1",
10
+ "probeCommandId": "claude-project-mcp-get-v1",
11
+ "probeCommand": ["claude", "mcp", "get", "dz-registration-probe"],
12
+ "timeoutMs": 30000,
13
+ "observedExitCode": 0,
14
+ "transcriptSha256": "sha256:0e30c79de6381c7e7b93a39cc644cb282b6c95ceaeb4a0f5a94fbfecb5c71549",
15
+ "parsedObservation": {
16
+ "registrationId": "dz-registration-probe",
17
+ "scope": "project",
18
+ "status": "Pending approval",
19
+ "approval": "pending",
20
+ "ready": false
21
+ },
22
+ "wrongPathNegativeControl": {
23
+ "carrierPath": ".claude/.mcp.json",
24
+ "probeCommand": ["claude", "mcp", "get", "dz-wrong-path-probe"],
25
+ "timeoutMs": 30000,
26
+ "observedExitCode": 1,
27
+ "transcriptSha256": "sha256:aba424ed140ee785947f314942566ae4bccc7b7c44ca9be785efbf36be0f62d5",
28
+ "registrationObserved": false,
29
+ "reasonCode": "POST_WRITE_REGISTRATION_NOT_OBSERVED"
30
+ },
31
+ "transportFamily": "stdio-local",
32
+ "fieldFamilies": ["type", "command", "args"],
33
+ "observedAt": "2026-08-30T19:22:15.366Z",
34
+ "documentation": ["https://code.claude.com/docs/en/mcp"]
35
+ }
@@ -0,0 +1,258 @@
1
+ /** Bounded, non-executing target registration verification. */
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { accessSync, constants, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import type { IntegrationComponent, IntegrationReasonCode } from '@dzhechkov/core';
10
+
11
+ import { CLAUDE_MCP_RECEIPT, assessClaudeMcpEvidence } from './integration-evidence.js';
12
+ import { TARGET_INTEGRATION_POLICY, type RegistrationObservation } from './target-integrations.js';
13
+ import type { TargetName } from './targets.js';
14
+ import type { ProbeWorkerResult } from './integration-probe-worker.js';
15
+
16
+ export const INTEGRATION_PROBE_TIMEOUT_MS = 30_000;
17
+ export const INTEGRATION_PROBE_STREAM_MAX_BYTES = 1024 * 1024;
18
+ export const INTEGRATION_PROBE_AGGREGATE_MAX_BYTES = 2 * 1024 * 1024;
19
+
20
+ export interface IntegrationProcessRequest {
21
+ readonly command: string;
22
+ readonly args: readonly string[];
23
+ readonly cwd: string;
24
+ readonly timeoutMs: number;
25
+ }
26
+
27
+ export interface IntegrationProcessObservation {
28
+ readonly status: number | null;
29
+ readonly signal: string | null;
30
+ readonly stdout: Uint8Array;
31
+ readonly stderr: Uint8Array;
32
+ readonly errorCode?: string;
33
+ }
34
+
35
+ export interface IntegrationProcessPort {
36
+ run(request: IntegrationProcessRequest): IntegrationProcessObservation;
37
+ }
38
+
39
+ export type IntegrationExecutableResolution =
40
+ | { readonly ok: true; readonly path: string }
41
+ | { readonly ok: false; readonly errorCode: 'ENOENT' | 'UNSAFE_EXECUTABLE' };
42
+
43
+ /** Resolve PATH once and reject a project-controlled probe binary before spawning it. */
44
+ export function resolveIntegrationExecutable(
45
+ command: string,
46
+ cwd: string,
47
+ pathValue = process.env['PATH'] ?? '',
48
+ ): IntegrationExecutableResolution {
49
+ const candidates = isAbsolute(command)
50
+ ? [command]
51
+ : command.includes('/') || command.includes('\\')
52
+ ? [resolve(cwd, command)]
53
+ : pathValue.split(delimiter).map((dir) => join(dir.length === 0 ? cwd : dir, command));
54
+ for (const candidate of candidates) {
55
+ if (!existsSync(candidate)) continue;
56
+ try {
57
+ accessSync(candidate, constants.X_OK);
58
+ const actual = realpathSync(candidate);
59
+ const rel = relative(realpathSync(cwd), actual);
60
+ if (rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))) {
61
+ return { ok: false, errorCode: 'UNSAFE_EXECUTABLE' };
62
+ }
63
+ return { ok: true, path: actual };
64
+ } catch {
65
+ continue;
66
+ }
67
+ }
68
+ return { ok: false, errorCode: 'ENOENT' };
69
+ }
70
+
71
+ export const defaultIntegrationProcessPort: IntegrationProcessPort = {
72
+ run(request) {
73
+ const executable = resolveIntegrationExecutable(request.command, request.cwd);
74
+ if (!executable.ok) {
75
+ return { status: null, signal: null, stdout: new Uint8Array(), stderr: new Uint8Array(), errorCode: executable.errorCode };
76
+ }
77
+ const workerPath = fileURLToPath(new URL('./integration-probe-worker.js', import.meta.url));
78
+ const result = spawnSync(process.execPath, [workerPath], {
79
+ input: JSON.stringify({
80
+ ...request,
81
+ command: executable.path,
82
+ streamMaxBytes: INTEGRATION_PROBE_STREAM_MAX_BYTES,
83
+ aggregateMaxBytes: INTEGRATION_PROBE_AGGREGATE_MAX_BYTES,
84
+ }),
85
+ cwd: request.cwd,
86
+ timeout: request.timeoutMs + 5_000,
87
+ killSignal: 'SIGKILL',
88
+ maxBuffer: 4 * 1024 * 1024,
89
+ encoding: 'utf8',
90
+ windowsHide: true,
91
+ });
92
+ if (result.error !== undefined || result.status !== 0) {
93
+ return {
94
+ status: result.status,
95
+ signal: result.signal,
96
+ stdout: new Uint8Array(),
97
+ stderr: new Uint8Array(),
98
+ errorCode: (result.error as NodeJS.ErrnoException | undefined)?.code ?? 'PROCESS_ERROR',
99
+ };
100
+ }
101
+ let observation: ProbeWorkerResult;
102
+ try {
103
+ observation = JSON.parse(result.stdout) as ProbeWorkerResult;
104
+ } catch {
105
+ return { status: null, signal: null, stdout: new Uint8Array(), stderr: new Uint8Array(), errorCode: 'PROCESS_ERROR' };
106
+ }
107
+ return {
108
+ status: observation.status,
109
+ signal: observation.signal,
110
+ stdout: Buffer.from(observation.stdoutBase64, 'base64'),
111
+ stderr: Buffer.from(observation.stderrBase64, 'base64'),
112
+ ...(observation.errorCode !== undefined ? { errorCode: observation.errorCode } : {}),
113
+ };
114
+ },
115
+ };
116
+
117
+ export interface VerifyTargetIntegrationOptions {
118
+ readonly target: TargetName;
119
+ readonly component: IntegrationComponent;
120
+ readonly projectRoot: string;
121
+ readonly registrationId?: string;
122
+ readonly phase?: 'preflight' | 'post-write';
123
+ readonly processPort?: IntegrationProcessPort;
124
+ }
125
+
126
+ export interface IntegrationVerification {
127
+ readonly ok: boolean;
128
+ readonly target: TargetName;
129
+ readonly component: IntegrationComponent;
130
+ readonly runtimeVersion?: string;
131
+ readonly evidenceVersion?: string;
132
+ readonly reasonCode?: IntegrationReasonCode;
133
+ readonly remediation?: string;
134
+ readonly registrations: readonly RegistrationObservation[];
135
+ }
136
+
137
+ function decodeBounded(observation: IntegrationProcessObservation): { stdout: string; stderr: string } | IntegrationReasonCode {
138
+ const stdout = Buffer.from(observation.stdout);
139
+ const stderr = Buffer.from(observation.stderr);
140
+ if (stdout.length > INTEGRATION_PROBE_STREAM_MAX_BYTES || stderr.length > INTEGRATION_PROBE_STREAM_MAX_BYTES ||
141
+ stdout.length + stderr.length > INTEGRATION_PROBE_AGGREGATE_MAX_BYTES) return 'LIVE_PROBE_FAILED';
142
+ try {
143
+ const decoder = new TextDecoder('utf-8', { fatal: true });
144
+ return { stdout: decoder.decode(stdout), stderr: decoder.decode(stderr) };
145
+ } catch {
146
+ return 'LIVE_PROBE_FAILED';
147
+ }
148
+ }
149
+
150
+ function parseClaudeVersion(text: string): string | undefined {
151
+ return /(?:Claude Code\s+)?(\d+\.\d+\.\d+)/i.exec(text)?.[1];
152
+ }
153
+
154
+ function failed(
155
+ options: VerifyTargetIntegrationOptions,
156
+ reasonCode: IntegrationReasonCode,
157
+ remediation: string,
158
+ runtimeVersion?: string,
159
+ ): IntegrationVerification {
160
+ return {
161
+ ok: false,
162
+ target: options.target,
163
+ component: options.component,
164
+ registrations: [],
165
+ reasonCode,
166
+ remediation,
167
+ ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
168
+ };
169
+ }
170
+
171
+ function parseClaudeProjectRegistration(
172
+ options: VerifyTargetIntegrationOptions,
173
+ id: string,
174
+ observation: IntegrationProcessObservation,
175
+ runtimeVersion: string,
176
+ ): IntegrationVerification {
177
+ if (observation.errorCode === 'ETIMEDOUT' || observation.signal === 'SIGKILL') {
178
+ return failed(options, 'LIVE_PROBE_TIMEOUT', `Claude mcp get exceeded ${INTEGRATION_PROBE_TIMEOUT_MS}ms`, runtimeVersion);
179
+ }
180
+ if (observation.errorCode !== undefined) {
181
+ return failed(options, 'LIVE_PROBE_FAILED', `Claude registration probe failed before a trustworthy observation (${observation.errorCode})`, runtimeVersion);
182
+ }
183
+ const text = decodeBounded(observation);
184
+ if (typeof text === 'string') return failed(options, text, 'Claude registration output was oversized or invalid UTF-8', runtimeVersion);
185
+ if (observation.status !== 0) return failed(options, 'POST_WRITE_REGISTRATION_NOT_OBSERVED', `claude mcp get ${id} did not observe the project registration`, runtimeVersion);
186
+ const output = `${text.stdout}\n${text.stderr}`;
187
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ // Claude 2.1.235 prints either `Name: <id>` or an exact `<id>:` heading depending on the
189
+ // installation channel. Require one whole attributable line; a prose mention is not evidence.
190
+ if (!new RegExp(`^(?:Name\\s*:\\s*${escapedId}|${escapedId}:)\\s*$`, 'im').test(output) ||
191
+ !/Scope\s*:\s*Project\b/i.test(output)) {
192
+ return failed(options, 'POST_WRITE_REGISTRATION_NOT_OBSERVED', `claude mcp get ${id} did not attribute the exact id to project scope`, runtimeVersion);
193
+ }
194
+ const pending = /pending approval/i.test(output);
195
+ return {
196
+ ok: true,
197
+ target: options.target,
198
+ component: options.component,
199
+ runtimeVersion,
200
+ evidenceVersion: CLAUDE_MCP_RECEIPT.runtimeVersion,
201
+ registrations: [{ id, scope: 'project', registered: true, approval: pending ? 'pending' : 'unknown', ready: !pending }],
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Verify only code-owned native list/get surfaces. Manifest command/URL fields
207
+ * are intentionally absent from this API, so they cannot be executed here.
208
+ */
209
+ export function verifyTargetIntegration(options: VerifyTargetIntegrationOptions): IntegrationVerification {
210
+ const port = options.processPort ?? defaultIntegrationProcessPort;
211
+ if (options.target !== 'claude-code' || options.component !== 'mcp') {
212
+ const cell = TARGET_INTEGRATION_POLICY[options.target][options.component];
213
+ return failed(options, cell.reasonCode ?? 'NO_QUALIFYING_LIVE_RECEIPT', 'no qualifying exact-version live receipt exists for this target/component');
214
+ }
215
+
216
+ const versionObs = port.run({ command: 'claude', args: ['--version'], cwd: options.projectRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS });
217
+ if (versionObs.errorCode === 'ENOENT') return failed(options, 'TARGET_BINARY_UNAVAILABLE', 'install Claude Code and rerun dz integrations-verify');
218
+ if (versionObs.errorCode === 'ETIMEDOUT' || versionObs.signal === 'SIGKILL') return failed(options, 'LIVE_PROBE_TIMEOUT', `Claude version probe exceeded ${INTEGRATION_PROBE_TIMEOUT_MS}ms`);
219
+ if (versionObs.errorCode !== undefined) return failed(options, 'LIVE_PROBE_FAILED', `Claude version probe failed before a trustworthy observation (${versionObs.errorCode})`);
220
+ const versionText = decodeBounded(versionObs);
221
+ if (typeof versionText === 'string') return failed(options, versionText, 'Claude version output was oversized or invalid UTF-8');
222
+ if (versionObs.status !== 0) return failed(options, 'LIVE_PROBE_FAILED', 'Claude version probe exited non-zero');
223
+ const runtimeVersion = parseClaudeVersion(`${versionText.stdout}\n${versionText.stderr}`);
224
+ if (runtimeVersion === undefined) return failed(options, 'LIVE_PROBE_FAILED', 'Claude version output did not contain an attributable semantic version');
225
+ const evidence = assessClaudeMcpEvidence(runtimeVersion);
226
+ if (!evidence.eligible) return failed(options, evidence.reasonCode ?? 'RECEIPT_STALE', `expected Claude Code ${CLAUDE_MCP_RECEIPT.runtimeVersion}; rerun after a matching receipt is recorded`, runtimeVersion);
227
+
228
+ if ((options.phase ?? 'preflight') === 'preflight') {
229
+ const probeId = 'dz-registration-probe';
230
+ const probeRoot = mkdtempSync(join(tmpdir(), 'dz-claude-mcp-probe-'));
231
+ try {
232
+ writeFileSync(join(probeRoot, '.mcp.json'), `${JSON.stringify({
233
+ mcpServers: { [probeId]: { type: 'stdio', command: 'node', args: ['-e', 'process.exit(0)'] } },
234
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
235
+ const observation = port.run({
236
+ command: 'claude', args: ['mcp', 'get', probeId], cwd: probeRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS,
237
+ });
238
+ const parsed = parseClaudeProjectRegistration(options, probeId, observation, runtimeVersion);
239
+ return parsed.ok ? { ...parsed, registrations: [] } : parsed;
240
+ } finally {
241
+ rmSync(probeRoot, { recursive: true, force: true });
242
+ }
243
+ }
244
+ const id = options.registrationId;
245
+ if (id === undefined) return failed(options, 'LIVE_PROBE_FAILED', 'post-write verification requires an exact registration id', runtimeVersion);
246
+ return parseClaudeProjectRegistration(
247
+ options,
248
+ id,
249
+ port.run({ command: 'claude', args: ['mcp', 'get', id], cwd: options.projectRoot, timeoutMs: INTEGRATION_PROBE_TIMEOUT_MS }),
250
+ runtimeVersion,
251
+ );
252
+ }
253
+
254
+ export interface RunIntegrationsVerifyOptions extends VerifyTargetIntegrationOptions {}
255
+
256
+ export function runIntegrationsVerify(options: RunIntegrationsVerifyOptions): IntegrationVerification {
257
+ return verifyTargetIntegration(options);
258
+ }