Module: PostHog::MCP::Sanitization Private

Defined in:
lib/posthog/mcp/sanitization.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

Event sanitization: redact non-text response content blocks, large base64 strings, PostHog tokens, credential-looking words, and sensitive keys. Pure functions that return new objects without mutating the input; run before truncation. Hash keys are strings.

Defined Under Namespace

Modules: SecretDetection

Constant Summary collapse

INJECTED_ARGUMENT_NAMES =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%w[context conversation_id llm_model].freeze
REDACTED_VALUE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

'[redacted]'
CIRCULAR_VALUE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

'[Circular ~]'
BINARY_REDACTED_VALUE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

'[binary data redacted - not supported by PostHog MCP analytics]'
BINARY_RESOURCE_REDACTED_VALUE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

'[binary resource content redacted - not supported by PostHog MCP analytics]'
BASE64_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%r{\A[A-Za-z0-9+/\n\r]+=*\z}
BASE64URL_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\A[A-Za-z0-9_-]+={0,2}\z/
BASE64URL_SPECIFIC_CHAR_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/[-_]/
BASE64_DATA_URL_PREFIX_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\Adata:[^,\s]*;base64,/i
BASE64_DATA_URL_PAYLOAD_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%r{\A[A-Za-z0-9+/_-]+={0,2}\z}
SIZE_GATE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

10_240
SOURCE_CONTEXT_FIELDS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Source lines an in-app stack frame carries around the raise.

%w[pre_context context_line post_context].freeze
POSTHOG_TOKEN_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\bph[a-z]_[A-Za-z0-9_-]{20,}\b/
SENSITIVE_KEY_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\A(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token|
access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)\z/ix
UNICODE_SPACE_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

PII redaction for the agent-narrated intent string only. Ordered so an earlier pass never eats digits a later pass needs. \d, \w and \b are ASCII-only in Ruby, which is what these patterns assume.

/[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/
EMAIL_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/
IPV4_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\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/
IPV6_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/
  \b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b
  |(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,7}:(?![\w:])
  |(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}(?!\w)
  |(?<![\w:])::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})(?!\w)
/x
US_SSN_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\b\d{3}[ .-]\d{2}[ .-]\d{4}\b/
CREDIT_CARD_CANDIDATE_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%r{\b\d(?:[ ./-]?\d){12,}\b}
DIGIT_GROUP_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

/\d+/
PHONE_NANP_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%r{(?<![\w+])(?:\+?1[ ./-]?)?(?:\(\d{3}\)[ ./-]?|\d{3}[ ./-])\d{3}[ ./-]\d{4}(?!\w)}
PHONE_INTL_PATTERN =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%r{(?<!\w)\+\d{1,3}(?:[ ./()-]{0,2}\d){7,13}(?!\w)}

Class Method Summary collapse

Class Method Details

.base64_data_url?(value) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


80
81
82
83
84
85
86
87
88
# File 'lib/posthog/mcp/sanitization.rb', line 80

def base64_data_url?(value)
  prefix = BASE64_DATA_URL_PREFIX_PATTERN.match(value)
  return false unless prefix

  payload = decode_percent(value[prefix[0].length..])
  return false if payload.nil?

  BASE64_DATA_URL_PAYLOAD_PATTERN.match?(payload.delete("\r\n"))
end

.binary_like?(value) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


102
103
104
105
106
107
108
# File 'lib/posthog/mcp/sanitization.rb', line 102

def binary_like?(value)
  return false unless value.length >= SIZE_GATE

  BASE64_PATTERN.match?(value) ||
    base64_data_url?(value) ||
    (BASE64URL_SPECIFIC_CHAR_PATTERN.match?(value) && BASE64URL_PATTERN.match?(value))
end

.build_captured_arguments(arguments) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



361
362
363
364
365
366
367
368
369
# File 'lib/posthog/mcp/sanitization.rb', line 361

def build_captured_arguments(arguments)
  return sanitize_captured_value(arguments) unless arguments.is_a?(Hash)

  arguments.each_with_object({}) do |(key, value), captured|
    next if INJECTED_ARGUMENT_NAMES.include?(key)

    captured[key] = sanitize_captured_value(value)
  end
