@zuplo/cli 7.9.9 → 7.9.12

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 (62) hide show
  1. package/node_modules/@posthog/core/dist/featureFlagUtils.d.ts.map +1 -1
  2. package/node_modules/@posthog/core/dist/featureFlagUtils.js +2 -1
  3. package/node_modules/@posthog/core/dist/featureFlagUtils.mjs +2 -1
  4. package/node_modules/@posthog/core/dist/index.d.ts +1 -0
  5. package/node_modules/@posthog/core/dist/index.d.ts.map +1 -1
  6. package/node_modules/@posthog/core/dist/index.js +66 -10
  7. package/node_modules/@posthog/core/dist/index.mjs +1 -0
  8. package/node_modules/@posthog/core/dist/privacy.d.ts +35 -0
  9. package/node_modules/@posthog/core/dist/privacy.d.ts.map +1 -0
  10. package/node_modules/@posthog/core/dist/privacy.js +366 -0
  11. package/node_modules/@posthog/core/dist/privacy.mjs +319 -0
  12. package/node_modules/@posthog/core/package.json +2 -2
  13. package/node_modules/@posthog/core/src/featureFlagUtils.ts +2 -0
  14. package/node_modules/@posthog/core/src/index.ts +1 -0
  15. package/node_modules/@posthog/core/src/privacy.ts +696 -0
  16. package/node_modules/@posthog/types/dist/index.d.ts +1 -1
  17. package/node_modules/@posthog/types/dist/index.d.ts.map +1 -1
  18. package/node_modules/@posthog/types/dist/posthog-config.d.ts +39 -8
  19. package/node_modules/@posthog/types/dist/posthog-config.d.ts.map +1 -1
  20. package/node_modules/@posthog/types/package.json +1 -1
  21. package/node_modules/@posthog/types/src/index.ts +1 -0
  22. package/node_modules/@posthog/types/src/posthog-config.ts +41 -8
  23. package/node_modules/@zuplo/core/customer.cli.minified.js +223 -223
  24. package/node_modules/@zuplo/core/index.minified.js +228 -228
  25. package/node_modules/@zuplo/core/package.json +1 -1
  26. package/node_modules/@zuplo/graphql/package.json +1 -1
  27. package/node_modules/@zuplo/openapi-tools/package.json +1 -1
  28. package/node_modules/@zuplo/otel/package.json +1 -1
  29. package/node_modules/@zuplo/runtime/out/esm/chunk-2XSPVUOE.js +411 -0
  30. package/node_modules/@zuplo/runtime/out/esm/chunk-2XSPVUOE.js.map +1 -0
  31. package/node_modules/@zuplo/runtime/out/esm/chunk-PBDT4FDO.js +37 -0
  32. package/node_modules/@zuplo/runtime/out/esm/{chunk-MWIJG5KC.js.map → chunk-PBDT4FDO.js.map} +1 -1
  33. package/node_modules/@zuplo/runtime/out/esm/{chunk-54HRCPVQ.js → chunk-VTO3F6DK.js} +2 -2
  34. package/node_modules/@zuplo/runtime/out/esm/{chunk-6O4OZCO6.js → chunk-YJ2BHVUH.js} +2 -2
  35. package/node_modules/@zuplo/runtime/out/esm/index.js +1 -1
  36. package/node_modules/@zuplo/runtime/out/esm/index.js.map +1 -1
  37. package/node_modules/@zuplo/runtime/out/esm/internal/index.js +1 -1
  38. package/node_modules/@zuplo/runtime/out/esm/mcp-gateway/index.js +1 -1
  39. package/node_modules/@zuplo/runtime/out/esm/mocks/index.js +1 -1
  40. package/node_modules/@zuplo/runtime/out/esm/sdks/aws/index.js +1 -1
  41. package/node_modules/@zuplo/runtime/out/types/index.d.ts +164 -54
  42. package/node_modules/@zuplo/runtime/out/types/internal/index.d.ts +44 -0
  43. package/node_modules/@zuplo/runtime/package.json +1 -1
  44. package/node_modules/express-rate-limit/dist/index.cjs +9 -7
  45. package/node_modules/express-rate-limit/dist/index.mjs +9 -7
  46. package/node_modules/express-rate-limit/package.json +9 -9
  47. package/node_modules/fast-copy/README.md +8 -7
  48. package/node_modules/fast-copy/dist/cjs/index.cjs +1 -1
  49. package/node_modules/fast-copy/dist/cjs/index.cjs.map +1 -1
  50. package/node_modules/fast-copy/dist/es/index.mjs +1 -1
  51. package/node_modules/fast-copy/dist/es/index.mjs.map +1 -1
  52. package/node_modules/fast-copy/dist/umd/index.js +1 -1
  53. package/node_modules/fast-copy/dist/umd/index.js.map +1 -1
  54. package/node_modules/fast-copy/package.json +1 -1
  55. package/package.json +11 -11
  56. package/node_modules/@zuplo/runtime/out/esm/chunk-MWIJG5KC.js +0 -37
  57. package/node_modules/@zuplo/runtime/out/esm/chunk-WFAFGL5Y.js +0 -411
  58. package/node_modules/@zuplo/runtime/out/esm/chunk-WFAFGL5Y.js.map +0 -1
  59. /package/node_modules/@zuplo/runtime/out/esm/{chunk-WFAFGL5Y.js.LEGAL.txt → chunk-2XSPVUOE.js.LEGAL.txt} +0 -0
  60. /package/node_modules/@zuplo/runtime/out/esm/{chunk-MWIJG5KC.js.LEGAL.txt → chunk-PBDT4FDO.js.LEGAL.txt} +0 -0
  61. /package/node_modules/@zuplo/runtime/out/esm/{chunk-54HRCPVQ.js.map → chunk-VTO3F6DK.js.map} +0 -0
  62. /package/node_modules/@zuplo/runtime/out/esm/{chunk-6O4OZCO6.js.map → chunk-YJ2BHVUH.js.map} +0 -0
