kobil-aitrust 1.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.
- kobil_aitrust-1.2.0/.gitignore +9 -0
- kobil_aitrust-1.2.0/LICENSE +128 -0
- kobil_aitrust-1.2.0/PKG-INFO +66 -0
- kobil_aitrust-1.2.0/README.md +41 -0
- kobil_aitrust-1.2.0/kobil_aitrust/__init__.py +88 -0
- kobil_aitrust-1.2.0/kobil_aitrust/_mcp_bridge.py +219 -0
- kobil_aitrust-1.2.0/kobil_aitrust/config.py +123 -0
- kobil_aitrust-1.2.0/kobil_aitrust/define_tool.py +150 -0
- kobil_aitrust-1.2.0/kobil_aitrust/errors.py +118 -0
- kobil_aitrust-1.2.0/kobil_aitrust/integrations.py +41 -0
- kobil_aitrust-1.2.0/kobil_aitrust/mcp_broker.py +355 -0
- kobil_aitrust-1.2.0/kobil_aitrust/mcp_local.py +381 -0
- kobil_aitrust-1.2.0/kobil_aitrust/mcp_managed.py +220 -0
- kobil_aitrust-1.2.0/kobil_aitrust/trust.py +1148 -0
- kobil_aitrust-1.2.0/pyproject.toml +45 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
KOBIL Agent Trust SDK License
|
|
2
|
+
Version 1.0
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 KOBIL GmbH. All rights reserved.
|
|
5
|
+
The Software is developed by the KOBIL AI Team.
|
|
6
|
+
|
|
7
|
+
In short: you may install and use the Software free of charge, also in
|
|
8
|
+
commercial products, and ship it inside your own application. You may not
|
|
9
|
+
change it, copy it into other software, or pass it on or sell it on its
|
|
10
|
+
own. The numbered terms below are the license; this summary is not.
|
|
11
|
+
|
|
12
|
+
1. Definitions
|
|
13
|
+
|
|
14
|
+
"The Software" is the code and documentation in this package.
|
|
15
|
+
|
|
16
|
+
"You" is the person or organization that accepts these terms.
|
|
17
|
+
|
|
18
|
+
"Your company" is the organization you work for, and the organizations
|
|
19
|
+
that control it, that it controls, or that are under common control
|
|
20
|
+
with it.
|
|
21
|
+
|
|
22
|
+
"Your application" is software or a service that you build and that
|
|
23
|
+
does more than give access to the Software. Your application is not a
|
|
24
|
+
work based on the Software just because it uses the Software.
|
|
25
|
+
|
|
26
|
+
2. Acceptance
|
|
27
|
+
|
|
28
|
+
By installing, copying or using the Software you accept these terms.
|
|
29
|
+
If you do not accept them, do not use the Software.
|
|
30
|
+
|
|
31
|
+
3. What you may do
|
|
32
|
+
|
|
33
|
+
KOBIL grants you and your company a non-exclusive, worldwide,
|
|
34
|
+
non-transferable license, free of charge, to:
|
|
35
|
+
|
|
36
|
+
a. install and use the Software, unchanged, to build, test and run your
|
|
37
|
+
applications, including commercial ones that you charge for;
|
|
38
|
+
b. make the copies of the Software that this needs, for example on
|
|
39
|
+
developer machines, on build servers and in your application's
|
|
40
|
+
environments;
|
|
41
|
+
c. ship the Software, unchanged, as part of your application, for
|
|
42
|
+
example inside its container image, package or bundle.
|
|
43
|
+
|
|
44
|
+
Your company's employees and contractors may do the same on your
|
|
45
|
+
company's behalf.
|
|
46
|
+
|
|
47
|
+
Building your application with tools such as bundlers, minifiers,
|
|
48
|
+
transpilers or compilers is not a change to the Software. Using AI
|
|
49
|
+
coding tools to write applications that use the Software is allowed.
|
|
50
|
+
|
|
51
|
+
4. Shipping the Software inside your application
|
|
52
|
+
|
|
53
|
+
When you ship the Software as part of your application:
|
|
54
|
+
|
|
55
|
+
a. the Software must be needed for your application to work, and must
|
|
56
|
+
not be offered as a library, SDK or tool of its own;
|
|
57
|
+
b. the people who receive your application get no right to use the
|
|
58
|
+
Software apart from your application;
|
|
59
|
+
c. keep this license and the copyright notice with the Software, or in
|
|
60
|
+
your application's third-party notices.
|
|
61
|
+
|
|
62
|
+
5. What you may not do
|
|
63
|
+
|
|
64
|
+
You may not:
|
|
65
|
+
|
|
66
|
+
a. change, adapt or translate the Software, or make works based on it;
|
|
67
|
+
b. copy the Software's code, or substantial parts of it, into other
|
|
68
|
+
software, whether by hand or with automated or AI tools, or use the
|
|
69
|
+
Software to train AI models;
|
|
70
|
+
c. publish, share, sublicense, sell, rent or lease the Software on its
|
|
71
|
+
own, or as part of another SDK, library, framework or developer
|
|
72
|
+
tool;
|
|
73
|
+
d. remove or change this license or any copyright notice in the
|
|
74
|
+
Software;
|
|
75
|
+
e. use the Software to break the law, or to suggest that KOBIL endorses
|
|
76
|
+
your application.
|
|
77
|
+
|
|
78
|
+
These limits apply to the Software itself. You may sell and
|
|
79
|
+
commercially distribute your application.
|
|
80
|
+
|
|
81
|
+
6. KOBIL services
|
|
82
|
+
|
|
83
|
+
The Software can connect to KOBIL services. Using those services is
|
|
84
|
+
governed by their own terms, not by this license. This license does not
|
|
85
|
+
give you access to any KOBIL service.
|
|
86
|
+
|
|
87
|
+
7. Ownership
|
|
88
|
+
|
|
89
|
+
KOBIL keeps all rights in the Software that this license does not
|
|
90
|
+
expressly grant. This license gives you no right to KOBIL's names,
|
|
91
|
+
logos or trademarks, except to say truthfully that your application
|
|
92
|
+
uses the Software.
|
|
93
|
+
|
|
94
|
+
8. Other software
|
|
95
|
+
|
|
96
|
+
The Software uses third-party open-source packages. Each of them is
|
|
97
|
+
covered by its own license, not by this one.
|
|
98
|
+
|
|
99
|
+
9. New versions
|
|
100
|
+
|
|
101
|
+
KOBIL may release new versions of the Software under a new version of
|
|
102
|
+
this license. Each copy of the Software is covered by the license that
|
|
103
|
+
came with it.
|
|
104
|
+
|
|
105
|
+
10. Violations
|
|
106
|
+
|
|
107
|
+
If you break these terms, KOBIL may tell you so in writing. The first
|
|
108
|
+
time this happens, your license continues if, within 30 days of that
|
|
109
|
+
notice, you fully comply again and take practical steps to correct past
|
|
110
|
+
violations. Otherwise, and after any later violation, your license
|
|
111
|
+
ends. You must then stop using and shipping the Software.
|
|
112
|
+
|
|
113
|
+
11. No warranty
|
|
114
|
+
|
|
115
|
+
As far as the law allows, the Software comes "as is", without any
|
|
116
|
+
warranty or condition, express or implied, including fitness for a
|
|
117
|
+
particular purpose and non-infringement.
|
|
118
|
+
|
|
119
|
+
12. Limitation of liability
|
|
120
|
+
|
|
121
|
+
As far as the law allows, KOBIL is not liable for any damages that come
|
|
122
|
+
from these terms or from using, or not being able to use, the Software,
|
|
123
|
+
under any kind of legal claim.
|
|
124
|
+
|
|
125
|
+
13. Other permissions
|
|
126
|
+
|
|
127
|
+
For any use these terms do not allow, ask KOBIL for written permission
|
|
128
|
+
at www.kobil.com.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kobil-aitrust
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: KOBIL AI Trust Platform — unified meta-package wrapping SDK and Audit into a simple developer-facing API
|
|
5
|
+
License-Expression: LicenseRef-KOBIL-Agent-Trust-SDK-1.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: agent,ai,audit,ciba,fga,kobil,mfa,oauth,security,trust
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: kobil-agent-sdk<4,>=3.0
|
|
10
|
+
Requires-Dist: mcp>=2.0.0
|
|
11
|
+
Provides-Extra: all
|
|
12
|
+
Requires-Dist: anthropic>=0.20; extra == 'all'
|
|
13
|
+
Requires-Dist: kobil-agent-audit<4,>=3.0; extra == 'all'
|
|
14
|
+
Requires-Dist: openai>=1.0; extra == 'all'
|
|
15
|
+
Provides-Extra: anthropic
|
|
16
|
+
Requires-Dist: anthropic>=0.20; extra == 'anthropic'
|
|
17
|
+
Provides-Extra: audit
|
|
18
|
+
Requires-Dist: kobil-agent-audit<4,>=3.0; extra == 'audit'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# kobil-aitrust (Python)
|
|
27
|
+
|
|
28
|
+
Python mirror of the `@uveysservetoglu/kobil-aitrust` TypeScript meta-package — wraps the SDK
|
|
29
|
+
and Audit packages into a single developer-facing API via `AITrust()`.
|
|
30
|
+
|
|
31
|
+
Behavioral 1:1 port of the TypeScript package in `../src`. The TypeScript
|
|
32
|
+
source is the reference specification.
|
|
33
|
+
|
|
34
|
+
## Dependency note
|
|
35
|
+
|
|
36
|
+
`AITrust()` constructs a `KobilAI` from `kobil_agent_sdk` (the agent-sdk port).
|
|
37
|
+
That dependency is resolved lazily through `kobil_aitrust.trust._make_kobil_ai`
|
|
38
|
+
so the package imports without agent-sdk installed and so tests can inject a
|
|
39
|
+
fake — mirroring the TS `vi.mock('@uveysservetoglu/kobil-agent-sdk')`. Audit is resolved
|
|
40
|
+
the same way via `_import_audit_cls`.
|
|
41
|
+
|
|
42
|
+
## Install (dev)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m venv .venv && source .venv/bin/activate
|
|
46
|
+
pip install -e ".[dev]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Test
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pytest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from kobil_aitrust import AITrust, AITrustConfig
|
|
60
|
+
|
|
61
|
+
ai = AITrust(AITrustConfig(platform_url="https://trust.kobil.com", client_id="my-agent"))
|
|
62
|
+
|
|
63
|
+
@ai.tool # or: secured = ai.tool(fn, ToolOptions(name="get_weather", scopes=["weather:read"]))
|
|
64
|
+
async def get_weather(city, ctx):
|
|
65
|
+
return "sunny"
|
|
66
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# kobil-aitrust (Python)
|
|
2
|
+
|
|
3
|
+
Python mirror of the `@uveysservetoglu/kobil-aitrust` TypeScript meta-package — wraps the SDK
|
|
4
|
+
and Audit packages into a single developer-facing API via `AITrust()`.
|
|
5
|
+
|
|
6
|
+
Behavioral 1:1 port of the TypeScript package in `../src`. The TypeScript
|
|
7
|
+
source is the reference specification.
|
|
8
|
+
|
|
9
|
+
## Dependency note
|
|
10
|
+
|
|
11
|
+
`AITrust()` constructs a `KobilAI` from `kobil_agent_sdk` (the agent-sdk port).
|
|
12
|
+
That dependency is resolved lazily through `kobil_aitrust.trust._make_kobil_ai`
|
|
13
|
+
so the package imports without agent-sdk installed and so tests can inject a
|
|
14
|
+
fake — mirroring the TS `vi.mock('@uveysservetoglu/kobil-agent-sdk')`. Audit is resolved
|
|
15
|
+
the same way via `_import_audit_cls`.
|
|
16
|
+
|
|
17
|
+
## Install (dev)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m venv .venv && source .venv/bin/activate
|
|
21
|
+
pip install -e ".[dev]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Test
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pytest
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from kobil_aitrust import AITrust, AITrustConfig
|
|
35
|
+
|
|
36
|
+
ai = AITrust(AITrustConfig(platform_url="https://trust.kobil.com", client_id="my-agent"))
|
|
37
|
+
|
|
38
|
+
@ai.tool # or: secured = ai.tool(fn, ToolOptions(name="get_weather", scopes=["weather:read"]))
|
|
39
|
+
async def get_weather(city, ctx):
|
|
40
|
+
return "sunny"
|
|
41
|
+
```
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""kobil-aitrust — unified meta-package for the KOBIL AI Trust Platform (Python).
|
|
2
|
+
|
|
3
|
+
Python mirror of ``@uveysservetoglu/kobil-aitrust``. Wraps the SDK and Audit packages
|
|
4
|
+
into a simple developer-facing API via the :func:`AITrust` factory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .config import AITrustConfig
|
|
10
|
+
from .define_tool import (
|
|
11
|
+
SecuredTool,
|
|
12
|
+
ToolDefinition,
|
|
13
|
+
ToolExecute,
|
|
14
|
+
ToolInput,
|
|
15
|
+
define_tool,
|
|
16
|
+
register_tools,
|
|
17
|
+
to_anthropic_tools,
|
|
18
|
+
to_openai_tools,
|
|
19
|
+
)
|
|
20
|
+
from .errors import (
|
|
21
|
+
AITrustError,
|
|
22
|
+
ApprovalDenied,
|
|
23
|
+
ApprovalTimeout,
|
|
24
|
+
ConfigurationError,
|
|
25
|
+
NotAuthenticated,
|
|
26
|
+
NotAuthorized,
|
|
27
|
+
UntrustedAgent,
|
|
28
|
+
get_error_code,
|
|
29
|
+
is_aitrust_error,
|
|
30
|
+
)
|
|
31
|
+
from .integrations import (
|
|
32
|
+
secure_anthropic_client,
|
|
33
|
+
secure_langchain_model,
|
|
34
|
+
secure_openai_client,
|
|
35
|
+
secure_vercel_ai,
|
|
36
|
+
)
|
|
37
|
+
from .trust import (
|
|
38
|
+
AITrust,
|
|
39
|
+
AITrustInstance,
|
|
40
|
+
ApprovalOptions,
|
|
41
|
+
ToolContext,
|
|
42
|
+
ToolOptions,
|
|
43
|
+
detect_client_framework,
|
|
44
|
+
detect_framework,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# The release pipeline stamps the version into pyproject.toml only, so read it back from the installed metadata.
|
|
48
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError, version as _version
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
__version__ = _version("kobil-aitrust")
|
|
52
|
+
except _PackageNotFoundError: # running from a source checkout that is not installed
|
|
53
|
+
__version__ = "0.0.0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AITrust",
|
|
57
|
+
"AITrustInstance",
|
|
58
|
+
"AITrustConfig",
|
|
59
|
+
"ToolOptions",
|
|
60
|
+
"ToolContext",
|
|
61
|
+
"ApprovalOptions",
|
|
62
|
+
"detect_framework",
|
|
63
|
+
"detect_client_framework",
|
|
64
|
+
# define-tool
|
|
65
|
+
"ToolDefinition",
|
|
66
|
+
"SecuredTool",
|
|
67
|
+
"ToolExecute",
|
|
68
|
+
"ToolInput",
|
|
69
|
+
"define_tool",
|
|
70
|
+
"register_tools",
|
|
71
|
+
"to_anthropic_tools",
|
|
72
|
+
"to_openai_tools",
|
|
73
|
+
# errors
|
|
74
|
+
"AITrustError",
|
|
75
|
+
"NotAuthenticated",
|
|
76
|
+
"NotAuthorized",
|
|
77
|
+
"ApprovalDenied",
|
|
78
|
+
"ApprovalTimeout",
|
|
79
|
+
"UntrustedAgent",
|
|
80
|
+
"ConfigurationError",
|
|
81
|
+
"is_aitrust_error",
|
|
82
|
+
"get_error_code",
|
|
83
|
+
# integrations
|
|
84
|
+
"secure_langchain_model",
|
|
85
|
+
"secure_openai_client",
|
|
86
|
+
"secure_anthropic_client",
|
|
87
|
+
"secure_vercel_ai",
|
|
88
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Loopback MCP bridge (A-E3) — for runtimes that accept only STATIC MCP headers
|
|
2
|
+
(Claude Agent SDK, ADK-TS) and so cannot refresh a Bearer in-code.
|
|
3
|
+
|
|
4
|
+
A tiny local MCP reverse-proxy: it is an MCP *server* to the runtime (on an ephemeral
|
|
5
|
+
`127.0.0.1` port) and an MCP *client* to the real broker. It holds the
|
|
6
|
+
`BrokerBearerProvider` and injects a fresh Bearer on every forwarded call — invisibly,
|
|
7
|
+
so the runtime's static config never carries (or can leak) the token.
|
|
8
|
+
|
|
9
|
+
Hardening (plan A-E3):
|
|
10
|
+
* bound to `127.0.0.1` only; requests from a non-loopback client are refused;
|
|
11
|
+
* a 256-bit in-memory secret, checked constant-time, gates the MCP path (so another
|
|
12
|
+
local process can't drive the bridge);
|
|
13
|
+
* the secret lives only in memory and in the runtime's in-process config — never in
|
|
14
|
+
argv, logs, or a URL;
|
|
15
|
+
* `dispose()` is idempotent and stops the server with the process.
|
|
16
|
+
|
|
17
|
+
This module is imported lazily (only when `create_claude_broker_mcp` is called), so
|
|
18
|
+
kobil-aitrust keeps no hard dependency on `mcp`/`uvicorn`.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import secrets
|
|
25
|
+
import socket
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
BRIDGE_SECRET_HEADER = "x-aitrust-bridge-secret"
|
|
31
|
+
_READY_PATH = "/_bridge_ready"
|
|
32
|
+
_MCP_PATH = "/mcp"
|
|
33
|
+
_LOOPBACK = {"127.0.0.1", "::1"}
|
|
34
|
+
# Connect / write / pool budget for the broker leg — the mcp library's own default.
|
|
35
|
+
_CONNECT_TIMEOUT_S = 30.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _BrokerForwardClient:
|
|
39
|
+
"""Forwards list/call to the real broker over a fresh connection carrying a fresh
|
|
40
|
+
Bearer. Mirrors the broker's own per-call upstream client, but INJECTS auth."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
endpoint: str,
|
|
45
|
+
provider: Any,
|
|
46
|
+
*,
|
|
47
|
+
tool_timeout_s: float | None = None,
|
|
48
|
+
end_user: Any = None,
|
|
49
|
+
approver: Any = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
from .mcp_broker import DEFAULT_TOOL_TIMEOUT_S
|
|
52
|
+
|
|
53
|
+
self._endpoint = endpoint
|
|
54
|
+
self._provider = provider
|
|
55
|
+
self._tool_timeout_s = tool_timeout_s or DEFAULT_TOOL_TIMEOUT_S
|
|
56
|
+
# Resolved per call, not here: a callable has to be consulted at the moment of the
|
|
57
|
+
# call, or a server handling several people pins whoever came first.
|
|
58
|
+
self._end_user = end_user
|
|
59
|
+
# Who the agent names to approve a held call (Revision 14); resolved per call too.
|
|
60
|
+
self._approver = approver
|
|
61
|
+
|
|
62
|
+
async def _session(self):
|
|
63
|
+
import httpx2
|
|
64
|
+
from mcp import ClientSession
|
|
65
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
66
|
+
|
|
67
|
+
token = await self._provider.token()
|
|
68
|
+
# The read timeout must outlast the broker's approval hold: a CIBA-gated call
|
|
69
|
+
# is answered only after a human decides (up to max_approval_wait_s = 120 s),
|
|
70
|
+
# and nothing is sent meanwhile. httpx2's 5 s default cut every held call —
|
|
71
|
+
# and a slow allow call — before the broker could answer. Passing our own
|
|
72
|
+
# client also drops the mcp library's 300 s SSE default, so set it here.
|
|
73
|
+
timeout = httpx2.Timeout(_CONNECT_TIMEOUT_S, read=self._tool_timeout_s)
|
|
74
|
+
from .mcp_broker import approver_headers, end_user_headers
|
|
75
|
+
|
|
76
|
+
hc = httpx2.AsyncClient(
|
|
77
|
+
headers={"Authorization": f"Bearer {token}", **end_user_headers(self._end_user),
|
|
78
|
+
**approver_headers(self._approver)},
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
return hc, streamable_http_client, ClientSession
|
|
82
|
+
|
|
83
|
+
async def list_tools(self):
|
|
84
|
+
hc, factory, ClientSession = await self._session()
|
|
85
|
+
async with hc:
|
|
86
|
+
async with factory(self._endpoint, http_client=hc) as (read, write):
|
|
87
|
+
async with ClientSession(read, write) as session:
|
|
88
|
+
await session.initialize()
|
|
89
|
+
return await session.list_tools()
|
|
90
|
+
|
|
91
|
+
async def call_tool(self, name: str, arguments: dict | None):
|
|
92
|
+
hc, factory, ClientSession = await self._session()
|
|
93
|
+
async with hc:
|
|
94
|
+
async with factory(self._endpoint, http_client=hc) as (read, write):
|
|
95
|
+
async with ClientSession(read, write) as session:
|
|
96
|
+
await session.initialize()
|
|
97
|
+
return await session.call_tool(name, arguments or {})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LoopbackBridge:
|
|
101
|
+
def __init__(self, *, endpoint: str | None = None, provider: Any = None, backend: Any = None,
|
|
102
|
+
tool_timeout_s: float | None = None, end_user: Any = None, approver: Any = None) -> None:
|
|
103
|
+
if backend is None and (endpoint is None or provider is None):
|
|
104
|
+
raise ValueError("endpoint/provider or backend is required")
|
|
105
|
+
self._forward = backend or _BrokerForwardClient(
|
|
106
|
+
endpoint, provider, tool_timeout_s=tool_timeout_s, end_user=end_user, approver=approver
|
|
107
|
+
)
|
|
108
|
+
self._secret = secrets.token_urlsafe(32) # 256-bit
|
|
109
|
+
self._port = _free_loopback_port()
|
|
110
|
+
self._server = None
|
|
111
|
+
self._thread: threading.Thread | None = None
|
|
112
|
+
self._disposed = False
|
|
113
|
+
|
|
114
|
+
# -- public surface -----------------------------------------------------------
|
|
115
|
+
@property
|
|
116
|
+
def url(self) -> str:
|
|
117
|
+
return f"http://127.0.0.1:{self._port}{_MCP_PATH}"
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def secret(self) -> str:
|
|
121
|
+
return self._secret
|
|
122
|
+
|
|
123
|
+
def start(self, *, ready_timeout_s: float = 10.0) -> "LoopbackBridge":
|
|
124
|
+
import httpx2
|
|
125
|
+
import uvicorn
|
|
126
|
+
|
|
127
|
+
app = self._build_app()
|
|
128
|
+
config = uvicorn.Config(app, host="127.0.0.1", port=self._port, log_level="warning")
|
|
129
|
+
self._server = uvicorn.Server(config)
|
|
130
|
+
self._thread = threading.Thread(target=self._server.run, daemon=True)
|
|
131
|
+
self._thread.start()
|
|
132
|
+
|
|
133
|
+
deadline = time.monotonic() + ready_timeout_s
|
|
134
|
+
while time.monotonic() < deadline:
|
|
135
|
+
try:
|
|
136
|
+
if httpx2.get(f"http://127.0.0.1:{self._port}{_READY_PATH}", timeout=0.3).status_code == 200:
|
|
137
|
+
return self
|
|
138
|
+
except Exception:
|
|
139
|
+
time.sleep(0.05)
|
|
140
|
+
self.dispose()
|
|
141
|
+
raise RuntimeError("loopback bridge did not become ready")
|
|
142
|
+
|
|
143
|
+
def dispose(self) -> None:
|
|
144
|
+
if self._disposed:
|
|
145
|
+
return
|
|
146
|
+
self._disposed = True
|
|
147
|
+
if self._server is not None:
|
|
148
|
+
self._server.should_exit = True
|
|
149
|
+
if self._thread is not None:
|
|
150
|
+
self._thread.join(timeout=5)
|
|
151
|
+
|
|
152
|
+
# -- server assembly ----------------------------------------------------------
|
|
153
|
+
def _build_app(self):
|
|
154
|
+
import mcp.types as t
|
|
155
|
+
from mcp.server.mcpserver import MCPServer
|
|
156
|
+
from starlette.requests import Request
|
|
157
|
+
from starlette.responses import JSONResponse
|
|
158
|
+
|
|
159
|
+
server = MCPServer(name="aitrust-broker-bridge", version="0.0.1")
|
|
160
|
+
|
|
161
|
+
@server.custom_route(_READY_PATH, methods=["GET"])
|
|
162
|
+
async def _ready(_request: Request) -> JSONResponse:
|
|
163
|
+
return JSONResponse({"status": "ready"})
|
|
164
|
+
|
|
165
|
+
async def _list(_ctx, _params):
|
|
166
|
+
return await self._forward.list_tools()
|
|
167
|
+
|
|
168
|
+
async def _call(_ctx, params):
|
|
169
|
+
try:
|
|
170
|
+
return await self._forward.call_tool(params.name, params.arguments)
|
|
171
|
+
except Exception as exc: # forward failure surfaces as an MCP tool error
|
|
172
|
+
return t.CallToolResult(
|
|
173
|
+
content=[t.TextContent(type="text", text=f"broker bridge error: {exc}")],
|
|
174
|
+
is_error=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
ll = server._lowlevel_server
|
|
178
|
+
ll.add_request_handler("tools/list", t.PaginatedRequestParams, _list)
|
|
179
|
+
ll.add_request_handler("tools/call", t.CallToolRequestParams, _call)
|
|
180
|
+
|
|
181
|
+
app = server.streamable_http_app(streamable_http_path=_MCP_PATH, host="127.0.0.1")
|
|
182
|
+
return _LoopbackGuard(app, secret=self._secret)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _LoopbackGuard:
|
|
186
|
+
"""Pure-ASGI guard: MCP path requires a loopback client AND the constant-time secret.
|
|
187
|
+
The readiness route stays open (no secret) for local startup polling."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, app, *, secret: str) -> None:
|
|
190
|
+
self._app = app
|
|
191
|
+
self._secret = secret
|
|
192
|
+
|
|
193
|
+
async def __call__(self, scope, receive, send):
|
|
194
|
+
if scope["type"] == "http":
|
|
195
|
+
path = scope.get("path", "")
|
|
196
|
+
if path == _MCP_PATH or path.startswith(_MCP_PATH + "/"):
|
|
197
|
+
client = scope.get("client") or ("", 0)
|
|
198
|
+
if client[0] not in _LOOPBACK:
|
|
199
|
+
return await _deny(send, 403, "non_loopback")
|
|
200
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
|
201
|
+
if not hmac.compare_digest(headers.get(BRIDGE_SECRET_HEADER, ""), self._secret):
|
|
202
|
+
return await _deny(send, 401, "bad_bridge_secret")
|
|
203
|
+
return await self._app(scope, receive, send)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _deny(send, status: int, reason: str) -> None:
|
|
207
|
+
body = json.dumps({"error": reason}).encode()
|
|
208
|
+
await send({"type": "http.response.start", "status": status,
|
|
209
|
+
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]})
|
|
210
|
+
await send({"type": "http.response.body", "body": body})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _free_loopback_port() -> int:
|
|
214
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
215
|
+
try:
|
|
216
|
+
s.bind(("127.0.0.1", 0))
|
|
217
|
+
return s.getsockname()[1]
|
|
218
|
+
finally:
|
|
219
|
+
s.close()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Config resolution for kobil-aitrust (mirror of src/config.ts).
|
|
2
|
+
|
|
3
|
+
Note: ``create_kobil_ai_config`` returns a snake_case dict — the internal API
|
|
4
|
+
contract for the Python KobilAI (agent-sdk). This differs from the TS camelCase
|
|
5
|
+
only by naming convention; the resolution precedence (explicit → AITRUST_* env
|
|
6
|
+
→ KOBIL_* env) is identical.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AITrustConfig:
|
|
18
|
+
platform_url: Optional[str] = None
|
|
19
|
+
client_id: Optional[str] = None
|
|
20
|
+
client_secret: Optional[str] = None
|
|
21
|
+
agent_id: Optional[str] = None
|
|
22
|
+
scopes: Optional[list[str]] = None
|
|
23
|
+
dpop: Optional[bool] = None
|
|
24
|
+
management_url: Optional[str] = None
|
|
25
|
+
audit_url: Optional[str] = None
|
|
26
|
+
idp_url: Optional[str] = None
|
|
27
|
+
proxy_url: Optional[str] = None
|
|
28
|
+
proxy_cert_path: Optional[str] = None
|
|
29
|
+
proxy_key_path: Optional[str] = None
|
|
30
|
+
audience: Optional[str] = None
|
|
31
|
+
api_token: Optional[str] = None
|
|
32
|
+
realm: Optional[str] = None
|
|
33
|
+
token_endpoint: Optional[str] = None
|
|
34
|
+
authorization_endpoint: Optional[str] = None
|
|
35
|
+
backchannel_auth_endpoint: Optional[str] = None
|
|
36
|
+
keycloak_token_url: Optional[str] = None
|
|
37
|
+
timeout_ms: Optional[int] = None
|
|
38
|
+
isolated: Optional[bool] = None
|
|
39
|
+
# () -> Optional[str] | Awaitable[Optional[str]]
|
|
40
|
+
secret_provider: Optional[Callable[[], Any]] = None
|
|
41
|
+
# (kobil_agent_sdk ApprovalPendingEvent) -> None | Awaitable[None]. Called once when a
|
|
42
|
+
# native tool call is held for approval, before the SDK starts waiting; observational.
|
|
43
|
+
# The MCP equivalent is attach_mcp(on_approval_pending=...).
|
|
44
|
+
on_approval_pending: Optional[Callable[[Any], Any]] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_config_value(
|
|
48
|
+
explicit: Optional[str], aitrust_env: str, kobil_env: str, env: Optional[dict] = None
|
|
49
|
+
) -> Optional[str]:
|
|
50
|
+
env = env if env is not None else os.environ
|
|
51
|
+
if explicit is not None:
|
|
52
|
+
return explicit
|
|
53
|
+
return env.get(aitrust_env) if env.get(aitrust_env) is not None else env.get(kobil_env)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_local_config(config: Optional[AITrustConfig] = None, env: Optional[dict] = None) -> dict:
|
|
57
|
+
config = config or AITrustConfig()
|
|
58
|
+
env = env if env is not None else os.environ
|
|
59
|
+
|
|
60
|
+
def rv(explicit, a, k):
|
|
61
|
+
return resolve_config_value(explicit, a, k, env)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"platform_url": rv(config.platform_url, "AITRUST_PLATFORM_URL", "KOBIL_PLATFORM_URL") or "",
|
|
65
|
+
"management_url": rv(config.management_url, "AITRUST_MANAGEMENT_URL", "KOBIL_MANAGEMENT_URL"),
|
|
66
|
+
"audit_url": rv(config.audit_url, "AITRUST_AUDIT_URL", "KOBIL_AUDIT_URL"),
|
|
67
|
+
"idp_url": rv(config.idp_url, "AITRUST_IDP_URL", "KOBIL_IDP_URL"),
|
|
68
|
+
"client_id": rv(config.client_id, "AITRUST_CLIENT_ID", "KOBIL_CLIENT_ID") or "",
|
|
69
|
+
"agent_id": rv(config.agent_id, "AITRUST_AGENT_ID", "KOBIL_AGENT_ID"),
|
|
70
|
+
"client_secret": rv(config.client_secret, "AITRUST_CLIENT_SECRET", "KOBIL_CLIENT_SECRET"),
|
|
71
|
+
"api_token": rv(config.api_token, "AITRUST_API_TOKEN", "KOBIL_API_TOKEN"),
|
|
72
|
+
"audience": rv(config.audience, "AITRUST_AUDIENCE", "KOBIL_AUDIENCE"),
|
|
73
|
+
"scopes": config.scopes,
|
|
74
|
+
"dpop": config.dpop,
|
|
75
|
+
"proxy_url": rv(config.proxy_url, "AITRUST_PROXY_URL", "KOBIL_PROXY_URL"),
|
|
76
|
+
"proxy_cert_path": rv(config.proxy_cert_path, "AITRUST_PROXY_CERT_PATH", "KOBIL_PROXY_CERT_PATH"),
|
|
77
|
+
"proxy_key_path": rv(config.proxy_key_path, "AITRUST_PROXY_KEY_PATH", "KOBIL_PROXY_KEY_PATH"),
|
|
78
|
+
"realm": rv(config.realm, "AITRUST_REALM", "KOBIL_REALM"),
|
|
79
|
+
"token_endpoint": rv(config.token_endpoint, "AITRUST_TOKEN_ENDPOINT", "KOBIL_TOKEN_ENDPOINT"),
|
|
80
|
+
"authorization_endpoint": rv(
|
|
81
|
+
config.authorization_endpoint, "AITRUST_AUTHORIZATION_ENDPOINT", "KOBIL_AUTHORIZATION_ENDPOINT"
|
|
82
|
+
),
|
|
83
|
+
"backchannel_auth_endpoint": rv(
|
|
84
|
+
config.backchannel_auth_endpoint, "AITRUST_BACKCHANNEL_AUTH_ENDPOINT", "KOBIL_BACKCHANNEL_AUTH_ENDPOINT"
|
|
85
|
+
),
|
|
86
|
+
"keycloak_token_url": rv(config.keycloak_token_url, "AITRUST_KEYCLOAK_TOKEN_URL", "KOBIL_KEYCLOAK_TOKEN_URL"),
|
|
87
|
+
"timeout_ms": config.timeout_ms,
|
|
88
|
+
"isolated": config.isolated,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_kobil_ai_config(config: Optional[AITrustConfig] = None) -> dict:
|
|
93
|
+
resolved = resolve_local_config(config)
|
|
94
|
+
return {
|
|
95
|
+
"platform_url": resolved["platform_url"],
|
|
96
|
+
"management_url": resolved["management_url"],
|
|
97
|
+
"audit_url": resolved["audit_url"],
|
|
98
|
+
"idp_url": resolved["idp_url"],
|
|
99
|
+
"client_id": resolved["client_id"],
|
|
100
|
+
"agent_id": resolved["agent_id"],
|
|
101
|
+
"client_secret": resolved["client_secret"],
|
|
102
|
+
"api_token": resolved["api_token"],
|
|
103
|
+
"audience": resolved["audience"],
|
|
104
|
+
"scopes": resolved["scopes"],
|
|
105
|
+
"dpop_enabled": resolved["dpop"],
|
|
106
|
+
"timeout_ms": resolved["timeout_ms"],
|
|
107
|
+
"proxy_url": resolved["proxy_url"],
|
|
108
|
+
"proxy_cert_path": resolved["proxy_cert_path"],
|
|
109
|
+
"proxy_key_path": resolved["proxy_key_path"],
|
|
110
|
+
"realm": resolved["realm"],
|
|
111
|
+
"token_endpoint": resolved["token_endpoint"],
|
|
112
|
+
"authorization_endpoint": resolved["authorization_endpoint"],
|
|
113
|
+
"backchannel_auth_endpoint": resolved["backchannel_auth_endpoint"],
|
|
114
|
+
"keycloak_token_url": resolved["keycloak_token_url"],
|
|
115
|
+
"isolated": resolved["isolated"],
|
|
116
|
+
# Carried explicitly: this is a dict literal, so a key left out is silently dropped
|
|
117
|
+
# and the callback never fires. It is not configuration to resolve from the
|
|
118
|
+
# environment, so it comes straight from the config object.
|
|
119
|
+
"on_approval_pending": config.on_approval_pending if config else None,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = ["AITrustConfig", "resolve_config_value", "resolve_local_config", "create_kobil_ai_config"]
|