hilos-agent 0.10.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queue.mjs CHANGED
@@ -71,21 +71,37 @@ export function looksLikeCancel(text) {
71
71
  * (optional) dedupes against the active + queued jobs.
72
72
  * - cancelActive(reason) aborts ONLY the in-flight job's AbortSignal; queued jobs
73
73
  * are untouched.
74
+ * - shutdown(reason) closes intake, discards queued jobs, aborts active work,
75
+ * and resolves only after that work has completed its teardown.
74
76
  * - idle() resolves when nothing is active or queued (used to drain on --once).
75
77
  */
76
78
  /**
77
79
  * @param {{
78
80
  * runJob: (job: any, ctx: { signal: AbortSignal }) => Promise<void> | void,
81
+ * afterJob?: (job: any) => Promise<boolean | void> | boolean | void,
82
+ * afterJobRetryDelay?: () => Promise<void>,
79
83
  * concurrency?: number,
80
84
  * }} [opts]
81
85
  */
82
86
  export function createQueue(opts = {}) {
83
- const { runJob, concurrency = 1 } = opts;
87
+ const {
88
+ runJob,
89
+ afterJob,
90
+ afterJobRetryDelay = () => new Promise((resolve) => setTimeout(resolve, 1000)),
91
+ concurrency = 1,
92
+ } = opts;
84
93
  void concurrency; // single-slot in v1; documented in the ticket.
85
94
  const queued = [];
86
95
  let active = null; // { job, controller }
87
96
  let draining = false;
97
+ let closed = false;
98
+ let betweenJobs = false;
88
99
  let idleResolvers = [];
100
+ let resolveClosed;
101
+ const closedPromise = new Promise((resolve) => {
102
+ resolveClosed = resolve;
103
+ });
104
+ const CLOSED = Symbol("queue-closed");
89
105
 
90
106
  function has(key) {
91
107
  if (key == null) return false;
@@ -99,7 +115,7 @@ export function createQueue(opts = {}) {
99
115
  async function drain() {
100
116
  if (draining) return;
101
117
  draining = true;
102
- while (queued.length) {
118
+ while (!closed && queued.length) {
103
119
  const job = queued.shift();
104
120
  const controller = new AbortController();
105
121
  active = { job, controller };
@@ -108,6 +124,30 @@ export function createQueue(opts = {}) {
108
124
  } catch {
109
125
  // runJob is expected to handle its own errors; never break the loop.
110
126
  }
127
+ if (afterJob) {
128
+ // A completed handler may have left durable ownership state behind.
129
+ // Keep this job active and fence the next FIFO entry until the hook
130
+ // proves that state safe. Intake can continue appending behind the
131
+ // fence, so a poll response is never acknowledged and then dropped.
132
+ betweenJobs = true;
133
+ let safe = false;
134
+ while (!safe && !closed) {
135
+ const check = Promise.resolve()
136
+ .then(() => afterJob(job))
137
+ .then((result) => result !== false)
138
+ .catch(() => false);
139
+ const outcome = await Promise.race([
140
+ check,
141
+ closedPromise.then(() => CLOSED),
142
+ ]);
143
+ if (outcome === CLOSED) break;
144
+ safe = outcome;
145
+ if (!safe && !closed) {
146
+ await Promise.race([afterJobRetryDelay(), closedPromise]);
147
+ }
148
+ }
149
+ betweenJobs = false;
150
+ }
111
151
  active = null;
112
152
  }
113
153
  draining = false;
@@ -117,6 +157,15 @@ export function createQueue(opts = {}) {
117
157
  }
118
158
 
119
159
  function enqueue(job) {
160
+ if (closed) {
161
+ return {
162
+ accepted: false,
163
+ deduped: false,
164
+ shutdown: true,
165
+ position: 0,
166
+ startedImmediately: false,
167
+ };
168
+ }
120
169
  if (job && job.key != null && has(job.key)) {
121
170
  return { accepted: false, deduped: true, position: 0, startedImmediately: false };
122
171
  }
@@ -140,10 +189,28 @@ export function createQueue(opts = {}) {
140
189
  function activeCount() {
141
190
  return active ? 1 : 0;
142
191
  }
192
+ function blocked() {
193
+ return betweenJobs;
194
+ }
143
195
  function idle() {
144
196
  if (!active && queued.length === 0) return Promise.resolve();
145
197
  return new Promise((r) => idleResolvers.push(r));
146
198
  }
147
199
 
148
- return { enqueue, cancelActive, has, size, active: activeCount, idle };
200
+ function shutdown(reason) {
201
+ // Set the fence before abort dispatch: an abort listener (or any other
202
+ // synchronous callback) can try to enqueue, and must observe a closed
203
+ // queue. Clearing pending jobs in the same turn ensures drain() can never
204
+ // advance to one after the active handler's teardown completes.
205
+ closed = true;
206
+ resolveClosed();
207
+ queued.splice(0, queued.length);
208
+ if (active) {
209
+ active.cancelled = true;
210
+ active.controller.abort(reason || "queue shutdown");
211
+ }
212
+ return idle();
213
+ }
214
+
215
+ return { enqueue, cancelActive, shutdown, has, size, active: activeCount, blocked, idle };
149
216
  }
package/src/redact.mjs CHANGED
@@ -3,8 +3,14 @@
3
3
  // redact CLI output before it is returned, logged, or attached to a report.
4
4
 
5
5
  const PEM_BLOCK = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*?-----END [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----/g;
6
+ // A bounded caller may intentionally provide only the front of a long log.
7
+ // Once a private-key header appears, fail closed even if its END marker fell
8
+ // beyond that caller's scan window.
9
+ const UNTERMINATED_PEM = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*$/g;
6
10
  const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]{6,})?\b/g;
11
+ const UNTERMINATED_JWT = /\beyJ[A-Za-z0-9_-]{20,}(?:\.[A-Za-z0-9_-]*)?$/g;
7
12
  const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']+)@/gi;
13
+ const UNTERMINATED_URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']{8,})$/gi;
8
14
  const PROVIDER_TOKENS = new RegExp(
9
15
  [
10
16
  "sk-ant-[A-Za-z0-9_-]{8,}",
@@ -25,23 +31,370 @@ const PROVIDER_TOKENS = new RegExp(
25
31
  ].join("|"),
26
32
  "g",
27
33
  );
28
- const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]{12,}/g;
29
- const ASSIGNMENT =
30
- /((?:api[_-]?key|access[_-]?key|secret[_-]?access[_-]?key|client[_-]?secret|private[_-]?key|service[_-]?role[_-]?key|auth[_-]?token|refresh[_-]?token|session[_-]?token|apikey|token|secret|password|passwd|credential)["']?\s*[:=]+\s*["']?)(?!\[redacted:)([A-Za-z0-9_~.+/-]{8,})/gi;
34
+ const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]+/g;
35
+ const SENSITIVE_HEADER = /(\b(?:proxy-authorization|authorization|set-cookie|cookie)\s*[:=])([^\r\n]*)/gi;
36
+ // Structured keys canonicalize punctuation before classification. Accept a
37
+ // bounded run of the same non-alphanumeric separators in serialized/form keys
38
+ // (`api[key]`, `api@key`) without allowing the scan to cross an assignment,
39
+ // quote, or physical line boundary.
40
+ const ASSIGNMENT_KEY_SEPARATOR =
41
+ "[\\t !#$%&()*+,./?@\\[\\]\\\\^_`{|}~-]{0,8}";
42
+ const GENERIC_SENSITIVE_ASSIGNMENT_KEY = [
43
+ `api${ASSIGNMENT_KEY_SEPARATOR}(?:keys?|tokens?)`,
44
+ `access${ASSIGNMENT_KEY_SEPARATOR}(?:keys?(?:${ASSIGNMENT_KEY_SEPARATOR}id)?|tokens?)`,
45
+ `secret(?:(?:${ASSIGNMENT_KEY_SEPARATOR}access)?${ASSIGNMENT_KEY_SEPARATOR}keys?)?`,
46
+ `client${ASSIGNMENT_KEY_SEPARATOR}secret`,
47
+ `private${ASSIGNMENT_KEY_SEPARATOR}key`,
48
+ `service${ASSIGNMENT_KEY_SEPARATOR}role${ASSIGNMENT_KEY_SEPARATOR}key`,
49
+ `(?:auth|refresh|session|oauth|id)${ASSIGNMENT_KEY_SEPARATOR}tokens?`,
50
+ "tokens?",
51
+ `authorization(?:${ASSIGNMENT_KEY_SEPARATOR}header)?`,
52
+ `proxy${ASSIGNMENT_KEY_SEPARATOR}authorization`,
53
+ `cookie(?:${ASSIGNMENT_KEY_SEPARATOR}header)?`,
54
+ `set${ASSIGNMENT_KEY_SEPARATOR}cookie`,
55
+ "password",
56
+ "passwd",
57
+ "credentials?",
58
+ ].join("|");
59
+ // Once a credential root is identified, consume a bounded serializer suffix
60
+ // as one character-class run. Digits, punctuation, and the letters in `value`
61
+ // cover arbitrary `[n].value` depth without nested regex repetition. Excluding
62
+ // other letters prevents prose such as `token is [redacted:jwt]` from being
63
+ // reinterpreted as one enormous assignment key.
64
+ const SENSITIVE_ASSIGNMENT_SUFFIX =
65
+ "[ \\t._/@\\[\\](){}0-9vVaAlLuUeE-]{0,160}";
66
+ const SENSITIVE_ASSIGNMENT_KEY =
67
+ `(?:${GENERIC_SENSITIVE_ASSIGNMENT_KEY})${SENSITIVE_ASSIGNMENT_SUFFIX}`;
68
+ const EXCESSIVE_ESCAPE_RUN = /\\{33}/;
69
+ const SENSITIVE_CANONICAL_KEY_SUFFIX = /(?:api_?(?:keys?|tokens?)|access_?(?:keys?(?:_?id)?|tokens?)|secret(?:_?access_?keys?|_?keys?)?|client_?secret|private_?key|service_?role_?key|(?:auth|refresh|session|oauth|id)_?tokens?|tokens?|authorization(?:_?header)?|proxy_?authorization|cookie(?:_?header)?|set_?cookie|password|passwd|credentials?)(?:(?:_value)|(?:_\d{1,6}))*$/;
70
+ // Start of a quoted credential assignment. The value is scanned below instead
71
+ // of captured by a regular expression: an escaped quote inside a password is
72
+ // content, not the closing delimiter, and nested serialized JSON represents
73
+ // each delimiter with the same run of backslashes.
74
+ const QUOTED_ASSIGNMENT_START = new RegExp(
75
+ `((?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*[:=]+[ \\t]*)(\\$?)(\\\\{0,32})(["'])`,
76
+ "gi",
77
+ );
78
+ const YAML_MULTILINE_ASSIGNMENT_LINE = new RegExp(
79
+ `^(([ \\t]*(?:-[ \\t]+)?)(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
80
+ "gim",
81
+ );
82
+ const YAML_EXPLICIT_MULTILINE_ASSIGNMENT_LINE = new RegExp(
83
+ `^(([ \\t]*(?:-[ \\t]+)?)\\?[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
84
+ "gim",
85
+ );
86
+ const YAML_SPLIT_EXPLICIT_MULTILINE_ASSIGNMENT_LINE = new RegExp(
87
+ `^(([ \\t]*(?:-[ \\t]+)?)\\?[ \\t]*(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]*)*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*(?:[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]*)+(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*)*[ \\t]+(?:(?:![^ \\t\\r\\n]+|&[^ \\t\\r\\n]+)[ \\t]+)*(?:(?:\\\\{1,32})?["']?${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["']?)[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n(?:[ \\t]*(?:#[^\\r\\n]*)?\\r?\\n)*[ \\t]*:[ \\t]*)([^\\r\\n]*)(\\r?\\n|$)`,
88
+ "gim",
89
+ );
90
+ const SHELL_MULTILINE_ASSIGNMENT = new RegExp(
91
+ `(${SENSITIVE_ASSIGNMENT_KEY}[ \\t]*[:=]+[ \\t]*)(?:\\$?\\(|[<>]\\(|\\$\\{|\\$\\[|\\x60)[\\s\\S]*`,
92
+ "gi",
93
+ );
94
+ const JSON_UNQUOTED_ASSIGNMENT = new RegExp(
95
+ `(((?:\\\\{1,32})?["']${SENSITIVE_ASSIGNMENT_KEY}(?:\\\\{1,32})?["'])[ \\t]*[:=]+[ \\t]*)(?!(?:\\\\{1,32})?["'])([^,;}\\]\\r\\n]+)`,
96
+ "gi",
97
+ );
98
+ const UNQUOTED_ASSIGNMENT = new RegExp(
99
+ `(${SENSITIVE_ASSIGNMENT_KEY}[ \\t]*[:=]+[ \\t]*)(?!(?:\\$?)(?:\\\\{1,32})?["'])((?:\\\\(?:\\r?\\n|;)|[^;\\r\\n])+)`,
100
+ "gi",
101
+ );
102
+
103
+ function redactAssignment(match, prefix, value, suffix = "", allowSyntaxLiterals = true) {
104
+ const normalized = value.trim();
105
+ if (
106
+ !normalized ||
107
+ /^\[redacted:[^\]]+\]$/i.test(normalized) ||
108
+ (allowSyntaxLiterals && /^(?:null|true|false|undefined|bearer)$/i.test(normalized)) ||
109
+ (allowSyntaxLiterals && /^(?:null|true|false|undefined)[ \t]+\\{33,}$/i.test(normalized)) ||
110
+ /^bearer\s+\[redacted:[^\]]+\]["']?$/i.test(normalized)
111
+ ) {
112
+ return match;
113
+ }
114
+ return `${prefix}[redacted:value]${suffix}`;
115
+ }
116
+
117
+ function closingQuote(text, start, quote, delimiterSlashes) {
118
+ for (let i = start; i < text.length; i += 1) {
119
+ if (text[i] !== quote) continue;
120
+ let slashCount = 0;
121
+ for (let j = i - 1; j >= start && text[j] === "\\"; j -= 1) slashCount += 1;
122
+ if (delimiterSlashes === 0 ? slashCount % 2 === 0 : slashCount === delimiterSlashes) {
123
+ return { quoteIndex: i, tokenStart: i - slashCount };
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Redact escape-aware quoted assignments without ever exposing a suffix. */
130
+ function redactQuotedAssignments(text) {
131
+ QUOTED_ASSIGNMENT_START.lastIndex = 0;
132
+ let cursor = 0;
133
+ let out = "";
134
+ let match;
135
+ while ((match = QUOTED_ASSIGNMENT_START.exec(text)) !== null) {
136
+ const prefix = match[1];
137
+ const sigil = match[2];
138
+ const slashes = match[3];
139
+ const quote = match[4];
140
+ const valueStart = QUOTED_ASSIGNMENT_START.lastIndex;
141
+ const closing = closingQuote(text, valueStart, quote, slashes.length);
142
+ // A malformed/unterminated credential value fails closed through the rest
143
+ // of the bounded input. Stopping at a newline would expose every following
144
+ // continuation line while still claiming redaction was applied.
145
+ const valueEnd = closing ? closing.tokenStart : text.length;
146
+ const quotedEnd = closing ? closing.quoteIndex + 1 : text.length;
147
+ let segmentEnd = quotedEnd;
148
+ if (closing) {
149
+ // Shell assignments may concatenate quoted and unquoted word fragments:
150
+ // PASSWORD="secret"tail is one credential, not a secret plus safe text.
151
+ // Consume the attached fragment through the assignment terminator.
152
+ const quotedKey = /(?:\\+)?["'][^"'\r\n]+(?:\\+)?["']\s*[:=]+\s*$/.test(prefix);
153
+ const terminator = quotedKey ? /[\s,;}\]]/ : /[\s;]/;
154
+ while (segmentEnd < text.length) {
155
+ if (!quotedKey && text[segmentEnd] === "\\" && segmentEnd + 1 < text.length) {
156
+ segmentEnd += text[segmentEnd + 1] === "\r" && text[segmentEnd + 2] === "\n" ? 3 : 2;
157
+ continue;
158
+ }
159
+ if (terminator.test(text[segmentEnd])) break;
160
+ segmentEnd += 1;
161
+ }
162
+ }
163
+ const value = text.slice(valueStart, valueEnd) + text.slice(quotedEnd, segmentEnd);
164
+ const opener = `${sigil}${slashes}${quote}`;
165
+ const suffix = closing ? `${slashes}${quote}` : "";
166
+ const whole = text.slice(match.index, segmentEnd);
167
+
168
+ out += text.slice(cursor, match.index);
169
+ out += redactAssignment(whole, `${prefix}${opener}`, value, suffix, false);
170
+ cursor = segmentEnd;
171
+ QUOTED_ASSIGNMENT_START.lastIndex = segmentEnd;
172
+ }
173
+ return out + text.slice(cursor);
174
+ }
175
+
176
+ function redactYamlMatches(text, assignmentLine, redactSameLineValue = false) {
177
+ assignmentLine.lastIndex = 0;
178
+ let cursor = 0;
179
+ let out = "";
180
+ let match;
181
+ while ((match = assignmentLine.exec(text)) !== null) {
182
+ const prefix = match[1];
183
+ const keyIndent = match[2].length;
184
+ const firstValue = match[3];
185
+ const firstLineBreak = match[4];
186
+ const uncommentedFirstValue = firstValue
187
+ .replace(/(?:^|[ \t]+)#.*$/, "")
188
+ .trim();
189
+ const blockScalar =
190
+ /^(?:(?:![^ \t]+|&[^ \t]+)[ \t]+)*[|>][+\-0-9]*(?:[ \t]+#.*)?$/.test(
191
+ firstValue.trim(),
192
+ );
193
+ const allowsIndentlessSequence =
194
+ /^(?:(?:![^ \t]+|&[^ \t]+)[ \t]*)*$/.test(uncommentedFirstValue);
195
+ const hasSensitiveSameLineValue =
196
+ redactSameLineValue &&
197
+ Boolean(uncommentedFirstValue) &&
198
+ !/^(?:null|true|false|undefined)$/i.test(uncommentedFirstValue);
199
+ if (!firstLineBreak && !hasSensitiveSameLineValue) continue;
200
+
201
+ let scan = assignmentLine.lastIndex;
202
+ let lastConsumed = scan;
203
+ let sawIndentedContent = false;
204
+ while (scan < text.length) {
205
+ const newline = text.indexOf("\n", scan);
206
+ const next = newline === -1 ? text.length : newline + 1;
207
+ const rawLine = text.slice(scan, newline === -1 ? text.length : newline);
208
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
209
+ if (line.trim().length === 0 || (!blockScalar && /^[ \t]*#/.test(line))) {
210
+ scan = next;
211
+ continue;
212
+ }
213
+ const indentation = /^[ \t]*/.exec(line)?.[0].length ?? 0;
214
+ const indentlessSequence =
215
+ allowsIndentlessSequence &&
216
+ indentation === keyIndent &&
217
+ /^-(?:[ \t]|$)/.test(line.slice(indentation));
218
+ if (indentation <= keyIndent && !indentlessSequence) break;
219
+ sawIndentedContent = true;
220
+ lastConsumed = next;
221
+ scan = next;
222
+ }
223
+ if (!sawIndentedContent && !hasSensitiveSameLineValue) continue;
224
+
225
+ out += text.slice(cursor, match.index);
226
+ out += `${prefix}${/[ \\t]$/.test(prefix) ? "" : " "}[redacted:value]`;
227
+ if (lastConsumed < text.length) out += firstLineBreak;
228
+ cursor = lastConsumed;
229
+ assignmentLine.lastIndex = lastConsumed;
230
+ }
231
+ return out + text.slice(cursor);
232
+ }
233
+
234
+ /** Redact YAML scalar bodies while preserving keys at the same or lower indent. */
235
+ function redactYamlMultilineAssignments(text) {
236
+ return redactYamlMatches(
237
+ redactYamlMatches(
238
+ redactYamlMatches(text, YAML_SPLIT_EXPLICIT_MULTILINE_ASSIGNMENT_LINE, true),
239
+ YAML_EXPLICIT_MULTILINE_ASSIGNMENT_LINE,
240
+ true,
241
+ ),
242
+ YAML_MULTILINE_ASSIGNMENT_LINE,
243
+ );
244
+ }
245
+
246
+ function decodeJsonKeyEscapes(value) {
247
+ const simple = {
248
+ b: "\b",
249
+ f: "\f",
250
+ n: "\n",
251
+ r: "\r",
252
+ t: "\t",
253
+ "\"": "\"",
254
+ "\\": "\\",
255
+ "/": "/",
256
+ };
257
+ const text = String(value || "");
258
+ if (!text.includes("\\")) return text;
259
+ const output = [];
260
+ let cursor = 0;
261
+ let index = 0;
262
+ while (index < text.length) {
263
+ if (text[index] !== "\\") {
264
+ index += 1;
265
+ continue;
266
+ }
267
+ let escapeAt = index;
268
+ while (escapeAt < text.length && text[escapeAt] === "\\") escapeAt += 1;
269
+ const escape = text[escapeAt];
270
+ let replacement;
271
+ let consumed = escapeAt + 1;
272
+ if (
273
+ escape?.toLowerCase() === "u" &&
274
+ /^[0-9a-f]{4}$/i.test(text.slice(escapeAt + 1, escapeAt + 5))
275
+ ) {
276
+ replacement = String.fromCharCode(
277
+ Number.parseInt(text.slice(escapeAt + 1, escapeAt + 5), 16),
278
+ );
279
+ consumed = escapeAt + 5;
280
+ } else if (Object.hasOwn(simple, escape)) {
281
+ replacement = simple[escape];
282
+ }
283
+ if (replacement === undefined) {
284
+ index = escapeAt + (escape ? 1 : 0);
285
+ continue;
286
+ }
287
+ output.push(text.slice(cursor, index), replacement);
288
+ cursor = consumed;
289
+ index = consumed;
290
+ }
291
+ if (output.length === 0) return text;
292
+ output.push(text.slice(cursor));
293
+ return output.join("");
294
+ }
295
+
296
+ function isSensitiveAssignmentCandidate(value) {
297
+ const canonical = decodeJsonKeyEscapes(value)
298
+ .slice(-200)
299
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
300
+ .replace(/([A-Za-z])(\d)/g, "$1_$2")
301
+ .replace(/(\d)([A-Za-z])/g, "$1_$2")
302
+ .replace(/([A-Za-z0-9])(value)/gi, "$1_$2")
303
+ .replace(/[^A-Za-z0-9]+/g, "_")
304
+ .replace(/^_+|_+$/g, "")
305
+ .toLowerCase();
306
+ return SENSITIVE_CANONICAL_KEY_SUFFIX.test(canonical);
307
+ }
308
+
309
+ function isBenignAssignmentValue(value) {
310
+ const normalized = value.trimStart().replace(/^\$?[\\"']+/, "");
311
+ return /^(?:null|true|false|undefined)(?=$|[\s,;}\]])/i.test(normalized) ||
312
+ /^\[redacted:[^\]]+\][\\"']*(?=$|[\s,;}\]])/i.test(normalized) ||
313
+ /^bearer\s+\[redacted:[^\]]+\][\\"']*(?=$|[\s,;}\]])/i.test(normalized);
314
+ }
315
+
316
+ /** Find a credential-shaped key before any plausible assignment delimiter.
317
+ * Manual bounded suffix classification keeps arbitrary punctuation aligned
318
+ * with structured object keys without a separator-depth regex bypass. */
319
+ function hasSensitiveAssignment(text, allowEmptyValue = false) {
320
+ let lineStart = 0;
321
+ while (lineStart <= text.length) {
322
+ let lineEnd = lineStart;
323
+ while (lineEnd < text.length && !/[\r\n\u2028\u2029\v\f]/.test(text[lineEnd])) {
324
+ lineEnd += 1;
325
+ }
326
+ for (let delimiter = lineStart; delimiter < lineEnd; delimiter += 1) {
327
+ if (text[delimiter] !== ":" && text[delimiter] !== "=") continue;
328
+ let end = delimiter;
329
+ while (end > lineStart && /[ \t]/.test(text[end - 1])) end -= 1;
330
+ const keyLength = end - lineStart;
331
+ const candidate = text.slice(Math.max(lineStart, end - 200), end);
332
+ if (keyLength <= 160 && !isSensitiveAssignmentCandidate(candidate)) continue;
333
+ let valueStart = delimiter + 1;
334
+ while (valueStart < lineEnd && /[ \t]/.test(text[valueStart])) valueStart += 1;
335
+ const value = text.slice(valueStart, Math.min(lineEnd, valueStart + 256));
336
+ if (isBenignAssignmentValue(value)) continue;
337
+ if (value || allowEmptyValue) return true;
338
+ }
339
+ if (lineEnd >= text.length) break;
340
+ lineStart = text[lineEnd] === "\r" && text[lineEnd + 1] === "\n"
341
+ ? lineEnd + 2
342
+ : lineEnd + 1;
343
+ }
344
+ return false;
345
+ }
346
+
347
+ function hasDeeplyEscapedSensitiveAssignment(text) {
348
+ if (!EXCESSIVE_ESCAPE_RUN.test(text)) return false;
349
+ const collapsed = decodeJsonKeyEscapes(text).replace(/[\\"']/g, "");
350
+ return hasSensitiveAssignment(collapsed, true);
351
+ }
31
352
 
32
353
  /** Replace credential-shaped content with typed markers. Idempotent. */
33
354
  export function redactSecrets(text) {
34
355
  if (!text) return text;
35
- return text
356
+ if (hasDeeplyEscapedSensitiveAssignment(text)) {
357
+ return "[redacted:deeply-escaped]";
358
+ }
359
+ // Detect multiline value syntax before the header pass. Otherwise a key
360
+ // such as `authorization: |` is rewritten as a one-line header first and the
361
+ // block marker disappears while its secret body remains.
362
+ const structuralRedacted = redactYamlMultilineAssignments(text)
363
+ .replace(SHELL_MULTILINE_ASSIGNMENT, (_match, prefix) => `${prefix}[redacted:value]`);
364
+ const redacted = structuralRedacted
36
365
  .replace(PEM_BLOCK, "[redacted:pem]")
366
+ .replace(UNTERMINATED_PEM, "[redacted:pem]")
37
367
  .replace(JWT, "[redacted:jwt]")
368
+ .replace(UNTERMINATED_JWT, "[redacted:jwt]")
38
369
  .replace(URL_CREDENTIALS, "$1[redacted:url-credentials]@")
370
+ .replace(UNTERMINATED_URL_CREDENTIALS, "$1[redacted:url-credentials]")
39
371
  .replace(PROVIDER_TOKENS, "[redacted:token]")
40
372
  .replace(BEARER, "$1[redacted:token]")
41
- .replace(ASSIGNMENT, (match, prefix, _value, offset, source) => {
42
- if (source[offset + match.length] === "(") return match;
43
- return `${prefix}[redacted:value]`;
373
+ .replace(SENSITIVE_HEADER, (match, prefix, rawValue) => {
374
+ const value = rawValue.trimStart();
375
+ if (
376
+ !value ||
377
+ /^\[redacted:[^\]]+\]$/i.test(value) ||
378
+ /^Bearer\s+\[redacted:[^\]]+\]["']?$/i.test(value)
379
+ ) {
380
+ return match;
381
+ }
382
+ return `${prefix}${rawValue.slice(0, rawValue.length - value.length)}[redacted:value]`;
44
383
  });
384
+ const assignmentsRedacted = redactQuotedAssignments(redacted)
385
+ .replace(JSON_UNQUOTED_ASSIGNMENT, (match, prefix, _key, value) =>
386
+ redactAssignment(match, prefix, value),
387
+ )
388
+ .replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) =>
389
+ redactAssignment(match, prefix, value),
390
+ );
391
+ // A runtime may serialize a structured key with punctuation the regex-safe
392
+ // fast path deliberately does not enumerate (`api:key`, nine separators).
393
+ // Withhold that rare field rather than let a different adapter's spelling
394
+ // become a credential bypass.
395
+ return hasSensitiveAssignment(assignmentsRedacted)
396
+ ? "[redacted:credential-assignment]"
397
+ : assignmentsRedacted;
45
398
  }
46
399
 
47
400
  /**