@wenrwa/marketplace-cli 0.8.4 → 0.8.6

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 (42) hide show
  1. package/dist/__tests__/bridge-changes-report.test.js +25 -0
  2. package/dist/__tests__/bridge-changes-reporter.test.js +98 -0
  3. package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
  4. package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
  5. package/dist/__tests__/bridge-save-env.test.js +343 -0
  6. package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
  7. package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
  8. package/dist/__tests__/bridge-server.test.js +9 -11
  9. package/dist/__tests__/secrets-egress.test.d.ts +1 -0
  10. package/dist/__tests__/secrets-egress.test.js +237 -0
  11. package/dist/__tests__/secrets-redact.test.d.ts +1 -0
  12. package/dist/__tests__/secrets-redact.test.js +318 -0
  13. package/dist/__tests__/term-snapshot.test.js +136 -0
  14. package/dist/bridge/changesReport.js +10 -3
  15. package/dist/bridge/changesReporter.js +0 -0
  16. package/dist/bridge/contextWriter.js +5 -1
  17. package/dist/bridge/envSaver.d.ts +63 -0
  18. package/dist/bridge/envSaver.js +256 -0
  19. package/dist/bridge/hiveopSettings.js +5 -0
  20. package/dist/bridge/launchSupervisor.js +4 -1
  21. package/dist/bridge/localContextWriter.js +6 -2
  22. package/dist/bridge/paneClient.d.ts +32 -0
  23. package/dist/bridge/paneClient.js +30 -0
  24. package/dist/bridge/realPty.js +5 -1
  25. package/dist/bridge/relayClient.d.ts +0 -18
  26. package/dist/bridge/relayClient.js +21 -6
  27. package/dist/bridge/repoEnv.d.ts +34 -0
  28. package/dist/bridge/repoEnv.js +186 -0
  29. package/dist/bridge/server.js +69 -26
  30. package/dist/bridge/sessionManager.d.ts +4 -0
  31. package/dist/bridge/sessionManager.js +28 -8
  32. package/dist/bridge/termSnapshot.js +114 -2
  33. package/dist/bridge/workGraphPointer.d.ts +25 -0
  34. package/dist/bridge/workGraphPointer.js +36 -1
  35. package/dist/commands/bridge.js +4 -0
  36. package/dist/secrets/console.d.ts +4 -0
  37. package/dist/secrets/console.js +34 -0
  38. package/dist/secrets/redact.d.ts +79 -0
  39. package/dist/secrets/redact.js +637 -0
  40. package/hooks/opencode-plugin.mjs +55 -2
  41. package/hooks/pre-tool-use.js +96 -0
  42. package/package.json +1 -1
