posthog-ruby 3.23.8 → 3.24.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.
@@ -0,0 +1,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'set'
5
+ require 'uri'
6
+
7
+ module PostHog
8
+ module MCP
9
+ # Event sanitization: redact non-text response content blocks, large base64
10
+ # strings, PostHog tokens, credential-looking words, and sensitive keys.
11
+ # Pure functions that return new objects without mutating the input; run
12
+ # before truncation. Hash keys are strings.
13
+ #
14
+ # @api private
15
+ module Sanitization
16
+ INJECTED_ARGUMENT_NAMES = %w[context conversation_id llm_model].freeze
17
+ REDACTED_VALUE = '[redacted]'
18
+ CIRCULAR_VALUE = '[Circular ~]'
19
+ BINARY_REDACTED_VALUE = '[binary data redacted - not supported by PostHog MCP analytics]'
20
+ BINARY_RESOURCE_REDACTED_VALUE = '[binary resource content redacted - not supported by PostHog MCP analytics]'
21
+ BASE64_PATTERN = %r{\A[A-Za-z0-9+/\n\r]+=*\z}
22
+ BASE64URL_PATTERN = /\A[A-Za-z0-9_-]+={0,2}\z/
23
+ BASE64URL_SPECIFIC_CHAR_PATTERN = /[-_]/
24
+ BASE64_DATA_URL_PREFIX_PATTERN = /\Adata:[^,\s]*;base64,/i
25
+ BASE64_DATA_URL_PAYLOAD_PATTERN = %r{\A[A-Za-z0-9+/_-]+={0,2}\z}
26
+ SIZE_GATE = 10_240
27
+ # Source lines an in-app stack frame carries around the raise.
28
+ SOURCE_CONTEXT_FIELDS = %w[pre_context context_line post_context].freeze
29
+ POSTHOG_TOKEN_PATTERN = /\bph[a-z]_[A-Za-z0-9_-]{20,}\b/
30
+ SENSITIVE_KEY_PATTERN = /\A(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token|
31
+ access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)\z/ix
32
+
33
+ # PII redaction for the agent-narrated intent string only. Ordered so an
34
+ # earlier pass never eats digits a later pass needs. `\d`, `\w` and `\b`
35
+ # are ASCII-only in Ruby, which is what these patterns assume.
36
+ UNICODE_SPACE_PATTERN = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/
37
+ EMAIL_PATTERN = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/
38
+ 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/
39
+ IPV6_PATTERN = /
40
+ \b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b
41
+ |(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,7}:(?![\w:])
42
+ |(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}(?!\w)
43
+ |(?<![\w:])::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})(?!\w)
44
+ /x
45
+ US_SSN_PATTERN = /\b\d{3}[ .-]\d{2}[ .-]\d{4}\b/
46
+ CREDIT_CARD_CANDIDATE_PATTERN = %r{\b\d(?:[ ./-]?\d){12,}\b}
47
+ DIGIT_GROUP_PATTERN = /\d+/
48
+ PHONE_NANP_PATTERN = %r{(?<![\w+])(?:\+?1[ ./-]?)?(?:\(\d{3}\)[ ./-]?|\d{3}[ ./-])\d{3}[ ./-]\d{4}(?!\w)}
49
+ PHONE_INTL_PATTERN = %r{(?<!\w)\+\d{1,3}(?:[ ./()-]{0,2}\d){7,13}(?!\w)}
50
+
51
+ module_function
52
+
53
+ # Deep-copies a value with string keys so the pipeline can rely on one shape.
54
+ # Runs before the cycle-aware normalizer, so it must detect cycles itself:
55
+ # user-supplied properties may be self-referential, and the resulting
56
+ # `SystemStackError` is not a `StandardError` the sink could rescue.
57
+ def stringify_keys(value, seen = {}.compare_by_identity)
58
+ case value
59
+ when Hash, Array
60
+ return CIRCULAR_VALUE if seen.key?(value)
61
+
62
+ seen[value] = true
63
+ begin
64
+ if value.is_a?(Hash)
65
+ value.to_h { |k, v| [k.to_s, stringify_keys(v, seen)] }
66
+ else
67
+ value.map { |v| stringify_keys(v, seen) }
68
+ end
69
+ ensure
70
+ seen.delete(value)
71
+ end
72
+ else value
73
+ end
74
+ end
75
+
76
+ def redact_key?(key)
77
+ SENSITIVE_KEY_PATTERN.match?(key.to_s)
78
+ end
79
+
80
+ def base64_data_url?(value)
81
+ prefix = BASE64_DATA_URL_PREFIX_PATTERN.match(value)
82
+ return false unless prefix
83
+
84
+ payload = decode_percent(value[prefix[0].length..])
85
+ return false if payload.nil?
86
+
87
+ BASE64_DATA_URL_PAYLOAD_PATTERN.match?(payload.delete("\r\n"))
88
+ end
89
+
90
+ # Percent-decoding only: `+` is a base64 character, so form decoding
91
+ # (which turns it into a space) would break detection of valid data URLs.
92
+ def decode_percent(value)
93
+ if URI.respond_to?(:decode_uri_component)
94
+ URI.decode_uri_component(value)
95
+ else
96
+ URI::DEFAULT_PARSER.unescape(value)
97
+ end
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ def binary_like?(value)
103
+ return false unless value.length >= SIZE_GATE
104
+
105
+ BASE64_PATTERN.match?(value) ||
106
+ base64_data_url?(value) ||
107
+ (BASE64URL_SPECIFIC_CHAR_PATTERN.match?(value) && BASE64URL_PATTERN.match?(value))
108
+ end
109
+
110
+ def sanitize_string(value)
111
+ return BINARY_REDACTED_VALUE if binary_like?(value)
112
+
113
+ value = value.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
114
+ redact_secret_tokens(SecretDetection.redact_private_key_blocks(value))
115
+ end
116
+
117
+ # Redact credential-looking words, leaving surrounding text intact.
118
+ def redact_secret_tokens(value)
119
+ return (SecretDetection.secret?(value) ? REDACTED_VALUE : value) unless value.include?(' ')
120
+
121
+ value.split(' ', -1).map { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word }.join(' ')
122
+ end
123
+
124
+ def passes_luhn?(digits)
125
+ total = 0
126
+ double = false
127
+ (digits.length - 1).downto(0) do |index|
128
+ digit = digits.getbyte(index) - 48
129
+ return false if digit.negative? || digit > 9
130
+
131
+ if double
132
+ digit *= 2
133
+ digit -= 9 if digit > 9
134
+ end
135
+ total += digit
136
+ double = !double
137
+ end
138
+ (total % 10).zero?
139
+ end
140
+
141
+ # Within a card candidate, redact every run of whole separator-delimited
142
+ # digit groups whose joined digits are 13-19 long and pass Luhn.
143
+ def redact_card_in_match(text)
144
+ groups = []
145
+ text.scan(DIGIT_GROUP_PATTERN) do
146
+ groups << [Regexp.last_match[0], Regexp.last_match.begin(0), Regexp.last_match.end(0)]
147
+ end
148
+ output = +''
149
+ cursor = 0
150
+ first = 0
151
+ while first < groups.length
152
+ digits = +''
153
+ matched_last = -1
154
+ (first...groups.length).each do |last|
155
+ digits << groups[last][0]
156
+ break if digits.length > 19
157
+
158
+ matched_last = last if digits.length >= 13 && passes_luhn?(digits)
159
+ end
160
+ if matched_last >= 0
161
+ output << text[cursor...groups[first][1]] << REDACTED_VALUE
162
+ cursor = groups[matched_last][2]
163
+ first = matched_last + 1
164
+ else
165
+ first += 1
166
+ end
167
+ end
168
+ output << text[cursor..]
169
+ end
170
+
171
+ # Redact structured personal identifiers (emails, IPs, cards, US SSNs,
172
+ # phone numbers) from free text. Intended for `$mcp_intent` only.
173
+ def redact_pii(value)
174
+ return value unless value.is_a?(String)
175
+
176
+ result = value.gsub(UNICODE_SPACE_PATTERN, ' ')
177
+ result = result.gsub(EMAIL_PATTERN, REDACTED_VALUE)
178
+ result = result.gsub(IPV4_PATTERN, REDACTED_VALUE)
179
+ result = result.gsub(IPV6_PATTERN, REDACTED_VALUE)
180
+ result = result.gsub(CREDIT_CARD_CANDIDATE_PATTERN) { |match| redact_card_in_match(match) }
181
+ result = result.gsub(US_SSN_PATTERN, REDACTED_VALUE)
182
+ result = result.gsub(PHONE_NANP_PATTERN, REDACTED_VALUE)
183
+ result.gsub(PHONE_INTL_PATTERN, REDACTED_VALUE)
184
+ end
185
+
186
+ def sanitize_captured_value(value)
187
+ case value
188
+ when nil then nil
189
+ when String then sanitize_string(value)
190
+ when Array then value.map { |item| sanitize_captured_value(item) }
191
+ when Hash
192
+ value.to_h do |key, nested|
193
+ [key.to_s, redact_key?(key) ? REDACTED_VALUE : sanitize_captured_value(nested)]
194
+ end
195
+ else value
196
+ end
197
+ end
198
+
199
+ # Sanitize an event's response, parameters, intent and error. Returns a
200
+ # new shallow copy; does not mutate the input.
201
+ def sanitize_event(event)
202
+ result = event.dup
203
+ result['response'] = sanitize_response(result['response']) unless result['response'].nil?
204
+ result['parameters'] = sanitize_captured_value(result['parameters']) unless result['parameters'].nil?
205
+ unless result['user_intent'].nil?
206
+ result['user_intent'] = redact_pii(sanitize_captured_value(result['user_intent']))
207
+ end
208
+ result['error'] = sanitize_exception_values(result['error']) unless result['error'].nil?
209
+ result
210
+ end
211
+
212
+ def sanitize_exception_values(error)
213
+ return error unless error.is_a?(Hash)
214
+
215
+ list = error['$exception_list']
216
+ return error unless list.is_a?(Array)
217
+
218
+ error.merge('$exception_list' => list.map { |exception| sanitize_exception_entry(exception) })
219
+ end
220
+
221
+ # An in-app frame carries the source lines around the raise. They are the
222
+ # most useful part of a stack trace and also the part most likely to hold a
223
+ # hard-coded credential, and a caller can make a tool fail on demand, so
224
+ # they get the same redaction as every other captured string.
225
+ def sanitize_exception_entry(exception)
226
+ return exception unless exception.is_a?(Hash)
227
+
228
+ entry = exception.merge('value' => sanitize_captured_value(exception['value']))
229
+ stacktrace = entry['stacktrace']
230
+ frames = stacktrace.is_a?(Hash) ? stacktrace['frames'] : nil
231
+ return entry unless frames.is_a?(Array)
232
+
233
+ entry.merge('stacktrace' => stacktrace.merge('frames' => frames.map { |frame| sanitize_frame(frame) }))
234
+ end
235
+
236
+ def sanitize_frame(frame)
237
+ return frame unless frame.is_a?(Hash) && SOURCE_CONTEXT_FIELDS.any? { |field| frame.key?(field) }
238
+
239
+ sanitized = frame.dup
240
+ SOURCE_CONTEXT_FIELDS.each do |field|
241
+ next unless sanitized.key?(field)
242
+
243
+ value = sanitized[field]
244
+ sanitized[field] = if value.is_a?(Array)
245
+ value.map { |line| sanitize_source_line(line) }
246
+ else
247
+ sanitize_source_line(value)
248
+ end
249
+ end
250
+ sanitized
251
+ end
252
+
253
+ # Redact secrets in a line of source without disturbing its shape. The
254
+ # generic string path splits on whitespace and rejoins with single spaces,
255
+ # which would flatten the indentation that makes a stack trace readable, so
256
+ # replacement happens per non-space run and leaves the gaps untouched.
257
+ def sanitize_source_line(line)
258
+ return line unless line.is_a?(String)
259
+ return BINARY_REDACTED_VALUE if binary_like?(line)
260
+
261
+ redacted = SecretDetection.redact_private_key_blocks(line.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE))
262
+ redacted.gsub(/\S+/) { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word }
263
+ end
264
+
265
+ def sanitize_response(response)
266
+ unless response.is_a?(Hash) || response.is_a?(Array) || response.is_a?(String)
267
+ return sanitize_captured_value(response)
268
+ end
269
+
270
+ sanitized = sanitize_captured_value(response)
271
+ return sanitized unless sanitized.is_a?(Hash)
272
+
273
+ result = sanitized.dup
274
+ result['content'] = sanitize_content_blocks(result['content']) if result['content'].is_a?(Array)
275
+ result['messages'] = sanitize_prompt_messages(result['messages']) if result['messages'].is_a?(Array)
276
+ result['contents'] = sanitize_resource_contents(result['contents']) if result['contents'].is_a?(Array)
277
+ structured = result['structuredContent']
278
+ if structured.is_a?(Hash) || structured.is_a?(Array)
279
+ result['structuredContent'] =
280
+ sanitize_captured_value(structured)
281
+ end
282
+ result
283
+ end
284
+
285
+ def sanitize_content_blocks(blocks)
286
+ blocks.map { |block| sanitize_content_block(block) }
287
+ end
288
+
289
+ # A `prompts/get` result carries its blocks under `messages[].content`,
290
+ # either as a single block or as an array of them.
291
+ def sanitize_prompt_messages(messages)
292
+ messages.map do |message|
293
+ next message unless message.is_a?(Hash) && message.key?('content')
294
+
295
+ content = message['content']
296
+ sanitized = case content
297
+ when Array then sanitize_content_blocks(content)
298
+ when Hash then sanitize_content_block(content)
299
+ else content
300
+ end
301
+ message.merge('content' => sanitized)
302
+ end
303
+ end
304
+
305
+ # A `resources/read` result carries its payloads under `contents[]`, where a
306
+ # binary resource is a `blob` rather than a typed content block.
307
+ def sanitize_resource_contents(contents)
308
+ contents.map do |entry|
309
+ next entry unless entry.is_a?(Hash) && entry.key?('blob')
310
+
311
+ entry.merge('blob' => BINARY_RESOURCE_REDACTED_VALUE)
312
+ end
313
+ end
314
+
315
+ def sanitize_content_block(block)
316
+ return block unless block.is_a?(Hash)
317
+
318
+ case block['type']
319
+ when 'text', 'resource_link' then sanitize_captured_value(block)
320
+ when 'image' then text_block('[image content redacted - not supported by PostHog MCP analytics]')
321
+ when 'audio' then text_block('[audio content redacted - not supported by PostHog MCP analytics]')
322
+ when 'resource'
323
+ resource = block['resource']
324
+ if resource.is_a?(Hash) && resource.key?('blob')
325
+ text_block(BINARY_RESOURCE_REDACTED_VALUE)
326
+ else
327
+ sanitize_captured_value(block)
328
+ end
329
+ else
330
+ text_block("[unsupported content type \"#{block['type']}\" redacted - " \
331
+ 'not supported by PostHog MCP analytics]')
332
+ end
333
+ end
334
+
335
+ def text_block(text)
336
+ { 'type' => 'text', 'text' => text }
337
+ end
338
+
339
+ # Build the sanitized `$mcp_parameters` payload from a JSON-RPC request,
340
+ # dropping the SDK-injected arguments (they surface as dedicated properties).
341
+ def build_captured_mcp_parameters(request)
342
+ request = stringify_keys(request)
343
+ return { 'request' => sanitize_captured_value(request) } unless request.is_a?(Hash)
344
+
345
+ captured = {}
346
+ %w[id jsonrpc method].each do |key|
347
+ captured[key] = sanitize_captured_value(request[key]) if request.key?(key)
348
+ end
349
+ captured['params'] = build_captured_params(request['params']) if request.key?('params')
350
+ { 'request' => captured }
351
+ end
352
+
353
+ def build_captured_params(params)
354
+ return sanitize_captured_value(params) unless params.is_a?(Hash)
355
+
356
+ params.to_h do |key, value|
357
+ [key, key == 'arguments' ? build_captured_arguments(value) : sanitize_captured_value(value)]
358
+ end
359
+ end
360
+
361
+ def build_captured_arguments(arguments)
362
+ return sanitize_captured_value(arguments) unless arguments.is_a?(Hash)
363
+
364
+ arguments.each_with_object({}) do |(key, value), captured|
365
+ next if INJECTED_ARGUMENT_NAMES.include?(key)
366
+
367
+ captured[key] = sanitize_captured_value(value)
368
+ end
369
+ end
370
+
371
+ # Last-resort credential detection for bare words.
372
+ #
373
+ # @api private
374
+ module SecretDetection
375
+ MIN_LENGTH = 16
376
+ MIN_ENTROPY_BITS = 3.8
377
+ MIN_CHAR_CLASSES = 3
378
+ HEX_DIGITS = '0123456789abcdefABCDEF'.chars.to_set.freeze
379
+ REJECT_CHARS = "()[]{}<>'\"`,;".chars.to_set.freeze
380
+ UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
381
+ PATH_WORD_RE = /\A[a-z][a-z.]*\z/
382
+ # A private key is redacted as a whole block, before anything is split into
383
+ # words. The body would mostly be caught word by word anyway - each base64
384
+ # line is high entropy on its own - but that is a heuristic, and a line
385
+ # that happens to look like a path (two lowercase `/`-separated segments)
386
+ # slips through it. Key material should not ride on a heuristic.
387
+ #
388
+ # Matched non-greedily, so several blocks in one value are handled
389
+ # separately, and terminated at end-of-string so a truncated block still
390
+ # loses its body. Covers the `RSA`/`EC`/`OPENSSH`/`ENCRYPTED` variants and
391
+ # the PGP `BLOCK` spelling.
392
+ PEM_PRIVATE_KEY_HINT = 'PRIVATE KEY'
393
+ PEM_PRIVATE_KEY_BLOCK = /
394
+ -----BEGIN[A-Z0-9\ ]*\ PRIVATE\ KEY(?:\ BLOCK)?-----
395
+ .*?
396
+ (?:-----END[A-Z0-9\ ]*\ PRIVATE\ KEY(?:\ BLOCK)?-----|\z)
397
+ /mx
398
+ KNOWN_SECRET_MAX_SCAN_LENGTH = 200
399
+ KNOWN_SECRET_RE = Regexp.union(
400
+ /sk-ant-[A-Za-z0-9_-]{16,}/,
401
+ /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/,
402
+ /hf_[A-Za-z0-9]{34}/,
403
+ /AKIA[0-9A-Z]{16}/,
404
+ /(?:ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[0-9A-Z]{16}/,
405
+ /AIza[A-Za-z0-9_-]{35}/,
406
+ /ya29\.[A-Za-z0-9_-]{20,}/,
407
+ /do[opr]_v1_[a-f0-9]{64}/,
408
+ /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/,
409
+ /sq0[a-z]{3}-[A-Za-z0-9_-]{22,43}/,
410
+ /gh[pousr]_[A-Za-z0-9]{36}/,
411
+ /github_pat_[A-Za-z0-9_]{20,}/,
412
+ /gl(?:pat|ptt|rt|soat)-[A-Za-z0-9_-]{20}/,
413
+ /glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8}/,
414
+ /xox[abeoprs]-[A-Za-z0-9-]{10,}/,
415
+ /xapp-[0-9]-[A-Za-z0-9-]{10,}/,
416
+ /SK[0-9a-fA-F]{32}/,
417
+ /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/,
418
+ /key-[0-9a-f]{32}/,
419
+ /[0-9a-f]{32}-us[0-9]{1,2}/,
420
+ /npm_[A-Za-z0-9]{36}/,
421
+ /pypi-AgEI[A-Za-z0-9_-]{50,}/,
422
+ /dapi[0-9a-f]{32}/,
423
+ /dp\.pt\.[A-Za-z0-9]{40,}/,
424
+ /PMAK-[a-f0-9]{24}-[a-f0-9]{34}/,
425
+ /lin_api_[A-Za-z0-9]{40}/,
426
+ /ntn_[A-Za-z0-9]{40,}/,
427
+ /shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}/,
428
+ /NR(?:AK|JS|II|MA|RA)-[A-Za-z0-9]{27}/,
429
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/
430
+ )
431
+
432
+ module_function
433
+
434
+ # Whole private-key blocks are handled by {redact_private_key_blocks} before
435
+ # a value is ever split, so there is no marker check here: the marker
436
+ # contains a space and could never match a single word anyway.
437
+ def secret?(value)
438
+ return false unless value.is_a?(String) && !value.empty?
439
+
440
+ n = value.length
441
+ return false if n < MIN_LENGTH
442
+ return true if high_entropy_secret?(value)
443
+ return KNOWN_SECRET_RE.match?(value) if n <= KNOWN_SECRET_MAX_SCAN_LENGTH
444
+
445
+ false
446
+ rescue StandardError
447
+ false
448
+ end
449
+
450
+ # @return [String] the value with every `-----BEGIN … PRIVATE KEY-----`
451
+ # block replaced, leaving surrounding text intact
452
+ def redact_private_key_blocks(value)
453
+ return value unless value.include?(PEM_PRIVATE_KEY_HINT)
454
+
455
+ value.gsub(PEM_PRIVATE_KEY_BLOCK, REDACTED_VALUE)
456
+ end
457
+
458
+ def path_or_url?(value)
459
+ return true if value.include?('://') || value.include?('\\')
460
+ return false unless value.include?('/')
461
+
462
+ value.split('/').count { |segment| !segment.empty? && PATH_WORD_RE.match?(segment) } >= 2
463
+ end
464
+
465
+ def high_entropy_secret?(value)
466
+ return false if value.include?(' ') || path_or_url?(value) || UUID_RE.match?(value)
467
+
468
+ counts = value.each_char.tally
469
+ distinct = counts.keys
470
+ return false if distinct.any? { |ch| REJECT_CHARS.include?(ch) }
471
+
472
+ has_lower = has_upper = has_digit = has_symbol = false
473
+ hex_only = true
474
+ distinct.each do |ch|
475
+ return false if ch.match?(/\s/)
476
+
477
+ case ch
478
+ when /[[:lower:]]/
479
+ has_lower = true
480
+ hex_only = false unless HEX_DIGITS.include?(ch)
481
+ when /[[:upper:]]/
482
+ has_upper = true
483
+ hex_only = false unless HEX_DIGITS.include?(ch)
484
+ when /[[:digit:]]/
485
+ has_digit = true
486
+ else
487
+ has_symbol = true
488
+ hex_only = false
489
+ end
490
+ end
491
+ return false if hex_only
492
+ return false if [has_lower, has_upper, has_digit, has_symbol].count(true) < MIN_CHAR_CLASSES
493
+
494
+ n = value.length.to_f
495
+ entropy = counts.values.sum do |occurrences|
496
+ p = occurrences / n
497
+ -p * Math.log2(p)
498
+ end
499
+ entropy >= MIN_ENTROPY_BITS
500
+ end
501
+ end
502
+ end
503
+ end
504
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Injects analytics parameters (`context`, `conversation_id`, `llm_model`)
6
+ # into advertised tool input schemas and declares `_mcp_instructions` on
7
+ # output schemas. Always returns new hashes: the `mcp` gem's `Tool.to_h`
8
+ # shares its nested schema hashes with the tool class, so in-place writes
9
+ # would leak into the tool permanently.
10
+ #
11
+ # Works on symbol- or string-keyed schemas and writes back in the input's key style.
12
+ #
13
+ # @api private
14
+ module SchemaMutation
15
+ COMPLEX_KEYS = %w[oneOf allOf anyOf].freeze
16
+
17
+ module_function
18
+
19
+ def fetch(hash, name)
20
+ return nil unless hash.is_a?(Hash)
21
+
22
+ hash.key?(name.to_sym) ? hash[name.to_sym] : hash[name.to_s]
23
+ end
24
+
25
+ def key_for(hash, name)
26
+ return name.to_sym if hash.key?(name.to_sym)
27
+ return name.to_s if hash.key?(name.to_s)
28
+
29
+ hash.keys.first.is_a?(String) ? name.to_s : name.to_sym
30
+ end
31
+
32
+ def declares_param?(schema, name)
33
+ properties = fetch(schema, :properties)
34
+ properties.is_a?(Hash) && (properties.key?(name.to_sym) || properties.key?(name.to_s))
35
+ end
36
+
37
+ def complex?(schema)
38
+ COMPLEX_KEYS.any? { |key| truthy?(fetch(schema, key)) }
39
+ end
40
+
41
+ # Whether an analytics parameter may be injected into (and therefore owned
42
+ # in) this input schema. A composed (oneOf/allOf/anyOf) or referenced
43
+ # ($ref) schema can declare the property out of band, and a sibling
44
+ # property next to a reference to a closed object makes the schema
45
+ # unsatisfiable, so those are left alone entirely.
46
+ def injectable?(schema)
47
+ return true unless schema.is_a?(Hash)
48
+
49
+ !complex?(schema) && !truthy?(fetch(schema, :$ref))
50
+ end
51
+
52
+ # Key style of `hash`, falling back to `parent`'s when `hash` is empty.
53
+ def string_keys?(hash, parent)
54
+ source = hash.empty? ? parent : hash
55
+ source.keys.first.is_a?(String)
56
+ end
57
+
58
+ def truthy?(value)
59
+ !(value.nil? || value == false || (value.respond_to?(:empty?) && value.empty?))
60
+ end
61
+
62
+ def deep_dup(value)
63
+ case value
64
+ when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
65
+ when Array then value.map { |v| deep_dup(v) }
66
+ else value
67
+ end
68
+ end
69
+
70
+ # Add a string property to an object schema. Returns the input unchanged
71
+ # (logging a warning) when the property exists or the schema is composed
72
+ # or referenced.
73
+ #
74
+ # @return [Hash] new schema
75
+ def add_parameter(schema, name, description, tool_name:, required:, options: nil, label: name)
76
+ if declares_param?(schema, name)
77
+ Log.debug(options,
78
+ "WARN: Tool \"#{tool_name}\" already has '#{name}' parameter. Skipping #{label} injection.")
79
+ return schema
80
+ end
81
+ unless injectable?(schema)
82
+ Log.debug(options,
83
+ "WARN: Tool \"#{tool_name}\" has a composed schema (oneOf/allOf/anyOf/$ref). " \
84
+ "Skipping #{label} injection.")
85
+ return schema
86
+ end
87
+
88
+ if schema.nil? || (schema.respond_to?(:empty?) && schema.empty?)
89
+ schema = { type: 'object', properties: {},
90
+ required: [] }
91
+ end
92
+ schema = deep_dup(schema)
93
+ properties_key = key_for(schema, :properties)
94
+ schema[properties_key] = {} unless schema[properties_key].is_a?(Hash)
95
+
96
+ # `additionalProperties: false` stays: the injected name is listed under
97
+ # `properties`, so it is still accepted, and relaxing the constraint would
98
+ # advertise a looser schema than the dispatcher actually validates against.
99
+ property_key = string_keys?(schema[properties_key], schema) ? name.to_s : name.to_sym
100
+ schema[properties_key][property_key] = { type: 'string', description: description }
101
+
102
+ if required
103
+ required_key = key_for(schema, :required)
104
+ if schema[required_key].is_a?(Array)
105
+ schema[required_key] << name.to_s unless schema[required_key].map(&:to_s).include?(name.to_s)
106
+ else
107
+ schema[required_key] = [name.to_s]
108
+ end
109
+ end
110
+ schema
111
+ end
112
+
113
+ def add_context_parameter(schema, tool_name:, description: nil, required: true, options: nil)
114
+ add_parameter(schema, 'context', description || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION,
115
+ tool_name: tool_name, required: required, options: options, label: 'context')
116
+ end
117
+
118
+ def add_conversation_id_parameter(schema, tool_name:, options: nil)
119
+ add_parameter(schema, ConversationId::PARAM_NAME, DEFAULT_CONVERSATION_ID_DESCRIPTION,
120
+ tool_name: tool_name, required: false, options: options, label: 'conversation_id')
121
+ end
122
+
123
+ def add_model_parameter(schema, tool_name:, description: nil, required: true, options: nil)
124
+ add_parameter(schema, ModelCapture::PARAM_NAME, description || DEFAULT_MODEL_PARAMETER_DESCRIPTION,
125
+ tool_name: tool_name, required: required, options: options, label: 'llm_model')
126
+ end
127
+
128
+ # Whether `_mcp_instructions` can safely be declared on this output schema.
129
+ def declarable_output?(schema)
130
+ return false unless schema.is_a?(Hash)
131
+ return false if truthy?(fetch(schema, :$ref)) || complex?(schema)
132
+
133
+ properties = fetch(schema, :properties)
134
+ return false if !properties.nil? && !properties.is_a?(Hash)
135
+
136
+ !truthy?(properties) || !declares_param?(schema, ConversationId::MCP_INSTRUCTIONS_KEY)
137
+ end
138
+
139
+ def our_declaration?(declaration)
140
+ declaration.is_a?(Hash) && fetch(declaration, :description) == ConversationId::INSTRUCTIONS_FIELD_DESCRIPTION
141
+ end
142
+
143
+ # Declare an optional `_mcp_instructions` on the output schema.
144
+ #
145
+ # @return [Array(Hash, Boolean)] `[schema, declared]`
146
+ def add_output_instructions(schema, tool_name:, options: nil)
147
+ return [schema, false] if schema.nil? || (schema.respond_to?(:empty?) && schema.empty?)
148
+
149
+ key = ConversationId::MCP_INSTRUCTIONS_KEY
150
+ unless declarable_output?(schema)
151
+ properties = fetch(schema, :properties)
152
+ if properties.is_a?(Hash) && declares_param?(schema, key)
153
+ return [schema, true] if our_declaration?(fetch(properties, key))
154
+
155
+ Log.debug(options,
156
+ "WARN: Tool \"#{tool_name}\" already declares '#{key}' in its output schema. Leaving it alone.")
157
+ else
158
+ Log.debug(options, "WARN: Tool \"#{tool_name}\" has a complex output schema (oneOf/allOf/anyOf/$ref). " \
159
+ "Skipping '#{key}' declaration; its session handle stays content-only.")
160
+ end
161
+ return [schema, false]
162
+ end
163
+
164
+ schema = deep_dup(schema)
165
+ properties_key = key_for(schema, :properties)
166
+ schema[properties_key] = {} unless schema[properties_key].is_a?(Hash)
167
+ property_key = string_keys?(schema[properties_key], schema) ? key : key.to_sym
168
+ schema[properties_key][property_key] = {
169
+ type: 'object',
170
+ description: ConversationId::INSTRUCTIONS_FIELD_DESCRIPTION,
171
+ properties: {
172
+ conversation_id: { type: 'string', description: ConversationId::CONVERSATION_ID_FIELD_DESCRIPTION }
173
+ }
174
+ }
175
+ [schema, true]
176
+ end
177
+ end
178
+ end
179
+ end