bootproof 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +844 -152
  2. package/dist/agent-plan.d.ts +44 -0
  3. package/dist/agent-plan.js +826 -0
  4. package/dist/agent-run.d.ts +117 -0
  5. package/dist/agent-run.js +459 -0
  6. package/dist/ai-repair.d.ts +58 -0
  7. package/dist/ai-repair.js +380 -0
  8. package/dist/cli.js +730 -46
  9. package/dist/diagnosis.js +101 -16
  10. package/dist/diff.d.ts +29 -0
  11. package/dist/diff.js +569 -0
  12. package/dist/exec.d.ts +30 -2
  13. package/dist/exec.js +329 -51
  14. package/dist/external-health.d.ts +16 -0
  15. package/dist/external-health.js +214 -0
  16. package/dist/infer.js +238 -39
  17. package/dist/plan.js +2 -0
  18. package/dist/proof.d.ts +78 -2
  19. package/dist/proof.js +265 -12
  20. package/dist/receipt.d.ts +52 -0
  21. package/dist/receipt.js +356 -0
  22. package/dist/redact.d.ts +4 -0
  23. package/dist/redact.js +86 -2
  24. package/dist/registry.d.ts +82 -30
  25. package/dist/registry.js +355 -53
  26. package/dist/remote.js +3 -3
  27. package/dist/repair-playbooks.d.ts +24 -0
  28. package/dist/repair-playbooks.js +593 -0
  29. package/dist/repair-safety.d.ts +130 -0
  30. package/dist/repair-safety.js +766 -0
  31. package/dist/repair.d.ts +43 -11
  32. package/dist/repair.js +716 -7
  33. package/dist/run.d.ts +3 -0
  34. package/dist/run.js +218 -41
  35. package/dist/sbom.d.ts +22 -0
  36. package/dist/sbom.js +99 -0
  37. package/dist/taxonomy.d.ts +8 -3
  38. package/dist/taxonomy.js +404 -8
  39. package/dist/types.d.ts +40 -1
  40. package/docs/AGENT_IN_THE_LOOP.md +171 -0
  41. package/docs/AGENT_RUN_RECEIPTS.md +38 -0
  42. package/docs/CI_ACTION.md +67 -2
  43. package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
  44. package/docs/DISTRIBUTION.md +83 -0
  45. package/docs/FAILURE_TAXONOMY.md +28 -1
  46. package/docs/HONESTY_CONTRACT.md +34 -12
  47. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  48. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  49. package/docs/REGISTRY.md +48 -28
  50. package/docs/REPAIR_RECEIPT.md +54 -8
  51. package/docs/agent-loop-gap-analysis.md +188 -0
  52. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  53. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  54. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  55. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  56. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  57. package/docs/examples/registry-seeds/php-composer.json +33 -0
  58. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  59. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  60. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  61. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  62. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  63. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  64. package/docs/schemas/ci-context-v1.schema.json +63 -0
  65. package/docs/schemas/diff-result-v1.schema.json +66 -0
  66. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  67. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  68. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  69. package/docs/schemas/repair-action-v1.schema.json +136 -0
  70. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  71. package/package.json +21 -11
