opshield 0.2.0__py3-none-any.whl

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.
opshield/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """OpShield — rollback, cost control, and testing for AI agents."""
2
+
3
+ from opshield.shield import OpShield
4
+ from opshield.action import Action, ActionResult, RiskLevel
5
+ from opshield.approval import ApprovalManager
6
+ from opshield.async_shield import AsyncOpShield
7
+ from opshield.capabilities import CapabilityManager
8
+ from opshield.chaos import ChaosEngine, FaultType
9
+ from opshield.circuitbreaker import CircuitBreaker, CircuitState
10
+ from opshield.compliance import ComplianceReporter
11
+ from opshield.forecast import CostForecaster
12
+ from opshield.logger import ActionLogger
13
+ from opshield.masking import DataMasker
14
+ from opshield.multiagent import AgentPool
15
+ from opshield.policy import load_policy, validate_policy
16
+ from opshield.ratelimit import RateLimiter, ToolLimit
17
+ from opshield.replay import ActionReplayer
18
+ from opshield.retry import RetryPolicy
19
+ from opshield.scoring import AgentScorer, PoolScorer
20
+ from opshield.tracing import Tracer
21
+ from opshield.webhooks import EventNotifier
22
+ from opshield.config import rules_from_config
23
+
24
+ __all__ = [
25
+ "OpShield",
26
+ "AsyncOpShield",
27
+ "AgentPool",
28
+ "AgentScorer",
29
+ "ApprovalManager",
30
+ "Action",
31
+ "ActionResult",
32
+ "CapabilityManager",
33
+ "ChaosEngine",
34
+ "CircuitBreaker",
35
+ "CircuitState",
36
+ "ComplianceReporter",
37
+ "CostForecaster",
38
+ "FaultType",
39
+ "PoolScorer",
40
+ "RiskLevel",
41
+ "ActionLogger",
42
+ "ActionReplayer",
43
+ "DataMasker",
44
+ "RateLimiter",
45
+ "ToolLimit",
46
+ "RetryPolicy",
47
+ "Tracer",
48
+ "EventNotifier",
49
+ "load_policy",
50
+ "validate_policy",
51
+ "rules_from_config",
52
+ ]
53
+ __version__ = "0.2.0"
opshield/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from opshield.cli import main
2
+
3
+ main()
opshield/action.py ADDED
@@ -0,0 +1,50 @@
1
+ """Action model — represents a single agent action with metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+
12
+ class RiskLevel(Enum):
13
+ LOW = "low"
14
+ MEDIUM = "medium"
15
+ HIGH = "high"
16
+ CRITICAL = "critical"
17
+
18
+
19
+ @dataclass
20
+ class Action:
21
+ """A single action an agent wants to perform."""
22
+
23
+ tool_name: str
24
+ args: dict[str, Any] = field(default_factory=dict)
25
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
26
+ timestamp: float = field(default_factory=time.time)
27
+ risk: RiskLevel = RiskLevel.LOW
28
+ cost: float = 0.0
29
+
30
+ @property
31
+ def description(self) -> str:
32
+ args_str = ", ".join(f"{k}={v!r}" for k, v in self.args.items())
33
+ return f"{self.tool_name}({args_str})"
34
+
35
+
36
+ @dataclass
37
+ class ActionResult:
38
+ """The result of executing an action."""
39
+
40
+ action: Action
41
+ output: Any = None
42
+ error: str | None = None
43
+ blocked: bool = False
44
+ block_reason: str | None = None
45
+ snapshot_id: str | None = None
46
+ cost: float = 0.0
47
+
48
+ @property
49
+ def success(self) -> bool:
50
+ return not self.blocked and self.error is None
opshield/approval.py ADDED
@@ -0,0 +1,275 @@
1
+ """Human-in-the-loop approval workflows for AI agent actions.
2
+
3
+ Provides configurable approval gates that pause execution until a human
4
+ approves or denies the action. Supports timeout with auto-deny, callback-
5
+ based approval, and escalation chains.
6
+
7
+ Usage:
8
+ approver = ApprovalManager(
9
+ default_timeout=30.0,
10
+ on_approval_request=my_callback,
11
+ )
12
+
13
+ # Register tool-specific policies
14
+ approver.require_approval("send_email", timeout=60.0)
15
+ approver.require_approval("delete_record", auto_deny=True)
16
+
17
+ # Check in shield flow
18
+ decision = approver.request_approval(action)
19
+ if decision.approved:
20
+ execute(action)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import threading
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from enum import Enum
29
+
30
+
31
+ class ApprovalDecision(Enum):
32
+ APPROVED = "approved"
33
+ DENIED = "denied"
34
+ TIMEOUT = "timeout"
35
+ ESCALATED = "escalated"
36
+ NOT_REQUIRED = "not_required"
37
+
38
+
39
+ @dataclass
40
+ class ApprovalResult:
41
+ decision: ApprovalDecision
42
+ tool_name: str
43
+ approver: str | None = None
44
+ reason: str | None = None
45
+ elapsed: float = 0.0
46
+
47
+ @property
48
+ def approved(self) -> bool:
49
+ return self.decision in (
50
+ ApprovalDecision.APPROVED,
51
+ ApprovalDecision.NOT_REQUIRED,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class ApprovalPolicy:
57
+ timeout: float = 30.0
58
+ auto_deny_on_timeout: bool = True
59
+ escalate_to: callable | None = None
60
+ required_for_risk: str | None = None
61
+
62
+
63
+ class ApprovalManager:
64
+ """Manages human-in-the-loop approval for agent actions."""
65
+
66
+ def __init__(
67
+ self,
68
+ default_timeout: float = 30.0,
69
+ on_approval_request: callable | None = None,
70
+ auto_approve_low_risk: bool = True,
71
+ ) -> None:
72
+ self._default_timeout = default_timeout
73
+ self._on_request = on_approval_request
74
+ self._auto_approve_low = auto_approve_low_risk
75
+ self._policies: dict[str, ApprovalPolicy] = {}
76
+ self._pending: dict[str, threading.Event] = {}
77
+ self._decisions: dict[str, ApprovalResult] = {}
78
+ self._history: list[ApprovalResult] = []
79
+ self._lock = threading.Lock()
80
+
81
+ def require_approval(
82
+ self,
83
+ tool_name: str,
84
+ timeout: float | None = None,
85
+ auto_deny: bool = True,
86
+ escalate_to: callable | None = None,
87
+ ) -> None:
88
+ self._policies[tool_name] = ApprovalPolicy(
89
+ timeout=timeout or self._default_timeout,
90
+ auto_deny_on_timeout=auto_deny,
91
+ escalate_to=escalate_to,
92
+ )
93
+
94
+ def require_approval_for_risk(
95
+ self,
96
+ risk_level: str,
97
+ timeout: float | None = None,
98
+ auto_deny: bool = True,
99
+ ) -> None:
100
+ policy = ApprovalPolicy(
101
+ timeout=timeout or self._default_timeout,
102
+ auto_deny_on_timeout=auto_deny,
103
+ required_for_risk=risk_level.upper(),
104
+ )
105
+ self._policies[f"__risk__{risk_level.upper()}"] = policy
106
+
107
+ def needs_approval(self, tool_name: str, risk_level: str = "LOW") -> bool:
108
+ if tool_name in self._policies:
109
+ return True
110
+ risk_key = f"__risk__{risk_level.upper()}"
111
+ if risk_key in self._policies:
112
+ return True
113
+ return False
114
+
115
+ def request_approval(
116
+ self,
117
+ tool_name: str,
118
+ args: dict | None = None,
119
+ risk_level: str = "LOW",
120
+ action_id: str | None = None,
121
+ ) -> ApprovalResult:
122
+ if not self.needs_approval(tool_name, risk_level):
123
+ result = ApprovalResult(
124
+ decision=ApprovalDecision.NOT_REQUIRED,
125
+ tool_name=tool_name,
126
+ )
127
+ return result
128
+
129
+ policy = self._policies.get(tool_name)
130
+ if not policy:
131
+ risk_key = f"__risk__{risk_level.upper()}"
132
+ policy = self._policies.get(risk_key, ApprovalPolicy())
133
+
134
+ request_id = action_id or f"{tool_name}_{time.time()}"
135
+
136
+ event = threading.Event()
137
+ with self._lock:
138
+ self._pending[request_id] = event
139
+
140
+ if self._on_request:
141
+ self._on_request(
142
+ request_id=request_id,
143
+ tool_name=tool_name,
144
+ args=args or {},
145
+ risk_level=risk_level,
146
+ )
147
+
148
+ start = time.time()
149
+ approved = event.wait(timeout=policy.timeout)
150
+ elapsed = time.time() - start
151
+
152
+ with self._lock:
153
+ self._pending.pop(request_id, None)
154
+
155
+ if request_id in self._decisions:
156
+ result = self._decisions.pop(request_id)
157
+ result.elapsed = elapsed
158
+ elif approved:
159
+ result = ApprovalResult(
160
+ decision=ApprovalDecision.APPROVED,
161
+ tool_name=tool_name,
162
+ elapsed=elapsed,
163
+ )
164
+ else:
165
+ if policy.escalate_to:
166
+ try:
167
+ escalation = policy.escalate_to(
168
+ tool_name=tool_name,
169
+ args=args,
170
+ risk_level=risk_level,
171
+ )
172
+ if escalation:
173
+ result = ApprovalResult(
174
+ decision=ApprovalDecision.APPROVED,
175
+ tool_name=tool_name,
176
+ approver="escalation",
177
+ elapsed=elapsed,
178
+ )
179
+ else:
180
+ result = ApprovalResult(
181
+ decision=ApprovalDecision.ESCALATED,
182
+ tool_name=tool_name,
183
+ reason="Escalation denied",
184
+ elapsed=elapsed,
185
+ )
186
+ except Exception:
187
+ result = ApprovalResult(
188
+ decision=ApprovalDecision.DENIED,
189
+ tool_name=tool_name,
190
+ reason="Escalation failed",
191
+ elapsed=elapsed,
192
+ )
193
+ elif policy.auto_deny_on_timeout:
194
+ result = ApprovalResult(
195
+ decision=ApprovalDecision.TIMEOUT,
196
+ tool_name=tool_name,
197
+ reason=f"Approval timed out after {policy.timeout}s",
198
+ elapsed=elapsed,
199
+ )
200
+ else:
201
+ result = ApprovalResult(
202
+ decision=ApprovalDecision.DENIED,
203
+ tool_name=tool_name,
204
+ reason="No approval received",
205
+ elapsed=elapsed,
206
+ )
207
+
208
+ self._history.append(result)
209
+ return result
210
+
211
+ def approve(
212
+ self,
213
+ request_id: str,
214
+ approver: str | None = None,
215
+ reason: str | None = None,
216
+ ) -> bool:
217
+ with self._lock:
218
+ event = self._pending.get(request_id)
219
+ if not event:
220
+ return False
221
+ self._decisions[request_id] = ApprovalResult(
222
+ decision=ApprovalDecision.APPROVED,
223
+ tool_name="",
224
+ approver=approver,
225
+ reason=reason,
226
+ )
227
+ event.set()
228
+ return True
229
+
230
+ def deny(
231
+ self,
232
+ request_id: str,
233
+ approver: str | None = None,
234
+ reason: str | None = None,
235
+ ) -> bool:
236
+ with self._lock:
237
+ event = self._pending.get(request_id)
238
+ if not event:
239
+ return False
240
+ self._decisions[request_id] = ApprovalResult(
241
+ decision=ApprovalDecision.DENIED,
242
+ tool_name="",
243
+ approver=approver,
244
+ reason=reason,
245
+ )
246
+ event.set()
247
+ return True
248
+
249
+ @property
250
+ def pending_count(self) -> int:
251
+ with self._lock:
252
+ return len(self._pending)
253
+
254
+ @property
255
+ def history(self) -> list[ApprovalResult]:
256
+ return list(self._history)
257
+
258
+ def stats(self) -> dict:
259
+ total = len(self._history)
260
+ approved = sum(1 for r in self._history if r.approved)
261
+ denied = sum(
262
+ 1 for r in self._history
263
+ if r.decision == ApprovalDecision.DENIED
264
+ )
265
+ timed_out = sum(
266
+ 1 for r in self._history
267
+ if r.decision == ApprovalDecision.TIMEOUT
268
+ )
269
+ return {
270
+ "total_requests": total,
271
+ "approved": approved,
272
+ "denied": denied,
273
+ "timed_out": timed_out,
274
+ "pending": self.pending_count,
275
+ }
@@ -0,0 +1,195 @@
1
+ """Async version of OpShield for use with asyncio-based agent frameworks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import inspect
8
+ from typing import Any, Callable
9
+
10
+ from opshield.action import Action, ActionResult, RiskLevel
11
+ from opshield.shield import OpShield
12
+
13
+
14
+ class AsyncOpShield(OpShield):
15
+ """OpShield with async tool support.
16
+
17
+ Usage:
18
+ shield = AsyncOpShield(budget=5.00)
19
+
20
+ @shield.async_tool(cost=0.03)
21
+ async def query_db(sql: str) -> str:
22
+ return await db.execute(sql)
23
+
24
+ result = await query_db(sql="SELECT * FROM users")
25
+ """
26
+
27
+ def async_tool(
28
+ self,
29
+ fn: Callable | None = None,
30
+ *,
31
+ cost: float = 0.0,
32
+ risk: RiskLevel | None = None,
33
+ snapshot_state: Callable[[], Any] | None = None,
34
+ restore_state: Callable[[Any], None] | None = None,
35
+ ) -> Callable:
36
+
37
+ def decorator(func: Callable) -> Callable:
38
+ tool_name = func.__name__
39
+
40
+ if snapshot_state and restore_state:
41
+ self._tool_states[tool_name] = {
42
+ "capture": snapshot_state,
43
+ "restore": restore_state,
44
+ }
45
+
46
+ @functools.wraps(func)
47
+ async def wrapper(*args: Any, **kwargs: Any) -> ActionResult:
48
+ action = Action(
49
+ tool_name=tool_name,
50
+ args=kwargs if kwargs else {"args": list(args)},
51
+ cost=cost,
52
+ )
53
+ return await self._async_execute(action, func, args, kwargs, cost, risk)
54
+
55
+ wrapper._shield_tool = True
56
+ wrapper._tool_name = tool_name
57
+ return wrapper
58
+
59
+ if fn is not None:
60
+ return decorator(fn)
61
+ return decorator
62
+
63
+ async def _async_execute(
64
+ self,
65
+ action: Action,
66
+ func: Callable,
67
+ args: tuple,
68
+ kwargs: dict,
69
+ cost: float,
70
+ risk_override: RiskLevel | None,
71
+ ) -> ActionResult:
72
+ detected_risk, reasons = self._rules.evaluate(action)
73
+ action.risk = risk_override or detected_risk
74
+
75
+ if action.risk == RiskLevel.CRITICAL:
76
+ result = ActionResult(
77
+ action=action,
78
+ blocked=True,
79
+ block_reason=f"CRITICAL risk blocked: {'; '.join(reasons)}",
80
+ )
81
+ self._history.append(result)
82
+ if self._logger:
83
+ self._logger.log(result)
84
+ self._notifier.emit("action_blocked", {
85
+ "tool": action.tool_name, "reason": result.block_reason,
86
+ })
87
+ return result
88
+
89
+ if action.risk == RiskLevel.HIGH and self._on_block:
90
+ allowed = self._on_block(action, reasons)
91
+ if not allowed:
92
+ result = ActionResult(
93
+ action=action,
94
+ blocked=True,
95
+ block_reason=f"Blocked by user: {'; '.join(reasons)}",
96
+ )
97
+ self._history.append(result)
98
+ if self._logger:
99
+ self._logger.log(result)
100
+ self._notifier.emit("action_blocked", {
101
+ "tool": action.tool_name, "reason": result.block_reason,
102
+ })
103
+ return result
104
+
105
+ if self._budget.would_exceed(cost):
106
+ result = ActionResult(
107
+ action=action,
108
+ blocked=True,
109
+ block_reason=f"Budget exceeded (spent: ${self._budget.spent:.2f}, "
110
+ f"budget: ${self._budget.budget:.2f}, action cost: ${cost:.2f})",
111
+ )
112
+ self._history.append(result)
113
+ if self._logger:
114
+ self._logger.log(result)
115
+ self._notifier.emit("budget_exceeded", {
116
+ "tool": action.tool_name, "spent": self._budget.spent,
117
+ "budget": self._budget.budget,
118
+ })
119
+ return result
120
+
121
+ exceeded, rate_reason = self._rate_limiter.would_exceed(action.tool_name)
122
+ if exceeded:
123
+ result = ActionResult(
124
+ action=action,
125
+ blocked=True,
126
+ block_reason=rate_reason,
127
+ )
128
+ self._history.append(result)
129
+ if self._logger:
130
+ self._logger.log(result)
131
+ self._notifier.emit("rate_limited", {
132
+ "tool": action.tool_name, "reason": rate_reason,
133
+ })
134
+ return result
135
+
136
+ snapshot_id = None
137
+ if self._auto_snapshot and action.tool_name in self._tool_states:
138
+ state = self._tool_states[action.tool_name]["capture"]()
139
+ snap = self._snapshots.capture(action.id, action.tool_name, state)
140
+ snapshot_id = snap.id
141
+
142
+ if self.is_test_mode:
143
+ result = ActionResult(
144
+ action=action,
145
+ output=f"[TEST MODE] Would execute: {action.description}",
146
+ snapshot_id=snapshot_id,
147
+ cost=cost,
148
+ )
149
+ self._budget.record(action.id, action.tool_name, cost)
150
+ self._rate_limiter.record(action.tool_name)
151
+ self._history.append(result)
152
+ if self._logger:
153
+ self._logger.log(result)
154
+ return result
155
+
156
+ try:
157
+ if inspect.iscoroutinefunction(func):
158
+ output = await func(*args, **kwargs)
159
+ else:
160
+ loop = asyncio.get_event_loop()
161
+ output = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
162
+ result = ActionResult(
163
+ action=action,
164
+ output=output,
165
+ snapshot_id=snapshot_id,
166
+ cost=cost,
167
+ )
168
+ except Exception as e:
169
+ result = ActionResult(
170
+ action=action,
171
+ error=str(e),
172
+ snapshot_id=snapshot_id,
173
+ cost=cost,
174
+ )
175
+ self._notifier.emit("action_error", {
176
+ "tool": action.tool_name, "error": str(e),
177
+ })
178
+
179
+ self._budget.record(action.id, action.tool_name, cost)
180
+ self._rate_limiter.record(action.tool_name)
181
+ self._history.append(result)
182
+ if self._logger:
183
+ self._logger.log(result)
184
+
185
+ if result.success:
186
+ self._notifier.emit("action_completed", {
187
+ "tool": action.tool_name, "cost": cost,
188
+ })
189
+
190
+ if self._budget.warning:
191
+ self._notifier.emit("budget_warning", {
192
+ "spent": self._budget.spent, "budget": self._budget.budget,
193
+ })
194
+
195
+ return result
opshield/budget.py ADDED
@@ -0,0 +1,76 @@
1
+ """Budget tracker for agent cost control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class CostEntry:
11
+ """A single cost record."""
12
+
13
+ action_id: str
14
+ tool_name: str
15
+ cost: float
16
+ timestamp: float
17
+
18
+
19
+ class BudgetTracker:
20
+ """Tracks cumulative cost and enforces a budget limit."""
21
+
22
+ def __init__(self, budget: float | None = None, warn_at: float = 0.8) -> None:
23
+ self._budget = budget
24
+ self._warn_at = warn_at
25
+ self._entries: list[CostEntry] = []
26
+
27
+ @property
28
+ def budget(self) -> float | None:
29
+ return self._budget
30
+
31
+ @property
32
+ def spent(self) -> float:
33
+ return sum(e.cost for e in self._entries)
34
+
35
+ @property
36
+ def remaining(self) -> float | None:
37
+ if self._budget is None:
38
+ return None
39
+ return max(0.0, self._budget - self.spent)
40
+
41
+ @property
42
+ def over_budget(self) -> bool:
43
+ if self._budget is None:
44
+ return False
45
+ return self.spent >= self._budget
46
+
47
+ @property
48
+ def warning(self) -> bool:
49
+ if self._budget is None:
50
+ return False
51
+ return self.spent >= self._budget * self._warn_at
52
+
53
+ def would_exceed(self, cost: float) -> bool:
54
+ if self._budget is None:
55
+ return False
56
+ return self.spent + cost > self._budget
57
+
58
+ def record(self, action_id: str, tool_name: str, cost: float) -> CostEntry:
59
+ entry = CostEntry(
60
+ action_id=action_id,
61
+ tool_name=tool_name,
62
+ cost=cost,
63
+ timestamp=time.time(),
64
+ )
65
+ self._entries.append(entry)
66
+ return entry
67
+
68
+ def summary(self) -> dict:
69
+ return {
70
+ "budget": self._budget,
71
+ "spent": round(self.spent, 4),
72
+ "remaining": round(self.remaining, 4) if self.remaining is not None else None,
73
+ "entries": len(self._entries),
74
+ "over_budget": self.over_budget,
75
+ "warning": self.warning,
76
+ }