@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.
@@ -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
- from urllib.request import Request, urlopen
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
- return os.environ.get("RULEMETRIC_API_URL", "http://localhost:3000")
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
- api_key = os.environ.get("RULEMETRIC_API_KEY", "")
103
- token = os.environ.get("RULEMETRIC_ACCESS_TOKEN", "")
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
- if 400 <= exc.code < 500:
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(RETRY_BACKOFF * (2 ** attempt))
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
- """Call POST /api/sessions/link-instructions to auto-link captured context."""
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=b"{}", headers=headers, method="POST")
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())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/proxy",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",