scopebound 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.
- scopebound-0.1.0/.gitignore +7 -0
- scopebound-0.1.0/PKG-INFO +46 -0
- scopebound-0.1.0/README.md +28 -0
- scopebound-0.1.0/pyproject.toml +31 -0
- scopebound-0.1.0/scopebound/__init__.py +24 -0
- scopebound-0.1.0/scopebound/adapters/__init__.py +0 -0
- scopebound-0.1.0/scopebound/adapters/autogen.py +142 -0
- scopebound-0.1.0/scopebound/adapters/crewai.py +154 -0
- scopebound-0.1.0/scopebound/adapters/langchain.py +56 -0
- scopebound-0.1.0/scopebound/adapters/openai_assistants.py +138 -0
- scopebound-0.1.0/scopebound/adapters/semantic_kernel.py +87 -0
- scopebound-0.1.0/scopebound/client.py +130 -0
- scopebound-0.1.0/scopebound/exceptions.py +17 -0
- scopebound-0.1.0/scopebound_demo_agent.py +367 -0
- scopebound-0.1.0/tests/__init__.py +0 -0
- scopebound-0.1.0/tests/test_autogen_adapter.py +186 -0
- scopebound-0.1.0/tests/test_client.py +193 -0
- scopebound-0.1.0/tests/test_crewai_adapter.py +213 -0
- scopebound-0.1.0/tests/test_langchain_adapter.py +143 -0
- scopebound-0.1.0/tests/test_openai_assistants.py +216 -0
- scopebound-0.1.0/tests/test_semantic_kernel_adapter.py +188 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scopebound
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-trust identity and enforcement for AI agents
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: agents,ai,enforcement,langchain,security
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: langchain-core>=0.2.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: respx>=0.21.0; extra == 'dev'
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core>=0.2.0; extra == 'langchain'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Scopebound Python SDK
|
|
20
|
+
|
|
21
|
+
Zero-trust identity and enforcement for AI agents.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
```bash
|
|
25
|
+
pip install scopebound
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart — LangChain
|
|
29
|
+
```python
|
|
30
|
+
from scopebound import ScopeboundSDK, enforce
|
|
31
|
+
from langchain_core.tools import BaseTool
|
|
32
|
+
|
|
33
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
34
|
+
|
|
35
|
+
@enforce(sb, role="invoice-processor")
|
|
36
|
+
class ReadInvoicesTool(BaseTool):
|
|
37
|
+
name = "read_invoices"
|
|
38
|
+
description = "Read invoices from the database"
|
|
39
|
+
|
|
40
|
+
def _run(self, query: str) -> str:
|
|
41
|
+
return "invoice data"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Apache-2.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Scopebound Python SDK
|
|
2
|
+
|
|
3
|
+
Zero-trust identity and enforcement for AI agents.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
```bash
|
|
7
|
+
pip install scopebound
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Quickstart — LangChain
|
|
11
|
+
```python
|
|
12
|
+
from scopebound import ScopeboundSDK, enforce
|
|
13
|
+
from langchain_core.tools import BaseTool
|
|
14
|
+
|
|
15
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
16
|
+
|
|
17
|
+
@enforce(sb, role="invoice-processor")
|
|
18
|
+
class ReadInvoicesTool(BaseTool):
|
|
19
|
+
name = "read_invoices"
|
|
20
|
+
description = "Read invoices from the database"
|
|
21
|
+
|
|
22
|
+
def _run(self, query: str) -> str:
|
|
23
|
+
return "invoice data"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
Apache-2.0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scopebound"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-trust identity and enforcement for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
keywords = ["ai", "agents", "security", "enforcement", "langchain"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
langchain = [
|
|
19
|
+
"langchain-core>=0.2.0",
|
|
20
|
+
]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0.0",
|
|
23
|
+
"pytest-asyncio>=0.23.0",
|
|
24
|
+
"pytest-httpx>=0.30.0",
|
|
25
|
+
"langchain-core>=0.2.0",
|
|
26
|
+
"respx>=0.21.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scopebound Python SDK — zero-trust identity and enforcement for AI agents.
|
|
3
|
+
"""
|
|
4
|
+
from .client import ScopeboundSDK
|
|
5
|
+
from .exceptions import ScopeboundDenyError, ScopeboundTimeoutError, ScopeboundUnavailableError
|
|
6
|
+
from .adapters.langchain import enforce
|
|
7
|
+
from .adapters.openai_assistants import ScopeboundSession
|
|
8
|
+
from .adapters.crewai import CrewEnforcer
|
|
9
|
+
from .adapters.autogen import enforce_autogen, AutoGenSession
|
|
10
|
+
from .adapters.semantic_kernel import enforce_sk
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ScopeboundSDK",
|
|
14
|
+
"ScopeboundDenyError",
|
|
15
|
+
"ScopeboundTimeoutError",
|
|
16
|
+
"ScopeboundUnavailableError",
|
|
17
|
+
"enforce",
|
|
18
|
+
"ScopeboundSession",
|
|
19
|
+
"CrewEnforcer",
|
|
20
|
+
"enforce_autogen",
|
|
21
|
+
"AutoGenSession",
|
|
22
|
+
"enforce_sk",
|
|
23
|
+
]
|
|
24
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import functools
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Callable, Generator
|
|
8
|
+
|
|
9
|
+
from ..client import ScopeboundSDK
|
|
10
|
+
from ..exceptions import ScopeboundDenyError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def enforce_autogen(sdk: ScopeboundSDK, role: str) -> Callable:
|
|
14
|
+
"""
|
|
15
|
+
Decorator that wraps an AutoGen tool function with Scopebound enforcement.
|
|
16
|
+
|
|
17
|
+
Works with both plain functions and AutoGen FunctionTool callables.
|
|
18
|
+
The decorated function is intercepted before execution — enforcement
|
|
19
|
+
happens before the tool runs.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
23
|
+
|
|
24
|
+
@enforce_autogen(sb, role="invoice-processor")
|
|
25
|
+
def read_invoices(limit: int = 10) -> str:
|
|
26
|
+
return f"found {limit} invoices"
|
|
27
|
+
|
|
28
|
+
# Register with AutoGen agent:
|
|
29
|
+
agent = AssistantAgent(
|
|
30
|
+
name="agent",
|
|
31
|
+
tools=[read_invoices],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
On deny, the function returns a structured error dict so the AutoGen
|
|
35
|
+
conversation can handle it gracefully without crashing the run.
|
|
36
|
+
"""
|
|
37
|
+
def decorator(func: Callable) -> Callable:
|
|
38
|
+
@functools.wraps(func)
|
|
39
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
call_id = str(uuid.uuid4())[:8]
|
|
41
|
+
call_args = _extract_args(args, kwargs)
|
|
42
|
+
try:
|
|
43
|
+
sdk.enforce(
|
|
44
|
+
role_id=role,
|
|
45
|
+
tool_name=func.__name__,
|
|
46
|
+
call_args=call_args,
|
|
47
|
+
call_id=call_id,
|
|
48
|
+
)
|
|
49
|
+
except ScopeboundDenyError as e:
|
|
50
|
+
# Return structured error dict — AutoGen conversation handles it.
|
|
51
|
+
return {
|
|
52
|
+
"error": e.deny_code,
|
|
53
|
+
"reason": e.reason,
|
|
54
|
+
"retry_guidance": e.retry_guidance,
|
|
55
|
+
}
|
|
56
|
+
return func(*args, **kwargs)
|
|
57
|
+
|
|
58
|
+
# Preserve AutoGen metadata — name and description must be accessible.
|
|
59
|
+
wrapped.__name__ = func.__name__
|
|
60
|
+
wrapped.__doc__ = func.__doc__
|
|
61
|
+
|
|
62
|
+
atexit.register(sdk.revoke)
|
|
63
|
+
return wrapped
|
|
64
|
+
|
|
65
|
+
return decorator
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AutoGenSession:
|
|
69
|
+
"""
|
|
70
|
+
Manages Scopebound session lifecycle around an AutoGen conversation run.
|
|
71
|
+
|
|
72
|
+
Provisions sessions for all agent roles at the start of a conversation
|
|
73
|
+
and revokes them when the conversation ends.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
77
|
+
|
|
78
|
+
with AutoGenSession(sb, roles={"researcher": "read-only-analyst"}) as session:
|
|
79
|
+
await Console(agent.run_stream(...))
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, sdk: ScopeboundSDK, roles: dict[str, str]):
|
|
83
|
+
"""
|
|
84
|
+
Args:
|
|
85
|
+
sdk: A ScopeboundSDK instance shared across the conversation.
|
|
86
|
+
roles: Dict mapping AutoGen agent names to Scopebound role IDs.
|
|
87
|
+
Example: {"researcher": "read-only-analyst"}
|
|
88
|
+
"""
|
|
89
|
+
self.sdk = sdk
|
|
90
|
+
self.roles = roles
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def conversation(self) -> Generator[None, None, None]:
|
|
94
|
+
"""
|
|
95
|
+
Context manager that provisions all role sessions at entry
|
|
96
|
+
and revokes them at exit (including on exception).
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
with session.conversation():
|
|
100
|
+
result = await agent.run(task="...")
|
|
101
|
+
"""
|
|
102
|
+
for role_id in self.roles.values():
|
|
103
|
+
self.sdk.provision(role_id=role_id)
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
self.sdk.revoke()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "AutoGenSession":
|
|
110
|
+
for role_id in self.roles.values():
|
|
111
|
+
self.sdk.provision(role_id=role_id)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, *_: Any) -> None:
|
|
115
|
+
self.sdk.revoke()
|
|
116
|
+
|
|
117
|
+
def enforce_tool(self, agent_name: str) -> Callable:
|
|
118
|
+
"""
|
|
119
|
+
Returns an @enforce_autogen decorator bound to the given agent's role.
|
|
120
|
+
|
|
121
|
+
Usage:
|
|
122
|
+
session = AutoGenSession(sb, roles={"researcher": "read-only-analyst"})
|
|
123
|
+
|
|
124
|
+
@session.enforce_tool("researcher")
|
|
125
|
+
def search_web(query: str) -> str:
|
|
126
|
+
return "results"
|
|
127
|
+
"""
|
|
128
|
+
role_id = self.roles.get(agent_name)
|
|
129
|
+
if role_id is None:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Agent {agent_name!r} not found in roles dict. "
|
|
132
|
+
f"Available: {list(self.roles.keys())}"
|
|
133
|
+
)
|
|
134
|
+
return enforce_autogen(self.sdk, role_id)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_args(args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
138
|
+
result: dict[str, Any] = {}
|
|
139
|
+
if args:
|
|
140
|
+
result["args"] = list(args)
|
|
141
|
+
result.update(kwargs)
|
|
142
|
+
return result
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import functools
|
|
3
|
+
import uuid
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, Generator
|
|
6
|
+
|
|
7
|
+
from ..client import ScopeboundSDK
|
|
8
|
+
from ..exceptions import ScopeboundDenyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CrewEnforcer:
|
|
12
|
+
"""
|
|
13
|
+
Wraps CrewAI BaseTool subclasses with Scopebound enforcement and manages
|
|
14
|
+
session lifecycle around crew.kickoff() runs.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
18
|
+
|
|
19
|
+
enforcer = CrewEnforcer(sb, roles={
|
|
20
|
+
"researcher": "read-only-analyst",
|
|
21
|
+
"writer": "email-sender",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Wrap individual tools:
|
|
25
|
+
@enforcer.enforce_tool(agent_role="researcher")
|
|
26
|
+
class SearchTool(BaseTool):
|
|
27
|
+
name: str = "search"
|
|
28
|
+
description: str = "Search the web"
|
|
29
|
+
def _run(self, query: str = "") -> str:
|
|
30
|
+
return "results"
|
|
31
|
+
|
|
32
|
+
# Run the crew with automatic session lifecycle:
|
|
33
|
+
with enforcer.crew_session():
|
|
34
|
+
result = crew.kickoff()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, sdk: ScopeboundSDK, roles: dict[str, str]):
|
|
38
|
+
"""
|
|
39
|
+
Args:
|
|
40
|
+
sdk: A ScopeboundSDK instance shared across the crew run.
|
|
41
|
+
roles: Dict mapping CrewAI agent role names to Scopebound role IDs.
|
|
42
|
+
Example: {"researcher": "read-only-analyst", "writer": "email-sender"}
|
|
43
|
+
"""
|
|
44
|
+
self.sdk = sdk
|
|
45
|
+
self.roles = roles
|
|
46
|
+
self._active_sessions: list[str] = []
|
|
47
|
+
|
|
48
|
+
def enforce_tool(self, agent_role: str):
|
|
49
|
+
"""
|
|
50
|
+
Class decorator that wraps a CrewAI BaseTool with Scopebound enforcement.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
agent_role: The CrewAI agent role name (key in the roles dict).
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If agent_role is not in the roles dict.
|
|
57
|
+
"""
|
|
58
|
+
if agent_role not in self.roles:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Agent role {agent_role!r} not found in roles dict. "
|
|
61
|
+
f"Available roles: {list(self.roles.keys())}"
|
|
62
|
+
)
|
|
63
|
+
scopebound_role = self.roles[agent_role]
|
|
64
|
+
|
|
65
|
+
def decorator(cls):
|
|
66
|
+
original_run = cls._run
|
|
67
|
+
original_arun = getattr(cls, "_arun", None)
|
|
68
|
+
|
|
69
|
+
@functools.wraps(original_run)
|
|
70
|
+
def wrapped_run(self_tool, *args, **kwargs):
|
|
71
|
+
call_id = str(uuid.uuid4())[:8]
|
|
72
|
+
call_args = _extract_args(args, kwargs)
|
|
73
|
+
try:
|
|
74
|
+
self.sdk.enforce(
|
|
75
|
+
role_id = scopebound_role,
|
|
76
|
+
tool_name = self_tool.name,
|
|
77
|
+
call_args = call_args,
|
|
78
|
+
call_id = call_id,
|
|
79
|
+
)
|
|
80
|
+
except ScopeboundDenyError as e:
|
|
81
|
+
# CrewAI expects ToolException on tool failure.
|
|
82
|
+
# Import lazily so crewai is not a hard dependency.
|
|
83
|
+
raise _make_tool_exception(e) from e
|
|
84
|
+
return original_run(self_tool, *args, **kwargs)
|
|
85
|
+
|
|
86
|
+
cls._run = wrapped_run
|
|
87
|
+
|
|
88
|
+
if original_arun is not None:
|
|
89
|
+
@functools.wraps(original_arun)
|
|
90
|
+
async def wrapped_arun(self_tool, *args, **kwargs):
|
|
91
|
+
call_id = str(uuid.uuid4())[:8]
|
|
92
|
+
call_args = _extract_args(args, kwargs)
|
|
93
|
+
try:
|
|
94
|
+
self.sdk.enforce(
|
|
95
|
+
role_id = scopebound_role,
|
|
96
|
+
tool_name = self_tool.name,
|
|
97
|
+
call_args = call_args,
|
|
98
|
+
call_id = call_id,
|
|
99
|
+
)
|
|
100
|
+
except ScopeboundDenyError as e:
|
|
101
|
+
raise _make_tool_exception(e) from e
|
|
102
|
+
return await original_arun(self_tool, *args, **kwargs)
|
|
103
|
+
|
|
104
|
+
cls._arun = wrapped_arun
|
|
105
|
+
|
|
106
|
+
return cls
|
|
107
|
+
|
|
108
|
+
return decorator
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def crew_session(self) -> Generator[None, None, None]:
|
|
112
|
+
"""
|
|
113
|
+
Context manager that provisions sessions for all roles at entry
|
|
114
|
+
and revokes them at exit.
|
|
115
|
+
|
|
116
|
+
Usage:
|
|
117
|
+
with enforcer.crew_session():
|
|
118
|
+
crew.kickoff()
|
|
119
|
+
"""
|
|
120
|
+
# Provision a session for each role at kickoff start.
|
|
121
|
+
for scopebound_role in self.roles.values():
|
|
122
|
+
self.sdk.provision(role_id=scopebound_role)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
yield
|
|
126
|
+
finally:
|
|
127
|
+
# Revoke all sessions at kickoff end.
|
|
128
|
+
self.sdk.revoke()
|
|
129
|
+
self._active_sessions.clear()
|
|
130
|
+
|
|
131
|
+
def revoke_all(self) -> None:
|
|
132
|
+
"""Revoke all active sessions. Called by atexit handler."""
|
|
133
|
+
self.sdk.revoke()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _extract_args(args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
137
|
+
result: dict[str, Any] = {}
|
|
138
|
+
if args:
|
|
139
|
+
result["args"] = list(args)
|
|
140
|
+
result.update(kwargs)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _make_tool_exception(deny_error: ScopeboundDenyError) -> Exception:
|
|
145
|
+
"""
|
|
146
|
+
Wrap ScopeboundDenyError in a CrewAI ToolException if crewai is installed,
|
|
147
|
+
otherwise re-raise the original error. This allows CrewAI error handling
|
|
148
|
+
to work correctly while keeping crewai as an optional dependency.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
from crewai.tools.tool_usage import ToolException
|
|
152
|
+
return ToolException(str(deny_error))
|
|
153
|
+
except ImportError:
|
|
154
|
+
return deny_error
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import atexit
|
|
3
|
+
import functools
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Callable, Type, TypeVar
|
|
6
|
+
from langchain_core.tools import BaseTool
|
|
7
|
+
from ..client import ScopeboundSDK
|
|
8
|
+
from ..exceptions import ScopeboundDenyError
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=BaseTool)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def enforce(sdk: ScopeboundSDK, role: str) -> Callable:
|
|
14
|
+
"""Class decorator that wraps a LangChain BaseTool with Scopebound enforcement."""
|
|
15
|
+
def decorator(cls):
|
|
16
|
+
original_run = cls._run
|
|
17
|
+
original_arun = getattr(cls, "_arun", None)
|
|
18
|
+
|
|
19
|
+
@functools.wraps(original_run)
|
|
20
|
+
def wrapped_run(self, *args, **kwargs):
|
|
21
|
+
call_id = str(uuid.uuid4())[:8]
|
|
22
|
+
sdk.enforce(
|
|
23
|
+
role_id=role,
|
|
24
|
+
tool_name=self.name,
|
|
25
|
+
call_args=_extract_args(args, kwargs),
|
|
26
|
+
call_id=call_id,
|
|
27
|
+
)
|
|
28
|
+
return original_run(self, *args, **kwargs)
|
|
29
|
+
|
|
30
|
+
cls._run = wrapped_run
|
|
31
|
+
|
|
32
|
+
if original_arun is not None:
|
|
33
|
+
@functools.wraps(original_arun)
|
|
34
|
+
async def wrapped_arun(self, *args, **kwargs):
|
|
35
|
+
call_id = str(uuid.uuid4())[:8]
|
|
36
|
+
sdk.enforce(
|
|
37
|
+
role_id=role,
|
|
38
|
+
tool_name=self.name,
|
|
39
|
+
call_args=_extract_args(args, kwargs),
|
|
40
|
+
call_id=call_id,
|
|
41
|
+
)
|
|
42
|
+
return await original_arun(self, *args, **kwargs)
|
|
43
|
+
cls._arun = wrapped_arun
|
|
44
|
+
|
|
45
|
+
atexit.register(sdk.revoke)
|
|
46
|
+
return cls
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_args(args, kwargs):
|
|
52
|
+
result = {}
|
|
53
|
+
if args:
|
|
54
|
+
result["args"] = list(args)
|
|
55
|
+
result.update(kwargs)
|
|
56
|
+
return result
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from ..client import ScopeboundSDK
|
|
5
|
+
from ..exceptions import ScopeboundDenyError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Type aliases matching the OpenAI SDK structures we interact with.
|
|
9
|
+
# We use duck typing rather than importing openai directly so the adapter
|
|
10
|
+
# works without openai as a hard dependency.
|
|
11
|
+
ToolCall = Any # openai.types.beta.threads.required_action_submit_tool_outputs.ToolCall
|
|
12
|
+
Handler = Callable[[str, dict], str] # handler(tool_name, arguments) -> output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScopeboundSession:
|
|
16
|
+
"""
|
|
17
|
+
Wraps the OpenAI Assistants API run polling loop with Scopebound enforcement.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
sb = ScopeboundSDK(base_url="http://localhost:8080")
|
|
21
|
+
session = ScopeboundSession(sb, role="invoice-processor")
|
|
22
|
+
|
|
23
|
+
# In your run polling loop:
|
|
24
|
+
tool_outputs = session.execute_tool_calls(
|
|
25
|
+
tool_calls=run.required_action.submit_tool_outputs.tool_calls,
|
|
26
|
+
handlers={
|
|
27
|
+
"read_invoices": read_invoices_handler,
|
|
28
|
+
"send_email": send_email_handler,
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
client.beta.threads.runs.submit_tool_outputs(
|
|
32
|
+
thread_id=thread.id,
|
|
33
|
+
run_id=run.id,
|
|
34
|
+
tool_outputs=tool_outputs,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Denied tool calls are submitted as structured error JSON so the LLM
|
|
38
|
+
can handle them gracefully rather than causing the run to fail.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, sdk: ScopeboundSDK, role: str):
|
|
42
|
+
"""
|
|
43
|
+
Args:
|
|
44
|
+
sdk: A ScopeboundSDK instance. The session JWT is provisioned
|
|
45
|
+
lazily on the first execute_tool_calls() call and reused.
|
|
46
|
+
role: The agent role to enforce against.
|
|
47
|
+
"""
|
|
48
|
+
self.sdk = sdk
|
|
49
|
+
self.role = role
|
|
50
|
+
|
|
51
|
+
def execute_tool_calls(
|
|
52
|
+
self,
|
|
53
|
+
tool_calls: list[ToolCall],
|
|
54
|
+
handlers: dict[str, Handler],
|
|
55
|
+
) -> list[dict[str, str]]:
|
|
56
|
+
"""
|
|
57
|
+
Enforce and execute a list of tool calls from an Assistants API run.
|
|
58
|
+
|
|
59
|
+
For each tool_call:
|
|
60
|
+
- Sends /v1/enforce with the tool name and arguments.
|
|
61
|
+
- If allowed: calls the corresponding handler and collects its output.
|
|
62
|
+
- If denied: submits a structured error JSON as the tool output so
|
|
63
|
+
the LLM can handle the denial gracefully.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
tool_calls: The list of tool calls from
|
|
67
|
+
run.required_action.submit_tool_outputs.tool_calls.
|
|
68
|
+
handlers: Dict mapping tool function names to callables.
|
|
69
|
+
Each callable receives (tool_name, arguments: dict) -> str.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A list of tool output dicts ready for submit_tool_outputs:
|
|
73
|
+
[{"tool_call_id": "...", "output": "..."}, ...]
|
|
74
|
+
"""
|
|
75
|
+
outputs = []
|
|
76
|
+
for tool_call in tool_calls:
|
|
77
|
+
output = self._handle_single(tool_call, handlers)
|
|
78
|
+
outputs.append({
|
|
79
|
+
"tool_call_id": tool_call.id,
|
|
80
|
+
"output": output,
|
|
81
|
+
})
|
|
82
|
+
return outputs
|
|
83
|
+
|
|
84
|
+
def _handle_single(self, tool_call: ToolCall, handlers: dict[str, Handler]) -> str:
|
|
85
|
+
"""Enforce and execute a single tool call. Returns the output string."""
|
|
86
|
+
func = tool_call.function
|
|
87
|
+
tool_name = func.name
|
|
88
|
+
call_id = tool_call.id # use Assistants API ID for audit correlation
|
|
89
|
+
|
|
90
|
+
# Parse arguments — Assistants API returns them as a JSON string.
|
|
91
|
+
try:
|
|
92
|
+
arguments = json.loads(func.arguments or "{}")
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
arguments = {}
|
|
95
|
+
|
|
96
|
+
# Enforce the call.
|
|
97
|
+
try:
|
|
98
|
+
self.sdk.enforce(
|
|
99
|
+
role_id = self.role,
|
|
100
|
+
tool_name = tool_name,
|
|
101
|
+
call_args = arguments,
|
|
102
|
+
call_id = call_id[:128], # enforce 128-char limit
|
|
103
|
+
)
|
|
104
|
+
except ScopeboundDenyError as e:
|
|
105
|
+
# Submit denial as structured JSON — LLM handles it gracefully.
|
|
106
|
+
return json.dumps({
|
|
107
|
+
"error": e.deny_code,
|
|
108
|
+
"reason": e.reason,
|
|
109
|
+
"retry_guidance": e.retry_guidance,
|
|
110
|
+
})
|
|
111
|
+
except Exception as e:
|
|
112
|
+
# Unexpected error from enforcement plane.
|
|
113
|
+
return json.dumps({"error": "ENFORCEMENT_ERROR", "reason": str(e)})
|
|
114
|
+
|
|
115
|
+
# Call the handler.
|
|
116
|
+
handler = handlers.get(tool_name)
|
|
117
|
+
if handler is None:
|
|
118
|
+
return json.dumps({
|
|
119
|
+
"error": "HANDLER_NOT_FOUND",
|
|
120
|
+
"reason": f"No handler registered for tool '{tool_name}'",
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
result = handler(tool_name, arguments)
|
|
125
|
+
# Handlers must return a string — coerce if needed.
|
|
126
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return json.dumps({"error": "HANDLER_ERROR", "reason": str(e)})
|
|
129
|
+
|
|
130
|
+
def revoke(self) -> None:
|
|
131
|
+
"""Revoke the underlying session. Call when the assistant run is complete."""
|
|
132
|
+
self.sdk.revoke()
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> "ScopeboundSession":
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *_: Any) -> None:
|
|
138
|
+
self.revoke()
|