coolhand 0.9.0 → 0.10.1

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.
data/docs/openai.md CHANGED
@@ -12,7 +12,7 @@ Requires Rails — `Coolhand::WebhookInterceptor` and `Coolhand::OpenAi::BatchRe
12
12
  - Include the interceptor in your controller: `include Coolhand::WebhookInterceptor`
13
13
  - Add the `before_action` to validate and populate the `@validator` payload: `before_action :intercept_batch_request, only: :openai`
14
14
  - Ensure you skip CSRF for the webhook endpoint: `skip_before_action :verify_authenticity_token`
15
- - Override the `webhook_secret` method to return your OpenAI webhook secret
15
+ - Override the `webhook_secret` method to return your OpenAI webhook secret. Define it under `private` so it can't be routed as an action
16
16
 
17
17
  `intercept_batch_request` already validates the webhook and, for `batch.completed`/`batch.failed`/`batch.expired`/`batch.cancelled` events, calls `Coolhand::OpenAi::BatchResultProcessor` — that part requires no code in your controller action. The batch's status is checked inline in the `before_action`, but for a completed batch the two JSONL file downloads and the one-request-log-per-item sends run on Coolhand's [background log thread](configuration.md#asynchronous-logging), so a large batch doesn't hold up your webhook response. (With `config.async_logging = false` this all runs inline, and a large batch can trip your webhook endpoint's timeout and OpenAI's retry behavior.) The example below shows a controller action doing additional, app-specific work on top of that (e.g. updating your own `BatchApiRequest` record and enqueuing your own background job), which is optional.
18
18
 
@@ -53,19 +53,68 @@ rescue StandardError => e
53
53
  head :internal_server_error
54
54
  end
55
55
 
56
+ private # keep helpers like this below your public actions
57
+
56
58
  def webhook_secret
57
59
  Rails.application.credentials.openai_webhook_secret
58
60
  end
59
61
  # ...existing code...
60
62
  ```
61
63
 
64
+ `intercept_batch_request`, `webhook_secret` and `process_event` are private in `Coolhand::WebhookInterceptor`, so Rails doesn't treat them as controller actions, and `before_action :intercept_batch_request` works with a private method. Code outside the controller can no longer call them directly (it gets `NoMethodError`); `super` from your own override still works.
65
+
66
+ If your controller re-defines `webhook_secret` or `process_event` as public, they are public methods again, but `Coolhand::WebhookInterceptor` still removes all three names from the controller's `action_methods`, so they stay unroutable. That also means these names are reserved: a controller action with one of those names would not be routable. Defining your overrides under `private` is still the recommended form.
67
+
68
+ ## What gets logged for a completed batch
69
+
70
+ Each batch item is logged with `source_api: "openai"` and the `model` from the item's request body (falling back to the response body). Batch input files use relative URLs such as `/v1/chat/completions`; these are logged as `https://api.openai.com/v1/chat/completions` (the host is not configurable, so Azure OpenAI or proxy batches are logged with that host too). The batch's `in_progress_at` and `completed_at` timestamps become each log's start and completion times; if OpenAI reports either as missing, the whole batch is skipped with an error in `Rails.logger` rather than logged with bogus times. If `completed_at` is earlier than `in_progress_at`, a warning is logged and `duration_ms` is clamped to 0.
71
+
72
+ Each item's request body and response body are capped separately by [`max_captured_body_bytes`](configuration.md#body-capture-limits): one over the limit is logged as a `{"_coolhand_capture_skipped" => "body_too_large", ...}` placeholder (the item itself is still logged, and `model` is still taken from the full request body). See [Body Capture Limits](configuration.md#body-capture-limits) for the other placeholders. When the OpenAI client returns a result file as a String or IO, it is read and parsed one JSONL line at a time; a client that returns the file already parsed (`ruby-openai` does) has loaded it in full before Coolhand sees it. A malformed line is skipped and counted in an error log, and the other lines are still processed.
73
+
74
+ The batch ID from the webhook payload and the batch's input/output file IDs are used in calls to the OpenAI API with your `OPENAI_API_KEY`, so they are format-checked first (`batch_...` and `file-...`/`file_...`, letters, digits, `_` and `-` only). An event or batch whose IDs don't match is ignored with an error in `Rails.logger`.
75
+
62
76
  ## Signature header and secret rotation
63
77
 
