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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
@@ -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()