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/tools.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The base-API tool decorator — govern any callable.
|
|
2
|
+
|
|
3
|
+
``@firstops.tool`` wraps a function so each call is forwarded to sentinel as a
|
|
4
|
+
``pre_tool_use`` event (block / modify args) and a ``post_tool_use`` event
|
|
5
|
+
(audit). It is the harness-agnostic floor: it audits everywhere and blocks
|
|
6
|
+
where the surrounding harness propagates a raised exception (per the design's
|
|
7
|
+
Discovery A, raising is the block mechanism for the base layer).
|
|
8
|
+
|
|
9
|
+
Coverage is honest: a decorated tool is governed; an un-decorated one is not.
|
|
10
|
+
For framework built-ins the developer never authored, use the harness adapter
|
|
11
|
+
instead (it hooks the execution boundary).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import inspect
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from firstops import _runtime
|
|
23
|
+
from firstops.channels import classify, mcp_info
|
|
24
|
+
from firstops.events import (
|
|
25
|
+
CHANNEL_MCP,
|
|
26
|
+
EVENT_POST_TOOL_USE,
|
|
27
|
+
EVENT_PRE_TOOL_USE,
|
|
28
|
+
ActionEvent,
|
|
29
|
+
Decision,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("firstops")
|
|
33
|
+
|
|
34
|
+
# Attributes that mark an already-built framework tool object (LangChain
|
|
35
|
+
# StructuredTool, LlamaIndex FunctionTool, etc.). We refuse to wrap these —
|
|
36
|
+
# the user must decorate the underlying function before the framework wraps it.
|
|
37
|
+
_TOOL_OBJECT_MARKERS = ("invoke", "ainvoke", "args_schema", "_run")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Names of tools governed by @firstops.tool this process — used by
|
|
41
|
+
# firstops.coverage to reconcile declared-vs-governed and surface gaps.
|
|
42
|
+
_GOVERNED_TOOLS: set[str] = set()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def governed_tool_names() -> set[str]:
|
|
46
|
+
"""Return the set of tool names governed by @firstops.tool."""
|
|
47
|
+
return set(_GOVERNED_TOOLS)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FirstOpsPolicyError(Exception):
|
|
51
|
+
"""Raised when sentinel denies a governed tool call."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, tool_name: str, reason: str, policy_id: str = ""):
|
|
54
|
+
self.tool_name = tool_name
|
|
55
|
+
self.reason = reason
|
|
56
|
+
self.policy_id = policy_id
|
|
57
|
+
super().__init__(f"FirstOps blocked tool {tool_name!r}: {reason}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tool(fn: Callable | None = None, *, name: str | None = None):
|
|
61
|
+
"""Decorator that governs a tool function. Usable as ``@tool`` or ``@tool(name=...)``."""
|
|
62
|
+
|
|
63
|
+
def decorator(func: Callable) -> Callable:
|
|
64
|
+
_check_wrappable(func)
|
|
65
|
+
tool_name = name or getattr(func, "__name__", "tool")
|
|
66
|
+
# Register BOTH the governance name and the function's __name__ so a
|
|
67
|
+
# coverage check that enumerates tools by either key sees it governed.
|
|
68
|
+
_GOVERNED_TOOLS.add(tool_name)
|
|
69
|
+
fn_name = getattr(func, "__name__", None)
|
|
70
|
+
if fn_name:
|
|
71
|
+
_GOVERNED_TOOLS.add(fn_name)
|
|
72
|
+
|
|
73
|
+
# Order matters: async-gen and generator are NOT coroutine functions,
|
|
74
|
+
# so they must be detected first or they'd fall into the sync wrapper
|
|
75
|
+
# and return an un-iterated (async)generator with the body unexecuted.
|
|
76
|
+
if inspect.isasyncgenfunction(func):
|
|
77
|
+
# Govern EAGERLY at call time (the wrapper is a plain function that
|
|
78
|
+
# returns the async generator), so a DENY blocks before any
|
|
79
|
+
# iteration rather than on the first __anext__.
|
|
80
|
+
@functools.wraps(func)
|
|
81
|
+
def agwrapper(*args: Any, **kwargs: Any):
|
|
82
|
+
args, kwargs = _govern(func, tool_name, args, kwargs)
|
|
83
|
+
return _agen_iterate(func, tool_name, args, kwargs)
|
|
84
|
+
|
|
85
|
+
return agwrapper
|
|
86
|
+
|
|
87
|
+
if inspect.isgeneratorfunction(func):
|
|
88
|
+
|
|
89
|
+
@functools.wraps(func)
|
|
90
|
+
def gwrapper(*args: Any, **kwargs: Any):
|
|
91
|
+
args, kwargs = _govern(func, tool_name, args, kwargs)
|
|
92
|
+
return _gen_iterate(func, tool_name, args, kwargs)
|
|
93
|
+
|
|
94
|
+
return gwrapper
|
|
95
|
+
|
|
96
|
+
if inspect.iscoroutinefunction(func):
|
|
97
|
+
|
|
98
|
+
@functools.wraps(func)
|
|
99
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
100
|
+
args, kwargs = _govern(func, tool_name, args, kwargs)
|
|
101
|
+
result = None
|
|
102
|
+
error: Exception | None = None
|
|
103
|
+
try:
|
|
104
|
+
result = await func(*args, **kwargs)
|
|
105
|
+
return result
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error = e
|
|
108
|
+
raise
|
|
109
|
+
finally:
|
|
110
|
+
_audit_post(tool_name, result, error)
|
|
111
|
+
|
|
112
|
+
return awrapper
|
|
113
|
+
|
|
114
|
+
@functools.wraps(func)
|
|
115
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
116
|
+
args, kwargs = _govern(func, tool_name, args, kwargs)
|
|
117
|
+
result = None
|
|
118
|
+
error: Exception | None = None
|
|
119
|
+
try:
|
|
120
|
+
result = func(*args, **kwargs)
|
|
121
|
+
return result
|
|
122
|
+
except Exception as e:
|
|
123
|
+
error = e
|
|
124
|
+
raise
|
|
125
|
+
finally:
|
|
126
|
+
_audit_post(tool_name, result, error)
|
|
127
|
+
|
|
128
|
+
return wrapper
|
|
129
|
+
|
|
130
|
+
if fn is not None:
|
|
131
|
+
return decorator(fn)
|
|
132
|
+
return decorator
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Sentinel marker for "the result is a stream we didn't materialize".
|
|
136
|
+
_STREAM_SENTINEL = object()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _gen_iterate(func, tool_name, args, kwargs):
|
|
140
|
+
"""Drive a generator tool, auditing once after it's exhausted/aborted."""
|
|
141
|
+
error: Exception | None = None
|
|
142
|
+
try:
|
|
143
|
+
yield from func(*args, **kwargs)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
error = e
|
|
146
|
+
raise
|
|
147
|
+
finally:
|
|
148
|
+
_audit_post(tool_name, _STREAM_SENTINEL, error)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _agen_iterate(func, tool_name, args, kwargs):
|
|
152
|
+
"""Async analogue of :func:`_gen_iterate`."""
|
|
153
|
+
error: Exception | None = None
|
|
154
|
+
try:
|
|
155
|
+
async for item in func(*args, **kwargs):
|
|
156
|
+
yield item
|
|
157
|
+
except Exception as e:
|
|
158
|
+
error = e
|
|
159
|
+
raise
|
|
160
|
+
finally:
|
|
161
|
+
_audit_post(tool_name, _STREAM_SENTINEL, error)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_wrappable(func: Callable) -> None:
|
|
165
|
+
if (
|
|
166
|
+
inspect.isfunction(func)
|
|
167
|
+
or inspect.ismethod(func)
|
|
168
|
+
or inspect.iscoroutinefunction(func)
|
|
169
|
+
or inspect.isgeneratorfunction(func)
|
|
170
|
+
or inspect.isasyncgenfunction(func)
|
|
171
|
+
):
|
|
172
|
+
return
|
|
173
|
+
if callable(func) and any(hasattr(func, m) for m in _TOOL_OBJECT_MARKERS):
|
|
174
|
+
raise TypeError(
|
|
175
|
+
f"@firstops.tool expects a plain function, not a built framework tool "
|
|
176
|
+
f"object ({type(func).__name__}). Decorate the underlying function "
|
|
177
|
+
f"before the framework wraps it (put @firstops.tool innermost)."
|
|
178
|
+
)
|
|
179
|
+
if not callable(func):
|
|
180
|
+
raise TypeError(f"@firstops.tool expects a callable, got {type(func).__name__}")
|
|
181
|
+
# Other callables (functools.partial, lambdas) are allowed.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _govern(
|
|
185
|
+
func: Callable, tool_name: str, args: tuple, kwargs: dict
|
|
186
|
+
) -> tuple[tuple, dict]:
|
|
187
|
+
"""Run the pre_tool_use evaluation and apply the decision to the call args."""
|
|
188
|
+
tool_input = _bind_inputs(func, args, kwargs)
|
|
189
|
+
decision = _evaluate_pre(tool_name, tool_input)
|
|
190
|
+
return _apply_pre(func, decision, tool_name, args, kwargs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _jsonable(value: Any) -> Any:
|
|
194
|
+
try:
|
|
195
|
+
json.dumps(value)
|
|
196
|
+
return value
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
return repr(value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _bind_inputs(func: Callable, args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
202
|
+
"""Map a call's args/kwargs to a JSON-able {param: value} dict."""
|
|
203
|
+
try:
|
|
204
|
+
bound = inspect.signature(func).bind_partial(*args, **kwargs)
|
|
205
|
+
bound.apply_defaults()
|
|
206
|
+
return {k: _jsonable(v) for k, v in bound.arguments.items()}
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
out: dict[str, Any] = {f"arg{i}": _jsonable(a) for i, a in enumerate(args)}
|
|
209
|
+
out.update({k: _jsonable(v) for k, v in kwargs.items()})
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _evaluate_pre(tool_name: str, tool_input: dict[str, Any]) -> Decision | None:
|
|
214
|
+
rt = _runtime.runtime()
|
|
215
|
+
if rt is None:
|
|
216
|
+
return None # not initialized — no governance, run normally
|
|
217
|
+
channel = classify(tool_name)
|
|
218
|
+
event = ActionEvent(
|
|
219
|
+
event_type=EVENT_PRE_TOOL_USE,
|
|
220
|
+
tool_name=tool_name,
|
|
221
|
+
channel=channel,
|
|
222
|
+
tool_input=tool_input,
|
|
223
|
+
mcp=mcp_info(tool_name) if channel == CHANNEL_MCP else None,
|
|
224
|
+
# The decorator rebinds args from a modify payload, so request-path
|
|
225
|
+
# scrub is applicable — let sentinel ship modify, not escalate to deny.
|
|
226
|
+
producer_can_apply_modify=True,
|
|
227
|
+
)
|
|
228
|
+
return rt.enforcement.evaluate(event)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _apply_pre(
|
|
232
|
+
func: Callable, decision: Decision | None, tool_name: str, args: tuple, kwargs: dict
|
|
233
|
+
) -> tuple[tuple, dict]:
|
|
234
|
+
if decision is None:
|
|
235
|
+
return args, kwargs
|
|
236
|
+
if decision.blocked:
|
|
237
|
+
raise FirstOpsPolicyError(tool_name, decision.reason, decision.policy_id)
|
|
238
|
+
if decision.modified and decision.modified_payload:
|
|
239
|
+
rebound = _rebind(func, decision.modified_payload, args, kwargs)
|
|
240
|
+
if rebound is not None:
|
|
241
|
+
return rebound
|
|
242
|
+
# We signalled producer_can_apply_modify, so sentinel shipped a scrub
|
|
243
|
+
# instead of a deny. If it doesn't fit the signature, fail CLOSED —
|
|
244
|
+
# running the tool with unscrubbed args is worse than blocking.
|
|
245
|
+
logger.warning(
|
|
246
|
+
"firstops: could not apply modify to %s; blocking (fail closed)", tool_name
|
|
247
|
+
)
|
|
248
|
+
raise FirstOpsPolicyError(
|
|
249
|
+
tool_name,
|
|
250
|
+
"policy required a modification this tool could not apply",
|
|
251
|
+
decision.policy_id,
|
|
252
|
+
)
|
|
253
|
+
return args, kwargs
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _rebind(
|
|
257
|
+
func: Callable, payload: bytes, args: tuple, kwargs: dict
|
|
258
|
+
) -> tuple[tuple, dict] | None:
|
|
259
|
+
"""Overlay sentinel's scrubbed inputs onto the call, respecting param kinds.
|
|
260
|
+
|
|
261
|
+
Returns (args, kwargs) honoring positional-only / *args / **kwargs, or None
|
|
262
|
+
if the payload can't be applied (caller then falls open to original args).
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
new_input = json.loads(payload)
|
|
266
|
+
except (ValueError, TypeError):
|
|
267
|
+
return None
|
|
268
|
+
if not isinstance(new_input, dict):
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
sig = inspect.signature(func)
|
|
272
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
273
|
+
bound.apply_defaults()
|
|
274
|
+
merged = dict(bound.arguments)
|
|
275
|
+
merged.update(new_input)
|
|
276
|
+
|
|
277
|
+
out_args: list[Any] = []
|
|
278
|
+
out_kwargs: dict[str, Any] = {}
|
|
279
|
+
consumed = set()
|
|
280
|
+
for pname, param in sig.parameters.items():
|
|
281
|
+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
282
|
+
out_args.extend(merged.get(pname, ()) or ())
|
|
283
|
+
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
284
|
+
out_kwargs.update(merged.get(pname, {}) or {})
|
|
285
|
+
elif pname in merged:
|
|
286
|
+
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
|
|
287
|
+
out_args.append(merged[pname])
|
|
288
|
+
else:
|
|
289
|
+
out_kwargs[pname] = merged[pname]
|
|
290
|
+
consumed.add(pname)
|
|
291
|
+
# Validate the reconstruction actually binds before returning it.
|
|
292
|
+
sig.bind(*out_args, **out_kwargs)
|
|
293
|
+
return tuple(out_args), out_kwargs
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _audit_post(tool_name: str, result: Any, error: Exception | None = None) -> None:
|
|
299
|
+
"""Emit a post_tool_use audit event. Best-effort: never affects the call."""
|
|
300
|
+
rt = _runtime.runtime()
|
|
301
|
+
if rt is None:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
if error is not None:
|
|
305
|
+
output: dict[str, Any] = {"error": repr(error)}
|
|
306
|
+
elif result is _STREAM_SENTINEL:
|
|
307
|
+
output = {"result": "<stream>"}
|
|
308
|
+
else:
|
|
309
|
+
output = {"result": _jsonable(result)}
|
|
310
|
+
event = ActionEvent(
|
|
311
|
+
event_type=EVENT_POST_TOOL_USE,
|
|
312
|
+
tool_name=tool_name,
|
|
313
|
+
channel=classify(tool_name),
|
|
314
|
+
tool_output=output,
|
|
315
|
+
)
|
|
316
|
+
rt.enforcement.evaluate(event)
|
|
317
|
+
except Exception as e: # pragma: no cover - defensive
|
|
318
|
+
logger.debug("firstops post-eval failed for %s: %s", tool_name, e)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: firstops
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and OpenAI Agents.
|
|
5
|
+
Project-URL: Homepage, https://firstops.dev
|
|
6
|
+
Project-URL: Documentation, https://github.com/firstops-dev/firstops-python
|
|
7
|
+
Project-URL: Repository, https://github.com/firstops-dev/firstops-python
|
|
8
|
+
Project-URL: Issues, https://github.com/firstops-dev/firstops-python/issues
|
|
9
|
+
Author-email: FirstOps <dev@firstops.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,claude,dpop,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: cryptography>=42.0
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: claude-agent-sdk; extra == 'all'
|
|
28
|
+
Requires-Dist: langchain-mcp-adapters; extra == 'all'
|
|
29
|
+
Requires-Dist: langchain-openai; extra == 'all'
|
|
30
|
+
Requires-Dist: langchain>=1.0; extra == 'all'
|
|
31
|
+
Requires-Dist: langgraph; extra == 'all'
|
|
32
|
+
Requires-Dist: openai-agents; extra == 'all'
|
|
33
|
+
Provides-Extra: claude
|
|
34
|
+
Requires-Dist: claude-agent-sdk; extra == 'claude'
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
38
|
+
Provides-Extra: langgraph
|
|
39
|
+
Requires-Dist: langchain-mcp-adapters; extra == 'langgraph'
|
|
40
|
+
Requires-Dist: langchain-openai; extra == 'langgraph'
|
|
41
|
+
Requires-Dist: langchain>=1.0; extra == 'langgraph'
|
|
42
|
+
Requires-Dist: langgraph; extra == 'langgraph'
|
|
43
|
+
Provides-Extra: openai
|
|
44
|
+
Requires-Dist: openai-agents; extra == 'openai'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# FirstOps Python SDK
|
|
48
|
+
|
|
49
|
+
Govern what your AI agents do. FirstOps applies identity, policy enforcement, credential brokering, and audit to every **LLM call**, **tool call**, and **MCP call** your agent makes — across LangGraph, the Claude Agent SDK, and the OpenAI Agents SDK, or any custom loop.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "firstops[langgraph]" # or [claude], [openai], [all]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- **Python 3.10+**
|
|
56
|
+
- Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## What FirstOps governs
|
|
61
|
+
|
|
62
|
+
| Surface | How it's wired | What you get |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| **LLM calls** | point the model `base_url` at the local sidecar | inspect prompts/responses, scrub PII, block, audit |
|
|
65
|
+
| **Tool calls** | one adapter (or `@firstops.tool`) | block / rewrite args / audit — including framework built-ins |
|
|
66
|
+
| **MCP servers** | point the MCP client at the local proxy | server-side policy + **credential brokering** (the agent never holds the upstream token) |
|
|
67
|
+
|
|
68
|
+
Every action is evaluated by FirstOps and returns `allow` / `deny` / `modify` — your agent logic doesn't change.
|
|
69
|
+
|
|
70
|
+
## Quick start (LangGraph)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import firstops
|
|
74
|
+
from firstops.integrations.langgraph import FirstOpsMiddleware
|
|
75
|
+
from langchain.agents import create_agent
|
|
76
|
+
from langchain_openai import ChatOpenAI
|
|
77
|
+
|
|
78
|
+
fo = firstops.init(
|
|
79
|
+
agent_id="<agent-uuid>", # from the FirstOps dashboard
|
|
80
|
+
private_key_pem=open("agent-key.pem").read(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Route the LLM through FirstOps; wire one middleware to govern every tool call.
|
|
84
|
+
llm = ChatOpenAI(model="gpt-4o-mini", base_url=firstops.llm_base_url("openai"), api_key="sk-...")
|
|
85
|
+
agent = create_agent(model=llm, tools=[...], middleware=[FirstOpsMiddleware(fo)])
|
|
86
|
+
|
|
87
|
+
agent.invoke({"messages": [{"role": "user", "content": "..."}]})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The whole integration is `init()` + a `base_url` swap + one middleware. See [`examples/`](examples/) for runnable agents, including MCP.
|
|
91
|
+
|
|
92
|
+
## Other harnesses
|
|
93
|
+
|
|
94
|
+
**Claude Agent SDK** — one `PreToolUse` hook governs every tool (built-ins, MCP, custom):
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from claude_agent_sdk import query, ClaudeAgentOptions
|
|
98
|
+
from firstops.integrations.claude import firstops_hooks
|
|
99
|
+
|
|
100
|
+
options = ClaudeAgentOptions(hooks=firstops_hooks(fo), permission_mode="bypassPermissions")
|
|
101
|
+
async for _ in query(prompt="...", options=options):
|
|
102
|
+
pass
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**OpenAI Agents SDK** — a guardrail per tool + the model routed through the sidecar:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from agents import Agent, function_tool, set_default_openai_client
|
|
109
|
+
from firstops.integrations.openai_agents import firstops_tool_input_guardrail
|
|
110
|
+
from openai import AsyncOpenAI
|
|
111
|
+
|
|
112
|
+
set_default_openai_client(AsyncOpenAI(base_url=firstops.llm_base_url("openai"), api_key="sk-..."))
|
|
113
|
+
guard = firstops_tool_input_guardrail(fo)
|
|
114
|
+
|
|
115
|
+
@function_tool(tool_input_guardrails=[guard])
|
|
116
|
+
def send_email(to: str, body: str) -> str: ...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Any framework / custom loop** — the base API:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
@firstops.tool # govern any callable: block / scrub args / audit
|
|
123
|
+
def send_email(to: str, body: str): ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## MCP servers
|
|
127
|
+
|
|
128
|
+
Point your MCP client at the local proxy; FirstOps brokers the upstream credentials.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
132
|
+
|
|
133
|
+
mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"), "transport": "streamable_http"}})
|
|
134
|
+
tools = await mcp.get_tools()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Management client
|
|
138
|
+
|
|
139
|
+
Provision agents and connections from your backend:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from firstops import FirstOps
|
|
143
|
+
|
|
144
|
+
admin = FirstOps(api_key="fo_key_...")
|
|
145
|
+
agent = admin.agents.create(name="research-bot") # -> id + private_key (shown once)
|
|
146
|
+
admin.connections.register(principal_id=agent.id, name="slack", upstream_url="https://mcp.slack.com/sse")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## How it works
|
|
150
|
+
|
|
151
|
+
`firstops.init()` starts a local sidecar and establishes the agent's identity (a DPoP-bound principal — RFC 9449). Tool and LLM actions are forwarded to the FirstOps gateway, which evaluates them against your policies and returns the verdict; MCP and LLM traffic flow through the sidecar with credentials brokered. Enforcement fails open on infrastructure errors; authentication fails closed.
|
|
152
|
+
|
|
153
|
+
## Documentation
|
|
154
|
+
|
|
155
|
+
- Guides: <https://firstops.dev/docs>
|
|
156
|
+
- Repository: <https://github.com/firstops-dev/firstops-python>
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
firstops/__init__.py,sha256=mkGIkRos4vMr3sOXNM6kWBAyIaY8JvyKQRaH8x3jFMk,1363
|
|
2
|
+
firstops/_identity.py,sha256=SHUwdPsXZOgZBH15MNfk1NcvGK_0sFVVNJixB0Kl_Iw,2171
|
|
3
|
+
firstops/_runtime.py,sha256=YUJ8ZrT-r83W6wFwT79OQlcj7tjB9_xVqdmGIoQmBnQ,4928
|
|
4
|
+
firstops/channels.py,sha256=e14hhV6IYE8I3A9PZdzH59_sSzr7sfxDhkdexqg6etA,1445
|
|
5
|
+
firstops/client.py,sha256=L6CgVbeHx0rrt5_quIfHxAyLMwBGMfQEf6jtTD_QT2c,14413
|
|
6
|
+
firstops/coverage.py,sha256=ri-xyeANYPfzQr5kTreZ7R9ciI9sCEIJ6BQMHO1U-bE,2859
|
|
7
|
+
firstops/dpop.py,sha256=CnrrRalG_JflWvHKXzWhPpfQxB_9aHPaph0GLgqT1CY,2765
|
|
8
|
+
firstops/enforcement.py,sha256=1RxSlPSbUH2Hm1kLOYX7RCyd8cBc8gjTUg4e1l1a-gw,2751
|
|
9
|
+
firstops/events.py,sha256=uiPow8-mefOW3kZz1JHLBkJoyX4ukTMcxQGllQFu0To,6779
|
|
10
|
+
firstops/llm.py,sha256=LUHE_tzgtJ5T-GjY9aVq4yD7fhRmXxCqPblovcl4SSE,1753
|
|
11
|
+
firstops/proxy.py,sha256=-ApBofk5yiOhpOfM7zFPad_PzSWJajUiPhC8GQ_xlPc,16283
|
|
12
|
+
firstops/tools.py,sha256=_-py7p1DayfOFvMD-JeiR-sl581k1KSlg0RuOLonz0c,11642
|
|
13
|
+
firstops/integrations/__init__.py,sha256=ItugNKg3EZ_mEM8hiExAGAUaTMTzYZ6ov0EexWqhemU,573
|
|
14
|
+
firstops/integrations/_common.py,sha256=9gXsCoT9myPnjNPhGuRSkYHRrPNKQo4N-z1X6xZ0iQw,4615
|
|
15
|
+
firstops/integrations/claude.py,sha256=ECjZZ557IoWpVo1pilLbF3QCAk_OfHa4ryL2gC73eDQ,3155
|
|
16
|
+
firstops/integrations/langgraph.py,sha256=gHgRgr_05XmJkdoY_a8YvfviZYxS4lfJSQhmmArLg74,3395
|
|
17
|
+
firstops/integrations/openai_agents.py,sha256=InP2g7NZ_Eh_RDuWgBa-8ZSZo8ZA_L8bRP9SigQFHwc,3406
|
|
18
|
+
firstops-0.2.0.dist-info/METADATA,sha256=D7E2pjSyVykj38yyyUXXJhgnKda-OXF5bWc3bgojW-E,6269
|
|
19
|
+
firstops-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
firstops-0.2.0.dist-info/licenses/LICENSE,sha256=4QQ3p8KL54k-TogpPIWwCf_EvT1H8PquBqcToweyzKA,1065
|
|
21
|
+
firstops-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FirstOps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|