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/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()
|
firstops/enforcement.py
ADDED
|
@@ -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()
|