64
78
  The `webhook-signature` header may carry several space-separated signatures (`v1,sigA v1,sigB`), as senders produce while a webhook secret is being rotated. `Coolhand::OpenAi::WebhookValidator` accepts the request if any `v1,` entry matches your secret, comparing each in constant time and ignoring entries with any other version prefix. The timestamp and replay checks below still run, and only after a signature matches.
65
79
 
80
+ ## Local development and testing
81
+
82
+ Signature headers (`webhook-signature`, `webhook-timestamp`, `webhook-id`) are required whenever a webhook secret is configured, in every `Rails.env`, including `development` and `test`. A request missing any of them is rejected, so your controller responds with 401. Earlier versions let unsigned requests through in development and test, which meant an app running with `RAILS_ENV=development` on a reachable host accepted forged webhooks.
83
+
84
+ The same applies to a missing (`nil`) request body: with a secret configured it is rejected in every `Rails.env`, since there is nothing to verify the signature against. An empty-string body is not treated as missing; it goes through the normal signature check and passes only if it was actually signed.
85
+
86
+ Signature verification (and the missing-body check) is skipped, with a logged warning, only when no secret is configured at all, and only in `development` and `test`. Every other environment rejects the request.
87
+
88
+ If you post unsigned test payloads to a local app that has a secret set, pick one of these:
89
+
90
+ - **Sign the test payload** with the same secret:
91
+
92
+ ```ruby
93
+ require "openssl"
94
+ require "base64"
95
+ require "json"
96
+ require "securerandom"
97
+
98
+ # Same key handling as the validator: a `whsec_` secret is base64-decoded, anything else is used as-is.
99
+ secret = ENV.fetch("OPENAI_WEBHOOK_SECRET")
100
+ secret_bytes = secret.start_with?("whsec_") ? Base64.strict_decode64(secret.delete_prefix("whsec_")) : secret
101
+ id = "wh_local_#{SecureRandom.hex(4)}"
102
+ timestamp = Time.now.to_i.to_s
103
+ body = { type: "batch.completed", data: { id: "batch_123" } }.to_json
104
+ signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret_bytes, "#{id}.#{timestamp}.#{body}"))
105
+
106
+ # Send `body` with these headers:
107
+ # webhook-id: <id>
108
+ # webhook-timestamp: <timestamp>
109
+ # webhook-signature: v1,<signature>
110
+ ```
111
+
112
+ - **Leave the secret unset in development**, for example have `webhook_secret` return `nil` unless `Rails.env.production?`.
113
+ - **Replay a real delivery** to your local app through a tunnel.
114
+
66
115
  ## Replay protection
67
116
 
68
- `Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within that window — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
117
+ `Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within twice that window (a request dated at the edge of the tolerance stays fresh for up to two tolerances, so the id is remembered at least as long) — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
69
118
 
70
119
  Both are configurable:
71
120
 
@@ -76,7 +125,7 @@ Coolhand.configure do |config|
76
125
  end
77
126
  ```
78
127
 
79
- The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the tolerance window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works, e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
128
+ The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the replay-protection window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works (`ttl_seconds` is twice `webhook_replay_tolerance_seconds`), e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
80
129
 
81
130
  ```ruby
82
131
  class RailsCacheWebhookIdStore
@@ -92,4 +141,14 @@ Coolhand.configure do |config|
92
141
  end
