@basein/runner 0.2.11 → 0.3.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 (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -4,47 +4,649 @@
4
4
  * The proxy sits on the credential path for every wrapped server (§9), and every
5
5
  * argument and result it sees crosses a process boundary and then the network.
6
6
  * So redaction runs on the recording copy of both arguments *and* results,
7
- * before the step report is even queued.
7
+ * before the step report is even queued — and once more, as the last line of
8
+ * defence, on every free-text field `RemoteRecorder` sends (the prompt, the
9
+ * answer, the reasoning, error messages), because those never pass through a
10
+ * structured payload at all.
8
11
  *
9
12
  * Three rules, in the order they fire:
10
- * 1. **By key.** A value under a key matching {@link SECRET_KEY} is replaced
11
- * wholesale, whatever its type. `authorization: { scheme, token }` loses
12
- * both halves, because the object as a whole is the secret.
13
+ * 1. **By key.** A value under a key that names a secret ({@link isSecretKey})
14
+ * is replaced wholesale, whatever its type. `authorization: { scheme, token }`
15
+ * loses both halves, because the object as a whole is the secret.
13
16
  * 2. **`env` blocks.** An object under an `env` key has *every* value replaced.
14
17
  * MCP server configs put credentials there under arbitrary names, so no key
15
18
  * pattern would catch them.
16
- * 3. **By shape.** A string that looks like a bearer token, a JWT, an
17
- * `sk-`/`ghp_`-style key or an AWS access key id is replaced wherever it
18
- * appears — including inside free text, where key-matching cannot reach.
19
+ * 3. **By shape.** A string that looks like a credential — a bearer token, a
20
+ * JWT, a provider key, a PEM private key, a password in a URL, a
21
+ * `DB_PASSWORD=…` line of a `.env` file, an `X-Api-Key:` header — is
22
+ * replaced wherever it appears, including inside free text, where key
23
+ * matching cannot reach. Reading `.env` or `~/.aws/credentials` is a routine
24
+ * thing for an agent to do; its result is recorded like any other.
25
+ *
26
+ * KEYS ARE MATCHED ON WHOLE NAME SEGMENTS, not substrings. The old rule was one
27
+ * unanchored alternation, so `author`, `max_tokens`, `nextPageToken` and
28
+ * `tokenizer` were all "secrets" — the recording lost real arguments (numbers
29
+ * became the string "[redacted]") and a scenario built from it had nothing to
30
+ * replay. Names are split on camelCase, snake_case, kebab-case and dots, and a
31
+ * segment has to *be* the secret word (`token`, `accessToken`, `x-api-key`), not
32
+ * merely contain it. The names 0.2.12's substring rule caught that do hold
33
+ * credentials are still caught: plurals (`tokens`, `api_keys`), an `auth` that
34
+ * ends the name (`basic_auth`, `X-Upstream-Auth`), `oauth`, `authentication`,
35
+ * `auth_code` — and a number under the secret itself (`token: 482913`, an OTP).
36
+ *
37
+ * TEXT RULES ARE ANCHORED TO WHERE SECRETS ARE WRITTEN, not to every
38
+ * `name = value` in sight. A recorded Read of source code must survive: its
39
+ * bytes are what a scenario's output logic is authored against, and
40
+ * `const token = await getToken()` is not a secret. So assignments are only
41
+ * redacted in env/ini/YAML line form, shell `NAME=value` form, header form and
42
+ * JSON `"key": "value"` form, and never when the value is visibly code (a call,
43
+ * a member access, a `${reference}`, a type in a declaration such as
44
+ * `password: string`, or a variable named for the same thing such as
45
+ * `token: newToken`). A separator never spans a line break: an empty
46
+ * `DB_PASSWORD=` must not take the next line with it.
47
+ *
48
+ * JSON TEXT IS REDACTED AS JSON. A serialized tool payload goes through
49
+ * {@link redactJsonText}, which parses it and runs the rules on each string
50
+ * leaf — the same pass its producer's {@link redact} made — so the result is
51
+ * 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
55
+ * sequence and never replace a quote or a backslash, so an escaped document
56
+ * stays parseable.
19
57
  *
20
58
  * Redaction never throws and never mutates its input: a cycle, a `BigInt`, a
21
59
  * getter that explodes — each degrades to a placeholder rather than losing the
22
60
  * step or, worse, taking the session down.
23
61
  */
62
+ import { truncateString } from "./truncate.js";
24
63
  export const REDACTED = "[redacted]";
25
- /** Keys whose values are secret regardless of shape. */
64
+ /**
65
+ * The substring rule this module used before key segments. Kept exported for
66
+ * anything that imported it, and applied again under `BIR_REDACT_LEGACY_KEYS=1`.
67
+ *
68
+ * @deprecated over-matches ordinary names (`author`, `max_tokens`); use {@link isSecretKey}.
69
+ */
26
70
  export const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|apikey|authorization|auth|cookie|session[_-]?id|credential|private[_-]?key|access[_-]?key|client[_-]?secret)/i;
