@posthog/mcp 0.14.0 → 0.15.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.
- package/LICENSE +64 -0
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-highlevel.js +4 -1
- package/dist/extensions/instrument-highlevel.mjs +5 -2
- package/dist/extensions/instrument-lowlevel.d.ts +4 -3
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.js +4 -1
- package/dist/extensions/instrument-lowlevel.mjs +5 -2
- package/dist/extensions/instrumentation.d.ts +3 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +76 -2
- package/dist/extensions/instrumentation.mjs +72 -1
- package/dist/extensions/internal.js +9 -2
- package/dist/extensions/internal.mjs +9 -2
- package/dist/extensions/mcp-payloads.d.ts +13 -0
- package/dist/extensions/mcp-payloads.d.ts.map +1 -1
- package/dist/extensions/mcp-payloads.js +205 -4
- package/dist/extensions/mcp-payloads.mjs +201 -3
- package/dist/extensions/sanitization.d.ts.map +1 -1
- package/dist/extensions/sanitization.js +2 -1
- package/dist/extensions/sanitization.mjs +3 -2
- package/dist/index.d.ts +3 -3
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.mjs +1 -1
- package/package.json +3 -3
- package/src/extensions/instrument-highlevel.ts +6 -0
- package/src/extensions/instrument-lowlevel.ts +10 -3
- package/src/extensions/instrumentation.ts +105 -0
- package/src/extensions/internal.ts +13 -2
- package/src/extensions/mcp-payloads.ts +427 -4
- package/src/extensions/sanitization.ts +14 -6
- package/src/index.ts +3 -3
- package/src/types.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -11,6 +11,7 @@ import { handleReportMissing, resolveMissingCapabilityToolName } from './tools'
|
|
|
11
11
|
import {
|
|
12
12
|
handleInitializeRequest,
|
|
13
13
|
handleListToolsRequest,
|
|
14
|
+
traceResourceRequest,
|
|
14
15
|
patchRequestHandlers,
|
|
15
16
|
registerFallbackRequestHandler,
|
|
16
17
|
captureToolCall,
|
|
@@ -27,9 +28,10 @@ type MCPRequest = Parameters<MCPRequestHandler>[0]
|
|
|
27
28
|
type MCPRequestExtra = Parameters<MCPRequestHandler>[1]
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Instruments a low-level `Server`: wraps `initialize`, `tools/list`,
|
|
31
|
-
* `tools/call
|
|
32
|
-
*
|
|
31
|
+
* Instruments a low-level `Server`: wraps `initialize`, `tools/list`,
|
|
32
|
+
* `tools/call`, `resources/list`, `resources/templates/list`, and
|
|
33
|
+
* `resources/read`. The tool-call lifecycle is delegated to
|
|
34
|
+
* {@link captureToolCall}, shared with the high-level wrapper.
|
|
33
35
|
*/
|
|
34
36
|
export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn): void {
|
|
35
37
|
try {
|
|
@@ -42,6 +44,11 @@ export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn
|
|
|
42
44
|
'tools/list': (server, originalHandler, request, extra) =>
|
|
43
45
|
handleListToolsRequest(server, originalHandler, request, extra, logger),
|
|
44
46
|
'tools/call': traceToolCall,
|
|
47
|
+
'resources/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
|
|
48
|
+
// Both listings publish `$mcp_resources_list`; the captured
|
|
49
|
+
// `request.method` is what tells a static listing from a templated one.
|
|
50
|
+
'resources/templates/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
|
|
51
|
+
'resources/read': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesRead, logger),
|
|
45
52
|
}
|
|
46
53
|
patchRequestHandlers(server, handlers)
|
|
47
54
|
|
|
@@ -395,6 +395,111 @@ function publishFailedToolEvent(
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
type ResourceEventType = typeof MCPAnalyticsEventType.mcpResourcesList | typeof MCPAnalyticsEventType.mcpResourcesRead
|
|
399
|
+
|
|
400
|
+
interface TraceRequestParams {
|
|
401
|
+
server: MCPServerLike
|
|
402
|
+
originalHandler: MCPRequestHandler
|
|
403
|
+
request: MCPRequestLike
|
|
404
|
+
extra: CompatibleRequestHandlerExtra | undefined
|
|
405
|
+
eventType: ResourceEventType
|
|
406
|
+
logger: LoggerFn
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One resource request's outcome: either the handler threw, or it returned. */
|
|
410
|
+
type ResourceOutcome = { error: unknown } | { result: unknown }
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Stamps a resource request's outcome onto its prepared event and publishes it.
|
|
414
|
+
*
|
|
415
|
+
* A listing's result is captured as the event response; a read's is not. What
|
|
416
|
+
* `resources/list` and `resources/templates/list` return is discovery metadata —
|
|
417
|
+
* names, uris, mime types, the next cursor — which answers "what did this client
|
|
418
|
+
* actually see?", while a read returns the resource body itself, which analytics
|
|
419
|
+
* has no business holding.
|
|
420
|
+
*/
|
|
421
|
+
function publishResourceEvent(
|
|
422
|
+
server: MCPServerLike,
|
|
423
|
+
preparedEvent: PreparedToolEvent | null,
|
|
424
|
+
startTime: Date,
|
|
425
|
+
params: TraceRequestParams,
|
|
426
|
+
outcome: ResourceOutcome
|
|
427
|
+
): void {
|
|
428
|
+
if (!preparedEvent) {
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const { event, requestAttribution } = preparedEvent
|
|
432
|
+
// Stamping is inside the `try` with the publish: `captureException` reads the
|
|
433
|
+
// thrown value's own `stack`, which an application error is free to define as
|
|
434
|
+
// a throwing getter. Outside, that would replace the resource error the caller
|
|
435
|
+
// is waiting on with ours.
|
|
436
|
+
try {
|
|
437
|
+
if ('error' in outcome) {
|
|
438
|
+
event.isError = true
|
|
439
|
+
event.error = captureException(outcome.error)
|
|
440
|
+
} else {
|
|
441
|
+
event.isError = false
|
|
442
|
+
if (params.eventType === MCPAnalyticsEventType.mcpResourcesList) {
|
|
443
|
+
event.response = outcome.result
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
event.duration = Date.now() - startTime.getTime()
|
|
447
|
+
captureEvent(server, event, params.logger, requestAttribution)
|
|
448
|
+
} catch (error) {
|
|
449
|
+
params.logger(`Warning: PostHog MCP analytics failed to publish ${params.request.method} analytics - ${error}`)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Builds the handler patch that captures one resource method, for either adapter. */
|
|
454
|
+
export function traceResourceRequest(eventType: ResourceEventType, logger: LoggerFn): HandlerPatch {
|
|
455
|
+
return (server, originalHandler, request, extra) =>
|
|
456
|
+
captureResourceRequest({ server, originalHandler, request, extra, eventType, logger })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Captures a non-tool MCP request without changing its result or error semantics. */
|
|
460
|
+
async function captureResourceRequest(params: TraceRequestParams): Promise<unknown> {
|
|
461
|
+
const { server, originalHandler, request, extra, eventType, logger } = params
|
|
462
|
+
const data = getServerTrackingData(server)
|
|
463
|
+
if (!data) {
|
|
464
|
+
logger(
|
|
465
|
+
'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using resources.'
|
|
466
|
+
)
|
|
467
|
+
return await originalHandler(request, extra)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const startTime = new Date()
|
|
471
|
+
let preparedEvent: PreparedToolEvent | null = null
|
|
472
|
+
try {
|
|
473
|
+
const sessionId = getSessionId(server, extra)
|
|
474
|
+
const sessionInfo = getSessionInfo(server, data, sessionId)
|
|
475
|
+
const event: McpEvent = {
|
|
476
|
+
sessionId,
|
|
477
|
+
eventType,
|
|
478
|
+
parameters: buildCapturedMcpParameters(request),
|
|
479
|
+
resourceName: eventType === MCPAnalyticsEventType.mcpResourcesRead ? request.params?.uri : undefined,
|
|
480
|
+
timestamp: startTime,
|
|
481
|
+
}
|
|
482
|
+
stampClientIdentity(event, request, extra, server)
|
|
483
|
+
stampTransportIdentity(event, extra)
|
|
484
|
+
const identity = await handleIdentify(server, data, sessionId, request, sessionInfo, extra)
|
|
485
|
+
await applyResolvedMetadata(event, data, request, extra)
|
|
486
|
+
preparedEvent = { event, requestAttribution: withIdentity(sessionInfo, identity) }
|
|
487
|
+
} catch (error) {
|
|
488
|
+
logger(`Warning: PostHog MCP analytics could not prepare ${request.method} analytics - ${error}`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let result: unknown
|
|
492
|
+
try {
|
|
493
|
+
result = await originalHandler(request, extra)
|
|
494
|
+
} catch (error) {
|
|
495
|
+
publishResourceEvent(server, preparedEvent, startTime, params, { error })
|
|
496
|
+
throw error
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
publishResourceEvent(server, preparedEvent, startTime, params, { result })
|
|
500
|
+
return result
|
|
501
|
+
}
|
|
502
|
+
|
|
398
503
|
// --- tools/list -----------------------------------------------------------
|
|
399
504
|
|
|
400
505
|
/**
|
|
@@ -226,15 +226,26 @@ export async function resolveEventProperties(
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Names what a request is about, for `$identify`. `resources/read` addresses its
|
|
231
|
+
* subject by `uri` rather than `name`, so an identify published from a read used
|
|
232
|
+
* to report `Unknown`.
|
|
233
|
+
*/
|
|
229
234
|
function getRequestResourceName(request: unknown): string {
|
|
230
235
|
if (!request || typeof request !== 'object' || !('params' in request)) {
|
|
231
236
|
return 'Unknown'
|
|
232
237
|
}
|
|
233
238
|
|
|
234
239
|
const params = request.params
|
|
235
|
-
if (!params || typeof params !== 'object'
|
|
240
|
+
if (!params || typeof params !== 'object') {
|
|
236
241
|
return 'Unknown'
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
|
|
244
|
+
for (const key of ['name', 'uri'] as const) {
|
|
245
|
+
const value = (params as Record<string, unknown>)[key]
|
|
246
|
+
if (typeof value === 'string') {
|
|
247
|
+
return value
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return 'Unknown'
|
|
240
251
|
}
|
|
@@ -16,6 +16,55 @@ const POSTHOG_TOKEN_PATTERN = /\bph[a-z]_[A-Za-z0-9_-]{20,}\b/g
|
|
|
16
16
|
const SENSITIVE_KEY_PATTERN =
|
|
17
17
|
/^(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token|access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)$/i
|
|
18
18
|
|
|
19
|
+
// Deliberately no leading `\b`: `_` is a word character but not scheme-legal, so
|
|
20
|
+
// `resource_https://user:pw@host` has no boundary to anchor to and would keep its
|
|
21
|
+
// credentials. Leftmost matching makes `foo.https://x` match from `f` with scheme
|
|
22
|
+
// `foo.https`, which redacts the same URL and is therefore harmless.
|
|
23
|
+
//
|
|
24
|
+
// `'` is a valid URI sub-delimiter, so the terminal class must not stop at one:
|
|
25
|
+
// excluding it truncated `https://example.com/o'reilly?token=x` at the path and
|
|
26
|
+
// shipped the token in the clear. Only the characters that are never valid
|
|
27
|
+
// unencoded in a URI are excluded.
|
|
28
|
+
//
|
|
29
|
+
// The authority is optional too. An MCP resource URI frequently has none
|
|
30
|
+
// (`resource:guide?token=x`, `file:/guide.md`), and requiring `//` let those
|
|
31
|
+
// through untouched; `[^\s<>"]+` absorbs a `//host` when there is one. The cost
|
|
32
|
+
// is over-matching ordinary prose (`Error:foo`, `at12:30`, `C:\path`), which is
|
|
33
|
+
// harmless: a match with nothing to redact is returned byte-for-byte, so the
|
|
34
|
+
// text around it is never rewritten.
|
|
35
|
+
const URL_PATTERN = /[a-z][a-z0-9+.-]{0,63}:[^\s<>"]+/gi
|
|
36
|
+
/** The same pattern without `g`, for asking whether a value holds a URL at all. */
|
|
37
|
+
const URL_PATTERN_ONCE = new RegExp(URL_PATTERN.source, 'i')
|
|
38
|
+
/** Finds the first scheme that brings a real authority, i.e. what the pattern required before it went authority-less. */
|
|
39
|
+
const URL_AUTHORITY_SEARCH = /[a-z][a-z0-9+.-]{0,63}:\/\//i
|
|
40
|
+
/** The same, anchored: does this match *open* with an authority? */
|
|
41
|
+
const URL_AUTHORITY_PATTERN = new RegExp(`^${URL_AUTHORITY_SEARCH.source}`, 'i')
|
|
42
|
+
/** Every authority start in a value, in order. */
|
|
43
|
+
const URL_AUTHORITY_SEARCH_ALL = new RegExp(URL_AUTHORITY_SEARCH.source, 'gi')
|
|
44
|
+
/** The field separators. `?` and `#` are structural only as delimiters — see {@link isStructural}. */
|
|
45
|
+
const URL_FIELD_CHARACTERS = '=&'
|
|
46
|
+
/** The legacy field separator. Never split on, only checked; see {@link sanitizeUrlFieldValue}. */
|
|
47
|
+
const LEGACY_FIELD_SEPARATOR = ';'
|
|
48
|
+
// The terminal class above also absorbs the prose punctuation that follows a URL
|
|
49
|
+
// in a sentence, `'` included now that a URL can contain one. See
|
|
50
|
+
// `splitTrailingPunctuation`.
|
|
51
|
+
const URL_TRAILING_PUNCTUATION = ".,;:!?)]}'"
|
|
52
|
+
const MAX_URL_LENGTH = 8192
|
|
53
|
+
const MAX_URL_QUERY_FIELDS = 128
|
|
54
|
+
// A query key is sensitive when any `-`/`_`/`.`/`/`/`;`-delimited segment names
|
|
55
|
+
// a credential, so compound names (`private_token`, `oauth_signature`,
|
|
56
|
+
// `subscription-key`, `X-Amz-Security-Token`, `Key-Pair-Id`) are covered without
|
|
57
|
+
// enumerating every vendor's spelling. `/` is a separator because a fragment's
|
|
58
|
+
// field list can be written `#/token=…`, and `;` because a legacy `;`-separated
|
|
59
|
+
// pair parses into a single key (`download;token`) — either would otherwise hide
|
|
60
|
+
// the name. Over-redacting a benign `sort_key` — or `sort/key`, or `sort;key` —
|
|
61
|
+
// is the accepted trade for an analytics payload.
|
|
62
|
+
const SENSITIVE_QUERY_KEY_SEGMENT_PATTERN =
|
|
63
|
+
/(^|[-_./;])(auth|token|secret|password|passwd|pwd|credential|signature|sig|key|hmac|sas|bearer|jwt|session|sessionid)([-_./;]|$)/i
|
|
64
|
+
// `code` — the OAuth authorization code — is matched only as a whole key: as a
|
|
65
|
+
// segment it would eat `country_code`, `zip_code`, and `lang_code`.
|
|
66
|
+
const SENSITIVE_QUERY_KEY_EXACT_PATTERN = /^(code|AWSAccessKeyId|GoogleAccessId|Policy)$/i
|
|
67
|
+
|
|
19
68
|
// PII redaction for the agent-narrated intent string only. `$mcp_intent` is free
|
|
20
69
|
// text the calling LLM writes into the injected `context` argument, so it can
|
|
21
70
|
// carry personal data the model read aloud despite being told not to. We redact
|
|
@@ -75,6 +124,12 @@ function shouldRedactKey(key: string): boolean {
|
|
|
75
124
|
return SENSITIVE_KEY_PATTERN.test(key)
|
|
76
125
|
}
|
|
77
126
|
|
|
127
|
+
function shouldRedactQueryKey(key: string): boolean {
|
|
128
|
+
return (
|
|
129
|
+
shouldRedactKey(key) || SENSITIVE_QUERY_KEY_SEGMENT_PATTERN.test(key) || SENSITIVE_QUERY_KEY_EXACT_PATTERN.test(key)
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
78
133
|
function isBase64DataUrl(value: string): boolean {
|
|
79
134
|
const prefix = BASE64_DATA_URL_PREFIX_PATTERN.exec(value)
|
|
80
135
|
if (!prefix) {
|
|
@@ -91,16 +146,384 @@ function isBase64DataUrl(value: string): boolean {
|
|
|
91
146
|
return BASE64_DATA_URL_PAYLOAD_PATTERN.test(payload.replace(/[\r\n]/g, ''))
|
|
92
147
|
}
|
|
93
148
|
|
|
94
|
-
function
|
|
95
|
-
|
|
149
|
+
function exceedsUrlFieldLimit(fields: string): boolean {
|
|
150
|
+
return fields.split('&', MAX_URL_QUERY_FIELDS + 1).length > MAX_URL_QUERY_FIELDS
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** How much of the sanitizer one call is allowed to apply. */
|
|
154
|
+
interface UrlSanitizeMode {
|
|
155
|
+
/** Sanitize a URL found inside a retained field value. Off past depth 1. */
|
|
156
|
+
allowNestedUrls: boolean
|
|
157
|
+
/** Split prose punctuation off the end. Off when the match is a whole string. */
|
|
158
|
+
stripPunctuation: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether any `;`-separated piece of `value` names a credential. */
|
|
162
|
+
function hasLegacySensitiveField(value: string): boolean {
|
|
163
|
+
return value.split(LEGACY_FIELD_SEPARATOR).some((piece) => shouldRedactQueryKey(piece.split('=', 1)[0]))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The sanitized value of one query or fragment field. */
|
|
167
|
+
function sanitizeUrlFieldValue(key: string, value: string, allowNestedUrls: boolean): string {
|
|
168
|
+
if (shouldRedactQueryKey(key)) {
|
|
169
|
+
return REDACTED_VALUE
|
|
170
|
+
}
|
|
171
|
+
// `;` once separated fields the way `&` does, so a value carrying one may hold
|
|
172
|
+
// a credential field inside it. Splitting on it is not an option — that cuts a
|
|
173
|
+
// credential's own value in two and publishes the tail as a bare key
|
|
174
|
+
// (`?password=prefix;rest`) — so instead the whole value goes when any piece
|
|
175
|
+
// of it names a credential.
|
|
176
|
+
if (value.includes(LEGACY_FIELD_SEPARATOR) && hasLegacySensitiveField(value)) {
|
|
177
|
+
return REDACTED_VALUE
|
|
178
|
+
}
|
|
179
|
+
if (!URL_PATTERN_ONCE.test(value)) {
|
|
180
|
+
return value
|
|
181
|
+
}
|
|
182
|
+
// One level of nesting is the whole budget. Past it a URL-bearing value is
|
|
183
|
+
// dropped rather than trusted: nothing deeper would sanitize it, so a gateway
|
|
184
|
+
// address wrapping a gateway address would otherwise ship its credentials.
|
|
185
|
+
return allowNestedUrls
|
|
186
|
+
? sanitizeUrlsInString(value, { allowNestedUrls: false, stripPunctuation: true })
|
|
187
|
+
: REDACTED_VALUE
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Redacts the values of credential-named fields in one `&`-separated field list
|
|
192
|
+
* — a query string or a fragment. `serialized` is only meaningful when `changed`
|
|
193
|
+
* is true, so an untouched part keeps its original encoding instead of being
|
|
194
|
+
* re-serialized. `lastFieldSensitive` says whether whatever follows the list
|
|
195
|
+
* could be more of a credential, which is what decides both the fragment tail
|
|
196
|
+
* and restored prose punctuation; see {@link sanitizeUrl}.
|
|
197
|
+
*/
|
|
198
|
+
function sanitizeUrlFields(
|
|
199
|
+
fields: string,
|
|
200
|
+
allowNestedUrls: boolean
|
|
201
|
+
): { changed: boolean; lastFieldSensitive: boolean; serialized: string } {
|
|
202
|
+
const sanitized = new URLSearchParams()
|
|
203
|
+
let changed = false
|
|
204
|
+
let lastFieldSensitive = false
|
|
205
|
+
for (const [key, value] of new URLSearchParams(fields)) {
|
|
206
|
+
const sanitizedValue = sanitizeUrlFieldValue(key, value, allowNestedUrls)
|
|
207
|
+
// `changed` is compared, not inferred from the key: the PostHog-token pass
|
|
208
|
+
// runs first, so a value can already read `[redacted]`, and calling that a
|
|
209
|
+
// change would re-serialize the field only to percent-encode it.
|
|
210
|
+
changed ||= sanitizedValue !== value
|
|
211
|
+
// Sensitivity is the separate question — whether what follows this field
|
|
212
|
+
// could be more of a credential — and an already-redacted value is still a
|
|
213
|
+
// credential's field.
|
|
214
|
+
lastFieldSensitive = shouldRedactQueryKey(key) || sanitizedValue !== value
|
|
215
|
+
sanitized.append(key, sanitizedValue)
|
|
216
|
+
}
|
|
217
|
+
return { changed, lastFieldSensitive, serialized: sanitized.toString() }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Splits the prose punctuation that follows a URL in a sentence off the end of a
|
|
222
|
+
* match, so it is not parsed as part of the address and can be re-appended
|
|
223
|
+
* verbatim to whatever the sanitizer returns.
|
|
224
|
+
*
|
|
225
|
+
* Walked back character by character rather than matched with a `$`-anchored
|
|
226
|
+
* pattern: a captured URI is attacker-influenceable, and on a long punctuation
|
|
227
|
+
* run that does not end the match a backtracking pattern is quadratic.
|
|
228
|
+
*/
|
|
229
|
+
function splitTrailingPunctuation(value: string): { address: string; suffix: string } {
|
|
230
|
+
let end = value.length
|
|
231
|
+
while (end > 0 && URL_TRAILING_PUNCTUATION.includes(value[end - 1])) {
|
|
232
|
+
end--
|
|
233
|
+
}
|
|
234
|
+
return { address: value.slice(0, end), suffix: value.slice(end) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A fragment's tail, given how its head came out.
|
|
239
|
+
*
|
|
240
|
+
* A `?` inside a credential looks exactly like the one between a route and its
|
|
241
|
+
* fields. So when the head's last field is a credential's, everything after that
|
|
242
|
+
* `?` may be the rest of its value — `#password=prefix?rest` — and the
|
|
243
|
+
* tail goes whole rather than being read on its own terms. An empty tail has
|
|
244
|
+
* nothing to hide and stays empty. Either way it counts as a rewritten trailing
|
|
245
|
+
* field, so prose punctuation after the URL goes with it for the same reason.
|
|
246
|
+
*/
|
|
247
|
+
function sanitizeFragmentTail(
|
|
248
|
+
tail: string,
|
|
249
|
+
headLastFieldSensitive: boolean,
|
|
250
|
+
allowNestedUrls: boolean
|
|
251
|
+
): { value: string; lastFieldSensitive: boolean } {
|
|
252
|
+
if (!headLastFieldSensitive) {
|
|
253
|
+
return sanitizeFragmentPart(tail, allowNestedUrls)
|
|
254
|
+
}
|
|
255
|
+
return { value: tail === '' ? '' : REDACTED_VALUE, lastFieldSensitive: true }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Splits a fragment at its first `?`. `tail` is null when there is none.
|
|
260
|
+
*
|
|
261
|
+
* Nothing is assumed about either part. Shape alone cannot tell a router's route
|
|
262
|
+
* from a field list — `#/docs/id=1?token=…` puts an `=` in the route, and
|
|
263
|
+
* `#/token=…&next=https://x/?page=1` puts a `/` and a `?` in a field list — so
|
|
264
|
+
* each part is read as fields when it holds an `=` and as text otherwise.
|
|
265
|
+
*/
|
|
266
|
+
function splitFragmentParts(hash: string): { head: string; tail: string | null } {
|
|
267
|
+
const fragment = hash.slice(1)
|
|
268
|
+
const separator = fragment.indexOf('?')
|
|
269
|
+
if (separator < 0) {
|
|
270
|
+
return { head: fragment, tail: null }
|
|
271
|
+
}
|
|
272
|
+
return { head: fragment.slice(0, separator), tail: fragment.slice(separator + 1) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One fragment part, and whether its last field was rewritten. */
|
|
276
|
+
function sanitizeFragmentPart(part: string, allowNestedUrls: boolean): { value: string; lastFieldSensitive: boolean } {
|
|
277
|
+
if (!part.includes('=')) {
|
|
278
|
+
return { value: sanitizeFragmentText(part, allowNestedUrls), lastFieldSensitive: false }
|
|
279
|
+
}
|
|
280
|
+
const fields = sanitizeUrlFields(part, allowNestedUrls)
|
|
281
|
+
// Verbatim unless something was actually rewritten: only the part that changed
|
|
282
|
+
// should change encoding.
|
|
283
|
+
return { value: fields.changed ? fields.serialized : part, lastFieldSensitive: fields.lastFieldSensitive }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Sanitizes the part of a fragment that is text rather than fields — a plain
|
|
288
|
+
* `#intro`, or the route in front of a field list. It is text, and text can
|
|
289
|
+
* carry an address, so it gets the URL pass.
|
|
290
|
+
*
|
|
291
|
+
* Depth is capped the way a field value's is: at the nested level a URL-bearing
|
|
292
|
+
* text is dropped rather than descended into. That is also what stops
|
|
293
|
+
* `resource:x#resource:x#…` from recursing once per `#`.
|
|
294
|
+
*/
|
|
295
|
+
function sanitizeFragmentText(text: string, allowNestedUrls: boolean): string {
|
|
296
|
+
if (!allowNestedUrls) {
|
|
297
|
+
return URL_PATTERN_ONCE.test(text) ? REDACTED_VALUE : text
|
|
298
|
+
}
|
|
299
|
+
return sanitizeUrlsInString(text, { allowNestedUrls: false, stripPunctuation: true })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every offset inside `value` where an adjacent address starts; see
|
|
304
|
+
* {@link sanitizeUrl}.
|
|
305
|
+
*
|
|
306
|
+
* An authority is in *value position* — left alone, because the field pass
|
|
307
|
+
* already gives it the nested pass — when a field name's `=` is the nearest
|
|
308
|
+
* structural character behind it. Structural means a field separator (`=`, `&`,
|
|
309
|
+
* `;`) or one of the URL's three delimiters; see {@link isDelimiter}. Before any
|
|
310
|
+
* delimiter no `=` has been seen, so `…/redirect=https://user:pw@host` is two
|
|
311
|
+
* addresses rather than one field.
|
|
312
|
+
*
|
|
313
|
+
* Decided in one forward walk, not a backward scan per authority. The scan was
|
|
314
|
+
* quadratic on a value that opens its fields once and then runs thousands of
|
|
315
|
+
* addresses together, because every one of them scanned back to the same `?`.
|
|
316
|
+
*/
|
|
317
|
+
function findEmbeddedAuthorityIndexes(value: string): number[] {
|
|
318
|
+
const starts = [...value.matchAll(URL_AUTHORITY_SEARCH_ALL)].map((match) => match.index)
|
|
319
|
+
const indexes: number[] = []
|
|
320
|
+
const delimiters = { query: false, fragment: false, fragmentQuery: false }
|
|
321
|
+
let pending = 0
|
|
322
|
+
let inFields = false
|
|
323
|
+
let lastStructural = ''
|
|
324
|
+
for (let cursor = 0; cursor < value.length && pending < starts.length; cursor++) {
|
|
325
|
+
if (starts[pending] === cursor) {
|
|
326
|
+
if (cursor > 0 && lastStructural !== '=') {
|
|
327
|
+
indexes.push(cursor)
|
|
328
|
+
}
|
|
329
|
+
pending++
|
|
330
|
+
}
|
|
331
|
+
const character = value[cursor]
|
|
332
|
+
if (isDelimiter(character, delimiters, lastStructural)) {
|
|
333
|
+
lastStructural = character
|
|
334
|
+
inFields = true
|
|
335
|
+
} else if (inFields && URL_FIELD_CHARACTERS.includes(character)) {
|
|
336
|
+
lastStructural = character
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return indexes
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether this `?` or `#` is one of a URL's three delimiters, consuming it if so.
|
|
344
|
+
*
|
|
345
|
+
* A URL has exactly one query delimiter, one fragment delimiter, and — matching
|
|
346
|
+
* how the fragment is split — one delimiter between the fragment's own head and
|
|
347
|
+
* tail. Every other `?` or `#` is ordinary text inside a value, and reading one
|
|
348
|
+
* as structural would cut `?token=prefix?https://secret/…` in half at a `?` that
|
|
349
|
+
* belongs to the token.
|
|
350
|
+
*/
|
|
351
|
+
function isDelimiter(
|
|
352
|
+
character: string,
|
|
353
|
+
seen: { query: boolean; fragment: boolean; fragmentQuery: boolean },
|
|
354
|
+
lastStructural: string
|
|
355
|
+
): boolean {
|
|
356
|
+
if (character === '#') {
|
|
357
|
+
return seen.fragment ? false : (seen.fragment = true)
|
|
358
|
+
}
|
|
359
|
+
if (character !== '?') {
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
if (seen.fragment) {
|
|
363
|
+
// Inside a fragment, a `?` right after a field name may belong to that
|
|
364
|
+
// field's value rather than open the tail. Leaving it unstructural keeps a
|
|
365
|
+
// following address attached, so the fragment split and its fail-closed tail
|
|
366
|
+
// rule get to decide — they can see the whole credential, this cannot.
|
|
367
|
+
if (lastStructural === '=') {
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
return seen.fragmentQuery ? false : (seen.fragmentQuery = true)
|
|
371
|
+
}
|
|
372
|
+
return seen.query ? false : (seen.query = true)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Redacts the credentials embedded in one URL-shaped match: the userinfo, plus
|
|
377
|
+
* the values of credential-named query and fragment fields. A retained value that
|
|
378
|
+
* is itself a URL — a gateway's `?url=` passthrough — gets the same pass one
|
|
379
|
+
* level deep.
|
|
380
|
+
*
|
|
381
|
+
* One match can hold more than one address: a prose word in front of it
|
|
382
|
+
* (`Failed URL:https://…`, `a:b:https://…`), several run together
|
|
383
|
+
* (`…/doc,https://…`), or one parked in another's query or fragment
|
|
384
|
+
* (`…/?download)[b](https://…`). The later address always begins inside what
|
|
385
|
+
* would parse as the earlier one's path, query key, or fragment — none of which
|
|
386
|
+
* is examined for userinfo — so it is cut out and sanitized on its own.
|
|
387
|
+
*
|
|
388
|
+
* The exception is an authority in value position (see
|
|
389
|
+
* {@link findEmbeddedAuthorityIndexes}), which the field pass already gives the
|
|
390
|
+
* nested pass. Cutting there would take a gateway's `?url=https://…` out of the
|
|
391
|
+
* field it belongs to.
|
|
392
|
+
*
|
|
393
|
+
* One pass, no recursion: after the cuts, every authority left inside a piece is
|
|
394
|
+
* either at its start or in value position, so no piece can need cutting again.
|
|
395
|
+
*
|
|
396
|
+
* Every piece but the last is sanitized without the punctuation split: it is
|
|
397
|
+
* followed by an address rather than by prose, so its last character is a
|
|
398
|
+
* separator, not a sentence's. (It matters — stripping the `:` off `URL:` would
|
|
399
|
+
* leave `URL`, which `new URL()` rejects, and the prose word would become
|
|
400
|
+
* `[redacted]`.)
|
|
401
|
+
*/
|
|
402
|
+
function sanitizeUrl(value: string, mode: UrlSanitizeMode): string {
|
|
403
|
+
const embedded = findEmbeddedAuthorityIndexes(value)
|
|
404
|
+
if (embedded.length === 0) {
|
|
405
|
+
return sanitizeSingleUrl(value, mode)
|
|
406
|
+
}
|
|
407
|
+
let result = ''
|
|
408
|
+
let start = 0
|
|
409
|
+
for (const index of embedded) {
|
|
410
|
+
result += sanitizeSingleUrl(value.slice(start, index), { ...mode, stripPunctuation: false })
|
|
411
|
+
start = index
|
|
412
|
+
}
|
|
413
|
+
return result + sanitizeSingleUrl(value.slice(start), mode)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** {@link sanitizeUrl} for a value already known to hold exactly one address. */
|
|
417
|
+
function sanitizeSingleUrl(value: string, mode: UrlSanitizeMode): string {
|
|
418
|
+
// The length bound caps the work an attacker-shaped address can force, so it
|
|
419
|
+
// only applies to a match that has an authority. A long authority-less match
|
|
420
|
+
// is usually a data URI; it is parsed like any other, which is linear in
|
|
421
|
+
// `new URL()` and still capped by the field bound below, and comes back
|
|
422
|
+
// byte-for-byte when it holds nothing to redact.
|
|
423
|
+
if (value.length > MAX_URL_LENGTH && URL_AUTHORITY_PATTERN.test(value)) {
|
|
424
|
+
return REDACTED_VALUE
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const { address, suffix } = mode.stripPunctuation ? splitTrailingPunctuation(value) : { address: value, suffix: '' }
|
|
428
|
+
let url: URL
|
|
429
|
+
try {
|
|
430
|
+
url = new URL(address)
|
|
431
|
+
} catch {
|
|
432
|
+
return REDACTED_VALUE + suffix
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const query = url.search.slice(1)
|
|
436
|
+
const hasFragment = url.hash !== ''
|
|
437
|
+
const fragment = splitFragmentParts(url.hash)
|
|
438
|
+
if (exceedsUrlFieldLimit(query) || exceedsUrlFieldLimit(fragment.head) || exceedsUrlFieldLimit(fragment.tail ?? '')) {
|
|
439
|
+
return REDACTED_VALUE + suffix
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let changed = false
|
|
443
|
+
if (url.username || url.password) {
|
|
444
|
+
url.username = REDACTED_VALUE
|
|
445
|
+
url.password = ''
|
|
446
|
+
changed = true
|
|
447
|
+
}
|
|
448
|
+
const sanitizedQuery = sanitizeUrlFields(query, mode.allowNestedUrls)
|
|
449
|
+
if (sanitizedQuery.changed) {
|
|
450
|
+
url.search = sanitizedQuery.serialized
|
|
451
|
+
changed = true
|
|
452
|
+
}
|
|
453
|
+
const sanitizedHead = sanitizeFragmentPart(fragment.head, mode.allowNestedUrls)
|
|
454
|
+
const sanitizedTail =
|
|
455
|
+
fragment.tail === null
|
|
456
|
+
? null
|
|
457
|
+
: sanitizeFragmentTail(fragment.tail, sanitizedHead.lastFieldSensitive, mode.allowNestedUrls)
|
|
458
|
+
const sanitizedHash = sanitizedTail === null ? sanitizedHead.value : `${sanitizedHead.value}?${sanitizedTail.value}`
|
|
459
|
+
if (sanitizedHash !== url.hash.slice(1)) {
|
|
460
|
+
url.hash = sanitizedHash
|
|
461
|
+
changed = true
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The punctuation split off the end may be the tail of the very credential
|
|
465
|
+
// just replaced (`?password=fakepass!!!`) rather than the sentence's. When the
|
|
466
|
+
// last field of the URL's trailing part is a credential's the punctuation goes
|
|
467
|
+
// with it; losing a comma from the surrounding prose is the accepted cost of
|
|
468
|
+
// not shipping `!!!`.
|
|
469
|
+
const trailingFields = hasFragment ? (sanitizedTail ?? sanitizedHead) : sanitizedQuery
|
|
470
|
+
return (changed ? url.toString() : address) + (trailingFields.lastFieldSensitive ? '' : suffix)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Replaces every URL-shaped match in `text`.
|
|
475
|
+
*
|
|
476
|
+
* A match that IS the whole string is an address rather than prose — a captured
|
|
477
|
+
* `$mcp_resource_name`, a `params.uri`, a nested `?url=` value — so nothing is
|
|
478
|
+
* split off its end: a trailing `!` there belongs to the URL.
|
|
479
|
+
*/
|
|
480
|
+
function sanitizeUrlsInString(text: string, mode: UrlSanitizeMode): string {
|
|
481
|
+
return text.replace(URL_PATTERN, (match) =>
|
|
482
|
+
sanitizeUrl(match, { ...mode, stripPunctuation: mode.stripPunctuation && match.length !== text.length })
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Whether a string is large enough, and shaped enough like base64, to be a blob. */
|
|
487
|
+
function isBinaryBlob(value: string): boolean {
|
|
488
|
+
return (
|
|
96
489
|
value.length >= SIZE_GATE &&
|
|
97
490
|
(BASE64_PATTERN.test(value) ||
|
|
98
491
|
isBase64DataUrl(value) ||
|
|
99
492
|
(BASE64URL_SPECIFIC_CHAR_PATTERN.test(value) && BASE64URL_PATTERN.test(value)))
|
|
100
|
-
)
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Replaces PostHog API keys wherever they appear in a string. */
|
|
497
|
+
function redactCredentials(value: string): string {
|
|
498
|
+
return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Replaces every URL-shaped match, credentials and all. */
|
|
502
|
+
function redactUrls(value: string): string {
|
|
503
|
+
return sanitizeUrlsInString(value, { allowNestedUrls: true, stripPunctuation: true })
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function sanitizeString(value: string): string {
|
|
507
|
+
return isBinaryBlob(value) ? BINARY_REDACTED_VALUE : redactUrls(redactCredentials(value))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Sanitizes the agent-narrated intent: structured PII on top of the passes every
|
|
512
|
+
* captured string gets.
|
|
513
|
+
*
|
|
514
|
+
* Every step of the order is load-bearing. The binary gate reads the value as it
|
|
515
|
+
* arrived, because splicing `[redacted]` into a blob — a Luhn-valid run inside
|
|
516
|
+
* base64 is enough — stops it looking like base64 and would ship it whole.
|
|
517
|
+
* Credentials go before PII, because a PII pattern can cut a token in half (a
|
|
518
|
+
* phone-shaped run inside one) and leave the halves behind. PII goes before the
|
|
519
|
+
* URL pass, because the URL rewrite percent-encodes the `@` that the email
|
|
520
|
+
* pattern anchors on.
|
|
521
|
+
*/
|
|
522
|
+
export function sanitizeIntent(value: string): string {
|
|
523
|
+
if (isBinaryBlob(value)) {
|
|
101
524
|
return BINARY_REDACTED_VALUE
|
|
102
525
|
}
|
|
103
|
-
return value
|
|
526
|
+
return redactUrls(redactPii(redactCredentials(value)))
|
|
104
527
|
}
|
|
105
528
|
|
|
106
529
|
function passesLuhn(digits: string): boolean {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
|
|
5
5
|
|
|
6
6
|
import type { ErrorProperties, Event, McpEvent } from '../types'
|
|
7
|
-
import {
|
|
7
|
+
import { sanitizeCapturedValue, sanitizeIntent } from './mcp-payloads'
|
|
8
8
|
|
|
9
9
|
type SanitizedRecord = Record<string, unknown>
|
|
10
10
|
|
|
@@ -32,14 +32,22 @@ export function sanitizeEvent<T extends Event | McpEvent>(event: T): T {
|
|
|
32
32
|
result.parameters = sanitizeParameters(result.parameters)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Every event type, not just `resources/read`: `$identify` and the `$exception`
|
|
36
|
+
// sibling carry the same name, and a tool or prompt name is free text an
|
|
37
|
+
// application can spell as a URL too.
|
|
38
|
+
if (result.resourceName != null) {
|
|
39
|
+
result.resourceName = sanitizeCapturedValue(result.resourceName) as string
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// The intent comes straight from an agent-narrated `context` string, so it can
|
|
36
43
|
// contain a secret the LLM read aloud or personal data it narrated about the
|
|
37
|
-
// user.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// parameters and responses often hold the same shapes as
|
|
44
|
+
// user. `sanitizeIntent` adds structured PII redaction (emails, phone numbers,
|
|
45
|
+
// IPs, cards, SSNs) to the passes every captured value gets, in the one order
|
|
46
|
+
// that works — see its doc comment. PII redaction is scoped to the intent only:
|
|
47
|
+
// structured tool parameters and responses often hold the same shapes as
|
|
48
|
+
// legitimate data.
|
|
41
49
|
if (result.userIntent != null) {
|
|
42
|
-
result.userIntent =
|
|
50
|
+
result.userIntent = sanitizeIntent(result.userIntent)
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
if (result.llmModel != null) {
|