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/client.py ADDED
@@ -0,0 +1,427 @@
1
+ """FirstOps management client for programmatic agent and connection CRUD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+
11
+ @dataclass
12
+ class Agent:
13
+ """An agent principal."""
14
+
15
+ id: str
16
+ tenant_id: str
17
+ name: str
18
+ reference_id: str
19
+ metadata: dict[str, str]
20
+ created_at: int
21
+ token: str # fo_agent_<id> — used in Authorization header
22
+ private_key: str | None = None # PEM, only set on creation
23
+
24
+
25
+ @dataclass
26
+ class Connection:
27
+ """A registered MCP connection."""
28
+
29
+ id: str
30
+ tenant_id: str
31
+ principal_id: str
32
+ name: str
33
+ upstream_url: str
34
+ status: str
35
+ created_at: int
36
+
37
+
38
+ @dataclass
39
+ class ParamDefinition:
40
+ """A single parameter required to configure a template connection."""
41
+
42
+ key: str # placeholder key, e.g. "api_token"
43
+ display_name: str # human-readable label
44
+ description: str # help text
45
+ required: bool
46
+ secret: bool # if true, treat the value as sensitive
47
+ maps_to: str # "HEADER", "QUERY_PARAM", or "URL_PLACEHOLDER"
48
+
49
+
50
+ @dataclass
51
+ class ServerTemplate:
52
+ """A FirstOps catalog entry describing an MCP server integration."""
53
+
54
+ id: str
55
+ name: str
56
+ description: str
57
+ category: str
58
+ upstream_url_template: str
59
+ required_params: list[ParamDefinition]
60
+ tenant_configured: bool
61
+ user_connected: bool
62
+ existing_connection_id: str
63
+ auth_type: str
64
+ transport_type: str
65
+ oauth_setup_guide: str
66
+ requires_org_config: bool
67
+ raw: dict[str, Any] # full proto-JSON for fields the SDK doesn't model
68
+
69
+
70
+ def _parse_template(entry: dict[str, Any]) -> ServerTemplate:
71
+ """Parse a ServerTemplateWithStatus proto-JSON entry.
72
+
73
+ The catalog RPC returns ``ServerTemplateWithStatus``, a wrapper with
74
+ the actual ``ServerTemplate`` nested under ``template`` plus three
75
+ overlay flags (``tenant_configured``, ``user_connected``,
76
+ ``existing_connection_id``). We flatten that into a single dataclass.
77
+
78
+ When the backend returns a bare ``ServerTemplate`` (no wrapper), we
79
+ fall back to reading fields off the entry itself.
80
+ """
81
+ inner = (
82
+ entry.get("template")
83
+ if isinstance(entry.get("template"), dict)
84
+ else entry
85
+ )
86
+
87
+ params_raw = inner.get("required_params") or []
88
+ params: list[ParamDefinition] = []
89
+ for p in params_raw:
90
+ params.append(
91
+ ParamDefinition(
92
+ key=p.get("key", ""),
93
+ display_name=p.get("display_name", ""),
94
+ description=p.get("description", ""),
95
+ required=p.get("required", False),
96
+ secret=p.get("secret", False),
97
+ maps_to=p.get("maps_to", ""),
98
+ )
99
+ )
100
+
101
+ # oauth_setup_guide is a nested message — stringify to something usable.
102
+ oauth_guide = inner.get("oauth_setup_guide")
103
+ if isinstance(oauth_guide, dict):
104
+ oauth_guide_str = oauth_guide.get("instructions", "") or ""
105
+ elif isinstance(oauth_guide, str):
106
+ oauth_guide_str = oauth_guide
107
+ else:
108
+ oauth_guide_str = ""
109
+
110
+ return ServerTemplate(
111
+ id=inner.get("id", ""),
112
+ name=inner.get("name", ""),
113
+ description=inner.get("description", ""),
114
+ category=inner.get("category", ""),
115
+ upstream_url_template=inner.get("upstream_url", ""),
116
+ required_params=params,
117
+ tenant_configured=entry.get("tenant_configured", False),
118
+ user_connected=entry.get("user_connected", False),
119
+ existing_connection_id=entry.get("existing_connection_id", ""),
120
+ auth_type=inner.get("auth_method", "") or inner.get("auth_type", ""),
121
+ transport_type=inner.get("transport_type", ""),
122
+ oauth_setup_guide=oauth_guide_str,
123
+ requires_org_config=inner.get("requires_org_config", False),
124
+ raw=entry,
125
+ )
126
+
127
+
128
+ class FirstOpsError(Exception):
129
+ """Raised when the FirstOps API returns an error."""
130
+
131
+ def __init__(self, status_code: int, message: str):
132
+ self.status_code = status_code
133
+ self.message = message
134
+ super().__init__(f"{message} (HTTP {status_code})")
135
+
136
+
137
+ class _AgentsResource:
138
+ def __init__(self, client: FirstOps):
139
+ self._client = client
140
+
141
+ def create(self, name: str) -> Agent:
142
+ """Create a new agent principal with a DPoP keypair."""
143
+ resp = self._client._request("POST", "/api/v1/sdk/agents", json={"name": name})
144
+ agent_data = resp["agent"]
145
+ return Agent(
146
+ id=agent_data["id"],
147
+ tenant_id=agent_data.get("tenant_id", ""),
148
+ name=agent_data.get("name", "") or agent_data.get("metadata", {}).get("name", ""),
149
+ reference_id=agent_data.get("reference_id", ""),
150
+ metadata=agent_data.get("metadata", {}),
151
+ created_at=agent_data.get("created_at", 0),
152
+ token=f"fo_agent_{agent_data['id']}",
153
+ private_key=resp.get("private_key"),
154
+ )
155
+
156
+ def list(self) -> list[Agent]:
157
+ """List all agent principals in the tenant."""
158
+ resp = self._client._request("GET", "/api/v1/sdk/agents")
159
+ agents = []
160
+ for a in resp.get("agents", []):
161
+ agents.append(
162
+ Agent(
163
+ id=a["id"],
164
+ tenant_id=a.get("tenant_id", ""),
165
+ name=a.get("name", "") or a.get("metadata", {}).get("name", ""),
166
+ reference_id=a.get("reference_id", ""),
167
+ metadata=a.get("metadata", {}),
168
+ created_at=a.get("created_at", 0),
169
+ token=f"fo_agent_{a['id']}",
170
+ )
171
+ )
172
+ return agents
173
+
174
+ def get(self, agent_id: str) -> Agent:
175
+ """Get a single agent by ID."""
176
+ resp = self._client._request("GET", f"/api/v1/sdk/agents/{agent_id}")
177
+ a = resp["agent"]
178
+ return Agent(
179
+ id=a["id"],
180
+ tenant_id=a.get("tenant_id", ""),
181
+ name=a.get("name", "") or a.get("metadata", {}).get("name", ""),
182
+ reference_id=a.get("reference_id", ""),
183
+ metadata=a.get("metadata", {}),
184
+ created_at=a.get("created_at", 0),
185
+ token=f"fo_agent_{a['id']}",
186
+ )
187
+
188
+ def delete(self, agent_id: str) -> None:
189
+ """Delete an agent principal."""
190
+ self._client._request("DELETE", f"/api/v1/sdk/agents/{agent_id}")
191
+
192
+
193
+ class _ConnectionsResource:
194
+ def __init__(self, client: FirstOps):
195
+ self._client = client
196
+
197
+ def register(
198
+ self,
199
+ *,
200
+ principal_id: str,
201
+ # Template-based registration (preferred for catalog entries)
202
+ template_id: str = "",
203
+ user_params: dict[str, str] | None = None,
204
+ # Raw registration (when not using a template)
205
+ name: str = "",
206
+ upstream_url: str = "",
207
+ auth_type: str = "",
208
+ transport_type: str = "",
209
+ upstream_headers: dict[str, str] | None = None,
210
+ upstream_query_params: dict[str, str] | None = None,
211
+ source: str = "sdk",
212
+ ) -> Connection:
213
+ """Register an MCP connection for an agent.
214
+
215
+ Two modes:
216
+
217
+ 1. **Template-based** (preferred): pass `template_id` from the
218
+ FirstOps catalog plus `user_params` for the required placeholders.
219
+ The backend resolves the upstream URL, pre-fills auth_type and
220
+ transport_type from the template, and maps params to headers or
221
+ query params per the template's `ParamDefinition.maps_to` rules.
222
+
223
+ 2. **Raw**: pass `name` and `upstream_url` directly. Use this only
224
+ when registering a custom MCP server that isn't in the catalog.
225
+
226
+ Example (template):
227
+ conn = client.connections.register(
228
+ principal_id=agent.id,
229
+ template_id="github-pat",
230
+ user_params={"api_token": "ghp_..."},
231
+ )
232
+
233
+ Example (raw):
234
+ conn = client.connections.register(
235
+ principal_id=agent.id,
236
+ name="my-custom-mcp",
237
+ upstream_url="https://mcp.internal.corp/sse",
238
+ )
239
+ """
240
+ body: dict[str, Any] = {
241
+ "principal_id": principal_id,
242
+ "source": source,
243
+ }
244
+
245
+ if template_id:
246
+ body["template_id"] = template_id
247
+ if user_params:
248
+ body["user_params"] = user_params
249
+ # Template flow still requires a name for the connection record.
250
+ # If the caller doesn't supply one, derive from the template ID.
251
+ body["name"] = name or template_id
252
+ # upstream_url is resolved server-side from the template.
253
+ body["upstream_url"] = upstream_url # empty is fine here
254
+ else:
255
+ if not name or not upstream_url:
256
+ raise ValueError(
257
+ "register() requires either template_id or "
258
+ "both name and upstream_url"
259
+ )
260
+ body["name"] = name
261
+ body["upstream_url"] = upstream_url
262
+
263
+ if auth_type:
264
+ body["auth_type"] = auth_type
265
+ if transport_type:
266
+ body["transport_type"] = transport_type
267
+ if upstream_headers:
268
+ body["upstream_headers"] = upstream_headers
269
+ if upstream_query_params:
270
+ body["upstream_query_params"] = upstream_query_params
271
+
272
+ resp = self._client._request(
273
+ "POST", "/api/v1/sdk/connections/register", json=body
274
+ )
275
+ c = resp["connection"]
276
+ return Connection(
277
+ id=c["id"],
278
+ tenant_id=c.get("tenant_id", ""),
279
+ principal_id=c.get("principal_id", ""),
280
+ name=c.get("mcp_server_name", "") or c.get("name", ""),
281
+ upstream_url=c.get("upstream_url", ""),
282
+ status=c.get("status", ""),
283
+ created_at=c.get("created_at", 0),
284
+ )
285
+
286
+ def list(self, *, principal_id: str | None = None) -> list[Connection]:
287
+ """List connections, optionally filtered by principal."""
288
+ params: dict[str, str] = {}
289
+ if principal_id:
290
+ params["principal_id"] = principal_id
291
+
292
+ resp = self._client._request("GET", "/api/v1/sdk/connections", params=params)
293
+ connections = []
294
+ for c in resp.get("connections", []):
295
+ connections.append(
296
+ Connection(
297
+ id=c["id"],
298
+ tenant_id=c.get("tenant_id", ""),
299
+ principal_id=c.get("principal_id", ""),
300
+ name=c.get("mcp_server_name", "") or c.get("name", ""),
301
+ upstream_url=c.get("upstream_url", ""),
302
+ status=c.get("status", ""),
303
+ created_at=c.get("created_at", 0),
304
+ )
305
+ )
306
+ return connections
307
+
308
+ def delete(self, connection_id: str) -> None:
309
+ """Delete a connection."""
310
+ self._client._request("DELETE", f"/api/v1/sdk/connections/{connection_id}")
311
+
312
+
313
+ class _CatalogResource:
314
+ def __init__(self, client: FirstOps):
315
+ self._client = client
316
+
317
+ def list(
318
+ self,
319
+ *,
320
+ principal_id: str | None = None,
321
+ search: str | None = None,
322
+ category: str | None = None,
323
+ ) -> list[ServerTemplate]:
324
+ """List MCP server templates in the FirstOps catalog.
325
+
326
+ When `principal_id` is supplied, the response includes
327
+ `user_connected` and `existing_connection_id` overlays scoped to
328
+ that agent — useful when rendering "Connect" vs "Already connected"
329
+ states in a customer's UI.
330
+ """
331
+ params: dict[str, str] = {}
332
+ if principal_id:
333
+ params["principal_id"] = principal_id
334
+ if search:
335
+ params["search"] = search
336
+ if category:
337
+ params["category"] = category
338
+
339
+ resp = self._client._request(
340
+ "GET", "/api/v1/catalog/templates", params=params
341
+ )
342
+ return [_parse_template(t) for t in resp.get("templates", [])]
343
+
344
+ def get(
345
+ self, template_id: str, *, principal_id: str | None = None
346
+ ) -> ServerTemplate:
347
+ """Get a single catalog template by ID."""
348
+ params: dict[str, str] = {}
349
+ if principal_id:
350
+ params["principal_id"] = principal_id
351
+
352
+ resp = self._client._request(
353
+ "GET", f"/api/v1/catalog/templates/{template_id}", params=params
354
+ )
355
+ return _parse_template(resp["template"])
356
+
357
+
358
+ class FirstOps:
359
+ """Management client for the FirstOps API.
360
+
361
+ Usage:
362
+ client = FirstOps(api_key="fo_key_...")
363
+ agent = client.agents.create(name="my-bot")
364
+ client.connections.register(
365
+ principal_id=agent.id,
366
+ name="slack",
367
+ upstream_url="https://mcp.slack.com/sse",
368
+ )
369
+ """
370
+
371
+ def __init__(
372
+ self,
373
+ api_key: str,
374
+ base_url: str = "https://api.firstops.dev",
375
+ timeout: float = 30.0,
376
+ ):
377
+ if not api_key or not api_key.startswith("fo_key_"):
378
+ raise ValueError("api_key must start with 'fo_key_'")
379
+
380
+ self._base_url = base_url.rstrip("/")
381
+ self._api_key = api_key
382
+ self._http = httpx.Client(
383
+ timeout=timeout,
384
+ headers={
385
+ "Authorization": f"Bearer {api_key}",
386
+ "Content-Type": "application/json",
387
+ },
388
+ )
389
+ self.agents = _AgentsResource(self)
390
+ self.connections = _ConnectionsResource(self)
391
+ self.catalog = _CatalogResource(self)
392
+
393
+ def _request(
394
+ self,
395
+ method: str,
396
+ path: str,
397
+ *,
398
+ json: dict[str, Any] | None = None,
399
+ params: dict[str, str] | None = None,
400
+ ) -> dict[str, Any]:
401
+ resp = self._http.request(
402
+ method,
403
+ f"{self._base_url}{path}",
404
+ json=json,
405
+ params=params,
406
+ )
407
+ if resp.status_code >= 400:
408
+ try:
409
+ body = resp.json()
410
+ msg = body.get("error", f"HTTP {resp.status_code}")
411
+ except Exception:
412
+ msg = f"HTTP {resp.status_code}"
413
+ raise FirstOpsError(resp.status_code, msg)
414
+
415
+ if resp.status_code == 204 or not resp.content:
416
+ return {}
417
+ return resp.json()
418
+
419
+ def close(self) -> None:
420
+ """Close the underlying HTTP client."""
421
+ self._http.close()
422
+
423
+ def __enter__(self) -> FirstOps:
424
+ return self
425
+
426
+ def __exit__(self, *args: Any) -> None:
427
+ self.close()
firstops/coverage.py ADDED
@@ -0,0 +1,65 @@
1
+ """Coverage honesty — surface what is and isn't governed.
2
+
3
+ For a security product, a *silent* coverage gap is the worst failure. This
4
+ module makes the gaps loud: reconcile the tools an agent declares against the
5
+ tools FirstOps actually governs, and expose the per-adapter capability matrix
6
+ so no surface claims more than it delivers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from firstops.tools import governed_tool_names
14
+
15
+ # Per-surface capability: what each integration can actually enforce.
16
+ # block = can stop the call; scrub = can rewrite args/prompt before it runs;
17
+ # audit = emits the action to the audit trail.
18
+ CAPABILITY_MATRIX: dict[str, dict[str, bool]] = {
19
+ "base_decorator": {"block": True, "scrub": True, "audit": True},
20
+ "claude": {"block": True, "scrub": True, "audit": True},
21
+ "langgraph": {"block": True, "scrub": True, "audit": True},
22
+ # OpenAI Agents tool guardrails are read-only: block + audit, but no
23
+ # argument scrub (use the base decorator to scrub on OpenAI Agents).
24
+ "openai_agents": {"block": True, "scrub": False, "audit": True},
25
+ "llm_chain_link": {"block": True, "scrub": True, "audit": True},
26
+ "mcp_proxy": {"block": True, "scrub": True, "audit": True},
27
+ }
28
+
29
+
30
+ def capability(surface: str) -> dict[str, bool]:
31
+ """Return the capability dict for a surface, or {} if unknown."""
32
+ return dict(CAPABILITY_MATRIX.get(surface, {}))
33
+
34
+
35
+ def ungoverned_tools(declared: Iterable[str]) -> list[str]:
36
+ """Return declared tool names NOT governed by ``@firstops.tool`` (a gap).
37
+
38
+ Honesty caveats (read before trusting the result):
39
+ - This reconciles ONLY tools governed via the base decorator. A tool
40
+ governed by a harness adapter (at the framework's execution boundary)
41
+ will appear here as "ungoverned" even though it IS governed — adapter
42
+ coverage is per-registration, not per-name.
43
+ - The governed set is **process-wide**, not per-agent. With one agent per
44
+ process (the common case) that equals per-agent; with multiple agents in
45
+ one process it's the union, which can under-report a gap.
46
+
47
+ Non-string entries are coerced to ``str`` so a malformed registry can't
48
+ crash the check.
49
+ """
50
+ return sorted({str(d) for d in declared} - governed_tool_names())
51
+
52
+
53
+ def coverage_report(declared: Iterable[str]) -> dict[str, list[str]]:
54
+ """Return a ``{governed, ungoverned}`` split of the declared tool names.
55
+
56
+ Same caveats as :func:`ungoverned_tools` — ``ungoverned`` here means
57
+ "not decorator-governed" and may include adapter-governed tools; the
58
+ governed set is process-wide, not per-agent.
59
+ """
60
+ declared_set = {str(d) for d in declared}
61
+ governed = governed_tool_names()
62
+ return {
63
+ "governed": sorted(declared_set & governed),
64
+ "ungoverned": sorted(declared_set - governed),
65
+ }
firstops/dpop.py ADDED
@@ -0,0 +1,78 @@
1
+ """DPoP proof generation (RFC 9449) using ES256 (P-256)."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import time
8
+
9
+ from cryptography.hazmat.primitives.asymmetric import ec, utils
10
+ from cryptography.hazmat.primitives.hashes import SHA256
11
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
12
+
13
+
14
+ def load_private_key(pem_data: str) -> ec.EllipticCurvePrivateKey:
15
+ """Load an EC P-256 private key from PEM string."""
16
+ key = load_pem_private_key(pem_data.encode(), password=None)
17
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
18
+ raise ValueError("expected EC private key")
19
+ if not isinstance(key.curve, ec.SECP256R1):
20
+ raise ValueError("expected P-256 curve")
21
+ return key
22
+
23
+
24
+ def public_key_jwk(key: ec.EllipticCurvePrivateKey) -> dict[str, str]:
25
+ """Return the JWK representation of the public key."""
26
+ pub = key.public_key()
27
+ numbers = pub.public_numbers()
28
+ return {
29
+ "kty": "EC",
30
+ "crv": "P-256",
31
+ "x": _b64url_int(numbers.x, 32),
32
+ "y": _b64url_int(numbers.y, 32),
33
+ }
34
+
35
+
36
+ def jwk_thumbprint(key: ec.EllipticCurvePrivateKey) -> str:
37
+ """Compute RFC 7638 JWK thumbprint (base64url SHA-256)."""
38
+ jwk = public_key_jwk(key)
39
+ # RFC 7638: members in lexicographic order for EC: crv, kty, x, y
40
+ canonical = f'{{"crv":"{jwk["crv"]}","kty":"{jwk["kty"]}","x":"{jwk["x"]}","y":"{jwk["y"]}"}}'
41
+ digest = hashlib.sha256(canonical.encode()).digest()
42
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
43
+
44
+
45
+ def create_proof(key: ec.EllipticCurvePrivateKey, method: str, url: str) -> str:
46
+ """Create a DPoP proof JWT for the given HTTP method and URL."""
47
+ jwk = public_key_jwk(key)
48
+
49
+ header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk}
50
+ claims = {
51
+ "jti": base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode(),
52
+ "htm": method,
53
+ "htu": url,
54
+ "iat": int(time.time()),
55
+ }
56
+
57
+ header_b64 = _b64url_json(header)
58
+ claims_b64 = _b64url_json(claims)
59
+ signed_content = f"{header_b64}.{claims_b64}"
60
+
61
+ # Sign with ES256 — cryptography hashes internally
62
+ der_sig = key.sign(signed_content.encode(), ec.ECDSA(SHA256()))
63
+ # Convert DER to IEEE P1363 (fixed 64 bytes)
64
+ r, s = utils.decode_dss_signature(der_sig)
65
+ sig_bytes = r.to_bytes(32, "big") + s.to_bytes(32, "big")
66
+ sig_b64 = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode()
67
+
68
+ return f"{signed_content}.{sig_b64}"
69
+
70
+
71
+ def _b64url_json(obj: dict) -> str:
72
+ data = json.dumps(obj, separators=(",", ":")).encode()
73
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
74
+
75
+
76
+ def _b64url_int(n: int, size: int) -> str:
77
+ b = n.to_bytes(size, "big")
78
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
@@ -0,0 +1,73 @@
1
+ """The enforcement spine — an in-process client of sentinel's EvaluateHook.
2
+
3
+ Every governed action (tool call, LLM call) is forwarded here; sentinel
4
+ decides. The SDK performs **no local policy evaluation**.
5
+
6
+ Failure semantics (invariant): enforcement **fails open** — on any transport
7
+ error, timeout, or non-200, the action is allowed and the failure is audited
8
+ locally. Authentication (DPoP) is the only fail-closed surface, and even an
9
+ auth failure never blocks the agent: it surfaces as a fail-open allow here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import httpx
17
+
18
+ from firstops._identity import Identity
19
+ from firstops.events import ActionEvent, Decision
20
+
21
+ logger = logging.getLogger("firstops")
22
+
23
+ _HOOK_PATH = "/api/v1/daemon/evaluate-hook"
24
+ _DEFAULT_TIMEOUT = 5.0
25
+
26
+
27
+ class EnforcementClient:
28
+ """Forwards action events to sentinel and returns the Decision."""
29
+
30
+ def __init__(
31
+ self,
32
+ identity: Identity,
33
+ *,
34
+ timeout: float = _DEFAULT_TIMEOUT,
35
+ http: httpx.Client | None = None,
36
+ ):
37
+ self._identity = identity
38
+ self._url = identity.gateway_url + _HOOK_PATH
39
+ self._http = http or httpx.Client(timeout=timeout)
40
+
41
+ def evaluate(self, event: ActionEvent) -> Decision:
42
+ """Evaluate one action. **Never raises** — fails open on any error.
43
+
44
+ Enforcement is fail-open by invariant: a transport error, timeout,
45
+ non-200, malformed body, or any unexpected exception must allow the
46
+ action (and audit the failure), never block the agent. The only
47
+ fail-closed surface is DPoP auth, and even an auth rejection surfaces
48
+ here as a fail-open allow rather than a raised exception.
49
+ """
50
+ try:
51
+ # htu binds to the path only (no query); _url has no query string.
52
+ proof = self._identity.proof("POST", self._url)
53
+ resp = self._http.post(
54
+ self._url,
55
+ json=event.to_wire(),
56
+ headers={
57
+ "Authorization": f"Bearer {self._identity.bearer_token}",
58
+ "DPoP": proof,
59
+ "Content-Type": "application/json",
60
+ },
61
+ )
62
+ if resp.status_code != 200:
63
+ logger.warning(
64
+ "firstops enforcement: status %d, failing open", resp.status_code
65
+ )
66
+ return Decision.fail_open(f"status {resp.status_code}")
67
+ return Decision.from_wire(resp.json())
68
+ except Exception as e: # noqa: BLE001 - fail-open is the whole point
69
+ logger.warning("firstops enforcement: failing open: %s", e)
70
+ return Decision.fail_open(f"error: {e}")
71
+
72
+ def close(self) -> None:
73
+ self._http.close()