end

.build_captured_mcp_parameters(request) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build the sanitized $mcp_parameters payload from a JSON-RPC request, dropping the SDK-injected arguments (they surface as dedicated properties).



341
342
343
344
345
346
347
348
349
350
351
# File 'lib/posthog/mcp/sanitization.rb', line 341

def build_captured_mcp_parameters(request)
  request = stringify_keys(request)
  return { 'request' => sanitize_captured_value(request) } unless request.is_a?(Hash)

  captured = {}
  %w[id jsonrpc method].each do |key|
    captured[key] = sanitize_captured_value(request[key]) if request.key?(key)
  end
  captured['params'] = build_captured_params(request['params']) if request.key?('params')
  { 'request' => captured }
end

.build_captured_params(params) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



353
354
355
356
357
358
359
# File 'lib/posthog/mcp/sanitization.rb', line 353

def build_captured_params(params)
  return sanitize_captured_value(params) unless params.is_a?(Hash)

  params.to_h do |key, value|
    [key, key == 'arguments' ? build_captured_arguments(value) : sanitize_captured_value(value)]
  end
end

.decode_percent(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Percent-decoding only: + is a base64 character, so form decoding (which turns it into a space) would break detection of valid data URLs.



92
93
94
95
96
97
98
99
100
# File 'lib/posthog/mcp/sanitization.rb', line 92

def decode_percent(value)
  if URI.respond_to?(:decode_uri_component)
    URI.decode_uri_component(value)
  else
    URI::DEFAULT_PARSER.unescape(value)
  end
rescue ArgumentError
  nil
end

.passes_luhn?(digits) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/posthog/mcp/sanitization.rb', line 124

def passes_luhn?(digits)
  total = 0
  double = false
  (digits.length - 1).downto(0) do |index|
    digit = digits.getbyte(index) - 48
    return false if digit.negative? || digit > 9

    if double
      digit *= 2
      digit -= 9 if digit > 9
    end
    total += digit
    double = !double
  end
  (total % 10).zero?
end

.redact_card_in_match(text) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Within a card candidate, redact every run of whole separator-delimited digit groups whose joined digits are 13-19 long and pass Luhn.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/posthog/mcp/sanitization.rb', line 143

def redact_card_in_match(text)
  groups = []
  text.scan(DIGIT_GROUP_PATTERN) do
    groups << [Regexp.last_match[0], Regexp.last_match.begin(0), Regexp.last_match.end(0)]
  end
  output = +''
  cursor = 0
  first = 0
  while first < groups.length
    digits = +''
    matched_last = -1
    (first...groups.length).each do |last|
      digits << groups[last][0]
      break if digits.length > 19

      matched_last = last if digits.length >= 13 && passes_luhn?(digits)
    end
    if matched_last >= 0
      output << text[cursor...groups[first][1]] << REDACTED_VALUE
      cursor = groups[matched_last][2]
      first = matched_last + 1
    else
      first += 1
    end
  end
  output << text[cursor..]
end

.redact_key?(key) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


76
77
78
# File 'lib/posthog/mcp/sanitization.rb', line 76

def redact_key?(key)
  SENSITIVE_KEY_PATTERN.match?(key.to_s)
end

.redact_pii(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Redact structured personal identifiers (emails, IPs, cards, US SSNs, phone numbers) from free text. Intended for $mcp_intent only.



173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/posthog/mcp/sanitization.rb', line 173

def redact_pii(value)
  return value unless value.is_a?(String)

  result = value.gsub(UNICODE_SPACE_PATTERN, ' ')
  result = result.gsub(EMAIL_PATTERN, REDACTED_VALUE)
  result = result.gsub(IPV4_PATTERN, REDACTED_VALUE)
  result = result.gsub(IPV6_PATTERN, REDACTED_VALUE)
  result = result.gsub(CREDIT_CARD_CANDIDATE_PATTERN) { |match| redact_card_in_match(match) }
  result = result.gsub(US_SSN_PATTERN, REDACTED_VALUE)
  result = result.gsub(PHONE_NANP_PATTERN, REDACTED_VALUE)
  result.gsub(PHONE_INTL_PATTERN, REDACTED_VALUE)
end

.redact_secret_tokens(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Redact credential-looking words, leaving surrounding text intact.



118
119
120
121
122
# File 'lib/posthog/mcp/sanitization.rb', line 118

def redact_secret_tokens(value)
  return (SecretDetection.secret?(value) ? REDACTED_VALUE : value) unless value.include?(' ')

  value.split(' ', -1).map { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word }.join(' ')
end

.sanitize_captured_value(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/posthog/mcp/sanitization.rb', line 186

def sanitize_captured_value(value)
  case value
  when nil then nil
  when String then sanitize_string(value)
  when Array then value.map { |item| sanitize_captured_value(item) }
  when Hash
    value.to_h do |key, nested|
      [key.to_s, redact_key?(key) ? REDACTED_VALUE : sanitize_captured_value(nested)]
    end
  else value
  end
end

.sanitize_content_block(block) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/posthog/mcp/sanitization.rb', line 315

def sanitize_content_block(block)
  return block unless block.is_a?(Hash)

  case block['type']
  when 'text', 'resource_link' then sanitize_captured_value(block)
  when 'image' then text_block('[image content redacted - not supported by PostHog MCP analytics]')
  when 'audio' then text_block('[audio content redacted - not supported by PostHog MCP analytics]')
  when 'resource'
    resource = block['resource']
    if resource.is_a?(Hash) && resource.key?('blob')
      text_block(BINARY_RESOURCE_REDACTED_VALUE)
    else
      sanitize_captured_value(block)
    end
  else
    text_block("[unsupported content type \"#{block['type']}\" redacted - " \
               'not supported by PostHog MCP analytics]')
  end
end

.sanitize_content_blocks(blocks) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



285
286
287
# File 'lib/posthog/mcp/sanitization.rb', line 285

def sanitize_content_blocks(blocks)
  blocks.map { |block| sanitize_content_block(block) }
end

.sanitize_event(event) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Sanitize an event's response, parameters, intent and error. Returns a new shallow copy; does not mutate the input.



201
202
203
204
205
206
207
208
209
210
# File 'lib/posthog/mcp/sanitization.rb', line 201

def sanitize_event(event)
  result = event.dup
  result['response'] = sanitize_response(result['response']) unless result['response'].nil?
  result['parameters'] = sanitize_captured_value(result['parameters']) unless result['parameters'].nil?
  unless result['user_intent'].nil?
    result['user_intent'] = redact_pii(sanitize_captured_value(result['user_intent']))
  end
  result['error'] = sanitize_exception_values(result['error']) unless result['error'].nil?
  result
end

.sanitize_exception_entry(exception) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

An in-app frame carries the source lines around the raise. They are the most useful part of a stack trace and also the part most likely to hold a hard-coded credential, and a caller can make a tool fail on demand, so they get the same redaction as every other captured string.



225
226
227
228
229
230
231
232
233
234
# File 'lib/posthog/mcp/sanitization.rb', line 225

def sanitize_exception_entry(exception)
  return exception unless exception.is_a?(Hash)

  entry = exception.merge('value' => sanitize_captured_value(exception['value']))
  stacktrace = entry['stacktrace']
  frames = stacktrace.is_a?(Hash) ? stacktrace['frames'] : nil
  return entry unless frames.is_a?(Array)

  entry.merge('stacktrace' => stacktrace.merge('frames' => frames.map { |frame| sanitize_frame(frame) }))
end

.sanitize_exception_values(error) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



212
213
214
215
216
217
218
219
# File 'lib/posthog/mcp/sanitization.rb', line 212

def sanitize_exception_values(error)
  return error unless error.is_a?(Hash)

  list = error['$exception_list']
  return error unless list.is_a?(Array)

  error.merge('$exception_list' => list.map { |exception| sanitize_exception_entry(exception) })
end

.sanitize_frame(frame) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/posthog/mcp/sanitization.rb', line 236

def sanitize_frame(frame)
  return frame unless frame.is_a?(Hash) && SOURCE_CONTEXT_FIELDS.any? { |field| frame.key?(field) }

  sanitized = frame.dup
  SOURCE_CONTEXT_FIELDS.each do |field|
    next unless sanitized.key?(field)

    value = sanitized[field]
    sanitized[field] = if value.is_a?(Array)
                         value.map { |line| sanitize_source_line(line) }
                       else
                         sanitize_source_line(value)
                       end
  end
  sanitized
end

.sanitize_prompt_messages(messages) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

A prompts/get result carries its blocks under messages[].content, either as a single block or as an array of them.



291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/posthog/mcp/sanitization.rb', line 291

def sanitize_prompt_messages(messages)
  messages.map do |message|
    next message unless message.is_a?(Hash) && message.key?('content')

    content = message['content']
    sanitized = case content
                when Array then sanitize_content_blocks(content)
                when Hash then sanitize_content_block(content)
                else content
                end
    message.merge('content' => sanitized)
  end
end

.sanitize_resource_contents(contents) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

A resources/read result carries its payloads under contents[], where a binary resource is a blob rather than a typed content block.



307
308
309
310
311
312
313
# File 'lib/posthog/mcp/sanitization.rb', line 307

def sanitize_resource_contents(contents)
  contents.map do |entry|
    next entry unless entry.is_a?(Hash) && entry.key?('blob')

    entry.merge('blob' => BINARY_RESOURCE_REDACTED_VALUE)
  end
end

.sanitize_response(response) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/posthog/mcp/sanitization.rb', line 265

def sanitize_response(response)
  unless response.is_a?(Hash) || response.is_a?(Array) || response.is_a?(String)
    return sanitize_captured_value(response)
  end

  sanitized = sanitize_captured_value(response)
  return sanitized unless sanitized.is_a?(Hash)

  result = sanitized.dup
  result['content'] = sanitize_content_blocks(result['content']) if result['content'].is_a?(Array)
  result['messages'] = sanitize_prompt_messages(result['messages']) if result['messages'].is_a?(Array)
  result['contents'] = sanitize_resource_contents(result['contents']) if result['contents'].is_a?(Array)
  structured = result['structuredContent']
  if structured.is_a?(Hash) || structured.is_a?(Array)
    result['structuredContent'] =
      sanitize_captured_value(structured)
  end
  result
end

.sanitize_source_line(line) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Redact secrets in a line of source without disturbing its shape. The generic string path splits on whitespace and rejoins with single spaces, which would flatten the indentation that makes a stack trace readable, so replacement happens per non-space run and leaves the gaps untouched.



257
258
259
260
261
262
263
# File 'lib/posthog/mcp/sanitization.rb', line 257

def sanitize_source_line(line)
  return line unless line.is_a?(String)
  return BINARY_REDACTED_VALUE if binary_like?(line)

  redacted = SecretDetection.redact_private_key_blocks(line.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE))
  redacted.gsub(/\S+/) { |word| SecretDetection.secret?(word) ? REDACTED_VALUE : word }
end

.sanitize_string(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



110
111
112
113
114
115
# File 'lib/posthog/mcp/sanitization.rb', line 110

def sanitize_string(value)
  return BINARY_REDACTED_VALUE if binary_like?(value)

  value = value.gsub(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
  redact_secret_tokens(SecretDetection.redact_private_key_blocks(value))
end

.stringify_keys(value, seen = {}.compare_by_identity) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Deep-copies a value with string keys so the pipeline can rely on one shape. Runs before the cycle-aware normalizer, so it must detect cycles itself: user-supplied properties may be self-referential, and the resulting SystemStackError is not a StandardError the sink could rescue.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/posthog/mcp/sanitization.rb', line 57

def stringify_keys(value, seen = {}.compare_by_identity)
  case value
  when Hash, Array
    return CIRCULAR_VALUE if seen.key?(value)

    seen[value] = true
    begin
      if value.is_a?(Hash)
        value.to_h { |k, v| [k.to_s, stringify_keys(v, seen)] }
      else
        value.map { |v| stringify_keys(v, seen) }
      end
    ensure
      seen.delete(value)
    end
  else value
  end
end

.text_block(text) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



335
336
337
# File 'lib/posthog/mcp/sanitization.rb', line 335

def text_block(text)
  { 'type' => 'text', 'text' => text }
end