sentinelayer-cli 0.4.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +16 -18
  2. package/package.json +7 -6
  3. package/src/agents/jules/config/definition.js +13 -62
  4. package/src/agents/jules/config/system-prompt.js +8 -1
  5. package/src/agents/jules/fix-cycle.js +12 -372
  6. package/src/agents/jules/loop.js +116 -26
  7. package/src/agents/jules/pulse.js +10 -327
  8. package/src/agents/jules/stream.js +13 -12
  9. package/src/agents/jules/swarm/orchestrator.js +3 -3
  10. package/src/agents/jules/swarm/sub-agent.js +6 -3
  11. package/src/agents/jules/tools/aidenid-email.js +189 -0
  12. package/src/agents/jules/tools/auth-audit.js +1187 -45
  13. package/src/agents/jules/tools/dispatch.js +25 -12
  14. package/src/agents/jules/tools/file-edit.js +2 -180
  15. package/src/agents/jules/tools/file-read.js +2 -100
  16. package/src/agents/jules/tools/glob.js +2 -168
  17. package/src/agents/jules/tools/grep.js +2 -228
  18. package/src/agents/jules/tools/path-guards.js +2 -161
  19. package/src/agents/jules/tools/runtime-audit.js +6 -2
  20. package/src/agents/jules/tools/shell.js +2 -383
  21. package/src/agents/persona-visuals.js +64 -0
  22. package/src/agents/shared-tools/dispatch-core.js +320 -0
  23. package/src/agents/shared-tools/file-edit.js +180 -0
  24. package/src/agents/shared-tools/file-read.js +100 -0
  25. package/src/agents/shared-tools/glob.js +168 -0
  26. package/src/agents/shared-tools/grep.js +228 -0
  27. package/src/agents/shared-tools/index.js +46 -0
  28. package/src/agents/shared-tools/path-guards.js +161 -0
  29. package/src/agents/shared-tools/shell.js +383 -0
  30. package/src/ai/aidenid.js +56 -7
  31. package/src/ai/client.js +45 -0
  32. package/src/ai/proxy.js +137 -0
  33. package/src/auth/gate.js +290 -16
  34. package/src/auth/http.js +450 -39
  35. package/src/auth/service.js +262 -47
  36. package/src/auth/session-store.js +475 -21
  37. package/src/cli.js +5 -0
  38. package/src/commands/audit.js +13 -8
  39. package/src/commands/auth.js +53 -9
  40. package/src/commands/omargate.js +10 -2
  41. package/src/commands/scan.js +10 -4
  42. package/src/commands/session.js +590 -0
  43. package/src/commands/spec.js +62 -0
  44. package/src/commands/watch.js +3 -2
  45. package/src/daemon/assignment-ledger.js +196 -0
  46. package/src/daemon/error-worker.js +599 -16
  47. package/src/daemon/fix-cycle.js +384 -0
  48. package/src/daemon/ingest-refresh.js +10 -9
  49. package/src/daemon/jira-lifecycle.js +135 -0
  50. package/src/daemon/pulse.js +327 -0
  51. package/src/daemon/scope-engine.js +1068 -0
  52. package/src/events/schema.js +190 -0
  53. package/src/interactive/index.js +18 -16
  54. package/src/legacy-cli.js +606 -37
  55. package/src/prompt/generator.js +19 -1
  56. package/src/review/ai-review.js +11 -1
  57. package/src/review/local-review.js +75 -19
  58. package/src/review/omargate-interactive.js +68 -0
  59. package/src/review/omargate-orchestrator.js +404 -0
  60. package/src/review/persona-prompts.js +296 -0
  61. package/src/review/scan-modes.js +48 -0
  62. package/src/scan/generator.js +1 -1
  63. package/src/session/agent-registry.js +352 -0
  64. package/src/session/daemon.js +801 -0
  65. package/src/session/paths.js +33 -0
  66. package/src/session/runtime-bridge.js +739 -0
  67. package/src/session/store.js +388 -0
  68. package/src/session/stream.js +325 -0
  69. package/src/spec/generator.js +100 -0
  70. package/src/telemetry/session-tracker.js +148 -32
  71. package/src/telemetry/sync.js +6 -2
  72. package/src/ui/command-hints.js +13 -0
@@ -1,4 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
5
  import { setTimeout as sleep } from "node:timers/promises";
3
6
  import { assertPermittedAuditTarget } from "./url-policy.js";
4
7
 
@@ -10,11 +13,26 @@ import { assertPermittedAuditTarget } from "./url-policy.js";
10
13
  * Falls back gracefully when AIdenID or Playwright unavailable.
11
14
  */
12
15
 
