governance-sdk 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.
- governance_sdk-0.1.0/PKG-INFO +133 -0
- governance_sdk-0.1.0/README.md +120 -0
- governance_sdk-0.1.0/governance_sdk/__init__.py +81 -0
- governance_sdk-0.1.0/governance_sdk/client.py +331 -0
- governance_sdk-0.1.0/governance_sdk/config.py +48 -0
- governance_sdk-0.1.0/governance_sdk/context.py +177 -0
- governance_sdk-0.1.0/governance_sdk/exceptions.py +11 -0
- governance_sdk-0.1.0/governance_sdk/instrumentor.py +333 -0
- governance_sdk-0.1.0/governance_sdk.egg-info/PKG-INFO +133 -0
- governance_sdk-0.1.0/governance_sdk.egg-info/SOURCES.txt +13 -0
- governance_sdk-0.1.0/governance_sdk.egg-info/dependency_links.txt +1 -0
- governance_sdk-0.1.0/governance_sdk.egg-info/requires.txt +5 -0
- governance_sdk-0.1.0/governance_sdk.egg-info/top_level.txt +1 -0
- governance_sdk-0.1.0/pyproject.toml +23 -0
- governance_sdk-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: governance-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A plug-and-play SDK to capture and audit tool calls in Agentic AI applications
|
|
5
|
+
Author-email: Sanjay Nithin <sanjaynithin220@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.25.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
12
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# Governance SDK (AI Frameworks Edition)
|
|
15
|
+
|
|
16
|
+
A lightweight, non-blocking, plug-and-play Python SDK to automatically intercept, evaluate, and authorize tool calls in LangChain, LangGraph, and CrewAI agentic applications.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Multi-Framework Auto-Interception**: Automatically intercepts tool calls in **LangChain**, **LangGraph**, and **CrewAI** (by hooking base tool run/arun methods) with zero configuration.
|
|
21
|
+
- **Zero-Touch Automatic Context Capture**: Walks up the Python execution stack frames to auto-resolve active context details such as `session_id`, `agent_name`, `purpose`, and `trace_id` by inspecting local variables. No manual wrapping required!
|
|
22
|
+
- **Three-Tier Governance Workflow**: Evaluates risk and routes tool execution decisions:
|
|
23
|
+
- `allow`: Automatically executes the tool.
|
|
24
|
+
- `preview_and_confirmation`: Triggers an interactive CLI prompt to confirm execution.
|
|
25
|
+
- `needs_full_review`: Triggers an interactive CLI prompt indicating full review is required.
|
|
26
|
+
- **Exception-Based Control**: If a developer/user denies an execution prompt, the SDK raises `PermissionDeniedError` or `ReviewRequiredError`, letting the calling application abort or handle it gracefully.
|
|
27
|
+
- **Non-Blocking Asynchronous Auditing**: Completed tool logs are queued in-memory and batch-shipped to the Governance Server asynchronously by a background worker thread.
|
|
28
|
+
- **Fail-safe & Resilient**: Operates with exponential backoff on server log shipment delivery failure.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Install the package in editable mode:
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
Initialize the SDK at the entry point of your agentic application.
|
|
44
|
+
|
|
45
|
+
### 1. SDK Initialization
|
|
46
|
+
```python
|
|
47
|
+
import governance_sdk
|
|
48
|
+
|
|
49
|
+
governance_sdk.init(
|
|
50
|
+
server_url="http://127.0.0.1:8000/api/v1/tool-calls",
|
|
51
|
+
risk_check_url="http://127.0.0.1:8000/api/v1/risk-checks",
|
|
52
|
+
project_name="customer-support-agent"
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You can also use environment variables:
|
|
57
|
+
```bash
|
|
58
|
+
export GOVERNANCE_SERVER_URL="http://127.0.0.1:8000/api/v1/tool-calls"
|
|
59
|
+
export GOVERNANCE_RISK_CHECK_URL="http://127.0.0.1:8000/api/v1/risk-checks"
|
|
60
|
+
export GOVERNANCE_PROJECT_NAME="customer-support-agent"
|
|
61
|
+
```
|
|
62
|
+
And simply call `init()`:
|
|
63
|
+
```python
|
|
64
|
+
import governance_sdk
|
|
65
|
+
governance_sdk.init()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Auto-Interception & Context Capture
|
|
69
|
+
Once `init()` is called, any tool executed by your agent in LangChain, LangGraph, or CrewAI will be automatically intercepted:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain_core.tools import tool
|
|
73
|
+
|
|
74
|
+
@tool
|
|
75
|
+
def delete_user_data(user_id: str) -> str:
|
|
76
|
+
"""Deletes sensitive user information."""
|
|
77
|
+
return f"User {user_id} deleted."
|
|
78
|
+
|
|
79
|
+
# In your agent logic:
|
|
80
|
+
# When this tool is called, the SDK automatically walks the stack to extract:
|
|
81
|
+
# - session_id (resolves from variables named session_id, sid, session, etc.)
|
|
82
|
+
# - agent_name (resolves from class name or variable agent_name)
|
|
83
|
+
# - purpose (resolves from variables named purpose, intent, reason, etc.)
|
|
84
|
+
# Then it verifies the risk score on the server side.
|
|
85
|
+
delete_user_data.run({"user_id": "usr_992"})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3. Explicit Context Override (Optional)
|
|
89
|
+
If you want to manually specify context instead of relying on the auto-captured stack frames, use the `agent_context` manager:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import governance_sdk
|
|
93
|
+
|
|
94
|
+
with governance_sdk.agent_context(agent_name="Supervisor-Agent", session_id="session_override_123", purpose="cleanup"):
|
|
95
|
+
# All tool calls executed inside this block will prioritize these values
|
|
96
|
+
agent.run("delete temporary files")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Captured Payload Format
|
|
102
|
+
|
|
103
|
+
The JSON payload sent to the governance logging server has the following structure:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"project_name": "customer-support-agent",
|
|
108
|
+
"tool_calls": [
|
|
109
|
+
{
|
|
110
|
+
"tool_name": "delete_user_data",
|
|
111
|
+
"tool_description": "Deletes sensitive user information.",
|
|
112
|
+
"arguments": { "user_id": "usr_992" },
|
|
113
|
+
"output": "User usr_992 deleted.",
|
|
114
|
+
"error": null,
|
|
115
|
+
"status": "success",
|
|
116
|
+
"timestamp_start": "2026-07-30T13:30:00Z",
|
|
117
|
+
"timestamp_end": "2026-07-30T13:30:00.045Z",
|
|
118
|
+
"duration_ms": 45,
|
|
119
|
+
"context": {
|
|
120
|
+
"agent_name": "Supervisor-Agent",
|
|
121
|
+
"session_id": "session_override_123",
|
|
122
|
+
"purpose": "cleanup",
|
|
123
|
+
"caller_filename": "main.py",
|
|
124
|
+
"caller_line_number": 42,
|
|
125
|
+
"caller_function": "execute_task"
|
|
126
|
+
},
|
|
127
|
+
"risk_score": 0.90,
|
|
128
|
+
"risk_category": "high_risk",
|
|
129
|
+
"governance_decision": "allow"
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
```
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Governance SDK (AI Frameworks Edition)
|
|
2
|
+
|
|
3
|
+
A lightweight, non-blocking, plug-and-play Python SDK to automatically intercept, evaluate, and authorize tool calls in LangChain, LangGraph, and CrewAI agentic applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multi-Framework Auto-Interception**: Automatically intercepts tool calls in **LangChain**, **LangGraph**, and **CrewAI** (by hooking base tool run/arun methods) with zero configuration.
|
|
8
|
+
- **Zero-Touch Automatic Context Capture**: Walks up the Python execution stack frames to auto-resolve active context details such as `session_id`, `agent_name`, `purpose`, and `trace_id` by inspecting local variables. No manual wrapping required!
|
|
9
|
+
- **Three-Tier Governance Workflow**: Evaluates risk and routes tool execution decisions:
|
|
10
|
+
- `allow`: Automatically executes the tool.
|
|
11
|
+
- `preview_and_confirmation`: Triggers an interactive CLI prompt to confirm execution.
|
|
12
|
+
- `needs_full_review`: Triggers an interactive CLI prompt indicating full review is required.
|
|
13
|
+
- **Exception-Based Control**: If a developer/user denies an execution prompt, the SDK raises `PermissionDeniedError` or `ReviewRequiredError`, letting the calling application abort or handle it gracefully.
|
|
14
|
+
- **Non-Blocking Asynchronous Auditing**: Completed tool logs are queued in-memory and batch-shipped to the Governance Server asynchronously by a background worker thread.
|
|
15
|
+
- **Fail-safe & Resilient**: Operates with exponential backoff on server log shipment delivery failure.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Install the package in editable mode:
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
Initialize the SDK at the entry point of your agentic application.
|
|
31
|
+
|
|
32
|
+
### 1. SDK Initialization
|
|
33
|
+
```python
|
|
34
|
+
import governance_sdk
|
|
35
|
+
|
|
36
|
+
governance_sdk.init(
|
|
37
|
+
server_url="http://127.0.0.1:8000/api/v1/tool-calls",
|
|
38
|
+
risk_check_url="http://127.0.0.1:8000/api/v1/risk-checks",
|
|
39
|
+
project_name="customer-support-agent"
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You can also use environment variables:
|
|
44
|
+
```bash
|
|
45
|
+
export GOVERNANCE_SERVER_URL="http://127.0.0.1:8000/api/v1/tool-calls"
|
|
46
|
+
export GOVERNANCE_RISK_CHECK_URL="http://127.0.0.1:8000/api/v1/risk-checks"
|
|
47
|
+
export GOVERNANCE_PROJECT_NAME="customer-support-agent"
|
|
48
|
+
```
|
|
49
|
+
And simply call `init()`:
|
|
50
|
+
```python
|
|
51
|
+
import governance_sdk
|
|
52
|
+
governance_sdk.init()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Auto-Interception & Context Capture
|
|
56
|
+
Once `init()` is called, any tool executed by your agent in LangChain, LangGraph, or CrewAI will be automatically intercepted:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from langchain_core.tools import tool
|
|
60
|
+
|
|
61
|
+
@tool
|
|
62
|
+
def delete_user_data(user_id: str) -> str:
|
|
63
|
+
"""Deletes sensitive user information."""
|
|
64
|
+
return f"User {user_id} deleted."
|
|
65
|
+
|
|
66
|
+
# In your agent logic:
|
|
67
|
+
# When this tool is called, the SDK automatically walks the stack to extract:
|
|
68
|
+
# - session_id (resolves from variables named session_id, sid, session, etc.)
|
|
69
|
+
# - agent_name (resolves from class name or variable agent_name)
|
|
70
|
+
# - purpose (resolves from variables named purpose, intent, reason, etc.)
|
|
71
|
+
# Then it verifies the risk score on the server side.
|
|
72
|
+
delete_user_data.run({"user_id": "usr_992"})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 3. Explicit Context Override (Optional)
|
|
76
|
+
If you want to manually specify context instead of relying on the auto-captured stack frames, use the `agent_context` manager:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import governance_sdk
|
|
80
|
+
|
|
81
|
+
with governance_sdk.agent_context(agent_name="Supervisor-Agent", session_id="session_override_123", purpose="cleanup"):
|
|
82
|
+
# All tool calls executed inside this block will prioritize these values
|
|
83
|
+
agent.run("delete temporary files")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Captured Payload Format
|
|
89
|
+
|
|
90
|
+
The JSON payload sent to the governance logging server has the following structure:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"project_name": "customer-support-agent",
|
|
95
|
+
"tool_calls": [
|
|
96
|
+
{
|
|
97
|
+
"tool_name": "delete_user_data",
|
|
98
|
+
"tool_description": "Deletes sensitive user information.",
|
|
99
|
+
"arguments": { "user_id": "usr_992" },
|
|
100
|
+
"output": "User usr_992 deleted.",
|
|
101
|
+
"error": null,
|
|
102
|
+
"status": "success",
|
|
103
|
+
"timestamp_start": "2026-07-30T13:30:00Z",
|
|
104
|
+
"timestamp_end": "2026-07-30T13:30:00.045Z",
|
|
105
|
+
"duration_ms": 45,
|
|
106
|
+
"context": {
|
|
107
|
+
"agent_name": "Supervisor-Agent",
|
|
108
|
+
"session_id": "session_override_123",
|
|
109
|
+
"purpose": "cleanup",
|
|
110
|
+
"caller_filename": "main.py",
|
|
111
|
+
"caller_line_number": 42,
|
|
112
|
+
"caller_function": "execute_task"
|
|
113
|
+
},
|
|
114
|
+
"risk_score": 0.90,
|
|
115
|
+
"risk_category": "high_risk",
|
|
116
|
+
"governance_decision": "allow"
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any
|
|
2
|
+
|
|
3
|
+
from governance_sdk.config import SDKConfig
|
|
4
|
+
from governance_sdk.client import GovernanceClient
|
|
5
|
+
from governance_sdk.context import agent_context
|
|
6
|
+
from governance_sdk.exceptions import GovernanceError, PermissionDeniedError, ReviewRequiredError
|
|
7
|
+
from governance_sdk.instrumentor import patch_all
|
|
8
|
+
|
|
9
|
+
_active_client: Optional[GovernanceClient] = None
|
|
10
|
+
|
|
11
|
+
def init(
|
|
12
|
+
api_key: str,
|
|
13
|
+
server_url: Optional[str] = None,
|
|
14
|
+
risk_check_url: Optional[str] = None,
|
|
15
|
+
project_name: Optional[str] = None,
|
|
16
|
+
enabled: Optional[bool] = None,
|
|
17
|
+
batch_size: Optional[int] = None,
|
|
18
|
+
flush_interval: Optional[float] = None,
|
|
19
|
+
max_queue_size: Optional[int] = None,
|
|
20
|
+
fallback_policy: Optional[str] = None,
|
|
21
|
+
risk_threshold: Optional[float] = None,
|
|
22
|
+
extra_metadata: Optional[Dict[str, Any]] = None
|
|
23
|
+
) -> Optional[GovernanceClient]:
|
|
24
|
+
"""
|
|
25
|
+
Initializes the Governance SDK.
|
|
26
|
+
Starts the background worker queue and patches LangChain tool calling mechanisms.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
import governance_sdk
|
|
30
|
+
governance_sdk.init(
|
|
31
|
+
server_url="https://governance.yourorg.com/api/v1/tool-calls",
|
|
32
|
+
project_name="customer-support-agent"
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
global _active_client
|
|
36
|
+
|
|
37
|
+
config = SDKConfig(
|
|
38
|
+
server_url=server_url,
|
|
39
|
+
risk_check_url=risk_check_url,
|
|
40
|
+
api_key=api_key,
|
|
41
|
+
project_name=project_name,
|
|
42
|
+
enabled=enabled,
|
|
43
|
+
batch_size=batch_size,
|
|
44
|
+
flush_interval=flush_interval,
|
|
45
|
+
max_queue_size=max_queue_size,
|
|
46
|
+
fallback_policy=fallback_policy,
|
|
47
|
+
risk_threshold=risk_threshold,
|
|
48
|
+
extra_metadata=extra_metadata
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not config.enabled: # is governance server enabled or not
|
|
52
|
+
if _active_client:
|
|
53
|
+
_active_client.shutdown()
|
|
54
|
+
_active_client = None
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not _active_client:
|
|
58
|
+
_active_client = GovernanceClient(config)
|
|
59
|
+
_active_client.start()
|
|
60
|
+
else:
|
|
61
|
+
_active_client.config = config
|
|
62
|
+
_active_client.start()
|
|
63
|
+
|
|
64
|
+
# Auto-patch LangChain tool calling
|
|
65
|
+
patch_all(_active_client)
|
|
66
|
+
|
|
67
|
+
return _active_client
|
|
68
|
+
|
|
69
|
+
def get_active_client() -> Optional[GovernanceClient]:
|
|
70
|
+
"""Retrieves the active global governance client."""
|
|
71
|
+
return _active_client
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"init",
|
|
75
|
+
"agent_context",
|
|
76
|
+
"get_active_client",
|
|
77
|
+
"GovernanceError",
|
|
78
|
+
"PermissionDeniedError",
|
|
79
|
+
"ReviewRequiredError"
|
|
80
|
+
]
|
|
81
|
+
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, Any, List, Optional
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
|
|
11
|
+
from governance_sdk.config import SDKConfig
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("governance_sdk")
|
|
14
|
+
|
|
15
|
+
def safe_serialize(obj: Any) -> Any:
|
|
16
|
+
"""Safely serializes objects that might not be JSON serializable."""
|
|
17
|
+
try:
|
|
18
|
+
json.dumps(obj)
|
|
19
|
+
return obj
|
|
20
|
+
except (TypeError, OverflowError):
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
return {str(k): safe_serialize(v) for k, v in obj.items()}
|
|
23
|
+
elif isinstance(obj, (list, tuple, set)):
|
|
24
|
+
return [safe_serialize(x) for x in obj]
|
|
25
|
+
else:
|
|
26
|
+
try:
|
|
27
|
+
if hasattr(obj, "__dict__"):
|
|
28
|
+
return safe_serialize(obj.__dict__)
|
|
29
|
+
return repr(obj)
|
|
30
|
+
except Exception:
|
|
31
|
+
return str(obj)
|
|
32
|
+
|
|
33
|
+
class GovernanceClient:
|
|
34
|
+
def __init__(self, config: SDKConfig):
|
|
35
|
+
self.config = config
|
|
36
|
+
self.queue: queue.Queue = queue.Queue(maxsize=config.max_queue_size)
|
|
37
|
+
self.stop_event = threading.Event()
|
|
38
|
+
self.worker_thread: Optional[threading.Thread] = None
|
|
39
|
+
self.lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
def start(self) -> None:
|
|
42
|
+
"""Starts the background worker thread."""
|
|
43
|
+
with self.lock:
|
|
44
|
+
if self.worker_thread and self.worker_thread.is_alive():
|
|
45
|
+
return
|
|
46
|
+
self.stop_event.clear()
|
|
47
|
+
self.worker_thread = threading.Thread(
|
|
48
|
+
target=self._worker_loop,
|
|
49
|
+
name="governance-sdk-worker",
|
|
50
|
+
daemon=True
|
|
51
|
+
)
|
|
52
|
+
self.worker_thread.start()
|
|
53
|
+
atexit.register(self.shutdown)
|
|
54
|
+
|
|
55
|
+
def send_tool_call(self, tool_call_data: Dict[str, Any]) -> None:
|
|
56
|
+
"""Enqueues a tool call payload without blocking."""
|
|
57
|
+
if not self.config.is_configured():
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not self.worker_thread or not self.worker_thread.is_alive():
|
|
61
|
+
self.start()
|
|
62
|
+
|
|
63
|
+
sanitized = safe_serialize(tool_call_data)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
self.queue.put_nowait(sanitized)
|
|
67
|
+
except queue.Full:
|
|
68
|
+
logger.warning("Governance SDK queue is full. Dropping tool call report.")
|
|
69
|
+
|
|
70
|
+
def check_tool_risk(
|
|
71
|
+
self,
|
|
72
|
+
tool_name: str,
|
|
73
|
+
tool_description: str,
|
|
74
|
+
arguments: Any,
|
|
75
|
+
context: Dict[str, Any]
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Synchronously queries the governance server to evaluate the risk score of a tool call before execution.
|
|
79
|
+
Categorizes risk and returns decision (allow, preview_and_confirmation, needs_full_review).
|
|
80
|
+
"""
|
|
81
|
+
if not self.config.is_configured():
|
|
82
|
+
return {
|
|
83
|
+
"decision": "allow",
|
|
84
|
+
"risk_score": 0.0,
|
|
85
|
+
"risk_category": "safe",
|
|
86
|
+
"reason": "Governance SDK is disabled or not configured"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
payload = {
|
|
90
|
+
"project_name": self.config.project_name,
|
|
91
|
+
"tool_name": tool_name,
|
|
92
|
+
"tool_description": tool_description,
|
|
93
|
+
"arguments": safe_serialize(arguments),
|
|
94
|
+
"context": safe_serialize(context)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
data = json.dumps(payload).encode("utf-8")
|
|
98
|
+
headers = {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"User-Agent": "governance-sdk-python/0.1.0"
|
|
101
|
+
}
|
|
102
|
+
if self.config.api_key:
|
|
103
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
self.config.risk_check_url,
|
|
108
|
+
data=data,
|
|
109
|
+
headers=headers,
|
|
110
|
+
method="POST"
|
|
111
|
+
)
|
|
112
|
+
# Use a 10-second timeout for risk assessment to support multi-agent analysis
|
|
113
|
+
with urllib.request.urlopen(req, timeout=10.0) as response:
|
|
114
|
+
if 200 <= response.status < 300:
|
|
115
|
+
resp_data = json.loads(response.read().decode("utf-8"))
|
|
116
|
+
|
|
117
|
+
risk_score = float(resp_data.get("risk_score", 0.0))
|
|
118
|
+
raw_decision = resp_data.get("decision", "allow").lower()
|
|
119
|
+
|
|
120
|
+
# Categorize risk score if not provided by server
|
|
121
|
+
if "risk_category" in resp_data:
|
|
122
|
+
risk_category = resp_data["risk_category"].lower()
|
|
123
|
+
else:
|
|
124
|
+
if risk_score >= 0.80:
|
|
125
|
+
risk_category = "high_risk"
|
|
126
|
+
elif risk_score >= 0.60:
|
|
127
|
+
risk_category = "medium_risk"
|
|
128
|
+
elif risk_score >= 0.30:
|
|
129
|
+
risk_category = "low_risk"
|
|
130
|
+
else:
|
|
131
|
+
risk_category = "safe"
|
|
132
|
+
|
|
133
|
+
reason = resp_data.get("reason", "Approved by default policy")
|
|
134
|
+
|
|
135
|
+
# Local risk override guardrail: map high risk or threshold exceedance to user prompt
|
|
136
|
+
if raw_decision == "allow" and risk_category == "high_risk":
|
|
137
|
+
decision = "preview_and_confirmation"
|
|
138
|
+
reason = f"Local override: High risk score ({risk_score}) requires permission. {reason}"
|
|
139
|
+
elif raw_decision == "allow" and risk_score > self.config.risk_threshold:
|
|
140
|
+
decision = "preview_and_confirmation"
|
|
141
|
+
reason = f"Local override: Threshold exceeded ({risk_score} > {self.config.risk_threshold}) requires permission. {reason}"
|
|
142
|
+
else:
|
|
143
|
+
# Standardize decision to the three allowed options
|
|
144
|
+
if raw_decision in ("preview_and_confirmation", "needs_permission"):
|
|
145
|
+
decision = "preview_and_confirmation"
|
|
146
|
+
elif raw_decision in ("needs_full_review", "block"):
|
|
147
|
+
decision = "needs_full_review"
|
|
148
|
+
else:
|
|
149
|
+
decision = "allow"
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"decision": decision,
|
|
153
|
+
"risk_score": risk_score,
|
|
154
|
+
"risk_category": risk_category,
|
|
155
|
+
"reason": reason
|
|
156
|
+
}
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.warning(f"Governance Server risk-check failed: {e}. Falling back to '{self.config.fallback_policy}' policy.")
|
|
159
|
+
|
|
160
|
+
# Fallback handling
|
|
161
|
+
fallback_decision = self.config.fallback_policy
|
|
162
|
+
if fallback_decision == "policy":
|
|
163
|
+
return self._evaluate_local_fallback_policy(tool_name, tool_description, arguments)
|
|
164
|
+
elif fallback_decision == "block":
|
|
165
|
+
return {
|
|
166
|
+
"decision": "needs_full_review",
|
|
167
|
+
"risk_score": 1.0,
|
|
168
|
+
"risk_category": "high_risk",
|
|
169
|
+
"reason": "Server unreachable. Applied fallback closed policy: block."
|
|
170
|
+
}
|
|
171
|
+
else:
|
|
172
|
+
return {
|
|
173
|
+
"decision": "allow",
|
|
174
|
+
"risk_score": 0.0,
|
|
175
|
+
"risk_category": "safe",
|
|
176
|
+
"reason": "Server unreachable. Applied fallback open policy: allow."
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def _evaluate_local_fallback_policy(self, tool_name: str, tool_description: str, arguments: Any) -> Dict[str, Any]:
|
|
180
|
+
"""
|
|
181
|
+
Evaluates a local rules-based safety policy when the Governance Server is unreachable.
|
|
182
|
+
"""
|
|
183
|
+
tool_lower = tool_name.lower()
|
|
184
|
+
desc_lower = (tool_description or "").lower()
|
|
185
|
+
args_str = str(arguments).lower()
|
|
186
|
+
|
|
187
|
+
# 1. Check for destructive commands/shell execution
|
|
188
|
+
destructive_commands = ["rm -rf", "rm -f", "mkfs", "dd if=", "reboot", "shutdown", "chmod 777", "chown"]
|
|
189
|
+
shell_keywords = ["shell", "terminal", "exec", "command", "system", "run"]
|
|
190
|
+
|
|
191
|
+
is_shell_tool = any(kw in tool_lower for kw in shell_keywords)
|
|
192
|
+
has_destructive = any(cmd in args_str for cmd in destructive_commands)
|
|
193
|
+
|
|
194
|
+
if is_shell_tool and has_destructive:
|
|
195
|
+
return {
|
|
196
|
+
"decision": "needs_full_review",
|
|
197
|
+
"risk_score": 0.95,
|
|
198
|
+
"risk_category": "high_risk",
|
|
199
|
+
"reason": "Server unreachable. Local policy block: Destructive command detected in shell execution."
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# 2. Check for credential leaks / sensitive reads
|
|
203
|
+
sensitive_patterns = ["akiat", "akia", "slack_token", "stripe", "github_token", "password", "private_key", ".pem", "id_rsa"]
|
|
204
|
+
has_sensitive = any(pat in args_str for pat in sensitive_patterns)
|
|
205
|
+
has_sensitive_files = any(f in args_str for f in ["/etc/passwd", "/etc/shadow", ".ssh/"])
|
|
206
|
+
|
|
207
|
+
if has_sensitive or has_sensitive_files:
|
|
208
|
+
return {
|
|
209
|
+
"decision": "preview_and_confirmation",
|
|
210
|
+
"risk_score": 0.85,
|
|
211
|
+
"risk_category": "high_risk",
|
|
212
|
+
"reason": "Server unreachable. Local policy warning: Sensitive data or credential leak risk detected."
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# 3. Check for general database or filesystem deletion
|
|
216
|
+
delete_keywords = ["delete", "remove", "destroy", "rm", "drop", "truncate"]
|
|
217
|
+
is_delete_op = any(kw in tool_lower for kw in delete_keywords) or any(kw in desc_lower for kw in delete_keywords)
|
|
218
|
+
|
|
219
|
+
if is_delete_op:
|
|
220
|
+
return {
|
|
221
|
+
"decision": "preview_and_confirmation",
|
|
222
|
+
"risk_score": 0.70,
|
|
223
|
+
"risk_category": "medium_risk",
|
|
224
|
+
"reason": "Server unreachable. Local policy warning: Destructive delete/remove operation detected."
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# 4. Otherwise allow
|
|
228
|
+
return {
|
|
229
|
+
"decision": "allow",
|
|
230
|
+
"risk_score": 0.15,
|
|
231
|
+
"risk_category": "safe",
|
|
232
|
+
"reason": "Server unreachable. Local policy allow: No high-risk patterns matched."
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _worker_loop(self) -> None:
|
|
237
|
+
"""Background thread loop that batches and sends tool logs."""
|
|
238
|
+
batch_size = self.config.batch_size
|
|
239
|
+
flush_interval = self.config.flush_interval
|
|
240
|
+
|
|
241
|
+
while not self.stop_event.is_set():
|
|
242
|
+
batch = self._gather_batch(batch_size, timeout=flush_interval)
|
|
243
|
+
if batch:
|
|
244
|
+
self._send_batch_with_retry(batch)
|
|
245
|
+
|
|
246
|
+
self._flush_remaining()
|
|
247
|
+
|
|
248
|
+
def _gather_batch(self, batch_size: int, timeout: float) -> List[Dict[str, Any]]:
|
|
249
|
+
batch = []
|
|
250
|
+
start_time = time.time()
|
|
251
|
+
|
|
252
|
+
while len(batch) < batch_size:
|
|
253
|
+
remaining_time = timeout - (time.time() - start_time)
|
|
254
|
+
if remaining_time <= 0:
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
item = self.queue.get(timeout=max(0.01, remaining_time))
|
|
259
|
+
batch.append(item)
|
|
260
|
+
self.queue.task_done()
|
|
261
|
+
except queue.Empty:
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
return batch
|
|
265
|
+
|
|
266
|
+
def _send_batch_with_retry(self, batch: List[Dict[str, Any]]) -> None:
|
|
267
|
+
payload = {
|
|
268
|
+
"project_name": self.config.project_name,
|
|
269
|
+
"tool_calls": batch
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
data = json.dumps(payload).encode("utf-8")
|
|
273
|
+
headers = {
|
|
274
|
+
"Content-Type": "application/json",
|
|
275
|
+
"User-Agent": "governance-sdk-python/0.1.0"
|
|
276
|
+
}
|
|
277
|
+
if self.config.api_key:
|
|
278
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
279
|
+
|
|
280
|
+
backoff = 1.0
|
|
281
|
+
max_backoff = 30.0
|
|
282
|
+
retries = 3
|
|
283
|
+
|
|
284
|
+
for attempt in range(retries):
|
|
285
|
+
if self.stop_event.is_set() and attempt > 0:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
req = urllib.request.Request(
|
|
290
|
+
self.config.server_url,
|
|
291
|
+
data=data,
|
|
292
|
+
headers=headers,
|
|
293
|
+
method="POST"
|
|
294
|
+
)
|
|
295
|
+
with urllib.request.urlopen(req, timeout=5.0) as response:
|
|
296
|
+
if 200 <= response.status < 300:
|
|
297
|
+
return
|
|
298
|
+
except (urllib.error.URLError, Exception) as e:
|
|
299
|
+
if attempt < retries - 1:
|
|
300
|
+
time.sleep(backoff)
|
|
301
|
+
backoff = min(backoff * 2, max_backoff)
|
|
302
|
+
else:
|
|
303
|
+
logger.error(f"Governance SDK failed to send {len(batch)} tool logs: {e}")
|
|
304
|
+
|
|
305
|
+
def _flush_remaining(self) -> None:
|
|
306
|
+
while not self.queue.empty():
|
|
307
|
+
batch = []
|
|
308
|
+
while len(batch) < self.config.batch_size:
|
|
309
|
+
try:
|
|
310
|
+
item = self.queue.get_nowait()
|
|
311
|
+
batch.append(item)
|
|
312
|
+
self.queue.task_done()
|
|
313
|
+
except queue.Empty:
|
|
314
|
+
break
|
|
315
|
+
if batch:
|
|
316
|
+
self._send_batch_with_retry(batch)
|
|
317
|
+
|
|
318
|
+
def shutdown(self) -> None:
|
|
319
|
+
with self.lock:
|
|
320
|
+
if self.stop_event.is_set():
|
|
321
|
+
return
|
|
322
|
+
self.stop_event.set()
|
|
323
|
+
|
|
324
|
+
if self.worker_thread:
|
|
325
|
+
self.worker_thread.join(timeout=3.0)
|
|
326
|
+
self.worker_thread = None
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
atexit.unregister(self.shutdown)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|