firstops 0.2.0__tar.gz
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-0.2.0/.github/workflows/publish.yml +28 -0
- firstops-0.2.0/.github/workflows/test.yml +27 -0
- firstops-0.2.0/.gitignore +27 -0
- firstops-0.2.0/LICENSE +21 -0
- firstops-0.2.0/PKG-INFO +160 -0
- firstops-0.2.0/README.md +114 -0
- firstops-0.2.0/examples/README.md +44 -0
- firstops-0.2.0/examples/_shared.py +32 -0
- firstops-0.2.0/examples/claude_sdk_basic.py +66 -0
- firstops-0.2.0/examples/claude_sdk_mcp.py +70 -0
- firstops-0.2.0/examples/customers_openai.txt +21 -0
- firstops-0.2.0/examples/langgraph_basic.py +66 -0
- firstops-0.2.0/examples/langgraph_notion_mcp.py +92 -0
- firstops-0.2.0/examples/openai_agents_basic.py +74 -0
- firstops-0.2.0/examples/openai_agents_mcp.py +78 -0
- firstops-0.2.0/examples/out/customers.txt +161 -0
- firstops-0.2.0/pyproject.toml +71 -0
- firstops-0.2.0/src/firstops/__init__.py +58 -0
- firstops-0.2.0/src/firstops/_identity.py +59 -0
- firstops-0.2.0/src/firstops/_runtime.py +150 -0
- firstops-0.2.0/src/firstops/channels.py +38 -0
- firstops-0.2.0/src/firstops/client.py +427 -0
- firstops-0.2.0/src/firstops/coverage.py +65 -0
- firstops-0.2.0/src/firstops/dpop.py +78 -0
- firstops-0.2.0/src/firstops/enforcement.py +73 -0
- firstops-0.2.0/src/firstops/events.py +195 -0
- firstops-0.2.0/src/firstops/integrations/__init__.py +12 -0
- firstops-0.2.0/src/firstops/integrations/_common.py +132 -0
- firstops-0.2.0/src/firstops/integrations/claude.py +84 -0
- firstops-0.2.0/src/firstops/integrations/langgraph.py +87 -0
- firstops-0.2.0/src/firstops/integrations/openai_agents.py +87 -0
- firstops-0.2.0/src/firstops/llm.py +51 -0
- firstops-0.2.0/src/firstops/proxy.py +408 -0
- firstops-0.2.0/src/firstops/tools.py +318 -0
- firstops-0.2.0/tests/__init__.py +0 -0
- firstops-0.2.0/tests/test_channels.py +68 -0
- firstops-0.2.0/tests/test_contract_parity.py +103 -0
- firstops-0.2.0/tests/test_dpop.py +123 -0
- firstops-0.2.0/tests/test_enforcement.py +166 -0
- firstops-0.2.0/tests/test_enforcement_adversarial.py +280 -0
- firstops-0.2.0/tests/test_events.py +108 -0
- firstops-0.2.0/tests/test_events_adversarial.py +197 -0
- firstops-0.2.0/tests/test_identity_adversarial.py +164 -0
- firstops-0.2.0/tests/test_integrations.py +183 -0
- firstops-0.2.0/tests/test_integrations_bughunt.py +429 -0
- firstops-0.2.0/tests/test_llm_route.py +155 -0
- firstops-0.2.0/tests/test_llm_route_adversarial.py +358 -0
- firstops-0.2.0/tests/test_m3_bughunt.py +328 -0
- firstops-0.2.0/tests/test_m3_scrub_coverage.py +118 -0
- firstops-0.2.0/tests/test_proxy.py +120 -0
- firstops-0.2.0/tests/test_proxy_regression.py +212 -0
- firstops-0.2.0/tests/test_proxy_router_regression.py +138 -0
- firstops-0.2.0/tests/test_runtime.py +52 -0
- firstops-0.2.0/tests/test_runtime_adversarial.py +206 -0
- firstops-0.2.0/tests/test_tools.py +168 -0
- firstops-0.2.0/tests/test_tools_adversarial.py +290 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # Required for PyPI trusted publisher
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
|
|
21
|
+
- name: Install build tools
|
|
22
|
+
run: pip install build
|
|
23
|
+
|
|
24
|
+
- name: Build package
|
|
25
|
+
run: python -m build
|
|
26
|
+
|
|
27
|
+
- name: Publish to PyPI
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: pytest -v
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Virtual environments
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
env/
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.pyc
|
|
9
|
+
*.pyo
|
|
10
|
+
*.egg-info/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
|
|
14
|
+
# Testing
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
firstops-0.2.0/LICENSE
ADDED
|
@@ -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.
|
firstops-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
firstops-0.2.0/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# FirstOps Python SDK
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install "firstops[langgraph]" # or [claude], [openai], [all]
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- **Python 3.10+**
|
|
10
|
+
- Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What FirstOps governs
|
|
15
|
+
|
|
16
|
+
| Surface | How it's wired | What you get |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| **LLM calls** | point the model `base_url` at the local sidecar | inspect prompts/responses, scrub PII, block, audit |
|
|
19
|
+
| **Tool calls** | one adapter (or `@firstops.tool`) | block / rewrite args / audit — including framework built-ins |
|
|
20
|
+
| **MCP servers** | point the MCP client at the local proxy | server-side policy + **credential brokering** (the agent never holds the upstream token) |
|
|
21
|
+
|
|
22
|
+
Every action is evaluated by FirstOps and returns `allow` / `deny` / `modify` — your agent logic doesn't change.
|
|
23
|
+
|
|
24
|
+
## Quick start (LangGraph)
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import firstops
|
|
28
|
+
from firstops.integrations.langgraph import FirstOpsMiddleware
|
|
29
|
+
from langchain.agents import create_agent
|
|
30
|
+
from langchain_openai import ChatOpenAI
|
|
31
|
+
|
|
32
|
+
fo = firstops.init(
|
|
33
|
+
agent_id="<agent-uuid>", # from the FirstOps dashboard
|
|
34
|
+
private_key_pem=open("agent-key.pem").read(),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Route the LLM through FirstOps; wire one middleware to govern every tool call.
|
|
38
|
+
llm = ChatOpenAI(model="gpt-4o-mini", base_url=firstops.llm_base_url("openai"), api_key="sk-...")
|
|
39
|
+
agent = create_agent(model=llm, tools=[...], middleware=[FirstOpsMiddleware(fo)])
|
|
40
|
+
|
|
41
|
+
agent.invoke({"messages": [{"role": "user", "content": "..."}]})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The whole integration is `init()` + a `base_url` swap + one middleware. See [`examples/`](examples/) for runnable agents, including MCP.
|
|
45
|
+
|
|
46
|
+
## Other harnesses
|
|
47
|
+
|
|
48
|
+
**Claude Agent SDK** — one `PreToolUse` hook governs every tool (built-ins, MCP, custom):
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from claude_agent_sdk import query, ClaudeAgentOptions
|
|
52
|
+
from firstops.integrations.claude import firstops_hooks
|
|
53
|
+
|
|
54
|
+
options = ClaudeAgentOptions(hooks=firstops_hooks(fo), permission_mode="bypassPermissions")
|
|
55
|
+
async for _ in query(prompt="...", options=options):
|
|
56
|
+
pass
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**OpenAI Agents SDK** — a guardrail per tool + the model routed through the sidecar:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from agents import Agent, function_tool, set_default_openai_client
|
|
63
|
+
from firstops.integrations.openai_agents import firstops_tool_input_guardrail
|
|
64
|
+
from openai import AsyncOpenAI
|
|
65
|
+
|
|
66
|
+
set_default_openai_client(AsyncOpenAI(base_url=firstops.llm_base_url("openai"), api_key="sk-..."))
|
|
67
|
+
guard = firstops_tool_input_guardrail(fo)
|
|
68
|
+
|
|
69
|
+
@function_tool(tool_input_guardrails=[guard])
|
|
70
|
+
def send_email(to: str, body: str) -> str: ...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Any framework / custom loop** — the base API:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
@firstops.tool # govern any callable: block / scrub args / audit
|
|
77
|
+
def send_email(to: str, body: str): ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## MCP servers
|
|
81
|
+
|
|
82
|
+
Point your MCP client at the local proxy; FirstOps brokers the upstream credentials.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
86
|
+
|
|
87
|
+
mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"), "transport": "streamable_http"}})
|
|
88
|
+
tools = await mcp.get_tools()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Management client
|
|
92
|
+
|
|
93
|
+
Provision agents and connections from your backend:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from firstops import FirstOps
|
|
97
|
+
|
|
98
|
+
admin = FirstOps(api_key="fo_key_...")
|
|
99
|
+
agent = admin.agents.create(name="research-bot") # -> id + private_key (shown once)
|
|
100
|
+
admin.connections.register(principal_id=agent.id, name="slack", upstream_url="https://mcp.slack.com/sse")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
`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.
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
- Guides: <https://firstops.dev/docs>
|
|
110
|
+
- Repository: <https://github.com/firstops-dev/firstops-python>
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# FirstOps SDK — Examples
|
|
2
|
+
|
|
3
|
+
Runnable agents that govern every LLM call, tool call, and MCP call through
|
|
4
|
+
FirstOps.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
python -m venv .venv && source .venv/bin/activate
|
|
10
|
+
pip install -e .. # the FirstOps SDK (this repo)
|
|
11
|
+
pip install "langchain>=1.0" langgraph langchain-openai langchain-mcp-adapters openai
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Config (env vars)
|
|
15
|
+
|
|
16
|
+
| Var | Meaning |
|
|
17
|
+
|-----|---------|
|
|
18
|
+
| `FO_AGENT_ID` | Agent principal ID (UUID) from `client.agents.create(...)` |
|
|
19
|
+
| `FO_PRIVATE_KEY_PATH` | Path to the agent's EC P-256 private-key PEM |
|
|
20
|
+
| `FO_GATEWAY` | FirstOps gateway base URL (default `https://api.firstops.dev`) |
|
|
21
|
+
| `FO_PORT` | Local sidecar port (default `9322`) |
|
|
22
|
+
| `OPENAI_API_KEY` | OpenAI key — passes through the sidecar to OpenAI, never stored |
|
|
23
|
+
| `FO_MCP_CONNECTION_ID` | (MCP example) a registered MCP connection ID for the agent |
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
- **`langgraph_basic.py`** — a LangGraph agent with two local tools and the LLM
|
|
28
|
+
routed through the sidecar chain-link. Exercises tool governance + LLM
|
|
29
|
+
governance.
|
|
30
|
+
- **`langgraph_notion_mcp.py`** — adds a Notion MCP server (via the sidecar's
|
|
31
|
+
MCP proxy) and a local `write_to_file` tool, then asks the agent to fetch
|
|
32
|
+
customer info from Notion and write it to a local file. Exercises **MCP +
|
|
33
|
+
local tool** governance together.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
|
|
37
|
+
python langgraph_basic.py
|
|
38
|
+
|
|
39
|
+
FO_AGENT_ID=... FO_PRIVATE_KEY_PATH=... OPENAI_API_KEY=... \
|
|
40
|
+
FO_MCP_CONNECTION_ID=... python langgraph_notion_mcp.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Each run prints a `[GOVERN]` line for every governed action (channel, tool,
|
|
44
|
+
decision), so you can see exactly what FirstOps evaluated.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared boilerplate for the example agents: config + a governance tracer."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_config() -> dict:
|
|
7
|
+
agent_id = os.environ["FO_AGENT_ID"].strip()
|
|
8
|
+
key_path = os.environ["FO_PRIVATE_KEY_PATH"]
|
|
9
|
+
with open(key_path) as f:
|
|
10
|
+
key_pem = f.read()
|
|
11
|
+
return {
|
|
12
|
+
"agent_id": agent_id,
|
|
13
|
+
"key_pem": key_pem,
|
|
14
|
+
"gateway": os.environ.get("FO_GATEWAY", "https://api.firstops.dev"),
|
|
15
|
+
"port": int(os.environ.get("FO_PORT", "9322")),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def trace(fo) -> None:
|
|
20
|
+
"""Wrap the enforcement client so every governed action prints."""
|
|
21
|
+
orig = fo.enforcement.evaluate
|
|
22
|
+
|
|
23
|
+
def traced(event):
|
|
24
|
+
d = orig(event)
|
|
25
|
+
print(
|
|
26
|
+
f" [GOVERN] {event.channel:<13} {event.event_type:<14} "
|
|
27
|
+
f"{event.tool_name:<32} -> {d.action}"
|
|
28
|
+
+ (f" (FAILED_OPEN: {d.reason})" if d.failed_open else "")
|
|
29
|
+
)
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
fo.enforcement.evaluate = traced
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Claude Agent SDK agent governed by FirstOps.
|
|
2
|
+
|
|
3
|
+
The Claude Agent SDK runs tools (Bash, Write, Read, MCP) inside the Claude Code
|
|
4
|
+
subprocess. FirstOps governs each one via a single PreToolUse hook — block,
|
|
5
|
+
rewrite args (updatedInput), or allow — the daemon model, in-process.
|
|
6
|
+
|
|
7
|
+
`permission_mode="bypassPermissions"` makes the FirstOps hook the sole gate:
|
|
8
|
+
a hook `deny` still blocks; everything else flows. Run with the env vars in
|
|
9
|
+
README.md (no OpenAI key needed — Claude uses your local Claude Code auth).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import firstops
|
|
16
|
+
from claude_agent_sdk import (
|
|
17
|
+
AssistantMessage,
|
|
18
|
+
ClaudeAgentOptions,
|
|
19
|
+
ResultMessage,
|
|
20
|
+
TextBlock,
|
|
21
|
+
ToolUseBlock,
|
|
22
|
+
query,
|
|
23
|
+
)
|
|
24
|
+
from firstops.integrations.claude import firstops_hooks
|
|
25
|
+
|
|
26
|
+
from _shared import load_config, trace
|
|
27
|
+
|
|
28
|
+
WORKDIR = Path(__file__).parent / "claude_work"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
cfg = load_config()
|
|
33
|
+
fo = firstops.init(
|
|
34
|
+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
|
|
35
|
+
)
|
|
36
|
+
trace(fo)
|
|
37
|
+
WORKDIR.mkdir(exist_ok=True)
|
|
38
|
+
try:
|
|
39
|
+
options = ClaudeAgentOptions(
|
|
40
|
+
hooks=firstops_hooks(fo), # ← every tool call governed by FirstOps
|
|
41
|
+
allowed_tools=["Bash", "Write", "Read"],
|
|
42
|
+
permission_mode="bypassPermissions",
|
|
43
|
+
cwd=str(WORKDIR),
|
|
44
|
+
)
|
|
45
|
+
prompt = (
|
|
46
|
+
"Create a file named greeting.txt containing exactly "
|
|
47
|
+
"'Hello from a FirstOps-governed Claude agent'. "
|
|
48
|
+
"Then run a bash command to print today's date. "
|
|
49
|
+
"Finally, read greeting.txt back and report its contents."
|
|
50
|
+
)
|
|
51
|
+
print("\n>>> running Claude agent (tools governed via PreToolUse hook)\n")
|
|
52
|
+
async for message in query(prompt=prompt, options=options):
|
|
53
|
+
if isinstance(message, AssistantMessage):
|
|
54
|
+
for block in message.content:
|
|
55
|
+
if isinstance(block, TextBlock) and block.text.strip():
|
|
56
|
+
print(f" [CLAUDE] {block.text.strip()[:160]}")
|
|
57
|
+
elif isinstance(block, ToolUseBlock):
|
|
58
|
+
print(f" [TOOL-USE] {block.name} {block.input}")
|
|
59
|
+
elif isinstance(message, ResultMessage):
|
|
60
|
+
print(f"\n>>> result:\n{getattr(message, 'result', message)}")
|
|
61
|
+
finally:
|
|
62
|
+
firstops.shutdown()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Claude Agent SDK agent with a Notion MCP server, governed by FirstOps.
|
|
2
|
+
|
|
3
|
+
Same daemon-model hook as claude_sdk_basic.py, but now the agent also talks to
|
|
4
|
+
a Notion MCP server (through the FirstOps proxy). The single PreToolUse hook
|
|
5
|
+
governs BOTH the MCP tool calls (mcp__notion__*) and the built-in Write tool.
|
|
6
|
+
|
|
7
|
+
Run with the env vars in README.md, incl. FO_MCP_CONNECTION_ID. Uses your local
|
|
8
|
+
Claude Code auth (no OpenAI key needed).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import firstops
|
|
16
|
+
from claude_agent_sdk import (
|
|
17
|
+
AssistantMessage,
|
|
18
|
+
ClaudeAgentOptions,
|
|
19
|
+
ResultMessage,
|
|
20
|
+
TextBlock,
|
|
21
|
+
ToolUseBlock,
|
|
22
|
+
query,
|
|
23
|
+
)
|
|
24
|
+
from firstops.integrations.claude import firstops_hooks
|
|
25
|
+
|
|
26
|
+
from _shared import load_config, trace
|
|
27
|
+
|
|
28
|
+
WORKDIR = Path(__file__).parent / "claude_work"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
cfg = load_config()
|
|
33
|
+
conn_id = os.environ["FO_MCP_CONNECTION_ID"].strip()
|
|
34
|
+
|
|
35
|
+
fo = firstops.init(
|
|
36
|
+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
|
|
37
|
+
)
|
|
38
|
+
trace(fo)
|
|
39
|
+
WORKDIR.mkdir(exist_ok=True)
|
|
40
|
+
try:
|
|
41
|
+
options = ClaudeAgentOptions(
|
|
42
|
+
hooks=firstops_hooks(fo), # governs both MCP and built-in tools
|
|
43
|
+
mcp_servers={
|
|
44
|
+
"notion": {"type": "http", "url": firstops.mcp_url(conn_id)}
|
|
45
|
+
},
|
|
46
|
+
allowed_tools=["Write", "Read", "mcp__notion"],
|
|
47
|
+
permission_mode="bypassPermissions",
|
|
48
|
+
cwd=str(WORKDIR),
|
|
49
|
+
)
|
|
50
|
+
prompt = (
|
|
51
|
+
"Use the Notion tools to find the customer database and fetch the "
|
|
52
|
+
"customer records. Then write the customer info to a file named "
|
|
53
|
+
"customers_claude.txt."
|
|
54
|
+
)
|
|
55
|
+
print("\n>>> running Claude agent (Notion MCP + Write, governed)\n")
|
|
56
|
+
async for message in query(prompt=prompt, options=options):
|
|
57
|
+
if isinstance(message, AssistantMessage):
|
|
58
|
+
for block in message.content:
|
|
59
|
+
if isinstance(block, TextBlock) and block.text.strip():
|
|
60
|
+
print(f" [CLAUDE] {block.text.strip()[:140]}")
|
|
61
|
+
elif isinstance(block, ToolUseBlock):
|
|
62
|
+
print(f" [TOOL-USE] {block.name}")
|
|
63
|
+
elif isinstance(message, ResultMessage):
|
|
64
|
+
print(f"\n>>> result:\n{getattr(message, 'result', message)}")
|
|
65
|
+
finally:
|
|
66
|
+
firstops.shutdown()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Name Email Phone Company Revenue ($) Plan Status
|
|
2
|
+
1 Sarah Chen sarah.chen@acmecorp.io +1-415-555-0142 Acme Corp 248,000 Enterprise Active
|
|
3
|
+
2 Marcus Johnson marcus.j@brevity.co +1-212-555-0198 Brevity Inc 85,500 Pro Active
|
|
4
|
+
3 Priya Sharma priya@novatech.in +91-98765-43210 NovaTech 412,000 Enterprise Active
|
|
5
|
+
4 James O'Brien jobrien@lumendata.com +44-20-7946-0958 Lumen Data 67,200 Starter Churned
|
|
6
|
+
5 Mei Lin mei.lin@zephyrcloud.io +65-9123-4567 Zephyr Cloud 195,000 Pro Active
|
|
7
|
+
6 Carlos Ruiz cruiz@fintechflow.mx +52-55-5555-0147 FintechFlow 320,000 Enterprise Active
|
|
8
|
+
7 Aisha Patel aisha@gridpoint.ai +1-650-555-0173 GridPoint AI 54,800 Starter Trial
|
|
9
|
+
8 Tom Eriksson tom.e@nordicsaas.se +46-70-123-4567 Nordic SaaS 128,500 Pro Active
|
|
10
|
+
9 Rachel Kim rkim@ocelotlabs.com +1-310-555-0261 Ocelot Labs 91,000 Pro Churned
|
|
11
|
+
10 David Müller dmuller@berlinops.de +49-30-5555-0184 BerlinOps 275,000 Enterprise Active
|
|
12
|
+
11 Fatima Al-Hassan fatima@clearstack.ae +971-50-555-0139 ClearStack 182,000 Pro Active
|
|
13
|
+
12 Liam Cooper lcooper@pulseio.com.au +61-4-5555-0192 Pulse.io 43,200 Starter Trial
|
|
14
|
+
13 Yuki Tanaka yuki@kaizenlabs.jp +81-3-5555-0167 Kaizen Labs 356,000 Enterprise Active
|
|
15
|
+
14 Nina Petrova nina.p@dataweave.ru +7-495-555-0128 DataWeave 72,400 Pro Churned
|
|
16
|
+
15 Ben Adeyemi ben@scalepath.ng +234-801-555-0145 ScalePath 38,900 Starter Active
|
|
17
|
+
16 Sophie Dubois sdubois@cloudnine.fr +33-1-5555-0176 CloudNine 210,000 Enterprise Active
|
|
18
|
+
17 Raj Menon raj@bytebridge.io +1-512-555-0203 ByteBridge 99,800 Pro Active
|
|
19
|
+
18 Emma Walsh ewalsh@peakvector.ie +353-1-555-0154 PeakVector 156,300 Pro Active
|
|
20
|
+
19 Alex Novak anovak@synchub.cz +420-222-555-016 SyncHub 61,700 Starter Churned
|
|
21
|
+
20 Isabela Costa icosta@flowmetrics.br +55-11-5555-0189 FlowMetrics 287,500 Enterprise Active
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""LangGraph agent governed by FirstOps — tool calls + LLM, no MCP.
|
|
2
|
+
|
|
3
|
+
Every LLM call routes through the sidecar chain-link; every tool call is
|
|
4
|
+
intercepted by FirstOpsMiddleware. Run with the env vars in README.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import firstops
|
|
10
|
+
from firstops.integrations.langgraph import FirstOpsMiddleware
|
|
11
|
+
from langchain.agents import create_agent
|
|
12
|
+
from langchain_core.tools import tool as lc_tool
|
|
13
|
+
from langchain_openai import ChatOpenAI
|
|
14
|
+
|
|
15
|
+
from _shared import load_config, trace
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lc_tool
|
|
19
|
+
def get_weather(city: str) -> str:
|
|
20
|
+
"""Get the current weather for a city."""
|
|
21
|
+
print(f" [TOOL get_weather] city={city}")
|
|
22
|
+
return f"It is 21C and sunny in {city}."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lc_tool
|
|
26
|
+
def send_email(to: str, body: str) -> str:
|
|
27
|
+
"""Send an email to a recipient."""
|
|
28
|
+
print(f" [TOOL send_email] to={to} body={body!r}")
|
|
29
|
+
return "email sent"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
cfg = load_config()
|
|
34
|
+
fo = firstops.init(
|
|
35
|
+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
|
|
36
|
+
)
|
|
37
|
+
trace(fo)
|
|
38
|
+
try:
|
|
39
|
+
llm = ChatOpenAI(
|
|
40
|
+
model="gpt-4o-mini",
|
|
41
|
+
base_url=firstops.llm_base_url("openai"),
|
|
42
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
43
|
+
)
|
|
44
|
+
agent = create_agent(
|
|
45
|
+
model=llm,
|
|
46
|
+
tools=[get_weather, send_email],
|
|
47
|
+
middleware=[FirstOpsMiddleware(fo)],
|
|
48
|
+
)
|
|
49
|
+
print("\n>>> invoking agent\n")
|
|
50
|
+
result = agent.invoke(
|
|
51
|
+
{
|
|
52
|
+
"messages": [
|
|
53
|
+
{
|
|
54
|
+
"role": "user",
|
|
55
|
+
"content": "Check the weather in Paris, then email it to alice@example.com.",
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
print(f"\n>>> final answer:\n{result['messages'][-1].content}\n")
|
|
61
|
+
finally:
|
|
62
|
+
firstops.shutdown()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|