sentinel-oversight 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.
- sentinel_oversight-0.1.0/.gitignore +14 -0
- sentinel_oversight-0.1.0/LICENSE +22 -0
- sentinel_oversight-0.1.0/PKG-INFO +82 -0
- sentinel_oversight-0.1.0/README.md +66 -0
- sentinel_oversight-0.1.0/pyproject.toml +28 -0
- sentinel_oversight-0.1.0/sentinel/__init__.py +21 -0
- sentinel_oversight-0.1.0/sentinel/adapters/__init__.py +0 -0
- sentinel_oversight-0.1.0/sentinel/adapters/langchain.py +71 -0
- sentinel_oversight-0.1.0/sentinel/client.py +156 -0
- sentinel_oversight-0.1.0/sentinel/config.py +45 -0
- sentinel_oversight-0.1.0/sentinel/decorator.py +149 -0
- sentinel_oversight-0.1.0/sentinel/exceptions.py +25 -0
- sentinel_oversight-0.1.0/tests/__init__.py +0 -0
- sentinel_oversight-0.1.0/tests/test_decorator.py +58 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher Sellers / RegEngine, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sentinel-oversight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Sentinel human-in-the-loop oversight
|
|
5
|
+
Project-URL: Homepage, https://oversight.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/PetrefiedThunder/sentinel-sdk
|
|
7
|
+
Author: Christopher Sellers / RegEngine, Inc.
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Sentinel SDK
|
|
18
|
+
|
|
19
|
+
[](https://opensource.org/licenses/MIT)
|
|
20
|
+
[](https://pypi.org/project/sentinel-oversight/)
|
|
21
|
+
|
|
22
|
+
**Oversight infrastructure for AI agents.**
|
|
23
|
+
|
|
24
|
+
Sentinel adds human-in-the-loop approval to any Python function your agent calls.
|
|
25
|
+
Wrap the function with `@oversight`, and the SDK pauses execution, requests
|
|
26
|
+
approval, and only runs once a human approves.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install sentinel-oversight
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from sentinel import configure, oversight
|
|
38
|
+
|
|
39
|
+
configure(api_key="sk_live_...")
|
|
40
|
+
|
|
41
|
+
@oversight(
|
|
42
|
+
risk_level="high",
|
|
43
|
+
approvers=["slack://channel/agent-approvals"],
|
|
44
|
+
timeout_seconds=300,
|
|
45
|
+
)
|
|
46
|
+
def transfer_funds(amount: int, recipient: str):
|
|
47
|
+
return stripe.transfers.create(amount=amount, destination=recipient)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
When your agent calls `transfer_funds(1000, "acct_xyz")`, Sentinel pauses
|
|
51
|
+
execution, posts an approval card to Slack, and only runs the function once a
|
|
52
|
+
human clicks Approve. If rejected, `ApprovalRejected` is raised. If no response
|
|
53
|
+
within `timeout_seconds`, `ApprovalTimeout` is raised (unless `fallback="execute"`).
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Set via `configure(...)` or env vars:
|
|
58
|
+
|
|
59
|
+
- `SENTINEL_API_URL` (default `https://api.oversight.sh`)
|
|
60
|
+
- `SENTINEL_API_KEY`
|
|
61
|
+
- `SENTINEL_TIMEOUT` (default `300`)
|
|
62
|
+
- `SENTINEL_FALLBACK` (default `reject`)
|
|
63
|
+
|
|
64
|
+
## LangChain
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sentinel.adapters.langchain import SentinelCallbackHandler
|
|
68
|
+
|
|
69
|
+
agent.run("...", callbacks=[SentinelCallbackHandler(risk_level="high")])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Install with `pip install sentinel-oversight[langchain]`.
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- Website: https://oversight.sh
|
|
77
|
+
- API repo: https://github.com/PetrefiedThunder/sentinel-api
|
|
78
|
+
- This SDK: https://github.com/PetrefiedThunder/sentinel-sdk
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Sentinel SDK
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://pypi.org/project/sentinel-oversight/)
|
|
5
|
+
|
|
6
|
+
**Oversight infrastructure for AI agents.**
|
|
7
|
+
|
|
8
|
+
Sentinel adds human-in-the-loop approval to any Python function your agent calls.
|
|
9
|
+
Wrap the function with `@oversight`, and the SDK pauses execution, requests
|
|
10
|
+
approval, and only runs once a human approves.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install sentinel-oversight
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from sentinel import configure, oversight
|
|
22
|
+
|
|
23
|
+
configure(api_key="sk_live_...")
|
|
24
|
+
|
|
25
|
+
@oversight(
|
|
26
|
+
risk_level="high",
|
|
27
|
+
approvers=["slack://channel/agent-approvals"],
|
|
28
|
+
timeout_seconds=300,
|
|
29
|
+
)
|
|
30
|
+
def transfer_funds(amount: int, recipient: str):
|
|
31
|
+
return stripe.transfers.create(amount=amount, destination=recipient)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When your agent calls `transfer_funds(1000, "acct_xyz")`, Sentinel pauses
|
|
35
|
+
execution, posts an approval card to Slack, and only runs the function once a
|
|
36
|
+
human clicks Approve. If rejected, `ApprovalRejected` is raised. If no response
|
|
37
|
+
within `timeout_seconds`, `ApprovalTimeout` is raised (unless `fallback="execute"`).
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Set via `configure(...)` or env vars:
|
|
42
|
+
|
|
43
|
+
- `SENTINEL_API_URL` (default `https://api.oversight.sh`)
|
|
44
|
+
- `SENTINEL_API_KEY`
|
|
45
|
+
- `SENTINEL_TIMEOUT` (default `300`)
|
|
46
|
+
- `SENTINEL_FALLBACK` (default `reject`)
|
|
47
|
+
|
|
48
|
+
## LangChain
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sentinel.adapters.langchain import SentinelCallbackHandler
|
|
52
|
+
|
|
53
|
+
agent.run("...", callbacks=[SentinelCallbackHandler(risk_level="high")])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Install with `pip install sentinel-oversight[langchain]`.
|
|
57
|
+
|
|
58
|
+
## Links
|
|
59
|
+
|
|
60
|
+
- Website: https://oversight.sh
|
|
61
|
+
- API repo: https://github.com/PetrefiedThunder/sentinel-api
|
|
62
|
+
- This SDK: https://github.com/PetrefiedThunder/sentinel-sdk
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sentinel-oversight"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Sentinel human-in-the-loop oversight"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Christopher Sellers / RegEngine, Inc." }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"pytest-asyncio",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://oversight.sh"
|
|
25
|
+
Repository = "https://github.com/PetrefiedThunder/sentinel-sdk"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["sentinel"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Sentinel SDK — oversight infrastructure for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .client import SentinelClient
|
|
4
|
+
from .config import SentinelConfig, configure, get_config
|
|
5
|
+
from .decorator import oversight
|
|
6
|
+
from .exceptions import ApprovalRejected, ApprovalTimeout, SentinelConfigError, SentinelError
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"oversight",
|
|
12
|
+
"SentinelClient",
|
|
13
|
+
"SentinelConfig",
|
|
14
|
+
"configure",
|
|
15
|
+
"get_config",
|
|
16
|
+
"SentinelError",
|
|
17
|
+
"SentinelConfigError",
|
|
18
|
+
"ApprovalRejected",
|
|
19
|
+
"ApprovalTimeout",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""LangChain adapter for Sentinel.
|
|
2
|
+
|
|
3
|
+
Provides a callback handler that creates an approval request when a tool starts
|
|
4
|
+
and blocks until a decision is reached.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from ..client import SentinelClient
|
|
12
|
+
from ..exceptions import ApprovalRejected
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SentinelCallbackHandler:
|
|
16
|
+
"""LangChain callback handler that gates tool execution behind Sentinel approvals.
|
|
17
|
+
|
|
18
|
+
Inherits from langchain_core.callbacks.BaseCallbackHandler at instantiation
|
|
19
|
+
time so the langchain dependency stays optional.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __new__(cls, *args, **kwargs):
|
|
23
|
+
try:
|
|
24
|
+
from langchain_core.callbacks import BaseCallbackHandler # type: ignore
|
|
25
|
+
except ImportError as e:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"langchain-core is required for SentinelCallbackHandler. "
|
|
28
|
+
"Install with: pip install sentinel-oversight[langchain]"
|
|
29
|
+
) from e
|
|
30
|
+
|
|
31
|
+
# Dynamically create a subclass mixing in BaseCallbackHandler.
|
|
32
|
+
if not issubclass(cls, BaseCallbackHandler):
|
|
33
|
+
new_cls = type(cls.__name__, (cls, BaseCallbackHandler), {})
|
|
34
|
+
instance = object.__new__(new_cls)
|
|
35
|
+
return instance
|
|
36
|
+
return object.__new__(cls)
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
client: Optional[SentinelClient] = None,
|
|
41
|
+
risk_level: str = "medium",
|
|
42
|
+
approvers: Optional[list] = None,
|
|
43
|
+
timeout_seconds: Optional[float] = None,
|
|
44
|
+
):
|
|
45
|
+
self.client = client or SentinelClient()
|
|
46
|
+
self.risk_level = risk_level
|
|
47
|
+
self.approvers = approvers
|
|
48
|
+
self.timeout_seconds = timeout_seconds
|
|
49
|
+
|
|
50
|
+
def on_tool_start(
|
|
51
|
+
self,
|
|
52
|
+
serialized: dict,
|
|
53
|
+
input_str: str,
|
|
54
|
+
**kwargs: Any,
|
|
55
|
+
) -> None:
|
|
56
|
+
tool_name = (serialized or {}).get("name", "unknown_tool")
|
|
57
|
+
approval = self.client.create_approval(
|
|
58
|
+
function_name=tool_name,
|
|
59
|
+
arguments={"input": input_str},
|
|
60
|
+
risk_level=self.risk_level,
|
|
61
|
+
approvers=self.approvers,
|
|
62
|
+
timeout_seconds=self.timeout_seconds,
|
|
63
|
+
)
|
|
64
|
+
action_id = approval.get("action_id") or approval.get("id")
|
|
65
|
+
decision = self.client.wait_for_decision(action_id, timeout=self.timeout_seconds)
|
|
66
|
+
status = decision.get("status") or decision.get("decision")
|
|
67
|
+
if status != "approved":
|
|
68
|
+
raise ApprovalRejected(
|
|
69
|
+
reason=decision.get("reason", "Tool execution not approved"),
|
|
70
|
+
action_id=action_id,
|
|
71
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import SentinelConfig, get_config
|
|
10
|
+
from .exceptions import ApprovalTimeout, SentinelConfigError, SentinelError
|
|
11
|
+
|
|
12
|
+
USER_AGENT = "sentinel-sdk-python/0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SentinelClient:
|
|
16
|
+
def __init__(self, config: Optional[SentinelConfig] = None):
|
|
17
|
+
self.config = config or get_config()
|
|
18
|
+
|
|
19
|
+
# ------------- internals -------------
|
|
20
|
+
def _headers(self) -> dict:
|
|
21
|
+
if not self.config.api_key:
|
|
22
|
+
raise SentinelConfigError("Call sentinel.configure(api_key=...) before using Sentinel")
|
|
23
|
+
h = {"User-Agent": USER_AGENT, "Content-Type": "application/json"}
|
|
24
|
+
h["Authorization"] = f"Bearer {self.config.api_key}"
|
|
25
|
+
return h
|
|
26
|
+
|
|
27
|
+
def _url(self, path: str) -> str:
|
|
28
|
+
return f"{self.config.api_url.rstrip('/')}{path}"
|
|
29
|
+
|
|
30
|
+
# ------------- sync API -------------
|
|
31
|
+
def create_approval(
|
|
32
|
+
self,
|
|
33
|
+
function_name: str,
|
|
34
|
+
arguments: Any,
|
|
35
|
+
risk_level: str = "medium",
|
|
36
|
+
approvers: Optional[list] = None,
|
|
37
|
+
timeout_seconds: Optional[float] = None,
|
|
38
|
+
) -> dict:
|
|
39
|
+
payload = {
|
|
40
|
+
"function_name": function_name,
|
|
41
|
+
"arguments": arguments,
|
|
42
|
+
"risk_level": risk_level,
|
|
43
|
+
"approvers": approvers or [],
|
|
44
|
+
"timeout_seconds": timeout_seconds or self.config.timeout_seconds,
|
|
45
|
+
}
|
|
46
|
+
with httpx.Client(timeout=30.0) as c:
|
|
47
|
+
r = c.post(self._url("/v1/approvals"), json=payload, headers=self._headers())
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
return r.json()
|
|
50
|
+
|
|
51
|
+
def get_approval(self, action_id: str) -> dict:
|
|
52
|
+
with httpx.Client(timeout=30.0) as c:
|
|
53
|
+
r = c.get(self._url(f"/v1/approvals/{action_id}"), headers=self._headers())
|
|
54
|
+
r.raise_for_status()
|
|
55
|
+
return r.json()
|
|
56
|
+
|
|
57
|
+
def wait_for_decision(
|
|
58
|
+
self,
|
|
59
|
+
action_id: str,
|
|
60
|
+
timeout: Optional[float] = None,
|
|
61
|
+
poll_interval: Optional[float] = None,
|
|
62
|
+
) -> dict:
|
|
63
|
+
timeout = timeout or self.config.timeout_seconds
|
|
64
|
+
poll_interval = poll_interval or self.config.poll_interval
|
|
65
|
+
deadline = time.monotonic() + timeout
|
|
66
|
+
while True:
|
|
67
|
+
data = self.get_approval(action_id)
|
|
68
|
+
status = data.get("status") or data.get("decision")
|
|
69
|
+
if status in ("approved", "rejected"):
|
|
70
|
+
return data
|
|
71
|
+
if time.monotonic() >= deadline:
|
|
72
|
+
raise ApprovalTimeout(action_id=action_id, timeout_seconds=timeout)
|
|
73
|
+
time.sleep(poll_interval)
|
|
74
|
+
|
|
75
|
+
def emit_audit_event(
|
|
76
|
+
self,
|
|
77
|
+
action_id: str,
|
|
78
|
+
execution_result: Any = None,
|
|
79
|
+
error: Optional[str] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
payload = {
|
|
82
|
+
"action_id": action_id,
|
|
83
|
+
"execution_result": execution_result,
|
|
84
|
+
"error": error,
|
|
85
|
+
}
|
|
86
|
+
try:
|
|
87
|
+
with httpx.Client(timeout=10.0) as c:
|
|
88
|
+
c.post(self._url("/v1/audit-events"), json=payload, headers=self._headers())
|
|
89
|
+
except Exception:
|
|
90
|
+
# best-effort — never raises
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# ------------- async API -------------
|
|
94
|
+
async def acreate_approval(
|
|
95
|
+
self,
|
|
96
|
+
function_name: str,
|
|
97
|
+
arguments: Any,
|
|
98
|
+
risk_level: str = "medium",
|
|
99
|
+
approvers: Optional[list] = None,
|
|
100
|
+
timeout_seconds: Optional[float] = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
payload = {
|
|
103
|
+
"function_name": function_name,
|
|
104
|
+
"arguments": arguments,
|
|
105
|
+
"risk_level": risk_level,
|
|
106
|
+
"approvers": approvers or [],
|
|
107
|
+
"timeout_seconds": timeout_seconds or self.config.timeout_seconds,
|
|
108
|
+
}
|
|
109
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
110
|
+
r = await c.post(self._url("/v1/approvals"), json=payload, headers=self._headers())
|
|
111
|
+
r.raise_for_status()
|
|
112
|
+
return r.json()
|
|
113
|
+
|
|
114
|
+
async def aget_approval(self, action_id: str) -> dict:
|
|
115
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
116
|
+
r = await c.get(self._url(f"/v1/approvals/{action_id}"), headers=self._headers())
|
|
117
|
+
r.raise_for_status()
|
|
118
|
+
return r.json()
|
|
119
|
+
|
|
120
|
+
async def await_for_decision(
|
|
121
|
+
self,
|
|
122
|
+
action_id: str,
|
|
123
|
+
timeout: Optional[float] = None,
|
|
124
|
+
poll_interval: Optional[float] = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
timeout = timeout or self.config.timeout_seconds
|
|
127
|
+
poll_interval = poll_interval or self.config.poll_interval
|
|
128
|
+
deadline = time.monotonic() + timeout
|
|
129
|
+
while True:
|
|
130
|
+
data = await self.aget_approval(action_id)
|
|
131
|
+
status = data.get("status") or data.get("decision")
|
|
132
|
+
if status in ("approved", "rejected"):
|
|
133
|
+
return data
|
|
134
|
+
if time.monotonic() >= deadline:
|
|
135
|
+
raise ApprovalTimeout(action_id=action_id, timeout_seconds=timeout)
|
|
136
|
+
await asyncio.sleep(poll_interval)
|
|
137
|
+
|
|
138
|
+
async def aemit_audit_event(
|
|
139
|
+
self,
|
|
140
|
+
action_id: str,
|
|
141
|
+
execution_result: Any = None,
|
|
142
|
+
error: Optional[str] = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
payload = {
|
|
145
|
+
"action_id": action_id,
|
|
146
|
+
"execution_result": execution_result,
|
|
147
|
+
"error": error,
|
|
148
|
+
}
|
|
149
|
+
try:
|
|
150
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
151
|
+
await c.post(self._url("/v1/audit-events"), json=payload, headers=self._headers())
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["SentinelClient", "SentinelError"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field, replace
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _default_api_url() -> str:
|
|
7
|
+
return os.environ.get("SENTINEL_API_URL", "https://api.oversight.sh")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _default_api_key() -> Optional[str]:
|
|
11
|
+
return os.environ.get("SENTINEL_API_KEY")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_timeout() -> float:
|
|
15
|
+
try:
|
|
16
|
+
return float(os.environ.get("SENTINEL_TIMEOUT", "300"))
|
|
17
|
+
except ValueError:
|
|
18
|
+
return 300.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_fallback() -> str:
|
|
22
|
+
return os.environ.get("SENTINEL_FALLBACK", "reject")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SentinelConfig:
|
|
27
|
+
api_url: str = field(default_factory=_default_api_url)
|
|
28
|
+
api_key: Optional[str] = field(default_factory=_default_api_key)
|
|
29
|
+
timeout_seconds: float = field(default_factory=_default_timeout)
|
|
30
|
+
poll_interval: float = 2.0
|
|
31
|
+
fallback: str = field(default_factory=_default_fallback)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_config: SentinelConfig = SentinelConfig()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure(**kwargs) -> SentinelConfig:
|
|
38
|
+
"""Mutate the module-global config."""
|
|
39
|
+
global _config
|
|
40
|
+
_config = replace(_config, **kwargs)
|
|
41
|
+
return _config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_config() -> SentinelConfig:
|
|
45
|
+
return _config
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .client import SentinelClient
|
|
8
|
+
from .config import get_config
|
|
9
|
+
from .exceptions import ApprovalRejected, ApprovalTimeout
|
|
10
|
+
|
|
11
|
+
_PRIMITIVES = (str, int, float, bool, type(None))
|
|
12
|
+
_MAX_REPR = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _truncate_repr(obj: Any) -> str:
|
|
16
|
+
s = repr(obj)
|
|
17
|
+
if len(s) > _MAX_REPR:
|
|
18
|
+
return s[:_MAX_REPR] + "...<truncated>"
|
|
19
|
+
return s
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _serialize_arguments(value: Any) -> Any:
|
|
23
|
+
if isinstance(value, _PRIMITIVES):
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, (list, tuple)):
|
|
26
|
+
return [_serialize_arguments(v) for v in value]
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return {str(k): _serialize_arguments(v) for k, v in value.items()}
|
|
29
|
+
return _truncate_repr(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _bound_arguments(fn: Callable, args: tuple, kwargs: dict) -> dict:
|
|
33
|
+
signature = inspect.signature(fn)
|
|
34
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
35
|
+
bound.apply_defaults()
|
|
36
|
+
return _serialize_arguments(dict(bound.arguments))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def oversight(
|
|
40
|
+
risk_level: str = "medium",
|
|
41
|
+
approvers: Optional[list] = None,
|
|
42
|
+
timeout_seconds: Optional[float] = None,
|
|
43
|
+
fallback: Optional[str] = None,
|
|
44
|
+
) -> Callable:
|
|
45
|
+
def decorator(fn: Callable) -> Callable:
|
|
46
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
47
|
+
|
|
48
|
+
if is_async:
|
|
49
|
+
@functools.wraps(fn)
|
|
50
|
+
async def awrapper(*args, **kwargs):
|
|
51
|
+
cfg = get_config()
|
|
52
|
+
client = SentinelClient(cfg)
|
|
53
|
+
fb = fallback or cfg.fallback
|
|
54
|
+
arguments = _bound_arguments(fn, args, kwargs)
|
|
55
|
+
approval = await client.acreate_approval(
|
|
56
|
+
function_name=fn.__name__,
|
|
57
|
+
arguments=arguments,
|
|
58
|
+
risk_level=risk_level,
|
|
59
|
+
approvers=approvers,
|
|
60
|
+
timeout_seconds=timeout_seconds,
|
|
61
|
+
)
|
|
62
|
+
action_id = approval.get("action_id") or approval.get("id")
|
|
63
|
+
try:
|
|
64
|
+
decision = await client.await_for_decision(
|
|
65
|
+
action_id, timeout=timeout_seconds
|
|
66
|
+
)
|
|
67
|
+
except ApprovalTimeout:
|
|
68
|
+
if fb == "execute":
|
|
69
|
+
result = await fn(*args, **kwargs)
|
|
70
|
+
await client.aemit_audit_event(
|
|
71
|
+
action_id, execution_result=_truncate_repr(result),
|
|
72
|
+
error="timeout-fallback-execute",
|
|
73
|
+
)
|
|
74
|
+
return result
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
status = decision.get("status") or decision.get("decision")
|
|
78
|
+
if status == "approved":
|
|
79
|
+
try:
|
|
80
|
+
result = await fn(*args, **kwargs)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
await client.aemit_audit_event(
|
|
83
|
+
action_id, execution_result=None, error=_truncate_repr(e)
|
|
84
|
+
)
|
|
85
|
+
raise
|
|
86
|
+
await client.aemit_audit_event(
|
|
87
|
+
action_id, execution_result=_truncate_repr(result)
|
|
88
|
+
)
|
|
89
|
+
return result
|
|
90
|
+
if status == "rejected":
|
|
91
|
+
raise ApprovalRejected(
|
|
92
|
+
reason=decision.get("reason", ""), action_id=action_id
|
|
93
|
+
)
|
|
94
|
+
raise ApprovalRejected(
|
|
95
|
+
reason=f"Unknown decision status: {status}", action_id=action_id
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return awrapper
|
|
99
|
+
|
|
100
|
+
@functools.wraps(fn)
|
|
101
|
+
def wrapper(*args, **kwargs):
|
|
102
|
+
cfg = get_config()
|
|
103
|
+
client = SentinelClient(cfg)
|
|
104
|
+
fb = fallback or cfg.fallback
|
|
105
|
+
arguments = _bound_arguments(fn, args, kwargs)
|
|
106
|
+
approval = client.create_approval(
|
|
107
|
+
function_name=fn.__name__,
|
|
108
|
+
arguments=arguments,
|
|
109
|
+
risk_level=risk_level,
|
|
110
|
+
approvers=approvers,
|
|
111
|
+
timeout_seconds=timeout_seconds,
|
|
112
|
+
)
|
|
113
|
+
action_id = approval.get("action_id") or approval.get("id")
|
|
114
|
+
try:
|
|
115
|
+
decision = client.wait_for_decision(action_id, timeout=timeout_seconds)
|
|
116
|
+
except ApprovalTimeout:
|
|
117
|
+
if fb == "execute":
|
|
118
|
+
result = fn(*args, **kwargs)
|
|
119
|
+
client.emit_audit_event(
|
|
120
|
+
action_id, execution_result=_truncate_repr(result),
|
|
121
|
+
error="timeout-fallback-execute",
|
|
122
|
+
)
|
|
123
|
+
return result
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
status = decision.get("status") or decision.get("decision")
|
|
127
|
+
if status == "approved":
|
|
128
|
+
try:
|
|
129
|
+
result = fn(*args, **kwargs)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
client.emit_audit_event(
|
|
132
|
+
action_id, execution_result=None, error=_truncate_repr(e)
|
|
133
|
+
)
|
|
134
|
+
raise
|
|
135
|
+
client.emit_audit_event(
|
|
136
|
+
action_id, execution_result=_truncate_repr(result)
|
|
137
|
+
)
|
|
138
|
+
return result
|
|
139
|
+
if status == "rejected":
|
|
140
|
+
raise ApprovalRejected(
|
|
141
|
+
reason=decision.get("reason", ""), action_id=action_id
|
|
142
|
+
)
|
|
143
|
+
raise ApprovalRejected(
|
|
144
|
+
reason=f"Unknown decision status: {status}", action_id=action_id
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return wrapper
|
|
148
|
+
|
|
149
|
+
return decorator
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class SentinelError(Exception):
|
|
2
|
+
"""Base error for the Sentinel SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SentinelConfigError(SentinelError):
|
|
6
|
+
"""Raised when the SDK is used without required configuration."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApprovalRejected(SentinelError):
|
|
10
|
+
"""Raised when an approval request is rejected by an approver."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, reason: str = "", action_id: str | None = None):
|
|
13
|
+
self.reason = reason
|
|
14
|
+
self.action_id = action_id
|
|
15
|
+
super().__init__(reason or "Approval rejected")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApprovalTimeout(SentinelError):
|
|
19
|
+
"""Raised when an approval request times out without a decision."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, action_id: str | None = None, timeout_seconds: float | None = None):
|
|
22
|
+
self.action_id = action_id
|
|
23
|
+
self.timeout_seconds = timeout_seconds
|
|
24
|
+
msg = f"Approval timed out after {timeout_seconds}s" if timeout_seconds else "Approval timed out"
|
|
25
|
+
super().__init__(msg)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from unittest.mock import MagicMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from sentinel import ApprovalRejected, SentinelClient, SentinelConfigError, configure, oversight
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@patch("sentinel.decorator.SentinelClient")
|
|
9
|
+
def test_approval_success(mock_client_cls):
|
|
10
|
+
mock_client = MagicMock()
|
|
11
|
+
mock_client.create_approval.return_value = {"action_id": "act_1"}
|
|
12
|
+
mock_client.wait_for_decision.return_value = {"status": "approved"}
|
|
13
|
+
mock_client_cls.return_value = mock_client
|
|
14
|
+
|
|
15
|
+
@oversight(risk_level="high")
|
|
16
|
+
def transfer_funds(amount, to):
|
|
17
|
+
return {"amount": amount, "to": to}
|
|
18
|
+
|
|
19
|
+
result = transfer_funds(100, to="alice")
|
|
20
|
+
assert result == {"amount": 100, "to": "alice"}
|
|
21
|
+
mock_client.create_approval.assert_called_once_with(
|
|
22
|
+
function_name="transfer_funds",
|
|
23
|
+
arguments={"amount": 100, "to": "alice"},
|
|
24
|
+
risk_level="high",
|
|
25
|
+
approvers=None,
|
|
26
|
+
timeout_seconds=None,
|
|
27
|
+
)
|
|
28
|
+
mock_client.wait_for_decision.assert_called_once_with("act_1", timeout=None)
|
|
29
|
+
mock_client.emit_audit_event.assert_called_once()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@patch("sentinel.decorator.SentinelClient")
|
|
33
|
+
def test_rejection(mock_client_cls):
|
|
34
|
+
mock_client = MagicMock()
|
|
35
|
+
mock_client.create_approval.return_value = {"action_id": "act_2"}
|
|
36
|
+
mock_client.wait_for_decision.return_value = {
|
|
37
|
+
"status": "rejected",
|
|
38
|
+
"reason": "too risky",
|
|
39
|
+
}
|
|
40
|
+
mock_client_cls.return_value = mock_client
|
|
41
|
+
|
|
42
|
+
ran = {"v": False}
|
|
43
|
+
|
|
44
|
+
@oversight()
|
|
45
|
+
def dangerous():
|
|
46
|
+
ran["v"] = True
|
|
47
|
+
return "done"
|
|
48
|
+
|
|
49
|
+
with pytest.raises(ApprovalRejected) as exc:
|
|
50
|
+
dangerous()
|
|
51
|
+
assert "too risky" in str(exc.value)
|
|
52
|
+
assert ran["v"] is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_requires_api_key_before_network_calls():
|
|
56
|
+
configure(api_key=None)
|
|
57
|
+
with pytest.raises(SentinelConfigError):
|
|
58
|
+
SentinelClient().create_approval(function_name="dangerous", arguments={})
|