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 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)