@basein/runner 0.3.25 → 0.3.27

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 (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
@@ -45,13 +45,23 @@
45
45
  * `token: newToken`). A separator never spans a line break: an empty
46
46
  * `DB_PASSWORD=` must not take the next line with it.
47
47
  *
48
+ * A VALUE IN QUOTES IS READ TO ITS CLOSING QUOTE, spaces, `&`, `;` and all: a
49
+ * passphrase has words in it. Nor is it code for what it starts with or holds:
50
+ * a password may start with `$`, `!` or `(` and have a bracket in it
51
+ * (`"p@ss(w0rd)1"`), and went out as it was while that made a value look like
52
+ * code. Only a reference — `${VAR}`, `$(cmd)`, `<placeholder>`, `{{ template }}`
53
+ * — exempts it. Unquoted, an expression still shows by its shape.
54
+ *
48
55
  * JSON TEXT IS REDACTED AS JSON. A serialized tool payload goes through
49
56
  * {@link redactJsonText}, which parses it and runs the rules on each string
50
57
  * leaf — the same pass its producer's {@link redact} made — so the result is
51
58
  * always valid JSON and, when nothing new was found, the producer's bytes
52
- * exactly (live replay serializes a result the same way and compares). The
53
- * rules still tolerate JSON-escaped text (a string leaf that holds a JSON
54
- * document, a preview of one): they never start a match inside an escape
59
+ * exactly (live replay serializes a result the same way and compares). A
60
+ * string leaf is text, whatever it holds: a JSON document in one — a file's
61
+ * content, what an MCP tool returns in `content[].text` — gets the text rules
62
+ * where it stands, and is never parsed and written back. The text rules read
63
+ * JSON text, plain and JSON-escaped (a document held in a string, a preview of
64
+ * one cut off before it parses): they never start a match inside an escape
55
65
  * sequence and never replace a quote or a backslash, so an escaped document
56
66
  * stays parseable.
57
67
  *
@@ -278,14 +288,109 @@ export function isSecretEnvName(name) {
278
288
  return false;
279
289
  }
280
290
  // ── free-text values ────────────────────────────────────────────────────────
