firstops 0.2.0__tar.gz → 0.3.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.3.0/.github/CODEOWNERS +5 -0
- firstops-0.3.0/Makefile +61 -0
- {firstops-0.2.0 → firstops-0.3.0}/PKG-INFO +41 -9
- {firstops-0.2.0 → firstops-0.3.0}/README.md +35 -6
- firstops-0.3.0/examples/google_adk_basic.py +73 -0
- {firstops-0.2.0 → firstops-0.3.0}/pyproject.toml +7 -3
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/_common.py +1 -0
- firstops-0.3.0/src/firstops/integrations/google_adk.py +55 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/proxy.py +47 -11
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_integrations.py +47 -0
- {firstops-0.2.0 → firstops-0.3.0}/.github/workflows/publish.yml +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/.github/workflows/test.yml +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/.gitignore +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/LICENSE +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/README.md +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/_shared.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/claude_sdk_basic.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/claude_sdk_mcp.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/customers_openai.txt +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/langgraph_basic.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/langgraph_notion_mcp.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/openai_agents_basic.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/openai_agents_mcp.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/examples/out/customers.txt +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/__init__.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/_identity.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/_runtime.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/channels.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/client.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/coverage.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/dpop.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/enforcement.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/events.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/__init__.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/claude.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/langgraph.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/integrations/openai_agents.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/llm.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/src/firstops/tools.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/__init__.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_channels.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_contract_parity.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_dpop.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_enforcement.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_enforcement_adversarial.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_events.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_events_adversarial.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_identity_adversarial.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_integrations_bughunt.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_llm_route.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_llm_route_adversarial.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_m3_bughunt.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_m3_scrub_coverage.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy_regression.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_proxy_router_regression.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_runtime.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_runtime_adversarial.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_tools.py +0 -0
- {firstops-0.2.0 → firstops-0.3.0}/tests/test_tools_adversarial.py +0 -0
firstops-0.3.0/Makefile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# FirstOps SDK — dev + release tasks.
|
|
2
|
+
#
|
|
3
|
+
# Release: make release VERSION=0.2.1
|
|
4
|
+
# -> bumps pyproject version, runs tests, commits, tags v0.2.1, pushes the
|
|
5
|
+
# tag, and the GitHub `publish.yml` workflow builds + publishes to PyPI.
|
|
6
|
+
#
|
|
7
|
+
# PyPI versions are immutable: every release needs a NEW version. This target
|
|
8
|
+
# keeps the pyproject version and the git tag in lockstep so they can't drift.
|
|
9
|
+
|
|
10
|
+
.PHONY: help install test build clean release
|
|
11
|
+
|
|
12
|
+
PYTHON ?= python3
|
|
13
|
+
VENV ?= .venv
|
|
14
|
+
PY = $(VENV)/bin/python
|
|
15
|
+
|
|
16
|
+
help:
|
|
17
|
+
@echo "make install create venv + install package and dev deps"
|
|
18
|
+
@echo "make test run the test suite"
|
|
19
|
+
@echo "make build build wheel + sdist into dist/"
|
|
20
|
+
@echo "make clean remove build artifacts"
|
|
21
|
+
@echo "make release VERSION=X.Y.Z bump version, test, tag, and publish to PyPI"
|
|
22
|
+
|
|
23
|
+
$(VENV):
|
|
24
|
+
$(PYTHON) -m venv $(VENV)
|
|
25
|
+
|
|
26
|
+
install: $(VENV)
|
|
27
|
+
$(PY) -m pip install --upgrade pip
|
|
28
|
+
$(PY) -m pip install -e ".[dev]" build
|
|
29
|
+
|
|
30
|
+
test:
|
|
31
|
+
$(PY) -m pytest -q
|
|
32
|
+
|
|
33
|
+
build: clean
|
|
34
|
+
$(PY) -m build
|
|
35
|
+
|
|
36
|
+
clean:
|
|
37
|
+
rm -rf dist build src/*.egg-info *.egg-info
|
|
38
|
+
|
|
39
|
+
release:
|
|
40
|
+
@if [ -z "$(VERSION)" ]; then \
|
|
41
|
+
echo "Usage: make release VERSION=X.Y.Z"; exit 1; fi
|
|
42
|
+
@echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([abrc][0-9]+|\.post[0-9]+|\.dev[0-9]+)?$$' \
|
|
43
|
+
|| { echo "VERSION must look like 1.2.3 (got '$(VERSION)')"; exit 1; }
|
|
44
|
+
@if [ -n "$$(git status --porcelain)" ]; then \
|
|
45
|
+
echo "Working tree not clean — commit or stash your changes first."; \
|
|
46
|
+
echo "(A release commit should contain ONLY the version bump.)"; exit 1; fi
|
|
47
|
+
@if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \
|
|
48
|
+
echo "Tag v$(VERSION) already exists. Pick a new version (PyPI is immutable)."; exit 1; fi
|
|
49
|
+
@echo ">> bumping pyproject.toml -> $(VERSION)"
|
|
50
|
+
@$(PY) -c "import re,pathlib; p=pathlib.Path('pyproject.toml'); s=p.read_text(); s,n=re.subn(r'(?m)^version = \".*\"','version = \"$(VERSION)\"',s,count=1); assert n==1,'version line not found'; p.write_text(s)"
|
|
51
|
+
@echo ">> running tests"
|
|
52
|
+
@$(PY) -m pytest -q
|
|
53
|
+
@echo ">> committing + tagging v$(VERSION)"
|
|
54
|
+
@git add pyproject.toml
|
|
55
|
+
@git commit -m "Release v$(VERSION)"
|
|
56
|
+
@git tag -a "v$(VERSION)" -m "firstops v$(VERSION)"
|
|
57
|
+
@echo ">> pushing branch + tag (triggers the PyPI publish workflow)"
|
|
58
|
+
@git push
|
|
59
|
+
@git push origin "v$(VERSION)"
|
|
60
|
+
@echo ">> released v$(VERSION). Watch the publish run:"
|
|
61
|
+
@echo " gh run watch \$$(gh run list --workflow=publish.yml -L1 --json databaseId -q '.[0].databaseId') --exit-status"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firstops
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, OpenAI Agents, and Google ADK.
|
|
5
5
|
Project-URL: Homepage, https://firstops.dev
|
|
6
6
|
Project-URL: Documentation, https://github.com/firstops-dev/firstops-python
|
|
7
7
|
Project-URL: Repository, https://github.com/firstops-dev/firstops-python
|
|
@@ -9,7 +9,7 @@ Project-URL: Issues, https://github.com/firstops-dev/firstops-python/issues
|
|
|
9
9
|
Author-email: FirstOps <dev@firstops.dev>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Keywords: agent,claude,dpop,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
|
|
12
|
+
Keywords: agent,claude,dpop,google-adk,governance,guardrails,langchain,langgraph,llm,mcp,openai-agents,security
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -23,8 +23,11 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: cryptography>=42.0
|
|
25
25
|
Requires-Dist: httpx>=0.27
|
|
26
|
+
Provides-Extra: adk
|
|
27
|
+
Requires-Dist: google-adk[extensions]; extra == 'adk'
|
|
26
28
|
Provides-Extra: all
|
|
27
29
|
Requires-Dist: claude-agent-sdk; extra == 'all'
|
|
30
|
+
Requires-Dist: google-adk[extensions]; extra == 'all'
|
|
28
31
|
Requires-Dist: langchain-mcp-adapters; extra == 'all'
|
|
29
32
|
Requires-Dist: langchain-openai; extra == 'all'
|
|
30
33
|
Requires-Dist: langchain>=1.0; extra == 'all'
|
|
@@ -46,14 +49,20 @@ Description-Content-Type: text/markdown
|
|
|
46
49
|
|
|
47
50
|
# FirstOps Python SDK
|
|
48
51
|
|
|
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,
|
|
52
|
+
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, the OpenAI Agents SDK, and Google ADK, or any custom loop.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
|
-
pip install "firstops[langgraph]" #
|
|
57
|
+
pip install "firstops[langgraph]" # LangGraph + LangChain
|
|
58
|
+
pip install "firstops[claude]" # Claude Agent SDK
|
|
59
|
+
pip install "firstops[openai]" # OpenAI Agents SDK
|
|
60
|
+
pip install "firstops[adk]" # Google ADK
|
|
61
|
+
pip install "firstops[all]" # all of the above
|
|
62
|
+
pip install firstops # core only (management client / custom loops)
|
|
53
63
|
```
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
- Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
|
|
65
|
+
Python 3.10+. Core deps are just `cryptography` and `httpx` — your agent framework comes in via the extra you pick.
|
|
57
66
|
|
|
58
67
|
---
|
|
59
68
|
|
|
@@ -116,6 +125,16 @@ guard = firstops_tool_input_guardrail(fo)
|
|
|
116
125
|
def send_email(to: str, body: str) -> str: ...
|
|
117
126
|
```
|
|
118
127
|
|
|
128
|
+
**Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from google.adk.agents import LlmAgent
|
|
132
|
+
from firstops.integrations.google_adk import firstops_before_tool_callback
|
|
133
|
+
|
|
134
|
+
agent = LlmAgent(name="assistant", model=..., tools=[...],
|
|
135
|
+
before_tool_callback=firstops_before_tool_callback(fo))
|
|
136
|
+
```
|
|
137
|
+
|
|
119
138
|
**Any framework / custom loop** — the base API:
|
|
120
139
|
|
|
121
140
|
```python
|
|
@@ -134,6 +153,17 @@ mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"
|
|
|
134
153
|
tools = await mcp.get_tools()
|
|
135
154
|
```
|
|
136
155
|
|
|
156
|
+
## Examples
|
|
157
|
+
|
|
158
|
+
Runnable agents in [`examples/`](examples/) — each governs the LLM and tool calls; the `*_mcp` variants add a Notion MCP server:
|
|
159
|
+
|
|
160
|
+
- LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
|
|
161
|
+
- Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
|
|
162
|
+
- OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
|
|
163
|
+
- Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)
|
|
164
|
+
|
|
165
|
+
See [`examples/README.md`](examples/README.md) for the env vars to run them.
|
|
166
|
+
|
|
137
167
|
## Management client
|
|
138
168
|
|
|
139
169
|
Provision agents and connections from your backend:
|
|
@@ -152,8 +182,10 @@ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="ht
|
|
|
152
182
|
|
|
153
183
|
## Documentation
|
|
154
184
|
|
|
155
|
-
-
|
|
156
|
-
-
|
|
185
|
+
- [Docs home](https://firstops.dev/docs)
|
|
186
|
+
- Guides: [LangChain / LangGraph](https://firstops.dev/docs/guides/langchain) · [Claude Agent SDK](https://firstops.dev/docs/guides/claude-sdk) · [OpenAI Agents SDK](https://firstops.dev/docs/guides/openai-agents) · [Google ADK](https://firstops.dev/docs/guides/google-adk)
|
|
187
|
+
- Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
|
|
188
|
+
- [Repository](https://github.com/firstops-dev/firstops-python)
|
|
157
189
|
|
|
158
190
|
## License
|
|
159
191
|
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# FirstOps Python SDK
|
|
2
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,
|
|
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, the OpenAI Agents SDK, and Google ADK, or any custom loop.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
4
6
|
|
|
5
7
|
```bash
|
|
6
|
-
pip install "firstops[langgraph]" #
|
|
8
|
+
pip install "firstops[langgraph]" # LangGraph + LangChain
|
|
9
|
+
pip install "firstops[claude]" # Claude Agent SDK
|
|
10
|
+
pip install "firstops[openai]" # OpenAI Agents SDK
|
|
11
|
+
pip install "firstops[adk]" # Google ADK
|
|
12
|
+
pip install "firstops[all]" # all of the above
|
|
13
|
+
pip install firstops # core only (management client / custom loops)
|
|
7
14
|
```
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
- Core deps: `cryptography`, `httpx`. Your agent framework comes in via the extra you pick.
|
|
16
|
+
Python 3.10+. Core deps are just `cryptography` and `httpx` — your agent framework comes in via the extra you pick.
|
|
11
17
|
|
|
12
18
|
---
|
|
13
19
|
|
|
@@ -70,6 +76,16 @@ guard = firstops_tool_input_guardrail(fo)
|
|
|
70
76
|
def send_email(to: str, body: str) -> str: ...
|
|
71
77
|
```
|
|
72
78
|
|
|
79
|
+
**Google ADK** — one `before_tool_callback` governs every tool (block + rewrite args):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from google.adk.agents import LlmAgent
|
|
83
|
+
from firstops.integrations.google_adk import firstops_before_tool_callback
|
|
84
|
+
|
|
85
|
+
agent = LlmAgent(name="assistant", model=..., tools=[...],
|
|
86
|
+
before_tool_callback=firstops_before_tool_callback(fo))
|
|
87
|
+
```
|
|
88
|
+
|
|
73
89
|
**Any framework / custom loop** — the base API:
|
|
74
90
|
|
|
75
91
|
```python
|
|
@@ -88,6 +104,17 @@ mcp = MultiServerMCPClient({"notion": {"url": firstops.mcp_url("<connection-id>"
|
|
|
88
104
|
tools = await mcp.get_tools()
|
|
89
105
|
```
|
|
90
106
|
|
|
107
|
+
## Examples
|
|
108
|
+
|
|
109
|
+
Runnable agents in [`examples/`](examples/) — each governs the LLM and tool calls; the `*_mcp` variants add a Notion MCP server:
|
|
110
|
+
|
|
111
|
+
- LangGraph — [`langgraph_basic.py`](examples/langgraph_basic.py), [`langgraph_notion_mcp.py`](examples/langgraph_notion_mcp.py)
|
|
112
|
+
- Claude Agent SDK — [`claude_sdk_basic.py`](examples/claude_sdk_basic.py), [`claude_sdk_mcp.py`](examples/claude_sdk_mcp.py)
|
|
113
|
+
- OpenAI Agents SDK — [`openai_agents_basic.py`](examples/openai_agents_basic.py), [`openai_agents_mcp.py`](examples/openai_agents_mcp.py)
|
|
114
|
+
- Google ADK — [`google_adk_basic.py`](examples/google_adk_basic.py)
|
|
115
|
+
|
|
116
|
+
See [`examples/README.md`](examples/README.md) for the env vars to run them.
|
|
117
|
+
|
|
91
118
|
## Management client
|
|
92
119
|
|
|
93
120
|
Provision agents and connections from your backend:
|
|
@@ -106,8 +133,10 @@ admin.connections.register(principal_id=agent.id, name="slack", upstream_url="ht
|
|
|
106
133
|
|
|
107
134
|
## Documentation
|
|
108
135
|
|
|
109
|
-
-
|
|
110
|
-
-
|
|
136
|
+
- [Docs home](https://firstops.dev/docs)
|
|
137
|
+
- Guides: [LangChain / LangGraph](https://firstops.dev/docs/guides/langchain) · [Claude Agent SDK](https://firstops.dev/docs/guides/claude-sdk) · [OpenAI Agents SDK](https://firstops.dev/docs/guides/openai-agents) · [Google ADK](https://firstops.dev/docs/guides/google-adk)
|
|
138
|
+
- Concepts: [Identity](https://firstops.dev/docs/concepts/identity) · [Enforcement](https://firstops.dev/docs/concepts/enforcement) · [Connections](https://firstops.dev/docs/concepts/connections)
|
|
139
|
+
- [Repository](https://github.com/firstops-dev/firstops-python)
|
|
111
140
|
|
|
112
141
|
## License
|
|
113
142
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Google ADK agent governed by FirstOps.
|
|
2
|
+
|
|
3
|
+
One `before_tool_callback` governs every tool call (block + rewrite args). The
|
|
4
|
+
LLM runs through the sidecar chain-link via LiteLLM pointed at OpenAI.
|
|
5
|
+
|
|
6
|
+
Run with the env vars in README.md (needs OPENAI_API_KEY).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import firstops
|
|
13
|
+
from firstops.integrations.google_adk import firstops_before_tool_callback
|
|
14
|
+
from google.adk.agents import LlmAgent
|
|
15
|
+
from google.adk.models.lite_llm import LiteLlm
|
|
16
|
+
from google.adk.runners import InMemoryRunner
|
|
17
|
+
from google.genai import types
|
|
18
|
+
|
|
19
|
+
from _shared import load_config, trace
|
|
20
|
+
|
|
21
|
+
APP = "firstops-demo"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_weather(city: str) -> dict:
|
|
25
|
+
"""Get the current weather for a city."""
|
|
26
|
+
print(f" [TOOL get_weather] city={city}")
|
|
27
|
+
return {"weather": f"21C and sunny in {city}"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send_email(to: str, body: str) -> dict:
|
|
31
|
+
"""Send an email to a recipient."""
|
|
32
|
+
print(f" [TOOL send_email] to={to} body={body!r}")
|
|
33
|
+
return {"status": "sent"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
cfg = load_config()
|
|
38
|
+
fo = firstops.init(
|
|
39
|
+
cfg["agent_id"], cfg["key_pem"], gateway_url=cfg["gateway"], port=cfg["port"]
|
|
40
|
+
)
|
|
41
|
+
trace(fo)
|
|
42
|
+
try:
|
|
43
|
+
agent = LlmAgent(
|
|
44
|
+
name="assistant",
|
|
45
|
+
model=LiteLlm(
|
|
46
|
+
model="openai/gpt-4o-mini",
|
|
47
|
+
api_base=firstops.llm_base_url("openai"), # -> sidecar chain-link
|
|
48
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
49
|
+
),
|
|
50
|
+
instruction="You are a helpful assistant.",
|
|
51
|
+
tools=[get_weather, send_email],
|
|
52
|
+
before_tool_callback=firstops_before_tool_callback(fo), # governs tools
|
|
53
|
+
)
|
|
54
|
+
runner = InMemoryRunner(agent=agent, app_name=APP)
|
|
55
|
+
session = await runner.session_service.create_session(app_name=APP, user_id="u1")
|
|
56
|
+
msg = types.Content(
|
|
57
|
+
role="user",
|
|
58
|
+
parts=[types.Part(text="Check the weather in Paris, then email it to alice@example.com.")],
|
|
59
|
+
)
|
|
60
|
+
print("\n>>> running Google ADK agent\n")
|
|
61
|
+
async for event in runner.run_async(
|
|
62
|
+
user_id="u1", session_id=session.id, new_message=msg
|
|
63
|
+
):
|
|
64
|
+
if event.content and event.content.parts:
|
|
65
|
+
for part in event.content.parts:
|
|
66
|
+
if getattr(part, "text", None) and part.text.strip():
|
|
67
|
+
print(f" [ADK] {part.text.strip()[:160]}")
|
|
68
|
+
finally:
|
|
69
|
+
firstops.shutdown()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
asyncio.run(main())
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "firstops"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, and
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Govern MCP, tool calls, and LLM traffic for AI agents — across LangGraph, Claude Agent SDK, OpenAI Agents, and Google ADK."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
]
|
|
27
27
|
keywords = [
|
|
28
28
|
"mcp", "dpop", "agent", "security", "governance", "llm",
|
|
29
|
-
"langgraph", "langchain", "openai-agents", "claude", "guardrails",
|
|
29
|
+
"langgraph", "langchain", "openai-agents", "claude", "google-adk", "guardrails",
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
32
|
"cryptography>=42.0",
|
|
@@ -54,6 +54,9 @@ claude = [
|
|
|
54
54
|
openai = [
|
|
55
55
|
"openai-agents",
|
|
56
56
|
]
|
|
57
|
+
adk = [
|
|
58
|
+
"google-adk[extensions]",
|
|
59
|
+
]
|
|
57
60
|
all = [
|
|
58
61
|
"langchain>=1.0",
|
|
59
62
|
"langgraph",
|
|
@@ -61,6 +64,7 @@ all = [
|
|
|
61
64
|
"langchain-mcp-adapters",
|
|
62
65
|
"claude-agent-sdk",
|
|
63
66
|
"openai-agents",
|
|
67
|
+
"google-adk[extensions]",
|
|
64
68
|
]
|
|
65
69
|
dev = [
|
|
66
70
|
"pytest>=8.0",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Google ADK adapter — `before_tool_callback` governs every tool call.
|
|
2
|
+
|
|
3
|
+
ADK's agent-level `before_tool_callback` can both **block** a tool (return a
|
|
4
|
+
result, which short-circuits the call) and **rewrite its args** (mutate the args
|
|
5
|
+
dict in place) — so FirstOps gets block and scrub on Google ADK.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from google.adk.agents import LlmAgent
|
|
10
|
+
from firstops.integrations.google_adk import firstops_before_tool_callback
|
|
11
|
+
|
|
12
|
+
agent = LlmAgent(
|
|
13
|
+
name="assistant",
|
|
14
|
+
model=...,
|
|
15
|
+
tools=[...],
|
|
16
|
+
before_tool_callback=firstops_before_tool_callback(fo),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
The callback is a plain function (ADK invokes it as
|
|
20
|
+
``callback(tool=, args=, tool_context=)``), so this adapter needs no ADK import.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from firstops import _runtime
|
|
28
|
+
from firstops.integrations._common import (
|
|
29
|
+
ACTION_DENY,
|
|
30
|
+
ACTION_MODIFY,
|
|
31
|
+
HARNESS_GOOGLE_ADK,
|
|
32
|
+
decide,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def firstops_before_tool_callback(fo=None):
|
|
37
|
+
"""Return a ``before_tool_callback`` that governs every tool call."""
|
|
38
|
+
rt = fo if fo is not None else _runtime.runtime()
|
|
39
|
+
|
|
40
|
+
def _before_tool(tool, args, tool_context) -> dict[str, Any] | None:
|
|
41
|
+
tool_name = getattr(tool, "name", "") or ""
|
|
42
|
+
action, payload = decide(
|
|
43
|
+
rt, tool_name, args, can_apply_modify=True, harness=HARNESS_GOOGLE_ADK
|
|
44
|
+
)
|
|
45
|
+
if action == ACTION_DENY:
|
|
46
|
+
# A non-None return short-circuits the tool; this becomes the result
|
|
47
|
+
# the model sees.
|
|
48
|
+
return {"status": "denied", "error": f"blocked by FirstOps policy: {payload}"}
|
|
49
|
+
if action == ACTION_MODIFY and isinstance(payload, dict) and isinstance(args, dict):
|
|
50
|
+
# Rewrite the call's args in place (full replacement with scrubbed input).
|
|
51
|
+
args.clear()
|
|
52
|
+
args.update(payload)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return _before_tool
|
|
@@ -44,6 +44,14 @@ _LLM_STRIP_HEADERS = frozenset(
|
|
|
44
44
|
}
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
# Response headers we must not forward back to the client: hop-by-hop, plus the
|
|
48
|
+
# ones BaseHTTPRequestHandler sets itself (Server/Date) — forwarding the
|
|
49
|
+
# upstream's copies duplicates them, which strict clients (aiohttp/litellm)
|
|
50
|
+
# reject with "Duplicate 'Server' header".
|
|
51
|
+
_RESP_STRIP_HEADERS = frozenset(
|
|
52
|
+
{"transfer-encoding", "connection", "server", "date"}
|
|
53
|
+
)
|
|
54
|
+
|
|
47
55
|
# Hard cap on a forwarded request body (defensive against a huge Content-Length).
|
|
48
56
|
_MAX_BODY_BYTES = 100 * 1024 * 1024
|
|
49
57
|
|
|
@@ -210,6 +218,12 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
|
|
|
210
218
|
client = httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0))
|
|
211
219
|
|
|
212
220
|
class ProxyHandler(BaseHTTPRequestHandler):
|
|
221
|
+
# HTTP/1.1 so strict clients (litellm/aiohttp, Node MCP) get clean
|
|
222
|
+
# keep-alive framing. Non-streaming responses carry an exact
|
|
223
|
+
# Content-Length (see _forward); streaming responses close the
|
|
224
|
+
# connection explicitly.
|
|
225
|
+
protocol_version = "HTTP/1.1"
|
|
226
|
+
|
|
213
227
|
def do_POST(self):
|
|
214
228
|
self._dispatch("POST")
|
|
215
229
|
|
|
@@ -264,6 +278,7 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
|
|
|
264
278
|
if denial is not None:
|
|
265
279
|
self.send_response(403)
|
|
266
280
|
self.send_header("Content-Type", "application/json")
|
|
281
|
+
self.send_header("Content-Length", str(len(denial)))
|
|
267
282
|
self.end_headers()
|
|
268
283
|
self.wfile.write(denial)
|
|
269
284
|
return
|
|
@@ -364,20 +379,39 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
|
|
|
364
379
|
method, url, headers=headers, content=body,
|
|
365
380
|
timeout=httpx.Timeout(120.0, connect=10.0),
|
|
366
381
|
) as resp:
|
|
382
|
+
is_stream = "text/event-stream" in resp.headers.get("content-type", "")
|
|
383
|
+
|
|
384
|
+
if is_stream:
|
|
385
|
+
# Streaming (SSE): pass bytes through raw with flushing so
|
|
386
|
+
# events arrive live. No Content-Length, so close the
|
|
387
|
+
# connection to delimit the body under HTTP/1.1.
|
|
388
|
+
self.close_connection = True
|
|
389
|
+
self.send_response(resp.status_code)
|
|
390
|
+
for k, v in resp.headers.items():
|
|
391
|
+
if k.lower() not in _RESP_STRIP_HEADERS:
|
|
392
|
+
self.send_header(k, v)
|
|
393
|
+
self.send_header("Connection", "close")
|
|
394
|
+
self.end_headers()
|
|
395
|
+
for chunk in resp.iter_raw():
|
|
396
|
+
self.wfile.write(chunk)
|
|
397
|
+
self.wfile.flush()
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Non-streaming: buffer the DECODED body and send it with an
|
|
401
|
+
# exact Content-Length, dropping Content-Encoding/Length from
|
|
402
|
+
# upstream. This gives a cleanly-framed response that ANY
|
|
403
|
+
# client reads correctly (httpx, aiohttp, Node) — a
|
|
404
|
+
# close-delimited gzipped body trips stricter clients.
|
|
405
|
+
payload = resp.read() # httpx auto-decompresses
|
|
367
406
|
self.send_response(resp.status_code)
|
|
368
407
|
for k, v in resp.headers.items():
|
|
369
|
-
if k.lower() not in (
|
|
408
|
+
if k.lower() not in _RESP_STRIP_HEADERS and k.lower() not in (
|
|
409
|
+
"content-encoding", "content-length",
|
|
410
|
+
):
|
|
370
411
|
self.send_header(k, v)
|
|
412
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
371
413
|
self.end_headers()
|
|
372
|
-
|
|
373
|
-
# Forward the body RAW: httpx auto-decompresses iter_bytes()/
|
|
374
|
-
# read(), but we keep the upstream Content-Encoding/Length
|
|
375
|
-
# headers, so we must pass the original (possibly gzipped)
|
|
376
|
-
# bytes through untouched or the client's decode fails.
|
|
377
|
-
# Flush per chunk so SSE/streaming responses arrive live.
|
|
378
|
-
for chunk in resp.iter_raw():
|
|
379
|
-
self.wfile.write(chunk)
|
|
380
|
-
self.wfile.flush()
|
|
414
|
+
self.wfile.write(payload)
|
|
381
415
|
except httpx.HTTPError as e:
|
|
382
416
|
logger.error("upstream request failed: %s", e)
|
|
383
417
|
self.send_error(502, "upstream request failed")
|
|
@@ -386,10 +420,12 @@ def _make_handler(identity: Identity, local_port: int, enforcement, llm_upstream
|
|
|
386
420
|
"""Stream an SSE response, rewriting gateway URLs to localhost."""
|
|
387
421
|
try:
|
|
388
422
|
with httpx.stream("GET", url, headers=headers, timeout=None) as resp:
|
|
423
|
+
self.close_connection = True # SSE: no length, close-delimited
|
|
389
424
|
self.send_response(resp.status_code)
|
|
390
425
|
for k, v in resp.headers.items():
|
|
391
|
-
if k.lower() not in
|
|
426
|
+
if k.lower() not in _RESP_STRIP_HEADERS:
|
|
392
427
|
self.send_header(k, v)
|
|
428
|
+
self.send_header("Connection", "close")
|
|
393
429
|
self.end_headers()
|
|
394
430
|
|
|
395
431
|
gateway_msg_url = gateway + "/mcp/sse/message"
|
|
@@ -181,3 +181,50 @@ from firstops.integrations import langgraph
|
|
|
181
181
|
def test_langgraph_middleware_requires_langchain():
|
|
182
182
|
with pytest.raises(RuntimeError, match="langchain"):
|
|
183
183
|
langgraph.FirstOpsMiddleware(_rt(Decision(action="allow")))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---- Google ADK adapter ----------------------------------------------------
|
|
187
|
+
|
|
188
|
+
from firstops.integrations import google_adk
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _AdkTool:
|
|
192
|
+
def __init__(self, name):
|
|
193
|
+
self.name = name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_adk_allow_returns_none_and_stamps_harness():
|
|
197
|
+
rt = _rt(Decision(action="allow"))
|
|
198
|
+
cb = google_adk.firstops_before_tool_callback(rt)
|
|
199
|
+
args = {"city": "Paris"}
|
|
200
|
+
assert cb(tool=_AdkTool("get_weather"), args=args, tool_context=None) is None
|
|
201
|
+
assert args == {"city": "Paris"} # unchanged
|
|
202
|
+
ev = rt.enforcement.events[0]
|
|
203
|
+
assert ev.tool_name == "get_weather"
|
|
204
|
+
assert ev.metadata == {"harness": "google-adk"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_adk_deny_short_circuits_with_result_dict():
|
|
208
|
+
rt = _rt(Decision(action="deny", reason="destructive"))
|
|
209
|
+
out = google_adk.firstops_before_tool_callback(rt)(
|
|
210
|
+
tool=_AdkTool("run_shell"), args={"cmd": "x"}, tool_context=None
|
|
211
|
+
)
|
|
212
|
+
assert out is not None # non-None return blocks the tool
|
|
213
|
+
assert out["status"] == "denied"
|
|
214
|
+
assert "destructive" in out["error"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_adk_modify_rewrites_args_in_place():
|
|
218
|
+
rt = _rt(_modify({"to": "[REDACTED]"}))
|
|
219
|
+
args = {"to": "secret@example.com"}
|
|
220
|
+
out = google_adk.firstops_before_tool_callback(rt)(
|
|
221
|
+
tool=_AdkTool("send_email"), args=args, tool_context=None
|
|
222
|
+
)
|
|
223
|
+
assert out is None # proceed
|
|
224
|
+
assert args == {"to": "[REDACTED]"} # rewritten in place
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_adk_adapter_needs_no_framework():
|
|
228
|
+
# The callback is a plain function — constructing it must not require ADK.
|
|
229
|
+
cb = google_adk.firstops_before_tool_callback(_rt(Decision(action="allow")))
|
|
230
|
+
assert callable(cb)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|