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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .coverage
11
+ .env
12
+ .venv/
13
+ venv/
14
+
@@ -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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
20
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.1.0-blue.svg)](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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.1.0-blue.svg)](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={})