langchain-sealkeeper 0.1.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.
- langchain_sealkeeper-0.1.0/.gitignore +6 -0
- langchain_sealkeeper-0.1.0/LICENSE +21 -0
- langchain_sealkeeper-0.1.0/PKG-INFO +110 -0
- langchain_sealkeeper-0.1.0/README.md +89 -0
- langchain_sealkeeper-0.1.0/pyproject.toml +65 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/__init__.py +14 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/_shared.py +25 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/_version.py +1 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/interceptor.py +65 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/middleware.py +89 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/py.typed +0 -0
- langchain_sealkeeper-0.1.0/src/langchain_sealkeeper/tool.py +78 -0
- langchain_sealkeeper-0.1.0/tests/conftest.py +26 -0
- langchain_sealkeeper-0.1.0/tests/harness.py +273 -0
- langchain_sealkeeper-0.1.0/tests/test_interceptor.py +168 -0
- langchain_sealkeeper-0.1.0/tests/test_middleware.py +162 -0
- langchain_sealkeeper-0.1.0/tests/test_standard.py +33 -0
- langchain_sealkeeper-0.1.0/tests/test_tool.py +178 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The SealKeeper Authors
|
|
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.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-sealkeeper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools and gates that check a SealKeeper SEAL before an agent calls an agent or an MCP server.
|
|
5
|
+
Project-URL: Homepage, https://sealkeeper.run
|
|
6
|
+
Project-URL: SEAL Standard, https://sealkeeper.run/seal/standard
|
|
7
|
+
Project-URL: Repository, https://github.com/sealkeeper-dev/langchain-sealkeeper
|
|
8
|
+
Project-URL: Issues, https://github.com/sealkeeper-dev/langchain-sealkeeper/issues
|
|
9
|
+
Author: The SealKeeper Authors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: langchain-core<2,>=1.7.3
|
|
18
|
+
Requires-Dist: langchain<2,>=1.4.7
|
|
19
|
+
Requires-Dist: sealkeeper<0.2,>=0.1.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# langchain-sealkeeper
|
|
23
|
+
|
|
24
|
+
Check an AI agent or MCP server on [SealKeeper](https://sealkeeper.run) from LangChain, before your agent calls it.
|
|
25
|
+
|
|
26
|
+
A SEAL is SealKeeper's signed credential for an agent or a service. This package wires the [`sealkeeper`](https://pypi.org/project/sealkeeper/) library into LangChain. Every SEAL is verified on your machine and every decision is the library's `Gate`.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install langchain-sealkeeper
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Python 3.10 or later. It depends on `sealkeeper`, `langchain-core` and `langchain`.
|
|
35
|
+
|
|
36
|
+
## Look up
|
|
37
|
+
|
|
38
|
+
`SealKeeperLookupTool` lets the model look a target up.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from langchain.agents import create_agent
|
|
42
|
+
from langchain_sealkeeper import SealKeeperLookupTool
|
|
43
|
+
|
|
44
|
+
agent = create_agent(model, tools=[SealKeeperLookupTool()])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The tool is `sealkeeper_lookup`, with one argument, `target`. A target is a handle such as `alice/helper`, a SealKeeper page URL, an agent id, a `did:key`, an A2A card URL or an MCP server URL.
|
|
48
|
+
|
|
49
|
+
The model reads one line, such as `alice/helper: verified agent SEAL, level bronze, Trust Score 412.` or `alice/helper: no verified SEAL (broken, bad_signature).`
|
|
50
|
+
|
|
51
|
+
The `ToolMessage` artifact is the lookup as a dict of JSON values, so a checkpointer keeps it as it is. `verified` says whether the SEAL verified, and `seal` holds it when it did. Without one, `code` says why, as in the `sealkeeper` library's `Lookup`. A broken SEAL reads `verified` false and no level, kind or Trust Score.
|
|
52
|
+
|
|
53
|
+
A lookup that fails, such as SealKeeper not answering in time, answers an error `ToolMessage` with the reason.
|
|
54
|
+
|
|
55
|
+
## Gate an MCP server
|
|
56
|
+
|
|
57
|
+
`SealGateInterceptor` checks an MCP server's SEAL before each call to one of its tools. It needs `langchain-mcp-adapters`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
61
|
+
from langchain_sealkeeper import SealGateInterceptor
|
|
62
|
+
|
|
63
|
+
connections = {
|
|
64
|
+
"weather": {"transport": "streamable_http", "url": "https://mcp.example.com/mcp"},
|
|
65
|
+
"notes": {"transport": "stdio", "command": "notes-server", "args": []},
|
|
66
|
+
}
|
|
67
|
+
gate = SealGateInterceptor(connections, ["weather"])
|
|
68
|
+
client = MultiServerMCPClient(connections, tool_interceptors=[gate])
|
|
69
|
+
tools = await client.get_tools()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pass it the same `connections` as the client. It reads each server's `url` from them at every call, as a service. Load the tools with `client.get_tools()`, or with `load_mcp_tools(session, server_name=...)` and the connection's name, since a tool loaded without its server's name is not gated.
|
|
73
|
+
|
|
74
|
+
- A pass calls the tool.
|
|
75
|
+
- A block answers an error `ToolMessage` with the code and the reason, such as `SealKeeper blocked this call: not_found. SealKeeper knows no agent or service by that target`. The tool is not called and the agent's run goes on. The message has no `name`, and outside a LangGraph `ToolNode` its `tool_call_id` is empty.
|
|
76
|
+
- A server you do not name passes untouched.
|
|
77
|
+
- A stdio server has no URL to check, so naming one raises `ValueError`.
|
|
78
|
+
|
|
79
|
+
## Gate a remote agent
|
|
80
|
+
|
|
81
|
+
`SealGateMiddleware` checks a target's SEAL before each call to a tool that calls it, such as a tool that sends a task to a remote A2A agent.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from langchain.agents import create_agent
|
|
85
|
+
from langchain_sealkeeper import SealGateMiddleware, SealTarget
|
|
86
|
+
|
|
87
|
+
gate = SealGateMiddleware({
|
|
88
|
+
"ask_helper": SealTarget("agent", "alice/helper"),
|
|
89
|
+
"fetch_report": SealTarget("service", "https://mcp.example.com/mcp"),
|
|
90
|
+
})
|
|
91
|
+
agent = create_agent(model, tools=[ask_helper, fetch_report, search], middleware=[gate])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Each pinned tool name maps to a `SealTarget(kind, ref)`. `kind` is `"agent"` or `"service"`, and `ref` is any target the lookup takes. A block answers an error `ToolMessage` with the code and the reason, the tool is not called and the run goes on. A tool you do not pin passes untouched. It works with `invoke` and `ainvoke`.
|
|
95
|
+
|
|
96
|
+
## The rules
|
|
97
|
+
|
|
98
|
+
Both gates decide with a `sealkeeper.Gate` and its default rules. A pass needs a current, valid SEAL of the kind at bronze or above, and for a card or MCP server URL the SEAL must name that URL.
|
|
99
|
+
|
|
100
|
+
`allow_unsealed=True` on either gate also lets through a target SealKeeper does not know, one it is still checking and one with no SEAL while it is dormant. A withheld or broken SEAL, a delisted target and SealKeeper unavailable still block.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
gate = SealGateInterceptor(connections, ["weather"], allow_unsealed=True)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The block codes are in the [`sealkeeper` README](https://pypi.org/project/sealkeeper/). Each decision writes one INFO line to the `sealkeeper` logger, never the SEAL, a full URL or the tool's arguments.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# langchain-sealkeeper
|
|
2
|
+
|
|
3
|
+
Check an AI agent or MCP server on [SealKeeper](https://sealkeeper.run) from LangChain, before your agent calls it.
|
|
4
|
+
|
|
5
|
+
A SEAL is SealKeeper's signed credential for an agent or a service. This package wires the [`sealkeeper`](https://pypi.org/project/sealkeeper/) library into LangChain. Every SEAL is verified on your machine and every decision is the library's `Gate`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install langchain-sealkeeper
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Python 3.10 or later. It depends on `sealkeeper`, `langchain-core` and `langchain`.
|
|
14
|
+
|
|
15
|
+
## Look up
|
|
16
|
+
|
|
17
|
+
`SealKeeperLookupTool` lets the model look a target up.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from langchain.agents import create_agent
|
|
21
|
+
from langchain_sealkeeper import SealKeeperLookupTool
|
|
22
|
+
|
|
23
|
+
agent = create_agent(model, tools=[SealKeeperLookupTool()])
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The tool is `sealkeeper_lookup`, with one argument, `target`. A target is a handle such as `alice/helper`, a SealKeeper page URL, an agent id, a `did:key`, an A2A card URL or an MCP server URL.
|
|
27
|
+
|
|
28
|
+
The model reads one line, such as `alice/helper: verified agent SEAL, level bronze, Trust Score 412.` or `alice/helper: no verified SEAL (broken, bad_signature).`
|
|
29
|
+
|
|
30
|
+
The `ToolMessage` artifact is the lookup as a dict of JSON values, so a checkpointer keeps it as it is. `verified` says whether the SEAL verified, and `seal` holds it when it did. Without one, `code` says why, as in the `sealkeeper` library's `Lookup`. A broken SEAL reads `verified` false and no level, kind or Trust Score.
|
|
31
|
+
|
|
32
|
+
A lookup that fails, such as SealKeeper not answering in time, answers an error `ToolMessage` with the reason.
|
|
33
|
+
|
|
34
|
+
## Gate an MCP server
|
|
35
|
+
|
|
36
|
+
`SealGateInterceptor` checks an MCP server's SEAL before each call to one of its tools. It needs `langchain-mcp-adapters`.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
40
|
+
from langchain_sealkeeper import SealGateInterceptor
|
|
41
|
+
|
|
42
|
+
connections = {
|
|
43
|
+
"weather": {"transport": "streamable_http", "url": "https://mcp.example.com/mcp"},
|
|
44
|
+
"notes": {"transport": "stdio", "command": "notes-server", "args": []},
|
|
45
|
+
}
|
|
46
|
+
gate = SealGateInterceptor(connections, ["weather"])
|
|
47
|
+
client = MultiServerMCPClient(connections, tool_interceptors=[gate])
|
|
48
|
+
tools = await client.get_tools()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Pass it the same `connections` as the client. It reads each server's `url` from them at every call, as a service. Load the tools with `client.get_tools()`, or with `load_mcp_tools(session, server_name=...)` and the connection's name, since a tool loaded without its server's name is not gated.
|
|
52
|
+
|
|
53
|
+
- A pass calls the tool.
|
|
54
|
+
- A block answers an error `ToolMessage` with the code and the reason, such as `SealKeeper blocked this call: not_found. SealKeeper knows no agent or service by that target`. The tool is not called and the agent's run goes on. The message has no `name`, and outside a LangGraph `ToolNode` its `tool_call_id` is empty.
|
|
55
|
+
- A server you do not name passes untouched.
|
|
56
|
+
- A stdio server has no URL to check, so naming one raises `ValueError`.
|
|
57
|
+
|
|
58
|
+
## Gate a remote agent
|
|
59
|
+
|
|
60
|
+
`SealGateMiddleware` checks a target's SEAL before each call to a tool that calls it, such as a tool that sends a task to a remote A2A agent.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from langchain.agents import create_agent
|
|
64
|
+
from langchain_sealkeeper import SealGateMiddleware, SealTarget
|
|
65
|
+
|
|
66
|
+
gate = SealGateMiddleware({
|
|
67
|
+
"ask_helper": SealTarget("agent", "alice/helper"),
|
|
68
|
+
"fetch_report": SealTarget("service", "https://mcp.example.com/mcp"),
|
|
69
|
+
})
|
|
70
|
+
agent = create_agent(model, tools=[ask_helper, fetch_report, search], middleware=[gate])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each pinned tool name maps to a `SealTarget(kind, ref)`. `kind` is `"agent"` or `"service"`, and `ref` is any target the lookup takes. A block answers an error `ToolMessage` with the code and the reason, the tool is not called and the run goes on. A tool you do not pin passes untouched. It works with `invoke` and `ainvoke`.
|
|
74
|
+
|
|
75
|
+
## The rules
|
|
76
|
+
|
|
77
|
+
Both gates decide with a `sealkeeper.Gate` and its default rules. A pass needs a current, valid SEAL of the kind at bronze or above, and for a card or MCP server URL the SEAL must name that URL.
|
|
78
|
+
|
|
79
|
+
`allow_unsealed=True` on either gate also lets through a target SealKeeper does not know, one it is still checking and one with no SEAL while it is dormant. A withheld or broken SEAL, a delisted target and SealKeeper unavailable still block.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
gate = SealGateInterceptor(connections, ["weather"], allow_unsealed=True)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The block codes are in the [`sealkeeper` README](https://pypi.org/project/sealkeeper/). Each decision writes one INFO line to the `sealkeeper` logger, never the SEAL, a full URL or the tool's arguments.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "langchain-sealkeeper"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "LangChain tools and gates that check a SealKeeper SEAL before an agent calls an agent or an MCP server."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "The SealKeeper Authors" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"sealkeeper>=0.1.0,<0.2",
|
|
12
|
+
"langchain-core>=1.7.3,<2",
|
|
13
|
+
"langchain>=1.4.7,<2",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://sealkeeper.run"
|
|
24
|
+
"SEAL Standard" = "https://sealkeeper.run/seal/standard"
|
|
25
|
+
Repository = "https://github.com/sealkeeper-dev/langchain-sealkeeper"
|
|
26
|
+
Issues = "https://github.com/sealkeeper-dev/langchain-sealkeeper/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling>=1.27"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "src/langchain_sealkeeper/_version.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/langchain_sealkeeper"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
only-include = ["src/langchain_sealkeeper", "tests", "README.md", "LICENSE"]
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
# langchain-tests and langchain-mcp-adapters are for the tests only.
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.3",
|
|
45
|
+
"ruff>=0.14",
|
|
46
|
+
"langchain-tests>=1.1.9",
|
|
47
|
+
"langchain-mcp-adapters>=0.3.2",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
line-length = 88
|
|
53
|
+
include = ["*.py"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.isort]
|
|
59
|
+
known-first-party = ["langchain_sealkeeper", "harness"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
# Async tests run on anyio. pytest-asyncio comes with langchain-tests and
|
|
64
|
+
# stays off.
|
|
65
|
+
addopts = "--strict-markers -p no:asyncio"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""LangChain tools and gates on SealKeeper SEALs."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .interceptor import SealGateInterceptor
|
|
5
|
+
from .middleware import SealGateMiddleware, SealTarget
|
|
6
|
+
from .tool import SealKeeperLookupTool
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SealGateInterceptor",
|
|
10
|
+
"SealGateMiddleware",
|
|
11
|
+
"SealKeeperLookupTool",
|
|
12
|
+
"SealTarget",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""What the tool and both gates share. The User-Agent suffix, the gate both
|
|
2
|
+
gates decide with and the line a block answers with."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from sealkeeper import AsyncSealKeeper, Block, Gate, SealKeeper
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
USER_AGENT_SUFFIX = f"langchain-sealkeeper/{__version__}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_gate(allow_unsealed: bool) -> Gate:
|
|
14
|
+
"""A Gate with its default rules and allow_unsealed, whose lookups name
|
|
15
|
+
this package in their User-Agent."""
|
|
16
|
+
return Gate(
|
|
17
|
+
allow_unsealed=allow_unsealed,
|
|
18
|
+
client=SealKeeper(user_agent_suffix=USER_AGENT_SUFFIX),
|
|
19
|
+
async_client=AsyncSealKeeper(user_agent_suffix=USER_AGENT_SUFFIX),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def blocked_text(block: Block) -> str:
|
|
24
|
+
"""The error text of a blocked call, its code and its reason."""
|
|
25
|
+
return f"SealKeeper blocked this call: {block.code}. {block.reason}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SealGateInterceptor, a tool interceptor for langchain-mcp-adapters that
|
|
2
|
+
gates the tools of named MCP servers on each server's SEAL."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import ToolMessage
|
|
10
|
+
|
|
11
|
+
from ._shared import blocked_text, default_gate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SealGateInterceptor:
|
|
15
|
+
"""Checks a server's SEAL before each call to one of its tools.
|
|
16
|
+
|
|
17
|
+
Pass it as MultiServerMCPClient(connections, tool_interceptors=[gate]),
|
|
18
|
+
with the same connections. servers names the servers to gate. Each call
|
|
19
|
+
to one of their tools asks the gate about that server's url, read from
|
|
20
|
+
connections at the call, as a service. A pass calls the tool. A block
|
|
21
|
+
answers an error ToolMessage with the code and the reason and never
|
|
22
|
+
calls it, so the agent's run goes on. That ToolMessage has no name,
|
|
23
|
+
and its tool_call_id is the one LangGraph's ToolNode passes the tool,
|
|
24
|
+
empty for a call made outside one. A server not named passes untouched.
|
|
25
|
+
A stdio server has no URL to check and is refused. The gate sees a
|
|
26
|
+
tool's server only when the adapter loaded it with the server's name,
|
|
27
|
+
as client.get_tools() does. allow_unsealed also passes a server
|
|
28
|
+
SealKeeper does not know, one it is still checking and one with no SEAL
|
|
29
|
+
for dormancy.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
connections: Mapping[str, Mapping[str, Any]],
|
|
35
|
+
servers: Iterable[str],
|
|
36
|
+
*,
|
|
37
|
+
allow_unsealed: bool = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
if isinstance(servers, str):
|
|
40
|
+
raise TypeError("servers must be a list of server names")
|
|
41
|
+
names = tuple(servers)
|
|
42
|
+
for name in names:
|
|
43
|
+
connection = connections.get(name)
|
|
44
|
+
if connection is None:
|
|
45
|
+
raise ValueError(f"no connection is named {name!r}")
|
|
46
|
+
if connection.get("transport") == "stdio":
|
|
47
|
+
raise ValueError(f"{name!r} is a stdio server, with no URL to check")
|
|
48
|
+
self._connections = connections
|
|
49
|
+
self._servers = frozenset(names)
|
|
50
|
+
self._gate = default_gate(allow_unsealed)
|
|
51
|
+
|
|
52
|
+
async def __call__(
|
|
53
|
+
self, request: Any, handler: Callable[[Any], Awaitable[Any]]
|
|
54
|
+
) -> Any:
|
|
55
|
+
if request.server_name not in self._servers:
|
|
56
|
+
return await handler(request)
|
|
57
|
+
connection = self._connections.get(request.server_name) or {}
|
|
58
|
+
decision = await self._gate.acheck(connection.get("url"), "service")
|
|
59
|
+
if decision.passed:
|
|
60
|
+
return await handler(request)
|
|
61
|
+
return ToolMessage(
|
|
62
|
+
content=blocked_text(decision),
|
|
63
|
+
tool_call_id=getattr(request.runtime, "tool_call_id", None) or "",
|
|
64
|
+
status="error",
|
|
65
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""SealGateMiddleware, agent middleware that gates the tools which call a
|
|
2
|
+
remote agent or service on that target's SEAL."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
|
|
10
|
+
from langchain.agents.middleware import AgentMiddleware, ToolCallRequest
|
|
11
|
+
from langchain_core.messages import ToolMessage
|
|
12
|
+
from sealkeeper import Block
|
|
13
|
+
|
|
14
|
+
from ._shared import blocked_text, default_gate
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
# The type langchain's own hooks answer with.
|
|
18
|
+
from langgraph.types import Command
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SealTarget:
|
|
23
|
+
"""What a gated tool calls. kind is agent or service, ref the target to
|
|
24
|
+
look up, such as a handle, a card URL or an MCP server URL."""
|
|
25
|
+
|
|
26
|
+
kind: Literal["agent", "service"]
|
|
27
|
+
ref: str
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if self.kind not in ("agent", "service"):
|
|
31
|
+
raise ValueError('kind must be "agent" or "service"')
|
|
32
|
+
if not isinstance(self.ref, str):
|
|
33
|
+
raise TypeError("ref must be a string")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SealGateMiddleware(AgentMiddleware):
|
|
37
|
+
"""Checks a target's SEAL before each call to a tool pinned to it.
|
|
38
|
+
|
|
39
|
+
targets maps a tool name to the SealTarget it calls. A pass calls the
|
|
40
|
+
tool. A block answers an error ToolMessage with the code and the reason
|
|
41
|
+
and never calls it, so the agent's run goes on. A tool not pinned passes
|
|
42
|
+
untouched. allow_unsealed also passes a target SealKeeper does not
|
|
43
|
+
know, one it is still checking and one with no SEAL for dormancy.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, targets: Mapping[str, SealTarget], *, allow_unsealed: bool = False
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
for target in targets.values():
|
|
51
|
+
if not isinstance(target, SealTarget):
|
|
52
|
+
raise TypeError("each target must be a SealTarget")
|
|
53
|
+
self._targets = dict(targets)
|
|
54
|
+
self._gate = default_gate(allow_unsealed)
|
|
55
|
+
|
|
56
|
+
def wrap_tool_call(
|
|
57
|
+
self,
|
|
58
|
+
request: ToolCallRequest,
|
|
59
|
+
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
|
60
|
+
) -> ToolMessage | Command[Any]:
|
|
61
|
+
target = self._targets.get(request.tool_call["name"])
|
|
62
|
+
if target is None:
|
|
63
|
+
return handler(request)
|
|
64
|
+
decision = self._gate.check(target.ref, target.kind)
|
|
65
|
+
if decision.passed:
|
|
66
|
+
return handler(request)
|
|
67
|
+
return _blocked(request, decision)
|
|
68
|
+
|
|
69
|
+
async def awrap_tool_call(
|
|
70
|
+
self,
|
|
71
|
+
request: ToolCallRequest,
|
|
72
|
+
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
|
73
|
+
) -> ToolMessage | Command[Any]:
|
|
74
|
+
target = self._targets.get(request.tool_call["name"])
|
|
75
|
+
if target is None:
|
|
76
|
+
return await handler(request)
|
|
77
|
+
decision = await self._gate.acheck(target.ref, target.kind)
|
|
78
|
+
if decision.passed:
|
|
79
|
+
return await handler(request)
|
|
80
|
+
return _blocked(request, decision)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _blocked(request: ToolCallRequest, block: Block) -> ToolMessage:
|
|
84
|
+
return ToolMessage(
|
|
85
|
+
content=blocked_text(block),
|
|
86
|
+
name=request.tool_call["name"],
|
|
87
|
+
tool_call_id=request.tool_call["id"],
|
|
88
|
+
status="error",
|
|
89
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""SealKeeperLookupTool, a LangChain tool that looks a target up on
|
|
2
|
+
SealKeeper and verifies its SEAL."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from langchain_core.tools import BaseTool, ToolException
|
|
10
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
11
|
+
from sealkeeper import AsyncSealKeeper, Lookup, SealKeeper, SealKeeperError
|
|
12
|
+
|
|
13
|
+
from ._shared import USER_AGENT_SUFFIX
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LookupInput(BaseModel):
|
|
17
|
+
target: str = Field(
|
|
18
|
+
description=(
|
|
19
|
+
"The agent or service to look up. A handle such as alice/helper, "
|
|
20
|
+
"a SealKeeper page URL, an agent id, a did:key, an A2A card URL "
|
|
21
|
+
"or an MCP server URL."
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SealKeeperLookupTool(BaseTool):
|
|
27
|
+
"""Looks a target up on SealKeeper and verifies its SEAL.
|
|
28
|
+
|
|
29
|
+
The model reads one short line. The artifact is the lookup as a dict
|
|
30
|
+
that holds only JSON values, so a checkpointer stores it as it is.
|
|
31
|
+
level, kind and trust come only from a SEAL that verified, so a broken
|
|
32
|
+
SEAL reads verified false and none of them. A lookup that fails answers
|
|
33
|
+
an error ToolMessage with SealKeeper's line.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str = "sealkeeper_lookup"
|
|
37
|
+
description: str = (
|
|
38
|
+
"Look up an AI agent or service on SealKeeper and verify its SEAL, "
|
|
39
|
+
"SealKeeper's signed credential. Answers whether a SEAL verified, "
|
|
40
|
+
"and its kind, level and Trust Score when one did."
|
|
41
|
+
)
|
|
42
|
+
args_schema: type[BaseModel] = LookupInput
|
|
43
|
+
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
|
44
|
+
handle_tool_error: bool = True
|
|
45
|
+
_client: SealKeeper | None = PrivateAttr(default=None)
|
|
46
|
+
_async_client: AsyncSealKeeper | None = PrivateAttr(default=None)
|
|
47
|
+
|
|
48
|
+
def _run(self, target: str) -> tuple[str, dict[str, Any]]:
|
|
49
|
+
if self._client is None:
|
|
50
|
+
self._client = SealKeeper(user_agent_suffix=USER_AGENT_SUFFIX)
|
|
51
|
+
try:
|
|
52
|
+
found = self._client.lookup(target)
|
|
53
|
+
except SealKeeperError as error:
|
|
54
|
+
raise ToolException(str(error)) from None
|
|
55
|
+
return line_of(target, found), artifact_of(found)
|
|
56
|
+
|
|
57
|
+
async def _arun(self, target: str) -> tuple[str, dict[str, Any]]:
|
|
58
|
+
if self._async_client is None:
|
|
59
|
+
self._async_client = AsyncSealKeeper(user_agent_suffix=USER_AGENT_SUFFIX)
|
|
60
|
+
try:
|
|
61
|
+
found = await self._async_client.lookup(target)
|
|
62
|
+
except SealKeeperError as error:
|
|
63
|
+
raise ToolException(str(error)) from None
|
|
64
|
+
return line_of(target, found), artifact_of(found)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def line_of(target: str, found: Lookup) -> str:
|
|
68
|
+
"""The one line the model reads."""
|
|
69
|
+
if found.verified:
|
|
70
|
+
trust = "" if found.trust is None else f", Trust Score {found.trust}"
|
|
71
|
+
return f"{target}: verified {found.kind} SEAL, level {found.level}{trust}."
|
|
72
|
+
why = found.code if found.broken is None else f"{found.code}, {found.broken}"
|
|
73
|
+
return f"{target}: no verified SEAL ({why})."
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def artifact_of(found: Lookup) -> dict[str, Any]:
|
|
77
|
+
"""The lookup as a dict of JSON values, with verified beside its fields."""
|
|
78
|
+
return {"verified": found.verified, **dataclasses.asdict(found)}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from harness import RecordedSealKeeper, async_client, sync_client
|
|
4
|
+
from langchain_sealkeeper import _shared as shared
|
|
5
|
+
from langchain_sealkeeper import tool as tool_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Async tests run on asyncio, the loop httpx and LangChain use by default.
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def anyio_backend() -> str:
|
|
11
|
+
return "asyncio"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def sealkeeper(monkeypatch) -> RecordedSealKeeper:
|
|
16
|
+
"""The recorded SealKeeper behind every client the package makes, so no
|
|
17
|
+
test reaches the network."""
|
|
18
|
+
server = RecordedSealKeeper()
|
|
19
|
+
for module in (shared, tool_module):
|
|
20
|
+
monkeypatch.setattr(
|
|
21
|
+
module, "SealKeeper", lambda **kw: sync_client(server, **kw)
|
|
22
|
+
)
|
|
23
|
+
monkeypatch.setattr(
|
|
24
|
+
module, "AsyncSealKeeper", lambda **kw: async_client(server, **kw)
|
|
25
|
+
)
|
|
26
|
+
return server
|