package/dist/exec.js CHANGED
@@ -1,26 +1,154 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import http from "node:http";
3
- const TAIL = 4000;
4
- const tail = (s) => (s.length > TAIL ? s.slice(-TAIL) : s);
3
+ import https from "node:https";
4
+ import { redactText } from "./redact.js";
5
+ const EVIDENCE_LIMIT = 4000;
6
+ const head = (s) => (s.length > EVIDENCE_LIMIT ? s.slice(0, EVIDENCE_LIMIT) : s);
7
+ const tail = (s) => (s.length > EVIDENCE_LIMIT ? s.slice(-EVIDENCE_LIMIT) : s);
8
+ function meaningfulLines(evidenceHead, evidenceTail) {
9
+ return `${evidenceHead}\n${evidenceTail}`
10
+ .split(/\r?\n/)
11
+ .map(line => line.trim())
12
+ .filter(line => line.length > 0 &&
13
+ !/^(?:from\s+)?\S+:\d+(?::in\b|$)/i.test(line) &&
14
+ !/^at\s+\S+/i.test(line) &&
15
+ !/^#\d+\s+/i.test(line));
16
+ }
17
+ function detectCause(text) {
18
+ const checks = [
19
+ [/(?:missing|no such file|does not exist|could not find)[^\n]*config\/database\.yml|config\/database\.yml[^\n]*(?:missing|no such file|does not exist|could not find)/i, "missing config/database.yml"],
20
+ [/(?:missing|no such file|does not exist|could not find)[^\n]*config\/gitlab\.yml|config\/gitlab\.yml[^\n]*(?:missing|no such file|does not exist|could not find)/i, "missing config/gitlab.yml"],
21
+ [/(?:PG::ConnectionBad|postgres(?:ql)?|port 5432)[^\n]*(?:connection refused|could not connect)|(?:connection refused|could not connect)[^\n]*(?:postgres(?:ql)?|port 5432)/i, "PostgreSQL connection refused"],
22
+ [/(?:postgres(?:ql)?[^\n]*)?role\s+["']?[^"'\n]+["']?\s+does not exist/i, "PostgreSQL role missing"],
23
+ [/(?:database schema|relation\s+\S+\s+does not exist|no such table|pending migrations?)/i, "database schema missing"],
24
+ [/(?:unsupported|not supported)[^\n]*(?:postgres(?:ql)?|database)[^\n]*version|(?:postgres(?:ql)?|database)[^\n]*version[^\n]*(?:unsupported|not supported)/i, "unsupported database version"],
25
+ [/(?:unsupported|not supported)[^\n]*database (?:config|configuration)|database (?:config|configuration)[^\n]*(?:unsupported|not supported)/i, "unsupported database configuration"],
26
+ ];
27
+ return checks.find(([pattern]) => pattern.test(text))?.[1];
28
+ }
29
+ export function extractProcessEvidence(evidenceHead, evidenceTail) {
30
+ const lines = meaningfulLines(evidenceHead, evidenceTail);
31
+ const firstExceptionLine = lines.find(line => /\b(?:[A-Z]\w*(?:::[A-Z]\w*)*(?:Error|Exception)|PG::\w+|ActiveRecord::\w+|Errno::\w+|RuntimeError|LoadError|NameError|NoMethodError)\b/.test(line));
32
+ const firstErrorLine = lines.find(line => /\b(?:error|fatal|failed|failure|refused|missing|unsupported|could not|cannot|no such file|does not exist)\b/i.test(line));
33
+ const combined = `${evidenceHead}\n${evidenceTail}`;
34
+ const detectedCause = detectCause(combined);
35
+ return {
36
+ evidenceHead,
37
+ evidenceTail,
38
+ ...(firstErrorLine ? { firstErrorLine: redactText(firstErrorLine).text } : {}),
39
+ ...(firstExceptionLine ? { firstExceptionLine: redactText(firstExceptionLine).text } : {}),
40
+ ...(detectedCause ? { detectedCause } : {}),
41
+ };
42
+ }
43
+ export function execResultEvidence(result) {
44
+ const evidenceHead = [result.stderrHead, result.stdoutHead].filter(Boolean).join("\n");
45
+ const evidenceTail = result.stderr || result.stdout;
46
+ return extractProcessEvidence(evidenceHead, evidenceTail);
47
+ }
48
+ export function processEvidenceText(evidence) {
49
+ return [
50
+ evidence.evidenceHead,
51
+ evidence.evidenceTail,
52
+ evidence.firstErrorLine ? `First error: ${evidence.firstErrorLine}` : "",
53
+ evidence.firstExceptionLine ? `First exception: ${evidence.firstExceptionLine}` : "",
54
+ evidence.detectedCause ? `Detected cause: ${evidence.detectedCause}` : "",
55
+ ].filter(Boolean).join("\n");
56
+ }
57
+ function setExecutionEnvValue(env, name, value) {
58
+ for (const existing of Object.keys(env)) {
59
+ if (existing !== name && existing.toLowerCase() === name.toLowerCase())
60
+ delete env[existing];
61
+ }
62
+ if (value === undefined)
63
+ delete env[name];
64
+ else
65
+ env[name] = value;
66
+ }
67
+ export function buildExecutionEnv(overrides = {}) {
68
+ const env = { ...process.env };
69
+ for (const name of ["PATH", "HOME", "SHELL"]) {
70
+ const inherited = Object.keys(process.env).find(existing => existing.toLowerCase() === name.toLowerCase());
71
+ if (inherited)
72
+ setExecutionEnvValue(env, name, process.env[inherited]);
73
+ }
74
+ setExecutionEnvValue(env, "CI", "true");
75
+ setExecutionEnvValue(env, "BOOTPROOF", "1");
76
+ for (const [name, value] of Object.entries(overrides)) {
77
+ setExecutionEnvValue(env, name, value);
78
+ }
79
+ return env;
80
+ }
81
+ export function extractLeadingEnvironmentAssignments(command) {
82
+ const environment = {};
83
+ let remaining = command;
84
+ while (true) {
85
+ const match = remaining.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|<>]+))\s+/);
86
+ if (!match)
87
+ break;
88
+ environment[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
89
+ remaining = remaining.slice(match[0].length);
90
+ }
91
+ if (!remaining.trim() || Object.keys(environment).length === 0) {
92
+ return { command, environment: {} };
93
+ }
94
+ return { command: remaining, environment };
95
+ }
96
+ function shellInvocation(command, env) {
97
+ if (process.platform !== "win32")
98
+ return { command, env: buildExecutionEnv(env) };
99
+ const extracted = extractLeadingEnvironmentAssignments(command);
100
+ return {
101
+ command: extracted.command,
102
+ env: buildExecutionEnv({ ...env, ...extracted.environment }),
103
+ };
104
+ }
5
105
  export function runToCompletion(command, cwd, timeoutMs, env) {
6
106
  return new Promise(resolve => {
7
- const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env });
8
- let stdout = "", stderr = "", timedOut = false;
9
- child.stdout?.on("data", d => (stdout += d));
10
- child.stderr?.on("data", d => (stderr += d));
107
+ const invocation = shellInvocation(command, env);
108
+ const child = spawn(invocation.command, { cwd, shell: true, detached: process.platform !== "win32", env: invocation.env });
109
+ let stdoutHead = "", stdout = "", stderrHead = "", stderr = "", timedOut = false;
110
+ child.stdout?.on("data", d => {
111
+ const chunk = String(d);
112
+ stdoutHead = head(stdoutHead + chunk);
113
+ stdout = tail(stdout + chunk);
114
+ });
115
+ child.stderr?.on("data", d => {
116
+ const chunk = String(d);
117
+ stderrHead = head(stderrHead + chunk);
118
+ stderr = tail(stderr + chunk);
119
+ });
11
120
  const timer = setTimeout(() => { timedOut = true; killTree(child.pid); }, timeoutMs);
12
- child.on("close", code => { clearTimeout(timer); resolve({ exitCode: code, timedOut, stdout: tail(stdout), stderr: tail(stderr) }); });
13
- child.on("error", err => { clearTimeout(timer); resolve({ exitCode: null, timedOut, stdout: tail(stdout), stderr: tail(stderr + String(err)) }); });
121
+ child.on("close", code => { clearTimeout(timer); resolve({ exitCode: code, timedOut, stdoutHead, stdout, stderrHead, stderr }); });
122
+ child.on("error", err => {
123
+ clearTimeout(timer);
124
+ const error = String(err);
125
+ resolve({
126
+ exitCode: null,
127
+ timedOut,
128
+ stdoutHead,
129
+ stdout,
130
+ stderrHead: head(stderrHead + error),
131
+ stderr: tail(stderr + error),
132
+ });
133
+ });
14
134
  });