@@ -0,0 +1,637 @@
1
+ "use strict";
2
+ /**
3
+ * Secret masking for terminal output (owner-approved 2026-10-17).
4
+ *
5
+ * A secret pasted or printed in any HiveOp terminal must never reach anyone's
6
+ * screen — the owner's own view included (they stream and screenshot). The PTY
7
+ * child still gets the real bytes; only what is DISPLAYED or SENT is masked.
8
+ *
9
+ * ONE SOURCE OF TRUTH. backend/src/lib/secrets/redact.ts is a byte-identical
10
+ * copy (the backend cannot import from cli/), and a backend test fails the
11
+ * moment the two differ. Edit this file, then copy it over.
12
+ *
13
+ * Exports:
14
+ * - findSecrets(text) spans a plain string would mask
15
+ * - containsSecret(text) true when findSecrets finds anything
16
+ * - redact(text) one-shot, ANSI-aware masking of a whole string
17
+ * - createStreamRedactor(opts) the same for a chunked PTY stream
18
+ *
19
+ * Masking is LENGTH-PRESERVING: every hidden character becomes one '*'. That
20
+ * keeps every column a TUI drew where it drew it (a shorter line would leave
21
+ * stale cells behind a cursor-addressed repaint), keeps patch byte sizes, and
22
+ * lets a value that arrives in two pieces be masked piece by piece. Prefixed
23
+ * tokens keep their first 4 characters (`npm_****…`); a NAME=value keeps the
24
+ * name (`RAILWAY_API_TOKEN=****…`). '*' is ASCII, so it is one cell wide in
25
+ * every terminal, and a re-scan of masked text finds nothing (idempotent).
26
+ *
27
+ * Streaming: output arrives in arbitrary chunks, interleaved with escape
28
+ * sequences. Escapes pass through untouched (OSC titles are masked inside);
29
+ * text is matched on a "visible" view with SGR colour codes removed, so a
30
+ * token painted in two colours is still one token. A tail that could still be
31
+ * the start of a secret is held back (bounded) and released on the next chunk
32
+ * or after a short idle, so interactive echo stays snappy.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.MASK_CHAR = void 0;
36
+ exports.isSecretName = isSecretName;
37
+ exports.findSecrets = findSecrets;
38
+ exports.containsSecret = containsSecret;
39
+ exports.createStreamRedactor = createStreamRedactor;
40
+ exports.redact = redact;
41
+ exports.redactInSlices = redactInSlices;
42
+ exports.MASK_CHAR = '*';
43
+ // ── value / name rules ───────────────────────────────────────────────────────
44
+ /** Characters a secret value may be made of. Excludes whitespace, quotes,
45
+ * backslash (keeps JSON escapes intact), `$` (a `$VAR` reference is never a
46
+ * literal), shell/code punctuation and control characters. */
47
+ const VCH = "[^\\s\"'`\\\\;,(){}\\[\\]<>$|&\\x00-\\x1f\\x7f]";
48
+ const PLACEHOLDER_WORDS = /(x{5,}|your[-_ ]?|example|placeholder|changeme|change[-_]me|redacted|dummy|sample|<|>|\.\.\.|…)/i;
49
+ /** True when `v` reads as a real secret value, not code, a path or a placeholder. */
50
+ function plausibleValue(v, opts) {
51
+ if (v.length < opts.minLen)
52
+ return false;
53
+ if (/^[*•.\-_xX0#=]+$/.test(v))
54
+ return false; // masks and filler
55
+ if (/^(true|false|null|undefined|none|nil|yes|no|on|off|empty|unset|hidden)$/i.test(v))
56
+ return false;
57
+ if (/^(\/|\.\.?\/|~\/|[A-Za-z]:[\\/])/.test(v))
58
+ return false; // file paths
59
+ if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\//.test(v))
60
+ return false; // URLs (creds handled separately)
61
+ if (PLACEHOLDER_WORDS.test(v))
62
+ return false;
63
+ if (/^v?\d+([.:\-]\d+)*$/.test(v))
64
+ return false; // numbers, versions, times
65
+ if (/^\d{4}-\d{2}-\d{2}/.test(v))
66
+ return false; // ISO dates
67
+ if (/^v?\d+\.\d+\.\d+/.test(v))
68
+ return false; // semver-ish
69
+ if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(v))
70
+ return false; // a.b.c code path
71
+ if (/^pk_(live|test)_/.test(v))
72
+ return false; // Stripe publishable keys are public
73
+ if (opts.needDigit && !/\d/.test(v))
74
+ return false;
75
+ return true;
76
+ }
77
+ const STRONG_SEGMENTS = new Set([
78
+ 'token', 'secret', 'secrets', 'password', 'passwords', 'passwd', 'passphrase', 'pwd', 'pass',
79
+ 'credential', 'credentials', 'creds', 'apikey', 'privatekey', 'secretkey', 'accesskey', 'auth',
80
+ 'jwt', 'bearer', 'cookie',
81
+ ]);
82
+ /** `<word>_KEY` names that are NOT secrets (cache keys, sort keys, …). */
83
+ const KEY_EXCLUDE = new Set([
84
+ 'cache', 'sort', 'partition', 'primary', 'foreign', 'query', 'storage', 'idempotency', 'map',
85
+ 'object', 'hash', 'row', 'unique', 'composite', 'lookup', 'dedupe', 'dedup', 'group', 'routing',
86
+ 'shard', 'public', 'publishable', 'react', 'list', 'dict', 'index', 'cursor', 'sequence', 'sort',
87
+ 'message', 'translation', 'i18n', 'locale', 'host', 'item', 'node', 'field', 'column', 'table',
88
+ 'record', 'entry', 'state', 'store', 'redis', 'bucket', 'file', 'path', 'meta', 'event',
89
+ ]);
90
+ /** True when an identifier names a secret: RAILWAY_API_TOKEN, apiKey, --password, x-api-key. */
91
+ function isSecretName(name) {
92
+ const segs = name
93
+ .replace(/^-+/, '')
94
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
95
+ .toLowerCase()
96
+ .split(/[_.\-]+/)
97
+ .filter(Boolean);
98
+ if (segs.length === 0 || segs.length > 10)
99
+ return false;
100
+ if (segs.includes('public') || segs.includes('publishable'))
101
+ return false;
102
+ for (let i = 0; i < segs.length; i++) {
103
+ const s = segs[i];
104
+ if (STRONG_SEGMENTS.has(s))
105
+ return true;
106
+ if (s.length > 5 && (s.endsWith('token') || s.endsWith('secret') || s.endsWith('password') || s.endsWith('apikey')))
107
+ return true;
108
+ if ((s === 'key' || s === 'keys') && i > 0 && !KEY_EXCLUDE.has(segs[i - 1]))
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+ // ── patterns ─────────────────────────────────────────────────────────────────
114
+ //
115
+ // Every pattern either starts with a literal or is guarded by a lookbehind that
116
+ // stops it starting in the middle of a run of its own leading class — without
117
+ // that, a long run (a hex dump, a TUI line of 60k cells) is scanned once per
118
+ // start position, which is quadratic. The adversarial perf test pins this.
119
+ const B = '(?<![A-Za-z0-9_\\-])'; // token boundary
120
+ /** Known prefixed token formats. Group 1 is the token; the first 4 chars stay visible. */
121
+ const PREFIXED = [
122
+ /sk-ant-[A-Za-z0-9_\-]{20,}/,
123
+ /sk-(?:or-|proj-|svcacct-|admin-)?[A-Za-z0-9_\-]{20,}/,
124
+ /npm_[A-Za-z0-9]{20,}/,
125
+ /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/,
126
+ /github_pat_[A-Za-z0-9_]{20,}/,
127
+ /glpat-[A-Za-z0-9_\-]{20,}/,
128
+ /xox[abprse]-[A-Za-z0-9\-]{10,}/,
129
+ /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/,
130
+ /(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/,
131
+ /AIza[A-Za-z0-9_\-]{30,}/,
132
+ /eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}/,
133
+ /wm_(?:sk|ct)_[A-Za-z0-9_\-]{12,}/,
134
+ /hf_[A-Za-z0-9]{30,}/,
135
+ /sbp_[a-f0-9]{30,}/,
136
+ /sb_secret_[A-Za-z0-9_\-]{16,}/,
137
+ /SG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}/,
138
+ /fm[12]_[A-Za-z0-9_\-+\/=,]{20,}/,
139
+ /lin_api_[A-Za-z0-9]{20,}/,
140
+ /pypi-[A-Za-z0-9_\-]{30,}/,
141
+ /dop_v1_[a-f0-9]{30,}/,
142
+ ].map((r) => new RegExp(`${B}(${r.source})`, 'g'));
143
+ /** Prefixes for the hold-back test: a trailing run starting with one may still be growing. */
144
+ const KNOWN_PREFIXES = [
145
+ 'sk-', 'npm_', 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_', 'glpat-', 'xoxa-', 'xoxb-',
146
+ 'xoxp-', 'xoxr-', 'xoxs-', 'xoxe-', 'sk_live_', 'sk_test_', 'rk_live_', 'rk_test_', 'AKIA', 'ASIA',
147
+ 'AIza', 'eyJ', 'wm_sk_', 'wm_ct_', 'hf_', 'sbp_', 'sb_secret_', 'SG.', 'fm1_', 'fm2_', 'lin_api_',
148
+ 'pypi-', 'dop_v1_',
149
+ ];
150
+ const NAME = '[A-Za-z_][A-Za-z0-9_.\\-]*';
151
+ /** A NAME that contains a secret-ish word (the regex prefilter; isSecretName
152
+ * decides). Bounded so a 60k-cell identifier run cannot backtrack for long. */
153
+ const KW_NAME = '(?=[A-Za-z_])[A-Za-z0-9_.\\-]{0,64}?(?:[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss]|[Pp][Ww][Dd]|[Cc][Rr][Ee][Dd]|[Aa][Uu][Tt][Hh]|[Kk][Ee][Yy]|[Jj][Ww][Tt]|[Bb][Ee][Aa][Rr][Ee][Rr]|[Cc][Oo][Oo][Kk][Ii][Ee])[A-Za-z0-9_.\\-]{0,64}';
154
+ const HS = '[^\\S\\r\\n]*'; // horizontal whitespace
155
+ /** NAME=value, NAME: value, "name": "value", export NAME='value'. Groups: 1 name, 2 value. */
156
+ const ASSIGN_RE = new RegExp(`(?<![A-Za-z0-9_.\\-])(?:\\\\?["'])?(${KW_NAME})(?![A-Za-z0-9_.\\-])(?:\\\\?["'])?${HS}(?:=|:(?!\\/\\/))(?!=)${HS}(?:\\\\?["'])?(${VCH}+)`, 'g');
157
+ /** --token value / --api-key=value. Groups: 1 flag name, 2 value. */
158
+ const FLAG_RE = new RegExp(`(?<![A-Za-z0-9_\\-])--?([A-Za-z][A-Za-z0-9_\\-]*)(?:=|[^\\S\\r\\n]+)["']?(${VCH}+)`, 'g');
159
+ /** Authorization: <scheme> <value>. Group 1 value. */
160
+ const AUTH_HEADER_RE = new RegExp(`(?<![A-Za-z0-9_\\-])(?:Proxy-)?Authorization["']?${HS}[:=]${HS}["']?(?:(?:Bearer|Basic|Token|Bot|Digest)[^\\S\\r\\n]+)?(${VCH}+)`, 'gi');
161
+ /** Bearer <value> anywhere. Group 1 value. */
162
+ const BEARER_RE = new RegExp(`(?<![A-Za-z0-9_\\-])Bearer[^\\S\\r\\n]+(${VCH}+)`, 'gi');
163
+ /** scheme://user:password@host. Group 1 password. */
164
+ const URL_CRED_RE = /:\/\/[^\s:\/@"'<>]+:([^\s\/@"'<>$]+)@/g;
165
+ const PEM_BEGIN_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
166
+ const PEM_END_RE = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
167
+ const PEM_RUN_RE = /[A-Za-z0-9+\/=]{8,}/g;
168
+ /** Give up on an unterminated key block after this many characters. */
169
+ const PEM_BUDGET = 16_384;
170
+ // ── hold-back (streaming) ────────────────────────────────────────────────────
171
+ /** Trailing, still-growing forms (anchored at the end of a line tail). Group 1 = value so far. */
172
+ const TRAIL_ASSIGN_RE = new RegExp(`(?<![A-Za-z0-9_.\\-])(?:\\\\?["'])?(${NAME})(?:\\\\?["'])?${HS}(?:=|:(?!\\/\\/))(?!=)${HS}(?:\\\\?["'])?(${VCH}*)$`);
173
+ const TRAIL_FLAG_RE = new RegExp(`(?<![A-Za-z0-9_\\-])--?([A-Za-z][A-Za-z0-9_\\-]*)(?:=|[^\\S\\r\\n]+)["']?(${VCH}*)$`);
174
+ const TRAIL_BEARER_RE = new RegExp(`(?:Authorization["']?${HS}[:=]${HS}["']?(?:(?:Bearer|Basic|Token|Bot|Digest)[^\\S\\r\\n]+)?|Bearer[^\\S\\r\\n]+)(${VCH}*)$`, 'i');
175
+ const TRAIL_URL_RE = /:\/\/[^\s:\/@"'<>]+:([^\s\/@"'<>$]*)$/;
176
+ const TRAIL_RUN_RE = /(?<![A-Za-z0-9_\-.+\/=,])[A-Za-z0-9_\-.+\/=,]+$/;
177
+ /** How far back (visible chars) the trailing checks look. */
178
+ const TAIL_WINDOW = 600;
179
+ function pushSpan(out, start, end, kind) {
180
+ if (end > start)
181
+ out.push({ start, end, kind });
182
+ }
183
+ function detectStatic(v, out) {
184
+ for (const re of PREFIXED) {
185
+ re.lastIndex = 0;
186
+ let m;
187
+ while ((m = re.exec(v))) {
188
+ const tok = m[1];
189
+ const body = tok.slice(4);
190
+ if (/\d/.test(body) && /[A-Za-z]/.test(body) && !PLACEHOLDER_WORDS.test(tok) && !/^(.)\1+$/.test(body)) {
191
+ pushSpan(out, m.index + 4, m.index + tok.length, 'prefixed');
192
+ }
193
+ }
194
+ }
195
+ const valueAt = (m, group) => m.index + m[0].length - m[group].length;
196
+ let m;
197
+ ASSIGN_RE.lastIndex = 0;
198
+ while ((m = ASSIGN_RE.exec(v))) {
199
+ if (isSecretName(m[1]) && plausibleValue(m[2], { minLen: 12, needDigit: true })) {
200
+ const s = valueAt(m, 2);
201
+ pushSpan(out, s, s + m[2].length, 'assignment');
202
+ }
203
+ }
204
+ FLAG_RE.lastIndex = 0;
205
+ while ((m = FLAG_RE.exec(v))) {
206
+ if (isSecretName(m[1]) && plausibleValue(m[2], { minLen: 12, needDigit: true })) {
207
+ const s = valueAt(m, 2);
208
+ pushSpan(out, s, s + m[2].length, 'assignment');
209
+ }
210
+ }
211
+ AUTH_HEADER_RE.lastIndex = 0;
212
+ while ((m = AUTH_HEADER_RE.exec(v))) {
213
+ if (plausibleValue(m[1], { minLen: 8, needDigit: false }) && !/^(Bearer|Basic|Token|Bot|Digest)$/i.test(m[1])) {
214
+ const s = valueAt(m, 1);
215
+ pushSpan(out, s, s + m[1].length, 'bearer');
216
+ }
217
+ }
218
+ BEARER_RE.lastIndex = 0;
219
+ while ((m = BEARER_RE.exec(v))) {
220
+ if (plausibleValue(m[1], { minLen: 12, needDigit: true })) {
221
+ const s = valueAt(m, 1);
222
+ pushSpan(out, s, s + m[1].length, 'bearer');
223
+ }
224
+ }
225
+ URL_CRED_RE.lastIndex = 0;
226
+ while ((m = URL_CRED_RE.exec(v))) {
227
+ const pw = m[1];
228
+ if (!/^[*]+$/.test(pw)) {
229
+ const s = m.index + m[0].length - 1 - pw.length;
230
+ pushSpan(out, s, s + pw.length, 'url-credential');
231
+ }
232
+ }
233
+ }
234
+ /** Private-key blocks: mask base64 runs between BEGIN and END, across chunks. */
235
+ function detectPem(v, boundary, state, out) {
236
+ let inPem = state.inPem;
237
+ let budget = state.pemBudget;
238
+ let cur = inPem ? boundary : 0;
239
+ for (;;) {
240
+ if (inPem) {
241
+ PEM_END_RE.lastIndex = Math.max(0, cur - 64);
242
+ let end;
243
+ while ((end = PEM_END_RE.exec(v)) && end.index + end[0].length <= cur) { /* skip already-seen */ }
244
+ const regionEnd = end ? end.index : v.length;
245
+ const region = v.slice(cur, regionEnd);
246
+ PEM_RUN_RE.lastIndex = 0;
247
+ let r;
248
+ while ((r = PEM_RUN_RE.exec(region)))
249
+ pushSpan(out, cur + r.index, cur + r.index + r[0].length, 'private-key');
250
+ if (end) {
251
+ inPem = false;
252
+ cur = end.index + end[0].length;
253
+ continue;
254
+ }
255
+ if (cur >= boundary)
256
+ budget -= region.length;
257
+ if (budget <= 0)
258
+ inPem = false;
259
+ break;
260
+ }
261
+ PEM_BEGIN_RE.lastIndex = Math.max(0, cur, boundary - 64);
262
+ let begin;
263
+ while ((begin = PEM_BEGIN_RE.exec(v)) && begin.index + begin[0].length <= boundary) { /* emitted before */ }
264
+ if (!begin || begin.index + begin[0].length < cur)
265
+ break;
266
+ inPem = true;
267
+ budget = PEM_BUDGET;
268
+ cur = begin.index + begin[0].length;
269
+ }
270
+ return { inPem, pemBudget: budget };
271
+ }
272
+ /** Where a still-growing secret starts at the end of `v` (v.length when none). */
273
+ function holdPoint(v, state) {
274
+ let lineStart = Math.max(v.lastIndexOf('\n'), v.lastIndexOf('\r')) + 1;
275
+ if (state.inPem) {
276
+ // Hold the unfinished key line so an END marker is never split.
277
+ return v.length - lineStart <= 256 ? lineStart : v.length;
278
+ }
279
+ lineStart = Math.max(lineStart, v.length - TAIL_WINDOW);
280
+ const tail = v.slice(lineStart);
281
+ let hold = v.length;
282
+ const at = (m, group) => m ? lineStart + m.index + m[0].length - m[group].length : v.length;
283
+ const a = TRAIL_ASSIGN_RE.exec(tail);
284
+ if (a && a[2] && isSecretName(a[1]))
285
+ hold = Math.min(hold, at(a, 2));
286
+ const f = TRAIL_FLAG_RE.exec(tail);
287
+ if (f && f[2] && isSecretName(f[1]))
288
+ hold = Math.min(hold, at(f, 2));
289
+ const b = TRAIL_BEARER_RE.exec(tail);
290
+ if (b && b[1])
291
+ hold = Math.min(hold, at(b, 1));
292
+ const u = TRAIL_URL_RE.exec(tail);
293
+ if (u && u[1])
294
+ hold = Math.min(hold, at(u, 1));
295
+ const run = TRAIL_RUN_RE.exec(tail);
296
+ if (run && KNOWN_PREFIXES.some((p) => run[0].startsWith(p))) {
297
+ hold = Math.min(hold, lineStart + run.index + Math.min(4, run[0].length));
298
+ }
299
+ return hold;
300
+ }
301
+ /** In-progress values at the very end, masked on a final flush (typed secrets). */
302
+ function trailingInProgress(v, out) {
303
+ const lineStart = Math.max(Math.max(v.lastIndexOf('\n'), v.lastIndexOf('\r')) + 1, v.length - TAIL_WINDOW);
304
+ const tail = v.slice(lineStart);
305
+ const a = TRAIL_ASSIGN_RE.exec(tail) ?? TRAIL_FLAG_RE.exec(tail);
306
+ if (a && a[2] && isSecretName(a[1]) &&
307
+ plausibleValue(a[2], { minLen: 3, needDigit: false }) && (/\d/.test(a[2]) || a[2].length >= 8)) {
308
+ const s = lineStart + a.index + a[0].length - a[2].length;
309
+ pushSpan(out, s, v.length, 'assignment');
310
+ }
311
+ const run = TRAIL_RUN_RE.exec(tail);
312
+ if (run && run[0].length > 4 && KNOWN_PREFIXES.some((p) => run[0].startsWith(p) && run[0].length > p.length)) {
313
+ pushSpan(out, lineStart + run.index + 4, v.length, 'prefixed');
314
+ }
315
+ }
316
+ function scan(v, boundary, state, final, maskTrailing = false) {
317
+ const spans = [];
318
+ detectStatic(v, spans);
319
+ const next = detectPem(v, boundary, state, spans);
320
+ let hold = v.length;
321
+ if (!final)
322
+ hold = Math.max(boundary, holdPoint(v, next));
323
+ else if (maskTrailing)
324
+ trailingInProgress(v, spans);
325
+ return { spans: spans.filter((s) => s.end > boundary), hold, state: next };
326
+ }
327
+ /** Spans a plain (escape-free) string would mask, sorted and merged. */
328
+ function findSecrets(text) {
329
+ const { spans } = scan(text, 0, { inPem: false, pemBudget: 0 }, true);
330
+ spans.sort((x, y) => x.start - y.start);
331
+ const merged = [];
332
+ for (const s of spans) {
333
+ const last = merged[merged.length - 1];
334
+ if (last && s.start <= last.end)
335
+ last.end = Math.max(last.end, s.end);
336
+ else
337
+ merged.push({ ...s });
338
+ }
339
+ return merged;
340
+ }
341
+ /** True when `text` holds anything findSecrets would mask. */
342
+ function containsSecret(text) {
343
+ return findSecrets(text).length > 0;
344
+ }
345
+ function maskPlain(text) {
346
+ const spans = findSecrets(text);
347
+ if (spans.length === 0)
348
+ return text;
349
+ let out = '';
350
+ let pos = 0;
351
+ for (const s of spans) {
352
+ out += text.slice(pos, s.start) + exports.MASK_CHAR.repeat(s.end - s.start);
353
+ pos = s.end;
354
+ }
355
+ return out + text.slice(pos);
356
+ }
357
+ const ESC = '\x1b';
358
+ const MAX_OSC = 4096;
359
+ const MAX_STRING_HOLD = 65_536;
360
+ /** Classify a complete CSI by its final byte. */
361
+ function csiVisible(seq) {
362
+ const final = seq[seq.length - 1];
363
+ if (final === 'm')
364
+ return ''; // colours
365
+ if (final === 'C')
366
+ return ' '; // cursor forward stands in for spaces
367
+ if (/[ABDEFGHJKLMPSTXdfr@`ae]/.test(final))
368
+ return '\n'; // moves / erases separate words
369
+ return ''; // modes, queries, sync markers…
370
+ }
371
+ /**
372
+ * Split raw PTY output into text runs and escape sequences. `inString` says
373
+ * the previous chunk ended inside an opaque string (DCS/APC/…) that has
374
+ * already been passed through; its continuation is consumed opaquely.
375
+ */
376
+ function tokenize(raw, inString, final) {
377
+ const elements = [];
378
+ let i = 0;
379
+ const findST = (from) => {
380
+ for (let j = from; j < raw.length; j++) {
381
+ if (raw[j] === '\x07')
382
+ return j + 1;
383
+ if (raw[j] === ESC && raw[j + 1] === '\\')
384
+ return j + 2;
385
+ }
386
+ return -1;
387
+ };
388
+ if (inString) {
389
+ const end = findST(0);
390
+ if (end === -1) {
391
+ // Still inside; pass it all through (a lone trailing ESC may begin ST).
392
+ const keep = !final && raw.endsWith(ESC) ? 1 : 0;
393
+ if (raw.length - keep > 0)
394
+ elements.push({ raw: raw.slice(0, raw.length - keep), vis: '', text: false });
395
+ return { elements, rest: raw.slice(raw.length - keep), inString: true };
396
+ }
397
+ elements.push({ raw: raw.slice(0, end), vis: '', text: false });
398
+ i = end;
399
+ }
400
+ let textStart = i;
401
+ const flushText = (to) => {
402
+ if (to > textStart)
403
+ elements.push({ raw: raw.slice(textStart, to), vis: raw.slice(textStart, to), text: true });
404
+ };
405
+ while (i < raw.length) {
406
+ if (raw[i] !== ESC) {
407
+ i++;
408
+ continue;
409
+ }
410
+ flushText(i);
411
+ const start = i;
412
+ const next = raw[i + 1];
413
+ if (next === undefined) {
414
+ if (final) {
415
+ elements.push({ raw: ESC, vis: '', text: false });
416
+ i++;
417
+ textStart = i;
418
+ continue;
419
+ }
420
+ return { elements, rest: raw.slice(start), inString: false };
421
+ }
422
+ if (next === '[') {
423
+ let j = i + 2;
424
+ while (j < raw.length && raw.charCodeAt(j) >= 0x20 && raw.charCodeAt(j) <= 0x3f)
425
+ j++;
426
+ if (j >= raw.length) {
427
+ if (final) {
428
+ elements.push({ raw: raw.slice(start), vis: '', text: false });
429
+ i = raw.length;
430
+ textStart = i;
431
+ continue;
432
+ }
433
+ return { elements, rest: raw.slice(start), inString: false };
434
+ }
435
+ const seq = raw.slice(start, j + 1);
436
+ elements.push({ raw: seq, vis: csiVisible(seq), text: false });
437
+ i = j + 1;
438
+ }
439
+ else if (next === ']') {
440
+ const end = findST(i + 2);
441
+ if (end === -1) {
442
+ if (!final && raw.length - start <= MAX_OSC)
443
+ return { elements, rest: raw.slice(start), inString: false };
444
+ // Too long (or flushing): pass through unmasked as an opaque string.
445
+ elements.push({ raw: raw.slice(start), vis: '', text: false });
446
+ return { elements, rest: '', inString: !final };
447
+ }
448
+ const seq = raw.slice(start, end);
449
+ const termLen = seq.endsWith('\x07') ? 1 : 2;
450
+ const body = seq.slice(2, seq.length - termLen);
451
+ elements.push({ raw: ESC + ']' + maskPlain(body) + seq.slice(seq.length - termLen), vis: '', text: false });
452
+ i = end;
453
+ }
454
+ else if (next === 'P' || next === '_' || next === '^' || next === 'X') {
455
+ const end = findST(i + 2);
456
+ if (end === -1) {
457
+ if (!final && raw.length - start <= MAX_STRING_HOLD)
458
+ return { elements, rest: raw.slice(start), inString: false };
459
+ elements.push({ raw: raw.slice(start), vis: '', text: false });
460
+ return { elements, rest: '', inString: !final };
461
+ }
462
+ elements.push({ raw: raw.slice(start, end), vis: '', text: false });
463
+ i = end;
464
+ }
465
+ else {
466
+ // ESC [intermediates] final — charset selects, save/restore cursor, keypad modes.
467
+ let j = i + 1;
468
+ while (j < raw.length && raw.charCodeAt(j) >= 0x20 && raw.charCodeAt(j) <= 0x2f)
469
+ j++;
470
+ if (j >= raw.length) {
471
+ if (final) {
472
+ elements.push({ raw: raw.slice(start), vis: '', text: false });
473
+ i = raw.length;
474
+ textStart = i;
475
+ continue;
476
+ }
477
+ return { elements, rest: raw.slice(start), inString: false };
478
+ }
479
+ const seq = raw.slice(start, j + 1);
480
+ elements.push({ raw: seq, vis: next === '7' || next === '8' || next === 'D' || next === 'E' || next === 'M' ? '\n' : '', text: false });
481
+ i = j + 1;
482
+ }
483
+ textStart = i;
484
+ }
485
+ flushText(raw.length);
486
+ return { elements, rest: '', inString: false };
487
+ }
488
+ /** `text` with every position flagged in `masked` (offset by `base`) replaced by MASK_CHAR. */
489
+ function maskText(text, masked, base) {
490
+ if (!masked)
491
+ return text;
492
+ let out = '';
493
+ let from = 0;
494
+ for (let c = 0; c < text.length; c++) {
495
+ if (masked[base + c]) {
496
+ out += text.slice(from, c) + exports.MASK_CHAR;
497
+ from = c + 1;
498
+ }
499
+ }
500
+ return out + text.slice(from);
501
+ }
502
+ /** Visible characters of already-emitted output kept for matching across chunks. */
503
+ const CONTEXT = 512;
504
+ function createStreamRedactor(opts) {
505
+ const idleMs = opts.idleMs ?? 30;
506
+ const maxHold = opts.maxHold ?? 512;
507
+ const maskTrailing = opts.maskTrailing ?? true;
508
+ const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
509
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
510
+ let pending = '';
511
+ let context = '';
512
+ let inString = false;
513
+ let state = { inPem: false, pemBudget: 0 };
514
+ let timer = null;
515
+ const run = (raw, final) => {
516
+ if (!raw)
517
+ return;
518
+ const tok = tokenize(raw, inString, final);
519
+ let vis = '';
520
+ for (const el of tok.elements)
521
+ vis += el.vis;
522
+ const v = context + vis;
523
+ const boundary = context.length;
524
+ const res = scan(v, boundary, state, final, final && maskTrailing);
525
+ let hold = res.hold;
526
+ if (v.length - hold > maxHold)
527
+ hold = v.length;
528
+ const holdLocal = hold - boundary; // index into vis
529
+ let masked = null;
530
+ for (const s of res.spans) {
531
+ masked ??= new Uint8Array(vis.length);
532
+ for (let k = Math.max(s.start, boundary); k < s.end && k < v.length; k++)
533
+ masked[k - boundary] = 1;
534
+ }
535
+ // Emit elements until the one owning the first held visible char.
536
+ let out = '';
537
+ let held = '';
538
+ let k = 0; // visible index at the start of the current element
539
+ let cut = false;
540
+ for (const el of tok.elements) {
541
+ if (cut) {
542
+ held += el.raw;
543
+ continue;
544
+ }
545
+ const end = k + el.vis.length;
546
+ if (holdLocal < end) {
547
+ // The hold starts inside this element.
548
+ cut = true;
549
+ if (!el.text) {
550
+ held += el.raw;
551
+ continue;
552
+ }
553
+ const off = holdLocal - k;
554
+ out += maskText(el.raw.slice(0, off), masked, k);
555
+ held += el.raw.slice(off);
556
+ continue;
557
+ }
558
+ out += el.text ? maskText(el.raw, masked, k) : el.raw;
559
+ k = end;
560
+ }
561
+ held += tok.rest;
562
+ // Only state that covers EMITTED text advances; a held tail is rescanned.
563
+ state = holdLocal >= vis.length ? res.state : scan(v.slice(0, hold), boundary, state, true).state;
564
+ inString = tok.inString;
565
+ context = (context + vis.slice(0, Math.max(0, holdLocal))).slice(-CONTEXT);
566
+ pending = held;
567
+ if (out)
568
+ opts.emit(out);
569
+ };
570
+ const stopTimer = () => {
571
+ if (timer !== null) {
572
+ clearTimer(timer);
573
+ timer = null;
574
+ }
575
+ };
576
+ const flush = () => {
577
+ stopTimer();
578
+ const raw = pending;
579
+ pending = '';
580
+ run(raw, true);
581
+ // A final pass never holds; anything left is an escape it could not finish.
582
+ if (pending) {
583
+ const p = pending;
584
+ pending = '';
585
+ opts.emit(p);
586
+ }
587
+ };
588
+ return {
589
+ push(chunk) {
590
+ if (!chunk)
591
+ return;
592
+ const raw = pending + chunk;
593
+ pending = '';
594
+ run(raw, false);
595
+ if (pending.length > MAX_STRING_HOLD + MAX_OSC) {
596
+ flush();
597
+ return;
598
+ }
599
+ if (pending && timer === null)
600
+ timer = setTimer(() => { timer = null; flush(); }, idleMs);
601
+ if (!pending)
602
+ stopTimer();
603
+ },
604
+ flush,
605
+ dispose() {
606
+ flush();
607
+ stopTimer();
608
+ },
609
+ };
610
+ }
611
+ /** One-shot, ANSI-aware masking of a complete string (logs, replays, screens, files). */
612
+ function redact(text) {
613
+ if (!text)
614
+ return text;
615
+ let out = '';
616
+ const r = createStreamRedactor({ emit: (s) => { out += s; }, maskTrailing: false, setTimer: () => null, clearTimer: () => { } });
617
+ r.push(text);
618
+ r.flush();
619
+ return out;
620
+ }
621
+ /**
622
+ * redact() for large text (a multi-MB transcript) without blocking the event
623
+ * loop: the same stream redactor fed in slices, yielding between them so live
624
+ * terminal frames keep flowing. Same result as redact(text).
625
+ */
626
+ async function redactInSlices(text, sliceChars = 1 << 20) {
627
+ if (text.length <= sliceChars)
628
+ return redact(text);
629
+ let out = '';
630
+ const r = createStreamRedactor({ emit: (s) => { out += s; }, maskTrailing: false, setTimer: () => null, clearTimer: () => { } });
631
+ for (let i = 0; i < text.length; i += sliceChars) {
632
+ r.push(text.slice(i, i + sliceChars));
633
+ await new Promise((resolve) => setImmediate(resolve));
634
+ }
635
+ r.flush();
636
+ return out;
637
+ }