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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +11 -3
- data/docs/configuration.md +8 -2
- data/docs/vertex.md +2 -0
- data/lib/coolhand/base_interceptor.rb +38 -3
- data/lib/coolhand/default_exclude_api_patterns.yml +11 -0
- data/lib/coolhand/default_intercept_addresses.yml +54 -0
- data/lib/coolhand/net_http_interceptor.rb +36 -6
- data/lib/coolhand/open_ai/webhook_validator.rb +2 -1
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand.rb +4 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f38b442e23ef4733417edbaa234c3459734a11f861caeb4261ea034f7a464dd
|
|
4
|
+
data.tar.gz: c3d6aee98d5eea898ff53bedba1f43497c82517ad335d330d5e6619a5035e13f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
124
|
-
# config.intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.cohere.
|
|
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 |
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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|
|
|
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
|
-
|
|
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 =
|
|
97
|
-
return super unless
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/coolhand/version.rb
CHANGED
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.
|
|
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-
|
|
12
|
+
date: 2026-09-20 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: base64
|