27
71
  /** Keys whose *children* are all secret (values only — the names stay). */
28
72
  const ENV_KEY = /^env(ironment)?$/i;
29
- /** Value shapes that are secret wherever they appear, including inside prose. */
73
+ /**
74
+ * A string leaf longer than this is cut before its shapes are scanned. Every
75
+ * consumer caps a recorded payload far below it (64 KiB), so the cut is never
76
+ * visible in what is sent — it only stops a 20 MB tool result from costing a
77
+ * few hundred milliseconds of regex work on a hook's path.
78
+ */
79
+ export const REDACT_SCAN_MAX_CHARS = 256 * 1024;
80
+ // ── keys ──────────────────────────────────────────────────────────────────
81
+ /** Split a name into lower-case segments: camelCase, snake_case, kebab-case, dots. */
82
+ function segments(name) {
83
+ return name
84
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
85
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
86
+ .toLowerCase()
87
+ .split(/[\s_\-.:/]+/)
88
+ .filter(Boolean);
89
+ }
90
+ /**
91
+ * A last segment that makes a secret-sounding name a *description* of the
92
+ * secret rather than the secret: `tokenCount`, `secretName`, `passwordFile`,
93
+ * `api_key_env`. Their values are numbers, names and paths.
94
+ */
95
+ const NEUTRAL_TAILS = new Set([
96
+ "count", "counts", "limit", "limits", "usage", "used", "budget", "type", "types",
97
+ "length", "len", "size", "estimate", "total", "index", "name", "names", "domain",
98
+ "path", "paths", "file", "files", "dir", "url", "uri", "endpoint", "enabled",
99
+ "disabled", "required", "provider", "method", "mode", "format", "env", "var",
100
+ "variable", "policy", "ttl", "expiry", "lifetime", "rotation", "hint", "label",
101
+ ]);
102
+ /**
103
+ * A `token` (or `tokens`) after one of these is a pagination cursor or a
104
+ * counter, not a credential: `page_token`, `max_tokens`, `cached_tokens`.
105
+ */
106
+ const NON_SECRET_TOKEN_QUALIFIERS = new Set([
107
+ "page", "next", "continuation", "pagination", "sync", "cursor", "max", "min",
108
+ "num", "input", "output", "prompt", "completion", "total", "reasoning", "cache",
109
+ "cached",
110
+ ]);
111
+ /** The same words run together into one segment: `maxtokens`, `cachedtokens`. */
112
+ const NON_SECRET_TOKENS_COMPOUND = new RegExp(`^(?:${[...NON_SECRET_TOKEN_QUALIFIERS].join("|")})tokens$`);
113
+ /** `<qualifier> key` pairs that are credentials: `api key`, `private key`, … */
114
+ const KEY_QUALIFIERS = new Set([
115
+ "api", "access", "private", "secret", "encryption", "signing", "master",
116
+ "account", "client", "app", "license", "subscription", "service", "shared",
117
+ "session", "auth",
118
+ ]);
119
+ /** Keys that are secret only as the whole name. */
120
+ const WHOLE_KEYS = new Set(["auth", "pass", "pwd", "dsn", "jwt", "bearer", "otp"]);
121
+ /**
122
+ * What may follow `oauth` / `authentication` / `auth code` and make the name a
123
+ * description rather than the secret, besides {@link NEUTRAL_TAILS}:
124
+ * `oauth_scopes`, `authenticationStatus`, the `Authentication-Results` mail
125
+ * header. Only for these words: `tokenStatus` could still hold a token.
126
+ */
127
+ const AUTH_DESCRIPTOR_TAILS = new Set([
128
+ "scope", "scopes", "status", "result", "results", "scheme", "schemes", "flow", "error", "errors",
129
+ ]);
130
+ /** A one-time code, as the last segment: `otp`, `mfa_otp`, `totp`, `passcode`. */
131
+ const ONE_TIME_CODES = new Set(["otp", "totp", "hotp", "passcode"]);
132
+ /**
133
+ * Verdicts by key. A 50k-row result repeats the same handful of keys 50k
134
+ * times, and splitting each one into segments again made redaction several
135
+ * times slower than the payload is worth. Bounded, and simply cleared when full.
136
+ */
137
+ const keyVerdicts = new Map();
138
+ const KEY_CACHE_MAX = 5_000;
139
+ /**
140
+ * `BIR_REDACT_LEGACY_KEYS=1` also applies the old substring rule, for anyone
141
+ * who preferred over-redaction (`author`, `max_tokens` lost) to the new
142
+ * segment matching. Never less redaction than today's rules.
143
+ */
144
+ const LEGACY_KEYS = process.env.BIR_REDACT_LEGACY_KEYS === "1";
145
+ export function secretKeyStrength(name) {
146
+ const cached = keyVerdicts.get(name);
147
+ if (cached !== undefined)
148
+ return cached ?? undefined;
149
+ const verdict = judgeKey(name) ?? (LEGACY_KEYS && SECRET_KEY.test(name) ? "strong" : undefined);
150
+ if (keyVerdicts.size >= KEY_CACHE_MAX)
151
+ keyVerdicts.clear();
152
+ keyVerdicts.set(name, verdict ?? null);
153
+ return verdict;
154
+ }
155
+ function judgeKey(name) {
156
+ const key = name.trim();
157
+ if (!key)
158
+ return undefined;
159
+ const lower = key.toLowerCase();
160
+ const segs = segments(key);
161
+ if (segs.length === 0)
162
+ return undefined;
163
+ const last = segs[segs.length - 1];
164
+ const neutral = segs.length > 1 && NEUTRAL_TAILS.has(last);
165
+ if (WHOLE_KEYS.has(lower))
166
+ return "strong";
167
+ // Substring on purpose: `pgpassword`, `dbPasswd`, `keyPassphrase`.
168
+ if (/pass(word|wd|phrase)/.test(lower))
169
+ return neutral ? undefined : "strong";
170
+ if (neutral)
171
+ return undefined;
172
+ for (let i = 0; i < segs.length; i++) {
173
+ const s = segs[i];
174
+ const prev = i > 0 ? segs[i - 1] : "";
175
+ const next = segs[i + 1] ?? "";
176
+ // The secret itself is the last segment (`mfa_token`, `api_key`); a
177
+ // segment further in is something about it (`token_id`), and keeps numbers.
178
+ const itself = i === segs.length - 1 ? "strong" : "weak";
179
+ if (s === "secret" || s === "secrets" || (s.length > 6 && s.endsWith("secret")))
180
+ return "strong";
181
+ if (s === "credential" || s === "credentials" || /[a-z]credentials?$/.test(s))
182
+ return "strong";
183
+ if (s === "authorization" || s === "cookie" || s === "cookies")
184
+ return "strong";
185
+ if (s === "connectionstring" || (s === "connection" && next === "string"))
186
+ return "strong";
187
+ if (s === "conn" && next === "str")
188
+ return "strong";
189
+ if (s === "private" && (next === "key" || next === "keys"))
190
+ return "strong";
191
+ if (s === "privatekey" || s === "secretkey" || s === "privatekeys" || s === "secretkeys")
192
+ return "strong";
193
+ if (s === "token" || (s.length > 5 && s.endsWith("token"))) {
194
+ if (s === "token" && NON_SECRET_TOKEN_QUALIFIERS.has(prev))
195
+ continue;
196
+ if (/^(page|next|continuation|pagination|sync|max|min)token$/.test(s))
197
+ continue;
198
+ return itself;
199
+ }
200
+ // A list of them — `tokens`, `refreshTokens`, `api_keys` — is as secret as
201
+ // one, but a number there is a count (`tokens: 1234` in an LLM's usage).
202
+ if (s === "tokens" || (s.length > 6 && s.endsWith("tokens"))) {
203
+ if (s === "tokens" && NON_SECRET_TOKEN_QUALIFIERS.has(prev))
204
+ continue;
205
+ if (NON_SECRET_TOKENS_COMPOUND.test(s))
206
+ continue;
207
+ return "weak";
208
+ }
209
+ if (s === "key" && KEY_QUALIFIERS.has(prev))
210
+ return prev === "session" ? "weak" : itself;
211
+ if (s === "keys" && KEY_QUALIFIERS.has(prev))
212
+ return "weak";
213
+ if (/^(api|access|auth|encryption|signing|master|account|client|app|license|subscription)key$/.test(s))
214
+ return itself;
215
+ if (/^(api|access|auth|encryption|signing|master|account|client|app|license|subscription|session)keys?$/.test(s)) {
216
+ return "weak";
217
+ }
218
+ if (s === "session" && (next === "id" || next === "key"))
219
+ return "weak";
220
+ if (s === "sessionid" || s === "sessid" || s === "phpsessid")
221
+ return "weak";
222
+ if (s === "auth") {
223
+ // The credential an `auth` names: `basic_auth`, `httpAuth`, `X-Upstream-Auth`.
224
+ // `authMethod`, `auth_type` and `oauth_provider` never get here (a neutral tail).
225
+ if (i === segs.length - 1 || prev === "x" || prev === "proxy" || next === "header" || next === "key" || next === "value") {
226
+ return "weak";
227
+ }
228
+ if (next === "code" && !isDescriptor(segs[i + 2] ?? ""))
229
+ return "strong";
230
+ }
231
+ if ((s === "oauth" || s === "authentication") && !isDescriptor(next))
232
+ return "weak";
233
+ if (s === "authcode" && !isDescriptor(next))
234
+ return "strong";
235
+ if (ONE_TIME_CODES.has(s) && i === segs.length - 1)
236
+ return "strong";
237
+ if (s === "code" && (prev === "mfa" || prev === "otp" || prev === "totp" || prev === "2fa"))
238
+ return itself;
239
+ if (s === "jwt" || s === "bearer")
240
+ return "weak";
241
+ }
242
+ return undefined;
243
+ }
244
+ /** `oauth_provider_id`, `authenticationStatus`: the name describes the credential, it does not hold it. */
245
+ function isDescriptor(segment) {
246
+ return NEUTRAL_TAILS.has(segment) || AUTH_DESCRIPTOR_TAILS.has(segment);
247
+ }
248
+ /** True when a value under `name` is a secret (see {@link secretKeyStrength}). */
249
+ export function isSecretKey(name) {
250
+ return secretKeyStrength(name) !== undefined;
251
+ }
252
+ /**
253
+ * The same question for an UPPER_SNAKE environment variable name, where the
254
+ * words run together (`PGPASSWORD`, `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`)
255
+ * and substring matching is the convention rather than the mistake — minus
256
+ * the counters (`MAX_TOKENS`, `TOKENIZERS_PARALLELISM`) and file pointers
257
+ * (`PASSWORD_FILE`, the Docker secrets convention).
258
+ */
259
+ export function isSecretEnvName(name) {
260
+ const n = name.toUpperCase();
261
+ if (/_(FILE|PATH|DIR|NAME|URL|URI|ENABLED|TYPE|COUNT|LIMIT|LENGTH|SIZE|TTL|EXPIRY)$/.test(n))
262
+ return false;
263
+ // Settings *about* the secret — `CREDENTIALS_CHECK_MS = 2_000`, `TOKEN_HEADER`.
264
+ if (/_(MS|SECONDS|SECS|TIMEOUT|INTERVAL|RETRIES|ATTEMPTS|MAX|MIN|DAYS|HOURS|MINUTES|BYTES|CHARS|VERSION|HEADER|PREFIX|FIELD|PARAM|ALGORITHM|ISSUER|AUDIENCE|SCOPES?|EXPIRES(_IN)?|LIFETIME)$/.test(n)) {
265
+ return false;
266
+ }
267
+ if (/PASS(WORD|WD|PHRASE)|SECRET|CREDENTIAL|PRIVATE_?KEY|API_?KEY|ACCESS_?KEY|AUTH_?KEY|COOKIE|CONNECTION_?STRING/.test(n)) {
268
+ return true;
269
+ }
270
+ if (/(^|_)(PWD|DSN|PASS|SESSION_?ID|SESSION_?KEY|JWT|AUTH_?CODE|OTP|MFA_?CODE)(_|$)/.test(n))
271
+ return true;
272
+ // `BASIC_AUTH=user:pass`, `HTTP_AUTH=…`, `GITHUB_OAUTH=…`. A flag such as
273
+ // `SKIP_AUTH=true` is left alone by the rules that read these lines.
274
+ if (/(^|_)O?AUTH$/.test(n))
275
+ return true;
276
+ if (/TOKEN(?!S|IZ)/.test(n))
277
+ return !/(^|_)(PAGE|NEXT|MAX|MIN|CONTINUATION)_?TOKEN/.test(n);
278
+ return false;
279
+ }
280
+ // ── 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))
284
+ return true;
285
+ if (/^[$%{<([`@&*!=#]/.test(value))
286
+ return true; // ${VAR}, %VAR%, <placeholder>, expressions
287
+ if (/[(){}[\]]/.test(value))
288
+ return true; // calls, indexing, literals
289
+ if (/^(true|false|null|none|nil|undefined|required|optional|enabled|disabled|yes|no|on|off|\*+|x+|\.{3}|…)[,;]?$/i.test(value)) {
290
+ return true;
291
+ }
292
+ if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+[,;]?$/.test(value))
293
+ return true; // user.password, process.env.X
294
+ if (/^[A-Za-z_$][\w$]*[,;]$/.test(value))
295
+ return true; // `password: password,`
296
+ return false;
297
+ }
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
+ /**
308
+ * A bare identifier that refers to the secret instead of spelling it out: a
309
+ * type (`password: string`), the key's own word (`self.password = password`),
310
+ * or a compound variable name carrying it (`token: newToken`,
311
+ * `api_key = apiKey`, `secret = secret_value`). Only lowerCamel or snake_case
312
+ * names without a digit count as compound, so `password: hunter`,
313
+ * `password: MySecret1` and `api_key: abcdef` are still literals.
314
+ */
315
+ function namesItself(name, value) {
316
+ if (TYPE_NAME.test(value))
317
+ return true;
318
+ if (!/^[A-Za-z_$][A-Za-z_$]*$/.test(value))
319
+ return false;
320
+ const segs = segments(name);
321
+ const flat = value.toLowerCase().replace(/[_$]/g, "");
322
+ if (flat === segs.join("") || flat === segs[segs.length - 1])
323
+ return true;
324
+ if (!/^[a-z][a-z$]*(?:[A-Z][a-z$]*|_[a-z$]+)+$/.test(value))
325
+ return false;
326
+ return segs.some((s) => SECRET_WORDS.has(s) && flat.includes(s));
327
+ }
328
+ /**
329
+ * A value shaped like an opaque credential rather than a word of prose or an
330
+ * identifier. Random tokens of any useful length carry a digit or a symbol
331
+ * with near certainty; `tokens`, `authentication` and `authHeader` do not.
332
+ */
333
+ function looksLikeCredential(value) {
334
+ if (value.length < 8)
335
+ return false;
336
+ if (value.startsWith(REDACTED))
337
+ return false;
338
+ return /\d/.test(value) || /[-._~+/=]/.test(value) || value.length >= 32;
339
+ }
340
+ /** Characters a value may not contain: whitespace, quotes and a JSON escape. */
341
+ const V = `[^\\s"'\\\\]`;
342
+ /**
343
+ * A line start in plain text, or in JSON text where newlines are `\n` escapes
344
+ * and a file's first line starts right after the string's opening quote.
345
+ */
346
+ const LINE_START = `(?:^|[\\r\\n"]|\\\\[nr])`;
347
+ /** Before a shell word: whitespace, an operator, a quote, or a JSON escape. */
348
+ const WORD_START = `(?:^|[\\s;&|(\`"':]|\\\\[nrt])`;
349
+ /**
350
+ * An opening quote, JSON-escaped or not, or none. A backslash is taken only
351
+ * together with the quote it escapes: `(\\\\?["']?)` used to take the lone
352
+ * backslash of a `\n` after an empty `KEY=`, and the value after it, which
353
+ * left `KEY=\[redacted]` — an invalid escape in a JSON document.
354
+ */
355
+ const QUOTE = `((?:\\\\["'])|["']|)`;
356
+ /**
357
+ * `\b`, or just after a JSON `\n`/`\r`/`\t` escape, where `\b` sees the `n`
358
+ * as part of the word: `…\nsk-proj-…` inside a JSON document held in a string.
359
+ */
360
+ const B = `(?:(?<=\\\\[nrt])|\\b)`;
361
+ /**
362
+ * Value shapes that are secret wherever they appear, including inside prose.
363
+ * Order matters: specific token shapes first, so an assignment rule after them
364
+ * sees `[redacted]` and leaves it alone.
365
+ */
30
366
  const SECRET_SHAPES = [
31
- /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi, // Authorization header value
32
- /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g, // JWT
33
- /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI / Anthropic style
34
- /\bgh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub tokens
35
- /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, // Slack tokens
36
- /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
37
- /\b(?:AIza)[0-9A-Za-z_-]{20,}/g, // Google API key
367
+ // A PEM private key, whole — or cut off by truncation before its END line.
368
+ {
369
+ hint: (t) => t.includes("PRIVATE KEY"),
370
+ apply: redactPem,
371
+ },
372
+ // Authorization header value. Prose ("Bearer tokens expire") is left alone.
373
+ {
374
+ re: new RegExp(`${B}Bearer[ \\t]+([A-Za-z0-9\\-._~+/]+=*)`, "gi"),
375
+ replace: (m, tok) => (looksLikeCredential(tok) ? REDACTED : m),
376
+ },
377
+ // Provider token shapes, as one alternation: one scan instead of fifteen,
378
+ // which is most of the cost on a result made of many short strings.
379
+ {
380
+ re: new RegExp(`${B}(?:${[
381
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/, // JWT
382
+ /\bsk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style
383
+ /\b[rs]k_(?:live|test)_[A-Za-z0-9]{10,}/, // Stripe secret / restricted
384
+ /\bwhsec_[A-Za-z0-9]{20,}/, // Stripe webhook secret
385
+ /\bgh[pousr]_[A-Za-z0-9]{16,}/, // GitHub tokens
386
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
387
+ /\bglpat-[A-Za-z0-9_-]{20,}/, // GitLab PAT
388
+ /\bnpm_[A-Za-z0-9]{36}\b/, // npm
389
+ /\bhf_[A-Za-z0-9]{30,}/, // Hugging Face
390
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack tokens
391
+ /\bxapp-[A-Za-z0-9-]{10,}/, // Slack app token
392
+ /\bAKIA[0-9A-Z]{16}\b/, // AWS access key id
393
+ /\bAIza[0-9A-Za-z_-]{20,}/, // Google API key
394
+ /\bya29\.[A-Za-z0-9_-]{20,}/, // Google OAuth access token
395
+ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/, // SendGrid
396
+ ]
397
+ .map((r) => r.source.replace(/^\\b/, ""))
398
+ .join("|")})`, "g"),
399
+ replace: REDACTED,
400
+ },
401
+ // The password half of URL userinfo: postgres://admin:S3cret@host → admin:[redacted]@host.
402
+ {
403
+ hint: (t) => t.includes("://"),
404
+ re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@"'\\]+:)[^\s@/"'\\]+@/gi,
405
+ replace: `$1${REDACTED}@`,
406
+ },
407
+ // HTTP Basic credentials. Prose ("Basic authentication") is left alone.
408
+ {
409
+ 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,
411
+ },
412
+ // curl -u user:password / --user user:password.
413
+ {
414
+ hint: (t) => t.includes("-u") || t.includes("--user"),
415
+ re: /((?:^|\s)(?:-u|--user)[\s=]+["']?[^\s:"'\\]+:)[^\s"'\\]+/g,
416
+ replace: `$1${REDACTED}`,
417
+ },
418
+ // Authorization with a scheme the shapes above do not know (Token, Digest, …).
419
+ {
420
+ re: new RegExp(`${B}((?:Proxy-)?Authorization[ \\t]*[:=][ \\t]*(?:\\\\?["'])?(?:[A-Za-z][A-Za-z0-9-]*[ \\t]+)?)([^\\s"'\\\\,;]{8,})`, "gi"),
421
+ replace: (m, head, value) => (looksLikeCredential(value) ? `${head}${REDACTED}` : m),
422
+ },
423
+ // Azure-style connection strings: …;AccountKey=…; and Password=… inside them.
424
+ {
425
+ hint: (t) => t.includes("="),
426
+ re: /((?:^|[;"'\s])(?:AccountKey|SharedAccessKey|SharedAccessSignature|Password|Pwd)=)([^;"'\s\\]+)/gi,
427
+ replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
428
+ },
429
+ // A secret in a query string: ?access_token=…&page=2, &api_key=….
430
+ {
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,
434
+ },
435
+ // Command-line flags: --password=…, --api-key=…, --token …
436
+ {
437
+ 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}`,
442
+ },
443
+ // Cookie headers carry sessions: `Cookie: sid=…; theme=dark` loses all of it.
444
+ {
445
+ re: new RegExp(`${B}((?:Set-)?Cookie[ \\t]*:[ \\t]*)([^\\r\\n"'\\\\]*=[^\\r\\n"'\\\\]*)`, "gi"),
446
+ replace: (m, head, value) => (value.startsWith(REDACTED) ? m : `${head}${REDACTED}`),
447
+ },
448
+ // Header form with a hyphenated name: `X-Api-Key: …`, `X-Auth-Token: …`, `-H 'Private-Token: …'`.
449
+ {
450
+ hint: (t) => t.includes(":") && t.includes("-"),
451
+ 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,
453
+ },
454
+ // Shell and dotenv form: `export GITHUB_TOKEN=…`, `PGPASSWORD=… psql`, `API_KEY = "…"`.
455
+ {
456
+ 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))
469
+ return m;
470
+ return `${pre}${exp}${name}${sep}${quote}${REDACTED}`;
471
+ },
472
+ },
473
+ // Env/ini/YAML/properties lines with any-case names: `password: …`, `aws_secret_access_key = …`,
474
+ // `spring.datasource.password=…`, indented or `export`ed.
475
+ {
476
+ 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)
487
+ return m;
488
+ return `${pre}${name}${sep}${quote}${REDACTED}`;
489
+ },
490
+ },
491
+ // JSON form inside text: "password": "hunter2" (also with JSON-escaped quotes).
492
+ {
493
+ 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,
498
+ },
38
499
  ];
39
- /** Replace every secret-shaped run inside a string. */
500
+ /**
501
+ * Can any rule match? Every shape above needs one of these characters or
502
+ * words, so a leaf without them — most of a large result: names, ids, prose
503
+ * without punctuation — skips twenty-odd regex passes.
504
+ */
505
+ const MAYBE_SECRET = /[=:\-_.@]|AKIA|AIza|Bearer|Basic/i;
506
+ /** Most passes {@link redactText} makes; the second one almost never changes anything. */
507
+ const MAX_PASSES = 3;
508
+ /**
509
+ * Replace every secret-shaped run inside a string.
510
+ *
511
+ * Idempotent: redacting the result again changes nothing. That is what lets a
512
+ * payload be redacted by its producer and again at the recorder without its
513
+ * bytes moving (live replay compares against them). One pass is not always a
514
+ * fixed point — a match one rule declined (`Authorization: Authorization: …`)
515
+ * can hide a secret that only shows once a later rule has run — so a pass that
516
+ * changed something is followed by another, until one changes nothing.
517
+ */
40
518
  export function redactText(text) {
41
519
  let out = text;
520
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
521
+ const next = redactPass(out);
522
+ if (next === out)
523
+ break;
524
+ out = next;
525
+ }
526
+ return out;
527
+ }
528
+ function redactPass(text) {
529
+ if (text.length < 8 || !MAYBE_SECRET.test(text))
530
+ return text;
531
+ let out = text;
42
532
  for (const shape of SECRET_SHAPES) {
43
- shape.lastIndex = 0;
44
- out = out.replace(shape, REDACTED);
533
+ if (shape.hint && !shape.hint(out))
534
+ continue;
535
+ if (shape.apply) {
536
+ out = shape.apply(out);
537
+ continue;
538
+ }
539
+ if (!shape.re || shape.replace === undefined)
540
+ continue;
541
+ shape.re.lastIndex = 0;
542
+ out =
543
+ typeof shape.replace === "string"
544
+ ? out.replace(shape.re, shape.replace)
545
+ : out.replace(shape.re, shape.replace);
45
546
  }
46
547
  return out;
47
548
  }
549
+ const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/g;
550
+ const PEM_END = /-----END ([A-Z0-9 ]*)PRIVATE KEY-----/g;
551
+ /**
552
+ * What a key cut off before its END line is made of: base64, its headers
553
+ * (`Proc-Type: 4,ENCRYPTED`) and line breaks, raw or JSON-escaped. A backslash
554
+ * only as part of `\n`/`\r`/`\t`, so an escaped quote (`\"`) ends it rather
555
+ * than being eaten — which used to leave a JSON document with a dangling `\`.
556
+ */
557
+ const PEM_BODY = /(?:[A-Za-z0-9+/=\s:,.-]|\\[nrt])*/y;
558
+ /**
559
+ * Every PEM private key, whole or truncated, as `[redacted]`.
560
+ *
561
+ * Not one regex: `BEGIN…[^"]*?…END` rescanned to the end of the text for each
562
+ * BEGIN line that had no END, so a log repeating the header was quadratic
563
+ * (60 ms for 64 KiB, about a second for a leaf of the size {@link redact}
564
+ * scans). Here the END lines are found once, and each BEGIN looks up the next
565
+ * one of its kind — linear however the text is built. As before, a key never
566
+ * spans a `"`: in JSON text that is the end of the string it lives in.
567
+ */
568
+ function redactPem(text) {
569
+ const ends = new Map();
570
+ PEM_END.lastIndex = 0;
571
+ for (let m = PEM_END.exec(text); m; m = PEM_END.exec(text)) {
572
+ let list = ends.get(m[1]);
573
+ if (!list)
574
+ ends.set(m[1], (list = []));
575
+ list.push(m.index);
576
+ }
577
+ const cursor = new Map();
578
+ let quote = -2; // the first `"` at or after the last position asked about; -1 = none
579
+ let out = "";
580
+ let last = 0;
581
+ PEM_BEGIN.lastIndex = 0;
582
+ for (let m = PEM_BEGIN.exec(text); m; m = PEM_BEGIN.exec(text)) {
583
+ const label = m[1];
584
+ const after = m.index + m[0].length;
585
+ let stop = -1;
586
+ const list = ends.get(label);
587
+ if (list) {
588
+ let i = cursor.get(label) ?? 0;
589
+ while (i < list.length && list[i] < after)
590
+ i += 1;
591
+ cursor.set(label, i);
592
+ if (i < list.length) {
593
+ if (quote !== -1 && quote < after)
594
+ quote = text.indexOf('"', after);
595
+ if (quote === -1 || quote >= list[i])
596
+ stop = list[i] + `-----END ${label}PRIVATE KEY-----`.length;
597
+ }
598
+ }
599
+ if (stop < 0) {
600
+ PEM_BODY.lastIndex = after;
601
+ PEM_BODY.exec(text);
602
+ stop = Math.max(after, PEM_BODY.lastIndex);
603
+ }
604
+ out += text.slice(last, m.index) + REDACTED;
605
+ last = stop;
606
+ PEM_BEGIN.lastIndex = stop;
607
+ }
608
+ return last === 0 ? text : out + text.slice(last);
609
+ }
610
+ /** Deep enough for any producer's output ({@link redact} stops at 24) inside a wrapper. */
611
+ const JSON_TEXT_MAX_DEPTH = 64;
612
+ /**
613
+ * Redact a string that holds JSON — a serialized tool input, result or error —
614
+ * and return JSON, always.
615
+ *
616
+ * Running {@link redactText} over the serialized text could not promise that:
617
+ * a rule that met `\n` after an empty `KEY=` wrote `KEY=\[redacted]`, and the
618
+ * service stored a payload no parser accepts. So the text is parsed and every
619
+ * string leaf gets the rules as plain text, exactly as its producer's
620
+ * {@link redact} did. When that finds nothing new — the normal case, since
621
+ * producers redact first — the original bytes come back untouched: live replay
622
+ * serializes a result the same way and compares against what was recorded.
623
+ * Text that is not JSON (a truncated preview, a producer's plain error) gets
624
+ * {@link redactText}: there is no structure to keep.
625
+ */
626
+ export function redactJsonText(text) {
627
+ let parsed;
628
+ try {
629
+ parsed = JSON.parse(text);
630
+ }
631
+ catch {
632
+ return redactText(text);
633
+ }
634
+ let cleaned;
635
+ try {
636
+ cleaned = JSON.stringify(redact(parsed, JSON_TEXT_MAX_DEPTH));
637
+ }
638
+ catch {
639
+ return JSON.stringify({ truncated: true, reason: "unserializable" });
640
+ }
641
+ try {
642
+ if (cleaned === JSON.stringify(parsed))
643
+ return text;
644
+ }
645
+ catch {
646
+ /* too deep to compare: send the redacted form */
647
+ }
648
+ return cleaned;
649
+ }
48
650
  /**
49
651
  * Deep-clone `value` with secrets removed. The result is always JSON-safe, so
50
652
  * the caller can serialise it without a second failure mode.
@@ -53,16 +655,42 @@ export function redactText(text) {
53
655
  * @param maxDepth guard against pathological nesting (default 24).
54
656
  */
55
657
  export function redact(value, maxDepth = 24) {
56
- return walk(value, undefined, 0, maxDepth, new WeakSet());
658
+ return walk(value, 0, maxDepth, new WeakSet());
659
+ }
660
+ /**
661
+ * Set an own property, including one named `__proto__`: JSON.parse makes that
662
+ * an ordinary key, and a plain assignment would set the copy's prototype
663
+ * instead — the key would vanish from what is recorded.
664
+ */
665
+ function put(target, key, value) {
666
+ if (key === "__proto__") {
667
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
668
+ }
669
+ else {
670
+ target[key] = value;
671
+ }
672
+ }
673
+ /** The value that replaces `v` under a secret key of strength `strength`. */
674
+ function underSecretKey(v, strength) {
675
+ // A flag or a counter under a weak key (`sessionId: 42`, `tokens: 1234`) is
676
+ // not a credential; a number under `password` or `mfa_token` might be one (a
677
+ // PIN, a one-time code), so strong keys redact everything but a flag.
678
+ if (strength === "weak" && (typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)))) {
679
+ return v;
680
+ }
681
+ if (typeof v === "boolean")
682
+ return v;
683
+ return REDACTED;
57
684
  }
58
- function walk(value, key, depth, maxDepth, seen) {
59
- if (key !== undefined && SECRET_KEY.test(key))
60
- return REDACTED;
685
+ function walk(value, depth, maxDepth, seen) {
61
686
  if (value === null || value === undefined)
62
687
  return value ?? null;
63
688
  switch (typeof value) {
64
689
  case "string":
65
- return redactText(value);
690
+ // Cut first: shapes are scanned in linear time, but twenty-odd passes
691
+ // over a 20 MB leaf is still a visible pause on a hook's path, and no
692
+ // consumer sends anything close to this size.
693
+ return redactText(value.length > REDACT_SCAN_MAX_CHARS ? truncateString(value, REDACT_SCAN_MAX_CHARS) : value);
66
694
  case "number":
67
695
  return Number.isFinite(value) ? value : String(value);
68
696
  case "boolean":
@@ -83,7 +711,7 @@ function walk(value, key, depth, maxDepth, seen) {
83
711
  seen.add(obj);
84
712
  try {
85
713
  if (Array.isArray(obj)) {
86
- return obj.map((item) => walk(item, undefined, depth + 1, maxDepth, seen));
714
+ return obj.map((item) => walk(item, depth + 1, maxDepth, seen));
87
715
  }
88
716
  if (obj instanceof Date)
89
717
  return obj.toISOString();
@@ -91,18 +719,19 @@ function walk(value, key, depth, maxDepth, seen) {
91
719
  return { name: obj.name, message: redactText(obj.message) };
92
720
  const out = {};
93
721
  for (const [k, v] of Object.entries(obj)) {
94
- if (SECRET_KEY.test(k)) {
95
- out[k] = REDACTED;
722
+ const strength = secretKeyStrength(k);
723
+ if (strength) {
724
+ put(out, k, underSecretKey(v, strength));
96
725
  }
97
726
  else if (ENV_KEY.test(k) && v && typeof v === "object" && !Array.isArray(v)) {
98
727
  // Rule 2: an env block's names are useful, its values never are.
99
728
  const env = {};
100
729
  for (const name of Object.keys(v))
101
- env[name] = REDACTED;
102
- out[k] = env;
730
+ put(env, name, REDACTED);
731
+ put(out, k, env);
103
732
  }
104
733
  else {
105
- out[k] = walk(v, k, depth + 1, maxDepth, seen);
734
+ put(out, k, walk(v, depth + 1, maxDepth, seen));
106
735
  }
107
736
  }
108
737
  return out;