fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__py3-none-any.whl
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.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/proxy/proxy.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Simple HTTP proxy for capturing traffic during eval runs.
|
|
2
|
+
|
|
3
|
+
Captures requests/responses to whitelisted endpoints and writes them to a JSONL file.
|
|
4
|
+
Uses aiohttp as a simple HTTP proxy (no SSL interception, just tunneling for HTTPS).
|
|
5
|
+
|
|
6
|
+
Whitelist tiers:
|
|
7
|
+
1. Static: Known public LLM endpoints (googleapis.com, openai.com, etc.)
|
|
8
|
+
2. Runtime: Dynamically detected from SDK client initialization
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Set
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
import ssl
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Output directory
|
|
26
|
+
DEFAULT_LOG_DIR = Path.home() / ".fleet" / "proxy_logs"
|
|
27
|
+
|
|
28
|
+
# Import whitelist utilities
|
|
29
|
+
try:
|
|
30
|
+
from .whitelist import is_whitelisted, get_full_whitelist, STATIC_WHITELIST
|
|
31
|
+
except ImportError:
|
|
32
|
+
# Fallback for subprocess execution
|
|
33
|
+
from fleet.proxy.whitelist import is_whitelisted, get_full_whitelist, STATIC_WHITELIST
|
|
34
|
+
|
|
35
|
+
# Headers to redact (case-insensitive)
|
|
36
|
+
SENSITIVE_HEADERS = {
|
|
37
|
+
"authorization",
|
|
38
|
+
"x-api-key",
|
|
39
|
+
"api-key",
|
|
40
|
+
"x-goog-api-key",
|
|
41
|
+
"x-gemini-api-key",
|
|
42
|
+
"x-openai-api-key",
|
|
43
|
+
"x-anthropic-api-key",
|
|
44
|
+
"cookie",
|
|
45
|
+
"set-cookie",
|
|
46
|
+
"x-auth-token",
|
|
47
|
+
"x-access-token",
|
|
48
|
+
"x-refresh-token",
|
|
49
|
+
"proxy-authorization",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def redact_headers(headers: dict) -> dict:
|
|
54
|
+
"""Redact sensitive headers from a headers dict."""
|
|
55
|
+
redacted = {}
|
|
56
|
+
for k, v in headers.items():
|
|
57
|
+
if k.lower() in SENSITIVE_HEADERS:
|
|
58
|
+
# Keep first 8 chars for debugging, redact rest
|
|
59
|
+
if len(v) > 12:
|
|
60
|
+
redacted[k] = v[:8] + "...[REDACTED]"
|
|
61
|
+
else:
|
|
62
|
+
redacted[k] = "[REDACTED]"
|
|
63
|
+
else:
|
|
64
|
+
redacted[k] = v
|
|
65
|
+
return redacted
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_host(url: str) -> str:
|
|
69
|
+
"""Extract host from URL."""
|
|
70
|
+
if "://" not in url:
|
|
71
|
+
url = "http://" + url
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
return parsed.netloc.split(":")[0] # Remove port if present
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TrafficLogger:
|
|
77
|
+
"""Logs HTTP traffic to a JSONL file with whitelist filtering."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, log_file: Path, use_whitelist: bool = True):
|
|
80
|
+
self.log_file = log_file
|
|
81
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
self._file = open(log_file, "a")
|
|
83
|
+
self._lock = asyncio.Lock()
|
|
84
|
+
self._use_whitelist = use_whitelist
|
|
85
|
+
self._logged_count = 0
|
|
86
|
+
self._skipped_count = 0
|
|
87
|
+
|
|
88
|
+
# Log active whitelist at startup
|
|
89
|
+
whitelist = get_full_whitelist()
|
|
90
|
+
logger.info(f"Traffic logging to: {log_file}")
|
|
91
|
+
logger.info(f"Whitelist active: {sorted(whitelist)}")
|
|
92
|
+
print(f"Traffic logging to: {log_file}")
|
|
93
|
+
print(f"Whitelist ({len(whitelist)} endpoints): {', '.join(sorted(whitelist)[:5])}{'...' if len(whitelist) > 5 else ''}")
|
|
94
|
+
|
|
95
|
+
def should_log(self, host: str) -> bool:
|
|
96
|
+
"""Check if traffic to this host should be logged."""
|
|
97
|
+
if not self._use_whitelist:
|
|
98
|
+
return True
|
|
99
|
+
return is_whitelisted(host)
|
|
100
|
+
|
|
101
|
+
async def log(self, entry: dict, host: Optional[str] = None):
|
|
102
|
+
"""Log a traffic entry if it passes whitelist filter."""
|
|
103
|
+
# Check whitelist
|
|
104
|
+
if host and not self.should_log(host):
|
|
105
|
+
self._skipped_count += 1
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
entry["logged_at"] = datetime.now().isoformat()
|
|
109
|
+
async with self._lock:
|
|
110
|
+
self._file.write(json.dumps(entry) + "\n")
|
|
111
|
+
self._file.flush()
|
|
112
|
+
self._logged_count += 1
|
|
113
|
+
|
|
114
|
+
def close(self):
|
|
115
|
+
logger.info(f"Traffic logger closed. Logged: {self._logged_count}, Skipped: {self._skipped_count}")
|
|
116
|
+
self._file.close()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def logged_count(self) -> int:
|
|
120
|
+
return self._logged_count
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def skipped_count(self) -> int:
|
|
124
|
+
return self._skipped_count
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def run_proxy_server(
|
|
128
|
+
host: str = "127.0.0.1",
|
|
129
|
+
port: int = 8888,
|
|
130
|
+
log_file: Optional[Path] = None,
|
|
131
|
+
use_whitelist: bool = True,
|
|
132
|
+
):
|
|
133
|
+
"""Run a simple HTTP proxy server with whitelist filtering.
|
|
134
|
+
|
|
135
|
+
This proxy:
|
|
136
|
+
- Logs requests/responses to whitelisted endpoints (LLM APIs)
|
|
137
|
+
- Tunnels HTTPS via CONNECT (logs URL but not content)
|
|
138
|
+
- Filters out non-whitelisted traffic (reduces noise)
|
|
139
|
+
"""
|
|
140
|
+
import aiohttp
|
|
141
|
+
from aiohttp import web
|
|
142
|
+
|
|
143
|
+
log_file = log_file or (DEFAULT_LOG_DIR / f"traffic_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl")
|
|
144
|
+
traffic_logger = TrafficLogger(log_file, use_whitelist=use_whitelist)
|
|
145
|
+
|
|
146
|
+
async def handle_connect(request: web.Request):
|
|
147
|
+
"""Handle CONNECT for HTTPS tunneling."""
|
|
148
|
+
# Parse host:port from request.path
|
|
149
|
+
target = request.path_qs
|
|
150
|
+
if ":" in target:
|
|
151
|
+
target_host, target_port = target.rsplit(":", 1)
|
|
152
|
+
target_port = int(target_port)
|
|
153
|
+
else:
|
|
154
|
+
target_host = target
|
|
155
|
+
target_port = 443
|
|
156
|
+
|
|
157
|
+
# Log the CONNECT request (we can see the host but not the content)
|
|
158
|
+
# Only log if host is whitelisted
|
|
159
|
+
await traffic_logger.log({
|
|
160
|
+
"type": "https_connect",
|
|
161
|
+
"timestamp": datetime.now().isoformat(),
|
|
162
|
+
"method": "CONNECT",
|
|
163
|
+
"host": target_host,
|
|
164
|
+
"port": target_port,
|
|
165
|
+
"path": request.path_qs,
|
|
166
|
+
}, host=target_host)
|
|
167
|
+
|
|
168
|
+
# Connect to target
|
|
169
|
+
try:
|
|
170
|
+
reader, writer = await asyncio.open_connection(target_host, target_port)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
await traffic_logger.log({
|
|
173
|
+
"type": "https_connect_error",
|
|
174
|
+
"timestamp": datetime.now().isoformat(),
|
|
175
|
+
"host": target_host,
|
|
176
|
+
"port": target_port,
|
|
177
|
+
"error": str(e),
|
|
178
|
+
}, host=target_host)
|
|
179
|
+
return web.Response(status=502, text=f"Failed to connect: {e}")
|
|
180
|
+
|
|
181
|
+
# Send 200 Connection Established
|
|
182
|
+
response = web.StreamResponse(status=200, reason="Connection Established")
|
|
183
|
+
await response.prepare(request)
|
|
184
|
+
|
|
185
|
+
# Get the underlying transport
|
|
186
|
+
# This is a hack to get raw socket access in aiohttp
|
|
187
|
+
transport = request.transport
|
|
188
|
+
|
|
189
|
+
async def pipe(reader_from, writer_to):
|
|
190
|
+
try:
|
|
191
|
+
while True:
|
|
192
|
+
data = await reader_from.read(65536)
|
|
193
|
+
if not data:
|
|
194
|
+
break
|
|
195
|
+
writer_to.write(data)
|
|
196
|
+
await writer_to.drain()
|
|
197
|
+
except:
|
|
198
|
+
pass
|
|
199
|
+
finally:
|
|
200
|
+
try:
|
|
201
|
+
writer_to.close()
|
|
202
|
+
except:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
# Pipe data in both directions
|
|
206
|
+
client_reader = request.content
|
|
207
|
+
|
|
208
|
+
# For CONNECT tunneling, we need to hijack the connection
|
|
209
|
+
# This is complex with aiohttp, so we'll just close and note it
|
|
210
|
+
writer.close()
|
|
211
|
+
await traffic_logger.log({
|
|
212
|
+
"type": "https_tunnel_started",
|
|
213
|
+
"timestamp": datetime.now().isoformat(),
|
|
214
|
+
"host": target_host,
|
|
215
|
+
"port": target_port,
|
|
216
|
+
}, host=target_host)
|
|
217
|
+
|
|
218
|
+
return response
|
|
219
|
+
|
|
220
|
+
async def handle_request(request: web.Request):
|
|
221
|
+
"""Handle HTTP requests (non-CONNECT)."""
|
|
222
|
+
start_time = time.time()
|
|
223
|
+
|
|
224
|
+
# Build target URL
|
|
225
|
+
url = str(request.url)
|
|
226
|
+
if not url.startswith("http"):
|
|
227
|
+
# Relative URL - reconstruct from Host header
|
|
228
|
+
host_header = request.headers.get("Host", "")
|
|
229
|
+
url = f"http://{host_header}{request.path_qs}"
|
|
230
|
+
|
|
231
|
+
# Extract host for whitelist filtering
|
|
232
|
+
target_host = extract_host(url)
|
|
233
|
+
|
|
234
|
+
# Log request (redact sensitive headers)
|
|
235
|
+
request_entry = {
|
|
236
|
+
"method": request.method,
|
|
237
|
+
"url": url,
|
|
238
|
+
"headers": redact_headers(dict(request.headers)),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Read request body
|
|
242
|
+
try:
|
|
243
|
+
body = await request.read()
|
|
244
|
+
if body:
|
|
245
|
+
request_entry["body_length"] = len(body)
|
|
246
|
+
# Include body for JSON
|
|
247
|
+
content_type = request.headers.get("Content-Type", "")
|
|
248
|
+
if "json" in content_type:
|
|
249
|
+
try:
|
|
250
|
+
body_str = body.decode("utf-8", errors="replace")
|
|
251
|
+
if len(body_str) > 10000:
|
|
252
|
+
body_str = body_str[:10000] + "...[truncated]"
|
|
253
|
+
request_entry["body"] = body_str
|
|
254
|
+
except:
|
|
255
|
+
pass
|
|
256
|
+
except:
|
|
257
|
+
body = None
|
|
258
|
+
|
|
259
|
+
# Forward request
|
|
260
|
+
try:
|
|
261
|
+
async with aiohttp.ClientSession() as session:
|
|
262
|
+
async with session.request(
|
|
263
|
+
method=request.method,
|
|
264
|
+
url=url,
|
|
265
|
+
headers={k: v for k, v in request.headers.items() if k.lower() not in ("host", "connection")},
|
|
266
|
+
data=body,
|
|
267
|
+
ssl=False, # Don't verify SSL
|
|
268
|
+
) as resp:
|
|
269
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
270
|
+
|
|
271
|
+
# Read response body
|
|
272
|
+
resp_body = await resp.read()
|
|
273
|
+
|
|
274
|
+
# Build response entry (redact sensitive headers)
|
|
275
|
+
response_entry = {
|
|
276
|
+
"status_code": resp.status,
|
|
277
|
+
"headers": redact_headers(dict(resp.headers)),
|
|
278
|
+
"body_length": len(resp_body) if resp_body else 0,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Include body for JSON and SSE responses (MCP uses text/event-stream)
|
|
282
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
283
|
+
if resp_body and ("json" in content_type or "event-stream" in content_type or "text" in content_type):
|
|
284
|
+
try:
|
|
285
|
+
body_str = resp_body.decode("utf-8", errors="replace")
|
|
286
|
+
if len(body_str) > 50000:
|
|
287
|
+
body_str = body_str[:50000] + "...[truncated]"
|
|
288
|
+
response_entry["body"] = body_str
|
|
289
|
+
except:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
# Log complete entry (only if host is whitelisted)
|
|
293
|
+
await traffic_logger.log({
|
|
294
|
+
"type": "http",
|
|
295
|
+
"timestamp": datetime.now().isoformat(),
|
|
296
|
+
"duration_ms": duration_ms,
|
|
297
|
+
"host": target_host,
|
|
298
|
+
"request": request_entry,
|
|
299
|
+
"response": response_entry,
|
|
300
|
+
}, host=target_host)
|
|
301
|
+
|
|
302
|
+
# Return response to client
|
|
303
|
+
return web.Response(
|
|
304
|
+
status=resp.status,
|
|
305
|
+
headers={k: v for k, v in resp.headers.items() if k.lower() not in ("transfer-encoding", "content-encoding", "connection")},
|
|
306
|
+
body=resp_body,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
await traffic_logger.log({
|
|
311
|
+
"type": "http_error",
|
|
312
|
+
"timestamp": datetime.now().isoformat(),
|
|
313
|
+
"host": target_host,
|
|
314
|
+
"request": request_entry,
|
|
315
|
+
"error": str(e),
|
|
316
|
+
}, host=target_host)
|
|
317
|
+
return web.Response(status=502, text=f"Proxy error: {e}")
|
|
318
|
+
|
|
319
|
+
async def handler(request: web.Request):
|
|
320
|
+
"""Main request handler."""
|
|
321
|
+
if request.method == "CONNECT":
|
|
322
|
+
return await handle_connect(request)
|
|
323
|
+
else:
|
|
324
|
+
return await handle_request(request)
|
|
325
|
+
|
|
326
|
+
# Create app
|
|
327
|
+
app = web.Application()
|
|
328
|
+
app.router.add_route("*", "/{path:.*}", handler)
|
|
329
|
+
|
|
330
|
+
runner = web.AppRunner(app)
|
|
331
|
+
await runner.setup()
|
|
332
|
+
|
|
333
|
+
site = web.TCPSite(runner, host, port)
|
|
334
|
+
await site.start()
|
|
335
|
+
|
|
336
|
+
print(f"Proxy listening on {host}:{port}")
|
|
337
|
+
print(f"Traffic logging to: {log_file}")
|
|
338
|
+
print("Press Ctrl+C to stop")
|
|
339
|
+
|
|
340
|
+
# Keep running
|
|
341
|
+
try:
|
|
342
|
+
while True:
|
|
343
|
+
await asyncio.sleep(3600)
|
|
344
|
+
except asyncio.CancelledError:
|
|
345
|
+
pass
|
|
346
|
+
finally:
|
|
347
|
+
traffic_logger.close()
|
|
348
|
+
await runner.cleanup()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class ProxyManager:
|
|
352
|
+
"""Manages the lifecycle of the proxy server."""
|
|
353
|
+
|
|
354
|
+
def __init__(self, port: int = 8888, log_file: Optional[Path] = None):
|
|
355
|
+
self.port = port
|
|
356
|
+
self.log_file = log_file or (DEFAULT_LOG_DIR / f"traffic_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl")
|
|
357
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
358
|
+
self._started = False
|
|
359
|
+
|
|
360
|
+
async def start(self) -> dict:
|
|
361
|
+
"""Start the proxy in a subprocess.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
dict with proxy env vars to set
|
|
365
|
+
"""
|
|
366
|
+
if self._started:
|
|
367
|
+
return self.get_env_vars()
|
|
368
|
+
|
|
369
|
+
# Ensure log dir exists
|
|
370
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
|
|
372
|
+
# Start proxy as subprocess
|
|
373
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
374
|
+
sys.executable, "-m", "fleet.proxy.proxy",
|
|
375
|
+
"--port", str(self.port),
|
|
376
|
+
"--log-file", str(self.log_file),
|
|
377
|
+
stdout=asyncio.subprocess.PIPE,
|
|
378
|
+
stderr=asyncio.subprocess.PIPE,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Wait for proxy to be ready
|
|
382
|
+
await asyncio.sleep(2)
|
|
383
|
+
|
|
384
|
+
if self._process.returncode is not None:
|
|
385
|
+
stdout, stderr = await self._process.communicate()
|
|
386
|
+
raise RuntimeError(f"Proxy failed to start: {stderr.decode()}")
|
|
387
|
+
|
|
388
|
+
self._started = True
|
|
389
|
+
logger.info(f"Proxy started on port {self.port}, logging to {self.log_file}")
|
|
390
|
+
|
|
391
|
+
return self.get_env_vars()
|
|
392
|
+
|
|
393
|
+
def get_env_vars(self) -> dict:
|
|
394
|
+
"""Get environment variables for using this proxy.
|
|
395
|
+
|
|
396
|
+
Note: We only proxy HTTP, not HTTPS, to avoid SSL issues with LLM APIs.
|
|
397
|
+
HTTPS traffic goes direct but we can't inspect the content.
|
|
398
|
+
"""
|
|
399
|
+
proxy_url = f"http://127.0.0.1:{self.port}"
|
|
400
|
+
return {
|
|
401
|
+
"HTTP_PROXY": proxy_url,
|
|
402
|
+
"http_proxy": proxy_url,
|
|
403
|
+
# Don't proxy HTTPS - our simple proxy doesn't handle SSL interception
|
|
404
|
+
# This means we can only log HTTP traffic, not HTTPS API calls
|
|
405
|
+
# "HTTPS_PROXY": proxy_url,
|
|
406
|
+
# "https_proxy": proxy_url,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async def stop(self):
|
|
410
|
+
"""Stop the proxy."""
|
|
411
|
+
if self._process and self._process.returncode is None:
|
|
412
|
+
self._process.terminate()
|
|
413
|
+
try:
|
|
414
|
+
await asyncio.wait_for(self._process.wait(), timeout=5)
|
|
415
|
+
except asyncio.TimeoutError:
|
|
416
|
+
self._process.kill()
|
|
417
|
+
await self._process.wait()
|
|
418
|
+
self._started = False
|
|
419
|
+
logger.info("Proxy stopped")
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def log_path(self) -> Path:
|
|
423
|
+
return self.log_file
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def main():
|
|
427
|
+
"""CLI entry point."""
|
|
428
|
+
import argparse
|
|
429
|
+
|
|
430
|
+
parser = argparse.ArgumentParser(description="Fleet HTTP Proxy with LLM endpoint whitelist")
|
|
431
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
|
|
432
|
+
parser.add_argument("--port", type=int, default=8888, help="Port to listen on")
|
|
433
|
+
parser.add_argument("--log-file", type=Path, default=None, help="Log file path")
|
|
434
|
+
parser.add_argument("--no-whitelist", action="store_true",
|
|
435
|
+
help="Disable whitelist filtering (log ALL traffic)")
|
|
436
|
+
args = parser.parse_args()
|
|
437
|
+
|
|
438
|
+
logging.basicConfig(level=logging.INFO)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
asyncio.run(run_proxy_server(
|
|
442
|
+
host=args.host,
|
|
443
|
+
port=args.port,
|
|
444
|
+
log_file=args.log_file,
|
|
445
|
+
use_whitelist=not args.no_whitelist,
|
|
446
|
+
))
|
|
447
|
+
except KeyboardInterrupt:
|
|
448
|
+
print("\nProxy stopped")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == "__main__":
|
|
452
|
+
main()
|
|
453
|
+
|
fleet/proxy/whitelist.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Endpoint whitelist management for proxy capture.
|
|
3
|
+
|
|
4
|
+
Two-tier whitelist:
|
|
5
|
+
1. Static: Known public LLM endpoints (in code)
|
|
6
|
+
2. Runtime: Dynamically detected from SDK client initialization
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Set
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
# =============================================================================
|
|
17
|
+
# Static Whitelist - Known public LLM endpoints
|
|
18
|
+
# =============================================================================
|
|
19
|
+
|
|
20
|
+
STATIC_WHITELIST: Set[str] = {
|
|
21
|
+
# Google / Gemini
|
|
22
|
+
"generativelanguage.googleapis.com",
|
|
23
|
+
"aiplatform.googleapis.com",
|
|
24
|
+
|
|
25
|
+
# OpenAI
|
|
26
|
+
"api.openai.com",
|
|
27
|
+
|
|
28
|
+
# Anthropic
|
|
29
|
+
"api.anthropic.com",
|
|
30
|
+
|
|
31
|
+
# Azure OpenAI
|
|
32
|
+
"openai.azure.com",
|
|
33
|
+
|
|
34
|
+
# Cohere
|
|
35
|
+
"api.cohere.ai",
|
|
36
|
+
|
|
37
|
+
# Together AI
|
|
38
|
+
"api.together.xyz",
|
|
39
|
+
|
|
40
|
+
# Groq
|
|
41
|
+
"api.groq.com",
|
|
42
|
+
|
|
43
|
+
# Fireworks
|
|
44
|
+
"api.fireworks.ai",
|
|
45
|
+
|
|
46
|
+
# Replicate
|
|
47
|
+
"api.replicate.com",
|
|
48
|
+
|
|
49
|
+
# Mistral
|
|
50
|
+
"api.mistral.ai",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# Runtime Whitelist - Dynamically detected endpoints
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
_runtime_whitelist: Set[str] = set()
|
|
58
|
+
_whitelist_lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
# File for persisting runtime whitelist (shared with proxy subprocess)
|
|
61
|
+
_RUNTIME_WHITELIST_FILE = Path.home() / ".fleet" / "runtime_whitelist.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_host(url: str) -> str:
|
|
65
|
+
"""Extract host from URL."""
|
|
66
|
+
if not url:
|
|
67
|
+
return ""
|
|
68
|
+
# Handle URLs without scheme
|
|
69
|
+
if "://" not in url:
|
|
70
|
+
url = "https://" + url
|
|
71
|
+
parsed = urlparse(url)
|
|
72
|
+
return parsed.netloc or parsed.path.split("/")[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register_endpoint(endpoint: str) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Register an endpoint to the runtime whitelist.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
endpoint: URL or hostname of the endpoint
|
|
81
|
+
"""
|
|
82
|
+
host = _extract_host(endpoint)
|
|
83
|
+
if not host:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
with _whitelist_lock:
|
|
87
|
+
_runtime_whitelist.add(host)
|
|
88
|
+
_save_runtime_whitelist()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_runtime_whitelist() -> Set[str]:
|
|
92
|
+
"""Get the current runtime whitelist."""
|
|
93
|
+
with _whitelist_lock:
|
|
94
|
+
return _runtime_whitelist.copy()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_full_whitelist() -> Set[str]:
|
|
98
|
+
"""Get combined static + runtime whitelist."""
|
|
99
|
+
_load_runtime_whitelist() # Refresh from file
|
|
100
|
+
with _whitelist_lock:
|
|
101
|
+
return STATIC_WHITELIST | _runtime_whitelist
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _save_runtime_whitelist() -> None:
|
|
105
|
+
"""Persist runtime whitelist to file."""
|
|
106
|
+
try:
|
|
107
|
+
_RUNTIME_WHITELIST_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with open(_RUNTIME_WHITELIST_FILE, "w") as f:
|
|
109
|
+
json.dump(list(_runtime_whitelist), f)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # Best effort
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_runtime_whitelist() -> None:
|
|
115
|
+
"""Load runtime whitelist from file."""
|
|
116
|
+
global _runtime_whitelist
|
|
117
|
+
try:
|
|
118
|
+
if _RUNTIME_WHITELIST_FILE.exists():
|
|
119
|
+
with open(_RUNTIME_WHITELIST_FILE, "r") as f:
|
|
120
|
+
data = json.load(f)
|
|
121
|
+
with _whitelist_lock:
|
|
122
|
+
_runtime_whitelist = set(data)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass # Best effort
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clear_runtime_whitelist() -> None:
|
|
128
|
+
"""Clear the runtime whitelist."""
|
|
129
|
+
global _runtime_whitelist
|
|
130
|
+
with _whitelist_lock:
|
|
131
|
+
_runtime_whitelist.clear()
|
|
132
|
+
try:
|
|
133
|
+
if _RUNTIME_WHITELIST_FILE.exists():
|
|
134
|
+
_RUNTIME_WHITELIST_FILE.unlink()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_whitelisted(host: str) -> bool:
|
|
140
|
+
"""Check if a host is in the whitelist."""
|
|
141
|
+
host = host.lower()
|
|
142
|
+
whitelist = get_full_whitelist()
|
|
143
|
+
|
|
144
|
+
# Exact match
|
|
145
|
+
if host in whitelist:
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
# Subdomain match (e.g., us-central1-aiplatform.googleapis.com)
|
|
149
|
+
for pattern in whitelist:
|
|
150
|
+
if host.endswith("." + pattern) or host.endswith(pattern):
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# SDK Hooks - Auto-detect endpoints from client initialization
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
_original_genai_client_init = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _hook_genai_client():
|
|
164
|
+
"""
|
|
165
|
+
Monkey-patch google.genai.Client to capture endpoint configuration.
|
|
166
|
+
Call this early in your application.
|
|
167
|
+
"""
|
|
168
|
+
global _original_genai_client_init
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
from google import genai
|
|
172
|
+
from google.genai import Client
|
|
173
|
+
|
|
174
|
+
if _original_genai_client_init is not None:
|
|
175
|
+
return # Already hooked
|
|
176
|
+
|
|
177
|
+
_original_genai_client_init = Client.__init__
|
|
178
|
+
|
|
179
|
+
def _patched_init(self, *args, **kwargs):
|
|
180
|
+
_original_genai_client_init(self, *args, **kwargs)
|
|
181
|
+
|
|
182
|
+
# Extract base_url from http_options
|
|
183
|
+
http_options = kwargs.get("http_options")
|
|
184
|
+
if http_options:
|
|
185
|
+
base_url = getattr(http_options, "base_url", None)
|
|
186
|
+
if base_url:
|
|
187
|
+
register_endpoint(base_url)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Default Google endpoint
|
|
191
|
+
register_endpoint("generativelanguage.googleapis.com")
|
|
192
|
+
|
|
193
|
+
Client.__init__ = _patched_init
|
|
194
|
+
|
|
195
|
+
except ImportError:
|
|
196
|
+
pass # google-genai not installed
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _hook_openai_client():
|
|
200
|
+
"""Monkey-patch openai.OpenAI to capture endpoint configuration."""
|
|
201
|
+
try:
|
|
202
|
+
import openai
|
|
203
|
+
|
|
204
|
+
original_init = openai.OpenAI.__init__
|
|
205
|
+
|
|
206
|
+
def _patched_init(self, *args, **kwargs):
|
|
207
|
+
original_init(self, *args, **kwargs)
|
|
208
|
+
base_url = kwargs.get("base_url") or os.getenv("OPENAI_BASE_URL", "api.openai.com")
|
|
209
|
+
register_endpoint(base_url)
|
|
210
|
+
|
|
211
|
+
openai.OpenAI.__init__ = _patched_init
|
|
212
|
+
|
|
213
|
+
except (ImportError, AttributeError):
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _hook_anthropic_client():
|
|
218
|
+
"""Monkey-patch anthropic.Anthropic to capture endpoint configuration."""
|
|
219
|
+
try:
|
|
220
|
+
import anthropic
|
|
221
|
+
|
|
222
|
+
original_init = anthropic.Anthropic.__init__
|
|
223
|
+
|
|
224
|
+
def _patched_init(self, *args, **kwargs):
|
|
225
|
+
original_init(self, *args, **kwargs)
|
|
226
|
+
base_url = kwargs.get("base_url") or os.getenv("ANTHROPIC_BASE_URL", "api.anthropic.com")
|
|
227
|
+
register_endpoint(base_url)
|
|
228
|
+
|
|
229
|
+
anthropic.Anthropic.__init__ = _patched_init
|
|
230
|
+
|
|
231
|
+
except (ImportError, AttributeError):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def install_hooks():
|
|
236
|
+
"""Install all SDK hooks. Call this early in your application."""
|
|
237
|
+
_hook_genai_client()
|
|
238
|
+
_hook_openai_client()
|
|
239
|
+
_hook_anthropic_client()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Auto-load runtime whitelist on import
|
|
243
|
+
_load_runtime_whitelist()
|
|
244
|
+
|