@@ -0,0 +1,696 @@
1
+ // Portions of this file are derived from agentcathq/agentcat-typescript-sdk
2
+ // (formerly MCPCat/mcpcat-typescript-sdk)
3
+ // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
+ // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
+
6
+ export interface TextSanitizationOptions {
7
+ maxStringLength: number
8
+ truncationSuffix: string
9
+ }
10
+
11
+ const REDACTED_VALUE = '[redacted]'
12
+ const BINARY_REDACTED_VALUE = '[binary data redacted - not supported by PostHog MCP analytics]'
13
+ const BASE64_PATTERN = /^[A-Za-z0-9+/\n\r]+=*$/
14
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/
15
+ const BASE64URL_SPECIFIC_CHAR_PATTERN = /[-_]/
16
+ const BASE64_DATA_URL_PREFIX_PATTERN = /^data:[^,\s]*;base64,/i
17
+ const BASE64_DATA_URL_PAYLOAD_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/
18
+ const SIZE_GATE = 10_240
19
+ const POSTHOG_TOKEN_PATTERN = /\bph[a-z]_[A-Za-z0-9_-]{20,}\b/g
20
+ const SENSITIVE_KEY_PATTERN =
21
+ /^(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token|access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)$/i
22
+
23
+ // Deliberately no leading `\b`: `_` is a word character but not scheme-legal, so
24
+ // `resource_https://user:pw@host` has no boundary to anchor to and would keep its
25
+ // credentials. Leftmost matching makes `foo.https://x` match from `f` with scheme
26
+ // `foo.https`, which redacts the same URL and is therefore harmless.
27
+ //
28
+ // `'` is a valid URI sub-delimiter, so the terminal class must not stop at one:
29
+ // excluding it truncated `https://example.com/o'reilly?token=x` at the path and
30
+ // shipped the token in the clear. Only the characters that are never valid
31
+ // unencoded in a URI are excluded.
32
+ //
33
+ // The authority is optional too. An MCP resource URI frequently has none
34
+ // (`resource:guide?token=x`, `file:/guide.md`), and requiring `//` let those
35
+ // through untouched; `[^\s<>"]+` absorbs a `//host` when there is one. The cost
36
+ // is over-matching ordinary prose (`Error:foo`, `at12:30`, `C:\path`), which is
37
+ // harmless: a match with nothing to redact is returned byte-for-byte, so the
38
+ // text around it is never rewritten.
39
+ const URL_PATTERN = /[a-z][a-z0-9+.-]{0,63}:[^\s<>"]+/gi
40
+ // A character no URL match can contain, so a cut just after one splits no URL.
41
+ const URL_END_PATTERN = /[\s<>"]/
42
+ /** The same pattern without `g`, for asking whether a value holds a URL at all. */
43
+ const URL_PATTERN_ONCE = new RegExp(URL_PATTERN.source, 'i')
44
+ /** Finds the first scheme that brings a real authority, i.e. what the pattern required before it went authority-less. */
45
+ const URL_AUTHORITY_SEARCH = /[a-z][a-z0-9+.-]{0,63}:\/\//i
46
+ /** The same, anchored: does this match *open* with an authority? */
47
+ const URL_AUTHORITY_PATTERN = new RegExp(`^${URL_AUTHORITY_SEARCH.source}`, 'i')
48
+ /** Every authority start in a value, in order. */
49
+ const URL_AUTHORITY_SEARCH_ALL = new RegExp(URL_AUTHORITY_SEARCH.source, 'gi')
50
+ /** The field separators. `?` and `#` are structural only as delimiters — see {@link isStructural}. */
51
+ const URL_FIELD_CHARACTERS = '=&'
52
+ /** The legacy field separator. Never split on, only checked; see {@link sanitizeUrlFieldValue}. */
53
+ const LEGACY_FIELD_SEPARATOR = ';'
54
+ // The terminal class above also absorbs the prose punctuation that follows a URL
55
+ // in a sentence, `'` included now that a URL can contain one. See
56
+ // `splitTrailingPunctuation`.
57
+ const URL_TRAILING_PUNCTUATION = ".,;:!?)]}'"
58
+ const MAX_URL_LENGTH = 8192
59
+ const MAX_URL_QUERY_FIELDS = 128
60
+ // A query key is sensitive when any `-`/`_`/`.`/`/`/`;`-delimited segment names
61
+ // a credential, so compound names (`private_token`, `oauth_signature`,
62
+ // `subscription-key`, `X-Amz-Security-Token`, `Key-Pair-Id`) are covered without
63
+ // enumerating every vendor's spelling. `/` is a separator because a fragment's
64
+ // field list can be written `#/token=…`, and `;` because a legacy `;`-separated
65
+ // pair parses into a single key (`download;token`) — either would otherwise hide
66
+ // the name. Over-redacting a benign `sort_key` — or `sort/key`, or `sort;key` —
67
+ // is the accepted trade for an analytics payload.
68
+ const SENSITIVE_QUERY_KEY_SEGMENT_PATTERN =
69
+ /(^|[-_./;])(auth|token|secret|password|passwd|pwd|credential|signature|sig|key|hmac|sas|bearer|jwt|session|sessionid)([-_./;]|$)/i
70
+ // `code` — the OAuth authorization code — is matched only as a whole key: as a
71
+ // segment it would eat `country_code`, `zip_code`, and `lang_code`.
72
+ const SENSITIVE_QUERY_KEY_EXACT_PATTERN = /^(code|AWSAccessKeyId|GoogleAccessId|Policy)$/i
73
+
74
+ // PII redaction for the agent-narrated intent string only. `$mcp_intent` is free
75
+ // text the calling LLM writes into the injected `context` argument, so it can
76
+ // carry personal data the model read aloud despite being told not to. We redact
77
+ // well-defined *structured identifiers* — the kind regex can match with high
78
+ // precision. Person names and postal addresses are deliberately out of scope:
79
+ // they need an NER model that a client SDK cannot ship, and naive patterns would
80
+ // over-redact ordinary prose. Patterns are ordered so an earlier pass never eats
81
+ // digits a later pass needs (email before phone, IPs before phone, cards before
82
+ // the generic phone pass). See `redactPii`.
83
+ // Horizontal Unicode spaces (NBSP, narrow NBSP, ideographic space, ...) are what
84
+ // appear when text is copied from web pages or PDFs. `redactPii` normalizes them
85
+ // to an ASCII space first so the separator-based card/phone/SSN candidates match
86
+ // them instead of leaking the identifier they group.
87
+ const UNICODE_SPACE_PATTERN = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g
88
+ // Quantifiers are bounded to RFC-ish limits (local-part <=64, domain <=255,
89
+ // TLD <=24) rather than open-ended `+`. Unbounded `+` here is quadratic: on a
90
+ // long run of local-part chars with no valid `.tld`, `replace` rescans from
91
+ // every start position. `$mcp_intent` is attacker-influenceable free text seen
92
+ // before truncation, so an open-ended pattern is a reachable event-loop stall.
93
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
94
+ const IPV4_PATTERN = /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g
95
+ // Four forms: full 8-group, `::`-terminated (`2001:db8::`), a middle `::`
96
+ // (`2001:db8::8a2e:1`), and a leading `::` (`::1`). The compressed branches use a
97
+ // captured-prefix boundary so a hex-looking C++ scope like `std::bad` — whose
98
+ // left side is not a valid hex group — is not mistaken for an address, while a
99
+ // genuinely address-shaped `dead::beef` still matches. Capturing the prefix
100
+ // avoids lookbehind, which the browser SDK's ES5 bundle cannot contain.
101
+ const IPV6_PATTERN =
102
+ /(^|[^\w:])((?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}|::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})))(?=$|[^\w:])/g
103
+ // A separator (space, dot, or dash) is required between the 3-2-4 groups so bare
104
+ // 9-digit IDs are never mistaken for an SSN.
105
+ const US_SSN_PATTERN = /\b\d{3}[ .-]\d{2}[ .-]\d{4}\b/g
106
+ // A run of >=13 digits optionally grouped by a single space, dot, dash, or
107
+ // slash. This only marks the numeric region; `redactCardInMatch` then looks for
108
+ // the actual card as a run of whole separator-delimited groups that passes Luhn,
109
+ // so an adjacent field such as an expiry (`4111 1111 1111 1111 12/30`) is not
110
+ // absorbed into a failing check that would leak the card.
111
+ const CREDIT_CARD_CANDIDATE_PATTERN = /\b\d(?:[ ./-]?\d){12,}\b/g
112
+ // Matches each separator-delimited digit group inside a card candidate.
113
+ const DIGIT_GROUP_PATTERN = /\d+/g
114
+ // Phone matching is structural rather than "any 10–15 digits", so dates
115
+ // (`2024-01-15 12:30`) and dotted versions are not mistaken for numbers. Two
116
+ // forms: a North-American 3-3-4 grouping, and an international number that must
117
+ // start with `+` and a country code. The area code is either `(415)` (the
118
+ // separator after it is optional, so `(415)555-0142` matches) or a bare `415`
119
+ // that must be followed by a separator (space, dot, dash, or slash) — so a bare
120
+ // digit run is never taken for a phone number.
121
+ const PHONE_NANP_PATTERN = /(^|[^\w+])((?:\+?1[ ./-]?)?(?:\(\d{3}\)[ ./-]?|\d{3}[ ./-])\d{3}[ ./-]\d{4})(?![\w])/g
122
+ const PHONE_INTL_PATTERN = /(^|[^\w])(\+\d{1,3}(?:[ ./()-]{0,2}\d){7,13})(?![\w])/g
123
+
124
+ type JsonRecord = Record<string, unknown>
125
+
126
+ function isRecord(value: unknown): value is JsonRecord {
127
+ return !!value && typeof value === 'object' && !Array.isArray(value)
128
+ }
129
+
130
+ function shouldRedactKey(key: string): boolean {
131
+ return SENSITIVE_KEY_PATTERN.test(key)
132
+ }
133
+
134
+ function shouldRedactQueryKey(key: string): boolean {
135
+ return (
136
+ shouldRedactKey(key) || SENSITIVE_QUERY_KEY_SEGMENT_PATTERN.test(key) || SENSITIVE_QUERY_KEY_EXACT_PATTERN.test(key)
137
+ )
138
+ }
139
+
140
+ function isBase64DataUrl(value: string): boolean {
141
+ const prefix = BASE64_DATA_URL_PREFIX_PATTERN.exec(value)
142
+ if (!prefix) {
143
+ return false
144
+ }
145
+
146
+ let payload: string
147
+ try {
148
+ payload = decodeURIComponent(value.slice(prefix[0].length))
149
+ } catch {
150
+ return false
151
+ }
152
+
153
+ return BASE64_DATA_URL_PAYLOAD_PATTERN.test(payload.replace(/[\r\n]/g, ''))
154
+ }
155
+
156
+ function exceedsUrlFieldLimit(fields: string): boolean {
157
+ return fields.split('&', MAX_URL_QUERY_FIELDS + 1).length > MAX_URL_QUERY_FIELDS
158
+ }
159
+
160
+ /** How much of the sanitizer one call is allowed to apply. */
161
+ interface UrlSanitizeMode {
162
+ /** Sanitize a URL found inside a retained field value. Off past depth 1. */
163
+ allowNestedUrls: boolean
164
+ /** Split prose punctuation off the end. Off when the match is a whole string. */
165
+ stripPunctuation: boolean
166
+ }
167
+
168
+ /** Whether any `;`-separated piece of `value` names a credential. */
169
+ function hasLegacySensitiveField(value: string): boolean {
170
+ return value.split(LEGACY_FIELD_SEPARATOR).some((piece) => shouldRedactQueryKey(piece.split('=', 1)[0]))
171
+ }
172
+
173
+ /** The sanitized value of one query or fragment field. */
174
+ function sanitizeUrlFieldValue(key: string, value: string, allowNestedUrls: boolean): string {
175
+ if (shouldRedactQueryKey(key)) {
176
+ return REDACTED_VALUE
177
+ }
178
+ // `;` once separated fields the way `&` does, so a value carrying one may hold
179
+ // a credential field inside it. Splitting on it is not an option — that cuts a
180
+ // credential's own value in two and publishes the tail as a bare key
181
+ // (`?password=prefix;rest`) — so instead the whole value goes when any piece
182
+ // of it names a credential.
183
+ if (value.includes(LEGACY_FIELD_SEPARATOR) && hasLegacySensitiveField(value)) {
184
+ return REDACTED_VALUE
185
+ }
186
+ if (!URL_PATTERN_ONCE.test(value)) {
187
+ return value
188
+ }
189
+ // One level of nesting is the whole budget. Past it a URL-bearing value is
190
+ // dropped rather than trusted: nothing deeper would sanitize it, so a gateway
191
+ // address wrapping a gateway address would otherwise ship its credentials.
192
+ return allowNestedUrls
193
+ ? sanitizeUrlsInString(value, { allowNestedUrls: false, stripPunctuation: true })
194
+ : REDACTED_VALUE
195
+ }
196
+
197
+ /**
198
+ * Redacts the values of credential-named fields in one `&`-separated field list
199
+ * — a query string or a fragment. `serialized` is only meaningful when `changed`
200
+ * is true, so an untouched part keeps its original encoding instead of being
201
+ * re-serialized. `lastFieldSensitive` says whether whatever follows the list
202
+ * could be more of a credential, which is what decides both the fragment tail
203
+ * and restored prose punctuation; see {@link sanitizeUrl}.
204
+ */
205
+ function sanitizeUrlFields(
206
+ fields: string,
207
+ allowNestedUrls: boolean
208
+ ): { changed: boolean; lastFieldSensitive: boolean; serialized: string } {
209
+ const sanitized = new URLSearchParams()
210
+ let changed = false
211
+ let lastFieldSensitive = false
212
+ for (const [key, value] of new URLSearchParams(fields)) {
213
+ const sanitizedValue = sanitizeUrlFieldValue(key, value, allowNestedUrls)
214
+ // `changed` is compared, not inferred from the key: the PostHog-token pass
215
+ // runs first, so a value can already read `[redacted]`, and calling that a
216
+ // change would re-serialize the field only to percent-encode it.
217
+ changed ||= sanitizedValue !== value
218
+ // Sensitivity is the separate question — whether what follows this field
219
+ // could be more of a credential — and an already-redacted value is still a
220
+ // credential's field.
221
+ lastFieldSensitive = shouldRedactQueryKey(key) || sanitizedValue !== value
222
+ sanitized.append(key, sanitizedValue)
223
+ }
224
+ return { changed, lastFieldSensitive, serialized: sanitized.toString() }
225
+ }
226
+
227
+ /**
228
+ * Splits the prose punctuation that follows a URL in a sentence off the end of a
229
+ * match, so it is not parsed as part of the address and can be re-appended
230
+ * verbatim to whatever the sanitizer returns.
231
+ *
232
+ * Walked back character by character rather than matched with a `$`-anchored
233
+ * pattern: a captured URI is attacker-influenceable, and on a long punctuation
234
+ * run that does not end the match a backtracking pattern is quadratic.
235
+ */
236
+ function splitTrailingPunctuation(value: string): { address: string; suffix: string } {
237
+ let end = value.length
238
+ while (end > 0 && URL_TRAILING_PUNCTUATION.includes(value[end - 1])) {
239
+ end--
240
+ }
241
+ return { address: value.slice(0, end), suffix: value.slice(end) }
242
+ }
243
+
244
+ /**
245
+ * A fragment's tail, given how its head came out.
246
+ *
247
+ * A `?` inside a credential looks exactly like the one between a route and its
248
+ * fields. So when the head's last field is a credential's, everything after that
249
+ * `?` may be the rest of its value — `#password=prefix?rest` — and the
250
+ * tail goes whole rather than being read on its own terms. An empty tail has
251
+ * nothing to hide and stays empty. Either way it counts as a rewritten trailing
252
+ * field, so prose punctuation after the URL goes with it for the same reason.
253
+ */
254
+ function sanitizeFragmentTail(
255
+ tail: string,
256
+ headLastFieldSensitive: boolean,
257
+ allowNestedUrls: boolean
258
+ ): { value: string; lastFieldSensitive: boolean } {
259
+ if (!headLastFieldSensitive) {
260
+ return sanitizeFragmentPart(tail, allowNestedUrls)
261
+ }
262
+ return { value: tail === '' ? '' : REDACTED_VALUE, lastFieldSensitive: true }
263
+ }
264
+
265
+ /**
266
+ * Splits a fragment at its first `?`. `tail` is null when there is none.
267
+ *
268
+ * Nothing is assumed about either part. Shape alone cannot tell a router's route
269
+ * from a field list — `#/docs/id=1?token=…` puts an `=` in the route, and
270
+ * `#/token=…&next=https://x/?page=1` puts a `/` and a `?` in a field list — so
271
+ * each part is read as fields when it holds an `=` and as text otherwise.
272
+ */
273
+ function splitFragmentParts(hash: string): { head: string; tail: string | null } {
274
+ const fragment = hash.slice(1)
275
+ const separator = fragment.indexOf('?')
276
+ if (separator < 0) {
277
+ return { head: fragment, tail: null }
278
+ }
279
+ return { head: fragment.slice(0, separator), tail: fragment.slice(separator + 1) }
280
+ }
281
+
282
+ /** One fragment part, and whether its last field was rewritten. */
283
+ function sanitizeFragmentPart(part: string, allowNestedUrls: boolean): { value: string; lastFieldSensitive: boolean } {
284
+ if (!part.includes('=')) {
285
+ return { value: sanitizeFragmentText(part, allowNestedUrls), lastFieldSensitive: false }
286
+ }
287
+ const fields = sanitizeUrlFields(part, allowNestedUrls)
288
+ // Verbatim unless something was actually rewritten: only the part that changed
289
+ // should change encoding.
290
+ return { value: fields.changed ? fields.serialized : part, lastFieldSensitive: fields.lastFieldSensitive }
291
+ }
292
+
293
+ /**
294
+ * Sanitizes the part of a fragment that is text rather than fields — a plain
295
+ * `#intro`, or the route in front of a field list. It is text, and text can
296
+ * carry an address, so it gets the URL pass.
297
+ *
298
+ * Depth is capped the way a field value's is: at the nested level a URL-bearing
299
+ * text is dropped rather than descended into. That is also what stops
300
+ * `resource:x#resource:x#…` from recursing once per `#`.
301
+ */
302
+ function sanitizeFragmentText(text: string, allowNestedUrls: boolean): string {
303
+ if (!allowNestedUrls) {
304
+ return URL_PATTERN_ONCE.test(text) ? REDACTED_VALUE : text
305
+ }
306
+ return sanitizeUrlsInString(text, { allowNestedUrls: false, stripPunctuation: true })
307
+ }
308
+
309
+ /**
310
+ * Every offset inside `value` where an adjacent address starts; see
311
+ * {@link sanitizeUrl}.
312
+ *
313
+ * An authority is in *value position* — left alone, because the field pass
314
+ * already gives it the nested pass — when a field name's `=` is the nearest
315
+ * structural character behind it. Structural means a field separator (`=`, `&`,
316
+ * `;`) or one of the URL's three delimiters; see {@link isDelimiter}. Before any
317
+ * delimiter no `=` has been seen, so `…/redirect=https://user:pw@host` is two
318
+ * addresses rather than one field.
319
+ *
320
+ * Decided in one forward walk, not a backward scan per authority. The scan was
321
+ * quadratic on a value that opens its fields once and then runs thousands of
322
+ * addresses together, because every one of them scanned back to the same `?`.
323
+ */
324
+ function findEmbeddedAuthorityIndexes(value: string): number[] {
325
+ // The search tries up to 64 scheme characters at every position, which a long
326
+ // base64 `data:` URI turns into seconds; without `://` it cannot match at all.
327
+ if (!value.includes('://')) {
328
+ return []
329
+ }
330
+ const starts = [...value.matchAll(URL_AUTHORITY_SEARCH_ALL)].map((match) => match.index)
331
+ const indexes: number[] = []
332
+ const delimiters = { query: false, fragment: false, fragmentQuery: false }
333
+ let pending = 0
334
+ let inFields = false
335
+ let lastStructural = ''
336
+ for (let cursor = 0; cursor < value.length && pending < starts.length; cursor++) {
337
+ if (starts[pending] === cursor) {
338
+ if (cursor > 0 && lastStructural !== '=') {
339
+ indexes.push(cursor)
340
+ }
341
+ pending++
342
+ }
343
+ const character = value[cursor]
344
+ if (isDelimiter(character, delimiters, lastStructural)) {
345
+ lastStructural = character
346
+ inFields = true
347
+ } else if (inFields && URL_FIELD_CHARACTERS.includes(character)) {
348
+ lastStructural = character
349
+ }
350
+ }
351
+ return indexes
352
+ }
353
+
354
+ /**
355
+ * Whether this `?` or `#` is one of a URL's three delimiters, consuming it if so.
356
+ *
357
+ * A URL has exactly one query delimiter, one fragment delimiter, and — matching
358
+ * how the fragment is split — one delimiter between the fragment's own head and
359
+ * tail. Every other `?` or `#` is ordinary text inside a value, and reading one
360
+ * as structural would cut `?token=prefix?https://secret/…` in half at a `?` that
361
+ * belongs to the token.
362
+ */
363
+ function isDelimiter(
364
+ character: string,
365
+ seen: { query: boolean; fragment: boolean; fragmentQuery: boolean },
366
+ lastStructural: string
367
+ ): boolean {
368
+ if (character === '#') {
369
+ return seen.fragment ? false : (seen.fragment = true)
370
+ }
371
+ if (character !== '?') {
372
+ return false
373
+ }
374
+ if (seen.fragment) {
375
+ // Inside a fragment, a `?` right after a field name may belong to that
376
+ // field's value rather than open the tail. Leaving it unstructural keeps a
377
+ // following address attached, so the fragment split and its fail-closed tail
378
+ // rule get to decide — they can see the whole credential, this cannot.
379
+ if (lastStructural === '=') {
380
+ return false
381
+ }
382
+ return seen.fragmentQuery ? false : (seen.fragmentQuery = true)
383
+ }
384
+ return seen.query ? false : (seen.query = true)
385
+ }
386
+
387
+ /**
388
+ * Redacts the credentials embedded in one URL-shaped match: the userinfo, plus
389
+ * the values of credential-named query and fragment fields. A retained value that
390
+ * is itself a URL — a gateway's `?url=` passthrough — gets the same pass one
391
+ * level deep.
392
+ *
393
+ * One match can hold more than one address: a prose word in front of it
394
+ * (`Failed URL:https://…`, `a:b:https://…`), several run together
395
+ * (`…/doc,https://…`), or one parked in another's query or fragment
396
+ * (`…/?download)[b](https://…`). The later address always begins inside what
397
+ * would parse as the earlier one's path, query key, or fragment — none of which
398
+ * is examined for userinfo — so it is cut out and sanitized on its own.
399
+ *
400
+ * The exception is an authority in value position (see
401
+ * {@link findEmbeddedAuthorityIndexes}), which the field pass already gives the
402
+ * nested pass. Cutting there would take a gateway's `?url=https://…` out of the
403
+ * field it belongs to.
404
+ *
405
+ * One pass, no recursion: after the cuts, every authority left inside a piece is
406
+ * either at its start or in value position, so no piece can need cutting again.
407
+ *
408
+ * Every piece but the last is sanitized without the punctuation split: it is
409
+ * followed by an address rather than by prose, so its last character is a
410
+ * separator, not a sentence's. (It matters — stripping the `:` off `URL:` would
411
+ * leave `URL`, which `new URL()` rejects, and the prose word would become
412
+ * `[redacted]`.)
413
+ */
414
+ function sanitizeUrl(value: string, mode: UrlSanitizeMode): string {
415
+ const embedded = findEmbeddedAuthorityIndexes(value)
416
+ if (embedded.length === 0) {
417
+ return sanitizeSingleUrl(value, mode)
418
+ }
419
+ let result = ''
420
+ let start = 0
421
+ for (const index of embedded) {
422
+ result += sanitizeSingleUrl(value.slice(start, index), { ...mode, stripPunctuation: false })
423
+ start = index
424
+ }
425
+ return result + sanitizeSingleUrl(value.slice(start), mode)
426
+ }
427
+
428
+ /** {@link sanitizeUrl} for a value already known to hold exactly one address. */
429
+ function sanitizeSingleUrl(value: string, mode: UrlSanitizeMode): string {
430
+ // The length bound caps the work an attacker-shaped address can force, so it
431
+ // only applies to a match that has an authority. A long authority-less match
432
+ // is usually a data URI; it is parsed like any other, which is linear in
433
+ // `new URL()` and still capped by the field bound below, and comes back
434
+ // byte-for-byte when it holds nothing to redact.
435
+ if (value.length > MAX_URL_LENGTH && URL_AUTHORITY_PATTERN.test(value)) {
436
+ return REDACTED_VALUE
437
+ }
438
+
439
+ const { address, suffix } = mode.stripPunctuation ? splitTrailingPunctuation(value) : { address: value, suffix: '' }
440
+ let url: URL
441
+ try {
442
+ url = new URL(address)
443
+ } catch {
444
+ return REDACTED_VALUE + suffix
445
+ }
446
+
447
+ const query = url.search.slice(1)
448
+ const hasFragment = url.hash !== ''
449
+ const fragment = splitFragmentParts(url.hash)
450
+ if (exceedsUrlFieldLimit(query) || exceedsUrlFieldLimit(fragment.head) || exceedsUrlFieldLimit(fragment.tail ?? '')) {
451
+ return REDACTED_VALUE + suffix
452
+ }
453
+
454
+ let changed = false
455
+ if (url.username || url.password) {
456
+ url.username = REDACTED_VALUE
457
+ url.password = ''
458
+ changed = true
459
+ }
460
+ const sanitizedQuery = sanitizeUrlFields(query, mode.allowNestedUrls)
461
+ if (sanitizedQuery.changed) {
462
+ url.search = sanitizedQuery.serialized
463
+ changed = true
464
+ }
465
+ const sanitizedHead = sanitizeFragmentPart(fragment.head, mode.allowNestedUrls)
466
+ const sanitizedTail =
467
+ fragment.tail === null
468
+ ? null
469
+ : sanitizeFragmentTail(fragment.tail, sanitizedHead.lastFieldSensitive, mode.allowNestedUrls)
470
+ const sanitizedHash = sanitizedTail === null ? sanitizedHead.value : `${sanitizedHead.value}?${sanitizedTail.value}`
471
+ if (sanitizedHash !== url.hash.slice(1)) {
472
+ url.hash = sanitizedHash
473
+ changed = true
474
+ }
475
+
476
+ // The punctuation split off the end may be the tail of the very credential
477
+ // just replaced (`?password=fakepass!!!`) rather than the sentence's. When the
478
+ // last field of the URL's trailing part is a credential's the punctuation goes
479
+ // with it; losing a comma from the surrounding prose is the accepted cost of
480
+ // not shipping `!!!`.
481
+ const trailingFields = hasFragment ? (sanitizedTail ?? sanitizedHead) : sanitizedQuery
482
+ return (changed ? url.toString() : address) + (trailingFields.lastFieldSensitive ? '' : suffix)
483
+ }
484
+
485
+ /**
486
+ * Replaces every URL-shaped match in `text`.
487
+ *
488
+ * A match that IS the whole string is an address rather than prose — a captured
489
+ * `$mcp_resource_name`, a `params.uri`, a nested `?url=` value — so nothing is
490
+ * split off its end: a trailing `!` there belongs to the URL.
491
+ */
492
+ function sanitizeUrlsInString(text: string, mode: UrlSanitizeMode): string {
493
+ return text.replace(URL_PATTERN, (match) =>
494
+ sanitizeUrl(match, { ...mode, stripPunctuation: mode.stripPunctuation && match.length !== text.length })
495
+ )
496
+ }
497
+
498
+ /** Whether a string is large enough, and shaped enough like base64, to be a blob. */
499
+ function isBinaryBlob(value: string): boolean {
500
+ return (
501
+ value.length >= SIZE_GATE &&
502
+ (BASE64_PATTERN.test(value) ||
503
+ isBase64DataUrl(value) ||
504
+ (BASE64URL_SPECIFIC_CHAR_PATTERN.test(value) && BASE64URL_PATTERN.test(value)))
505
+ )
506
+ }
507
+
508
+ /** Replaces PostHog API keys wherever they appear in a string. */
509
+ function redactCredentials(value: string): string {
510
+ return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
511
+ }
512
+
513
+ /** Replaces every URL-shaped match, credentials and all. */
514
+ function redactUrls(value: string): string {
515
+ return sanitizeUrlsInString(value, { allowNestedUrls: true, stripPunctuation: true })
516
+ }
517
+
518
+ /**
519
+ * Runs `redact` over only the head of `value` that truncation can keep, so a
520
+ * multi-megabyte tool result costs about the same as the part captured. The head
521
+ * ends where no URL crosses it, because a URL is redacted or kept on the whole
522
+ * address. Redaction shrinks text, so a head that ends up too short to cover
523
+ * truncation's cut falls back to the whole string. Either way the kept part
524
+ * reads exactly as if the whole string were redacted.
525
+ */
526
+ function redactCapturedHead(
527
+ value: string,
528
+ redact: (value: string) => string,
529
+ options: TextSanitizationOptions
530
+ ): string {
531
+ const scannedHeadLength = 4 * options.maxStringLength
532
+ if (value.length > scannedHeadLength) {
533
+ const head = redact(value.slice(0, headLength(value, scannedHeadLength)))
534
+ // Twice what truncation keeps, so a match split where the head was cut sits
535
+ // past the part truncation keeps.
536
+ if (head.length >= 2 * options.maxStringLength) {
537
+ return head + options.truncationSuffix
538
+ }
539
+ }
540
+ return redact(value)
541
+ }
542
+
543
+ /** The scanned head, shortened to end just after the last character that ends a URL. */
544
+ function headLength(value: string, scannedHeadLength: number): number {
545
+ let end = scannedHeadLength
546
+ while (end > 0 && !URL_END_PATTERN.test(value[end - 1])) {
547
+ end--
548
+ }
549
+ return end
550
+ }
551
+
552
+ function sanitizeString(value: string, options: TextSanitizationOptions): string {
553
+ return isBinaryBlob(value)
554
+ ? BINARY_REDACTED_VALUE
555
+ : redactCapturedHead(value, (head) => redactUrls(redactCredentials(head)), options)
556
+ }
557
+
558
+ /**
559
+ * Sanitizes agent-narrated free text (`$mcp_intent`, the `send_feedback`
560
+ * fields): structured PII on top of the passes every captured string gets.
561
+ *
562
+ * Every step of the order is load-bearing. The binary gate reads the value as it
563
+ * arrived, because splicing `[redacted]` into a blob — a Luhn-valid run inside
564
+ * base64 is enough — stops it looking like base64 and would ship it whole.
565
+ * Credentials go before PII, because a PII pattern can cut a token in half (a
566
+ * phone-shaped run inside one) and leave the halves behind. PII goes before the
567
+ * URL pass, because the URL rewrite percent-encodes the `@` that the email
568
+ * pattern anchors on.
569
+ */
570
+ export function sanitizeFreeText(value: string, options: TextSanitizationOptions): string {
571
+ if (isBinaryBlob(value)) {
572
+ return BINARY_REDACTED_VALUE
573
+ }
574
+ return redactCapturedHead(value, (head) => redactUrls(redactPii(redactCredentials(head))), options)
575
+ }
576
+
577
+ function passesLuhn(digits: string): boolean {
578
+ let sum = 0
579
+ let double = false
580
+ for (let index = digits.length - 1; index >= 0; index--) {
581
+ let digit = digits.charCodeAt(index) - 48
582
+ if (digit < 0 || digit > 9) {
583
+ return false
584
+ }
585
+ if (double) {
586
+ digit *= 2
587
+ if (digit > 9) {
588
+ digit -= 9
589
+ }
590
+ }
591
+ sum += digit
592
+ double = !double
593
+ }
594
+ return sum % 10 === 0
595
+ }
596
+
597
+ // A card candidate can span more than one card plus adjacent fields (e.g. an
598
+ // expiry, or a second card). This redacts every card in it: scanning
599
+ // left-to-right over the whole separator-delimited digit groups, at each start it
600
+ // takes the longest run whose joined digits are 13–19 long and pass Luhn, redacts
601
+ // just that span, and resumes after it; a group that begins no valid run is kept
602
+ // and skipped. Checking group-aligned runs rather than arbitrary digit windows
603
+ // keeps the false-positive rate at Luhn's own ~1-in-10, instead of letting a
604
+ // chance-valid sub-window of an ordinary long ID trigger redaction.
605
+ function redactCardInMatch(match: string): string {
606
+ const groups: { digits: string; start: number; end: number }[] = []
607
+ for (let m = DIGIT_GROUP_PATTERN.exec(match); m !== null; m = DIGIT_GROUP_PATTERN.exec(match)) {
608
+ groups.push({ digits: m[0], start: m.index, end: m.index + m[0].length })
609
+ }
610
+ let output = ''
611
+ let cursor = 0
612
+ let first = 0
613
+ while (first < groups.length) {
614
+ let digits = ''
615
+ let matchedLast = -1
616
+ for (let last = first; last < groups.length; last++) {
617
+ digits += groups[last].digits
618
+ if (digits.length > 19) {
619
+ break
620
+ }
621
+ if (digits.length >= 13 && passesLuhn(digits)) {
622
+ matchedLast = last
623
+ }
624
+ }
625
+ if (matchedLast >= 0) {
626
+ output += match.slice(cursor, groups[first].start) + REDACTED_VALUE
627
+ cursor = groups[matchedLast].end
628
+ first = matchedLast + 1
629
+ } else {
630
+ first++
631
+ }
632
+ }
633
+ return output + match.slice(cursor)
634
+ }
635
+
636
+ /**
637
+ * Redacts structured personal identifiers (emails, IP addresses, credit-card
638
+ * numbers, US SSNs, and phone numbers) from a free-text string. Intended for the
639
+ * agent-narrated `$mcp_intent` value only — not for structured tool parameters or
640
+ * responses, where the same shapes are often legitimate data. Horizontal Unicode
641
+ * spaces are first normalized to an ASCII space so copy-pasted identifiers still
642
+ * match. Returns a new string; leaves the input's identifiers untouched when
643
+ * nothing matches.
644
+ */
645
+ export function redactPii(value: string): string {
646
+ let result = value.replace(UNICODE_SPACE_PATTERN, ' ')
647
+ result = result.replace(EMAIL_PATTERN, REDACTED_VALUE)
648
+ result = result.replace(IPV4_PATTERN, REDACTED_VALUE)
649
+ result = result.replace(IPV6_PATTERN, `$1${REDACTED_VALUE}`)
650
+ result = result.replace(CREDIT_CARD_CANDIDATE_PATTERN, redactCardInMatch)
651
+ result = result.replace(US_SSN_PATTERN, REDACTED_VALUE)
652
+ result = result.replace(PHONE_NANP_PATTERN, `$1${REDACTED_VALUE}`)
653
+ result = result.replace(PHONE_INTL_PATTERN, `$1${REDACTED_VALUE}`)
654
+ return result
655
+ }
656
+
657
+ export function sanitizeCapturedValue(value: unknown, options: TextSanitizationOptions): unknown {
658
+ return sanitizeValueWith(value, (stringValue) => sanitizeString(stringValue, options))
659
+ }
660
+
661
+ /**
662
+ * {@link sanitizeCapturedValue} with the intent-grade string pass
663
+ * ({@link sanitizeFreeText}) on every string leaf, so nested agent-narrated
664
+ * values get structured-PII redaction in the load-bearing order too.
665
+ */
666
+ export function sanitizeFreeTextValue(value: unknown, options: TextSanitizationOptions): unknown {
667
+ return sanitizeValueWith(value, (stringValue) => sanitizeFreeText(stringValue, options))
668
+ }
669
+
670
+ function sanitizeValueWith(value: unknown, sanitizeStringFn: (value: string) => string): unknown {
671
+ if (value == null) {
672
+ return value
673
+ }
674
+
675
+ if (typeof value === 'string') {
676
+ return sanitizeStringFn(value)
677
+ }
678
+
679
+ if (Array.isArray(value)) {
680
+ return value.map((item) => sanitizeValueWith(item, sanitizeStringFn))
681
+ }
682
+
683
+ if (value instanceof Date) {
684
+ return value
685
+ }
686
+
687
+ if (typeof value !== 'object') {
688
+ return value
689
+ }
690
+
691
+ const result: JsonRecord = {}
692
+ for (const [key, nestedValue] of Object.entries(value)) {
693
+ result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeValueWith(nestedValue, sanitizeStringFn)
694
+ }
695
+ return result
696
+ }