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/__init__.py +58 -0
- firstops/_identity.py +59 -0
- firstops/_runtime.py +150 -0
- firstops/channels.py +38 -0
- firstops/client.py +427 -0
- firstops/coverage.py +65 -0
- firstops/dpop.py +78 -0
- firstops/enforcement.py +73 -0
- firstops/events.py +195 -0
- firstops/integrations/__init__.py +12 -0
- firstops/integrations/_common.py +132 -0
- firstops/integrations/claude.py +84 -0
- firstops/integrations/langgraph.py +87 -0
- firstops/integrations/openai_agents.py +87 -0
- firstops/llm.py +51 -0
- firstops/proxy.py +408 -0
- firstops/tools.py +318 -0
- firstops-0.2.0.dist-info/METADATA +160 -0
- firstops-0.2.0.dist-info/RECORD +21 -0
- firstops-0.2.0.dist-info/WHEEL +4 -0
- firstops-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|