93
142
  ```
94
143
 
95
- Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream fails before the batch is actually handled (e.g. an unexpectedly-shaped payload), a same-id retry within the tolerance window will be rejected as a replay rather than reprocessed.
144
+ Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream raises (e.g. an error in your own `process_event` override, or an unexpectedly-shaped payload), the interceptor releases the claim before responding, so OpenAI's retry of the same `webhook-id` is processed rather than rejected as a replay. (`Coolhand::OpenAi::BatchResultProcessor` rescues and logs its own errors, and does its heavy work in the background, so this mainly matters for exceptions raised in your own code.)
145
+
146
+ Releasing is optional for custom stores: if your store also responds to `release!(id)`, it is called to forget the claim; a store that only implements `claim!` behaves as before and keeps the id claimed for its TTL even after a failure. To opt in, add `release!` to the store above:
147
+
148
+ ```ruby
149
+ class RailsCacheWebhookIdStore
150
+ def release!(id)
151
+ Rails.cache.delete("coolhand:openai_webhook:#{id}")
152
+ end
153
+ end
154
+ ```
data/docs/vertex.md CHANGED
@@ -19,6 +19,7 @@ require "coolhand/vertex/batch_result_processor"
19
19
  - Optionally pass `model:` to populate the logged `model` field. It falls back to a `"model"` key in `batch_info` if present, and is omitted from the payload entirely when neither is available. If the value (from either source) looks like a Vertex model resource path (`publishers/<publisher>/models/<id>` or `projects/<project>/locations/<location>/models/<id>`), it's normalized down to just the trailing id/version before being logged; any other string is sent as-is.
20
20
  - `batch_info["name"]` must be the exact Vertex job resource name (`projects/<project>/locations/<location>/batchPredictionJobs/<job-id>`), and `batch_info["startTime"]`/`["endTime"]` must be valid ISO 8601 timestamps — a batch with a missing or malformed resource name or timestamps is skipped entirely (logged as an error) rather than sent with a broken URL.
21
21
  - If `endTime` precedes `startTime` (e.g. clock skew), the batch is still logged — `duration_ms` is clamped to `0` and a warning is logged — rather than dropping the results.
22
+ - Each item's `"request"` and `"response"` are capped separately by [`max_captured_body_bytes`](configuration.md#body-capture-limits): one over the limit is logged as a `{"_coolhand_capture_skipped" => "body_too_large", ...}` placeholder. (See [Body Capture Limits](configuration.md#body-capture-limits) for the other placeholders.) The results array is supplied by your code, so it is already in memory; only what is sent to Coolhand is capped.
22
23
  - Each element of the `batch_results` array passed to `.call` must be a `Hash` with a `"request"` and/or `"response"` key (the request/response body to log for that item — either key alone is accepted). Any other shape is treated as malformed and skipped, with the skip counted in a `Rails.logger.warn` summary rather than raised.
23
24
 
24
25
  ## Minimal example
@@ -13,6 +13,28 @@ module Coolhand
13
13
  # paths (interceptor + webhook forwarding) stay consistent.
14
14
  SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
15
15
 
16
+ # Rate-limit telemetry headers (OpenAI `x-ratelimit-remaining-tokens`, Anthropic
17
+ # `anthropic-ratelimit-input-tokens-reset`, ...) contain the word "token" but carry counts and
18
+ # reset times, not credentials, so redacting them only loses useful data. The vocabulary after
19
+ # `ratelimit` is closed (a lookalike such as `x-ratelimit-api-key` stays redacted), and the
20
+ # optional vendor prefix captured in group 1 must itself be free of sensitive words (see
21
+ # `safe_header?`), so `secret-ratelimit-remaining` stays redacted too. Checked against the
22
+ # downcased header name.
23
+ SAFE_HEADER_PATTERN =
24
+ /\A((?:[a-z0-9]+-){0,2})rate-?limit(?:-(?:limit|remaining|reset|requests|tokens|input|output))+\z/
25
+
26
+ # True when a header name should be redacted. Shared with LoggerService so both logging paths
27
+ # make the same call.
28
+ def sensitive_header?(name)
29
+ down = name.to_s.downcase
30
+ down.match?(SENSITIVE_HEADER_PATTERN) && !safe_header?(down)
31
+ end
32
+
33
+ def safe_header?(down_name)
34
+ match = SAFE_HEADER_PATTERN.match(down_name)
35
+ !match.nil? && !match[1].match?(SENSITIVE_HEADER_PATTERN)
36
+ end
37
+
16
38
  def sanitize_headers(headers)
17
39
  return {} if headers.nil?
18
40
 
@@ -37,7 +59,7 @@ module Coolhand
37
59
  headers.each { |k, v| h[k.to_s] = normalize_header_value(v) }
38
60
  h
39
61
  else
40
- { "raw" => headers.to_s }
62
+ {} # fail closed: an unrecognised shape can't be checked header-by-header
41
63
  end
42
64
 
43
65
  raw ||= {} # in case to_hash raised and nothing was built
@@ -55,7 +77,7 @@ module Coolhand
55
77
  else
56
78
  "[REDACTED]"
57
79
  end
58
- elsif key_down.match?(SENSITIVE_HEADER_PATTERN)
80
+ elsif sensitive_header?(key_down)
59
81
  sanitized[k] = "[REDACTED]"
60
82
  end
61
83
  end
@@ -78,7 +100,39 @@ module Coolhand
78
100
  # credential params used by AWS (X-Amz-Signature, X-Amz-Credential,
79
101
  # X-Amz-Security-Token) and Google Cloud (X-Goog-Signature,
80
102
  # X-Goog-Credential) storage APIs.
81
- SENSITIVE_QUERY_PARAM_PATTERN = /key|token|secret|sig|credential|password|auth/i
103
+ SENSITIVE_QUERY_PARAM_PATTERN = Regexp.new(
104
+ %w[key token secret sig credential password auth jwt bearer session cookie passphrase ticket assertion].join("|"),
105
+ Regexp::IGNORECASE
106
+ )
107
+
108
+ # Param names that are sensitive but too short or generic to match as a substring without
109
+ # over-matching (`code` would hit `encoding`, `country_code`). Matched by whole name,
110
+ # case-insensitively: `code` (Azure Functions keys, OAuth authorization codes), `sas` (Azure
111
+ # SAS tokens), and `pwd`/`passwd`, which the `password` substring above does not cover.
112
+ SENSITIVE_QUERY_PARAM_EXACT_NAMES = %w[code sas pwd passwd pass sid otp].freeze
113
+
114
+ # Percent-encoded "[REDACTED]", the form the placeholder takes once inside a URL.
115
+ REDACTED_URL_VALUE = "%5BREDACTED%5D"
116
+
117
+ # A `;name=value` matrix parameter. In a path the value stops at the next path delimiter; in a
118
+ # query value "/" is legal inside a secret (base64), so the value runs to the next ";" or "&".
119
+ PATH_MATRIX_PARAM_PATTERN = %r{;([^/;?#=&]+)=([^/;?#&]*)}
120
+ QUERY_MATRIX_PARAM_PATTERN = /;([^;=&#]+)=([^;&#]*)/
121
+
122
+ def sensitive_query_param?(name)
123
+ decoded = begin
124
+ # scrub: a percent-escaped invalid byte (`%FF`) must not make the match below raise.
125
+ URI.decode_www_form_component(name).scrub
126
+ rescue ArgumentError
127
+ name.scrub
128
+ end
129
+ decoded.match?(SENSITIVE_QUERY_PARAM_PATTERN) || exact_sensitive_query_name?(decoded)
130
+ end
131
+
132
+ # Exact-name check that also sees through Rails/PHP bracket syntax: `code[]`, `auth[code]`.
133
+ def exact_sensitive_query_name?(decoded_name)
134
+ decoded_name.downcase.split(/[\[\]]+/).any? { |part| SENSITIVE_QUERY_PARAM_EXACT_NAMES.include?(part) }
135
+ end
82
136
 
83
137
  def sanitize_url(url)
84
138
  uri = URI.parse(url)
@@ -91,28 +145,57 @@ module Coolhand
91
145
  modified = true
92
146
  end
93
147
 
94
- if uri.query
95
- params = URI.decode_www_form(uri.query)
96
- redacted_query = false
97
- params.map! do |n, v|
98
- if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
99
- redacted_query = true
100
- [n, "[REDACTED]"]
101
- else
102
- [n, v]
103
- end
104
- end
105
- if redacted_query
106
- uri.query = URI.encode_www_form(params)
107
- modified = true
108
- end
148
+ path = uri.path && redact_matrix_params(uri.path, PATH_MATRIX_PARAM_PATTERN)
149
+ if path != uri.path
150
+ uri.path = path
151
+ modified = true
152
+ end
153
+
154
+ query = uri.query && redact_query(uri.query)
155
+ if query != uri.query
156
+ uri.query = query
157
+ modified = true
158
+ end
159
+
160
+ # A fragment never reaches a server, but a caller-supplied URL can carry one (OAuth
161
+ # implicit flow puts `access_token=...` there), so drop it as the fallback below does.
162
+ if uri.fragment
163
+ uri.fragment = nil
164
+ modified = true
109
165
  end
110
166
 
111
167
  modified ? uri.to_s : url
112
168
  rescue URI::InvalidURIError
113
169
  # Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
114
170
  # that could carry a credential (userinfo, query, fragment) rather than pass it through.
115
- url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
171
+ stripped = url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
172
+ redact_matrix_params(stripped, PATH_MATRIX_PARAM_PATTERN)
173
+ end
174
+
175
+ def redact_matrix_params(text, pattern)
176
+ text.gsub(pattern) do |match|
177
+ sensitive_query_param?(Regexp.last_match(1)) ? ";#{Regexp.last_match(1)}=#{REDACTED_URL_VALUE}" : match
178
+ end
179
+ end
180
+
181
+ # Works on the raw query rather than decode_www_form so a bare segment (`?sk-abc123`) is not
182
+ # mistaken for an empty-valued param, and untouched segments pass through byte-for-byte.
183
+ def redact_query(query)
184
+ query.split("&", -1).map { |segment| redact_query_segment(segment) }.join("&")
185
+ end
186
+
187
+ def redact_query_segment(segment)
188
+ return segment if segment.empty?
189
+
190
+ # Matrix redaction first: in `x;code=S` the first "=" makes the "name" `x;code`, which would
191
+ # dodge the exact-name rule.
192
+ segment = redact_matrix_params(segment, QUERY_MATRIX_PARAM_PATTERN)
193
+ name, separator, = segment.partition("=")
194
+ # No "=": nothing says what this is, so a stray token could be a credential. Fail closed.
195
+ return REDACTED_URL_VALUE if separator.empty?
196
+ return "#{name}=#{REDACTED_URL_VALUE}" if sensitive_query_param?(name)
197
+
198
+ segment
116
199
  end
117
200
 
118
201
  # Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
@@ -122,32 +205,46 @@ module Coolhand
122
205
  # (Elasticsearch) are caught — not just exact or snake_case-only names.
123
206
  SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
124
207
 
208
+ REDACTED = "[REDACTED]"
209
+
210
+ # Subtrees nested deeper than this are replaced wholesale rather than walked, so a pathological
211
+ # body can neither overflow the stack (SystemStackError is not a StandardError, so
212
+ # send_complete_request_log's rescue would not catch it) nor smuggle a credential past the walk.
213
+ MAX_BODY_DEPTH = 100
214
+
125
215
  def sanitize_body(body)
126
- return body unless body.is_a?(Hash)
216
+ BodyRedactor.redact(body)
217
+ end
127
218
 
128
- sanitized = body.dup
129
- sanitized.each_key do |key|
130
- next unless key.to_s.delete("_-").casecmp?("datasources")
219
+ def redact_sensitive_body_values(node, depth = 0)
220
+ return REDACTED if depth > MAX_BODY_DEPTH
131
221
 
132
- sanitized[key] = redact_sensitive_body_values(sanitized[key])
222
+ case node
223
+ when Hash then redact_sensitive_body_hash(node, depth)
224
+ when Array then node.map { |v| redact_sensitive_body_values(v, depth + 1) }
225
+ when String then redact_embedded_url_credentials(node)
226
+ else node
133
227
  end
134
- sanitized
135
228
  end
136
229
 
137
- def redact_sensitive_body_values(node)
138
- case node
139
- when Hash
140
- node.each_with_object({}) do |(k, v), acc|
141
- normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
142
- acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
143
- end
144
- when Array
145
- node.map { |v| redact_sensitive_body_values(v) }
146
- else
147
- node
230
+ def redact_sensitive_body_hash(hash, depth)
231
+ hash.each_with_object({}) do |(k, v), acc|
232
+ normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
233
+ acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? REDACTED : redact_sensitive_body_values(v, depth + 1)
148
234
  end
149
235
  end
150
236
 
237
+ # A single whitespace-free absolute URL, e.g. an Azure `endpoint` of `https://u:PASS@h/?sas=...`.
238
+ # Only applied to data_sources config and to the exact URL-valued body keys in BodyRedactor
239
+ # (never free text), where a URL value can carry userinfo or a SAS/key query param itself.
240
+ EMBEDDED_URL_PATTERN = %r{\A[a-z][a-z0-9+.-]*://\S+\z}i
241
+
242
+ def redact_embedded_url_credentials(value)
243
+ # JSON.parse accepts invalid UTF-8, which would make the regexp raise and drop the whole log.
244
+ value = value.scrub unless value.valid_encoding?
245
+ value.match?(EMBEDDED_URL_PATTERN) ? sanitize_url(value) : value
246
+ end
247
+
151
248
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
152
249
  response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil,
153
250
  declared: nil)