13
- export function authAudit(input) {
14
- if (!AUTH_OPS.has(input.operation)) {
15
- throw new AuthAuditError("Unknown operation: " + input.operation + ". Valid: " + [...AUTH_OPS].join(", "));
16
+ export async function authAudit(input = {}) {
17
+ const operation = String(input.operation || "").trim();
18
+ const requestId = createAuditRequestId();
19
+ if (!AUTH_OPS.has(operation)) {
20
+ const message = "Unknown operation: " + (operation || "<empty>") + ". Valid: " + [...AUTH_OPS].join(", ");
21
+ return finalizeAuditEnvelope(operation || "unknown", requestId, buildUnavailableAuditResponse(
22
+ requestId,
23
+ "AUTH_AUDIT_UNKNOWN_OPERATION",
24
+ message
25
+ ));
26
+ }
27
+ try {
28
+ const result = await AUTH_DISPATCH[operation]({ ...input, requestId, operation });
29
+ return finalizeAuditEnvelope(operation, requestId, result);
30
+ } catch (error) {
31
+ const code = error instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_EXECUTION_FAILED";
32
+ const message = normalizeErrorMessage(error, "Auth audit failed");
33
+ const diagnostics = extractErrorDiagnostics(error, operation || "auth_audit");
34
+ return finalizeAuditEnvelope(operation, requestId, buildUnavailableAuditResponse(requestId, code, message, diagnostics));
16
35
  }
17
- return AUTH_DISPATCH[input.operation](input);
18
36
  }
19
37
 
20
38
  const AUTH_OPS = new Set([
@@ -30,8 +48,34 @@ const AUTH_DISPATCH = {
30
48
  };
31
49
 
32
50
  const AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS = 60_000;
33
- const AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES = 2;
34
- const AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS = 250;
51
+ const AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES = 3;
52
+ const AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS = 300;
53
+ const AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS = 180_000;
54
+ const AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS = 2_000;
55
+ const AUTH_AIDENID_PROVISION_TIMEOUT_MS = 12_000;
56
+ const AUTH_AIDENID_PROVISION_MAX_RETRIES = 2;
57
+ const AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS = 300;
58
+ const AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS =
59
+ (AUTH_AIDENID_PROVISION_TIMEOUT_MS * (AUTH_AIDENID_PROVISION_MAX_RETRIES + 1))
60
+ + (AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS * AUTH_AIDENID_PROVISION_MAX_RETRIES * 2);
61
+ const AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS = 1_500;
62
+ const AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD = 3;
63
+ const AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS = 5 * 60 * 1000;
64
+ const AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS = 2 * 60 * 1000;
65
+ const AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS = 15 * 60 * 1000;
66
+ const AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT = "default";
67
+ const AUTH_AUDIT_PROVIDER_BREAKERS = new Map();
68
+ const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV = "SENTINELAYER_AUTH_AUDIT_BREAKER_STATE_FILE";
69
+ // Default to persisting in the user's sentinelayer state dir. Prior default
70
+ // (empty string) disabled persistence, letting provider failures recur
71
+ // across CLI invocations. Opt-out via SENTINELAYER_AUTH_AUDIT_BREAKER_
72
+ // STATE_FILE=off if needed.
73
+ const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT = ".sentinelayer/auth-audit-breaker.json";
74
+ const AUTH_AUDIT_PROVIDER_AIDENID = "aidenid";
75
+ const AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET = "playwright-target";
76
+ const AUTH_MUTATION_ALLOWED_ENV = "SENTINELAYER_ALLOW_AUTH_MUTATION";
77
+ const AUTH_AUDIT_ENVELOPE_ENV = "SENTINELAYER_AUTH_AUDIT_ENVELOPE";
78
+ const AUTH_AUDIT_ENVELOPE_VERSION = "v2";
35
79
  const RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES = new Set([
36
80
  "ETIMEDOUT",
37
81
  "ECONNRESET",
@@ -41,31 +85,921 @@ const RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES = new Set([
41
85
  "UND_ERR_CONNECT_TIMEOUT",
42
86
  "UND_ERR_HEADERS_TIMEOUT",
43
87
  ]);
88
+ const RETRYABLE_AIDENID_PROVISION_ERROR_CODES = new Set([
89
+ "ETIMEDOUT",
90
+ "ECONNRESET",
91
+ "ECONNREFUSED",
92
+ "EAI_AGAIN",
93
+ "ENOTFOUND",
94
+ "ECONNABORTED",
95
+ "AIDENID_ATTEMPT_TIMEOUT",
96
+ "UND_ERR_CONNECT_TIMEOUT",
97
+ "UND_ERR_HEADERS_TIMEOUT",
98
+ "UND_ERR_BODY_TIMEOUT",
99
+ ]);
100
+ const RETRYABLE_AIDENID_PROVISION_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
101
+ const RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS = [
102
+ /\bfetch failed\b/i,
103
+ /\bnetwork(?:\s+|-)error\b/i,
104
+ /\btimed?\s*out\b/i,
105
+ /\b(?:econnreset|econnrefused|eai_again|enotfound|etimedout)\b/i,
106
+ /\bconnection\b.*\b(?:reset|closed|terminated)\b/i,
107
+ ];
108
+
109
+ let AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = false;
110
+ const AUTH_AUDIT_JITTER_SECRET = randomBytes(16).toString("hex");
111
+ let AUTH_AUDIT_JITTER_COUNTER = 0;
112
+
113
+ function createAuditRequestId() {
114
+ try {
115
+ return randomUUID();
116
+ } catch {
117
+ const ts = Date.now().toString(36);
118
+ const rand = randomBytes(16).toString("hex");
119
+ return `authaudit-${ts}-${rand}`;
120
+ }
121
+ }
122
+
123
+ function normalizeErrorMessage(error, fallback) {
124
+ const fallbackMessage = String(fallback || "Auth audit failed");
125
+ if (error instanceof Error && error.message) {
126
+ return sanitizeAuditErrorMessage(error.message, fallbackMessage);
127
+ }
128
+ const normalized = String(error || "").trim();
129
+ return sanitizeAuditErrorMessage(normalized || fallbackMessage, fallbackMessage);
130
+ }
131
+
132
+ function sanitizeAuditErrorMessage(message, fallback = "Auth audit failed") {
133
+ const fallbackMessage = String(fallback || "Auth audit failed");
134
+ const normalized = String(message || "").trim();
135
+ const candidate = normalized || fallbackMessage;
136
+ const sanitized = candidate
137
+ .replace(/\bbearer\s+[a-z0-9._~+/=-]+\b/gi, "bearer [REDACTED]")
138
+ .replace(/\b(token|secret|password|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)\b\s*[:=]\s*["']?[^"'\s,;]+["']?/gi, "$1=[REDACTED]")
139
+ .replace(/\b[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+\b/gi, "[REDACTED_JWT]")
140
+ .replace(/\bhttps?:\/\/[^\s"'`]+/gi, (rawUrl) => sanitizeDiagnosticUrl(rawUrl))
141
+ .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/gi, "<redacted-email>");
142
+ if (sanitized.length <= 512) {
143
+ return sanitized;
144
+ }
145
+ return `${sanitized.slice(0, 509)}...`;
146
+ }
147
+
148
+ function sanitizeDiagnosticUrl(rawUrl) {
149
+ const candidate = String(rawUrl || "").trim();
150
+ if (!candidate) {
151
+ return "<redacted-url>";
152
+ }
153
+ try {
154
+ const parsed = new URL(candidate);
155
+ if (!parsed.hostname) {
156
+ return "<redacted-url>";
157
+ }
158
+ return `${parsed.protocol}//${parsed.host}/<redacted-path>`;
159
+ } catch {
160
+ return "<redacted-url>";
161
+ }
162
+ }
163
+
164
+ function buildUnavailableAuditResponse(requestId, code, message, options = {}) {
165
+ const safeMessage = sanitizeAuditErrorMessage(message, "Auth audit unavailable");
166
+ const errorPayload = {
167
+ code,
168
+ message: safeMessage,
169
+ requestId,
170
+ retryable: options.retryable === true,
171
+ };
172
+ const phase = String(options.phase || "").trim();
173
+ if (phase) {
174
+ errorPayload.phase = phase;
175
+ }
176
+ const parsedStatusCode = Number.parseInt(String(options.statusCode || ""), 10);
177
+ if (Number.isInteger(parsedStatusCode) && parsedStatusCode > 0) {
178
+ errorPayload.statusCode = parsedStatusCode;
179
+ }
180
+ const errorCode = String(options.errorCode || "").trim();
181
+ if (errorCode) {
182
+ errorPayload.errorCode = errorCode.toUpperCase();
183
+ }
184
+ if (options.retryTelemetry && typeof options.retryTelemetry === "object") {
185
+ errorPayload.retryTelemetry = options.retryTelemetry;
186
+ }
187
+ if (options.providerBreaker && typeof options.providerBreaker === "object") {
188
+ errorPayload.providerBreaker = options.providerBreaker;
189
+ }
190
+ return {
191
+ available: false,
192
+ requestId,
193
+ reason: safeMessage,
194
+ error: errorPayload,
195
+ };
196
+ }
197
+
198
+ function extractErrorDiagnostics(error, phase = "auth_audit") {
199
+ const diagnostics = {
200
+ phase: String(phase || "auth_audit").trim().slice(0, 64) || "auth_audit",
201
+ };
202
+ const statusCode = resolveAidenidProvisionStatusCode(error);
203
+ if (Number.isInteger(statusCode) && statusCode > 0) {
204
+ diagnostics.statusCode = statusCode;
205
+ }
206
+ let errorCode = resolveAidenidProvisionErrorCode(error);
207
+ if (!errorCode && error instanceof Error) {
208
+ errorCode = String(error.name || "").trim().toUpperCase();
209
+ }
210
+ if (errorCode) {
211
+ diagnostics.errorCode = errorCode.toUpperCase();
212
+ }
213
+ return diagnostics;
214
+ }
215
+
216
+ function isAuditEnvelopeV2Enabled() {
217
+ const normalized = String(process.env[AUTH_AUDIT_ENVELOPE_ENV] || "").trim().toLowerCase();
218
+ return normalized === "" || normalized === "true" || normalized === "1" || normalized === AUTH_AUDIT_ENVELOPE_VERSION;
219
+ }
220
+
221
+ function buildAuditDataEnvelope(payload) {
222
+ const data = { ...(payload && typeof payload === "object" ? payload : {}) };
223
+ delete data.ok;
224
+ delete data.operation;
225
+ delete data.envelope;
226
+ delete data.data;
227
+ return data;
228
+ }
229
+
230
+ function finalizeAuditEnvelope(operation, requestId, payload) {
231
+ const normalizedPayload = payload && typeof payload === "object" ? { ...payload } : { result: payload };
232
+ const normalizedRequestId = String(normalizedPayload.requestId || requestId || createAuditRequestId());
233
+ normalizedPayload.requestId = normalizedRequestId;
234
+ if (!Object.prototype.hasOwnProperty.call(normalizedPayload, "available")) {
235
+ normalizedPayload.available = !normalizedPayload.error;
236
+ }
237
+ if (!isAuditEnvelopeV2Enabled()) {
238
+ return normalizedPayload;
239
+ }
240
+ normalizedPayload.ok = normalizedPayload.available === true;
241
+ normalizedPayload.operation = String(operation || normalizedPayload.operation || "unknown");
242
+ normalizedPayload.envelope = AUTH_AUDIT_ENVELOPE_VERSION;
243
+ if (normalizedPayload.ok) {
244
+ normalizedPayload.data = buildAuditDataEnvelope(normalizedPayload);
245
+ } else {
246
+ if (!normalizedPayload.error || typeof normalizedPayload.error !== "object") {
247
+ normalizedPayload.error = {
248
+ code: "AUTH_AUDIT_FAILED",
249
+ message: String(normalizedPayload.reason || "Auth audit failed"),
250
+ requestId: normalizedRequestId,
251
+ retryable: false,
252
+ };
253
+ } else if (!normalizedPayload.error.requestId) {
254
+ normalizedPayload.error = {
255
+ ...normalizedPayload.error,
256
+ requestId: normalizedRequestId,
257
+ };
258
+ }
259
+ normalizedPayload.reason = String(
260
+ normalizedPayload.reason
261
+ || normalizedPayload.error.message
262
+ || "Auth audit failed"
263
+ );
264
+ normalizedPayload.data = null;
265
+ }
266
+ return normalizedPayload;
267
+ }
268
+
269
+ function resolveAidenidProvisionStatusCode(error) {
270
+ if (!(error instanceof Error)) {
271
+ return 0;
272
+ }
273
+ const directStatus = Number.parseInt(String(error.statusCode || error.status || ""), 10);
274
+ if (Number.isInteger(directStatus) && directStatus > 0) {
275
+ return directStatus;
276
+ }
277
+ const statusMatch = String(error.message || "").match(/\bstatus\s+(\d{3})\b/i);
278
+ if (!statusMatch) {
279
+ return 0;
280
+ }
281
+ const parsed = Number.parseInt(statusMatch[1], 10);
282
+ return Number.isInteger(parsed) ? parsed : 0;
283
+ }
284
+
285
+ function resolveAidenidProvisionErrorCode(error) {
286
+ if (!(error instanceof Error)) {
287
+ return "";
288
+ }
289
+ const explicitCode = String(error.errorCode || "").toUpperCase();
290
+ if (explicitCode) {
291
+ return explicitCode;
292
+ }
293
+ const directCode = String(error.code || "").toUpperCase();
294
+ if (directCode) {
295
+ return directCode;
296
+ }
297
+ const cause = error.cause;
298
+ if (!cause || typeof cause !== "object") {
299
+ return "";
300
+ }
301
+ return String(cause.code || cause.errno || "").toUpperCase();
302
+ }
303
+
304
+ function classifyAidenidProvisionFailure(error) {
305
+ const classification = {
306
+ retryable: false,
307
+ statusCode: 0,
308
+ errorCode: "",
309
+ };
310
+ if (!(error instanceof Error)) {
311
+ return classification;
312
+ }
313
+ classification.errorCode = resolveAidenidProvisionErrorCode(error);
314
+ classification.statusCode = resolveAidenidProvisionStatusCode(error);
315
+ if (typeof error.retryable === "boolean") {
316
+ classification.retryable = error.retryable;
317
+ return classification;
318
+ }
319
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
320
+ classification.retryable = true;
321
+ return classification;
322
+ }
323
+ if (RETRYABLE_AIDENID_PROVISION_ERROR_CODES.has(classification.errorCode)) {
324
+ classification.retryable = true;
325
+ return classification;
326
+ }
327
+ if (RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(classification.statusCode)) {
328
+ classification.retryable = true;
329
+ return classification;
330
+ }
331
+ const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
332
+ classification.retryable = RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
333
+ return classification;
334
+ }
335
+
336
+ function isRetryableAidenidProvisionError(error) {
337
+ return classifyAidenidProvisionFailure(error).retryable;
338
+ }
339
+
340
+ function deriveAidenidBackoffSeed(requestId) {
341
+ const normalizedRequestId = String(requestId || "").trim();
342
+ const counter = AUTH_AUDIT_JITTER_COUNTER++;
343
+ const seedMaterial = `${AUTH_AUDIT_JITTER_SECRET}:${normalizedRequestId || "fallback"}:${counter}`;
344
+ return createHash("sha256").update(seedMaterial).digest().readUInt32BE(0);
345
+ }
346
+
347
+ function computeAidenidProvisionBackoffMs(
348
+ attempt,
349
+ baseBackoffMs = AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
350
+ jitterSeed = 0
351
+ ) {
352
+ const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS);
353
+ const exponential = Math.min(2500, cappedBase * Math.pow(2, Math.max(0, attempt)));
354
+ const normalizedSeed = Number.isFinite(jitterSeed) ? Math.abs(Math.trunc(jitterSeed)) : 0;
355
+ const deterministicJitter = ((Math.max(0, attempt) * 1664525 + 1013904223 + normalizedSeed) % 1000) / 1000;
356
+ const jitterFactor = 0.5 + (deterministicJitter * 0.5);
357
+ return Math.max(1, Math.trunc(exponential * jitterFactor));
358
+ }
359
+
360
+ function normalizeAidenidTotalBudgetMs(value) {
361
+ if (!Number.isFinite(value) || value <= 0) {
362
+ return AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS;
363
+ }
364
+ return Math.max(1, Math.trunc(value));
365
+ }
366
+
367
+ function normalizeProviderBreakerScope(scope) {
368
+ const normalized = String(scope || "").trim().toLowerCase();
369
+ if (!normalized) {
370
+ return AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
371
+ }
372
+ return normalized.replace(/[^a-z0-9._:/-]/g, "-").replace(/-+/g, "-").slice(0, 120) || AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
373
+ }
374
+
375
+ function deriveProviderBreakerScope(options = {}) {
376
+ const explicitScope = normalizeProviderBreakerScope(
377
+ options.contextId || options.scopeId || options.repoScope || ""
378
+ );
379
+ if (explicitScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
380
+ return explicitScope;
381
+ }
382
+ const parts = [];
383
+ const urlCandidate = String(options.targetUrl || options.apiUrl || "").trim();
384
+ if (urlCandidate) {
385
+ try {
386
+ const parsed = new URL(urlCandidate);
387
+ if (parsed.hostname) {
388
+ parts.push(parsed.hostname.toLowerCase());
389
+ }
390
+ } catch {
391
+ // Fall through to non-URL scoped dimensions.
392
+ }
393
+ }
394
+ const orgScope = normalizeProviderBreakerScope(options.orgId || options.organizationId || "");
395
+ if (orgScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
396
+ parts.push(`org-${orgScope}`);
397
+ }
398
+ const projectScope = normalizeProviderBreakerScope(options.projectId || "");
399
+ if (projectScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
400
+ parts.push(`proj-${projectScope}`);
401
+ }
402
+ const repoScope = normalizeProviderBreakerScope(process.env.GITHUB_REPOSITORY || "");
403
+ if (repoScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
404
+ parts.push(`repo-${repoScope}`);
405
+ }
406
+ if (parts.length === 0) {
407
+ const workspaceFallback = createHash("sha256")
408
+ .update(process.cwd())
409
+ .digest("hex")
410
+ .slice(0, 20);
411
+ parts.push(`ws-${workspaceFallback}`);
412
+ }
413
+ return normalizeProviderBreakerScope(parts.join(":"));
414
+ }
415
+
416
+ function getProviderBreakerKey(provider, scope) {
417
+ return `${provider}:${scope}`;
418
+ }
419
+
420
+ function getProviderBreakerStatePath() {
421
+ const configuredPath = String(
422
+ process.env[AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV] || AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT
423
+ ).trim();
424
+ if (!configuredPath || configuredPath.toLowerCase() === "off" || configuredPath.toLowerCase() === "false") {
425
+ return "";
426
+ }
427
+ const repoScope = String(process.env.GITHUB_REPOSITORY || "local").trim() || "local";
428
+ const runScope = String(process.env.GITHUB_RUN_ID || process.pid || "0").trim();
429
+ const scopeSuffix = createHash("sha256")
430
+ .update(`${repoScope}:${runScope}`)
431
+ .digest("hex")
432
+ .slice(0, 12);
433
+ const resolvedPath = path.isAbsolute(configuredPath)
434
+ ? configuredPath
435
+ : path.resolve(process.cwd(), configuredPath);
436
+ const ext = path.extname(resolvedPath) || ".json";
437
+ const base = resolvedPath.endsWith(ext) ? resolvedPath.slice(0, -ext.length) : resolvedPath;
438
+ return `${base}.${scopeSuffix}${ext}`;
439
+ }
440
+
441
+ function hydrateProviderBreakerState() {
442
+ if (AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED) {
443
+ return;
444
+ }
445
+ AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = true;
446
+ const statePath = getProviderBreakerStatePath();
447
+ if (!statePath || !fs.existsSync(statePath)) {
448
+ return;
449
+ }
450
+ const nowMs = Date.now();
451
+ try {
452
+ const raw = fs.readFileSync(statePath, "utf-8");
453
+ const parsed = JSON.parse(raw);
454
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
455
+ for (const entry of entries) {
456
+ const lastUpdatedAtMs = Number.isFinite(entry?.lastUpdatedAtMs)
457
+ ? Math.max(0, Math.trunc(entry.lastUpdatedAtMs))
458
+ : Number.isFinite(entry?.windowStartedAt)
459
+ ? Math.max(0, Math.trunc(entry.windowStartedAt))
460
+ : 0;
461
+ if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
462
+ continue;
463
+ }
464
+ const provider = String(entry?.provider || "").trim().toLowerCase();
465
+ if (!provider) {
466
+ continue;
467
+ }
468
+ const scope = normalizeProviderBreakerScope(entry?.scope);
469
+ const key = getProviderBreakerKey(provider, scope);
470
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(key, {
471
+ key,
472
+ provider,
473
+ scope,
474
+ consecutiveFailures: Number.isFinite(entry?.consecutiveFailures) ? Math.max(0, Math.trunc(entry.consecutiveFailures)) : 0,
475
+ windowStartedAt: Number.isFinite(entry?.windowStartedAt) ? Math.max(0, Math.trunc(entry.windowStartedAt)) : 0,
476
+ openUntilMs: Number.isFinite(entry?.openUntilMs) ? Math.max(0, Math.trunc(entry.openUntilMs)) : 0,
477
+ lastFailureCode: String(entry?.lastFailureCode || "").trim().toUpperCase(),
478
+ lastUpdatedAtMs,
479
+ });
480
+ }
481
+ } catch {
482
+ // Fall back to in-memory behavior if persisted state cannot be loaded.
483
+ }
484
+ }
485
+
486
+ function persistProviderBreakerState(nowMs = Date.now()) {
487
+ const statePath = getProviderBreakerStatePath();
488
+ if (!statePath) {
489
+ return;
490
+ }
491
+ try {
492
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
493
+ const payload = {
494
+ version: 1,
495
+ updatedAtMs: nowMs,
496
+ entries: [...AUTH_AUDIT_PROVIDER_BREAKERS.values()].map((state) => ({
497
+ provider: state.provider,
498
+ scope: state.scope,
499
+ consecutiveFailures: state.consecutiveFailures,
500
+ windowStartedAt: state.windowStartedAt,
501
+ openUntilMs: state.openUntilMs,
502
+ lastFailureCode: state.lastFailureCode,
503
+ lastUpdatedAtMs: state.lastUpdatedAtMs || nowMs,
504
+ })),
505
+ };
506
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
507
+ fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf-8");
508
+ fs.renameSync(tmpPath, statePath);
509
+ } catch {
510
+ // Persistence is best-effort; in-memory safeguards remain active.
511
+ }
512
+ }
513
+
514
+ function sweepProviderBreakers(nowMs = Date.now()) {
515
+ let mutated = false;
516
+ for (const [breakerKey, state] of AUTH_AUDIT_PROVIDER_BREAKERS.entries()) {
517
+ const isOpen = Number(state.openUntilMs || 0) > nowMs;
518
+ const lastUpdatedAtMs = Number(state.lastUpdatedAtMs || state.windowStartedAt || 0);
519
+ if (isOpen) {
520
+ continue;
521
+ }
522
+ if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
523
+ AUTH_AUDIT_PROVIDER_BREAKERS.delete(breakerKey);
524
+ mutated = true;
525
+ }
526
+ }
527
+ if (mutated) {
528
+ persistProviderBreakerState(nowMs);
529
+ }
530
+ }
531
+
532
+ function getProviderBreakerState(provider, scope) {
533
+ hydrateProviderBreakerState();
534
+ const normalizedProvider = String(provider || "").trim().toLowerCase();
535
+ if (!normalizedProvider) {
536
+ return null;
537
+ }
538
+ const normalizedScope = normalizeProviderBreakerScope(scope);
539
+ const breakerKey = getProviderBreakerKey(normalizedProvider, normalizedScope);
540
+ const nowMs = Date.now();
541
+ sweepProviderBreakers(nowMs);
542
+ if (!AUTH_AUDIT_PROVIDER_BREAKERS.has(breakerKey)) {
543
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(breakerKey, {
544
+ key: breakerKey,
545
+ provider: normalizedProvider,
546
+ scope: normalizedScope,
547
+ consecutiveFailures: 0,
548
+ windowStartedAt: 0,
549
+ openUntilMs: 0,
550
+ lastFailureCode: "",
551
+ lastUpdatedAtMs: nowMs,
552
+ });
553
+ persistProviderBreakerState(nowMs);
554
+ }
555
+ return AUTH_AUDIT_PROVIDER_BREAKERS.get(breakerKey);
556
+ }
557
+
558
+ function getProviderBreakerSnapshot(provider, scope, nowMs = Date.now()) {
559
+ const state = getProviderBreakerState(provider, scope);
560
+ if (!state) {
561
+ return null;
562
+ }
563
+ const remainingCooldownMs = state.openUntilMs > nowMs ? state.openUntilMs - nowMs : 0;
564
+ return {
565
+ key: state.key,
566
+ provider: state.provider,
567
+ scope: state.scope,
568
+ consecutiveFailures: state.consecutiveFailures,
569
+ windowStartedAt: state.windowStartedAt || 0,
570
+ remainingCooldownMs,
571
+ cooldownUntilMs: state.openUntilMs || 0,
572
+ lastFailureCode: state.lastFailureCode || "",
573
+ };
574
+ }
575
+
576
+ function enforceProviderBreaker(provider, scope, requestId) {
577
+ const state = getProviderBreakerState(provider, scope);
578
+ if (!state) {
579
+ return;
580
+ }
581
+ const nowMs = Date.now();
582
+ if (state.openUntilMs > nowMs) {
583
+ const snapshot = getProviderBreakerSnapshot(provider, scope, nowMs);
584
+ const blocked = new AuthAuditError(
585
+ `Provider circuit is open for ${state.provider}/${state.scope}; retry after cooldown (requestId=${requestId}).`
586
+ );
587
+ blocked.errorCode = "AUTH_AUDIT_PROVIDER_CIRCUIT_OPEN";
588
+ blocked.retryable = false;
589
+ blocked.providerBreaker = snapshot;
590
+ throw blocked;
591
+ }
592
+ if (state.openUntilMs > 0 && state.openUntilMs <= nowMs) {
593
+ state.consecutiveFailures = 0;
594
+ state.windowStartedAt = nowMs;
595
+ state.openUntilMs = 0;
596
+ state.lastFailureCode = "";
597
+ state.lastUpdatedAtMs = nowMs;
598
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
599
+ persistProviderBreakerState(nowMs);
600
+ }
601
+ }
602
+
603
+ function recordProviderBreakerSuccess(provider, scope) {
604
+ const state = getProviderBreakerState(provider, scope);
605
+ if (!state) {
606
+ return;
607
+ }
608
+ state.consecutiveFailures = 0;
609
+ state.windowStartedAt = 0;
610
+ state.openUntilMs = 0;
611
+ state.lastFailureCode = "";
612
+ state.lastUpdatedAtMs = Date.now();
613
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
614
+ persistProviderBreakerState(state.lastUpdatedAtMs);
615
+ }
616
+
617
+ function recordProviderBreakerFailure(provider, scope, errorCode = "") {
618
+ const state = getProviderBreakerState(provider, scope);
619
+ if (!state) {
620
+ return null;
621
+ }
622
+ const nowMs = Date.now();
623
+ if (!state.windowStartedAt || (nowMs - state.windowStartedAt) > AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS) {
624
+ state.windowStartedAt = nowMs;
625
+ state.consecutiveFailures = 0;
626
+ }
627
+ state.consecutiveFailures += 1;
628
+ state.lastFailureCode = String(errorCode || "").trim().toUpperCase();
629
+ if (state.consecutiveFailures >= AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD) {
630
+ state.openUntilMs = nowMs + AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS;
631
+ }
632
+ state.lastUpdatedAtMs = nowMs;
633
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
634
+ persistProviderBreakerState(nowMs);
635
+ return getProviderBreakerSnapshot(provider, scope, nowMs);
636
+ }
637
+
638
+ function isAbortSignalLike(signal) {
639
+ return Boolean(
640
+ signal &&
641
+ typeof signal === "object" &&
642
+ typeof signal.aborted === "boolean" &&
643
+ typeof signal.addEventListener === "function" &&
644
+ typeof signal.removeEventListener === "function"
645
+ );
646
+ }
647
+
648
+ function composeAbortSignals(primarySignal, secondarySignal) {
649
+ const hasPrimary = isAbortSignalLike(primarySignal);
650
+ const hasSecondary = isAbortSignalLike(secondarySignal);
651
+ if (!hasPrimary && !hasSecondary) {
652
+ return { signal: undefined, cleanup: () => {} };
653
+ }
654
+ if (!hasPrimary) {
655
+ return { signal: secondarySignal, cleanup: () => {} };
656
+ }
657
+ if (!hasSecondary) {
658
+ return { signal: primarySignal, cleanup: () => {} };
659
+ }
660
+ const mergedController = new AbortController();
661
+ const forwardAbort = () => {
662
+ if (!mergedController.signal.aborted) {
663
+ mergedController.abort();
664
+ }
665
+ };
666
+ if (primarySignal.aborted || secondarySignal.aborted) {
667
+ forwardAbort();
668
+ return { signal: mergedController.signal, cleanup: () => {} };
669
+ }
670
+ primarySignal.addEventListener("abort", forwardAbort, { once: true });
671
+ secondarySignal.addEventListener("abort", forwardAbort, { once: true });
672
+ return {
673
+ signal: mergedController.signal,
674
+ cleanup: () => {
675
+ primarySignal.removeEventListener("abort", forwardAbort);
676
+ secondarySignal.removeEventListener("abort", forwardAbort);
677
+ },
678
+ };
679
+ }
680
+
681
+ async function provisionEmailIdentityWithRetry(provisionEmailIdentity, options = {}) {
682
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
683
+ ? Math.trunc(options.timeoutMs)
684
+ : AUTH_AIDENID_PROVISION_TIMEOUT_MS;
685
+ const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
686
+ ? options.maxRetries
687
+ : AUTH_AIDENID_PROVISION_MAX_RETRIES;
688
+ const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
689
+ ? Math.trunc(options.baseBackoffMs)
690
+ : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS;
691
+ const requestOptions = options.requestOptions && typeof options.requestOptions === "object"
692
+ ? { ...options.requestOptions }
693
+ : {};
694
+ const requestId = String(options.requestId || createAuditRequestId());
695
+ const jitterSeed = Number.isFinite(options.jitterSeed)
696
+ ? Math.abs(Math.trunc(options.jitterSeed))
697
+ : deriveAidenidBackoffSeed(requestId);
698
+ const totalBudgetMs = normalizeAidenidTotalBudgetMs(options.totalBudgetMs);
699
+ const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
700
+ ? Math.trunc(options.minAttemptTimeoutMs)
701
+ : AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS;
702
+ const effectiveMinAttemptTimeoutMs = Math.max(1, Math.min(timeoutMs, minAttemptTimeoutMs));
703
+ const attemptMetrics = [];
704
+ const retryWindowStartedAt = Date.now();
705
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
706
+ const elapsedMs = Date.now() - retryWindowStartedAt;
707
+ const remainingBudgetMs = totalBudgetMs - elapsedMs;
708
+ if (remainingBudgetMs <= 0) {
709
+ const exhausted = new AuthAuditError(
710
+ `AIdenID provisioning retry budget exhausted after ${attempt} attempt(s) (requestId=${requestId})`
711
+ );
712
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
713
+ exhausted.retryable = false;
714
+ exhausted.retryTelemetry = {
715
+ requestId,
716
+ attempts: attempt,
717
+ totalBudgetMs,
718
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
719
+ totalElapsedMs: elapsedMs,
720
+ attemptMetrics,
721
+ };
722
+ throw exhausted;
723
+ }
724
+ if (remainingBudgetMs < effectiveMinAttemptTimeoutMs) {
725
+ const exhausted = new AuthAuditError(
726
+ `AIdenID provisioning remaining retry budget (${remainingBudgetMs}ms) fell below minimum attempt window (${effectiveMinAttemptTimeoutMs}ms) (requestId=${requestId})`
727
+ );
728
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
729
+ exhausted.retryable = false;
730
+ exhausted.retryTelemetry = {
731
+ requestId,
732
+ attempts: attempt,
733
+ totalBudgetMs,
734
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
735
+ totalElapsedMs: elapsedMs,
736
+ attemptMetrics,
737
+ };
738
+ throw exhausted;
739
+ }
740
+ const attemptTimeoutMs = Math.max(effectiveMinAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
741
+ const attemptStartedAt = Date.now();
742
+ const attemptMetric = {
743
+ attempt: attempt + 1,
744
+ timeoutMs: attemptTimeoutMs,
745
+ budgetBeforeAttemptMs: remainingBudgetMs,
746
+ };
747
+ try {
748
+ const attemptPromise = provisionEmailIdentity({
749
+ ...requestOptions,
750
+ fetchImpl: async (resource, init = {}) => {
751
+ const callerSignal = isAbortSignalLike(init.signal) ? init.signal : undefined;
752
+ const controller = new AbortController();
753
+ const timeoutHandle = setTimeout(() => controller.abort(), attemptTimeoutMs);
754
+ const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
755
+ const nextInit = {
756
+ ...init,
757
+ ...(compositeSignal ? { signal: compositeSignal } : {}),
758
+ };
759
+ try {
760
+ const response = await fetch(resource, nextInit);
761
+ if (response && RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(Number(response.status || 0))) {
762
+ if (response.body && typeof response.body.cancel === "function") {
763
+ try {
764
+ await response.body.cancel();
765
+ } catch {
766
+ // No-op: retry classification still applies if body drain fails.
767
+ }
768
+ }
769
+ const transientHttpError = new AuthAuditError(`AIdenID transient HTTP ${response.status}`);
770
+ transientHttpError.errorCode = "AIDENID_HTTP_RETRYABLE";
771
+ transientHttpError.statusCode = Number(response.status || 0);
772
+ transientHttpError.retryable = true;
773
+ throw transientHttpError;
774
+ }
775
+ return response;
776
+ } catch (error) {
777
+ if (callerSignal && callerSignal.aborted === true) {
778
+ const aborted = new AuthAuditError("AIdenID provisioning aborted by caller");
779
+ aborted.errorCode = "AIDENID_ABORTED_BY_CALLER";
780
+ aborted.retryable = false;
781
+ throw aborted;
782
+ }
783
+ const failure = classifyAidenidProvisionFailure(error);
784
+ if (failure.retryable) {
785
+ const wrapped = new AuthAuditError(normalizeErrorMessage(error, "AIdenID provisioning transport failed"));
786
+ wrapped.errorCode = failure.errorCode || "AIDENID_TRANSPORT_RETRYABLE";
787
+ wrapped.statusCode = failure.statusCode || 0;
788
+ wrapped.retryable = true;
789
+ throw wrapped;
790
+ }
791
+ throw error;
792
+ } finally {
793
+ clearTimeout(timeoutHandle);
794
+ cleanupCompositeSignal();
795
+ }
796
+ },
797
+ });
798
+ const result = await Promise.race([
799
+ attemptPromise,
800
+ new Promise((_, reject) => {
801
+ const timer = setTimeout(() => {
802
+ const timeoutError = new AuthAuditError(
803
+ `AIdenID provisioning attempt ${attempt + 1} timed out after ${attemptTimeoutMs}ms (requestId=${requestId})`
804
+ );
805
+ timeoutError.errorCode = "AIDENID_ATTEMPT_TIMEOUT";
806
+ timeoutError.retryable = true;
807
+ reject(timeoutError);
808
+ }, attemptTimeoutMs);
809
+ // `finally()` returns a new promise. Swallow its rejection path so late
810
+ // attempt failures never escape as unhandledRejection after timeout wins.
811
+ void attemptPromise
812
+ .finally(() => clearTimeout(timer))
813
+ .catch(() => {});
814
+ }),
815
+ ]);
816
+ attemptMetric.durationMs = Date.now() - attemptStartedAt;
817
+ attemptMetric.outcome = "success";
818
+ attemptMetrics.push(attemptMetric);
819
+ const retryTelemetry = {
820
+ requestId,
821
+ attempts: attempt + 1,
822
+ totalBudgetMs,
823
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
824
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
825
+ attemptMetrics,
826
+ };
827
+ if (result && typeof result === "object" && !Array.isArray(result)) {
828
+ return {
829
+ ...result,
830
+ retryTelemetry,
831
+ };
832
+ }
833
+ return {
834
+ value: result,
835
+ retryTelemetry,
836
+ };
837
+ } catch (error) {
838
+ const failure = classifyAidenidProvisionFailure(error);
839
+ attemptMetric.durationMs = Date.now() - attemptStartedAt;
840
+ attemptMetric.outcome = failure.retryable ? "retryable_error" : "terminal_error";
841
+ attemptMetric.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
842
+ attemptMetric.statusCode = failure.statusCode || 0;
843
+ attemptMetrics.push(attemptMetric);
844
+ if (!failure.retryable || attempt >= maxRetries) {
845
+ const reason = normalizeErrorMessage(error, "AIdenID provisioning failed");
846
+ const terminal = new AuthAuditError(
847
+ `AIdenID provisioning failed after ${attempt + 1} attempt(s) (requestId=${requestId}): ${reason}`
848
+ );
849
+ terminal.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
850
+ terminal.retryable = false;
851
+ terminal.retryTelemetry = {
852
+ requestId,
853
+ attempts: attempt + 1,
854
+ totalBudgetMs,
855
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
856
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
857
+ attemptMetrics,
858
+ };
859
+ throw terminal;
860
+ }
861
+ }
862
+ const backoffMs = computeAidenidProvisionBackoffMs(attempt, baseBackoffMs, jitterSeed);
863
+ const remainingAfterAttemptMs = totalBudgetMs - (Date.now() - retryWindowStartedAt);
864
+ if (remainingAfterAttemptMs <= 0) {
865
+ break;
866
+ }
867
+ await sleep(Math.min(backoffMs, remainingAfterAttemptMs));
868
+ }
869
+ const exhausted = new AuthAuditError(`AIdenID provisioning failed after retry budget was exhausted (requestId=${requestId})`);
870
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
871
+ exhausted.retryable = false;
872
+ exhausted.retryTelemetry = {
873
+ requestId,
874
+ attempts: maxRetries + 1,
875
+ totalBudgetMs,
876
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
877
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
878
+ attemptMetrics,
879
+ };
880
+ throw exhausted;
881
+ }
882
+
883
+ function normalizeHeaderValue(value) {
884
+ const normalized = String(value || "").trim();
885
+ return normalized || "";
886
+ }
887
+
888
+ function evaluateAuthenticatedHeaderFindings(targetUrl, headers = {}, authSignals = {}) {
889
+ const findings = [];
890
+ const normalizedHeaders = headers && typeof headers === "object" ? headers : {};
891
+ const csp = normalizeHeaderValue(normalizedHeaders["content-security-policy"]);
892
+ const hsts = normalizeHeaderValue(normalizedHeaders["strict-transport-security"]);
893
+ const xFrameOptions = normalizeHeaderValue(normalizedHeaders["x-frame-options"]);
894
+ let requiresHsts = false;
895
+ try {
896
+ requiresHsts = new URL(targetUrl).protocol === "https:";
897
+ } catch {
898
+ requiresHsts = String(targetUrl || "").startsWith("https://");
899
+ }
900
+
901
+ if (!csp) {
902
+ findings.push({
903
+ severity: "P2",
904
+ title: "Authenticated page missing Content-Security-Policy header",
905
+ file: targetUrl,
906
+ });
907
+ }
908
+ if (requiresHsts && !hsts) {
909
+ findings.push({
910
+ severity: "P1",
911
+ title: "Authenticated page missing Strict-Transport-Security header",
912
+ file: targetUrl,
913
+ });
914
+ }
915
+ if (!xFrameOptions) {
916
+ findings.push({
917
+ severity: "P2",
918
+ title: "Authenticated page missing X-Frame-Options header",
919
+ file: targetUrl,
920
+ });
921
+ } else if (!/^(deny|sameorigin)$/i.test(xFrameOptions)) {
922
+ findings.push({
923
+ severity: "P2",
924
+ title: "Authenticated page has weak X-Frame-Options policy: " + xFrameOptions,
925
+ file: targetUrl,
926
+ });
927
+ }
928
+
929
+ if (authSignals && typeof authSignals === "object") {
930
+ authSignals.headerPolicyPassed = findings.length === 0;
931
+ authSignals.headerPolicyFindingCount = findings.length;
932
+ }
933
+ return findings;
934
+ }
44
935
 
45
936
  async function provisionTestIdentity(input) {
937
+ const requestId = String(input.requestId || createAuditRequestId());
938
+ const providerKey = AUTH_AUDIT_PROVIDER_AIDENID;
939
+ const providerScope = deriveProviderBreakerScope({
940
+ contextId: input.providerContextId || input.contextId || input.scopeId,
941
+ apiUrl: input.apiUrl,
942
+ orgId: input.orgId || input.aidenidOrgId,
943
+ projectId: input.projectId || input.aidenidProjectId,
944
+ });
46
945
  try {
946
+ enforceProviderBreaker(providerKey, providerScope, requestId);
47
947
  const executeRequested = input.execute === true;
48
948
  const allowLiveProvision = input.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
49
949
  if (executeRequested && !allowLiveProvision) {
50
- return {
51
- available: false,
52
- reason: "Live AIdenID provisioning requires explicit allowProvisioning=true (or SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION=1).",
53
- };
950
+ return buildUnavailableAuditResponse(
951
+ requestId,
952
+ "AIDENID_PROVISION_APPROVAL_REQUIRED",
953
+ "Live AIdenID provisioning requires explicit allowProvisioning=true (or SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION=1)."
954
+ );
54
955
  }
55
956
 
56
957
  const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
57
958
  const creds = await resolveAidenIdCredentials();
58
959
  if (!creds.apiKey) {
59
- return { available: false, reason: "AIdenID API key not configured (set AIDENID_API_KEY)" };
960
+ return buildUnavailableAuditResponse(
961
+ requestId,
962
+ "AIDENID_API_KEY_MISSING",
963
+ "AIdenID API key not configured (set AIDENID_API_KEY)"
964
+ );
60
965
  }
61
- const result = await provisionEmailIdentity({
62
- apiUrl: creds.apiUrl, apiKey: creds.apiKey,
63
- tags: ["jules-audit", "frontend-test"],
64
- ttlSeconds: 3600, dryRun: !executeRequested,
966
+ const result = await provisionEmailIdentityWithRetry(provisionEmailIdentity, {
967
+ timeoutMs: AUTH_AIDENID_PROVISION_TIMEOUT_MS,
968
+ maxRetries: AUTH_AIDENID_PROVISION_MAX_RETRIES,
969
+ baseBackoffMs: AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
970
+ totalBudgetMs: AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS,
971
+ minAttemptTimeoutMs: AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS,
972
+ requestId,
973
+ requestOptions: {
974
+ apiUrl: creds.apiUrl,
975
+ apiKey: creds.apiKey,
976
+ tags: ["jules-audit", "frontend-test"],
977
+ ttlSeconds: 3600,
978
+ dryRun: !executeRequested,
979
+ },
65
980
  });
66
- return { available: true, dryRun: !executeRequested, identity: result.identity || result };
981
+ const identity = result && typeof result === "object"
982
+ ? (Object.prototype.hasOwnProperty.call(result, "identity") ? result.identity : (Object.prototype.hasOwnProperty.call(result, "value") ? result.value : result))
983
+ : result;
984
+ const retryTelemetry = result && typeof result === "object" && result.retryTelemetry
985
+ ? result.retryTelemetry
986
+ : null;
987
+ recordProviderBreakerSuccess(providerKey, providerScope);
988
+ return { available: true, requestId, dryRun: !executeRequested, identity, retryTelemetry };
67
989
  } catch (err) {
68
- return { available: false, reason: "AIdenID provisioning failed: " + err.message };
990
+ const message = "AIdenID provisioning failed: " + normalizeErrorMessage(err, "unknown error");
991
+ const retryable = isRetryableAidenidProvisionError(err);
992
+ let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
993
+ if (retryable) {
994
+ providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
995
+ } else if (!providerBreaker) {
996
+ providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
997
+ }
998
+ return buildUnavailableAuditResponse(requestId, "AIDENID_PROVISION_FAILED", message, {
999
+ retryable,
1000
+ retryTelemetry: err && typeof err === "object" && err.retryTelemetry ? err.retryTelemetry : null,
1001
+ providerBreaker,
1002
+ });
69
1003
  }
70
1004
  }
71
1005
 
@@ -78,14 +1012,22 @@ async function provisionTestIdentity(input) {
78
1012
  * - Temp script/context cleanup in finally block (not just success path)
79
1013
  */
80
1014
  async function authenticatedPageCheck(input) {
1015
+ const requestId = String(input.requestId || createAuditRequestId());
1016
+ const providerKey = AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET;
81
1017
  const url = input.url;
82
1018
  if (!url) throw new AuthAuditError("authenticated_page_check requires url");
83
1019
  const targetUrl = resolveAuthAuditTarget(url, input, "authenticated_page_check.target");
1020
+ const providerScope = deriveProviderBreakerScope({
1021
+ contextId: input.providerContextId || input.contextId || input.scopeId,
1022
+ targetUrl,
1023
+ });
84
1024
 
85
1025
  const loginUrlCandidate = input.loginUrl || targetUrl + "/login";
86
1026
  const loginUrl = resolveAuthAuditTarget(loginUrlCandidate, input, "authenticated_page_check.login");
1027
+ const allowAuthMutation = input.allowAuthMutation === true || process.env[AUTH_MUTATION_ALLOWED_ENV] === "1";
87
1028
 
88
1029
  try {
1030
+ enforceProviderBreaker(providerKey, providerScope, requestId);
89
1031
  const authContextJson = JSON.stringify({
90
1032
  email: input.email || "",
91
1033
  password: input.password || "",
@@ -99,6 +1041,7 @@ async function authenticatedPageCheck(input) {
99
1041
  ...buildScrubbedEnv(),
100
1042
  SL_AUDIT_TARGET_URL: targetUrl,
101
1043
  SL_AUDIT_LOGIN_URL: loginUrl,
1044
+ SL_AUDIT_ALLOW_AUTH_MUTATION: allowAuthMutation ? "1" : "0",
102
1045
  };
103
1046
 
104
1047
  const output = await runPlaywrightAuditScriptWithRetry(null, env, {
@@ -119,12 +1062,23 @@ async function authenticatedPageCheck(input) {
119
1062
  findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: targetUrl });
120
1063
  }
121
1064
  }
122
- return { available: true, method: "playwright", findings, ...result };
1065
+ findings.push(...evaluateAuthenticatedHeaderFindings(targetUrl, result.headers || {}, result.authSignals || {}));
1066
+ recordProviderBreakerSuccess(providerKey, providerScope);
1067
+ return { available: true, requestId, method: "playwright", mutationAllowed: allowAuthMutation, findings, ...result };
123
1068
  } catch (err) {
124
- if (err instanceof AuthAuditError) {
125
- return { available: false, reason: err.message };
1069
+ const code = err instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_PLAYWRIGHT_FAILED";
1070
+ const baseMessage = err instanceof AuthAuditError ? err.message : "Playwright auth audit failed: " + normalizeErrorMessage(err, "unknown error");
1071
+ const retryable = isRetryablePlaywrightExecutionError(err);
1072
+ let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
1073
+ if (retryable) {
1074
+ providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
1075
+ } else if (!providerBreaker) {
1076
+ providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
126
1077
  }
127
- return { available: false, reason: "Playwright auth audit failed: " + err.message };
1078
+ return buildUnavailableAuditResponse(requestId, code, baseMessage, {
1079
+ retryable,
1080
+ providerBreaker,
1081
+ });
128
1082
  }
129
1083
  }
130
1084
 
@@ -137,24 +1091,30 @@ const fs = require('node:fs');
137
1091
  (async () => {
138
1092
  const targetUrl = process.env.SL_AUDIT_TARGET_URL;
139
1093
  const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
1094
+ const allowAuthMutation = process.env.SL_AUDIT_ALLOW_AUTH_MUTATION === '1';
140
1095
  let context = {};
141
1096
  try {
142
- const stdinPayload = fs.readFileSync(0, 'utf-8');
1097
+ let stdinPayload = fs.readFileSync(0, 'utf-8');
143
1098
  if (stdinPayload) {
144
1099
  context = JSON.parse(stdinPayload) || {};
145
1100
  }
1101
+ stdinPayload = '';
146
1102
  } catch {
147
1103
  context = {};
148
1104
  }
149
1105
 
150
- const email = context.email || '';
151
- const password = context.password || '';
1106
+ let email = context.email || '';
1107
+ let password = context.password || '';
152
1108
  const emailSelector = context.emailField || 'input[type="email"]';
153
1109
  const passwordSelector = context.passwordField || 'input[type="password"]';
154
1110
  const submitSelector = context.submitSelector || 'button[type="submit"]';
1111
+ if (Object.prototype.hasOwnProperty.call(context, 'password')) delete context.password;
1112
+ if (Object.prototype.hasOwnProperty.call(context, 'token')) delete context.token;
1113
+ if (Object.prototype.hasOwnProperty.call(context, 'secret')) delete context.secret;
155
1114
 
156
1115
  let browser = null;
157
- const results = { authenticated: false, authSignals: {}, errors: [], cookies: [], headers: {}, domStats: {} };
1116
+ const results = { authenticated: false, authSignals: {}, errors: [], cookies: [], headers: {}, domStats: {}, executionFailed: false };
1117
+ results.authSignals.mutationAllowed = allowAuthMutation;
158
1118
  function normalizePath(value) {
159
1119
  const normalized = String(value || '/').replace(/\\/+$/, '');
160
1120
  return normalized || '/';
@@ -199,19 +1159,57 @@ const fs = require('node:fs');
199
1159
 
200
1160
  if (email && password && loginUrl) {
201
1161
  await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
202
- await page.fill(emailSelector, email);
203
- await page.fill(passwordSelector, password);
204
- await page.click(submitSelector);
205
- await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
206
- const currentUrl = page.url();
207
- const postCookies = await page.context().cookies();
208
- const urlChanged = didLeaveLoginSurface(currentUrl, loginUrl);
209
- const authCookiePresent = postCookies.some(c => /(?:^|[-_])(session|token|auth|jwt)(?:$|[-_])/i.test(c.name) && (c.httpOnly || c.secure));
210
- const loginFormVisible = await page.evaluate((emailSel, passwordSel) => (
211
- Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
212
- ), emailSelector, passwordSelector).catch(() => false);
213
- results.authSignals = { urlChanged, authCookiePresent, loginFormVisible };
214
- results.authenticated = !loginFormVisible && (urlChanged || authCookiePresent);
1162
+ if (allowAuthMutation) {
1163
+ await page.fill(emailSelector, email);
1164
+ await page.fill(passwordSelector, password);
1165
+ await page.click(submitSelector);
1166
+ let navigationError = null;
1167
+ await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch((err) => {
1168
+ navigationError = sanitizeErrorText(err && err.message ? err.message : 'navigation timeout');
1169
+ });
1170
+ if (navigationError) {
1171
+ results.authSignals.navigationTimeout = true;
1172
+ results.errors.push({ type: 'navigation', text: navigationError });
1173
+ }
1174
+ results.authSignals.mutationPerformed = true;
1175
+ } else {
1176
+ results.authSignals.mutationPerformed = false;
1177
+ }
1178
+ const authVerificationMaxAttempts = allowAuthMutation ? 3 : 1;
1179
+ let verificationAttemptsUsed = 0;
1180
+ let urlChanged = false;
1181
+ let authCookiePresent = false;
1182
+ let loginFormVisible = true;
1183
+ for (let verificationAttempt = 1; verificationAttempt <= authVerificationMaxAttempts; verificationAttempt += 1) {
1184
+ verificationAttemptsUsed = verificationAttempt;
1185
+ const currentUrl = page.url();
1186
+ const postCookies = await page.context().cookies();
1187
+ urlChanged = didLeaveLoginSurface(currentUrl, loginUrl);
1188
+ authCookiePresent = postCookies.some(c => /(?:^|[-_])(session|token|auth|jwt)(?:$|[-_])/i.test(c.name) && (c.httpOnly || c.secure));
1189
+ loginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1190
+ Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1191
+ ), emailSelector, passwordSelector).catch(() => false);
1192
+ const navigationSucceeded = results.authSignals.navigationTimeout !== true;
1193
+ results.authenticated = navigationSucceeded && !loginFormVisible && urlChanged && authCookiePresent;
1194
+ if (results.authenticated) {
1195
+ break;
1196
+ }
1197
+ if (verificationAttempt < authVerificationMaxAttempts) {
1198
+ await page.waitForTimeout(400 * verificationAttempt);
1199
+ }
1200
+ }
1201
+ results.authSignals = {
1202
+ urlChanged,
1203
+ authCookiePresent,
1204
+ loginFormVisible,
1205
+ authVerificationAttemptsUsed: verificationAttemptsUsed,
1206
+ authVerificationMaxAttempts,
1207
+ };
1208
+ results.authSignals.authVerificationRetried = verificationAttemptsUsed > 1;
1209
+ results.authSignals.mutationAllowed = allowAuthMutation;
1210
+ results.authSignals.mutationPerformed = allowAuthMutation ? true : false;
1211
+ email = '';
1212
+ password = '';
215
1213
  }
216
1214
 
217
1215
  const targetResponse = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
@@ -232,6 +1230,17 @@ const fs = require('node:fs');
232
1230
  }));
233
1231
 
234
1232
  const response = targetResponse || null;
1233
+ const targetLoginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1234
+ Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1235
+ ), emailSelector, passwordSelector).catch(() => true);
1236
+ const targetStatus = response ? response.status() : null;
1237
+ const targetStatusOk = typeof targetStatus === 'number' ? targetStatus < 400 : false;
1238
+ results.authSignals.targetLoginFormVisible = targetLoginFormVisible;
1239
+ results.authSignals.targetStatus = targetStatus;
1240
+ results.authSignals.targetStatusOk = targetStatusOk;
1241
+ if (results.authenticated) {
1242
+ results.authenticated = !targetLoginFormVisible && targetStatusOk;
1243
+ }
235
1244
  if (response) {
236
1245
  const h = response.headers();
237
1246
  results.headers = {
@@ -240,8 +1249,29 @@ const fs = require('node:fs');
240
1249
  'strict-transport-security': h['strict-transport-security'] || null,
241
1250
  'cache-control': h['cache-control'] || null,
242
1251
  };
1252
+ const normalizedFramePolicy = String(results.headers['x-frame-options'] || '').trim().toLowerCase();
1253
+ const headerPolicyBreaches = [];
1254
+ if (!results.headers['content-security-policy']) {
1255
+ headerPolicyBreaches.push('missing_content_security_policy');
1256
+ }
1257
+ if (String(targetUrl || '').startsWith('https://') && !results.headers['strict-transport-security']) {
1258
+ headerPolicyBreaches.push('missing_strict_transport_security');
1259
+ }
1260
+ if (!normalizedFramePolicy) {
1261
+ headerPolicyBreaches.push('missing_x_frame_options');
1262
+ } else if (!(normalizedFramePolicy === 'deny' || normalizedFramePolicy === 'sameorigin')) {
1263
+ headerPolicyBreaches.push('weak_x_frame_options');
1264
+ }
1265
+ results.authSignals.headerPolicyBreaches = headerPolicyBreaches;
1266
+ results.authSignals.headerPolicyPassed = headerPolicyBreaches.length === 0;
1267
+ results.authSignals.headerPolicyFailed = headerPolicyBreaches.length > 0;
1268
+ } else {
1269
+ results.authSignals.headerPolicyBreaches = ['target_response_unavailable'];
1270
+ results.authSignals.headerPolicyPassed = false;
1271
+ results.authSignals.headerPolicyFailed = true;
243
1272
  }
244
1273
  } catch (err) {
1274
+ results.executionFailed = true;
245
1275
  const text = sanitizeErrorText('Playwright error: ' + (err && err.message ? err.message : ''));
246
1276
  results.errors.push({ type: 'playwright', text });
247
1277
  } finally {
@@ -249,6 +1279,9 @@ const fs = require('node:fs');
249
1279
  if (browser) {
250
1280
  await browser.close().catch(() => {});
251
1281
  }
1282
+ if (results.executionFailed) {
1283
+ process.exitCode = 1;
1284
+ }
252
1285
  }
253
1286
  })();
254
1287
  `;
@@ -279,6 +1312,8 @@ const RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS = [
279
1312
  /\bconnection\b.*\b(?:reset|terminated|closed)\b/i,
280
1313
  ];
281
1314
  const AUTH_FLOW_LOCAL_TEST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
1315
+ const DEFAULT_APPROVED_AUTH_AUDIT_HOSTS = new Set(["example.com", "www.example.com"]);
1316
+ const AUTH_AUDIT_ALLOWED_HOSTS_ENV = "SENTINELAYER_AUTH_AUDIT_ALLOWED_HOSTS";
282
1317
 
283
1318
  function computePlaywrightBackoffMs(attempt, baseBackoffMs = AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS) {
284
1319
  const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS);
@@ -330,13 +1365,35 @@ export async function runPlaywrightAuditScriptWithRetry(scriptPath, env, options
330
1365
  const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
331
1366
  ? Math.trunc(options.baseBackoffMs)
332
1367
  : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS;
1368
+ const totalBudgetMs = Number.isFinite(options.totalBudgetMs) && options.totalBudgetMs > 0
1369
+ ? Math.trunc(options.totalBudgetMs)
1370
+ : AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS;
1371
+ const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
1372
+ ? Math.trunc(options.minAttemptTimeoutMs)
1373
+ : AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS;
333
1374
  const execute = typeof options.exec === "function" ? options.exec : execFileSync;
1375
+ const now = typeof options.now === "function" ? options.now : Date.now;
1376
+ const sleepFn = typeof options.sleep === "function" ? options.sleep : sleep;
1377
+ const retryWindowStartedAt = now();
334
1378
 
335
1379
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
1380
+ const elapsedMs = now() - retryWindowStartedAt;
1381
+ const remainingBudgetMs = totalBudgetMs - elapsedMs;
1382
+ if (remainingBudgetMs <= 0) {
1383
+ throw new AuthAuditError(
1384
+ `Playwright auth audit failed: retry budget exhausted after ${attempt} attempt(s) over ${totalBudgetMs}ms`
1385
+ );
1386
+ }
1387
+ if (remainingBudgetMs < minAttemptTimeoutMs) {
1388
+ throw new AuthAuditError(
1389
+ `Playwright auth audit failed: remaining retry budget (${remainingBudgetMs}ms) below minimum attempt timeout (${minAttemptTimeoutMs}ms)`
1390
+ );
1391
+ }
1392
+ const attemptTimeoutMs = Math.max(minAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
336
1393
  try {
337
1394
  return execute(process.execPath, runArgs, {
338
1395
  encoding: "utf-8",
339
- timeout: timeoutMs,
1396
+ timeout: attemptTimeoutMs,
340
1397
  stdio: ["pipe", "pipe", "pipe"],
341
1398
  env,
342
1399
  input: stdinPayload,
@@ -347,7 +1404,14 @@ export async function runPlaywrightAuditScriptWithRetry(scriptPath, env, options
347
1404
  throw new AuthAuditError(`Playwright auth audit failed after ${attempt + 1} attempt(s): ${reason}`);
348
1405
  }
349
1406
  }
350
- await sleep(computePlaywrightBackoffMs(attempt, baseBackoffMs));
1407
+ const backoffMs = computePlaywrightBackoffMs(attempt, baseBackoffMs);
1408
+ const remainingAfterAttemptMs = totalBudgetMs - (now() - retryWindowStartedAt);
1409
+ if (remainingAfterAttemptMs <= 0) {
1410
+ throw new AuthAuditError(
1411
+ `Playwright auth audit failed: retry budget exhausted after ${attempt + 1} attempt(s) over ${totalBudgetMs}ms`
1412
+ );
1413
+ }
1414
+ await sleepFn(Math.min(backoffMs, remainingAfterAttemptMs));
351
1415
  }
352
1416
 
353
1417
  throw new AuthAuditError("Playwright auth audit failed after retry budget was exhausted");
@@ -401,6 +1465,67 @@ function isAllowedHttpAuthFlowTarget(urlObject) {
401
1465
  return AUTH_FLOW_LOCAL_TEST_HOSTS.has(urlObject.hostname);
402
1466
  }
403
1467
 
1468
+ function isUnapprovedAuthAuditBypassEnabled() {
1469
+ if (process.env.NODE_ENV === "test") {
1470
+ return true;
1471
+ }
1472
+ if (process.env.SENTINELAYER_ALLOW_UNAPPROVED_AUTH_AUDIT_TARGETS === "1") {
1473
+ return true;
1474
+ }
1475
+ return false;
1476
+ }
1477
+
1478
+ function normalizeHostEntry(value) {
1479
+ return String(value || "").trim().toLowerCase();
1480
+ }
1481
+
1482
+ function resolveApprovedAuthAuditHosts(input) {
1483
+ const approvedHosts = new Set(DEFAULT_APPROVED_AUTH_AUDIT_HOSTS);
1484
+ const hostLists = [];
1485
+ if (Array.isArray(input?.approvedHosts)) {
1486
+ hostLists.push(input.approvedHosts);
1487
+ }
1488
+ if (Array.isArray(input?.approvedHostnames)) {
1489
+ hostLists.push(input.approvedHostnames);
1490
+ }
1491
+ const envHosts = String(process.env[AUTH_AUDIT_ALLOWED_HOSTS_ENV] || "")
1492
+ .split(",")
1493
+ .map((entry) => normalizeHostEntry(entry))
1494
+ .filter(Boolean);
1495
+ hostLists.push(envHosts);
1496
+ for (const list of hostLists) {
1497
+ for (const host of list) {
1498
+ const normalized = normalizeHostEntry(host);
1499
+ if (normalized) {
1500
+ approvedHosts.add(normalized);
1501
+ }
1502
+ }
1503
+ }
1504
+ return approvedHosts;
1505
+ }
1506
+
1507
+ function assertApprovedAuthAuditTarget(parsed, input, operation) {
1508
+ if (isUnapprovedAuthAuditBypassEnabled()) {
1509
+ return parsed;
1510
+ }
1511
+ const allowLiveProvision = input?.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
1512
+ const approvedTargetId = String(input?.approvedTargetId || "").trim();
1513
+ if (!allowLiveProvision || !approvedTargetId) {
1514
+ throw new AuthAuditError(
1515
+ `Live ${operation} requires allowProvisioning=true and approvedTargetId to prevent unapproved outbound probing.`
1516
+ );
1517
+ }
1518
+ const approvedHosts = resolveApprovedAuthAuditHosts(input);
1519
+ const normalizedHost = normalizeHostEntry(parsed.hostname);
1520
+ if (!approvedHosts.has(normalizedHost)) {
1521
+ throw new AuthAuditError(
1522
+ `Blocked unapproved auth audit host for ${operation}: ${normalizedHost}. ` +
1523
+ `Add host to approvedHosts or ${AUTH_AUDIT_ALLOWED_HOSTS_ENV}.`
1524
+ );
1525
+ }
1526
+ return parsed;
1527
+ }
1528
+
404
1529
  function assertSecureAuthFlowTarget(urlValue, options = {}) {
405
1530
  let parsed;
406
1531
  try {
@@ -411,6 +1536,7 @@ function assertSecureAuthFlowTarget(urlValue, options = {}) {
411
1536
  } catch (error) {
412
1537
  throw new AuthAuditError(error.message);
413
1538
  }
1539
+ assertApprovedAuthAuditTarget(parsed, options.auditInput || {}, "check_auth_flow_security");
414
1540
  if (!isAllowedHttpAuthFlowTarget(parsed)) {
415
1541
  throw new AuthAuditError(
416
1542
  `HTTPS downgrade detected in auth flow target: ${parsed.toString()}`
@@ -420,12 +1546,18 @@ function assertSecureAuthFlowTarget(urlValue, options = {}) {
420
1546
  }
421
1547
 
422
1548
  async function fetchWithTimeout(url, options, timeoutMs) {
1549
+ const callerSignal = isAbortSignalLike(options?.signal) ? options.signal : undefined;
423
1550
  const controller = new AbortController();
424
1551
  const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
1552
+ const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
425
1553
  try {
426
- return await fetch(url, { ...options, signal: controller.signal });
1554
+ return await fetch(url, {
1555
+ ...options,
1556
+ ...(compositeSignal ? { signal: compositeSignal } : {}),
1557
+ });
427
1558
  } finally {
428
1559
  clearTimeout(timeoutHandle);
1560
+ cleanupCompositeSignal();
429
1561
  }
430
1562
  }
431
1563
 
@@ -459,14 +1591,15 @@ async function fetchLoginResponseWithRetry(currentUrl) {
459
1591
  }
460
1592
 
461
1593
  async function checkAuthFlowSecurity(input) {
1594
+ const requestId = String(input.requestId || createAuditRequestId());
462
1595
  const loginUrlCandidate = input.loginUrl || input.url;
463
1596
  if (!loginUrlCandidate) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
464
1597
  const allowPrivateTargets = input.allowPrivateTargets === true;
465
- const loginUrl = assertSecureAuthFlowTarget(loginUrlCandidate, { allowPrivateTargets }).toString();
1598
+ const loginUrl = assertSecureAuthFlowTarget(loginUrlCandidate, { allowPrivateTargets, auditInput: input }).toString();
466
1599
 
467
1600
  const findings = [];
468
1601
  try {
469
- const { headers, finalUrl, crossOriginRedirect } = await fetchLoginHeaders(loginUrl, { allowPrivateTargets });
1602
+ const { headers, finalUrl, crossOriginRedirect } = await fetchLoginHeaders(loginUrl, { allowPrivateTargets, auditInput: input });
470
1603
 
471
1604
  if (crossOriginRedirect) {
472
1605
  findings.push({
@@ -497,9 +1630,17 @@ async function checkAuthFlowSecurity(input) {
497
1630
  file: loginUrl,
498
1631
  });
499
1632
  }
500
- return { available: false, loginUrl, findings, reason: "auth flow check failed: " + err.message };
1633
+ return {
1634
+ ...buildUnavailableAuditResponse(
1635
+ requestId,
1636
+ "AUTH_FLOW_CHECK_FAILED",
1637
+ "auth flow check failed: " + normalizeErrorMessage(err, "unknown error")
1638
+ ),
1639
+ loginUrl,
1640
+ findings,
1641
+ };
501
1642
  }
502
- return { available: true, loginUrl, findings };
1643
+ return { available: true, requestId, loginUrl, findings };
503
1644
  }
504
1645
 
505
1646
  async function fetchLoginHeaders(loginUrl, options = {}) {
@@ -546,6 +1687,7 @@ function resolveAuthAuditTarget(urlValue, input, operation) {
546
1687
  operation,
547
1688
  allowPrivateTargets: input.allowPrivateTargets === true,
548
1689
  });
1690
+ assertApprovedAuthAuditTarget(parsed, input, operation);
549
1691
  return parsed.toString();
550
1692
  } catch (error) {
551
1693
  throw new AuthAuditError(error.message);