imprint-mcp 0.2.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 (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Credential / PII redaction. Replaces values of known-sensitive fields
3
+ * with `[REDACTED:N]` (N = original length) so the LLM still sees the
4
+ * shape but never the secret. Best-effort — see docs/troubleshooting.md
5
+ * for what it doesn't catch (response bodies, URL path segments, etc.)
6
+ * and how to audit a redacted session.
7
+ *
8
+ * When `opts.replacements` is provided (e.g. by `imprint teach` after the
9
+ * credential-extract pass), the named values are rewritten to literal
10
+ * `${credential.NAME}` placeholders BEFORE the generic byte-length redaction
11
+ * runs. The LLM then sees the placeholders verbatim and emits them into
12
+ * workflow.json without translation.
13
+ */
14
+
15
+ import { splitSetCookieHeader } from './cookie-jar.ts';
16
+ import type { Replacement } from './credential-extract.ts';
17
+ import { hasFreeformRedactionHint, redactFreeformText } from './freeform-redact.ts';
18
+ import { isSensitiveHeader, isSensitiveKey } from './sensitive-keys.ts';
19
+ import type { CapturedRequest, Session } from './types.ts';
20
+
21
+ const USER_INTERACTION_TYPES = new Set(['click', 'input', 'change', 'submit']);
22
+ const MULTI_VALUE_HEADERS = new Set(['cookie', 'set-cookie']);
23
+
24
+ /**
25
+ * Detect sensitive headers whose values are page-minted constants — baked
26
+ * into the site's JavaScript, not per-user secrets. The recording starts
27
+ * from a clean browser with no cookies or stored state, so any sensitive
28
+ * header value present in requests BEFORE the user's first interaction
29
+ * that wasn't set by a prior Set-Cookie or storage snapshot is an app
30
+ * constant and should not be redacted.
31
+ *
32
+ * Returns header names (lowercase) that should be passed to
33
+ * `redactSession()` via `keepHeaders`.
34
+ */
35
+ export function detectPageMintedHeaders(session: Session): string[] {
36
+ const firstInteraction = session.events.find((e) => USER_INTERACTION_TYPES.has(e.type));
37
+ const cutoff = firstInteraction?.timestamp ?? Number.POSITIVE_INFINITY;
38
+
39
+ const producedValues = new Set<string>();
40
+ for (const snap of session.storageSnapshots ?? []) {
41
+ for (const v of Object.values(snap.localStorage ?? {})) producedValues.add(v);
42
+ for (const v of Object.values(snap.sessionStorage ?? {})) producedValues.add(v);
43
+ }
44
+ for (const req of session.requests) {
45
+ if (req.timestamp >= cutoff) break;
46
+ const sc = Object.entries(req.response?.headers ?? {}).find(
47
+ ([n]) => n.toLowerCase() === 'set-cookie',
48
+ )?.[1];
49
+ if (sc) {
50
+ for (const cookie of splitSetCookieHeader(sc)) {
51
+ const first = cookie.split(';', 1)[0] ?? '';
52
+ const eq = first.indexOf('=');
53
+ if (eq > 0) producedValues.add(first.slice(eq + 1));
54
+ }
55
+ }
56
+ }
57
+
58
+ const pageMinted = new Set<string>();
59
+ for (const req of session.requests) {
60
+ if (req.timestamp >= cutoff) break;
61
+ for (const [name, value] of Object.entries(req.headers)) {
62
+ const lower = name.toLowerCase();
63
+ if (!isSensitiveHeader(name)) continue;
64
+ if (MULTI_VALUE_HEADERS.has(lower)) continue;
65
+ if (producedValues.has(value)) continue;
66
+ pageMinted.add(lower);
67
+ }
68
+ }
69
+
70
+ return [...pageMinted];
71
+ }
72
+
73
+ const REDACTED = (originalLength: number): string => `[REDACTED:${originalLength}]`;
74
+
75
+ interface RedactionMarkerContext {
76
+ ids: Map<string, number>;
77
+ nextId: number;
78
+ }
79
+
80
+ function createMarkerContext(): RedactionMarkerContext {
81
+ return { ids: new Map(), nextId: 1 };
82
+ }
83
+
84
+ function markerFor(value: string, ctx?: RedactionMarkerContext): string {
85
+ if (!ctx) return REDACTED(value.length);
86
+ let id = ctx.ids.get(value);
87
+ if (id === undefined) {
88
+ id = ctx.nextId++;
89
+ ctx.ids.set(value, id);
90
+ }
91
+ return `[REDACTED:v3:id=${id}:len=${value.length}]`;
92
+ }
93
+
94
+ interface BodyRedaction {
95
+ redacted: string;
96
+ redactionsCount: number;
97
+ placeholdersInjected: number;
98
+ freeformRedactions: number;
99
+ }
100
+
101
+ /** Redact all values of sensitive keys in a www-form-urlencoded body string.
102
+ * When `placeholderByKey` is given, sensitive keys whose names match get
103
+ * rewritten to the placeholder string instead of `[REDACTED:N]`. */
104
+ export function redactFormBody(
105
+ body: string,
106
+ placeholderByKey?: Map<string, string>,
107
+ markerContext?: RedactionMarkerContext,
108
+ ): BodyRedaction {
109
+ let count = 0;
110
+ let placeholders = 0;
111
+ const parts = body.split('&').map((pair) => {
112
+ const eq = pair.indexOf('=');
113
+ if (eq === -1) return pair;
114
+ const rawKey = pair.slice(0, eq);
115
+ const rawVal = pair.slice(eq + 1);
116
+ let decodedKey: string;
117
+ try {
118
+ decodedKey = decodeURIComponent(rawKey);
119
+ } catch {
120
+ decodedKey = rawKey;
121
+ }
122
+ if (placeholderByKey?.has(decodedKey)) {
123
+ placeholders++;
124
+ const placeholder = placeholderByKey.get(decodedKey) ?? '';
125
+ return `${rawKey}=${placeholder}`;
126
+ }
127
+ if (isSensitiveKey(decodedKey)) {
128
+ count++;
129
+ return `${rawKey}=${markerFor(rawVal, markerContext)}`;
130
+ }
131
+ return pair;
132
+ });
133
+ return {
134
+ redacted: parts.join('&'),
135
+ redactionsCount: count,
136
+ placeholdersInjected: placeholders,
137
+ freeformRedactions: 0,
138
+ };
139
+ }
140
+
141
+ /** Redact sensitive keys inside a JSON-stringified body. Returns body unchanged on parse failure.
142
+ * When `placeholderByPath` is given (path → placeholder), values at those JSON paths get
143
+ * rewritten to the placeholder string. */
144
+ export function redactJsonBody(
145
+ body: string,
146
+ placeholderByPath?: Map<string, string>,
147
+ freeform = true,
148
+ markerContext?: RedactionMarkerContext,
149
+ ): BodyRedaction {
150
+ let parsed: unknown;
151
+ try {
152
+ parsed = JSON.parse(body);
153
+ } catch {
154
+ return { redacted: body, redactionsCount: 0, placeholdersInjected: 0, freeformRedactions: 0 };
155
+ }
156
+
157
+ let count = 0;
158
+ let placeholders = 0;
159
+ let freeformCount = 0;
160
+ const visit = (node: unknown, pathSoFar: string[]): unknown => {
161
+ if (Array.isArray(node)) {
162
+ return node.map((v, i) => visit(v, [...pathSoFar, String(i)]));
163
+ }
164
+ if (node && typeof node === 'object') {
165
+ const out: Record<string, unknown> = {};
166
+ for (const [k, v] of Object.entries(node)) {
167
+ const path = [...pathSoFar, k].join('.');
168
+ const placeholder = placeholderByPath?.get(path);
169
+ if (placeholder !== undefined && (typeof v === 'string' || typeof v === 'number')) {
170
+ placeholders++;
171
+ out[k] = placeholder;
172
+ } else if (isSensitiveKey(k) && (typeof v === 'string' || typeof v === 'number')) {
173
+ count++;
174
+ out[k] = markerFor(String(v), markerContext);
175
+ } else if (typeof v === 'string' && v.length > 1 && (v[0] === '{' || v[0] === '[')) {
176
+ // JSON-in-JSON: try to parse and redact the nested string.
177
+ try {
178
+ const inner = JSON.parse(v);
179
+ const visited = visit(inner, [...pathSoFar, k]);
180
+ out[k] = JSON.stringify(visited);
181
+ } catch {
182
+ const r = freeform ? redactFreeformText(v) : { redacted: v, redactionsCount: 0 };
183
+ freeformCount += r.redactionsCount;
184
+ out[k] = r.redacted;
185
+ }
186
+ } else if (typeof v === 'string' && freeform) {
187
+ const r = redactFreeformText(v);
188
+ freeformCount += r.redactionsCount;
189
+ out[k] = r.redacted;
190
+ } else {
191
+ out[k] = visit(v, [...pathSoFar, k]);
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+ return node;
197
+ };
198
+ const redacted = JSON.stringify(visit(parsed, []));
199
+ return {
200
+ redacted,
201
+ redactionsCount: count,
202
+ placeholdersInjected: placeholders,
203
+ freeformRedactions: freeformCount,
204
+ };
205
+ }
206
+
207
+ /** Redact a request body of unknown content-type. Tries JSON first, falls back to form. */
208
+ export function redactBody(
209
+ body: string,
210
+ contentType?: string,
211
+ formPlaceholders?: Map<string, string>,
212
+ jsonPlaceholders?: Map<string, string>,
213
+ freeform = true,
214
+ markerContext?: RedactionMarkerContext,
215
+ ): BodyRedaction {
216
+ const ct = (contentType ?? '').toLowerCase();
217
+ if (ct.includes('urlencoded')) {
218
+ return redactFormBody(body, formPlaceholders, markerContext);
219
+ }
220
+ // Try JSON first — many APIs send JSON as text/plain or with no content-type.
221
+ const jsonR = redactJsonBody(body, jsonPlaceholders, freeform, markerContext);
222
+ if (jsonR.redactionsCount > 0 || jsonR.placeholdersInjected > 0 || jsonR.freeformRedactions > 0) {
223
+ return jsonR;
224
+ }
225
+ try {
226
+ JSON.parse(body);
227
+ return jsonR;
228
+ } catch {
229
+ const formR = redactFormBody(body, formPlaceholders, markerContext);
230
+ if (formR.redactionsCount > 0 || formR.placeholdersInjected > 0 || !freeform) return formR;
231
+ const freeformR = redactFreeformText(body);
232
+ return {
233
+ redacted: freeformR.redacted,
234
+ redactionsCount: 0,
235
+ placeholdersInjected: 0,
236
+ freeformRedactions: freeformR.redactionsCount,
237
+ };
238
+ }
239
+ }
240
+
241
+ /** Redact sensitive query params from a URL string. */
242
+ export function redactUrl(
243
+ url: string,
244
+ freeform = true,
245
+ markerContext?: RedactionMarkerContext,
246
+ ): { redacted: string; redactionsCount: number; freeformRedactions: number } {
247
+ let parsed: URL;
248
+ try {
249
+ parsed = new URL(url);
250
+ } catch {
251
+ return { redacted: url, redactionsCount: 0, freeformRedactions: 0 };
252
+ }
253
+ let count = 0;
254
+ let freeformCount = 0;
255
+ for (const key of Array.from(parsed.searchParams.keys())) {
256
+ if (isSensitiveKey(key)) {
257
+ const val = parsed.searchParams.get(key) ?? '';
258
+ parsed.searchParams.set(key, markerFor(val, markerContext));
259
+ count++;
260
+ }
261
+ }
262
+ if (freeform && parsed.pathname.length > 1 && hasFreeformRedactionHint(parsed.pathname)) {
263
+ const segments = parsed.pathname.split('/').map((segment) => {
264
+ if (segment.length === 0) return segment;
265
+ let decoded = segment;
266
+ try {
267
+ decoded = decodeURIComponent(segment);
268
+ } catch {
269
+ // Keep the raw segment if it is not valid percent-encoding.
270
+ }
271
+ const r = redactFreeformText(decoded);
272
+ freeformCount += r.redactionsCount;
273
+ return r.redacted;
274
+ });
275
+ parsed.pathname = segments.join('/');
276
+ }
277
+ return {
278
+ redacted: parsed.toString(),
279
+ redactionsCount: count + freeformCount,
280
+ freeformRedactions: freeformCount,
281
+ };
282
+ }
283
+
284
+ /** Redact sensitive headers in-place style (returns a new object). */
285
+ export function redactHeaders(
286
+ headers: Record<string, string>,
287
+ keepHeaders: ReadonlySet<string> = new Set(),
288
+ markerContext?: RedactionMarkerContext,
289
+ ): {
290
+ redacted: Record<string, string>;
291
+ redactionsCount: number;
292
+ } {
293
+ const out: Record<string, string> = {};
294
+ let count = 0;
295
+ for (const [k, v] of Object.entries(headers)) {
296
+ if (isSensitiveHeader(k) && !keepHeaders.has(k.toLowerCase())) {
297
+ const lower = k.toLowerCase();
298
+ if (lower === 'cookie') out[k] = redactCookieHeaderValue(v, markerContext);
299
+ else if (lower === 'set-cookie') out[k] = redactSetCookieHeaderValue(v, markerContext);
300
+ else out[k] = markerFor(v, markerContext);
301
+ count++;
302
+ } else {
303
+ out[k] = v;
304
+ }
305
+ }
306
+ return { redacted: out, redactionsCount: count };
307
+ }
308
+
309
+ function redactCookieHeaderValue(value: string, markerContext?: RedactionMarkerContext): string {
310
+ return value
311
+ .split(';')
312
+ .map((part) => {
313
+ const trimmed = part.trim();
314
+ const eq = trimmed.indexOf('=');
315
+ if (eq <= 0) return trimmed;
316
+ return `${trimmed.slice(0, eq)}=${markerFor(trimmed.slice(eq + 1), markerContext)}`;
317
+ })
318
+ .join('; ');
319
+ }
320
+
321
+ function redactSetCookieHeaderValue(value: string, markerContext?: RedactionMarkerContext): string {
322
+ return splitSetCookieHeader(value)
323
+ .map((cookie) => {
324
+ const parts = cookie.split(';').map((p) => p.trim());
325
+ const first = parts[0] ?? '';
326
+ const eq = first.indexOf('=');
327
+ if (eq <= 0) return cookie;
328
+ const redactedFirst = `${first.slice(0, eq)}=${markerFor(first.slice(eq + 1), markerContext)}`;
329
+ return [redactedFirst, ...parts.slice(1)].join('; ');
330
+ })
331
+ .join(', ');
332
+ }
333
+
334
+ interface RedactionStats {
335
+ /** Number of individual values replaced across the entire session. */
336
+ totalRedactions: number;
337
+ /** Number of requests touched (had at least one redaction). */
338
+ requestsRedacted: number;
339
+ /** Number of cookies whose VALUES were replaced. */
340
+ cookiesRedacted: number;
341
+ /** Values rewritten to a `${credential.X}` placeholder (extracted at teach time). */
342
+ placeholdersInjected: number;
343
+ /** Free-form PII/secrets found by the supplemental regex redactor. */
344
+ freeformRedactions: number;
345
+ /** Detected sensitive items that you should be aware of (for the user-facing report). */
346
+ warnings: string[];
347
+ }
348
+
349
+ interface RedactOptions {
350
+ /**
351
+ * Header names (case-insensitive) to NEVER redact even if they match
352
+ * the sensitive header list. Use for known-public headers like
353
+ * `X-API-Key` on sites where it's an app-level identifier embedded in
354
+ * the page JS rather than a per-user secret.
355
+ */
356
+ keepHeaders?: string[];
357
+ /**
358
+ * Replacements built by `extractCredentials()` to rewrite specific values
359
+ * to `${credential.NAME}` placeholders before the LLM sees them. The
360
+ * placeholders survive into workflow.json verbatim.
361
+ */
362
+ replacements?: Replacement[];
363
+ /** Internal escape hatch for benchmarks/tests that compare structured-only redaction. */
364
+ freeform?: boolean;
365
+ }
366
+
367
+ /** Produce a scrubbed copy of a session safe to send to an LLM. */
368
+ export function redactSession(
369
+ session: Session,
370
+ opts: RedactOptions = {},
371
+ ): { session: Session; stats: RedactionStats } {
372
+ const stats: RedactionStats = {
373
+ totalRedactions: 0,
374
+ requestsRedacted: 0,
375
+ cookiesRedacted: 0,
376
+ placeholdersInjected: 0,
377
+ freeformRedactions: 0,
378
+ warnings: [],
379
+ };
380
+ const keepHeaders = new Set((opts.keepHeaders ?? []).map((h) => h.toLowerCase()));
381
+ const useFreeform = opts.freeform ?? true;
382
+ const markerContext = createMarkerContext();
383
+
384
+ // Group replacements by request seq.
385
+ const replacementsBySeq = new Map<number, Replacement[]>();
386
+ for (const r of opts.replacements ?? []) {
387
+ const arr = replacementsBySeq.get(r.requestSeq) ?? [];
388
+ arr.push(r);
389
+ replacementsBySeq.set(r.requestSeq, arr);
390
+ }
391
+
392
+ const redactedRequests = session.requests.map((req: CapturedRequest) => {
393
+ let touched = 0;
394
+
395
+ const urlR = redactUrl(req.url, useFreeform, markerContext);
396
+ touched += urlR.redactionsCount;
397
+ stats.freeformRedactions += urlR.freeformRedactions;
398
+
399
+ const headersR = redactHeaders(req.headers, keepHeaders, markerContext);
400
+ touched += headersR.redactionsCount;
401
+
402
+ let body = req.body;
403
+ if (body) {
404
+ const ct = req.headers['content-type'] ?? req.headers['Content-Type'];
405
+ const reqReplacements = replacementsBySeq.get(req.seq) ?? [];
406
+ const formPlaceholders = new Map<string, string>();
407
+ const jsonPlaceholders = new Map<string, string>();
408
+ for (const r of reqReplacements) {
409
+ if (r.location.kind === 'body-form') {
410
+ formPlaceholders.set(r.location.key, r.placeholder);
411
+ } else if (r.location.kind === 'body-json') {
412
+ jsonPlaceholders.set(r.location.path.join('.'), r.placeholder);
413
+ }
414
+ }
415
+ const bodyR = redactBody(
416
+ body,
417
+ ct,
418
+ formPlaceholders,
419
+ jsonPlaceholders,
420
+ useFreeform,
421
+ markerContext,
422
+ );
423
+ body = bodyR.redacted;
424
+ touched += bodyR.redactionsCount + bodyR.freeformRedactions;
425
+ stats.placeholdersInjected += bodyR.placeholdersInjected;
426
+ stats.freeformRedactions += bodyR.freeformRedactions;
427
+ }
428
+
429
+ let response = req.response;
430
+ if (response) {
431
+ const respHeadersR = redactHeaders(response.headers, keepHeaders, markerContext);
432
+ touched += respHeadersR.redactionsCount;
433
+ let respBody = response.body;
434
+ if (respBody) {
435
+ const respBodyR = redactBody(
436
+ respBody,
437
+ response.mimeType,
438
+ undefined,
439
+ undefined,
440
+ useFreeform,
441
+ markerContext,
442
+ );
443
+ respBody = respBodyR.redacted;
444
+ touched += respBodyR.redactionsCount + respBodyR.freeformRedactions;
445
+ stats.freeformRedactions += respBodyR.freeformRedactions;
446
+ }
447
+ response = {
448
+ ...response,
449
+ headers: respHeadersR.redacted,
450
+ body: respBody,
451
+ };
452
+ }
453
+
454
+ if (touched > 0) {
455
+ stats.requestsRedacted++;
456
+ stats.totalRedactions += touched;
457
+ }
458
+
459
+ return {
460
+ ...req,
461
+ url: urlR.redacted,
462
+ headers: headersR.redacted,
463
+ body,
464
+ response,
465
+ };
466
+ });
467
+
468
+ const redactedSnapshots = (session.cookieSnapshots ?? []).map((snap) => ({
469
+ ...snap,
470
+ cookies: snap.cookies.map((c) => {
471
+ stats.cookiesRedacted++;
472
+ return { ...c, value: markerFor(c.value, markerContext) };
473
+ }),
474
+ }));
475
+
476
+ const redactedStorageSnapshots = (session.storageSnapshots ?? []).map((snap) => ({
477
+ ...snap,
478
+ localStorage: redactStorageRecord(snap.localStorage, markerContext),
479
+ sessionStorage: redactStorageRecord(snap.sessionStorage, markerContext),
480
+ }));
481
+
482
+ // Scrub captured DOM events too. inject-listener already masks password
483
+ // VALUES at capture time, but other fields (username, email, search terms)
484
+ // come through plaintext. When we have explicit replacements (the teach
485
+ // flow), replace those values verbatim in event detail strings.
486
+ const valueToPlaceholder = new Map<string, string>();
487
+ for (const r of opts.replacements ?? []) {
488
+ valueToPlaceholder.set(r.originalValue, r.placeholder);
489
+ }
490
+ const redactedEvents = session.events.map((ev) => {
491
+ let detail = ev.detail;
492
+ for (const [val, placeholder] of valueToPlaceholder) {
493
+ if (val.length === 0) continue;
494
+ // Avoid replacing inside JSON-string-escaped values that have already
495
+ // been turned into the placeholder (idempotent).
496
+ detail = detail.split(val).join(placeholder);
497
+ }
498
+ if (detail !== ev.detail) {
499
+ stats.placeholdersInjected++;
500
+ }
501
+ if (useFreeform) {
502
+ const freeformR = redactFreeformText(detail);
503
+ if (freeformR.redactionsCount > 0) {
504
+ detail = freeformR.redacted;
505
+ stats.freeformRedactions += freeformR.redactionsCount;
506
+ stats.totalRedactions += freeformR.redactionsCount;
507
+ }
508
+ }
509
+ return { ...ev, detail };
510
+ });
511
+
512
+ // Flag site-specific patterns that survive.
513
+ if (
514
+ session.requests.some(
515
+ (r) => r.body?.includes('patronPassword') || r.url.includes('patronPassword'),
516
+ )
517
+ ) {
518
+ stats.warnings.push('Discover & Go patronPassword detected and redacted.');
519
+ }
520
+ if (
521
+ session.requests.some(
522
+ (r) => r.body?.toLowerCase().includes('password') || r.url.toLowerCase().includes('password'),
523
+ )
524
+ ) {
525
+ // Already handled by the redact pass; just surface for the user-facing report.
526
+ stats.warnings.push('Password field(s) detected and redacted.');
527
+ }
528
+
529
+ return {
530
+ session: {
531
+ ...session,
532
+ requests: redactedRequests,
533
+ events: redactedEvents,
534
+ cookieSnapshots: redactedSnapshots,
535
+ storageSnapshots: redactedStorageSnapshots,
536
+ },
537
+ stats,
538
+ };
539
+ }
540
+
541
+ function redactStorageRecord(
542
+ values: Record<string, string> | undefined,
543
+ markerContext: RedactionMarkerContext,
544
+ ): Record<string, string> {
545
+ const out: Record<string, string> = {};
546
+ for (const [k, v] of Object.entries(values ?? {})) {
547
+ out[k] = isSensitiveKey(k) || hasFreeformRedactionHint(v) ? markerFor(v, markerContext) : v;
548
+ }
549
+ return out;
550
+ }