firstops 0.2.0__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.
firstops/proxy.py ADDED
@@ -0,0 +1,408 @@
1
+ """Sidecar proxy — a dual-mode local forward proxy.
2
+
3
+ Two routes share one local listener:
4
+
5
+ ``/mcp/...`` terminal to the FirstOps gateway (DPoP-signed,
6
+ credential-brokered) — the original behavior.
7
+ ``/llm/<provider>/...`` inline chain-link: governs the request via
8
+ EvaluateHook(channel=llm), then forwards to the
9
+ customer-configured upstream (their gateway or the
10
+ provider). FirstOps is NOT in the LLM data path; the
11
+ agent's own Authorization is passed through verbatim.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import threading
17
+ from http.server import HTTPServer, BaseHTTPRequestHandler
18
+ from socketserver import ThreadingMixIn
19
+ from urllib.parse import urlparse
20
+
21
+ import httpx
22
+
23
+ from firstops._identity import Identity, build_identity
24
+ from firstops.events import CHANNEL_LLM, EVENT_PRE_TOOL_USE, ActionEvent
25
+
26
+ logger = logging.getLogger("firstops")
27
+
28
+ # Headers we must not forward verbatim to an LLM upstream: the RFC 7230
29
+ # hop-by-hop set plus the few httpx sets itself. The agent's Authorization
30
+ # (its model key) is intentionally NOT here — it passes through verbatim.
31
+ _LLM_STRIP_HEADERS = frozenset(
32
+ {
33
+ "host",
34
+ "content-length",
35
+ "accept-encoding",
36
+ "connection",
37
+ "keep-alive",
38
+ "transfer-encoding",
39
+ "te",
40
+ "trailer",
41
+ "upgrade",
42
+ "proxy-authorization",
43
+ "proxy-authenticate",
44
+ }
45
+ )
46
+
47
+ # Hard cap on a forwarded request body (defensive against a huge Content-Length).
48
+ _MAX_BODY_BYTES = 100 * 1024 * 1024
49
+
50
+ _DEFAULT_PORT = 9322
51
+ _DEFAULT_GATEWAY = "https://api.firstops.dev"
52
+
53
+ # Module-global proxy state. Guarded by _lock for thread safety.
54
+ _lock = threading.Lock()
55
+ _server: HTTPServer | None = None
56
+ _server_thread: threading.Thread | None = None
57
+ _current_agent_id: str | None = None
58
+ _current_port: int | None = None
59
+ _current_gateway: str | None = None
60
+ _current_jkt: str | None = None
61
+
62
+
63
+ def init(
64
+ agent_id: str,
65
+ private_key_pem: str,
66
+ port: int = _DEFAULT_PORT,
67
+ gateway_url: str = _DEFAULT_GATEWAY,
68
+ enforcement=None,
69
+ llm_upstreams: dict[str, str] | None = None,
70
+ ) -> None:
71
+ """Start the sidecar proxy on localhost.
72
+
73
+ This is **idempotent** for the same agent: calling init() twice with the
74
+ same ``agent_id``, ``port``, and ``gateway_url`` is a no-op on the second
75
+ call. This lets library code call init() defensively without worrying
76
+ about coordinating with other callers in the same process.
77
+
78
+ Calling init() with a **different** agent, port, or gateway while the
79
+ proxy is already running raises ``RuntimeError`` — the sidecar is tied to
80
+ one agent's private key and cannot serve multiple identities from a single
81
+ instance. Call ``shutdown()`` first if you need to switch agents.
82
+
83
+ Args:
84
+ agent_id: The agent principal ID (without ``fo_agent_`` prefix).
85
+ private_key_pem: EC P-256 private key in PEM format.
86
+ port: Local port for the proxy (default 9322).
87
+ gateway_url: FirstOps gateway base URL.
88
+
89
+ Raises:
90
+ RuntimeError: If a proxy is already running for a different agent,
91
+ port, or gateway.
92
+ ValueError: If ``gateway_url`` is malformed.
93
+ """
94
+ global _server, _server_thread, _current_agent_id, _current_port, _current_gateway
95
+ global _current_jkt
96
+
97
+ gateway = gateway_url.rstrip("/")
98
+
99
+ # Build identity up front (validates gateway URL + key) so bad input fails
100
+ # fast and so we can compare the key thumbprint on the idempotent path.
101
+ identity = build_identity(agent_id, private_key_pem, gateway)
102
+ jkt = identity.jkt
103
+
104
+ with _lock:
105
+ if _server is not None:
106
+ same_target = (
107
+ _current_agent_id == agent_id
108
+ and _current_port == port
109
+ and _current_gateway == gateway
110
+ )
111
+ if same_target and _current_jkt == jkt:
112
+ logger.debug(
113
+ "firstops proxy already running for agent %s on port %d — init() is a no-op",
114
+ agent_id,
115
+ port,
116
+ )
117
+ return
118
+ if same_target:
119
+ # Same agent/port/gateway but a DIFFERENT key — refuse to
120
+ # silently keep signing with the stale key (that would 401 and
121
+ # fail open invisibly). Force an explicit re-key.
122
+ raise RuntimeError(
123
+ f"firstops proxy is already running for agent {agent_id!r} "
124
+ f"with a different key; call shutdown() before re-keying."
125
+ )
126
+
127
+ # Mismatch — refuse to silently replace the running sidecar.
128
+ raise RuntimeError(
129
+ f"firstops proxy is already running for agent "
130
+ f"{_current_agent_id!r} on port {_current_port} "
131
+ f"(gateway={_current_gateway!r}); cannot start a second "
132
+ f"instance for agent {agent_id!r}. Call shutdown() first "
133
+ f"if you want to switch identities."
134
+ )
135
+
136
+ handler_class = _make_handler(identity, port, enforcement, llm_upstreams)
137
+
138
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
139
+ daemon_threads = True
140
+
141
+ _server = ThreadingHTTPServer(("127.0.0.1", port), handler_class)
142
+ _server_thread = threading.Thread(
143
+ target=_server.serve_forever, daemon=True
144
+ )
145
+ _server_thread.start()
146
+ _current_agent_id = agent_id
147
+ _current_port = port
148
+ _current_gateway = gateway
149
+ _current_jkt = jkt
150
+ logger.info(
151
+ "firstops proxy listening on 127.0.0.1:%d for agent %s",
152
+ port,
153
+ agent_id,
154
+ )
155
+
156
+
157
+ def shutdown() -> None:
158
+ """Stop the sidecar proxy. Idempotent — no-op if not running."""
159
+ global _server, _server_thread, _current_agent_id, _current_port, _current_gateway
160
+ global _current_jkt
161
+ with _lock:
162
+ if _server is not None:
163
+ _server.shutdown()
164
+ _server.server_close() # release the listening socket, not just the loop
165
+ _server = None
166
+ _server_thread = None
167
+ _current_agent_id = None
168
+ _current_port = None
169
+ _current_gateway = None
170
+ _current_jkt = None
171
+ logger.info("firstops proxy stopped")
172
+
173
+
174
+ def is_running() -> bool:
175
+ """Return True if the sidecar proxy is currently running in this process."""
176
+ with _lock:
177
+ return _server is not None
178
+
179
+
180
+ def current_agent_id() -> str | None:
181
+ """Return the agent ID the running proxy is serving, or None if not running."""
182
+ with _lock:
183
+ return _current_agent_id
184
+
185
+
186
+ def _denial_body(provider: str, decision) -> bytes:
187
+ """Build a provider-shaped error envelope for a denied LLM request."""
188
+ msg = f"blocked by FirstOps policy: {decision.reason}"
189
+ if provider == "anthropic":
190
+ env = {
191
+ "type": "error",
192
+ "error": {"type": "firstops_policy_violation", "message": msg},
193
+ }
194
+ else: # openai-shaped default
195
+ env = {
196
+ "error": {
197
+ "message": msg,
198
+ "type": "firstops_policy_violation",
199
+ "policy_id": decision.policy_id,
200
+ }
201
+ }
202
+ return json.dumps(env).encode()
203
+
204
+
205
+ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstreams):
206
+ """Create a request handler class bound to the given identity + config."""
207
+
208
+ gateway = identity.gateway_url
209
+ # Pre-create a client for non-streaming requests
210
+ client = httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0))
211
+
212
+ class ProxyHandler(BaseHTTPRequestHandler):
213
+ def do_POST(self):
214
+ self._dispatch("POST")
215
+
216
+ def do_GET(self):
217
+ self._dispatch("GET")
218
+
219
+ def do_DELETE(self):
220
+ self._dispatch("DELETE")
221
+
222
+ def log_message(self, fmt, *args):
223
+ logger.debug(fmt, *args)
224
+
225
+ def _dispatch(self, method: str):
226
+ # Route by path prefix: LLM chain-link vs MCP terminal.
227
+ if self.path.startswith("/llm/"):
228
+ self._handle_llm(method)
229
+ else:
230
+ self._proxy_mcp(method)
231
+
232
+ # ---- LLM chain-link route -------------------------------------
233
+ def _handle_llm(self, method: str):
234
+ if enforcement is None or llm_upstreams is None:
235
+ self.send_error(503, "LLM governance not configured")
236
+ return
237
+
238
+ # /llm/<provider>/<rest...>
239
+ rest = self.path[len("/llm/"):]
240
+ provider, _, tail = rest.partition("/")
241
+ upstream_base = llm_upstreams.get(provider)
242
+ if not upstream_base:
243
+ self.send_error(502, f"unknown LLM provider: {provider!r}")
244
+ return
245
+
246
+ path_only, sep, query = tail.partition("?")
247
+ # Reject path traversal: the path we govern MUST equal the path we
248
+ # forward. Letting httpx normalize `..` would desync the two.
249
+ if ".." in path_only.split("/"):
250
+ self.send_error(400, "invalid path")
251
+ return
252
+
253
+ body = self._read_request_body()
254
+ if body is None:
255
+ self.send_error(400, "invalid or oversized request body")
256
+ return
257
+
258
+ target = upstream_base.rstrip("/") + "/" + path_only
259
+ if sep:
260
+ target += "?" + query
261
+
262
+ # Pre-request governance (channel=llm). Returns (forward_body, denial).
263
+ body, denial = self._govern_llm(provider, path_only, body)
264
+ if denial is not None:
265
+ self.send_response(403)
266
+ self.send_header("Content-Type", "application/json")
267
+ self.end_headers()
268
+ self.wfile.write(denial)
269
+ return
270
+
271
+ # Forward everything, strip the few headers we must replace. The
272
+ # agent's own Authorization (its model key) passes through verbatim;
273
+ # we never DPoP-sign an upstream that isn't the FirstOps gateway.
274
+ fwd_headers = {
275
+ k: v
276
+ for k, v in self.headers.items()
277
+ if k.lower() not in _LLM_STRIP_HEADERS
278
+ }
279
+ self._forward(method, target, fwd_headers, body or None)
280
+
281
+ def _read_request_body(self) -> bytes | None:
282
+ """Read the request body safely. Returns None on malformed/oversized
283
+ framing (caller replies 400). No chunked-request support — a missing
284
+ Content-Length is treated as an empty body."""
285
+ raw = self.headers.get("Content-Length")
286
+ if raw is None:
287
+ return b""
288
+ try:
289
+ n = int(raw)
290
+ except ValueError:
291
+ return None
292
+ if n < 0 or n > _MAX_BODY_BYTES:
293
+ return None
294
+ return self.rfile.read(n) if n > 0 else b""
295
+
296
+ def _govern_llm(self, provider: str, path_only: str, body: bytes):
297
+ """Evaluate an LLM request. Returns (body_to_forward, denial_or_None)."""
298
+ if not body:
299
+ return body, None
300
+ try:
301
+ parsed = json.loads(body)
302
+ except (ValueError, TypeError):
303
+ return body, None # non-JSON — can't govern, forward as-is
304
+ if not isinstance(parsed, dict):
305
+ return body, None
306
+
307
+ tool_name = f"{provider}." + path_only.strip("/").replace("/", ".")
308
+ event = ActionEvent(
309
+ event_type=EVENT_PRE_TOOL_USE,
310
+ tool_name=tool_name,
311
+ channel=CHANNEL_LLM,
312
+ tool_input=parsed,
313
+ # The sidecar rewrites the request body before forwarding, so a
314
+ # prompt scrub can ship as modify rather than escalate to deny.
315
+ producer_can_apply_modify=True,
316
+ )
317
+ decision = enforcement.evaluate(event)
318
+ if decision.blocked:
319
+ return body, _denial_body(provider, decision)
320
+ if decision.modified and decision.modified_payload:
321
+ return decision.modified_payload, None
322
+ return body, None
323
+
324
+ # ---- MCP terminal route (original behavior) -------------------
325
+ def _proxy_mcp(self, method: str):
326
+ path = self.path # e.g. /mcp/proxy/<connID> or /mcp/sse/message?...
327
+ gateway_url = gateway + path
328
+
329
+ # Read request body
330
+ content_length = int(self.headers.get("Content-Length", 0))
331
+ body = self.rfile.read(content_length) if content_length > 0 else None
332
+
333
+ # Create DPoP proof; identity.proof canonicalizes htu (strips query).
334
+ proof = identity.proof(method, gateway_url)
335
+
336
+ # Build upstream headers
337
+ upstream_headers = {
338
+ "Authorization": f"Bearer {identity.bearer_token}",
339
+ "DPoP": proof,
340
+ }
341
+ # Forward relevant headers
342
+ for h in ("Content-Type", "Accept", "Mcp-Session-Id"):
343
+ v = self.headers.get(h)
344
+ if v:
345
+ upstream_headers[h] = v
346
+
347
+ # Check if this is an SSE request
348
+ is_sse = (
349
+ method == "GET"
350
+ and self.headers.get("Accept", "").startswith("text/event-stream")
351
+ )
352
+
353
+ if is_sse:
354
+ self._stream_sse(gateway_url, upstream_headers)
355
+ else:
356
+ self._forward(method, gateway_url, upstream_headers, body)
357
+
358
+ def _forward(self, method: str, url: str, headers: dict, body: bytes | None):
359
+ """Forward a request, streaming if the response is SSE."""
360
+ try:
361
+ # Use a streaming request so we can detect SSE responses
362
+ # before reading the full body.
363
+ with httpx.stream(
364
+ method, url, headers=headers, content=body,
365
+ timeout=httpx.Timeout(120.0, connect=10.0),
366
+ ) as resp:
367
+ self.send_response(resp.status_code)
368
+ for k, v in resp.headers.items():
369
+ if k.lower() not in ("transfer-encoding", "connection"):
370
+ self.send_header(k, v)
371
+ self.end_headers()
372
+
373
+ # Forward the body RAW: httpx auto-decompresses iter_bytes()/
374
+ # read(), but we keep the upstream Content-Encoding/Length
375
+ # headers, so we must pass the original (possibly gzipped)
376
+ # bytes through untouched or the client's decode fails.
377
+ # Flush per chunk so SSE/streaming responses arrive live.
378
+ for chunk in resp.iter_raw():
379
+ self.wfile.write(chunk)
380
+ self.wfile.flush()
381
+ except httpx.HTTPError as e:
382
+ logger.error("upstream request failed: %s", e)
383
+ self.send_error(502, "upstream request failed")
384
+
385
+ def _stream_sse(self, url: str, headers: dict):
386
+ """Stream an SSE response, rewriting gateway URLs to localhost."""
387
+ try:
388
+ with httpx.stream("GET", url, headers=headers, timeout=None) as resp:
389
+ self.send_response(resp.status_code)
390
+ for k, v in resp.headers.items():
391
+ if k.lower() not in ("transfer-encoding", "connection"):
392
+ self.send_header(k, v)
393
+ self.end_headers()
394
+
395
+ gateway_msg_url = gateway + "/mcp/sse/message"
396
+ local_msg_url = f"http://127.0.0.1:{local_port}/mcp/sse/message"
397
+
398
+ for chunk in resp.iter_bytes():
399
+ # Rewrite gateway message URL to localhost
400
+ text = chunk.decode("utf-8", errors="replace")
401
+ text = text.replace(gateway_msg_url, local_msg_url)
402
+ self.wfile.write(text.encode())
403
+ self.wfile.flush()
404
+ except httpx.HTTPError as e:
405
+ logger.error("upstream SSE request failed: %s", e)
406
+ self.send_error(502, "upstream SSE request failed")
407
+
408
+ return ProxyHandler