@@ -159,7 +256,7 @@ module Coolhand
159
256
  headers: sanitize_headers(request_headers),
160
257
  request_body: sanitize_body(request_body),
161
258
  response_headers: sanitize_headers(response_headers),
162
- response_body: response_body,
259
+ response_body: sanitize_body(response_body),
163
260
  status_code: status_code,
164
261
  duration_ms: duration_ms,
165
262
  completed_at: end_time.iso8601,
@@ -181,6 +278,9 @@ module Coolhand
181
278
 
182
279
  def parse_json(string)
183
280
  JSON.parse(string)
281
+ rescue JSON::NestingError
282
+ # Too deep to walk for redaction; logging the raw string would skip BodyRedactor entirely.
283
+ { "_coolhand_capture_skipped" => "nesting_too_deep" }
184
284
  rescue JSON::ParserError, TypeError
185
285
  string
186
286
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Applies max_captured_body_bytes to the request/response bodies of batch items, the same way
5
+ # NetHttpInterceptor does for live requests: an oversized body is replaced by the body_too_large
6
+ # marker so the log entry (and the queued job holding it) stays small.
7
+ module BatchBodyCap
8
+ module_function
9
+
10
+ # Re-serializing a parsed number can write more bytes than the source had (depending on the json
11
+ # version, "1e9" becomes "1000000000.0" and "7e22" becomes "7.0000000000000004e+22"). Counting
12
+ # the separators around them, a body grows by well under this factor.
13
+ SERIALIZED_GROWTH = 6
14
+
15
+ # Returns the body, or the marker when its JSON size is over the cap. `source_size` is the byte
16
+ # size of the JSON the body was parsed from, when the caller has it (e.g. a JSONL line): a body
17
+ # that small even after SERIALIZED_GROWTH is within the cap without serializing it to measure.
18
+ def cap(body, source_size: nil)
19
+ return body if body.nil? || helpers.capture_limit.nil?
20
+ return body if source_size && !helpers.over_capture_limit?(source_size * SERIALIZED_GROWTH)
21
+
22
+ size = JSON.generate(body).bytesize
23
+ helpers.over_capture_limit?(size) ? helpers.body_too_large_marker(nil, size) : body
24
+ rescue JSON::NestingError
25
+ helpers.skipped_capture_marker(nil, "nesting_too_deep")
26
+ rescue JSON::JSONError
27
+ # NaN/Infinity or invalid UTF-8: it can't be logged either, and must not fail the other
28
+ # items of the batch.
29
+ helpers.skipped_capture_marker(nil, "unserializable_body")
30
+ end
31
+
32
+ def helpers
33
+ NetHttpInterceptor::Helpers
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Redacts credential-bearing values from request and response bodies before they are sent to
5
+ # Coolhand. Header and URL redaction live in BaseInterceptor; this covers the body.
6
+ module BodyRedactor
7
+ module_function
8
+
9
+ # Body keys whose *string* value is a credential in any request or response body, matched
10
+ # exactly (never as a substring) against the key lowercased with `_`, `-` and spaces removed,
11
+ # so `api_key`, `apiKey`, `API-Key` and `apikey` all match. Only String leaves are redacted:
12
+ # a JSON Schema property named `api_key` has a Hash value and is left alone, as is a numeric
13
+ # `usage.total_tokens`. Covers OpenAI Responses/Realtime MCP `tools[].authorization`, Anthropic
14
+ # `mcp_servers[].authorization_token`, and the legacy Realtime `client_secret` string form. Also
15
+ # covers `x-api-key`, AWS `aws_secret_access_key`/`secret_access_key`, `session_token`,
16
+ # `bearer_token` and any String under a generic `secret` key.
17
+ CREDENTIAL_BODY_KEYS = %w[
18
+ authorization authorizationtoken accesstoken refreshtoken idtoken
19
+ apikey clientsecret privatekey secretkey password
20
+ xapikey secret secretaccesskey awssecretaccesskey sessiontoken bearertoken
21
+ ].freeze
22
+
23
+ # Body keys whose *string* value is a URL that can carry credentials (MCP `server_url`, a
24
+ # presigned `image_url`/`file_url`, a datastore `endpoint`). Matched exactly like
25
+ # CREDENTIAL_BODY_KEYS, but the value is scrubbed with BaseInterceptor.sanitize_url instead of
26
+ # replaced, and only when it is a single absolute URL: prose that merely contains one is never
27
+ # touched. A bare `token` key is deliberately absent from both lists (logprobs use `{"token":"Hello"}`).
28
+ URL_BODY_KEYS = %w[url serverurl imageurl fileurl endpoint].freeze
29
+
30
+ def redact(body)
31
+ return StringBodyRedactor.redact(body) if body.is_a?(String)
32
+ return body unless body.is_a?(Hash) || body.is_a?(Array)
33
+
34
+ redact_body_node(body, 0)
35
+ end
36
+
37
+ def redact_body_node(node, depth, secret_object: false)
38
+ return BaseInterceptor::REDACTED if depth > BaseInterceptor::MAX_BODY_DEPTH
39
+
40
+ case node
41
+ when Hash then redact_body_hash(node, depth, secret_object: secret_object)
42
+ when Array then node.map { |v| redact_body_node(v, depth + 1) }
43
+ else node
44
+ end
45
+ end
46
+
47
+ # `secret_object` marks the object under a `client_secret` key (legacy Realtime
48
+ # `{value, expires_at}`), whose `value` is the ephemeral key.
49
+ def redact_body_hash(hash, depth, secret_object: false)
50
+ context = { secret_value: secret_object || ephemeral_secret?(hash), mcp: mcp_tool?(hash) }
51
+ hash.each_with_object({}) do |(key, value), acc|
52
+ acc[key] = redact_body_entry(normalize_body_key(key), value, depth, context)
53
+ end
54
+ end
55
+
56
+ def redact_body_entry(name, value, depth, context)
57
+ if name == "datasources"
58
+ BaseInterceptor.redact_sensitive_body_values(value, depth + 1)
59
+ elsif value.is_a?(String)
60
+ redact_string_value(name, value, context)
61
+ elsif name == "headers" && context[:mcp]
62
+ redact_header_values(value, depth + 1)
63
+ elsif name == "clientsecret" && value.is_a?(Hash)
64
+ redact_body_node(value, depth + 1, secret_object: true)
65
+ else
66
+ redact_body_node(value, depth + 1)
67
+ end
68
+ end
69
+
70
+ def redact_string_value(name, value, context)
71
+ return BaseInterceptor::REDACTED if redact_string_leaf?(name, context)
72
+
73
+ URL_BODY_KEYS.include?(name) ? BaseInterceptor.redact_embedded_url_credentials(value) : value
74
+ end
75
+
76
+ def redact_string_leaf?(name, context)
77
+ CREDENTIAL_BODY_KEYS.include?(name) || (context[:secret_value] && name == "value")
78
+ end
79
+
80
+ def normalize_body_key(key)
81
+ key.to_s.delete("_ -").downcase # trailing "-" is a literal hyphen, not a range
82
+ end
83
+
84
+ # Realtime GA `POST /v1/realtime/client_secrets` returns `{"value": "ek_...", "expires_at": 123,
85
+ # "session": {...}}`. `value` alone is far too generic to redact by name, so require the
86
+ # numeric `expires_at` that accompanies it.
87
+ def ephemeral_secret?(hash)
88
+ hash_value(hash, "value").is_a?(String) && hash_value(hash, "expires_at").is_a?(Numeric)
89
+ end
90
+
91
+ # An OpenAI remote-MCP tool (`type: "mcp"`, or any entry carrying a `server_url`), whose
92
+ # `headers` map holds authentication headers. A bare `headers` key elsewhere is left alone.
93
+ def mcp_tool?(hash)
94
+ hash_value(hash, "type") == "mcp" || !hash_value(hash, "server_url").nil?
95
+ end
96
+
97
+ # String values are the credentials; anything else (a malformed nested shape) goes back through
98
+ # the generic walk so it is still depth-limited and credential-key checked.
99
+ def redact_header_values(headers, depth)
100
+ return redact_body_node(headers, depth) unless headers.is_a?(Hash)
101
+
102
+ headers.transform_values do |v|
103
+ v.is_a?(String) ? BaseInterceptor::REDACTED : redact_body_node(v, depth + 1)
104
+ end
105
+ end
106
+
107
+ def hash_value(hash, name)
108
+ hash.fetch(name) { hash[name.to_sym] }
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module NetHttpInterceptor
5
+ # Stands in for a request's body_stream while Net::HTTP sends it, copying the bytes that pass
6
+ # through into a buffer capped at max_captured_body_bytes. The host's stream is read exactly
7
+ # once, by Net::HTTP, in the same chunks as without the gem; nothing is read ahead, buffered in
8
+ # full or rewound. Past the cap the buffer is dropped and only a byte count is kept, so memory
9
+ # stays bounded however large the upload is.
10
+ #
11
+ # Deliberately exposes nothing but #read (and #readpartial, only when the source has it): a
12
+ # delegating wrapper would also expose #to_io/#to_path, which IO.copy_stream uses to read the
13
+ # underlying file directly, bypassing the tap.
14
+ class BodyStreamTap
15
+ attr_reader :source, :bytes_seen
16
+
17
+ # IO.copy_stream also accepts a path (String) or a Pathname as its source, reading the file
18
+ # itself; the tap only offers #read, which those don't support, so they are left untouched.
19
+ def self.tappable?(stream)
20
+ stream.respond_to?(:read) && !stream.is_a?(String) &&
21
+ (stream.respond_to?(:to_io) || !stream.respond_to?(:to_path))
22
+ end
23
+
24
+ def self.install(req)
25
+ tap = new(req.body_stream)
26
+ req.body_stream = tap
27
+ tap
28
+ end
29
+
30
+ def initialize(source)
31
+ @source = source
32
+ @buffer = +"".b
33
+ @bytes_seen = 0
34
+ @eof = false
35
+ @failed = false
36
+ define_singleton_method(:readpartial) { |*args| read_partial(*args) } if source.respond_to?(:readpartial)
37
+ end
38
+
39
+ def read(*args)
40
+ data = @source.read(*args)
41
+ record(data, finished: end_of_stream?(args, data))
42
+ data
43
+ end
44
+
45
+ # Whether the source has been read to its end (not IO#eof?: false until a read hits the end).
46
+ def reached_end?
47
+ @eof
48
+ end
49
+
50
+ # The capture itself failed (not the host's stream); the request body is simply not logged.
51
+ def failed?
52
+ @failed
53
+ end
54
+
55
+ def overflowed?
56
+ @buffer.nil? && !@failed
57
+ end
58
+
59
+ def body
60
+ @buffer&.dup&.force_encoding(Encoding::UTF_8)
61
+ end
62
+
63
+ # Puts the host's own stream back, unless something else has replaced the tap since.
64
+ def restore_stream(req)
65
+ req.body_stream = @source if req.body_stream.equal?(self)
66
+ end
67
+
68
+ # The request body for the log once the request has been attempted. Over the cap is known
69
+ # from the bytes seen so far, even if the stream was not fully sent. Otherwise a stream that
70
+ # never reached its end (the request failed first) has no complete body.
71
+ def body_for_log(req)
72
+ return if failed?
73
+ return helpers.body_too_large_marker(req, bytes_seen) if overflowed?
74
+ return incomplete_marker(req) unless reached_end?
75
+
76
+ BaseInterceptor.parse_json(body)
77
+ end
78
+
79
+ private
80
+
81
+ # Reading everything, or getting nothing back from a read for a positive length (IO.copy_stream
82
+ # also treats an empty String there as the end).
83
+ def end_of_stream?(args, data)
84
+ length = args.first
85
+ length.nil? || data.nil? || (length.positive? && data.empty?)
86
+ end
87
+
88
+ def helpers
89
+ NetHttpInterceptor::Helpers
90
+ end
91
+
92
+ def incomplete_marker(req)
93
+ helpers.skipped_capture_marker(req, "body_stream_incomplete", size_bytes: bytes_seen)
94
+ end
95
+
96
+ def read_partial(*args)
97
+ data = @source.readpartial(*args)
98
+ record(data)
99
+ data
100
+ rescue EOFError
101
+ @eof = true
102
+ raise
103
+ end
104
+
105
+ # Runs inside the host's request send: whatever goes wrong here must never reach the host.
106
+ def record(data, finished: false)
107
+ @eof ||= finished
108
+ return unless data.is_a?(String)
109
+
110
+ @bytes_seen += data.bytesize
111
+ return unless @buffer
112
+
113
+ @buffer = helpers.over_capture_limit?(@bytes_seen) ? nil : @buffer << data.b
114
+ rescue StandardError => e
115
+ @failed = true
116
+ @buffer = nil
117
+ Coolhand.log "❌ Error capturing request body_stream: #{e.message}"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -23,7 +23,15 @@
23
23
  - "models.inference.ai.azure.com"
24
24
  - "aiplatform.googleapis.com"
25
25
  - "gateway.ai.cloudflare.com"
26
+ # Amazon Bedrock. Every endpoint variant needs its own entry: a wildcard entry is
27
+ # an exact host match with no implicit subdomains, and "amazonaws.com.cn" is not a
28
+ # dot-boundary suffix of "amazonaws.com". "api.aws" hosts are the dual-stack (IPv6)
29
+ # endpoints; "-fips" hosts are the FIPS 140-3 ones, used by GovCloud callers.
26
30
  - "bedrock-runtime.*.amazonaws.com"
31
+ - "bedrock-runtime.*.amazonaws.com.cn"
32
+ - "bedrock-runtime-fips.*.amazonaws.com"
33
+ - "bedrock-runtime.*.api.aws"
34
+ - "bedrock-runtime-fips.*.api.aws"
27
35
  - "api.deepseek.com"
28
36
  - "api.mistral.ai"
29
37
  - "api.perplexity.ai"