@rulemetric/proxy 0.6.9 → 0.7.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.
- package/addon/allow_hosts.json +49 -0
- package/addon/reporter.py +155 -10
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_doc": [
|
|
3
|
+
"Canonical interception scope for the capture proxy (mitmdump --allow-hosts).",
|
|
4
|
+
"Each entry is a Python regex matched by mitmproxy against 'host:port' of a",
|
|
5
|
+
"CONNECT. Hosts that match NOTHING here are tunneled untouched — no TLS",
|
|
6
|
+
"interception, no CA involvement — so bundled-CA tools (pip/certifi, gcloud,",
|
|
7
|
+
"node without NODE_EXTRA_CA_CERTS) keep working while the machine-wide",
|
|
8
|
+
"HTTPS_PROXY is pinned. The list must cover every host detect_provider()",
|
|
9
|
+
"(providers.py) and the usage-research adapters can capture; the pytest",
|
|
10
|
+
"sync-guard test_allow_hosts.py enforces that. Escape hatches (read by",
|
|
11
|
+
"`rulemetric proxy start`): RULEMETRIC_PROXY_ALL_HOSTS=1 restores",
|
|
12
|
+
"intercept-everything (needed for generic OpenAI-compatible endpoints on",
|
|
13
|
+
"arbitrary self-hosted domains — the detect_provider catch-all);",
|
|
14
|
+
"RULEMETRIC_PROXY_EXTRA_HOSTS=<comma-separated regexes> extends this list."
|
|
15
|
+
],
|
|
16
|
+
"allow": [
|
|
17
|
+
"(^|\\.)api\\.anthropic\\.com:",
|
|
18
|
+
"(^|\\.)generativelanguage\\.googleapis\\.com:",
|
|
19
|
+
"bedrock-runtime[^:]*:",
|
|
20
|
+
"\\.snowflakecomputing\\.com:",
|
|
21
|
+
"(^|\\.)copilot-proxy\\.githubusercontent\\.com:",
|
|
22
|
+
"\\.openai\\.azure\\.com:",
|
|
23
|
+
"\\.models\\.ai\\.azure\\.com:",
|
|
24
|
+
"(^|\\.)api\\.cloudflare\\.com:",
|
|
25
|
+
"\\.cloud\\.databricks\\.com:",
|
|
26
|
+
"(^|\\.)api\\.openai\\.com:",
|
|
27
|
+
"(^|\\.)api\\.groq\\.com:",
|
|
28
|
+
"(^|\\.)api\\.together\\.xyz:",
|
|
29
|
+
"(^|\\.)api\\.fireworks\\.ai:",
|
|
30
|
+
"(^|\\.)api\\.deepseek\\.com:",
|
|
31
|
+
"(^|\\.)api\\.perplexity\\.ai:",
|
|
32
|
+
"(^|\\.)api\\.cerebras\\.ai:",
|
|
33
|
+
"(^|\\.)api\\.sambanova\\.ai:",
|
|
34
|
+
"(^|\\.)api\\.x\\.ai:",
|
|
35
|
+
"(^|\\.)api\\.mistral\\.ai:",
|
|
36
|
+
"(^|\\.)integrate\\.api\\.nvidia\\.com:",
|
|
37
|
+
"(^|\\.)api\\.ai21\\.com:",
|
|
38
|
+
"(^|\\.)api\\.cohere\\.com:",
|
|
39
|
+
"(^|\\.)openrouter\\.ai:",
|
|
40
|
+
"(^|\\.)openai-proxy\\.replicate\\.com:",
|
|
41
|
+
"(^|\\.)api\\.githubcopilot\\.com:",
|
|
42
|
+
"(^|\\.)api\\.individual\\.githubcopilot\\.com:",
|
|
43
|
+
"(^|\\.)api\\.business\\.githubcopilot\\.com:",
|
|
44
|
+
"(^|\\.)api\\.enterprise\\.githubcopilot\\.com:",
|
|
45
|
+
"(^|\\.)chatgpt\\.com:",
|
|
46
|
+
"^localhost:",
|
|
47
|
+
"^127\\.0\\.0\\.1:"
|
|
48
|
+
]
|
|
49
|
+
}
|
package/addon/reporter.py
CHANGED
|
@@ -40,15 +40,60 @@ import threading
|
|
|
40
40
|
import time
|
|
41
41
|
from typing import Any
|
|
42
42
|
from urllib.error import HTTPError, URLError
|
|
43
|
-
|
|
43
|
+
import ssl
|
|
44
|
+
from urllib.request import (
|
|
45
|
+
HTTPSHandler,
|
|
46
|
+
ProxyHandler,
|
|
47
|
+
Request,
|
|
48
|
+
build_opener,
|
|
49
|
+
install_opener,
|
|
50
|
+
urlopen,
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
from secret_redactor import redact_snapshot
|
|
46
54
|
|
|
55
|
+
|
|
56
|
+
def _make_ssl_context() -> ssl.SSLContext:
|
|
57
|
+
"""Default-verify TLS context with a certifi fallback.
|
|
58
|
+
|
|
59
|
+
The prod-default upload target makes TLS verification load-bearing, and
|
|
60
|
+
some Python builds (pyenv/homebrew) ship OpenSSL with NO default CA
|
|
61
|
+
paths — there, every https upload would fail CERTIFICATE_VERIFY_FAILED
|
|
62
|
+
and spool forever. mitmproxy depends on certifi, so load its bundle
|
|
63
|
+
whenever the interpreter's default store comes up empty. Verification is
|
|
64
|
+
never weakened — this only ADDs roots when there are none.
|
|
65
|
+
"""
|
|
66
|
+
ctx = ssl.create_default_context()
|
|
67
|
+
try:
|
|
68
|
+
if ctx.cert_store_stats().get("x509_ca", 0) == 0:
|
|
69
|
+
import certifi
|
|
70
|
+
|
|
71
|
+
ctx.load_verify_locations(certifi.where())
|
|
72
|
+
except Exception: # pragma: no cover — verification stays at default
|
|
73
|
+
pass
|
|
74
|
+
return ctx
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# The reporter's OWN uploads to the RuleMetric API must go DIRECT, never through
|
|
78
|
+
# the capture proxy. The mitmproxy process inherits a machine-wide HTTPS_PROXY
|
|
79
|
+
# (installMacOSProxyEnv runs `launchctl setenv HTTPS_PROXY http://localhost:8787`),
|
|
80
|
+
# and Python's default urlopen honors it — so without this, every upload to
|
|
81
|
+
# https://rulemetric.com would loop back through the gateway → mitmproxy, get
|
|
82
|
+
# re-signed with mitmproxy's CA (which urllib does not trust), fail TLS
|
|
83
|
+
# verification, and be dropped. Installing a no-proxy opener PROCESS-WIDE (the
|
|
84
|
+
# urllib equivalent of the bash hooks' `curl --noproxy '*'`) fixes every
|
|
85
|
+
# urlopen() upload in both reporter.py and rulemetric_addon.py while keeping the
|
|
86
|
+
# call sites (and their test mocks of `reporter.urlopen`) unchanged.
|
|
87
|
+
install_opener(
|
|
88
|
+
build_opener(ProxyHandler({}), HTTPSHandler(context=_make_ssl_context()))
|
|
89
|
+
)
|
|
90
|
+
|
|
47
91
|
logger = logging.getLogger("rulemetric.reporter")
|
|
48
92
|
|
|
49
93
|
DEFAULT_TIMEOUT = 5 # seconds (was 2 — too aggressive for cold starts)
|
|
50
94
|
MAX_RETRIES = 2 # 3 total attempts
|
|
51
95
|
RETRY_BACKOFF = 0.5 # seconds — exponential backoff base
|
|
96
|
+
RETRY_AFTER_CAP = 10 # seconds — cap server-provided Retry-After (mirrors _post.sh)
|
|
52
97
|
|
|
53
98
|
SPOOL_MAX_BYTES = 100 * 1024 * 1024 # 100 MB cap before FIFO eviction
|
|
54
99
|
SPOOL_EVICT_FRACTION = 0.10 # evict the oldest 10% when over cap
|
|
@@ -94,13 +139,70 @@ def get_stats() -> dict[str, dict[str, int]]:
|
|
|
94
139
|
return {kind: dict(values) for kind, values in _stats.items()}
|
|
95
140
|
|
|
96
141
|
|
|
142
|
+
def _env_file_values() -> dict[str, str]:
|
|
143
|
+
"""Config fallback from ~/.config/rulemetric/env, mirroring the CLI
|
|
144
|
+
(loadEnvFileAuth, api-client.ts) and the bash hooks (_config.sh). The
|
|
145
|
+
mitmproxy process is frequently spawned WITHOUT RULEMETRIC_API_URL / _API_KEY
|
|
146
|
+
in its own environment, so without this fallback the reporter would default
|
|
147
|
+
to a dead localhost and silently drop every capture. Cheap (a <10-line file),
|
|
148
|
+
read per-call so a mid-session `auth login` is picked up. Never raises."""
|
|
149
|
+
values: dict[str, str] = {}
|
|
150
|
+
try:
|
|
151
|
+
with open(os.path.join(_config_dir(), "env"), "r", encoding="utf-8") as fh:
|
|
152
|
+
for raw in fh:
|
|
153
|
+
line = raw.strip()
|
|
154
|
+
if not line or line.startswith("#"):
|
|
155
|
+
continue
|
|
156
|
+
if line.startswith("export "):
|
|
157
|
+
line = line[len("export "):]
|
|
158
|
+
key, sep, val = line.partition("=")
|
|
159
|
+
if sep and key.strip() in (
|
|
160
|
+
"RULEMETRIC_API_URL",
|
|
161
|
+
"RULEMETRIC_API_KEY",
|
|
162
|
+
"RULEMETRIC_ACCESS_TOKEN",
|
|
163
|
+
):
|
|
164
|
+
values[key.strip()] = val.strip().strip('"').strip("'")
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
return values
|
|
168
|
+
|
|
169
|
+
|
|
97
170
|
def _get_api_url() -> str:
|
|
98
|
-
|
|
171
|
+
# env > env-file > prod (NOT a dead localhost). Mirrors api-client.ts:70 and
|
|
172
|
+
# _config.sh:78 so the proxy agrees with the CLI + hooks. Dev still opts into
|
|
173
|
+
# localhost by exporting RULEMETRIC_API_URL.
|
|
174
|
+
return (
|
|
175
|
+
os.environ.get("RULEMETRIC_API_URL")
|
|
176
|
+
or _env_file_values().get("RULEMETRIC_API_URL")
|
|
177
|
+
or "https://rulemetric.com"
|
|
178
|
+
)
|
|
99
179
|
|
|
100
180
|
|
|
101
|
-
def _get_auth_headers() -> dict[str, str]:
|
|
102
|
-
|
|
103
|
-
|
|
181
|
+
def _get_auth_headers(prefer_file: bool = False) -> dict[str, str]:
|
|
182
|
+
env_api_key = os.environ.get("RULEMETRIC_API_KEY", "")
|
|
183
|
+
env_token = os.environ.get("RULEMETRIC_ACCESS_TOKEN", "")
|
|
184
|
+
|
|
185
|
+
# Env-file fallback (same file the CLI + bash hooks read). Two cases:
|
|
186
|
+
# the mitmproxy process may carry NO credential in its own env, or — the
|
|
187
|
+
# nastier one — only an EXPIRING access token frozen at spawn time. This
|
|
188
|
+
# is a long-running daemon: an inherited JWT dies within ~an hour and
|
|
189
|
+
# every upload then 401s forever. The env file is re-read per call, so a
|
|
190
|
+
# later `rulemetric auth login` (which writes a non-expiring rm_ API key)
|
|
191
|
+
# heals a running proxy. Precedence: an API key always beats a token
|
|
192
|
+
# (keys don't expire); `prefer_file=True` (the 401-refresh path in
|
|
193
|
+
# _post_with_retry) additionally lets FILE values beat the frozen spawn
|
|
194
|
+
# env within each credential type.
|
|
195
|
+
env_file: dict[str, str] = {}
|
|
196
|
+
if prefer_file or not env_api_key or not env_token:
|
|
197
|
+
env_file = _env_file_values()
|
|
198
|
+
file_api_key = env_file.get("RULEMETRIC_API_KEY", "")
|
|
199
|
+
file_token = env_file.get("RULEMETRIC_ACCESS_TOKEN", "")
|
|
200
|
+
if prefer_file:
|
|
201
|
+
api_key = file_api_key or env_api_key
|
|
202
|
+
token = file_token or env_token
|
|
203
|
+
else:
|
|
204
|
+
api_key = env_api_key or file_api_key
|
|
205
|
+
token = env_token or file_token
|
|
104
206
|
|
|
105
207
|
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
106
208
|
if api_key:
|
|
@@ -181,8 +283,10 @@ def _post_with_retry(
|
|
|
181
283
|
"""
|
|
182
284
|
last_error: str | None = None
|
|
183
285
|
is_permanent = False
|
|
286
|
+
auth_refreshed = False
|
|
184
287
|
|
|
185
288
|
for attempt in range(1 + MAX_RETRIES):
|
|
289
|
+
sleep_override: float | None = None
|
|
186
290
|
try:
|
|
187
291
|
req = Request(url, data=data, headers=headers, method="POST")
|
|
188
292
|
with urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
|
|
@@ -196,7 +300,25 @@ def _post_with_retry(
|
|
|
196
300
|
)
|
|
197
301
|
except HTTPError as exc:
|
|
198
302
|
last_error = f"HTTP {exc.code}: {exc.reason}"
|
|
199
|
-
|
|
303
|
+
# 401/403 with credentials frozen at spawn time: the env file may
|
|
304
|
+
# hold FRESHER credentials (a later `auth login` rewrites it while
|
|
305
|
+
# this daemon keeps running). Rebuild headers file-first and retry
|
|
306
|
+
# once; if the file offers nothing different, the rejection is
|
|
307
|
+
# real and falls through to the permanent branch below.
|
|
308
|
+
if exc.code in (401, 403) and not auth_refreshed:
|
|
309
|
+
auth_refreshed = True
|
|
310
|
+
fresh = _get_auth_headers(prefer_file=True)
|
|
311
|
+
if (
|
|
312
|
+
fresh.get("Authorization")
|
|
313
|
+
and fresh.get("Authorization") != headers.get("Authorization")
|
|
314
|
+
):
|
|
315
|
+
logger.warning(
|
|
316
|
+
"%s: HTTP %d — retrying with refreshed env-file credentials",
|
|
317
|
+
log_label, exc.code,
|
|
318
|
+
)
|
|
319
|
+
headers = {**headers, "Authorization": fresh["Authorization"]}
|
|
320
|
+
continue
|
|
321
|
+
if 400 <= exc.code < 500 and exc.code not in (408, 429):
|
|
200
322
|
# 409 from a duplicate replay is "success" — receiver already
|
|
201
323
|
# has this row. Treat as posted, not failed.
|
|
202
324
|
if exc.code == 409:
|
|
@@ -207,6 +329,19 @@ def _post_with_retry(
|
|
|
207
329
|
)
|
|
208
330
|
is_permanent = True
|
|
209
331
|
return False, last_error, True
|
|
332
|
+
# 429 is the API's DESIGNED backpressure: the ingest admission
|
|
333
|
+
# gate sheds bursts on exactly the two endpoints we post to with
|
|
334
|
+
# 429 + Retry-After (ingest-load-shed.ts), and its safety
|
|
335
|
+
# argument assumes clients retry — the bash hooks do; so must
|
|
336
|
+
# we. 408 is a timeout, transient by definition. Honor
|
|
337
|
+
# Retry-After (capped) when the server supplies it.
|
|
338
|
+
if exc.code == 429:
|
|
339
|
+
hdr = exc.headers.get("Retry-After") if exc.headers else None
|
|
340
|
+
try:
|
|
341
|
+
if hdr is not None:
|
|
342
|
+
sleep_override = min(max(int(hdr), 0), RETRY_AFTER_CAP)
|
|
343
|
+
except (TypeError, ValueError):
|
|
344
|
+
sleep_override = None
|
|
210
345
|
logger.warning(
|
|
211
346
|
"%s: HTTP %d on attempt %d/%d: %s",
|
|
212
347
|
log_label, exc.code, attempt + 1, 1 + MAX_RETRIES, exc.reason,
|
|
@@ -219,7 +354,11 @@ def _post_with_retry(
|
|
|
219
354
|
)
|
|
220
355
|
|
|
221
356
|
if attempt < MAX_RETRIES:
|
|
222
|
-
time.sleep(
|
|
357
|
+
time.sleep(
|
|
358
|
+
sleep_override
|
|
359
|
+
if sleep_override is not None
|
|
360
|
+
else RETRY_BACKOFF * (2 ** attempt)
|
|
361
|
+
)
|
|
223
362
|
|
|
224
363
|
return False, last_error, is_permanent
|
|
225
364
|
|
|
@@ -559,6 +698,7 @@ def _post_snapshot(session_id: str, snapshot: dict) -> None:
|
|
|
559
698
|
_linked_sessions.add(session_id)
|
|
560
699
|
threading.Thread(
|
|
561
700
|
target=_trigger_link_instructions,
|
|
701
|
+
args=(session_id,),
|
|
562
702
|
daemon=True,
|
|
563
703
|
name=f"rulemetric-link-{session_id[:8]}",
|
|
564
704
|
).start()
|
|
@@ -569,14 +709,19 @@ def _post_snapshot(session_id: str, snapshot: dict) -> None:
|
|
|
569
709
|
_spool_append("snapshot", session_id, snapshot)
|
|
570
710
|
|
|
571
711
|
|
|
572
|
-
def _trigger_link_instructions() -> None:
|
|
573
|
-
"""
|
|
712
|
+
def _trigger_link_instructions(session_id: str) -> None:
|
|
713
|
+
"""POST /api/sessions/link-instructions scoped to one session.
|
|
714
|
+
|
|
715
|
+
The session id is required: the API answers an unscoped body as a no-op
|
|
716
|
+
(whole-account scans are gated behind an explicit backfill flag).
|
|
717
|
+
"""
|
|
574
718
|
api_url = _get_api_url()
|
|
575
719
|
url = f"{api_url}/api/sessions/link-instructions"
|
|
576
720
|
headers = _get_auth_headers()
|
|
721
|
+
data = json.dumps({"sessionId": session_id}).encode("utf-8")
|
|
577
722
|
|
|
578
723
|
try:
|
|
579
|
-
req = Request(url, data=
|
|
724
|
+
req = Request(url, data=data, headers=headers, method="POST")
|
|
580
725
|
with urlopen(req, timeout=10) as resp:
|
|
581
726
|
if resp.status < 300:
|
|
582
727
|
body = json.loads(resp.read())
|