coolhand 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3c1e767ea245f56520ee634e48cd2f12c6dc06be79b3998537fea9e9b8ea9ff
4
- data.tar.gz: aad0485d474aa1e3f796f2af30083304a1ed704ede8428ddc4b0c9a4ff32678e
3
+ metadata.gz: 8f38b442e23ef4733417edbaa234c3459734a11f861caeb4261ea034f7a464dd
4
+ data.tar.gz: c3d6aee98d5eea898ff53bedba1f43497c82517ad335d330d5e6619a5035e13f
5
5
  SHA512:
6
- metadata.gz: 2b497e1401c64e9eb6a6b1bbb061ecb359985a39f8dd95ce8018406d36e3c49ba061e5557299d4e6ee00498f8f8bf91ac9f3b91e56a32f5b08af599f5ef16768
7
- data.tar.gz: 84d13bff930f045607270849ad02fd5c8548b15b39f2043636c868bcfe857e04fab4c0ae113fe4e5872ca299d197c83b04c2f0adce5bffe3723c4bacd5890b03
6
+ metadata.gz: feafe561a70cd33c6c995f1ebd20c1d93e93ca9bb092dc6214f5b52464eb23e9ac68797d2c64377a2e215ed9ca0d50fa1b4b43f91dd2b355577ca33a2c80483f
7
+ data.tar.gz: 749542ecea37f0d9f08840bf6e0eda16fe5aa65f4484abb1e720c3358667190137509e99f80d798e0b3292809f95efd7823e7655a8c05db1b0747ebdcf784b02
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-20
11
+
12
+ ### Added
13
+ - **Seven more providers are monitored out of the box** ([#117](https://github.com/Coolhand-Labs/coolhand-ruby/pull/117)) — DeepSeek (`api.deepseek.com`), Mistral (`api.mistral.ai`), Perplexity (`api.perplexity.ai`) and xAI (`api.x.ai`) are captured host-wide. Cohere is captured only on its chat and embed endpoints (`/v2/chat`, `/v1/embed`, `/v2/embed`, on `api.cohere.com` and `api.cohere.ai`), TypeSafe Jev only on `api.typesafe.ai/v1/systemone`, and Ollama only on port 11434 for `/api/chat`, `/api/generate`, `/api/embed` and `/api/embeddings` (`localhost`, `127.0.0.1`, or a bare single-label host such as a Docker Compose service). No configuration required. **Migration note:** if your app already calls any of these, those calls will now start being logged to Coolhand after upgrading. To opt out, override `config.intercept_addresses` with your own list.
14
+ - **Azure inference endpoints are monitored out of the box** ([#115](https://github.com/Coolhand-Labs/coolhand-ruby/pull/115)) — Azure OpenAI dedicated hosts (`openai.azure.com`/`.us`/`.cn`), Azure AI Services / Foundry multi-service hosts (`cognitiveservices.azure.*`, `services.ai.azure.*`, captured only on their `/openai/` and `/models/` paths), serverless/MaaS hosts (`inference.ai.azure.com`, `models.ai.azure.com`) and Azure ML managed online endpoints (`inference.ml.azure.com`/`.us`). The Azure ML entries are deliberately not path-anchored, because every managed online endpoint scores at `/score`, so non-LLM deployments there will also be captured. Remove those two entries from `intercept_addresses` if that over-captures for you. Default `exclude_api_patterns` now also skips Azure OpenAI control-plane paths (`/openai/files`, `/batches`, `/fine_tuning`, `/models`, and their `/openai/v1/` spellings). **Migration note:** anyone who set `exclude_api_patterns` explicitly keeps their own list and will not get the new Azure exclusions.
15
+ - **`intercept_addresses` entries can pin a port and/or a path prefix** ([#117](https://github.com/Coolhand-Labs/coolhand-ruby/pull/117), [#115](https://github.com/Coolhand-Labs/coolhand-ruby/pull/115)) — `host:port`, `host/path` and `host:port/path` are all accepted. A request must match every part, and the path matches on a segment boundary (`host/openai` matches `/openai` and `/openai/x`, not `/openaiz`). Plain-host entries behave exactly as before. See [Configuration](docs/configuration.md#custom-intercept-addresses).
16
+
17
+ ### Changed
18
+ - Ruby 4.0 was added to the CI matrix ([#113](https://github.com/Coolhand-Labs/coolhand-ruby/pull/113)) and then removed again before release: its job could not `bundle install` against the locked dependencies (`BUNDLED WITH 2.3.6`, the conditional `pry-byebug`/`debug` Gemfile entry and `unicode-emoji 4.1.0`). CI covers Ruby 3.1–3.4 until the lock can support 4.0.
19
+
20
+ ### Fixed
21
+ - `examples/anthropic_example.rb` could not run against the pinned `anthropic` 0.4.x gem: it relied on the client reading `ANTHROPIC_API_KEY` from the environment and on object-style response access, and it targeted a retired model. It now passes `access_token:` explicitly, reads the response as a Hash, and uses `claude-haiku-4-5-20251001`. Found by running it against a live key during release validation.
22
+
23
+ ### Security
24
+ - **Azure OpenAI "On Your Data" datastore credentials are now redacted from captured request bodies** ([#115](https://github.com/Coolhand-Labs/coolhand-ruby/pull/115)) — a request's `data_sources`/`dataSources` entries can carry live credentials (Azure AI Search API keys, Cosmos/Mongo connection strings, Elasticsearch encoded keys) that header and URL sanitization never looked at. `BaseInterceptor.sanitize_body` now replaces any value under those entries whose key name contains `key`, `token`, `secret`, `password`, `credential`, `connectionstring` or `signature` (separators and case ignored) with `[REDACTED]` before the log is forwarded. Message content and tool definitions outside `data_sources` are untouched.
25
+ - **Release-time hardening from a whole-package security review:**
26
+ - `Coolhand.log` no longer raises when stdout is unwritable. Previously a failing `puts` inside the interceptor's `ensure` block could replace the host application's own LLM response with an `IOError`.
27
+ - A bad `intercept_addresses`/`exclude_api_patterns` value (e.g. a String or Symbol instead of an array of Strings) now degrades to "don't capture" instead of raising into every intercepted request.
28
+ - `BaseInterceptor.sanitize_url` now fails closed on an unparseable URL, dropping userinfo, query and fragment rather than returning the raw URL with credentials intact.
29
+ - Header redaction now also matches names containing `auth`, `passw`, `credential`, `bearer`, `jwt` or `session` (e.g. `X-Auth`, `X-Session-Id`, `X-Jwt`). **Migration note:** headers such as `x-session-id` now appear as `[REDACTED]` in Coolhand.
30
+ - `sanitize_body` matches the `data_sources` key case- and separator-insensitively, and also redacts `pwd`, `passw*`, `bearer` and `jwt` credential keys.
31
+ - Hosts with a trailing dot (`api.openai.com.`) are now matched like their dotless form.
32
+ - `OpenAi::WebhookValidator` rejects a bare `whsec_` secret, which decodes to an empty HMAC key and would make signatures forgeable.
33
+
34
+ ### Documentation
35
+ - Clarified that Google's "Gemini Enterprise Agent Platform" is the new marketing name for Vertex AI and does not change `aiplatform.googleapis.com` or `Coolhand::Vertex` ([#118](https://github.com/Coolhand-Labs/coolhand-ruby/pull/118)).
36
+
10
37
  ## [0.6.0] - 2026-09-12
11
38
 
12
39
  ### Added
data/README.md CHANGED
@@ -120,8 +120,8 @@ Coolhand.configure do |config|
120
120
  config.silent = Rails.env.production?
121
121
 
122
122
  # Specify which LLM endpoints to intercept (array of strings)
123
- # Optional - defaults to OpenAI, Anthropic, ElevenLabs, Google Gemini, and GitHub Models
124
- # config.intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.cohere.ai"]
123
+ # Optional - defaults to every provider listed under "Supported Libraries" below
124
+ # config.intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.cohere.com/v2/chat"]
125
125
  end
126
126
  ```
127
127
 
@@ -178,7 +178,7 @@ end
178
178
  | `enabled` | Boolean | `true` | Set to `false` to disable all patching and validation (e.g. `Rails.env.production?`) |
179
179
  | `capture` | Boolean | `true` | Whether to capture and forward intercepted requests. Set to `false` to monitor without forwarding, then use [`Coolhand.with_capture`](#selective-capture) to re-enable selectively |
180
180
  | `silent` | Boolean | `false` | Whether to suppress console output |
181
- | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API hosts to monitor (matched by host, not full URL). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
181
+ | `intercept_addresses` | Array | All [supported providers](#supported-libraries) (`DEFAULT_INTERCEPT_ADDRESSES`) | Array of API hosts to monitor (matched by host, optionally pinned to a port and/or path prefix). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
182
182
  | `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
183
183
  | `exclude_api_patterns` | Array | `["/batchPredictionJobs/"]` | Deny-list checked after `intercept_addresses`; matching paths are skipped. Unlike `intercept_addresses`, `exclude_api_patterns = []` genuinely disables exclusion. See [Configuration](docs/configuration.md) |
184
184
  | `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body — oversized bodies are replaced with a placeholder. Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
@@ -337,10 +337,18 @@ The monitor works with multiple transport layers and Ruby libraries:
337
337
  **Other providers monitored out of the box:**
338
338
  - Google Gemini (`generativelanguage.googleapis.com`)
339
339
  - Google Vertex AI (`aiplatform.googleapis.com`) — see the [Vertex AI batch result logging guide](docs/vertex.md) for async batch jobs
340
+ - Azure OpenAI Service, Azure AI Foundry, and Azure Machine Learning managed online endpoints (dedicated hosts, multi-service Azure AI Services hosts, and serverless/MaaS deployments) — see [Configuration](docs/configuration.md) for the full list of covered hosts
340
341
  - AWS Bedrock (OpenAI-compatible endpoint)
341
342
  - Cloudflare AI Gateway
342
343
  - OpenRouter
343
344
  - OpenCode Zen (`opencode.ai`)
345
+ - DeepSeek (`api.deepseek.com`)
346
+ - Mistral (`api.mistral.ai`)
347
+ - Perplexity (`api.perplexity.ai`)
348
+ - xAI (`api.x.ai`)
349
+ - Cohere (chat and embed endpoints only, on `api.cohere.com` and `api.cohere.ai`)
350
+ - TypeSafe Jev / System One (`api.typesafe.ai/v1/systemone`)
351
+ - Ollama (self-hosted, default port 11434 — chat, generate and embed endpoints) — see [Configuration](docs/configuration.md) for non-default hosts
344
352
 
345
353
  **Universal Coverage**: Since most Ruby HTTP libraries use Net::HTTP under the hood, Coolhand's single interceptor provides comprehensive monitoring without needing library-specific integrations.
346
354
 
@@ -74,7 +74,13 @@ Setting `intercept_addresses` **replaces** the default list entirely, so include
74
74
 
75
75
  The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
76
76
 
77
- Each entry is matched against the request's parsed host — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings or paths that merely mention a host can't trigger a false match.
77
+ Each entry is matched against the request's parsed host (optionally with a port and path prefix, see below) — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings that merely mention a host can't trigger a false match.
78
+
79
+ An entry can also pin a port (`host:port`) and/or a path (`host/path`, or `host:port/path`), and then a request must match all of them. Cohere, TypeSafe and Ollama are defaulted this way: `api.cohere.com/v2/chat`, `api.typesafe.ai/v1/systemone`, and `*:11434/api/chat` (plus `/api/generate`, `/api/embed`, `/api/embeddings`, also for `127.0.0.1`). Ollama has no fixed host, so its defaults require Ollama's default port *and* an Ollama path — an app's own unrelated `/api/chat` on another host or port is not captured. To capture an Ollama server on a dotted host or IP, or a non-default port, add entries such as `192.168.1.5:11434/api/chat` or `ollama.internal:8080/api/chat`. An IPv6 loopback server (`[::1]:11434`) is already covered by the defaults.
80
+
81
+ Path-anchoring is also how the Azure defaults work: multi-service hosts like `cognitiveservices.azure.com/openai/` (Azure AI Services, where Speech, Vision, Language and Content Safety share a domain with LLM inference) only match paths under the given prefix, after the host itself has matched.
82
+
83
+ **Note on Azure ML managed online endpoints:** `inference.ml.azure.com` and `inference.ml.azure.us` are intentionally captured *without* a path anchor. Every Azure ML managed online endpoint scores at `/score` regardless of what model sits behind it, so there's no path that distinguishes an LLM deployment from a tabular or vision one — this gem chooses to capture broadly rather than silently miss LLM deployments. If that over-captures for your setup, override `intercept_addresses` to a custom list omitting these two entries.
78
84
 
79
85
  A small number of Google API endpoints use a colon-suffixed path (e.g. `.../gemini-pro:generateContent`) with no host of their own. Those live in the separate `intercept_path_patterns` setting (default: `Coolhand::Configuration::DEFAULT_INTERCEPT_PATH_PATTERNS`) and, unlike `intercept_addresses`, are only checked against the path of requests whose host is already `googleapis.com` or a subdomain of it — and only when `intercept_addresses` still includes at least one `googleapis.com` host. If you override `intercept_addresses` to a list with no Google hosts, `intercept_path_patterns` has no effect and Google API traffic is not captured at all.
80
86
 
@@ -82,7 +88,7 @@ A small number of Google API endpoints use a colon-suffixed path (e.g. `.../gemi
82
88
 
83
89
  ## Excluding API Patterns
84
90
 
85
- `config.exclude_api_patterns` is a deny-list checked *after* `intercept_addresses` allows a request through — a match against the request's path is skipped and never forwarded as an `llm_request_log`. It defaults to `["/batchPredictionJobs/"]`, to suppress Vertex AI batch job management noise.
91
+ `config.exclude_api_patterns` is a deny-list checked *after* `intercept_addresses` allows a request through — a match against the request's path is skipped and never forwarded as an `llm_request_log`. It defaults to a small built-in list (see `Coolhand::Configuration::DEFAULT_EXCLUDE_API_PATTERNS`) that suppresses Vertex AI batch job management noise (`/batchPredictionJobs/`) and Azure OpenAI control-plane paths (file, batch, and fine-tuning management under `/openai/` and `/openai/v1/`) that aren't inference traffic.
86
92
 
87
93
  ```ruby
88
94
  Coolhand.configure do |config|
data/docs/vertex.md CHANGED
@@ -6,6 +6,8 @@ For monitoring regular (non-batch) Vertex AI calls, no extra setup is required b
6
6
 
7
7
  Requires Rails — `Coolhand::Vertex::BatchResultProcessor` logs via `Rails.logger` internally. `config.capture = false` and `Coolhand.without_capture` do not suppress these logs: unlike the passive Net::HTTP interceptor, calling this processor is an explicit, deliberate act, so it always sends.
8
8
 
9
+ > **Note:** As of May 2026, Google markets this product as "Gemini Enterprise Agent Platform" rather than "Vertex AI." The API endpoint (`aiplatform.googleapis.com`) and Ruby SDK are unchanged — it's a console/marketing rename, not an SDK rename — so `Coolhand::Vertex` naming stays accurate here. It's also still a distinct integration from the separate Gemini Developer API (`generativelanguage.googleapis.com`), which Coolhand intercepts independently.
10
+
9
11
  ## Usage
10
12
 
11
13
  ```ruby
@@ -11,7 +11,7 @@ module Coolhand
11
11
  # (cookie, set-cookie), and future/unknown providers using a
12
12
  # similarly-named header. Shared with LoggerService so the two logging
13
13
  # paths (interceptor + webhook forwarding) stay consistent.
14
- SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|authorization|cookie/i
14
+ SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
15
15
 
16
16
  def sanitize_headers(headers)
17
17
  return {} if headers.nil?
@@ -110,7 +110,42 @@ module Coolhand
110
110
 
111
111
  modified ? uri.to_s : url
112
112
  rescue URI::InvalidURIError
113
- url
113
+ # Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
114
+ # that could carry a credential (userinfo, query, fragment) rather than pass it through.
115
+ url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
116
+ end
117
+
118
+ # Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
119
+ # live datastore credentials in the request body, where header/query-param sanitization never
120
+ # looks. Matched against a separator-stripped, lowercased key name so both `connection_string`
121
+ # and `connectionString` (Cosmos/Mongo, embeds `AccountKey=...`) and `encoded_api_key`
122
+ # (Elasticsearch) are caught — not just exact or snake_case-only names.
123
+ SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
124
+
125
+ def sanitize_body(body)
126
+ return body unless body.is_a?(Hash)
127
+
128
+ sanitized = body.dup
129
+ sanitized.each_key do |key|
130
+ next unless key.to_s.delete("_-").casecmp?("datasources")
131
+
132
+ sanitized[key] = redact_sensitive_body_values(sanitized[key])
133
+ end
134
+ sanitized
135
+ end
136
+
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
148
+ end
114
149
  end
115
150
 
116
151
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
@@ -121,7 +156,7 @@ module Coolhand
121
156
  method: method.to_s.downcase,
122
157
  url: sanitize_url(url),
123
158
  headers: sanitize_headers(request_headers),
124
- request_body: request_body,
159
+ request_body: sanitize_body(request_body),
125
160
  response_headers: sanitize_headers(response_headers),
126
161
  response_body: response_body,
127
162
  status_code: status_code,
@@ -8,3 +8,14 @@
8
8
  # Users can disable: c.exclude_api_patterns = []
9
9
 
10
10
  - "/batchPredictionJobs/"
11
+ # Azure OpenAI control-plane paths (not inference) — both spellings are needed
12
+ # since substring matching is contiguous: "/openai/files" does not match
13
+ # "/openai/v1/files".
14
+ - "/openai/files"
15
+ - "/openai/batches"
16
+ - "/openai/fine_tuning"
17
+ - "/openai/models"
18
+ - "/openai/v1/files"
19
+ - "/openai/v1/batches"
20
+ - "/openai/v1/fine_tuning"
21
+ - "/openai/v1/models"
@@ -4,6 +4,10 @@
4
4
  # should be captured and forwarded as an llm_request_log. A single "*" in an
5
5
  # entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
6
6
  # matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
7
+ # An entry may also pin a port and/or a path prefix: "host:port", "host/path",
8
+ # or "host:port/path" (the path match is on a segment boundary) — useful for
9
+ # multi-service hosts where unrelated APIs (e.g. Azure Speech, Vision, Language)
10
+ # share a domain with the LLM inference path.
7
11
  #
8
12
  # Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
9
13
  # Users can override entirely: c.intercept_addresses = ["only.this.com"]
@@ -20,5 +24,55 @@
20
24
  - "aiplatform.googleapis.com"
21
25
  - "gateway.ai.cloudflare.com"
22
26
  - "bedrock-runtime.*.amazonaws.com"
27
+ - "api.deepseek.com"
28
+ - "api.mistral.ai"
29
+ - "api.perplexity.ai"
30
+ - "api.x.ai"
31
+ # Cohere is path-scoped, never host-wide: the server only ingests v2 chat and
32
+ # v1/v2 embed, and its other endpoints (v1 chat, rerank, tokenize, classify...)
33
+ # have different envelopes that would be recorded as empty "successes".
34
+ - "api.cohere.com/v2/chat"
35
+ - "api.cohere.com/v1/embed"
36
+ - "api.cohere.com/v2/embed"
37
+ - "api.cohere.ai/v2/chat"
38
+ - "api.cohere.ai/v1/embed"
39
+ - "api.cohere.ai/v2/embed"
40
+ # TypeSafe Jev (a "System One" model, not an LLM) — only /v1/systemone is ingested.
41
+ - "api.typesafe.ai/v1/systemone"
42
+ # Ollama (self-hosted) has no fixed host, so an entry must pin BOTH the default
43
+ # port (11434) and the path — a bare "/api/chat" would capture any app's own
44
+ # unrelated chat endpoint. "*" matches one host label (localhost, an "ollama"
45
+ # compose service, a bare hostname); a LAN IP or dotted host needs its own
46
+ # entry, e.g. "192.168.1.5:11434/api/chat".
47
+ - "*:11434/api/chat"
48
+ - "*:11434/api/generate"
49
+ - "*:11434/api/embed"
50
+ - "*:11434/api/embeddings"
51
+ - "127.0.0.1:11434/api/chat"
52
+ - "127.0.0.1:11434/api/generate"
53
+ - "127.0.0.1:11434/api/embed"
54
+ - "127.0.0.1:11434/api/embeddings"
23
55
  - "openrouter.ai"
24
56
  - "opencode.ai"
57
+ # Azure OpenAI dedicated hosts (commercial, US Gov, China/21Vianet)
58
+ - "openai.azure.com"
59
+ - "openai.azure.us"
60
+ - "openai.azure.cn"
61
+ # Azure AI Services / Foundry multi-service hosts — path-anchored so Speech,
62
+ # Vision, Language and Content Safety on the same host are NOT captured
63
+ - "cognitiveservices.azure.com/openai/"
64
+ - "cognitiveservices.azure.com/models/"
65
+ - "cognitiveservices.azure.us/openai/"
66
+ - "cognitiveservices.azure.us/models/"
67
+ - "cognitiveservices.azure.cn/openai/"
68
+ - "cognitiveservices.azure.cn/models/"
69
+ - "services.ai.azure.com/openai/"
70
+ - "services.ai.azure.com/models/"
71
+ - "services.ai.azure.us/openai/"
72
+ - "services.ai.azure.us/models/"
73
+ # Serverless / MaaS + Azure ML managed online endpoints. inference.ml.azure.*
74
+ # is deliberately unanchored — see docs/configuration.md for why.
75
+ - "inference.ai.azure.com"
76
+ - "models.ai.azure.com"
77
+ - "inference.ml.azure.com"
78
+ - "inference.ml.azure.us"
@@ -93,9 +93,8 @@ module Coolhand
93
93
  active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
94
94
  return super if active.key?(self)
95
95
 
96
- url = build_url_for_request(self, req)
97
- return super unless intercept?(url)
98
- return super unless should_capture?
96
+ url = capturable_url(req)
97
+ return super unless url
99
98
 
100
99
  # Capture body before setting the guard — if this raises we skip logging cleanly
101
100
  # and the guard is never set, so there is no leak. A failure here (e.g. an
@@ -226,17 +225,28 @@ module Coolhand
226
225
 
227
226
  return false if excluded_by_pattern?(uri)
228
227
 
229
- host = uri.host.downcase
228
+ host = uri.host.downcase.chomp(".")
229
+ path = uri.path.to_s
230
230
  addresses = Coolhand.configuration.intercept_addresses
231
- return true if addresses.any? { |a| host_matches?(host, a) }
231
+ authorities = [host, "#{host}:#{uri.port}"]
232
+ return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
232
233
 
233
234
  return false unless google_api_host_configured?(addresses)
234
235
  return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
235
236
 
236
- path = uri.path.to_s
237
237
  Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
238
238
  end
239
239
 
240
+ # The capture decision runs before the host's real request, so a bad config value (e.g. a
241
+ # non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
242
+ def capturable_url(req)
243
+ url = build_url_for_request(self, req)
244
+ url if intercept?(url) && should_capture?
245
+ rescue StandardError => e
246
+ Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
247
+ nil
248
+ end
249
+
240
250
  def excluded_by_pattern?(uri)
241
251
  patterns = Coolhand.configuration.exclude_api_patterns
242
252
  return false if patterns.nil? || patterns.empty?
@@ -259,6 +269,26 @@ module Coolhand
259
269
  end
260
270
  end
261
271
 
272
+ # An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
273
+ # path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
274
+ # that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
275
+ # matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
276
+ # host aren't swept in alongside the ones we mean to capture.
277
+ # The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
278
+ # stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
279
+ # a same-prefix-but-different-segment path like "/openaiz".
280
+ def address_matches?(host, path, pattern)
281
+ host_pattern, sep, path_pattern = pattern.to_s.partition("/")
282
+ return host_matches?(host, host_pattern) if sep.empty?
283
+ return false unless host_matches?(host, host_pattern)
284
+
285
+ path_pattern = path_pattern.delete_suffix("/")
286
+ return true if path_pattern.empty?
287
+
288
+ prefix = "/#{path_pattern}"
289
+ path == prefix || path.start_with?("#{prefix}/")
290
+ end
291
+
262
292
  # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
263
293
  # A single "*" in `pattern` matches exactly one host label, e.g.
264
294
  # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
@@ -98,7 +98,8 @@ module Coolhand
98
98
  signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
99
99
  expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
100
100
 
101
- signature_valid = webhook_signature.start_with?("v1,") &&
101
+ # An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
102
+ signature_valid = !secret_bytes.empty? && webhook_signature.start_with?("v1,") &&
102
103
  secure_compare(webhook_signature[3..], expected_signature)
103
104
 
104
105
  unless signature_valid
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/coolhand.rb CHANGED
@@ -91,6 +91,10 @@ module Coolhand
91
91
  return if configuration.silent
92
92
 
93
93
  puts "COOLHAND: #{message}"
94
+ rescue StandardError
95
+ # Logging must never raise into the host app (e.g. a closed stdout) — least of all from
96
+ # the interceptor's ensure block, where it would replace the host's own response.
97
+ nil
94
98
  end
95
99
 
96
100
  # Creates a new FeedbackService instance
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-13 00:00:00.000000000 Z
12
+ date: 2026-09-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64