15
135
  }
16
136
  export function superviseApp(command, cwd, env) {
17
- const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env });
18
- let out = "", exit = null;
19
- child.stdout?.on("data", d => (out += d));
20
- child.stderr?.on("data", d => (out += d));
21
- child.on("close", code => { exit = { code, early: true }; });
137
+ const invocation = shellInvocation(command, env);
138
+ const child = spawn(invocation.command, { cwd, shell: true, detached: process.platform !== "win32", env: invocation.env });
139
+ let outHead = "", outTail = "", exit = null;
140
+ const capture = (data) => {
141
+ const chunk = String(data);
142
+ outHead = head(outHead + chunk);
143
+ outTail = tail(outTail + chunk);
144
+ };
145
+ child.stdout?.on("data", capture);
146
+ child.stderr?.on("data", capture);
147
+ child.on("exit", code => { exit = { code, early: true }; });
148
+ child.on("error", () => { exit ??= { code: null, early: true }; });
22
149
  return {
23
- output: () => tail(out),
150
+ output: () => outTail,
151
+ evidence: () => extractProcessEvidence(outHead, outTail),
24
152
  exited: () => exit,
25
153
  stop: async () => {
26
154
  if (exit)
@@ -40,13 +168,39 @@ function killTree(pid, signal = "SIGTERM") {
40
168
  return;
41
169
  try {
42
170
  if (process.platform === "win32") {
43
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
171
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", env: buildExecutionEnv() });
44
172
  }
45
173
  else
46
174
  process.kill(-pid, signal); // negative pid = whole process group
47
175
  }
48
176
  catch { /* already gone */ }
49
177
  }
178
+ const EXPECTED_REDIRECT_PATHS = ["/users/sign_in", "/login", "/signin", "/auth", "/session/new"];
179
+ const BODY_EXCERPT_LIMIT = 1000;
180
+ function acceptedAsHealthy(statusCode, redirectLocation) {
181
+ if (statusCode !== null && statusCode >= 200 && statusCode < 300)
182
+ return true;
183
+ if (statusCode === null || statusCode < 300 || statusCode >= 400 || !redirectLocation)
184
+ return false;
185
+ const normalizedLocation = redirectLocation.toLowerCase();
186
+ return EXPECTED_REDIRECT_PATHS.some(expected => normalizedLocation.includes(expected));
187
+ }
188
+ function normalizedHeaders(headers) {
189
+ const normalized = {};
190
+ for (const [name, value] of Object.entries(headers)) {
191
+ if (value === undefined)
192
+ continue;
193
+ if (["authorization", "cookie", "proxy-authorization", "set-cookie"].includes(name.toLowerCase())) {
194
+ normalized[name] = "[redacted]";
195
+ continue;
196
+ }
197
+ normalized[name] = redactText(Array.isArray(value) ? value.join(", ") : value).text;
198
+ }
199
+ return normalized;
200
+ }
201
+ function connectionErrorMessage(error) {
202
+ return error.message || error.code || "connection failed";
203
+ }
50
204
  function cleanUrl(value) {
51
205
  return value.replace(/[),.;\]}]+$/, "");
