posthog-ruby 3.23.7 → 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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +12 -6
- data/lib/posthog/feature_flags.rb +155 -60
- data/lib/posthog/flag_definition_cache.rb +9 -5
- data/lib/posthog/mcp/README.md +53 -0
- data/lib/posthog/mcp/analytics.rb +116 -0
- data/lib/posthog/mcp/client.rb +246 -0
- data/lib/posthog/mcp/constants.rb +111 -0
- data/lib/posthog/mcp/conversation_id.rb +89 -0
- data/lib/posthog/mcp/event_builder.rb +168 -0
- data/lib/posthog/mcp/exceptions.rb +112 -0
- data/lib/posthog/mcp/identity.rb +194 -0
- data/lib/posthog/mcp/ids.rb +56 -0
- data/lib/posthog/mcp/instrumentation.rb +729 -0
- data/lib/posthog/mcp/intent.rb +101 -0
- data/lib/posthog/mcp/log.rb +37 -0
- data/lib/posthog/mcp/options.rb +128 -0
- data/lib/posthog/mcp/rack_middleware.rb +112 -0
- data/lib/posthog/mcp/request_scope.rb +72 -0
- data/lib/posthog/mcp/sanitization.rb +504 -0
- data/lib/posthog/mcp/schema_mutation.rb +179 -0
- data/lib/posthog/mcp/server_extension.rb +54 -0
- data/lib/posthog/mcp/session.rb +71 -0
- data/lib/posthog/mcp/session_token.rb +106 -0
- data/lib/posthog/mcp/sink.rb +98 -0
- data/lib/posthog/mcp/tools.rb +91 -0
- data/lib/posthog/mcp/tracking_data.rb +89 -0
- data/lib/posthog/mcp/truncation.rb +326 -0
- data/lib/posthog/mcp.rb +216 -0
- data/lib/posthog/version.rb +1 -1
- metadata +26 -1
|
@@ -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
|