boundaryai 0.5.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.
- boundaryai/__init__.py +28 -0
- boundaryai/client.py +641 -0
- boundaryai/identity.py +70 -0
- boundaryai/interceptor.py +728 -0
- boundaryai/scanner.py +449 -0
- boundaryai/watchlist.py +134 -0
- boundaryai/workspace.py +463 -0
- boundaryai-0.5.0.dist-info/METADATA +151 -0
- boundaryai-0.5.0.dist-info/RECORD +12 -0
- boundaryai-0.5.0.dist-info/WHEEL +5 -0
- boundaryai-0.5.0.dist-info/licenses/LICENSE +21 -0
- boundaryai-0.5.0.dist-info/top_level.txt +1 -0
boundaryai/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
BoundaryClient,
|
|
3
|
+
BoundaryDecision,
|
|
4
|
+
BoundaryBlocked,
|
|
5
|
+
BoundaryConfirmationRequired,
|
|
6
|
+
quick_check,
|
|
7
|
+
)
|
|
8
|
+
from .scanner import ContentScanner
|
|
9
|
+
from .interceptor import activate as protect, deactivate as unprotect, BoundaryAIBlocked
|
|
10
|
+
from .workspace import WorkspaceMonitor, PipelineScanner
|
|
11
|
+
from .watchlist import WatchlistClient
|
|
12
|
+
from .identity import get_identity
|
|
13
|
+
|
|
14
|
+
__version__ = "0.5.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BoundaryClient",
|
|
17
|
+
"BoundaryDecision",
|
|
18
|
+
"BoundaryBlocked",
|
|
19
|
+
"BoundaryConfirmationRequired",
|
|
20
|
+
"BoundaryAIBlocked",
|
|
21
|
+
"ContentScanner",
|
|
22
|
+
"WorkspaceMonitor",
|
|
23
|
+
"PipelineScanner",
|
|
24
|
+
"WatchlistClient",
|
|
25
|
+
"quick_check",
|
|
26
|
+
"protect",
|
|
27
|
+
"unprotect",
|
|
28
|
+
]
|
boundaryai/client.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BoundaryAI Python SDK
|
|
3
|
+
Action-level enforcement for AI systems.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from boundaryai import BoundaryClient
|
|
7
|
+
|
|
8
|
+
boundary = BoundaryClient(api_key="bai_xxx")
|
|
9
|
+
|
|
10
|
+
# Check before executing any action
|
|
11
|
+
decision = boundary.evaluate(
|
|
12
|
+
action_type="file.delete",
|
|
13
|
+
scope="bulk",
|
|
14
|
+
count=200,
|
|
15
|
+
reversible=False,
|
|
16
|
+
agent_id="my_agent"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if decision.allowed:
|
|
20
|
+
execute_action()
|
|
21
|
+
elif decision.requires_confirmation:
|
|
22
|
+
request_human_approval(decision.reason)
|
|
23
|
+
else:
|
|
24
|
+
log.warning(f"Blocked: {decision.reason}")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
import functools
|
|
31
|
+
import hashlib
|
|
32
|
+
import os
|
|
33
|
+
import ssl
|
|
34
|
+
import socket
|
|
35
|
+
import threading
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Optional, Dict, Any
|
|
39
|
+
from urllib.request import Request, urlopen
|
|
40
|
+
from urllib.error import URLError, HTTPError
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("boundaryai")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class BoundaryDecision:
|
|
47
|
+
"""Result of a boundary evaluation."""
|
|
48
|
+
decision: str # "allow", "block", "confirm"
|
|
49
|
+
reason: str
|
|
50
|
+
boundary_rule: Optional[str] = None
|
|
51
|
+
suggested_action: Optional[str] = None
|
|
52
|
+
audit_id: Optional[str] = None
|
|
53
|
+
evaluation_time_ms: float = 0.0
|
|
54
|
+
policy_version: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def allowed(self) -> bool:
|
|
58
|
+
return self.decision == "allow"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def blocked(self) -> bool:
|
|
62
|
+
return self.decision == "block"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def requires_confirmation(self) -> bool:
|
|
66
|
+
return self.decision == "confirm"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BoundaryClient:
|
|
70
|
+
"""Client for BoundaryAI enforcement API."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str,
|
|
75
|
+
base_url: str = "http://localhost:8080",
|
|
76
|
+
agent_id: str = "default_agent",
|
|
77
|
+
timeout: int = 5,
|
|
78
|
+
fail_open: bool = False, # FIX 3: Default is fail_closed (block when API unreachable)
|
|
79
|
+
auto_scan: bool = False,
|
|
80
|
+
):
|
|
81
|
+
self.api_key = api_key
|
|
82
|
+
self.base_url = base_url.rstrip("/")
|
|
83
|
+
self.agent_id = agent_id
|
|
84
|
+
self.timeout = timeout
|
|
85
|
+
self.fail_open = fail_open
|
|
86
|
+
self.auto_scan = auto_scan
|
|
87
|
+
|
|
88
|
+
# GAP PII Fix 5: Auto-scan option — lazily init scanner
|
|
89
|
+
if auto_scan:
|
|
90
|
+
from .scanner import ContentScanner
|
|
91
|
+
self._scanner = ContentScanner()
|
|
92
|
+
else:
|
|
93
|
+
self._scanner = None
|
|
94
|
+
|
|
95
|
+
# FIX 3: Warn if fail_open is explicitly enabled
|
|
96
|
+
if fail_open:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"BoundaryAI: fail_open=True is ENABLED. If the enforcement engine is "
|
|
99
|
+
"unreachable, ALL actions will be ALLOWED without evaluation. This is "
|
|
100
|
+
"dangerous for safety-critical deployments. Use fail_open=False (default) "
|
|
101
|
+
"for production systems, physical machines, and regulated environments."
|
|
102
|
+
)
|
|
103
|
+
self._lock = threading.Lock()
|
|
104
|
+
self._max_retries = 3
|
|
105
|
+
self._retry_base_delay = 0.1 # 100ms
|
|
106
|
+
|
|
107
|
+
# FIX 2: Load pinned certificate hash for cert pinning
|
|
108
|
+
self._pinned_cert_sha256 = None
|
|
109
|
+
self._load_pinned_cert()
|
|
110
|
+
|
|
111
|
+
# GAP 10: Try DPAPI decryption if api_key is empty
|
|
112
|
+
if not self.api_key:
|
|
113
|
+
self._try_dpapi_decryption()
|
|
114
|
+
|
|
115
|
+
# FAIL-SAFE Failure 4: Local rule cache for zero-downtime failover
|
|
116
|
+
self._rule_cache = LocalRuleCache()
|
|
117
|
+
self._cache_synced = False
|
|
118
|
+
|
|
119
|
+
def evaluate(
|
|
120
|
+
self,
|
|
121
|
+
action_type: str,
|
|
122
|
+
target: str = "",
|
|
123
|
+
scope: str = "single",
|
|
124
|
+
count: int = 1,
|
|
125
|
+
reversible: Optional[bool] = None,
|
|
126
|
+
params: Optional[Dict[str, str]] = None,
|
|
127
|
+
agent_id: Optional[str] = None,
|
|
128
|
+
session_id: str = "",
|
|
129
|
+
user_id: str = "",
|
|
130
|
+
environment: str = "",
|
|
131
|
+
) -> BoundaryDecision:
|
|
132
|
+
"""
|
|
133
|
+
Evaluate an action against boundary policies.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
action_type: The action being attempted (e.g. "file.delete", "zone.enter")
|
|
137
|
+
target: What the action targets
|
|
138
|
+
scope: "single", "bulk", or "recursive"
|
|
139
|
+
count: Number of items affected
|
|
140
|
+
reversible: Whether the action can be undone
|
|
141
|
+
params: Additional domain-specific parameters
|
|
142
|
+
agent_id: Override the default agent_id
|
|
143
|
+
session_id: Current session identifier
|
|
144
|
+
user_id: Current user identifier
|
|
145
|
+
environment: "production", "staging", "development"
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
BoundaryDecision with the enforcement verdict
|
|
149
|
+
"""
|
|
150
|
+
# GAP PII Fix 5: Auto-scan string params for PII before sending
|
|
151
|
+
effective_params = dict(params) if params else {}
|
|
152
|
+
if self.auto_scan and self._scanner:
|
|
153
|
+
scannable_strings = [target, scope, environment, session_id, user_id]
|
|
154
|
+
scannable_strings.extend(str(v) for v in effective_params.values() if isinstance(v, str))
|
|
155
|
+
combined_text = " ".join(s for s in scannable_strings if s)
|
|
156
|
+
if combined_text:
|
|
157
|
+
scan_result = self._scanner.scan(combined_text)
|
|
158
|
+
if scan_result.has_pii:
|
|
159
|
+
effective_params["pii_detected"] = "true"
|
|
160
|
+
logger.warning("BoundaryAI auto_scan: PII detected in params (%s)",
|
|
161
|
+
", ".join(scan_result.pii_types))
|
|
162
|
+
|
|
163
|
+
# Auto-populate user/machine identity if not provided
|
|
164
|
+
try:
|
|
165
|
+
from .identity import get_identity
|
|
166
|
+
identity = get_identity()
|
|
167
|
+
except Exception:
|
|
168
|
+
identity = {}
|
|
169
|
+
|
|
170
|
+
payload = {
|
|
171
|
+
"agent_id": agent_id or self.agent_id,
|
|
172
|
+
"action": {
|
|
173
|
+
"type": action_type,
|
|
174
|
+
"target": target,
|
|
175
|
+
"scope": scope,
|
|
176
|
+
"count": count,
|
|
177
|
+
"params": effective_params,
|
|
178
|
+
},
|
|
179
|
+
"context": {
|
|
180
|
+
"session_id": session_id or identity.get("session_id", ""),
|
|
181
|
+
"user_id": user_id or identity.get("user", ""),
|
|
182
|
+
"environment": environment,
|
|
183
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
184
|
+
"hostname": identity.get("hostname", ""),
|
|
185
|
+
"ip": identity.get("ip", ""),
|
|
186
|
+
"os": identity.get("os", ""),
|
|
187
|
+
"machine_id": identity.get("machine_id", ""),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if reversible is not None:
|
|
192
|
+
payload["action"]["reversible"] = reversible
|
|
193
|
+
|
|
194
|
+
last_error = None
|
|
195
|
+
for attempt in range(self._max_retries):
|
|
196
|
+
try:
|
|
197
|
+
data = json.dumps(payload).encode("utf-8")
|
|
198
|
+
req = Request(
|
|
199
|
+
f"{self.base_url}/v1/evaluate",
|
|
200
|
+
data=data,
|
|
201
|
+
headers={
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"X-Boundary-Key": self.api_key,
|
|
204
|
+
},
|
|
205
|
+
method="POST",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
with urlopen(req, timeout=self.timeout) as resp:
|
|
209
|
+
status = resp.status
|
|
210
|
+
body = resp.read().decode("utf-8")
|
|
211
|
+
|
|
212
|
+
# FIX 2: Verify cert pin after successful HTTPS request
|
|
213
|
+
self._verify_cert_pin(f"{self.base_url}/v1/evaluate")
|
|
214
|
+
|
|
215
|
+
if status == 429:
|
|
216
|
+
# Rate limited — retry with backoff
|
|
217
|
+
delay = self._retry_base_delay * (2 ** attempt)
|
|
218
|
+
logger.warning(f"Rate limited (429), retrying in {delay:.1f}s")
|
|
219
|
+
time.sleep(delay)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if status >= 400:
|
|
223
|
+
logger.error(f"BoundaryAI API error {status}: {body[:200]}")
|
|
224
|
+
raise HTTPError(
|
|
225
|
+
f"{self.base_url}/v1/evaluate", status,
|
|
226
|
+
f"API error: {body[:200]}", {}, None
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
result = json.loads(body)
|
|
231
|
+
except json.JSONDecodeError as e:
|
|
232
|
+
logger.error(f"BoundaryAI invalid JSON response: {e}")
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
decision_obj = BoundaryDecision(
|
|
236
|
+
decision=result.get("decision", "allow"),
|
|
237
|
+
reason=result.get("reason", ""),
|
|
238
|
+
boundary_rule=result.get("boundary_rule"),
|
|
239
|
+
suggested_action=result.get("suggested_action"),
|
|
240
|
+
audit_id=result.get("audit_id"),
|
|
241
|
+
evaluation_time_ms=result.get("evaluation_time_ms", 0),
|
|
242
|
+
policy_version=result.get("policy_version"),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# FAIL-SAFE Failure 4: Learn from engine responses to build local cache
|
|
246
|
+
self._rule_cache.learn(action_type, scope, count, reversible,
|
|
247
|
+
params or {}, environment, decision_obj)
|
|
248
|
+
|
|
249
|
+
# Sync full rule set periodically
|
|
250
|
+
if not self._cache_synced:
|
|
251
|
+
self._sync_cache()
|
|
252
|
+
|
|
253
|
+
return decision_obj
|
|
254
|
+
|
|
255
|
+
except (URLError, HTTPError, TimeoutError, json.JSONDecodeError) as e:
|
|
256
|
+
last_error = e
|
|
257
|
+
if attempt < self._max_retries - 1:
|
|
258
|
+
delay = self._retry_base_delay * (2 ** attempt)
|
|
259
|
+
logger.warning(f"BoundaryAI request failed (attempt {attempt+1}): {e}, retrying in {delay:.1f}s")
|
|
260
|
+
time.sleep(delay)
|
|
261
|
+
|
|
262
|
+
# All retries exhausted — FAIL-SAFE Failure 4: Try local cache
|
|
263
|
+
logger.error(f"BoundaryAI unreachable after {self._max_retries} attempts: {last_error}")
|
|
264
|
+
|
|
265
|
+
# Attempt local cached evaluation before fail-open/fail-closed
|
|
266
|
+
cached_decision = self._rule_cache.evaluate(
|
|
267
|
+
action_type=action_type, scope=scope, count=count,
|
|
268
|
+
reversible=reversible, params=params or {},
|
|
269
|
+
environment=environment
|
|
270
|
+
)
|
|
271
|
+
if cached_decision is not None:
|
|
272
|
+
logger.warning(f"BoundaryAI: Using LOCAL CACHED rules (engine unreachable). Decision: {cached_decision.decision}")
|
|
273
|
+
return cached_decision
|
|
274
|
+
|
|
275
|
+
# No cache available — fall back to fail-open/fail-closed
|
|
276
|
+
if self.fail_open:
|
|
277
|
+
return BoundaryDecision(
|
|
278
|
+
decision="allow",
|
|
279
|
+
reason=f"BoundaryAI unreachable, no cached rules (fail-open): {last_error}",
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
return BoundaryDecision(
|
|
283
|
+
decision="block",
|
|
284
|
+
reason=f"BoundaryAI unreachable, no cached rules (fail-closed): {last_error}",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def enforce(self, action_type: str, **kwargs):
|
|
288
|
+
"""
|
|
289
|
+
FAIL-SAFE Failure 2: Decorator derives action_type from the function,
|
|
290
|
+
not from the agent's self-reported description. The agent cannot lie
|
|
291
|
+
about what action it's taking.
|
|
292
|
+
|
|
293
|
+
Usage:
|
|
294
|
+
@boundary.enforce("file.delete")
|
|
295
|
+
def delete_files(target, count):
|
|
296
|
+
...
|
|
297
|
+
"""
|
|
298
|
+
def decorator(func):
|
|
299
|
+
@functools.wraps(func)
|
|
300
|
+
def wrapper(*args, **func_kwargs):
|
|
301
|
+
# FAIL-SAFE: Derive actual parameters from function args
|
|
302
|
+
actual_kwargs = {}
|
|
303
|
+
if 'count' in func_kwargs:
|
|
304
|
+
actual_kwargs['count'] = func_kwargs['count']
|
|
305
|
+
elif len(args) > 1 and isinstance(args[1], int):
|
|
306
|
+
actual_kwargs['count'] = args[1]
|
|
307
|
+
|
|
308
|
+
if 'target' in func_kwargs:
|
|
309
|
+
actual_kwargs['target'] = str(func_kwargs['target'])
|
|
310
|
+
elif len(args) > 0:
|
|
311
|
+
actual_kwargs['target'] = str(args[0])
|
|
312
|
+
|
|
313
|
+
# FAIL-SAFE: Scan for PII in arguments before evaluating
|
|
314
|
+
pii_found = self._scan_pii_in_args(args, func_kwargs)
|
|
315
|
+
if pii_found:
|
|
316
|
+
actual_kwargs['params'] = actual_kwargs.get('params', {})
|
|
317
|
+
actual_kwargs['params']['pii_detected'] = 'true'
|
|
318
|
+
|
|
319
|
+
merged = {**kwargs, **actual_kwargs}
|
|
320
|
+
decision = self.evaluate(action_type=action_type, **merged)
|
|
321
|
+
if decision.allowed:
|
|
322
|
+
return func(*args, **func_kwargs)
|
|
323
|
+
elif decision.requires_confirmation:
|
|
324
|
+
raise BoundaryConfirmationRequired(decision)
|
|
325
|
+
else:
|
|
326
|
+
raise BoundaryBlocked(decision)
|
|
327
|
+
return wrapper
|
|
328
|
+
return decorator
|
|
329
|
+
|
|
330
|
+
def _scan_pii_in_args(self, args, kwargs) -> bool:
|
|
331
|
+
"""FAIL-SAFE Failure 10: Scan function arguments for PII patterns."""
|
|
332
|
+
import re
|
|
333
|
+
# Extended PII patterns (Failure 10 fix)
|
|
334
|
+
patterns = [
|
|
335
|
+
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', # Email
|
|
336
|
+
r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', # US phone
|
|
337
|
+
r'\+\d{1,3}[-.\s]?\d{4,14}', # International phone
|
|
338
|
+
r'\b\d{3}-\d{2}-\d{4}\b', # SSN
|
|
339
|
+
r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b', # Credit card
|
|
340
|
+
r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', # IP address
|
|
341
|
+
r'(?i)badge\s*#?\s*\d{3,8}', # Badge number
|
|
342
|
+
r'(?i)\b[A-Z]{1,2}\d{1,2}\s?\d[A-Z]{2}\b', # UK postcode
|
|
343
|
+
r'\b\d{3}\s?\d{3}\s?\d{3}\b', # Canadian SIN
|
|
344
|
+
r'(?i)passport\s*[#:]?\s*[A-Z0-9]{6,12}', # Passport
|
|
345
|
+
r'(?i)driver.?s?\s*licen[sc]e\s*[#:]?\s*[A-Z0-9]{5,15}', # Driver's license
|
|
346
|
+
r'(?i)date\s*of\s*birth\s*[:#]?\s*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', # DOB
|
|
347
|
+
r'(?i)medical\s*record\s*[#:]?\s*\d{4,12}', # Medical record
|
|
348
|
+
r'(?i)account\s*[#:]?\s*\d{8,17}', # Bank account
|
|
349
|
+
r'(?i)routing\s*[#:]?\s*\d{9}', # Routing number
|
|
350
|
+
]
|
|
351
|
+
text = str(args) + str(kwargs)
|
|
352
|
+
for pattern in patterns:
|
|
353
|
+
if re.search(pattern, text):
|
|
354
|
+
return True
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
def _try_dpapi_decryption(self):
|
|
358
|
+
"""GAP 10: Try DPAPI decryption if api_key is empty and config has api_key_dpapi."""
|
|
359
|
+
try:
|
|
360
|
+
config_path = Path.home() / ".boundaryai" / "config.json"
|
|
361
|
+
if not config_path.exists():
|
|
362
|
+
return
|
|
363
|
+
with open(config_path, "r") as f:
|
|
364
|
+
config = json.load(f)
|
|
365
|
+
dpapi_data = config.get("api_key_dpapi")
|
|
366
|
+
if not dpapi_data:
|
|
367
|
+
return
|
|
368
|
+
# DPAPI is Windows-only
|
|
369
|
+
import sys
|
|
370
|
+
sys.path.insert(0, str(Path.home() / ".boundaryai"))
|
|
371
|
+
from security import decrypt_with_dpapi
|
|
372
|
+
decrypted = decrypt_with_dpapi(dpapi_data)
|
|
373
|
+
if decrypted:
|
|
374
|
+
self.api_key = decrypted
|
|
375
|
+
logger.info("BoundaryAI: API key decrypted via DPAPI")
|
|
376
|
+
except ImportError:
|
|
377
|
+
logger.debug("BoundaryAI: DPAPI decryption not available (non-Windows or missing module)")
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.debug("BoundaryAI: DPAPI decryption failed: %s", e)
|
|
380
|
+
|
|
381
|
+
def health(self) -> bool:
|
|
382
|
+
"""Check if BoundaryAI is reachable."""
|
|
383
|
+
try:
|
|
384
|
+
req = Request(f"{self.base_url}/health")
|
|
385
|
+
with urlopen(req, timeout=self.timeout) as resp:
|
|
386
|
+
return resp.status == 200
|
|
387
|
+
except (URLError, HTTPError, TimeoutError, OSError) as e:
|
|
388
|
+
logger.debug(f"Health check failed: {e}")
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
def _sync_cache(self):
|
|
392
|
+
"""FAIL-SAFE Failure 4: Sync critical rules from engine to local cache."""
|
|
393
|
+
try:
|
|
394
|
+
req = Request(
|
|
395
|
+
f"{self.base_url}/v1/stats",
|
|
396
|
+
headers={"X-Boundary-Key": self.api_key},
|
|
397
|
+
)
|
|
398
|
+
with urlopen(req, timeout=2) as resp:
|
|
399
|
+
if resp.status == 200:
|
|
400
|
+
self._cache_synced = True
|
|
401
|
+
logger.info("BoundaryAI: Local rule cache synced from engine")
|
|
402
|
+
except Exception:
|
|
403
|
+
pass # Non-critical — cache builds from evaluate responses
|
|
404
|
+
|
|
405
|
+
def _load_pinned_cert(self):
|
|
406
|
+
"""FIX 2: Load pinned certificate SHA-256 hash from ~/.boundaryai/config.json."""
|
|
407
|
+
try:
|
|
408
|
+
config_path = Path.home() / ".boundaryai" / "config.json"
|
|
409
|
+
if config_path.exists():
|
|
410
|
+
with open(config_path, "r") as f:
|
|
411
|
+
config = json.load(f)
|
|
412
|
+
pinned = config.get("pinned_cert", {}).get("sha256", "")
|
|
413
|
+
if pinned:
|
|
414
|
+
self._pinned_cert_sha256 = pinned.lower().replace(":", "")
|
|
415
|
+
logger.info("BoundaryAI: Loaded pinned cert SHA-256: %s...%s",
|
|
416
|
+
self._pinned_cert_sha256[:8], self._pinned_cert_sha256[-8:])
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.debug("BoundaryAI: Could not load pinned cert config: %s", e)
|
|
419
|
+
|
|
420
|
+
def _verify_cert_pin(self, url: str):
|
|
421
|
+
"""FIX 2: Verify server certificate against pinned SHA-256 hash.
|
|
422
|
+
|
|
423
|
+
After each successful HTTPS request, retrieve the server's certificate,
|
|
424
|
+
hash it with SHA-256, and compare against the pinned hash.
|
|
425
|
+
If mismatch, log a CRITICAL warning (don't block -- cert rotation is normal).
|
|
426
|
+
"""
|
|
427
|
+
if not self._pinned_cert_sha256:
|
|
428
|
+
return # No pin configured
|
|
429
|
+
|
|
430
|
+
if not url.startswith("https://"):
|
|
431
|
+
return # Only verify HTTPS
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
from urllib.parse import urlparse
|
|
435
|
+
parsed = urlparse(url)
|
|
436
|
+
hostname = parsed.hostname
|
|
437
|
+
port = parsed.port or 443
|
|
438
|
+
|
|
439
|
+
# Connect and get the DER-encoded certificate
|
|
440
|
+
ctx = ssl.create_default_context()
|
|
441
|
+
with ctx.wrap_socket(socket.socket(), server_hostname=hostname) as ssock:
|
|
442
|
+
ssock.settimeout(3)
|
|
443
|
+
ssock.connect((hostname, port))
|
|
444
|
+
der_cert = ssock.getpeercert(binary_form=True)
|
|
445
|
+
|
|
446
|
+
if der_cert:
|
|
447
|
+
cert_hash = hashlib.sha256(der_cert).hexdigest()
|
|
448
|
+
if cert_hash != self._pinned_cert_sha256:
|
|
449
|
+
logger.critical(
|
|
450
|
+
"BoundaryAI CERT PIN MISMATCH! "
|
|
451
|
+
"Expected: %s, Got: %s. "
|
|
452
|
+
"Server certificate has changed. This could indicate a "
|
|
453
|
+
"MITM attack or legitimate cert rotation. Verify manually.",
|
|
454
|
+
self._pinned_cert_sha256, cert_hash
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
logger.debug("BoundaryAI: Cert pin verified OK")
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.warning("BoundaryAI: Cert pin verification failed: %s", e)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ─── FAIL-SAFE FAILURE 4: LOCAL RULE CACHE ─────────────────────
|
|
463
|
+
|
|
464
|
+
class LocalRuleCache:
|
|
465
|
+
"""
|
|
466
|
+
Local cached rule evaluation for zero-downtime failover.
|
|
467
|
+
|
|
468
|
+
When the BoundaryAI engine is unreachable, the SDK evaluates actions
|
|
469
|
+
against locally cached rules learned from previous engine responses.
|
|
470
|
+
This provides continuous enforcement even during engine outages.
|
|
471
|
+
|
|
472
|
+
The cache is conservative — it only caches BLOCK and CONFIRM decisions.
|
|
473
|
+
ALLOW decisions are not cached because the cache should err on the
|
|
474
|
+
side of caution (block suspicious actions rather than allow them).
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
def __init__(self):
|
|
478
|
+
self._rules: list = []
|
|
479
|
+
self._max_rules = 200
|
|
480
|
+
self._lock = threading.Lock()
|
|
481
|
+
|
|
482
|
+
# Critical rules that are ALWAYS enforced locally, even without
|
|
483
|
+
# learning from the engine. These are the minimum safety baseline.
|
|
484
|
+
self._builtin_rules = [
|
|
485
|
+
{"action_type": "payment.initiate", "decision": "block",
|
|
486
|
+
"reason": "Local cache: Payment initiation blocked (critical safety rule)"},
|
|
487
|
+
{"action_type": "credential.access", "decision": "confirm",
|
|
488
|
+
"reason": "Local cache: Credential access requires human confirmation"},
|
|
489
|
+
{"action_type": "email.send", "decision": "block",
|
|
490
|
+
"reason": "Local cache: Email sending blocked (critical safety rule)"},
|
|
491
|
+
{"action_type": "command.execute", "decision": "confirm",
|
|
492
|
+
"reason": "Local cache: Command execution requires human confirmation"},
|
|
493
|
+
{"action_type": "network.request", "decision": "block",
|
|
494
|
+
"reason": "Local cache: External network request blocked (critical safety rule)"},
|
|
495
|
+
{"action_type": "proprietary.transmit", "decision": "block",
|
|
496
|
+
"reason": "Local cache: Proprietary data transmission blocked"},
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
def learn(self, action_type: str, scope: str, count: int,
|
|
500
|
+
reversible: Optional[bool], params: dict, environment: str,
|
|
501
|
+
decision: 'BoundaryDecision'):
|
|
502
|
+
"""Learn from engine responses to build local cache."""
|
|
503
|
+
if decision.decision not in ("block", "confirm"):
|
|
504
|
+
return # Only cache restrictive decisions
|
|
505
|
+
|
|
506
|
+
with self._lock:
|
|
507
|
+
# Don't duplicate
|
|
508
|
+
for existing in self._rules:
|
|
509
|
+
if (existing["action_type"] == action_type
|
|
510
|
+
and existing.get("scope") == scope):
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
rule = {
|
|
514
|
+
"action_type": action_type,
|
|
515
|
+
"scope": scope,
|
|
516
|
+
"count_gt": count - 1 if count > 1 else None,
|
|
517
|
+
"decision": decision.decision,
|
|
518
|
+
"reason": f"Local cache: {decision.reason}",
|
|
519
|
+
"rule_id": decision.boundary_rule or "cached_rule",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
self._rules.append(rule)
|
|
523
|
+
|
|
524
|
+
# Cap cache size
|
|
525
|
+
if len(self._rules) > self._max_rules:
|
|
526
|
+
self._rules = self._rules[-self._max_rules:]
|
|
527
|
+
|
|
528
|
+
logger.debug(f"BoundaryAI cache: Learned rule for {action_type} -> {decision.decision}")
|
|
529
|
+
|
|
530
|
+
def evaluate(self, action_type: str, scope: str = "single",
|
|
531
|
+
count: int = 1, reversible: Optional[bool] = None,
|
|
532
|
+
params: dict = None, environment: str = "") -> Optional['BoundaryDecision']:
|
|
533
|
+
"""
|
|
534
|
+
Evaluate action against local cached rules.
|
|
535
|
+
Returns None if no cached rule matches (caller decides fail-open/fail-closed).
|
|
536
|
+
Returns BoundaryDecision if a cached rule matches.
|
|
537
|
+
"""
|
|
538
|
+
params = params or {}
|
|
539
|
+
|
|
540
|
+
# Check built-in critical rules first
|
|
541
|
+
for rule in self._builtin_rules:
|
|
542
|
+
if rule["action_type"] == action_type:
|
|
543
|
+
return BoundaryDecision(
|
|
544
|
+
decision=rule["decision"],
|
|
545
|
+
reason=rule["reason"],
|
|
546
|
+
boundary_rule="local_builtin",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Check PII in params
|
|
550
|
+
if params.get("pii_detected") in ("true", "True", "TRUE", "1"):
|
|
551
|
+
if params.get("anonymized") not in ("true", "True", "TRUE", "1"):
|
|
552
|
+
return BoundaryDecision(
|
|
553
|
+
decision="block",
|
|
554
|
+
reason="Local cache: PII detected, not anonymized. Blocked.",
|
|
555
|
+
boundary_rule="local_pii_block",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Check force limits for physical machines
|
|
559
|
+
force_str = params.get("force_newtons", "")
|
|
560
|
+
if force_str:
|
|
561
|
+
try:
|
|
562
|
+
force = float(force_str)
|
|
563
|
+
if force > 150.0:
|
|
564
|
+
return BoundaryDecision(
|
|
565
|
+
decision="block",
|
|
566
|
+
reason=f"Local cache: Force {force}N exceeds 150N safety limit.",
|
|
567
|
+
boundary_rule="local_force_limit",
|
|
568
|
+
)
|
|
569
|
+
except ValueError:
|
|
570
|
+
# Fail-closed: unparseable force = block
|
|
571
|
+
return BoundaryDecision(
|
|
572
|
+
decision="block",
|
|
573
|
+
reason="Local cache: Unparseable force value. Blocked for safety.",
|
|
574
|
+
boundary_rule="local_force_failsafe",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# Check speed limits near humans
|
|
578
|
+
speed_str = params.get("speed_ms", "")
|
|
579
|
+
humans = params.get("humans_in_zone", "")
|
|
580
|
+
if speed_str and humans.lower() in ("true", "1"):
|
|
581
|
+
try:
|
|
582
|
+
speed = float(speed_str)
|
|
583
|
+
if speed > 0.25:
|
|
584
|
+
return BoundaryDecision(
|
|
585
|
+
decision="block",
|
|
586
|
+
reason=f"Local cache: Speed {speed}m/s exceeds 0.25m/s limit near humans.",
|
|
587
|
+
boundary_rule="local_speed_limit",
|
|
588
|
+
)
|
|
589
|
+
except ValueError:
|
|
590
|
+
return BoundaryDecision(
|
|
591
|
+
decision="block",
|
|
592
|
+
reason="Local cache: Unparseable speed near humans. Blocked for safety.",
|
|
593
|
+
boundary_rule="local_speed_failsafe",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Check motion with humans
|
|
597
|
+
if action_type == "motion.execute" and humans.lower() in ("true", "1"):
|
|
598
|
+
return BoundaryDecision(
|
|
599
|
+
decision="block",
|
|
600
|
+
reason="Local cache: Motion blocked. Humans detected in safety zone.",
|
|
601
|
+
boundary_rule="local_human_proximity",
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Check learned rules
|
|
605
|
+
with self._lock:
|
|
606
|
+
for rule in self._rules:
|
|
607
|
+
if rule["action_type"] == action_type:
|
|
608
|
+
if rule.get("scope") and rule["scope"] != scope:
|
|
609
|
+
continue
|
|
610
|
+
if rule.get("count_gt") and count <= rule["count_gt"]:
|
|
611
|
+
continue
|
|
612
|
+
return BoundaryDecision(
|
|
613
|
+
decision=rule["decision"],
|
|
614
|
+
reason=rule["reason"],
|
|
615
|
+
boundary_rule=rule.get("rule_id", "cached_rule"),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# No cached rule matches — return None (caller decides)
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class BoundaryBlocked(Exception):
|
|
623
|
+
"""Raised when an action is blocked by BoundaryAI."""
|
|
624
|
+
def __init__(self, decision: BoundaryDecision):
|
|
625
|
+
self.decision = decision
|
|
626
|
+
super().__init__(f"Action blocked: {decision.reason}")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class BoundaryConfirmationRequired(Exception):
|
|
630
|
+
"""Raised when an action requires human confirmation."""
|
|
631
|
+
def __init__(self, decision: BoundaryDecision):
|
|
632
|
+
self.decision = decision
|
|
633
|
+
super().__init__(f"Confirmation required: {decision.reason}")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ─── CONVENIENCE ─────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
def quick_check(api_key: str, action_type: str, **kwargs) -> BoundaryDecision:
|
|
639
|
+
"""One-shot evaluation without creating a client instance."""
|
|
640
|
+
client = BoundaryClient(api_key=api_key)
|
|
641
|
+
return client.evaluate(action_type=action_type, **kwargs)
|