52
206
  }
@@ -63,47 +217,141 @@ export function extractHealthCandidates(output) {
63
217
  }
64
218
  return [...candidates];
65
219
  }
66
- export async function pollHealthCandidates(initialUrls, timeoutMs, output = () => "", intervalMs = 1000) {
220
+ function effectivePort(url) {
221
+ if (url.port)
222
+ return url.port;
223
+ return url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : "";
224
+ }
225
+ export function detectHealthCandidatePortMismatch(inferredHealthUrl, advertisedHealthUrls, selectedCommand) {
226
+ if (!inferredHealthUrl)
227
+ return null;
228
+ try {
229
+ const inferred = new URL(inferredHealthUrl);
230
+ for (const advertisedHealthUrl of advertisedHealthUrls) {
231
+ const advertised = new URL(advertisedHealthUrl);
232
+ const advertisedPort = effectivePort(advertised);
233
+ if (advertisedPort &&
234
+ effectivePort(inferred) !== advertisedPort) {
235
+ return {
236
+ inferredHealthUrl,
237
+ advertisedHealthUrl,
238
+ advertisedPort,
239
+ selectedCommand: redactText(selectedCommand).text,
240
+ };
241
+ }
242
+ }
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ return null;
248
+ }
249
+ export function healthCandidatePortMismatchEvidence(mismatch) {
250
+ if (!mismatch)
251
+ return "";
252
+ return [
253
+ "Health candidate port mismatch",
254
+ `inferredHealthUrl: ${mismatch.inferredHealthUrl}`,
255
+ `advertisedHealthUrl: ${mismatch.advertisedHealthUrl}`,
256
+ `advertisedPort: ${mismatch.advertisedPort}`,
257
+ `selectedCommand: ${mismatch.selectedCommand}`,
258
+ ].join("\n");
259
+ }
260
+ export async function pollHealthCandidates(initialUrls, timeoutMs, output = () => "", intervalMs = 1000, shouldStop = () => false) {
67
261
  const started = Date.now();
68
262
  let attempts = 0;
69
263
  const candidates = new Set(initialUrls);
70
264
  const discoveredCandidates = new Set();
71
- while (Date.now() - started < timeoutMs) {
265
+ let latestResponse = null;
266
+ let latestConnectionError = null;
267
+ while (Date.now() - started < timeoutMs && !shouldStop()) {
72
268
  for (const candidate of extractHealthCandidates(output())) {
73
269
  if (!candidates.has(candidate))
74
270
  discoveredCandidates.add(candidate);
75
271
  candidates.add(candidate);
76
272
  }
77
273
  for (const url of candidates) {
274
+ if (shouldStop())
275
+ break;
78
276
  attempts++;
79
- const status = await probe(url);
80
- if (status !== null) {
277
+ const evidence = await probe(url);
278
+ if (evidence.statusCode !== null)
279
+ latestResponse = evidence;
280
+ else if (evidence.connectionError || !latestConnectionError)
281
+ latestConnectionError = evidence;
282
+ if (evidence.acceptedAsHealthy) {
283
+ if (shouldStop())
284
+ break;
81
285
  return {
82
286
  responded: true,
83
- status,
287
+ status: evidence.statusCode,
84
288
  attempts,
85
289
  elapsedMs: Date.now() - started,
86
290
  url,
87
291
  candidates: [...candidates],
88
292
  discoveredCandidates: [...discoveredCandidates],
293
+ evidence,
89
294
  };
90
295
  }
91
296
  }
92
- await new Promise(r => setTimeout(r, intervalMs));
297
+ const waitUntil = Date.now() + intervalMs;
298
+ while (Date.now() < waitUntil && !shouldStop()) {
299
+ await new Promise(r => setTimeout(r, Math.min(25, waitUntil - Date.now())));
300
+ }
93
301
  }
94
302
  for (const candidate of extractHealthCandidates(output())) {
95
303
  if (!candidates.has(candidate))
96
304
  discoveredCandidates.add(candidate);
97
305
  candidates.add(candidate);
98
306
  }
307
+ const evidence = latestResponse ?? latestConnectionError;
99
308
  return {
100
- responded: false,
101
- status: null,
309
+ responded: evidence?.statusCode !== null && evidence?.statusCode !== undefined,
310
+ status: evidence?.statusCode ?? null,
102
311
  attempts,
103
312
  elapsedMs: Date.now() - started,
104
- url: null,
313
+ url: evidence?.requestedUrl ?? null,
105
314
  candidates: [...candidates],
106
315
  discoveredCandidates: [...discoveredCandidates],
316
+ evidence,
317
+ };
318
+ }
319
+ export async function probeHealthCandidatesOnce(initialUrls) {
320
+ const started = Date.now();
321
+ const candidates = [...new Set(initialUrls)];
322
+ let attempts = 0;
323
+ let latestResponse = null;
324
+ let latestConnectionError = null;
325
+ for (const url of candidates) {
326
+ attempts++;
327
+ const evidence = await probe(url);
328
+ if (evidence.statusCode !== null)
329
+ latestResponse = evidence;
330
+ else if (evidence.connectionError || !latestConnectionError)
331
+ latestConnectionError = evidence;
332
+ if (evidence.acceptedAsHealthy) {
333
+ return {
334
+ responded: true,
335
+ status: evidence.statusCode,
336
+ attempts,
337
+ elapsedMs: Date.now() - started,
338
+ url,
339
+ candidates,
340
+ discoveredCandidates: [],
341
+ evidence,
342
+ };
343
+ }
344
+ }
345
+ const evidence = latestResponse ?? latestConnectionError;
346
+ return {
347
+ responded: evidence?.statusCode !== null && evidence?.statusCode !== undefined,
348
+ status: evidence?.statusCode ?? null,
349
+ attempts,
350
+ elapsedMs: Date.now() - started,
351
+ url: evidence?.requestedUrl ?? null,
352
+ candidates,
353
+ discoveredCandidates: [],
354
+ evidence,
107
355
  };
108
356
  }
109
357
  export function pollHealth(url, timeoutMs, intervalMs = 1000) {
@@ -111,32 +359,62 @@ export function pollHealth(url, timeoutMs, intervalMs = 1000) {
111
359
  }
112
360
  function probe(url) {
113
361
  return new Promise(resolve => {
114
- const req = http.get(url, { timeout: 3000 }, res => { res.resume(); resolve(res.statusCode ?? null); });
115
- req.on("timeout", () => { req.destroy(); resolve(null); });
116
- req.on("error", () => resolve(null));
362
+ let settled = false;
363
+ const finish = (evidence) => {
364
+ if (settled)
365
+ return;
366
+ settled = true;
367
+ resolve(evidence);
368
+ };
369
+ const connectionFailure = (message) => ({
370
+ requestedUrl: url,
371
+ statusCode: null,
372
+ statusText: null,
373
+ headers: {},
374
+ redirectLocation: null,
375
+ bodyExcerpt: "",
376
+ timestamp: new Date().toISOString(),
377
+ acceptedAsHealthy: false,
378
+ connectionError: redactText(message).text,
379
+ });
380
+ let transport;
381
+ try {
382
+ transport = new URL(url).protocol === "https:" ? https : http;
383
+ }
384
+ catch (error) {
385
+ finish(connectionFailure(connectionErrorMessage(error)));
386
+ return;
387
+ }
388
+ const req = transport.get(url, { timeout: 3000 }, res => {
389
+ let bodyExcerpt = "";
390
+ res.setEncoding("utf8");
391
+ res.on("data", chunk => {
392
+ if (bodyExcerpt.length < BODY_EXCERPT_LIMIT) {
393
+ bodyExcerpt += String(chunk).slice(0, BODY_EXCERPT_LIMIT - bodyExcerpt.length);
394
+ }
395
+ });
396
+ res.on("end", () => {
397
+ const statusCode = res.statusCode ?? null;
398
+ const headers = normalizedHeaders(res.headers);
399
+ const redirectLocation = headers.location ?? null;
400
+ finish({
401
+ requestedUrl: url,
402
+ statusCode,
403
+ statusText: statusCode === null ? null : res.statusMessage || http.STATUS_CODES[statusCode] || null,
404
+ headers,
405
+ redirectLocation,
406
+ bodyExcerpt: redactText(bodyExcerpt).text,
407
+ timestamp: new Date().toISOString(),
408
+ acceptedAsHealthy: acceptedAsHealthy(statusCode, redirectLocation),
409
+ connectionError: null,
410
+ });
411
+ });
412
+ res.on("error", error => finish(connectionFailure(connectionErrorMessage(error))));
413
+ });
414
+ req.on("timeout", () => {
415
+ finish(connectionFailure("request timed out after 3000ms"));
416
+ req.destroy();
417
+ });
418
+ req.on("error", error => finish(connectionFailure(connectionErrorMessage(error))));
117
419
  });
118
420
  }
119
- export function minimalEnv(extra = {}) {
120
- const keep = [
121
- "PATH",
122
- "HOME",
123
- "USER",
124
- "SHELL",
125
- "TMPDIR",
126
- "TEMP",
127
- "LANG",
128
- "TERM",
129
- "NODE_OPTIONS",
130
- "COREPACK_HOME",
131
- "npm_config_cache",
132
- "SystemRoot",
133
- "SYSTEMROOT",
134
- "ComSpec",
135
- "PATHEXT",
136
- ];
137
- const env = {};
138
- for (const k of keep)
139
- if (process.env[k])
140
- env[k] = process.env[k];
141
- return { ...env, ...extra, CI: "true", BOOTPROOF: "1" };
142
- }
@@ -0,0 +1,16 @@
1
+ import type { Attestation, ExternalVerificationClassification } from "./types.js";
2
+ export interface ExternalHealthObservation {
3
+ requestedUrl: string;
4
+ statusCode: number | null;
5
+ statusText: string | null;
6
+ finalUrl: string;
7
+ headers: Record<string, string>;
8
+ redirectLocation: string | null;
9
+ responseSnippet: string;
10
+ observedAt: string;
11
+ classification: ExternalVerificationClassification;
12
+ verified: boolean;
13
+ connectionError: string | null;
14
+ }
15
+ export declare function observeExternalHealth(value: string, timeoutMs?: number): Promise<ExternalHealthObservation>;
16
+ export declare function buildExternalHealthAttestation(repo: string, url: string, timeoutMs?: number): Promise<Attestation>;
@@ -0,0 +1,214 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { buildAttestation } from "./proof.js";
4
+ import { redactText } from "./redact.js";
5
+ const RESPONSE_SNIPPET_LIMIT = 1000;
6
+ const SENSITIVE_HEADER_NAME = /authorization|cookie|token|secret|api[-_]?key|password|passwd|credential|private[-_]?key|signature/i;
7
+ const SENSITIVE_FIELD_NAME = String.raw `(?:access[_-]?token|refresh[_-]?token|token|secret|password|passwd|api[_-]?key|private[_-]?key|authorization|cookie|session)`;
8
+ function parsedExternalUrl(value) {
9
+ const url = new URL(value);
10
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
11
+ throw new Error(`unsupported external health URL protocol: ${url.protocol}`);
12
+ }
13
+ if (url.username || url.password) {
14
+ throw new Error("external health URLs must not contain credentials");
15
+ }
16
+ return url;
17
+ }
18
+ function safeRecordedUrl(value) {
19
+ const url = new URL(value);
20
+ url.username = "";
21
+ url.password = "";
22
+ url.hash = "";
23
+ for (const name of new Set(url.searchParams.keys())) {
24
+ url.searchParams.set(name, "[redacted]");
25
+ }
26
+ return url.toString();
27
+ }
28
+ function safeRedirectLocation(location, baseUrl) {
29
+ try {
30
+ const sanitized = new URL(location, baseUrl);
31
+ const safe = safeRecordedUrl(sanitized);
32
+ if (location.startsWith("/")) {
33
+ const parsed = new URL(safe);
34
+ return `${parsed.pathname}${parsed.search}`;
35
+ }
36
+ return safe;
37
+ }
38
+ catch {
39
+ return redactText(location).text;
40
+ }
41
+ }
42
+ function redactExternalText(value) {
43
+ return redactText(value).text
44
+ .replace(new RegExp(`("${SENSITIVE_FIELD_NAME}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi"), '$1"[redacted]"')
45
+ .replace(new RegExp(`\\b(${SENSITIVE_FIELD_NAME}=)[^&\\s]+`, "gi"), "$1[redacted]");
46
+ }
47
+ function safeResponseHeaders(headers, baseUrl) {
48
+ const safe = {};
49
+ for (const [name, value] of Object.entries(headers)) {
50
+ if (value === undefined)
51
+ continue;
52
+ if (SENSITIVE_HEADER_NAME.test(name)) {
53
+ safe[name] = "[redacted]";
54
+ continue;
55
+ }
56
+ const joined = Array.isArray(value) ? value.join(", ") : value;
57
+ safe[name] = name.toLowerCase() === "location"
58
+ ? safeRedirectLocation(joined, baseUrl)
59
+ : redactExternalText(joined);
60
+ }
61
+ return safe;
62
+ }
63
+ function requestOnce(url, timeoutMs) {
64
+ return new Promise((resolve, reject) => {
65
+ const transport = url.protocol === "https:" ? https : http;
66
+ const request = transport.get(url, { timeout: timeoutMs }, response => {
67
+ let responseSnippet = "";
68
+ response.setEncoding("utf8");
69
+ response.on("data", chunk => {
70
+ if (responseSnippet.length >= RESPONSE_SNIPPET_LIMIT)
71
+ return;
72
+ responseSnippet += String(chunk).slice(0, RESPONSE_SNIPPET_LIMIT - responseSnippet.length);
73
+ });
74
+ response.on("end", () => {
75
+ const statusCode = response.statusCode ?? 0;
76
+ const headers = safeResponseHeaders(response.headers, url.toString());
77
+ resolve({
78
+ requestedUrl: safeRecordedUrl(url),
79
+ statusCode,
80
+ statusText: response.statusMessage || http.STATUS_CODES[statusCode] || "",
81
+ headers,
82
+ redirectLocation: headers.location ?? null,
83
+ responseSnippet: redactExternalText(responseSnippet).slice(0, RESPONSE_SNIPPET_LIMIT),
84
+ observedAt: new Date().toISOString(),
85
+ });
86
+ });
87
+ response.on("error", reject);
88
+ });
89
+ request.on("timeout", () => {
90
+ request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
91
+ });
92
+ request.on("error", reject);
93
+ });
94
+ }
95
+ export async function observeExternalHealth(value, timeoutMs = 5000) {
96
+ const initialUrl = parsedExternalUrl(value);
97
+ try {
98
+ const response = await requestOnce(initialUrl, timeoutMs);
99
+ const finalUrl = response.requestedUrl;
100
+ const authRequired = response.statusCode === 401 || response.statusCode === 403;
101
+ const verified = response.statusCode >= 200 && response.statusCode < 400;
102
+ const classification = authRequired
103
+ ? "auth_required"
104
+ : verified
105
+ ? "external_service_verified"
106
+ : "external_health_unreachable";
107
+ return {
108
+ requestedUrl: response.requestedUrl,
109
+ statusCode: response.statusCode,
110
+ statusText: response.statusText,
111
+ finalUrl,
112
+ headers: response.headers,
113
+ redirectLocation: response.redirectLocation,
114
+ responseSnippet: response.responseSnippet,
115
+ observedAt: response.observedAt,
116
+ classification,
117
+ verified,
118
+ connectionError: null,
119
+ };
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ return {
124
+ requestedUrl: safeRecordedUrl(initialUrl),
125
+ statusCode: null,
126
+ statusText: null,
127
+ finalUrl: safeRecordedUrl(initialUrl),
128
+ headers: {},
129
+ redirectLocation: null,
130
+ responseSnippet: "",
131
+ observedAt: new Date().toISOString(),
132
+ classification: "external_health_unreachable",
133
+ verified: false,
134
+ connectionError: redactExternalText(message),
135
+ };
136
+ }
137
+ }
138
+ function statusLabel(observation) {
139
+ if (observation.statusCode === null)
140
+ return "no HTTP response";
141
+ return `HTTP ${observation.statusCode}${observation.statusText ? ` ${observation.statusText}` : ""}`;
142
+ }
143
+ export async function buildExternalHealthAttestation(repo, url, timeoutMs = 5000) {
144
+ const startedAt = new Date().toISOString();
145
+ const observation = await observeExternalHealth(url, timeoutMs);
146
+ const status = statusLabel(observation);
147
+ const healthEvidence = {
148
+ requestedUrl: observation.requestedUrl,
149
+ statusCode: observation.statusCode,
150
+ statusText: observation.statusText,
151
+ headers: observation.headers,
152
+ redirectLocation: observation.redirectLocation,
153
+ bodyExcerpt: observation.responseSnippet,
154
+ timestamp: observation.observedAt,
155
+ acceptedAsHealthy: observation.verified,
156
+ connectionError: observation.connectionError,
157
+ };
158
+ const explanation = observation.verified
159
+ ? `Observed ${status} from an externally managed service. BootProof did not start or orchestrate the service.`
160
+ : observation.classification === "auth_required"
161
+ ? `Observed ${status}; authentication is required, so external health was not fully verified. BootProof did not start or orchestrate the service.`
162
+ : `External health was not verified: ${observation.connectionError ?? status}. BootProof did not start or orchestrate the service.`;
163
+ return buildAttestation({
164
+ repo,
165
+ plan: {
166
+ provider: "local",
167
+ steps: [{
168
+ id: "external-health",
169
+ kind: "health",
170
+ description: "Observe an externally managed HTTP health endpoint",
171
+ required: true,
172
+ }],
173
+ healthUrl: observation.requestedUrl,
174
+ healthCandidates: [observation.requestedUrl],
175
+ observedPort: observation.statusCode === null
176
+ ? null
177
+ : Number(new URL(observation.finalUrl).port || (new URL(observation.finalUrl).protocol === "https:" ? 443 : 80)),
178
+ healthCandidateSource: observation.statusCode === null ? "inferred" : "observed",
179
+ generatedFiles: [],
180
+ },
181
+ observed: [{
182
+ id: "external-health",
183
+ kind: "health",
184
+ startedAt,
185
+ finishedAt: observation.observedAt,
186
+ exitCode: null,
187
+ ok: observation.verified,
188
+ observation: observation.verified
189
+ ? `${status} observed at ${observation.finalUrl}; service ownership is external`
190
+ : `${observation.classification}: ${observation.connectionError ?? status}; service ownership is external`,
191
+ }],
192
+ startedAt,
193
+ booted: false,
194
+ healthVerified: observation.verified,
195
+ healthObservation: observation.verified ? `${status} at ${observation.finalUrl}` : null,
196
+ healthEvidence,
197
+ observedHealthCandidates: [observation.requestedUrl],
198
+ failureClass: observation.classification === "external_service_verified"
199
+ ? null
200
+ : observation.classification,
201
+ failureEvidence: observation.verified
202
+ ? null
203
+ : observation.connectionError ?? `${status} at ${observation.finalUrl}`,
204
+ explanation,
205
+ verificationMode: "external-health",
206
+ bootproofOrchestrated: false,
207
+ externalHealthUrl: observation.requestedUrl,
208
+ observedStatus: observation.statusCode,
209
+ observedFinalUrl: observation.finalUrl,
210
+ observedAt: observation.observedAt,
211
+ responseSnippet: observation.responseSnippet,
212
+ classification: observation.classification,
213
+ });
214
+ }