281
- /** A value that is visibly code or a reference, not a literal secret. */
282
- function looksLikeCode(value) {
283
- if (value.startsWith(REDACTED))
291
+ /** A boolean-ish setting: never a credential, whatever the name beside it. */
292
+ const FLAG_LITERAL = /^(?:true|false|yes|no|on|off|enabled|disabled|null|none)$/i;
293
+ /** A type in a declaration: `password: string` declares a field, it does not set one. */
294
+ const TYPE_NAME = /^(?:string|number|boolean|bigint|symbol|object|any|unknown|never|void|str|int|float|bool|bytes|dict|list|char|varchar|text|uuid)$/i;
295
+ /** Name segments a variable *for* a secret carries: `newToken`, `apiKey`, `secret_value`. */
296
+ const SECRET_WORDS = new Set([
297
+ "token", "secret", "password", "passwd", "passphrase", "pass", "pwd", "key", "auth",
298
+ "credential", "credentials", "cookie", "session", "jwt", "bearer", "dsn",
299
+ ]);
300
+ /**
301
+ * A value that stands for a secret kept somewhere else, written whole:
302
+ * `${DB_PASSWORD}`, `$(cat ~/.token)`, `%DB_PASSWORD%`, `<your-password>`,
303
+ * `{{ vault_password }}`, `{password}`.
304
+ *
305
+ * The whole value has to be the reference. The test used to be its first
306
+ * character — `$`, `%`, `{` or `<` — and a literal password that only starts
307
+ * like a reference was sent as it was: `$up3rS3cret`, `%2FAbCdEf…`, `<3Kittens!`.
308
+ */
309
+ const REFERENCE = new RegExp(`^(?:${[
310
+ /\$\{(?:[^{}]|\{[^{}]*\})*\}/, // ${VAR}, ${VAR:-${OTHER}}, ${{ secrets.X }}
311
+ /\$\(.*\)/, // $(command)
312
+ /`[^`]*`/, // `command`
313
+ /\$[0-9@*#?!$-]/, // $1, $@
314
+ /%(?:[0-9]|[A-Za-z_][A-Za-z0-9_]*%)/, // %VAR%, %1
315
+ /<[^<>]+>/, // <placeholder>
316
+ /\{\{.*\}\}/, // {{ template }}
317
+ /\{[A-Za-z_][\w.]*\}/, // {placeholder}
318
+ ]
319
+ .map((r) => r.source)
320
+ .join("|")})$`);
321
+ /**
322
+ * True for a {@link REFERENCE}, and for `$NAME`: a `$` and one whole name
323
+ * (`PGPASSWORD=$pw psql`, `token=$tok`, `X-Api-Key: $API_KEY`).
324
+ *
325
+ * A name that mixes upper and lower case around a digit is the exception: it
326
+ * is what a generated password looks like when its first character is a `$`
327
+ * — `$up3rS3cret`, `$ecretPass1` — where a variable is `$DB_PASSWORD`,
328
+ * `$tok`, `$apiKey` or `$VAR1`. Nothing else about the text tells the two
329
+ * apart, and the first is the one that must not be sent.
330
+ */
331
+ function isReference(value) {
332
+ // One quoting inside the other is a shell's way to write it: `'export API_KEY='"$API_KEY"'; run'`.
333
+ const wrap = /^(\\?["']).*\1$/.exec(value);
334
+ const inner = wrap ? value.slice(wrap[1].length, -wrap[1].length) : value;
335
+ if (REFERENCE.test(inner))
284
336
  return true;
285
- if (/^[$%{<([`@&*!=#]/.test(value))
286
- return true; // ${VAR}, %VAR%, <placeholder>, expressions
287
- if (/[(){}[\]]/.test(value))
288
- return true; // calls, indexing, literals
337
+ const ident = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(inner)?.[1];
338
+ return ident !== undefined && !(/\d/.test(ident) && /[a-z]/.test(ident) && /[A-Z]/.test(ident));
339
+ }
340
+ /** What opens a reference that may have a space in it, and what closes it. */
341
+ const OPENERS = [
342
+ ["$(", ")"],
343
+ ["${", "}"],
344
+ ["{{", "}}"],
345
+ ["`", "`"],
346
+ ["<", ">"],
347
+ ];
348
+ /** What follows a value on its line, as far as a reference could reasonably run. */
349
+ const LINE_REST = /[^\r\n\\]{0,200}/y;
350
+ /**
351
+ * An unquoted value ends at the first space, so a reference with a space in it
352
+ * arrives cut: `$(cat` of `$(cat ~/.token)`, `<your` of `<your api key>`. It
353
+ * is one all the same when what it opened is closed further along the line.
354
+ * `<3Kittens!` opens nothing that closes, and is a password.
355
+ */
356
+ function opensReference(value, text, end) {
357
+ for (const [open, close] of OPENERS) {
358
+ if (!value.startsWith(open))
359
+ continue;
360
+ // Closed inside the value with more after it: `${PREFIX}s3cret` is a literal in part.
361
+ if (value.includes(close, open.length))
362
+ return false;
363
+ LINE_REST.lastIndex = end;
364
+ return (LINE_REST.exec(text)?.[0] ?? "").includes(close);
365
+ }
366
+ return false;
367
+ }
368
+ /** A call or an index, from its first character: `os.getenv(`, `buildAuth(user);`, `tokens[0]`. */
369
+ const CALL_OR_INDEX = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*[([]/;
370
+ /**
371
+ * A value that is visibly code or a reference, not a literal secret.
372
+ *
373
+ * In quotes only a reference is. `"!Sup3rS3cret"`, `'@dm1nP@ssw0rd'` and
374
+ * `"(Secret)1!"` are strings, whatever they start with or hold; each was sent
375
+ * as it was while a first character or a bracket anywhere made a value code.
376
+ * Unquoted, an expression shows by its shape: it opens with an operator or a
377
+ * bracket, or it is a call or an index. A bracket that closes in the middle of
378
+ * a word is neither: `p@ss(w0rd)1`, `abc(def)123456`.
379
+ *
380
+ * @param text the text the value was found in, and `end` where its match
381
+ * ends there: an unquoted reference may go on past its value.
382
+ */
383
+ function looksLikeCode(value, quoted, text, end) {
384
+ if (isReference(quoted ? value : value.replace(/[,;]$/, "")))
385
+ return true;
386
+ if (!quoted) {
387
+ if (opensReference(value, text, end))
388
+ return true;
389
+ if (/^[([{`@&*!=#]/.test(value))
390
+ return true; // expressions, YAML tags and anchors, comments
391
+ if (CALL_OR_INDEX.test(value) && !/[)\]][\w$]/.test(value))
392
+ return true;
393
+ }
289
394
  if (/^(true|false|null|none|nil|undefined|required|optional|enabled|disabled|yes|no|on|off|\*+|x+|\.{3}|…)[,;]?$/i.test(value)) {
290
395
  return true;
291
396
  }
@@ -295,15 +400,6 @@ function looksLikeCode(value) {
295
400
  return true; // `password: password,`
296
401
  return false;
297
402
  }
298
- /** A boolean-ish setting: never a credential, whatever the name beside it. */
299
- const FLAG_LITERAL = /^(?:true|false|yes|no|on|off|enabled|disabled|null|none)$/i;
300
- /** A type in a declaration: `password: string` declares a field, it does not set one. */
301
- const TYPE_NAME = /^(?:string|number|boolean|bigint|symbol|object|any|unknown|never|void|str|int|float|bool|bytes|dict|list|char|varchar|text|uuid)$/i;
302
- /** Name segments a variable *for* a secret carries: `newToken`, `apiKey`, `secret_value`. */
303
- const SECRET_WORDS = new Set([
304
- "token", "secret", "password", "passwd", "passphrase", "pass", "pwd", "key", "auth",
305
- "credential", "credentials", "cookie", "session", "jwt", "bearer", "dsn",
306
- ]);
307
403
  /**
308
404
  * A bare identifier that refers to the secret instead of spelling it out: a
309
405
  * type (`password: string`), the key's own word (`self.password = password`),
@@ -358,6 +454,95 @@ const QUOTE = `((?:\\\\["'])|["']|)`;
358
454
  * as part of the word: `…\nsk-proj-…` inside a JSON document held in a string.
359
455
  */
360
456
  const B = `(?:(?<=\\\\[nrt])|\\b)`;
457
+ /**
458
+ * What stands between `"…"`: any character, or a backslash and the one it
459
+ * escapes — an escaped `\n` or `\/` is part of the value, as a .env file's
460
+ * `KEY="line1\nline2"` has it. In JSON text a plain `"` is only ever a
461
+ * delimiter, and never followed by a `\n`, so nothing is taken across a line.
462
+ */
463
+ const IN_DOUBLE_QUOTES = `(?:[^"\\\\\\r\\n]|\\\\[^\\r\\n])`;
464
+ /**
465
+ * Between `'…'`. A JSON `\n` or `\r` ends it: a JSON string holds a single
466
+ * quote as it is, so an unclosed one would take the lines after it.
467
+ */
468
+ const IN_SINGLE_QUOTES = `(?:[^'\\\\\\r\\n]|\\\\[^nr\\r\\n])`;
469
+ /**
470
+ * Between `\"…\"`, the quotes of a JSON document held in a string. There a
471
+ * backslash of the value's own is `\\`, and a quote of its own `\\\"`; a `\"`
472
+ * on its own is the closing quote.
473
+ */
474
+ const IN_ESCAPED_QUOTES = `(?:[^"\\\\\\r\\n]|\\\\\\\\(?:\\\\["\\\\]|[^"\\\\\\r\\n])|\\\\[/bftu])`;
475
+ /**
476
+ * A value in quotes, read to its closing quote: a passphrase has spaces in
477
+ * it, and `&`, `;` or `)` inside quotes are part of the password, not the end
478
+ * of it. `SMTP_PASSWORD="correct horse battery staple"` used to lose its first
479
+ * word and send the other three.
480
+ *
481
+ * One alternative for each way the quotes are written, each a (quote, value)
482
+ * pair of groups with the closing quote left in the text.
483
+ *
484
+ * Not a value: what follows a quote that CLOSES a string — `"API_KEY=" + key`
485
+ * in source, `["API_KEY=", 123, "x"]` in JSON. So a quoted value here does not
486
+ * start with a space or with `, : ] }`; one that does is left to the form the
487
+ * rule had before, which reads what follows the quote up to the next space.
488
+ */
489
+ function quotedForms(min) {
490
+ const opens = `(?![\\s,:\\]}])`;
491
+ return [
492
+ `(")${opens}(${IN_DOUBLE_QUOTES}{${min},})(?=")`,
493
+ `(')${opens}(${IN_SINGLE_QUOTES}{${min},})(?=')`,
494
+ `(\\\\")${opens}(${IN_ESCAPED_QUOTES}{${min},})(?=\\\\")`,
495
+ ].join("|");
496
+ }
497
+ /**
498
+ * The end of a line after a value, with nothing between them but blanks and a
499
+ * `#` comment. In JSON text the line ends at a `\n` escape, and a file's last
500
+ * line at the quote that closes the string holding it.
501
+ */
502
+ const LINE_END = /[ \t]*(?:#[^\r\n"'\\]*)?(?:$|[\r\n"']|\\[nr"])/y;
503
+ /** Where a shell stops reading an unquoted word. */
504
+ const SHELL_SEPARATOR = /[;&|)`]/;
505
+ /**
506
+ * Words run together, each one capitalised: `JavaScript`, `TypeScript`. After
507
+ * `Basic` that is a heading, not a credential. Base64 of eight characters or
508
+ * more is almost never made of capitalised words, so telling them apart costs
509
+ * no credential in practice.
510
+ */
511
+ const CAPITALISED_WORDS = /^(?:[A-Z][a-z]+){2,}$/;
512
+ /**
513
+ * Words joined by `-`, each perhaps numbered, or a short number between them:
514
+ * `jenkins-build-agent-number-forty-two`, `ci-runner-eu-west-1`. A UUID's
515
+ * groups of hex mix letters and digits as no word does, and a key of 32 hex
516
+ * characters has no `-` at all.
517
+ */
518
+ const HYPHENATED_WORDS = /^(?:[A-Za-z]+\d{0,3}|\d{1,3})(?:-(?:[A-Za-z]+\d{0,3}|\d{1,3}))+$/;
519
+ /**
520
+ * The parameter that carries the signature of a signed URL: an Azure SAS
521
+ * (`sig`), S3 and Google Cloud Storage presigned URLs (`X-Amz-Signature`,
522
+ * `X-Goog-Signature`), CloudFront (`Signature`). Whoever has it has the file.
523
+ *
524
+ * Secret in a query string only, so not one of {@link isSecretKey}'s names:
525
+ * as a key, `signature` is an ordinary argument of a code tool (a function's
526
+ * signature), and `sig` a signal's name.
527
+ */
528
+ const SIGNED_URL_PARAM = /^(?:sig|signature|x-amz-signature|x-goog-signature)$/i;
529
+ /** Where a match ends, and in what text: `replace()` passes both after a rule's own groups. */
530
+ function matchEnd(match, rest) {
531
+ return { text: rest[rest.length - 1], end: rest[rest.length - 2] + match.length };
532
+ }
533
+ /**
534
+ * What a rule built on {@link quotedForms} read: the one (quote, value) pair
535
+ * among its alternatives that took part in the match. `rest` is everything
536
+ * `replace()` passes from the first of those pairs on.
537
+ */
538
+ function matchedValue(match, rest) {
539
+ for (let i = 0; i + 3 < rest.length; i += 2) {
540
+ const value = rest[i + 1];
541
+ if (typeof value === "string")
542
+ return { quote: rest[i] ?? "", value, ...matchEnd(match, rest) };
543
+ }
544
+ return { quote: "", value: "", ...matchEnd(match, rest) };
545
+ }
361
546
  /**
362
547
  * Value shapes that are secret wherever they appear, including inside prose.
363
548
  * Order matters: specific token shapes first, so an assignment rule after them
@@ -378,7 +563,12 @@ const SECRET_SHAPES = [
378
563
  // which is most of the cost on a result made of many short strings.
379
564
  {
380
565
  re: new RegExp(`${B}(?:${[
381
- /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/, // JWT
566
+ // JWT. Not after an earlier `eyJ` of the same run of token characters:
567
+ // a `-` is one of them and a word boundary too, so in `eyJaaaa-eyJaaaa-…`
568
+ // every `eyJ` began a scan to the end of the run — 256 KiB of it took
569
+ // 8 s. A later one could only match where the first did: a first
570
+ // segment ends at the run's end whichever `eyJ` it began at.
571
+ new RegExp(`\\beyJ(?<!${B}eyJ[A-Za-z0-9_-]*?-eyJ)[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]*`),
382
572
  /\bsk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style
383
573
  /\b[rs]k_(?:live|test)_[A-Za-z0-9]{10,}/, // Stripe secret / restricted
384
574
  /\bwhsec_[A-Za-z0-9]{20,}/, // Stripe webhook secret
@@ -398,22 +588,79 @@ const SECRET_SHAPES = [
398
588
  .join("|")})`, "g"),
399
589
  replace: REDACTED,
400
590
  },
401
- // The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host.
591
+ // The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host,
592
+ // and with no user name before it: redis://:S3cret@host. A password that is a
593
+ // variable is not one: `redis://:$REDIS_PASSWORD@redis:6379`,
594
+ // `postgres://${DB_USER}:${DB_PASSWORD}@db/app` — a compose file's, a
595
+ // script's — go as they are.
596
+ //
597
+ // Found from its `://`, and the scheme checked behind that. Read forwards, a
598
+ // scheme began after every `.`, `+` and `-` of a run of its own characters
599
+ // and ran to the end of it: `a.a.a.a…` cost 23 s for 256 KiB.
402
600
  {
403
601
  hint: (t) => t.includes("://"),
404
- re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@"'\\]+:)[^\s@/"'\\]+@/gi,
405
- replace: `$1${REDACTED}@`,
602
+ re: /(:\/\/(?<=\b[a-z][a-z0-9+.-]*:\/\/)[^\s:/@"'\\]*:)([^\s@/"'\\]+)@/gi,
603
+ replace: (m, head, password) => password === REDACTED || isReference(password) ? m : `${head}${REDACTED}@`,
406
604
  },
407
- // HTTP Basic credentials. Prose ("Basic authentication") is left alone.
605
+ // User-info that is only a token: https://<token>@github.com/owner/repo.git, a
606
+ // Sentry DSN, and the user half of `https://<token>:x-oauth-basic@…`, whose
607
+ // password half the rule above has just taken. A user name is short and a
608
+ // word; a token is 32 characters or more, or 20 with a digit and both cases.
609
+ // Words joined by `-` are a name however long they run
610
+ // (`jenkins-build-agent-number-forty-two`); 32 hex characters, or a UUID,
611
+ // are still a token.
612
+ {
613
+ hint: (t) => t.includes("://") && t.includes("@"),
614
+ re: /(:\/\/(?<=\b[a-z][a-z0-9+.-]*:\/\/))([A-Za-z0-9_-]{20,})(?=(?::[^\s@/"'\\]*)?@[^\s@/"'\\]+)/gi,
615
+ replace: (m, head, user) => (user.length >= 32 && !HYPHENATED_WORDS.test(user)) || (/\d/.test(user) && /[a-z]/.test(user) && /[A-Z]/.test(user))
616
+ ? `${head}${REDACTED}`
617
+ : m,
618
+ },
619
+ // HTTP Basic credentials. Prose ("Basic authentication", "Basic JavaScript") is left alone.
408
620
  {
409
621
  re: new RegExp(`${B}Basic[ \\t]+([A-Za-z0-9+/]{8,}={0,2})(?![A-Za-z0-9+/=])`, "g"),
410
- replace: (m, cred) => /[0-9+/=]/.test(cred) || /[A-Z]/.test(cred.slice(1)) ? `Basic ${REDACTED}` : m,
622
+ replace: (m, cred) => /[0-9+/=]/.test(cred) || (/[A-Z]/.test(cred.slice(1)) && !CAPITALISED_WORDS.test(cred)) ? `Basic ${REDACTED}` : m,
411
623
  },
412
624
  // curl -u user:password / --user user:password.
625
+ //
626
+ // Between the flag and the user name: blanks and `=` up to a quote, or up to
627
+ // the last blank, or one `=`. It was `[\s=]+`, and a user name may hold `=`
628
+ // too, so the two shared a run of `=` in every way there is to split it:
629
+ // ` -u ====…` cost 43 s for 256 KiB. These three match the same text, each
630
+ // in one way.
631
+ //
632
+ // What `-u` is given may be a URL instead (`redis-cli -u redis://:pw@host`),
633
+ // whose password the URL rule has read already: `//` after the colon is not
634
+ // a password. Nor is a variable (`curl -u admin:$ADMIN_PASSWORD`).
413
635
  {
414
636
  hint: (t) => t.includes("-u") || t.includes("--user"),
415
- re: /((?:^|\s)(?:-u|--user)[\s=]+["']?[^\s:"'\\]+:)[^\s"'\\]+/g,
416
- replace: `$1${REDACTED}`,
637
+ re: /((?:^|\s)(?:-u|--user)(?:[\s=]+["']|[\s=]*\s|=)[^\s:"'\\]+:)(?!\/\/)([^\s"'\\]+)/g,
638
+ replace: (m, head, password) => (isReference(password) ? m : `${head}${REDACTED}`),
639
+ },
640
+ // `docker login -u me -p <password>` and `sshpass -p <password>`, the value
641
+ // after a blank or none (`-phunter2222`, `-p'two words'`). For most
642
+ // commands `-p` is a port, a path or "parents" (`docker run -p 8080:80`,
643
+ // `mkdir -p`), so it is read after these two only, and not past a `;`, `&`
644
+ // or `|` that ends the command it is in.
645
+ {
646
+ hint: (t) => t.includes("-p") && (t.includes("docker") || t.includes("sshpass")),
647
+ re: new RegExp(`(\\b(?:docker[ \\t]+login\\b[^\\r\\n;&|]{0,200}?|sshpass)[ \\t]+-p[ \\t]*)(?:${quotedForms(1)}|()([^\\s"'\\\\]+))`, "g"),
648
+ replace: (m, head, ...rest) => {
649
+ const { quote, value, text, end } = matchedValue(m, rest);
650
+ if (value === REDACTED || isReference(value) || (!quote && opensReference(value, text, end)))
651
+ return m;
652
+ return `${head}${quote}${REDACTED}`;
653
+ },
654
+ },
655
+ // Cookies given to curl on the command line: `curl -b 'sid=…'`, `--cookie "sid=…; theme=dark"`.
656
+ // A value with no `=` in it is the name of a cookie file, not a cookie.
657
+ {
658
+ hint: (t) => t.includes("-b") || t.includes("--cookie"),
659
+ re: new RegExp(`((?:\\bcurl\\b[^\\r\\n;&|]{0,300}?[ \\t]-b[ \\t]+|[ \\t]--cookie[ \\t=]+))(?:${quotedForms(1)}|()([^\\s"'\\\\]+))`, "g"),
660
+ replace: (m, head, ...rest) => {
661
+ const { quote, value } = matchedValue(m, rest);
662
+ return value.includes("=") ? `${head}${quote}${REDACTED}` : m;
663
+ },
417
664
  },
418
665
  // Authorization with a scheme the shapes above do not know (Token, Digest, …).
419
666
  {
@@ -426,75 +673,161 @@ const SECRET_SHAPES = [
426
673
  re: /((?:^|[;"'\s])(?:AccountKey|SharedAccessKey|SharedAccessSignature|Password|Pwd)=)([^;"'\s\\]+)/gi,
427
674
  replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
428
675
  },
429
- // A secret in a query string: ?access_token=…&page=2, &api_key=….
676
+ // A secret in a query string or a URL fragment: ?access_token=…&page=2,
677
+ // &api_key=…, #access_token=… (where an OAuth implicit grant puts it), and
678
+ // the signature of a signed URL.
430
679
  {
431
- hint: (t) => t.includes("=") && (t.includes("?") || t.includes("&")),
432
- re: /([?&])([A-Za-z_][A-Za-z0-9_.-]*)=([^&#\s"'\\]{4,})/g,
433
- replace: (m, sep, name, value) => isSecretKey(name) && !value.startsWith(REDACTED) && !/^[$%{<]/.test(value) ? `${sep}${name}=${REDACTED}` : m,
680
+ hint: (t) => t.includes("=") && (t.includes("?") || t.includes("&") || t.includes("#")),
681
+ re: /([?&#])([A-Za-z_][A-Za-z0-9_.-]*)=([^&#\s"'\\]{4,})/g,
682
+ replace: (m, sep, name, value) => (isSecretKey(name) || SIGNED_URL_PARAM.test(name)) && value !== REDACTED && !isReference(value)
683
+ ? `${sep}${name}=${REDACTED}`
684
+ : m,
434
685
  },
435
- // Command-line flags: --password=…, --api-key=…, --token …
686
+ // Command-line flags: --password=…, --api-key=…, --token …, the value bare
687
+ // or in quotes, the name's words joined by `-` or `_` (`--api_key`).
436
688
  {
437
689
  hint: (t) => t.includes("-"),
438
- re: /(\s--?(?:[a-z0-9]+-)*(?:password|passwd|passphrase|token|api-?key|secret|access-?key|auth-?token)(?:=|[ \t]+))([^\s"'\\-][^\s"'\\]*)/gi,
439
- replace: (m, head, value) => value.startsWith(REDACTED) || /^[$%{<]/.test(value) || (/\s$/.test(head) && value.length < 6)
440
- ? m
441
- : `${head}${REDACTED}`,
690
+ re: new RegExp(`(\\s--?(?:[a-z0-9]+[-_])*(?:password|passwd|passphrase|token|api[-_]?key|secret|access[-_]?key|auth[-_]?token)(?:=|[ \\t]+))(?:${quotedForms(1)}|()([^\\s"'\\\\-][^\\s"'\\\\]*))`, "gi"),
691
+ replace: (m, head, ...rest) => {
692
+ const { quote, value, text, end } = matchedValue(m, rest);
693
+ if (value === REDACTED)
694
+ return m;
695
+ if (!value.startsWith(REDACTED)) {
696
+ const flag = head.trim().replace(/^-+|=$/g, "");
697
+ if (isReference(value) || (!quote && opensReference(value, text, end)))
698
+ return m;
699
+ if (/\s$/.test(head) && value.length < 6)
700
+ return m;
701
+ // Help text names a type or the flag itself: `--token string Auth token`, `--password PASSWORD`.
702
+ if (!quote && (TYPE_NAME.test(value) || (/^[A-Z][A-Z_]*$/.test(value) && namesItself(flag, value))))
703
+ return m;
704
+ }
705
+ return `${head}${quote}${REDACTED}`;
706
+ },
442
707
  },
443
708
  // Cookie headers carry sessions: `Cookie: sid=…; theme=dark` loses all of it.
709
+ //
710
+ // Not after an earlier `Cookie:` of the same stretch of line: that one took
711
+ // the rest of it if an `=` follows, and if none does, none follows this one
712
+ // either. Without the check every `Cookie:` of a line with no `=` read to
713
+ // the line's end — 25 s for 256 KiB of them.
714
+ //
715
+ // And the value does not start with a blank, which the blanks of the head
716
+ // have taken: `Cookie:` and 256 KiB of spaces shared the run between the
717
+ // head and the value in every way there is to split it — a minute.
444
718
  {
445
- re: new RegExp(`${B}((?:Set-)?Cookie[ \\t]*:[ \\t]*)([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
719
+ re: new RegExp(`${B}(?=(?:Set-)?Cookie[ \\t]*:)(?<!${B}Cookie[ \\t]*:[^\\r\\n"'\\\\]*?)((?:Set-)?Cookie[ \\t]*:[ \\t]*)(?![ \\t])([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
446
720
  replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
447
721
  },
448
722
  // Header form with a hyphenated name: `X-Api-Key: …`, `X-Auth-Token: …`, `-H 'Private-Token: …'`.
449
723
  {
450
724
  hint: (t) => t.includes(":") && t.includes("-"),
451
725
  re: new RegExp(`(${WORD_START})((?:[A-Za-z0-9]+-)+[A-Za-z0-9]+)([ \\t]*:[ \\t]*)${QUOTE}(${V}{6,})`, "g"),
452
- replace: (m, pre, name, sep, quote, value) => isSecretKey(name) && !looksLikeCode(value) ? `${pre}${name}${sep}${quote}${REDACTED}` : m,
726
+ replace: (m, pre, name, sep, quote, value, ...rest) => {
727
+ if (!isSecretKey(name) || value === REDACTED)
728
+ return m;
729
+ const { text, end } = matchEnd(m, rest);
730
+ if (!value.startsWith(REDACTED) && looksLikeCode(value, quote !== "", text, end))
731
+ return m;
732
+ return `${pre}${name}${sep}${quote}${REDACTED}`;
733
+ },
453
734
  },
454
735
  // Shell and dotenv form: `export GITHUB_TOKEN=…`, `PGPASSWORD=… psql`, `API_KEY = "…"`.
736
+ //
737
+ // Unquoted, the value ends where a shell would end it — at a space or at
738
+ // `; & | )` or a backtick — because a command may go on from there:
739
+ // `FOO_TOKEN=abc123&& make deploy`. Whether such a character is part of the
740
+ // password instead (`DB_PASSWORD=S3cr&tP@ssw0rd!` in a .env file) is for the
741
+ // next rule to say: it knows whether the assignment is the whole line.
455
742
  {
456
743
  hint: (t) => t.includes("="),
457
- re: new RegExp(`(${WORD_START})((?:export[ \\t]+)?)([A-Z][A-Z0-9_]*)([ \\t]*=(?!=)[ \\t]*)${QUOTE}([^\\s"'\\\\;&|\`)]{3,})`, "g"),
458
- replace: (m, pre, exp, name, sep, quote, value) => {
459
- if (!isSecretEnvName(name) || value.startsWith(REDACTED))
460
- return m;
461
- // A flag is not a credential: `SKIP_AUTH=true`, `REQUIRE_OTP=off`.
462
- if (FLAG_LITERAL.test(value))
463
- return m;
464
- // `NAME=value` with no spaces is how env files and shells write it; with
465
- // spaces it may be code, so a call or a reference is left alone.
466
- if (sep.trim() !== sep && (looksLikeCode(value) || (!quote && namesItself(name, value))))
467
- return m;
468
- if (/^[$%{<]/.test(value))
744
+ re: new RegExp(`(${WORD_START})((?:export[ \\t]+)?)([A-Z][A-Z0-9_]*)([ \\t]*=(?!=)[ \\t]*)(?:${quotedForms(3)}|${QUOTE}([^\\s"'\\\\;&|\`)]{3,}))`, "g"),
745
+ replace: (m, pre, exp, name, sep, ...rest) => {
746
+ const { quote, value, text, end } = matchedValue(m, rest);
747
+ if (!isSecretEnvName(name) || value === REDACTED)
469
748
  return m;
749
+ // What an earlier rule left of a value is still the value: `GITHUB_TOKEN=[redacted]-suffix`.
750
+ if (!value.startsWith(REDACTED)) {
751
+ // A flag is not a credential: `SKIP_AUTH=true`, `REQUIRE_OTP=off`.
752
+ if (FLAG_LITERAL.test(value))
753
+ return m;
754
+ // `NAME=value` with no spaces is how env files and shells write it; with
755
+ // spaces it may be code, so a call or a reference is left alone.
756
+ if (sep.trim() !== sep && (looksLikeCode(value, quote !== "", text, end) || (!quote && namesItself(name, value)))) {
757
+ return m;
758
+ }
759
+ if (isReference(value) || (!quote && opensReference(value, text, end)))
760
+ return m;
761
+ }
470
762
  return `${pre}${exp}${name}${sep}${quote}${REDACTED}`;
471
763
  },
472
764
  },
473
765
  // Env/ini/YAML/properties lines with any-case names: `password: …`, `aws_secret_access_key = …`,
474
- // `spring.datasource.password=…`, indented or `export`ed.
766
+ // `spring.datasource.password=…`, indented or `export`ed — and an .npmrc line,
767
+ // whose name comes after the registry it is for: `//registry.example/:_authToken=…`.
475
768
  {
476
769
  hint: (t) => t.includes("=") || t.includes(":"),
477
- re: new RegExp(`(${LINE_START}(?:[ \\t]|\\\\t)*(?:-[ \\t]+)?(?:export[ \\t]+)?)([A-Za-z_][A-Za-z0-9_.-]*)([ \\t]*[=:][ \\t]*)${QUOTE}(${V}{4,})`, "g"),
478
- replace: (m, pre, name, sep, quote, value) => {
479
- if (!isSecretKey(name) && !isSecretEnvName(name))
480
- return m;
481
- if (looksLikeCode(value))
482
- return m;
483
- // `password: string`, `token: newToken`, `api_key = apiKey`: code naming the secret.
484
- if (!quote && namesItself(name, value))
485
- return m;
486
- if (sep.includes(":") && value.length < 6 && !quote)
770
+ re: new RegExp(`(${LINE_START}(?:[ \\t]|\\\\t)*(?:-[ \\t]+)?(?:export[ \\t]+)?(?://[^\\s"'\\\\=]*/:)?)([A-Za-z_][A-Za-z0-9_.-]*)([ \\t]*[=:][ \\t]*)(?:${quotedForms(4)}|${QUOTE}(${V}{4,}))`, "g"),
771
+ replace: (m, pre, name, sep, ...rest) => {
772
+ const { quote, value, text, end } = matchedValue(m, rest);
773
+ if ((!isSecretKey(name) && !isSecretEnvName(name)) || value === REDACTED)
487
774
  return m;
775
+ if (!value.startsWith(REDACTED)) {
776
+ if (looksLikeCode(value, quote !== "", text, end))
777
+ return m;
778
+ // `password: string`, `token: newToken`, `api_key = apiKey`: code naming the secret.
779
+ if (!quote && namesItself(name, value))
780
+ return m;
781
+ if (sep.includes(":") && value.length < 6 && !quote)
782
+ return m;
783
+ }
784
+ else if (!quote && sep.includes("=")) {
785
+ // The shell rule took the head of this value and stopped at `&`, `;`,
786
+ // `|`, `)` or a backtick. On a line that is this assignment and nothing
787
+ // more the rest is the rest of the password (`DB_PASSWORD=S3cr&tP@ssw0rd!`),
788
+ // and all of it goes. Where the line goes on it is a command
789
+ // (`FOO_TOKEN=[redacted]&& make deploy`), and from that character on it stays.
790
+ LINE_END.lastIndex = end;
791
+ if (!LINE_END.test(text)) {
792
+ const cut = value.search(SHELL_SEPARATOR);
793
+ const own = cut < 0 ? value : value.slice(0, cut);
794
+ return own === REDACTED ? m : `${pre}${name}${sep}${REDACTED}${value.slice(own.length)}`;
795
+ }
796
+ }
488
797
  return `${pre}${name}${sep}${quote}${REDACTED}`;
489
798
  },
490
799
  },
491
800
  // JSON form inside text: "password": "hunter2" (also with JSON-escaped quotes).
801
+ //
802
+ // The value runs to its closing quote, escapes and all. It used to end at
803
+ // the first backslash, so a value holding `\/`, `\u0026` or `\n` was not
804
+ // matched at all, and one holding `\"` kept everything after it. The form
805
+ // of before comes last, for a value whose quotes are not a pair
806
+ // (`\"password\": "…"`): what it matched it still does.
492
807
  {
493
808
  hint: (t) => t.includes('"') && t.includes(":"),
494
- re: /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\?")(\s*:\s*)(\\?")([^"\\\r\n]{4,})(?=\\?")/g,
495
- replace: (m, q1, name, q2, sep, q3, value) => isSecretKey(name) && !/^[$%{<]/.test(value) && !value.startsWith(REDACTED)
496
- ? `${q1}${name}${q2}${sep}${q3}${REDACTED}`
497
- : m,
809
+ re: new RegExp(`(\\\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\\\?")(\\s*:\\s*)(?:(")(${IN_DOUBLE_QUOTES}{4,})(?=")|(\\\\")(${IN_ESCAPED_QUOTES}{4,})(?=\\\\")|(\\\\?")([^"\\\\\\r\\n]{4,})(?=\\\\?"))`, "g"),
810
+ replace: (m, q1, name, q2, sep, ...rest) => {
811
+ const { quote, value } = matchedValue(m, rest);
812
+ return isSecretKey(name) && value !== REDACTED && !isReference(value) ? `${q1}${name}${q2}${sep}${quote}${REDACTED}` : m;
813
+ },
814
+ },
815
+ // A number under a key that names the secret itself, in JSON text: a
816
+ // one-time code (`"otp": 482913`), a PIN — which the same object passed as an
817
+ // object loses by its key. A whole number of four digits or more, under a
818
+ // strong key only: a count under `tokens` and an id under `sessionId` stay,
819
+ // and so do `"token": 0` and a ratio. Written back as a string in the key's
820
+ // own quotes, so the document still parses.
821
+ {
822
+ hint: (t) => t.includes('"') && t.includes(":"),
823
+ re: /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)(\\?")(\s*:\s*)(\d{4,})(?![\w.])/g,
824
+ replace: (m, q1, name, q2, sep) => secretKeyStrength(name) === "strong" ? `${q1}${name}${q2}${sep}${q1}${REDACTED}${q1}` : m,
825
+ },
826
+ // The strings of a list under a secret key, in JSON text: `"tokens":
827
+ // ["tok_…"]`, `"set-cookie": ["sid=…; Path=/"]`.
828
+ {
829
+ hint: (t) => t.includes("[") && t.includes('"') && t.includes(":"),
830
+ apply: redactSecretLists,
498
831
  },
499
832
  ];
500
833
  /**
@@ -546,8 +879,13 @@ function redactPass(text) {
546
879
  }
547
880
  return out;
548
881
  }
549
- const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/g;
550
- const PEM_END = /-----END ([A-Z0-9 ]*)PRIVATE KEY-----/g;
882
+ /**
883
+ * `-----BEGIN RSA PRIVATE KEY-----`, and PGP armor, which says one word more:
884
+ * `-----BEGIN PGP PRIVATE KEY BLOCK-----`. The END line has to be of the same
885
+ * kind, so a block does not end at the END line of a key of another.
886
+ */
887
+ const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY( BLOCK)?-----/g;
888
+ const PEM_END = /-----END ([A-Z0-9 ]*)PRIVATE KEY( BLOCK)?-----/g;
551
889
  /**
552
890
  * What a key cut off before its END line is made of: base64, its headers
553
891
  * (`Proc-Type: 4,ENCRYPTED`) and line breaks, raw or JSON-escaped. A backslash
@@ -569,10 +907,11 @@ function redactPem(text) {
569
907
  const ends = new Map();
570
908
  PEM_END.lastIndex = 0;
571
909
  for (let m = PEM_END.exec(text); m; m = PEM_END.exec(text)) {
572
- let list = ends.get(m[1]);
910
+ const kind = m[1] + (m[2] ?? "");
911
+ let list = ends.get(kind);
573
912
  if (!list)
574
- ends.set(m[1], (list = []));
575
- list.push(m.index);
913
+ ends.set(kind, (list = []));
914
+ list.push({ at: m.index, to: m.index + m[0].length });
576
915
  }
577
916
  const cursor = new Map();
578
917
  let quote = -2; // the first `"` at or after the last position asked about; -1 = none
@@ -580,20 +919,20 @@ function redactPem(text) {
580
919
  let last = 0;
581
920
  PEM_BEGIN.lastIndex = 0;
582
921
  for (let m = PEM_BEGIN.exec(text); m; m = PEM_BEGIN.exec(text)) {
583
- const label = m[1];
922
+ const kind = m[1] + (m[2] ?? "");
584
923
  const after = m.index + m[0].length;
585
924
  let stop = -1;
586
- const list = ends.get(label);
925
+ const list = ends.get(kind);
587
926
  if (list) {
588
- let i = cursor.get(label) ?? 0;
589
- while (i < list.length && list[i] < after)
927
+ let i = cursor.get(kind) ?? 0;
928
+ while (i < list.length && list[i].at < after)
590
929
  i += 1;
591
- cursor.set(label, i);
930
+ cursor.set(kind, i);
592
931
  if (i < list.length) {
593
932
  if (quote !== -1 && quote < after)
594
933
  quote = text.indexOf('"', after);
595
- if (quote === -1 || quote >= list[i])
596
- stop = list[i] + `-----END ${label}PRIVATE KEY-----`.length;
934
+ if (quote === -1 || quote >= list[i].at)
935
+ stop = list[i].to;
597
936
  }
598
937
  }
599
938
  if (stop < 0) {
@@ -607,8 +946,107 @@ function redactPem(text) {
607
946
  }
608
947
  return last === 0 ? text : out + text.slice(last);
609
948
  }
949
+ /**
950
+ * A member whose value is a list, `"tokens": [`, with its key's quotes plain
951
+ * or JSON-escaped (`\"tokens\": [`, a document held in a string). Between the
952
+ * key and the bracket: blanks, and in escaped text a `\n` or a `\t`.
953
+ */
954
+ const LIST_HEAD = /(\\?")([A-Za-z_][A-Za-z0-9_.-]*)\1(?:\s|\\[nrt])*:(?:\s|\\[nrt])*\[/g;
955
+ /** What may stand between the items of a list. */
956
+ const LIST_SPACE = /(?:\s|\\[nrt])*/y;
957
+ /** An item in plain quotes, and its text. */
958
+ const LIST_STRING = /"((?:[^"\\\r\n]|\\[^\r\n])*)"/y;
959
+ /** An item in JSON-escaped quotes, and its text. */
960
+ const LIST_ESCAPED_STRING = new RegExp(`\\\\"(${IN_ESCAPED_QUOTES}*)\\\\"`, "y");
961
+ /** An item that is a number, or `true`, `false` or `null`. */
962
+ const LIST_OTHER = /(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)(?![\w.])/y;
963
+ /**
964
+ * The items of every list under a secret key, as `[redacted]` each in its own
965
+ * quotes: a string always, a whole number of four digits or more under a
966
+ * strong key — what the same list passed as an object loses by its key. The
967
+ * list keeps its shape and its spacing, and the text around it every byte.
968
+ *
969
+ * Not a regex over the list: read item by item, each from where the last one
970
+ * ended, so a list is read once however long it is. An item that is not a
971
+ * string, a number or a literal — an object, a list — ends the reading: an
972
+ * object in the list is read by its own keys, by the rules above.
973
+ */
974
+ function redactSecretLists(text) {
975
+ let out = "";
976
+ let last = 0;
977
+ let changed = false;
978
+ LIST_HEAD.lastIndex = 0;
979
+ for (let m = LIST_HEAD.exec(text); m; m = LIST_HEAD.exec(text)) {
980
+ const strength = secretKeyStrength(m[2]);
981
+ if (!strength)
982
+ continue;
983
+ const quote = m[1];
984
+ const item = quote.length === 2 ? LIST_ESCAPED_STRING : LIST_STRING;
985
+ let at = m.index + m[0].length;
986
+ for (;;) {
987
+ LIST_SPACE.lastIndex = at;
988
+ LIST_SPACE.exec(text);
989
+ at = LIST_SPACE.lastIndex;
990
+ item.lastIndex = at;
991
+ const s = item.exec(text);
992
+ if (s) {
993
+ const value = s[1];
994
+ const from = at + quote.length;
995
+ if (value !== "" && value !== REDACTED && !isReference(value)) {
996
+ out += text.slice(last, from) + REDACTED;
997
+ last = from + value.length;
998
+ changed = true;
999
+ }
1000
+ at = item.lastIndex;
1001
+ }
1002
+ else {
1003
+ LIST_OTHER.lastIndex = at;
1004
+ const o = LIST_OTHER.exec(text);
1005
+ if (!o)
1006
+ break;
1007
+ if (strength === "strong" && /^\d{4,}$/.test(o[0])) {
1008
+ out += `${text.slice(last, at)}${quote}${REDACTED}${quote}`;
1009
+ last = at + o[0].length;
1010
+ changed = true;
1011
+ }
1012
+ at = LIST_OTHER.lastIndex;
1013
+ }
1014
+ LIST_SPACE.lastIndex = at;
1015
+ LIST_SPACE.exec(text);
1016
+ at = LIST_SPACE.lastIndex;
1017
+ if (text[at] !== ",")
1018
+ break;
1019
+ at += 1;
1020
+ }
1021
+ // The next list starts after this one: no item is read twice.
1022
+ if (LIST_HEAD.lastIndex < at)
1023
+ LIST_HEAD.lastIndex = at;
1024
+ }
1025
+ return changed ? out + text.slice(last) : text;
1026
+ }
610
1027
  /** Deep enough for any producer's output ({@link redact} stops at 24) inside a wrapper. */
611
1028
  const JSON_TEXT_MAX_DEPTH = 64;
1029
+ /**
1030
+ * Whether a string can be a JSON object or array, by its first and last
1031
+ * characters alone. A log line (`[INFO] …`) or a preview cut off before its
1032
+ * end is spared a parse that would read all of it to fail at the last character.
1033
+ */
1034
+ function mayHoldJson(text) {
1035
+ let first = 0;
1036
+ while (first < text.length && isJsonSpace(text.charCodeAt(first)))
1037
+ first += 1;
1038
+ const open = text.charCodeAt(first);
1039
+ if (open !== 0x7b && open !== 0x5b)
1040
+ return false; // { [
1041
+ let last = text.length - 1;
1042
+ while (last > first && isJsonSpace(text.charCodeAt(last)))
1043
+ last -= 1;
1044
+ return text.charCodeAt(last) === open + 2; // } ]
1045
+ }
1046
+ /** The four characters JSON allows between its tokens. */
1047
+ function isJsonSpace(code) {
1048
+ return code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09;
1049
+ }
612
1050
  /**
613
1051
  * Redact a string that holds JSON — a serialized tool input, result or error —
614
1052
  * and return JSON, always.
@@ -621,7 +1059,11 @@ const JSON_TEXT_MAX_DEPTH = 64;
621
1059
  * producers redact first — the original bytes come back untouched: live replay
622
1060
  * serializes a result the same way and compares against what was recorded.
623
1061
  * Text that is not JSON (a truncated preview, a producer's plain error) gets
624
- * {@link redactText}: there is no structure to keep.
1062
+ * {@link redactText}: there is no structure to keep. Unless the rules gave it
1063
+ * one: a private key written over several lines can be all that kept a
1064
+ * document from parsing, and with the key gone it is redacted as the JSON it
1065
+ * now is — or the next pass over it (the recorder's, after its producer's)
1066
+ * would be the one to do that, and move its bytes.
625
1067
  */
626
1068
  export function redactJsonText(text) {
627
1069
  let parsed;
@@ -629,7 +1071,16 @@ export function redactJsonText(text) {
629
1071
  parsed = JSON.parse(text);
630
1072
  }
631
1073
  catch {
632
- return redactText(text);
1074
+ const plain = redactText(text);
1075
+ if (plain === text || !mayHoldJson(plain))
1076
+ return plain;
1077
+ try {
1078
+ parsed = JSON.parse(plain);
1079
+ }
1080
+ catch {
1081
+ return plain;
1082
+ }
1083
+ text = plain;
633
1084
  }
634
1085
  let cleaned;
635
1086
  try {
@@ -690,6 +1141,14 @@ function walk(value, depth, maxDepth, seen) {
690
1141
  // Cut first: shapes are scanned in linear time, but twenty-odd passes
691
1142
  // over a 20 MB leaf is still a visible pause on a hook's path, and no
692
1143
  // consumer sends anything close to this size.
1144
+ // A string is text, whatever it holds. A JSON document in one — a file a
1145
+ // Write puts on disk, what an MCP tool returns in `content[].text` — is
1146
+ // not parsed and written back: that lost its formatting and its big
1147
+ // numbers, and rule 2 emptied a config's `env` block (`"browser": true`
1148
+ // in an .eslintrc became "[redacted]"), and the plan wrote that file.
1149
+ // The text rules read the JSON in it where it is: a value under a secret
1150
+ // key, a number under one (a one-time code), the strings of a list
1151
+ // (`"tokens": [...]`, a `set-cookie` array).
693
1152
  return redactText(value.length > REDACT_SCAN_MAX_CHARS ? truncateString(value, REDACT_SCAN_MAX_CHARS) : value);
694
1153
  case "number":
695
1154
  return Number.isFinite(value) ? value : String(value);