@openviking/dsh-memory-plugin 0.5.29 → 0.5.30

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/shared/redact.mjs DELETED
@@ -1,522 +0,0 @@
1
- // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
- /**
3
- * Client-side secret redaction for captured transcripts.
4
- *
5
- * An agent that runs `cat .env` or prints a token puts that credential in the
6
- * transcript the memory plugins capture. This module replaces such secrets
7
- * before a payload leaves the machine — before the POST to OpenViking and
8
- * before the local pending queue writes it to disk — so the server, its
9
- * extractor and the memories it writes never see the plaintext.
10
- *
11
- * The rules are gitleaks' default rule set plus OpenViking's own key formats
12
- * (lib/secret-rules.mjs, generated), and detection follows gitleaks: a
13
- * case-insensitive keyword prefilter, the rule regex, the secret taken from
14
- * the rule's `secretGroup` or else the first non-empty capture group, the
15
- * Shannon entropy threshold, then the global and the rule's allowlists and
16
- * stopwords. Only the secret span is replaced, so `API_KEY=sk-...` becomes
17
- * `API_KEY={{ov_redacted:<rule>:<tag>}}` and the turn still reads as what
18
- * happened.
19
- *
20
- * The tag is the first 8 hex characters of HMAC-SHA256(install key, secret):
21
- * the same secret gets the same tag across turns and sessions on one machine,
22
- * but the tag cannot be checked against a guessed secret without the key,
23
- * which never leaves the machine.
24
- *
25
- * Failure policy: redaction fails closed per string. A scan that throws
26
- * replaces the whole string with `{{ov_redacted:scan-failed:<tag>}}`; it never
27
- * throws to the hook and never lets the string through unscanned. Text past
28
- * `maxChars` (MAX_SCAN_CHARS by default) is not scanned either: it is cut at a
29
- * whitespace boundary (before an unterminated `-----BEGIN` line) and replaced
30
- * with `{{ov_redacted:scan-oversize:<tag>}}`. The same happens once one call
31
- * has scanned `maxTotalChars` (MAX_SCAN_TOTAL_CHARS) across all its strings,
32
- * which keeps a hook's redaction time bounded however large the batch.
33
- * A missing or unwritable key file only costs tag stability (an in-process
34
- * random key is used), never redaction itself.
35
- *
36
- * Redaction is idempotent: existing `{{ov_redacted:<rule>:<tag>}}`
37
- * placeholders and well-formed `{{ov_privacy:...}}` references are never
38
- * scanned or touched, so replaying a pending entry or redacting twice changes
39
- * nothing.
40
- */
41
-
42
- import { createHmac, randomBytes } from "node:crypto";
43
- import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
44
- import { dirname, join } from "node:path";
45
-
46
- import SECRET_RULES, { GLOBAL_ALLOWLISTS, TIERS } from "./secret-rules.mjs";
47
- import { stateDir } from "./workspace-identity.mjs";
48
-
49
- /** Ordered, cumulative: each tier runs its own rules and every earlier tier's. */
50
- export const REDACT_TIERS = Object.freeze([...TIERS]);
51
- export const DEFAULT_REDACT_TIER = "standard";
52
-
53
- /**
54
- * The most characters of one string that are scanned. It matches the
55
- * largest tool output a plugin captures (`captureToolMaxChars`, at most
56
- * 1,000,000), so nothing a plugin builds by default is cut.
57
- */
58
- export const MAX_SCAN_CHARS = 1_000_000;
59
-
60
- /**
61
- * The most characters scanned in one redactText / redactPayload /
62
- * redactPayloads call, summed over every string in it. Ordinary text scans at
63
- * well under 1 s per MB, but keyword-dense data (hashes, base64) can take
64
- * 10-20 s per MB, and a hook that times out re-sends the same turns next time.
65
- */
66
- export const MAX_SCAN_TOTAL_CHARS = 2_000_000;
67
-
68
- const OFF_WORDS = new Set(["off", "false", "0", "no"]);
69
- const ON_WORDS = new Set(["on", "true", "1", "yes"]);
70
-
71
- /**
72
- * Map a configured value onto a tier name. Anything that says "no" (`false`,
73
- * `0`, `"off"`, any case) is `off`; a value naming no tier this build knows (a
74
- * newer config, a typo) falls back to the default, so it can only redact more.
75
- */
76
- export function normalizeRedactTier(value, fallback = DEFAULT_REDACT_TIER) {
77
- const safeFallback = REDACT_TIERS.includes(fallback) ? fallback : DEFAULT_REDACT_TIER;
78
- if (value === false || value === 0) return "off";
79
- if (value === true || value === 1) return DEFAULT_REDACT_TIER;
80
- if (typeof value !== "string") return safeFallback;
81
- const word = value.trim().toLowerCase();
82
- if (!word) return safeFallback;
83
- if (OFF_WORDS.has(word)) return "off";
84
- if (REDACT_TIERS.includes(word)) return word;
85
- if (ON_WORDS.has(word)) return DEFAULT_REDACT_TIER;
86
- return safeFallback;
87
- }
88
-
89
- // ---------------------------------------------------------------------------
90
- // Install key
91
- // ---------------------------------------------------------------------------
92
-
93
- const KEY_HEX_RE = /^[0-9a-f]{64}$/;
94
- let cachedKey = null;
95
- let processKey = null;
96
-
97
- function readKeyFile(file) {
98
- try {
99
- const text = readFileSync(file, "utf8").trim();
100
- return KEY_HEX_RE.test(text) ? Buffer.from(text, "hex") : null;
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- /**
107
- * Create the key file without exposing a half-written one: a private temp
108
- * file is hard-linked into place, which fails if a concurrent hook got there
109
- * first (its key is then the one read back). A garbage key file, or a
110
- * filesystem without hard links, gets the temp file renamed over it.
111
- */
112
- function createKeyFile(file) {
113
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
114
- const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}`;
115
- writeFileSync(tmp, `${randomBytes(32).toString("hex")}\n`, { mode: 0o600, flag: "wx" });
116
- try {
117
- linkSync(tmp, file);
118
- } catch (error) {
119
- if (error?.code !== "EEXIST" || !readKeyFile(file)) renameSync(tmp, file);
120
- } finally {
121
- try { unlinkSync(tmp); } catch { /* renamed into place */ }
122
- }
123
- return readKeyFile(file);
124
- }
125
-
126
- /**
127
- * The per-install HMAC key: 32 random bytes in `<state dir>/redact/install-key`
128
- * (dir 0700, file 0600). When it cannot be read or created, a random key for
129
- * this process is used: tags then differ between processes, nothing else.
130
- */
131
- export function loadInstallKey({ env = process.env } = {}) {
132
- let file = "";
133
- try {
134
- file = join(stateDir(env), "redact", "install-key");
135
- } catch {
136
- // No state dir: process key.
137
- }
138
- if (cachedKey && cachedKey.file === file) return cachedKey.key;
139
- let key = file ? readKeyFile(file) : null;
140
- if (!key && file) {
141
- try {
142
- key = createKeyFile(file);
143
- } catch {
144
- key = null;
145
- }
146
- }
147
- key ||= processKey ||= randomBytes(32);
148
- cachedKey = { file, key };
149
- return key;
150
- }
151
-
152
- function placeholder(ruleId, key, secret) {
153
- const tag = createHmac("sha256", key).update(String(secret), "utf8").digest("hex").slice(0, 8);
154
- return `{{ov_redacted:${ruleId}:${tag}}}`;
155
- }
156
-
157
- // ---------------------------------------------------------------------------
158
- // Detection (gitleaks semantics)
159
- // ---------------------------------------------------------------------------
160
-
161
- function compileAllowlists(list) {
162
- return (list || []).map((al) => ({
163
- and: al.condition === "AND",
164
- target: al.regexTarget || "secret",
165
- regexes: (al.regexes || []).map((r) => new RegExp(r.regex, r.flags)),
166
- stopwords: (al.stopwords || []).map((w) => String(w).toLowerCase()),
167
- }));
168
- }
169
-
170
- const compiledCache = new Map();
171
-
172
- /** Compile the rules once per process; throws if a rule does not compile. */
173
- export function compileRuleSet(rules = SECRET_RULES, globalAllowlists = GLOBAL_ALLOWLISTS) {
174
- const hit = compiledCache.get(rules);
175
- if (hit && hit.globalsSource === globalAllowlists) return hit;
176
- const set = {
177
- globalsSource: globalAllowlists,
178
- globals: compileAllowlists(globalAllowlists),
179
- rules: rules.map((rule) => ({
180
- id: rule.id,
181
- tierIndex: REDACT_TIERS.indexOf(rule.tier || DEFAULT_REDACT_TIER),
182
- keywords: (rule.keywords || []).map((k) => String(k).toLowerCase()),
183
- entropy: Number(rule.entropy) || 0,
184
- secretGroup: Number(rule.secretGroup) || 0,
185
- scan: new RegExp(rule.regex, `${rule.flags || ""}g`),
186
- groups: new RegExp(rule.regex, `${rule.flags || ""}d`),
187
- allowlists: compileAllowlists(rule.allowlists),
188
- generic: rule.id.includes("generic"),
189
- })),
190
- };
191
- compiledCache.set(rules, set);
192
- return set;
193
- }
194
-
195
- /**
196
- * Shannon entropy as gitleaks computes it: symbol frequencies over code
197
- * points, divided by the UTF-8 byte length (Go's `len(string)`).
198
- */
199
- export function shannonEntropy(data) {
200
- if (!data) return 0;
201
- const counts = new Map();
202
- let bytes = 0;
203
- for (const ch of data) {
204
- counts.set(ch, (counts.get(ch) || 0) + 1);
205
- const cp = ch.codePointAt(0);
206
- bytes += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
207
- }
208
- let entropy = 0;
209
- for (const count of counts.values()) entropy -= (count / bytes) * Math.log2(count / bytes);
210
- return entropy;
211
- }
212
-
213
- function isAllowed(allowlists, secret, match, line) {
214
- const secretLower = secret.toLowerCase();
215
- for (const al of allowlists) {
216
- const target = al.target === "match" ? match : al.target === "line" ? line() : secret;
217
- const checks = [];
218
- if (al.regexes.length) checks.push(target !== "" && al.regexes.some((re) => re.test(target)));
219
- if (al.stopwords.length) checks.push(al.stopwords.some((word) => secretLower.includes(word)));
220
- if (al.and ? checks.every(Boolean) : checks.some(Boolean)) return true;
221
- }
222
- return false;
223
- }
224
-
225
- /**
226
- * The secret spans gitleaks would report in `text`. The `gitleaks:allow`
227
- * marker is deliberately not honoured: in a transcript it would be a way to
228
- * opt a line out of redaction.
229
- */
230
- function detect(text, rules, globals) {
231
- const findings = [];
232
- const lower = text.toLowerCase();
233
- for (const rule of rules) {
234
- if (rule.keywords.length && !rule.keywords.some((kw) => lower.includes(kw))) continue;
235
- const re = rule.scan;
236
- re.lastIndex = 0;
237
- for (let m; (m = re.exec(text)) !== null;) {
238
- if (m[0].length === 0) {
239
- re.lastIndex++;
240
- continue;
241
- }
242
- // gitleaks trims newlines off the match and re-runs the regex on it.
243
- let start = m.index;
244
- let end = m.index + m[0].length;
245
- while (start < end && text.charCodeAt(start) === 10) start++;
246
- while (end > start && text.charCodeAt(end - 1) === 10) end--;
247
- const match = text.slice(start, end);
248
- let span = [0, match.length];
249
- const sub = match ? rule.groups.exec(match) : null;
250
- if (sub && sub.length >= 2) {
251
- if (rule.secretGroup > 0) span = sub.indices[rule.secretGroup] || [0, 0];
252
- else span = sub.indices.slice(1).find((idx) => idx && idx[1] > idx[0]) || span;
253
- }
254
- const secret = match.slice(span[0], span[1]);
255
- if (!secret) continue;
256
- if (rule.entropy && shannonEntropy(secret) <= rule.entropy) continue;
257
- const line = () => {
258
- const from = text.lastIndexOf("\n", start - 1) + 1;
259
- const to = text.indexOf("\n", end);
260
- return text.slice(from, to < 0 ? text.length : to);
261
- };
262
- if (isAllowed(globals, secret, match, line) || isAllowed(rule.allowlists, secret, match, line)) continue;
263
- findings.push({ start: start + span[0], end: start + span[1], id: rule.id, secret, generic: rule.generic });
264
- }
265
- }
266
- return findings;
267
- }
268
-
269
- // ---------------------------------------------------------------------------
270
- // Redaction
271
- // ---------------------------------------------------------------------------
272
-
273
- /**
274
- * Spans that are already placeholders and are never scanned: exactly the
275
- * placeholders this module writes, and OpenViking privacy references in the
276
- * grammar the server parses (openviking/privacy/reference.py). Anything else
277
- * that merely starts with `{{ov_` is scanned like any other text.
278
- */
279
- const PROTECTED_RE = new RegExp(
280
- [
281
- "\\{\\{ov_redacted:[a-z0-9][a-z0-9-]{0,99}:[0-9a-f]{8}\\}\\}",
282
- "\\{\\{[ \\t]*ov_privacy:(?:" +
283
- "ambiguous" +
284
- "|skill:[A-Za-z0-9_.@-]{1,255}:[^\\s:{}]{1,255}" +
285
- "|[^\\s:{}]{1,255}:viking://~/privacy/[^/\\s{}]{1,255}/[^#\\s{}]{1,1024}#/values/[^\\s{}]{1,1024}" +
286
- ")[ \\t]*\\}\\}",
287
- ].join("|"),
288
- "g",
289
- );
290
- // One pass is not always a fixed point: a placeholder can give the text after
291
- // it a word boundary it lacked (`ghp_<36>AKIA<16>`: aws-access-token needs
292
- // `\bAKIA`, which only exists once the GitHub token is replaced). Passes repeat
293
- // until nothing changes, so a replayed entry is sent byte for byte; a string
294
- // with no finding costs one pass.
295
- const MAX_PASSES = 3;
296
-
297
- /** The [start, end) stretches of `text` outside existing placeholders. */
298
- function scannableSegments(text) {
299
- const segments = [];
300
- let last = 0;
301
- for (const m of text.matchAll(PROTECTED_RE)) {
302
- if (m.index > last) segments.push([last, m.index]);
303
- last = m.index + m[0].length;
304
- }
305
- if (last < text.length) segments.push([last, text.length]);
306
- return segments;
307
- }
308
-
309
- /**
310
- * Where scanning stops in a string with more than `max` scannable characters:
311
- * the `max`-th one, pulled back to whitespace within 4 KiB so a token is not
312
- * split with its head left in the scanned part. A `-----BEGIN` block whose
313
- * `-----END ...-----` line lies past the cut cannot match the private-key rule,
314
- * so the cut moves to the start of the BEGIN line instead.
315
- */
316
- function cutPoint(text, segments, max) {
317
- let budget = max;
318
- for (const [a, b] of segments) {
319
- if (b - a <= budget) {
320
- budget -= b - a;
321
- continue;
322
- }
323
- const hard = a + budget;
324
- const head = text.slice(a, hard).toLowerCase();
325
- const begin = head.lastIndexOf("-----begin");
326
- if (begin >= 0 && !/-----end[^\n]*-----/.test(head.slice(begin))) return a + head.lastIndexOf("\n", begin) + 1;
327
- for (let k = hard; k > Math.max(a, hard - 4096); k--) if (/\s/.test(text[k - 1])) return k;
328
- return hard;
329
- }
330
- return text.length;
331
- }
332
-
333
- /** Overlapping findings become one span, named by a provider rule over the generic one. */
334
- function mergeFindings(findings) {
335
- findings.sort((x, y) => x.start - y.start || y.end - x.end || Number(x.generic) - Number(y.generic));
336
- const merged = [];
337
- for (const f of findings) {
338
- const last = merged[merged.length - 1];
339
- if (last && f.start < last.end) {
340
- last.end = Math.max(last.end, f.end);
341
- if (last.rep.generic && !f.generic) last.rep = f;
342
- } else {
343
- merged.push({ start: f.start, end: f.end, rep: f });
344
- }
345
- }
346
- return merged;
347
- }
348
-
349
- /**
350
- * One scan-and-replace pass over `full`; `base` is where the value starts
351
- * after a key prefix, and `max` how many characters of the value to scan.
352
- * Returns the redacted value and how many of its characters were scanned.
353
- */
354
- function redactOnce(full, base, ctx, max) {
355
- const segments = scannableSegments(full);
356
- const scannable = segments.reduce((n, [a, b]) => n + (b - a), 0);
357
- const limit = scannable > base + max ? Math.max(base, cutPoint(full, segments, base + max)) : full.length;
358
- const findings = [];
359
- for (const [a, b] of segments) {
360
- if (Math.min(b, limit) <= a) continue;
361
- for (const f of detect(full.slice(a, Math.min(b, limit)), ctx.rules, ctx.globals)) {
362
- const start = Math.max(a + f.start, base);
363
- if (a + f.end > start) findings.push({ ...f, start, end: a + f.end });
364
- }
365
- }
366
- let out = "";
367
- let pos = base;
368
- for (const span of mergeFindings(findings)) {
369
- out += full.slice(pos, span.start) + placeholder(span.rep.id, ctx.key(), span.rep.secret);
370
- pos = span.end;
371
- }
372
- out += full.slice(pos, limit);
373
- if (limit < full.length) out += placeholder("scan-oversize", ctx.key(), full.slice(limit));
374
- return { text: out, scanned: Math.max(0, Math.min(scannable, base + max) - base) };
375
- }
376
-
377
- function makeContext(opts = {}) {
378
- const tier = normalizeRedactTier(opts.tier ?? DEFAULT_REDACT_TIER);
379
- let key = opts.key ? Buffer.from(opts.key) : null;
380
- const ctx = {
381
- tier,
382
- rules: [],
383
- globals: [],
384
- maxChars: Number.isFinite(opts.maxChars) && opts.maxChars > 0 ? opts.maxChars : MAX_SCAN_CHARS,
385
- // What is left of the call's scan budget; every string's first pass draws on it.
386
- budget: Number.isFinite(opts.maxTotalChars) && opts.maxTotalChars > 0 ? opts.maxTotalChars : MAX_SCAN_TOTAL_CHARS,
387
- key: () => (key ||= loadInstallKey(opts.env ? { env: opts.env } : undefined)),
388
- };
389
- if (tier === "off") return ctx;
390
- try {
391
- const set = compileRuleSet(opts.rules || SECRET_RULES, opts.globalAllowlists || GLOBAL_ALLOWLISTS);
392
- const index = REDACT_TIERS.indexOf(tier);
393
- ctx.rules = set.rules.filter((rule) => rule.tierIndex > 0 && rule.tierIndex <= index);
394
- ctx.globals = set.globals;
395
- } catch (error) {
396
- ctx.broken = error;
397
- }
398
- return ctx;
399
- }
400
-
401
- function failClosed(text, ctx) {
402
- try {
403
- return placeholder("scan-failed", ctx.key(), text);
404
- } catch {
405
- return "{{ov_redacted:scan-failed:00000000}}";
406
- }
407
- }
408
-
409
- function redactStringIn(text, ctx, keyName = null) {
410
- if (typeof text !== "string" || text === "") return text;
411
- if (ctx.broken) return failClosed(text, ctx);
412
- const prefix = keyName ? `${keyName}=` : "";
413
- // Fixed for every pass, so a later pass never cuts what the first one scanned.
414
- const max = Math.min(ctx.maxChars, ctx.budget);
415
- try {
416
- let current = text;
417
- for (let pass = 0; pass < MAX_PASSES; pass++) {
418
- const { text: next, scanned } = redactOnce(prefix + current, prefix.length, ctx, max);
419
- if (pass === 0) ctx.budget -= scanned;
420
- if (next === current) return current;
421
- current = next;
422
- }
423
- // Still changing after several passes: not a state to send.
424
- return failClosed(text, ctx);
425
- } catch {
426
- return failClosed(text, ctx);
427
- }
428
- }
429
-
430
- /**
431
- * Redact the secrets in one string.
432
- *
433
- * @param {string} text
434
- * @param {object} [opts]
435
- * @param {string} [opts.tier="standard"] - one of REDACT_TIERS; "off" returns the input
436
- * @param {string} [opts.keyName] - a field name to scan the value under, as `keyName=value`
437
- * @param {number} [opts.maxChars] - scannable characters of one string before the rest is cut
438
- * @param {number} [opts.maxTotalChars] - the same, summed over every string in the call
439
- * @returns {string}
440
- */
441
- export function redactText(text, opts = {}) {
442
- const ctx = makeContext(opts);
443
- if (ctx.tier === "off") return text;
444
- return redactStringIn(text, ctx, opts.keyName || null);
445
- }
446
-
447
- // Message and part fields that identify or describe rather than carry text.
448
- const META_KEYS = new Set([
449
- "role", "type", "id", "uri", "peer_id", "session_id", "created_at", "tool_id", "tool_name", "tool_status",
450
- "context_type", "tool_output_mime_type", "tool_output_externalized_reason",
451
- ]);
452
- const META_SUFFIX_RE = /_(?:id|uri|ref|sha256|at)$/;
453
- const KEY_CONTEXT_RE = /^[A-Za-z_][\w.-]{0,63}$/;
454
-
455
- const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
456
-
457
- /**
458
- * Redact every string under `value`. Inside an object each value is scanned
459
- * as `<key>=<value>`, so a rule that needs a key-ish word (generic-api-key)
460
- * sees `{ "API_TOKEN": "..." }` as it would see `API_TOKEN=...` in a file;
461
- * only the value is replaced. Keys are scanned as text of their own.
462
- */
463
- function redactDeep(value, ctx, keyName) {
464
- if (typeof value === "string") return redactStringIn(value, ctx, keyName);
465
- if (Array.isArray(value)) return value.map((item) => redactDeep(item, ctx, keyName));
466
- if (!isObject(value)) return value;
467
- const out = {};
468
- for (const [k, v] of Object.entries(value)) {
469
- const key = redactStringIn(k, ctx, null);
470
- out[key] = redactDeep(v, ctx, key === k && KEY_CONTEXT_RE.test(k) ? k : null);
471
- }
472
- return out;
473
- }
474
-
475
- /** A message or part: its fields, metadata copied and the rest redacted. */
476
- function redactFields(value, ctx) {
477
- if (!isObject(value)) return redactDeep(value, ctx, null);
478
- const out = {};
479
- for (const [k, v] of Object.entries(value)) {
480
- if (k === "parts" && Array.isArray(v)) out[k] = v.map((part) => redactFields(part, ctx));
481
- else if (META_KEYS.has(k) || META_SUFFIX_RE.test(k)) out[k] = v;
482
- else out[k] = redactDeep(v, ctx, null);
483
- }
484
- return out;
485
- }
486
-
487
- /**
488
- * Redact one session add-message payload — `{ role, content }` or
489
- * `{ role, parts: [...] }` — and return a redacted copy. Text-bearing fields
490
- * are scanned (`content`, `parts[].text`, `parts[].tool_output`, every leaf of
491
- * `parts[].tool_input`, `abstract`, any other non-metadata field); ids, URIs,
492
- * roles, statuses and timestamps are copied. The caller's object is never
493
- * mutated.
494
- */
495
- export function redactPayload(payload, opts = {}) {
496
- const ctx = makeContext(opts);
497
- if (ctx.tier === "off") return payload;
498
- return redactPayloadIn(payload, ctx);
499
- }
500
-
501
- function redactPayloadIn(payload, ctx) {
502
- if (payload == null) return payload;
503
- if (typeof payload === "string") return redactStringIn(payload, ctx, null);
504
- if (typeof payload !== "object") return payload;
505
- // Walk the data the payload is on the wire (class instances, `toJSON`), as a
506
- // copy; a payload that cannot be serialised could not be sent either.
507
- let data;
508
- try {
509
- data = JSON.parse(JSON.stringify(payload));
510
- } catch {
511
- return failClosed("", ctx);
512
- }
513
- return redactFields(data, ctx);
514
- }
515
-
516
- /** redactPayload over a list, with one scan budget (maxTotalChars) for the whole list. */
517
- export function redactPayloads(payloads, opts = {}) {
518
- if (!Array.isArray(payloads)) return payloads;
519
- const ctx = makeContext(opts);
520
- if (ctx.tier === "off") return payloads;
521
- return payloads.map((payload) => redactPayloadIn(payload, ctx));
522
- }