dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/policies.py
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Policy Engine
|
|
3
|
+
======================
|
|
4
|
+
Evaluates policies before intent execution.
|
|
5
|
+
|
|
6
|
+
A policy is a rule that can:
|
|
7
|
+
- ALLOW — intent executes normally
|
|
8
|
+
- BLOCK — intent is rejected with a reason
|
|
9
|
+
- CONFIRM — intent requires explicit confirmation before executing
|
|
10
|
+
- MODIFY — intent parameters are adjusted before execution
|
|
11
|
+
|
|
12
|
+
Policies are evaluated in priority order. First matching policy wins.
|
|
13
|
+
|
|
14
|
+
Example policies:
|
|
15
|
+
"never unlock doors after midnight"
|
|
16
|
+
"critical actions require confirmation"
|
|
17
|
+
"save_energy never turns off hallway lights"
|
|
18
|
+
"children cannot trigger away_mode"
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
engine = PolicyEngine()
|
|
22
|
+
engine.add(NeverAfterHoursPolicy("unlock", hour_start=0, hour_end=6))
|
|
23
|
+
engine.add(RequireConfirmationPolicy(["lock", "unlock", "alarm"]))
|
|
24
|
+
|
|
25
|
+
result = engine.evaluate(intent, action_plan)
|
|
26
|
+
if result.decision == PolicyDecision.BLOCK:
|
|
27
|
+
# reject the intent
|
|
28
|
+
elif result.decision == PolicyDecision.CONFIRM:
|
|
29
|
+
# wait for confirmation before executing
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
import logging
|
|
33
|
+
from abc import ABC, abstractmethod
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
import threading
|
|
36
|
+
from collections import deque
|
|
37
|
+
import time
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from typing import TYPE_CHECKING
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from .models import Intent, ActionPlan, DeviceAction
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger("dosync.policies")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Policy decision ───────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
class PolicyDecision(str, Enum):
|
|
51
|
+
ALLOW = "allow" # proceed normally
|
|
52
|
+
BLOCK = "block" # reject the intent
|
|
53
|
+
CONFIRM = "confirm" # require explicit confirmation
|
|
54
|
+
MODIFY = "modify" # adjust parameters before execution
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PolicyResult:
|
|
59
|
+
decision: PolicyDecision
|
|
60
|
+
policy_name: str
|
|
61
|
+
reason: str = ""
|
|
62
|
+
modified_actions: list = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def allow(policy_name: str) -> "PolicyResult":
|
|
66
|
+
return PolicyResult(PolicyDecision.ALLOW, policy_name)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def block(policy_name: str, reason: str) -> "PolicyResult":
|
|
70
|
+
log.warning("Policy BLOCK [%s]: %s", policy_name, reason)
|
|
71
|
+
return PolicyResult(PolicyDecision.BLOCK, policy_name, reason)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def confirm(policy_name: str, reason: str) -> "PolicyResult":
|
|
75
|
+
log.info("Policy CONFIRM [%s]: %s", policy_name, reason)
|
|
76
|
+
return PolicyResult(PolicyDecision.CONFIRM, policy_name, reason)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Base policy ───────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class BasePolicy(ABC):
|
|
82
|
+
"""
|
|
83
|
+
Base class for all DoSync policies.
|
|
84
|
+
|
|
85
|
+
To implement a custom policy:
|
|
86
|
+
class MyPolicy(BasePolicy):
|
|
87
|
+
def evaluate(self, intent, plan) -> PolicyResult:
|
|
88
|
+
...
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def name(self) -> str:
|
|
94
|
+
"""Unique name for this policy."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def priority(self) -> int:
|
|
99
|
+
"""Lower number = evaluated first. Default 100."""
|
|
100
|
+
return 100
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def bypass_on_emergency(self) -> bool:
|
|
104
|
+
"""Whether EMERGENCY urgency bypasses this policy.
|
|
105
|
+
|
|
106
|
+
Default: True — most safety policies should be bypassed for emergencies
|
|
107
|
+
(time restrictions, confirmation requirements, device exclusions).
|
|
108
|
+
|
|
109
|
+
Set to False for policies that represent absolute operator constraints
|
|
110
|
+
that must be honored even in emergencies (e.g. BlockIntentPolicy when
|
|
111
|
+
an operator has explicitly prohibited an intent class).
|
|
112
|
+
|
|
113
|
+
Note: IntentRateLimitPolicy and DeviceActuatorRateLimitPolicy handle
|
|
114
|
+
emergency bypass internally and do not rely on this flag.
|
|
115
|
+
"""
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
120
|
+
"""
|
|
121
|
+
Evaluate this policy against the intent and action plan.
|
|
122
|
+
Return None to abstain (policy does not apply to this intent).
|
|
123
|
+
Return a PolicyResult to make a decision.
|
|
124
|
+
"""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Built-in policies ─────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
class NeverAfterHoursPolicy(BasePolicy):
|
|
131
|
+
"""
|
|
132
|
+
Blocks specific actuator types outside allowed hours.
|
|
133
|
+
|
|
134
|
+
Example: never unlock doors between midnight and 6am.
|
|
135
|
+
|
|
136
|
+
NeverAfterHoursPolicy(
|
|
137
|
+
actuator_types=["unlock"],
|
|
138
|
+
blocked_hours_start=0,
|
|
139
|
+
blocked_hours_end=6,
|
|
140
|
+
reason="Security policy: no remote unlocking between 00:00 and 06:00"
|
|
141
|
+
)
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
actuator_types: list[str],
|
|
147
|
+
blocked_hours_start: int,
|
|
148
|
+
blocked_hours_end: int,
|
|
149
|
+
reason: str = "",
|
|
150
|
+
):
|
|
151
|
+
self._actuator_types = set(actuator_types)
|
|
152
|
+
self._start = blocked_hours_start
|
|
153
|
+
self._end = blocked_hours_end
|
|
154
|
+
self._reason = reason or (
|
|
155
|
+
f"Policy: {actuator_types} blocked between "
|
|
156
|
+
f"{blocked_hours_start:02d}:00 and {blocked_hours_end:02d}:00"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def name(self) -> str:
|
|
161
|
+
return "never_after_hours"
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def priority(self) -> int:
|
|
165
|
+
return 10 # high priority
|
|
166
|
+
|
|
167
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
168
|
+
from .models import Urgency
|
|
169
|
+
# Emergency always bypasses time restrictions
|
|
170
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
now = datetime.now()
|
|
174
|
+
in_blocked_hours = self._start <= now.hour < self._end
|
|
175
|
+
|
|
176
|
+
if not in_blocked_hours:
|
|
177
|
+
return None # outside blocked window — policy does not apply
|
|
178
|
+
|
|
179
|
+
relevant = [a for a in plan.actions if a.action in self._actuator_types]
|
|
180
|
+
if not relevant:
|
|
181
|
+
return None # no relevant actions
|
|
182
|
+
|
|
183
|
+
return PolicyResult.block(
|
|
184
|
+
self.name,
|
|
185
|
+
f"{self._reason} (current time: {now.strftime('%H:%M')})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class RequireConfirmationPolicy(BasePolicy):
|
|
190
|
+
"""
|
|
191
|
+
Requires explicit confirmation for specific actuator types.
|
|
192
|
+
|
|
193
|
+
Example: always confirm before locking/unlocking doors.
|
|
194
|
+
|
|
195
|
+
RequireConfirmationPolicy(
|
|
196
|
+
actuator_types=["lock", "unlock", "alarm"],
|
|
197
|
+
reason="Critical action requires confirmation"
|
|
198
|
+
)
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, actuator_types: list[str], reason: str = ""):
|
|
202
|
+
self._actuator_types = set(actuator_types)
|
|
203
|
+
self._reason = reason or f"Confirmation required for: {actuator_types}"
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def name(self) -> str:
|
|
207
|
+
return "require_confirmation"
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def priority(self) -> int:
|
|
211
|
+
return 20
|
|
212
|
+
|
|
213
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
214
|
+
from .models import Urgency
|
|
215
|
+
# Emergency bypasses confirmation
|
|
216
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
relevant = [a for a in plan.actions if a.action in self._actuator_types]
|
|
220
|
+
if not relevant:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
devices = [a.device_id for a in relevant]
|
|
224
|
+
return PolicyResult.confirm(
|
|
225
|
+
self.name,
|
|
226
|
+
f"{self._reason} — affects: {', '.join(devices)}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class BlockIntentPolicy(BasePolicy):
|
|
231
|
+
"""
|
|
232
|
+
Unconditionally blocks specific intents.
|
|
233
|
+
|
|
234
|
+
bypass_on_emergency=False: operator blocks are absolute — not bypassed
|
|
235
|
+
even by EMERGENCY urgency. If an operator has explicitly prohibited
|
|
236
|
+
an intent class, that prohibition is honored regardless of urgency.
|
|
237
|
+
|
|
238
|
+
Example: children cannot trigger away_mode.
|
|
239
|
+
|
|
240
|
+
BlockIntentPolicy(
|
|
241
|
+
intent_classes=["away_mode"],
|
|
242
|
+
actor_tags=["child"],
|
|
243
|
+
reason="Children cannot arm away mode"
|
|
244
|
+
)
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def bypass_on_emergency(self) -> bool:
|
|
249
|
+
return False # Operator blocks are absolute
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
intent_classes: list[str],
|
|
254
|
+
reason: str = "",
|
|
255
|
+
actor_tags: list[str] | None = None,
|
|
256
|
+
):
|
|
257
|
+
self._intents = set(intent_classes)
|
|
258
|
+
self._reason = reason or f"Intent blocked by policy: {intent_classes}"
|
|
259
|
+
self._actor_tags = set(actor_tags) if actor_tags else None
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def name(self) -> str:
|
|
263
|
+
return "block_intent"
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def priority(self) -> int:
|
|
267
|
+
return 5 # highest priority
|
|
268
|
+
|
|
269
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
270
|
+
if intent.intent.value not in self._intents:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
if self._actor_tags:
|
|
274
|
+
actor = intent.context.get("actor_tags", [])
|
|
275
|
+
if not (self._actor_tags & set(actor)):
|
|
276
|
+
return None # actor doesn't match — policy doesn't apply
|
|
277
|
+
|
|
278
|
+
return PolicyResult.block(self.name, self._reason)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class DeviceExclusionPolicy(BasePolicy):
|
|
282
|
+
"""
|
|
283
|
+
Excludes specific devices from specific intents.
|
|
284
|
+
|
|
285
|
+
Example: save_energy never turns off hallway lights.
|
|
286
|
+
|
|
287
|
+
DeviceExclusionPolicy(
|
|
288
|
+
intent_classes=["save_energy"],
|
|
289
|
+
excluded_device_ids=["wiz-hallway-01"],
|
|
290
|
+
reason="Hallway light stays on for safety"
|
|
291
|
+
)
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
intent_classes: list[str],
|
|
297
|
+
excluded_device_ids: list[str],
|
|
298
|
+
reason: str = "",
|
|
299
|
+
bypass_on_emergency: bool = True,
|
|
300
|
+
):
|
|
301
|
+
"""
|
|
302
|
+
Args:
|
|
303
|
+
bypass_on_emergency: whether EMERGENCY urgency ignores this exclusion.
|
|
304
|
+
|
|
305
|
+
True (default, and the historical behavior) suits an exclusion
|
|
306
|
+
that expresses convenience: in a fire, use everything that helps.
|
|
307
|
+
|
|
308
|
+
False makes the exclusion ABSOLUTE. That is a real deployment
|
|
309
|
+
need, not an edge case: an operator may exclude a device because
|
|
310
|
+
involving it is USELESS or HARMFUL, and an emergency does not
|
|
311
|
+
change that — screens that nobody will read while evacuating, or
|
|
312
|
+
a ward where lights must never come on because of a
|
|
313
|
+
photosensitive patient. Whether an exclusion is advisory or
|
|
314
|
+
absolute is the DEPLOYER's judgement about their building, which
|
|
315
|
+
is precisely the kind of decision the protocol must not make for
|
|
316
|
+
them (2026-07-12 panel). Added 2026-07-14, when the first
|
|
317
|
+
operator ground truth ("screens must not act in an emergency")
|
|
318
|
+
turned out to be inexpressible.
|
|
319
|
+
"""
|
|
320
|
+
self._intents = set(intent_classes)
|
|
321
|
+
self._excluded = set(excluded_device_ids)
|
|
322
|
+
self._reason = reason or f"Devices excluded by policy"
|
|
323
|
+
self._bypass_on_emergency = bypass_on_emergency
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def name(self) -> str:
|
|
327
|
+
return "device_exclusion"
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def priority(self) -> int:
|
|
331
|
+
return 30
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def bypass_on_emergency(self) -> bool:
|
|
335
|
+
return self._bypass_on_emergency
|
|
336
|
+
|
|
337
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
338
|
+
if intent.intent.value not in self._intents:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
filtered = [a for a in plan.actions if a.device_id not in self._excluded]
|
|
342
|
+
excluded = [a for a in plan.actions if a.device_id in self._excluded]
|
|
343
|
+
|
|
344
|
+
if not excluded:
|
|
345
|
+
return None # no excluded devices in this plan
|
|
346
|
+
|
|
347
|
+
log.info("DeviceExclusionPolicy: removed %d action(s) for %s",
|
|
348
|
+
len(excluded), [a.device_id for a in excluded])
|
|
349
|
+
|
|
350
|
+
# MODIFY: return filtered plan
|
|
351
|
+
result = PolicyResult(
|
|
352
|
+
decision=PolicyDecision.MODIFY,
|
|
353
|
+
policy_name=self.name,
|
|
354
|
+
reason=self._reason,
|
|
355
|
+
modified_actions=filtered,
|
|
356
|
+
)
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ── Device actuator rate limit policy ─────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
class DeviceActuatorRateLimitPolicy(BasePolicy):
|
|
364
|
+
"""
|
|
365
|
+
Limits how many times a specific device can be targeted per minute.
|
|
366
|
+
|
|
367
|
+
Complements IntentRateLimitPolicy (which limits by intent source).
|
|
368
|
+
This policy limits by target device, preventing a single device
|
|
369
|
+
from being flooded with commands regardless of how many agents
|
|
370
|
+
or intents trigger it.
|
|
371
|
+
|
|
372
|
+
When the limit is exceeded for a device, that device's action is
|
|
373
|
+
removed from the ActionPlan (MODIFY). Other devices in the plan
|
|
374
|
+
are not affected.
|
|
375
|
+
|
|
376
|
+
Emergency intents are NEVER rate limited — protocol guarantee.
|
|
377
|
+
|
|
378
|
+
Default limit: 20 actions per minute per device.
|
|
379
|
+
|
|
380
|
+
Usage:
|
|
381
|
+
policy_engine.add(DeviceActuatorRateLimitPolicy())
|
|
382
|
+
|
|
383
|
+
# Custom limit
|
|
384
|
+
policy_engine.add(DeviceActuatorRateLimitPolicy(limit_per_minute=10))
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
DEFAULT_LIMIT = 20 # actions per minute per device
|
|
388
|
+
|
|
389
|
+
def __init__(
|
|
390
|
+
self,
|
|
391
|
+
limit_per_minute: int | None = None,
|
|
392
|
+
window_seconds: int = 60,
|
|
393
|
+
db=None,
|
|
394
|
+
):
|
|
395
|
+
self._limit = limit_per_minute if limit_per_minute is not None else self.DEFAULT_LIMIT
|
|
396
|
+
self._window = window_seconds
|
|
397
|
+
self._db = db # DoSyncDB instance — if set, events are persisted
|
|
398
|
+
# Sliding window: {device_id: deque of timestamps}
|
|
399
|
+
self._windows: dict[str, deque] = {}
|
|
400
|
+
self._lock = threading.Lock()
|
|
401
|
+
# Restore windows from DB on startup if DB is available
|
|
402
|
+
if self._db is not None:
|
|
403
|
+
self._restore_from_db()
|
|
404
|
+
|
|
405
|
+
def set_db(self, db) -> None:
|
|
406
|
+
"""Wire DB after construction (called from hub startup)."""
|
|
407
|
+
self._db = db
|
|
408
|
+
self._restore_from_db()
|
|
409
|
+
|
|
410
|
+
def _restore_from_db(self) -> None:
|
|
411
|
+
"""Load rate limit events from DB on startup. Only loads events within current window."""
|
|
412
|
+
try:
|
|
413
|
+
events = self._db.load_rate_limit_events(self._window)
|
|
414
|
+
with self._lock:
|
|
415
|
+
for device_id, timestamps in events.items():
|
|
416
|
+
self._windows[device_id] = deque(sorted(timestamps))
|
|
417
|
+
log.info(
|
|
418
|
+
"DeviceActuatorRateLimitPolicy: restored %d device windows from DB",
|
|
419
|
+
len(events),
|
|
420
|
+
)
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
log.warning("DeviceActuatorRateLimitPolicy: could not restore from DB: %s", exc)
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def name(self) -> str:
|
|
426
|
+
return "device_actuator_rate_limit"
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def priority(self) -> int:
|
|
430
|
+
return 5 # runs after IntentRateLimitPolicy (0) but before other policies
|
|
431
|
+
|
|
432
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
433
|
+
from .models import Urgency
|
|
434
|
+
|
|
435
|
+
# Emergency intents are NEVER rate limited — protocol guarantee
|
|
436
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
if not plan.actions:
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
now = time.time()
|
|
443
|
+
cutoff = now - self._window
|
|
444
|
+
throttled: list[str] = []
|
|
445
|
+
allowed_actions = []
|
|
446
|
+
|
|
447
|
+
with self._lock:
|
|
448
|
+
for action in plan.actions:
|
|
449
|
+
device_id = action.device_id
|
|
450
|
+
|
|
451
|
+
if device_id not in self._windows:
|
|
452
|
+
self._windows[device_id] = deque()
|
|
453
|
+
window = self._windows[device_id]
|
|
454
|
+
|
|
455
|
+
# Evict expired timestamps
|
|
456
|
+
while window and window[0] < cutoff:
|
|
457
|
+
window.popleft()
|
|
458
|
+
|
|
459
|
+
if len(window) >= self._limit:
|
|
460
|
+
throttled.append(device_id)
|
|
461
|
+
else:
|
|
462
|
+
window.append(now)
|
|
463
|
+
allowed_actions.append(action)
|
|
464
|
+
# Persist to DB (best-effort — never block execution)
|
|
465
|
+
if self._db is not None:
|
|
466
|
+
try:
|
|
467
|
+
self._db.append_rate_limit_event(device_id, now)
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
if not throttled:
|
|
472
|
+
return None # all devices within limit
|
|
473
|
+
|
|
474
|
+
log.info(
|
|
475
|
+
"DeviceActuatorRateLimitPolicy: throttled %d device(s): %s",
|
|
476
|
+
len(throttled), throttled,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if not allowed_actions:
|
|
480
|
+
# Every device in the plan is throttled — block the entire intent
|
|
481
|
+
return PolicyResult.block(
|
|
482
|
+
self.name,
|
|
483
|
+
f"All {len(throttled)} device(s) in plan are rate limited "
|
|
484
|
+
f"({self._limit} actions/{self._window}s).",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Partial throttle — remove over-limit devices, execute the rest
|
|
488
|
+
return PolicyResult(
|
|
489
|
+
decision=PolicyDecision.MODIFY,
|
|
490
|
+
policy_name=self.name,
|
|
491
|
+
reason=f"Throttled {len(throttled)} device(s): {throttled}",
|
|
492
|
+
modified_actions=allowed_actions,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def get_stats(self) -> dict:
|
|
496
|
+
"""Return current action counts per device for monitoring."""
|
|
497
|
+
now = time.time()
|
|
498
|
+
cutoff = now - self._window
|
|
499
|
+
stats = {}
|
|
500
|
+
with self._lock:
|
|
501
|
+
for device_id, window in self._windows.items():
|
|
502
|
+
active = sum(1 for t in window if t >= cutoff)
|
|
503
|
+
stats[device_id] = {
|
|
504
|
+
"count": active,
|
|
505
|
+
"limit": self._limit,
|
|
506
|
+
"remaining": max(0, self._limit - active),
|
|
507
|
+
}
|
|
508
|
+
return stats
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── Policy Engine ─────────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
class IntentRateLimitPolicy(BasePolicy):
|
|
514
|
+
"""
|
|
515
|
+
Limits intent execution frequency per source using a sliding window counter.
|
|
516
|
+
|
|
517
|
+
This policy is a REQUIRED component of any DoSync-compliant deployment.
|
|
518
|
+
It protects the hub against runaway AI agents, malfunctioning automations,
|
|
519
|
+
and denial-of-service conditions.
|
|
520
|
+
|
|
521
|
+
Emergency intents are NEVER rate limited — this is a protocol-level guarantee.
|
|
522
|
+
All other urgency levels are limited independently per source.
|
|
523
|
+
|
|
524
|
+
Default limits (configurable):
|
|
525
|
+
info: 60 intents / minute per source
|
|
526
|
+
warning: 60 intents / minute per source
|
|
527
|
+
alert: 20 intents / minute per source
|
|
528
|
+
|
|
529
|
+
Usage:
|
|
530
|
+
policy_engine.add(IntentRateLimitPolicy())
|
|
531
|
+
|
|
532
|
+
# Custom limits
|
|
533
|
+
policy_engine.add(IntentRateLimitPolicy(
|
|
534
|
+
limits_per_minute={"info": 30, "warning": 30, "alert": 10}
|
|
535
|
+
))
|
|
536
|
+
|
|
537
|
+
The response follows HTTP 429 semantics: BLOCK with reason including
|
|
538
|
+
the current count, the limit, and the seconds until the window resets.
|
|
539
|
+
Every blocked intent is logged in the tamper-evident audit trail.
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
# Protocol-defined minimum default limits.
|
|
543
|
+
# A compliant hub MUST enforce at least these limits.
|
|
544
|
+
DEFAULT_LIMITS: dict[str, int] = {
|
|
545
|
+
"info": 60,
|
|
546
|
+
"warning": 60,
|
|
547
|
+
"alert": 20,
|
|
548
|
+
# "emergency" is intentionally absent — always bypassed
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
def __init__(
|
|
552
|
+
self,
|
|
553
|
+
limits_per_minute: dict[str, int] | None = None,
|
|
554
|
+
window_seconds: int = 60,
|
|
555
|
+
):
|
|
556
|
+
self._limits = limits_per_minute if limits_per_minute is not None else self.DEFAULT_LIMITS
|
|
557
|
+
self._window = window_seconds
|
|
558
|
+
# Sliding window: {source: {urgency_value: deque of timestamps}}
|
|
559
|
+
self._windows: dict[str, dict[str, deque]] = {}
|
|
560
|
+
self._lock = threading.Lock()
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def name(self) -> str:
|
|
564
|
+
return "intent_rate_limit"
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def priority(self) -> int:
|
|
568
|
+
return 0 # FIRST line of defense — runs before all other policies
|
|
569
|
+
|
|
570
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
571
|
+
from .models import Urgency
|
|
572
|
+
|
|
573
|
+
# Emergency intents are NEVER rate limited — protocol guarantee
|
|
574
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
urgency_value = str(intent.urgency.value)
|
|
578
|
+
limit = self._limits.get(urgency_value)
|
|
579
|
+
if limit is None:
|
|
580
|
+
return None # no limit configured for this urgency level
|
|
581
|
+
|
|
582
|
+
source = getattr(intent, "source", None) or "unknown"
|
|
583
|
+
now = time.time()
|
|
584
|
+
cutoff = now - self._window
|
|
585
|
+
|
|
586
|
+
with self._lock:
|
|
587
|
+
# Initialize per-source, per-urgency sliding window
|
|
588
|
+
if source not in self._windows:
|
|
589
|
+
self._windows[source] = {}
|
|
590
|
+
if urgency_value not in self._windows[source]:
|
|
591
|
+
self._windows[source][urgency_value] = deque()
|
|
592
|
+
|
|
593
|
+
window = self._windows[source][urgency_value]
|
|
594
|
+
|
|
595
|
+
# Evict timestamps outside the sliding window
|
|
596
|
+
while window and window[0] < cutoff:
|
|
597
|
+
window.popleft()
|
|
598
|
+
|
|
599
|
+
current_count = len(window)
|
|
600
|
+
|
|
601
|
+
if current_count >= limit:
|
|
602
|
+
# Calculate retry-after: seconds until oldest entry leaves the window
|
|
603
|
+
retry_after = max(1, int(self._window - (now - window[0])) + 1)
|
|
604
|
+
return PolicyResult.block(
|
|
605
|
+
self.name,
|
|
606
|
+
f"Rate limit exceeded for source '{source}': "
|
|
607
|
+
f"{current_count}/{limit} {urgency_value} intents "
|
|
608
|
+
f"in the last {self._window}s. "
|
|
609
|
+
f"Retry after {retry_after}s."
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
# Record this intent execution
|
|
613
|
+
window.append(now)
|
|
614
|
+
return None # within limit — allow
|
|
615
|
+
|
|
616
|
+
def get_stats(self) -> dict:
|
|
617
|
+
"""Return current rate limit counters for all sources. Useful for monitoring."""
|
|
618
|
+
now = time.time()
|
|
619
|
+
cutoff = now - self._window
|
|
620
|
+
stats = {}
|
|
621
|
+
with self._lock:
|
|
622
|
+
for source, urgency_windows in self._windows.items():
|
|
623
|
+
stats[source] = {}
|
|
624
|
+
for urgency, window in urgency_windows.items():
|
|
625
|
+
# Count active entries
|
|
626
|
+
active = sum(1 for t in window if t >= cutoff)
|
|
627
|
+
limit = self._limits.get(urgency, 0)
|
|
628
|
+
stats[source][urgency] = {
|
|
629
|
+
"count": active,
|
|
630
|
+
"limit": limit,
|
|
631
|
+
"remaining": max(0, limit - active),
|
|
632
|
+
}
|
|
633
|
+
return stats
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class PolicyEngine:
|
|
637
|
+
"""
|
|
638
|
+
Evaluates all registered policies against an intent and action plan.
|
|
639
|
+
|
|
640
|
+
Policies are evaluated in priority order (lowest number first).
|
|
641
|
+
First BLOCK or CONFIRM result wins.
|
|
642
|
+
MODIFY policies are accumulated — all matching MODIFY policies apply.
|
|
643
|
+
ALLOW is the default if no policy matches.
|
|
644
|
+
|
|
645
|
+
Usage:
|
|
646
|
+
engine = PolicyEngine()
|
|
647
|
+
engine.add(NeverAfterHoursPolicy(["unlock"], 0, 6))
|
|
648
|
+
engine.add(RequireConfirmationPolicy(["alarm"]))
|
|
649
|
+
|
|
650
|
+
result = engine.evaluate(intent, plan)
|
|
651
|
+
"""
|
|
652
|
+
|
|
653
|
+
def __init__(self):
|
|
654
|
+
self._policies: list[BasePolicy] = []
|
|
655
|
+
|
|
656
|
+
def add(self, policy: BasePolicy) -> None:
|
|
657
|
+
"""Register a policy."""
|
|
658
|
+
self._policies.append(policy)
|
|
659
|
+
self._policies.sort(key=lambda p: p.priority)
|
|
660
|
+
log.info("Policy registered: %s (priority %d)", policy.name, policy.priority)
|
|
661
|
+
|
|
662
|
+
def remove(self, policy_name: str) -> None:
|
|
663
|
+
"""Remove a policy by name."""
|
|
664
|
+
self._policies = [p for p in self._policies if p.name != policy_name]
|
|
665
|
+
|
|
666
|
+
def list_policies(self) -> list[dict]:
|
|
667
|
+
"""List all registered policies."""
|
|
668
|
+
return [{"name": p.name, "priority": p.priority} for p in self._policies]
|
|
669
|
+
|
|
670
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult:
|
|
671
|
+
"""
|
|
672
|
+
Evaluate all policies. Returns the first blocking/confirming result,
|
|
673
|
+
or ALLOW if all policies pass. MODIFY policies are applied cumulatively.
|
|
674
|
+
"""
|
|
675
|
+
from .models import Urgency
|
|
676
|
+
|
|
677
|
+
# Emergency intents bypass policies that declare bypass_on_emergency=True.
|
|
678
|
+
# Policies with bypass_on_emergency=False are still evaluated
|
|
679
|
+
# (e.g. BlockIntentPolicy — operator blocks are absolute).
|
|
680
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
681
|
+
non_bypassable = [p for p in self._policies if not p.bypass_on_emergency]
|
|
682
|
+
emergency_plan = plan
|
|
683
|
+
emergency_modified: list[str] = []
|
|
684
|
+
emergency_reasons: list[str] = []
|
|
685
|
+
if non_bypassable:
|
|
686
|
+
for policy in non_bypassable:
|
|
687
|
+
try:
|
|
688
|
+
result = policy.evaluate(intent, emergency_plan)
|
|
689
|
+
except Exception as e:
|
|
690
|
+
log.error("Policy '%s' raised an exception: %s", policy.name, e)
|
|
691
|
+
continue
|
|
692
|
+
if result is None:
|
|
693
|
+
continue
|
|
694
|
+
if result.decision == PolicyDecision.BLOCK:
|
|
695
|
+
log.info(
|
|
696
|
+
"PolicyEngine: EMERGENCY intent blocked by non-bypassable policy '%s': %s",
|
|
697
|
+
policy.name, result.reason,
|
|
698
|
+
)
|
|
699
|
+
return result
|
|
700
|
+
if result.decision == PolicyDecision.MODIFY:
|
|
701
|
+
# A non-bypassable policy's decision is honored WHATEVER it
|
|
702
|
+
# is — not only BLOCK. Until 2026-07-14 this branch
|
|
703
|
+
# evaluated the policy, received its MODIFY, and silently
|
|
704
|
+
# discarded it on the way to allow("emergency_bypass"): the
|
|
705
|
+
# deployer declared an absolute restriction, the engine
|
|
706
|
+
# asked the policy, got the answer, and threw it away.
|
|
707
|
+
# Nobody noticed because every MODIFY policy in the tree was
|
|
708
|
+
# bypassable, so the case never arose until an operator
|
|
709
|
+
# needed "these devices must not act, not even in an
|
|
710
|
+
# emergency" (a ward where lights must never come on, a
|
|
711
|
+
# screen nobody will read while evacuating).
|
|
712
|
+
from .models import ActionPlan
|
|
713
|
+
emergency_plan = ActionPlan(
|
|
714
|
+
intent_id=plan.intent_id,
|
|
715
|
+
actions=result.modified_actions,
|
|
716
|
+
urgency=plan.urgency,
|
|
717
|
+
)
|
|
718
|
+
emergency_modified.append(policy.name)
|
|
719
|
+
emergency_reasons.append(f"{policy.name}: {result.reason}")
|
|
720
|
+
log.info(
|
|
721
|
+
"PolicyEngine: EMERGENCY intent — %d/%d policies bypassed",
|
|
722
|
+
len(self._policies) - len(non_bypassable), len(self._policies),
|
|
723
|
+
)
|
|
724
|
+
if emergency_modified:
|
|
725
|
+
log.info("PolicyEngine: EMERGENCY plan modified by non-bypassable %s",
|
|
726
|
+
emergency_modified)
|
|
727
|
+
return PolicyResult(
|
|
728
|
+
decision=PolicyDecision.MODIFY,
|
|
729
|
+
policy_name=", ".join(emergency_modified),
|
|
730
|
+
# Carry each policy's OWN declared reason: for audit
|
|
731
|
+
# provenance, "why was this removed" is the operator's
|
|
732
|
+
# words, not this engine's generic phrasing.
|
|
733
|
+
reason="; ".join(emergency_reasons)
|
|
734
|
+
or "Plan modified by non-bypassable policies",
|
|
735
|
+
modified_actions=emergency_plan.actions,
|
|
736
|
+
)
|
|
737
|
+
return PolicyResult.allow("emergency_bypass")
|
|
738
|
+
|
|
739
|
+
current_plan = plan
|
|
740
|
+
modify_applied = []
|
|
741
|
+
modify_reasons: list[str] = []
|
|
742
|
+
|
|
743
|
+
for policy in self._policies:
|
|
744
|
+
try:
|
|
745
|
+
result = policy.evaluate(intent, current_plan)
|
|
746
|
+
except Exception as e:
|
|
747
|
+
log.error("Policy '%s' raised an exception: %s", policy.name, e)
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
if result is None:
|
|
751
|
+
continue # policy abstained
|
|
752
|
+
|
|
753
|
+
if result.decision == PolicyDecision.BLOCK:
|
|
754
|
+
return result # stop immediately
|
|
755
|
+
|
|
756
|
+
if result.decision == PolicyDecision.CONFIRM:
|
|
757
|
+
return result # stop and request confirmation
|
|
758
|
+
|
|
759
|
+
if result.decision == PolicyDecision.MODIFY:
|
|
760
|
+
# Apply modification and continue evaluating
|
|
761
|
+
from .models import ActionPlan
|
|
762
|
+
current_plan = ActionPlan(
|
|
763
|
+
intent_id=plan.intent_id,
|
|
764
|
+
actions=result.modified_actions,
|
|
765
|
+
urgency=plan.urgency,
|
|
766
|
+
)
|
|
767
|
+
modify_applied.append(policy.name)
|
|
768
|
+
modify_reasons.append(f"{policy.name}: {result.reason}")
|
|
769
|
+
|
|
770
|
+
if modify_applied:
|
|
771
|
+
log.info("PolicyEngine: MODIFY applied by %s", modify_applied)
|
|
772
|
+
return PolicyResult(
|
|
773
|
+
decision=PolicyDecision.MODIFY,
|
|
774
|
+
policy_name=", ".join(modify_applied),
|
|
775
|
+
reason="; ".join(modify_reasons) or "Plan modified by policies",
|
|
776
|
+
modified_actions=current_plan.actions,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
return PolicyResult.allow("no_policy_matched")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ── Intent priority map ───────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
INTENT_PRIORITY: dict[str, int] = {
|
|
785
|
+
# Priority 1 — Emergency (highest)
|
|
786
|
+
"ensure_safety": 1,
|
|
787
|
+
"alert_anomaly": 1,
|
|
788
|
+
# Priority 2 — Security
|
|
789
|
+
"control_access": 2,
|
|
790
|
+
# Priority 3 — Notification
|
|
791
|
+
"notify": 3,
|
|
792
|
+
# Domain-specific intents (e.g. children_arrived_home) fall through to default 99
|
|
793
|
+
# Priority 4 — Comfort
|
|
794
|
+
"set_environment": 4,
|
|
795
|
+
"morning_routine": 4,
|
|
796
|
+
"bedtime_routine": 4,
|
|
797
|
+
"remind_chore": 4,
|
|
798
|
+
"report_status": 4,
|
|
799
|
+
# Priority 5 — Efficiency (lowest)
|
|
800
|
+
"save_energy": 5,
|
|
801
|
+
"away_mode": 5,
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
def get_intent_priority(intent_value: str) -> int:
|
|
805
|
+
"""Returns priority for an intent. Lower = higher priority. Default 99."""
|
|
806
|
+
return INTENT_PRIORITY.get(intent_value, 99)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ── Conflict resolution policy ────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
class ConflictResolutionPolicy(BasePolicy):
|
|
812
|
+
"""
|
|
813
|
+
Detects and resolves conflicts between simultaneous intents.
|
|
814
|
+
|
|
815
|
+
When two intents affect the same devices simultaneously, the one
|
|
816
|
+
with higher priority (lower number) wins. The lower priority intent
|
|
817
|
+
is blocked or has conflicting actions removed.
|
|
818
|
+
|
|
819
|
+
Priority scale (lower = higher priority):
|
|
820
|
+
1 = Emergency
|
|
821
|
+
2 = Security
|
|
822
|
+
3 = Presence
|
|
823
|
+
4 = Comfort
|
|
824
|
+
5 = Efficiency
|
|
825
|
+
|
|
826
|
+
Example:
|
|
827
|
+
notify (priority 3) fires at the same time as
|
|
828
|
+
save_energy (priority 5) — save_energy loses on shared devices.
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def __init__(self, hub):
|
|
832
|
+
self._hub = hub
|
|
833
|
+
|
|
834
|
+
@property
|
|
835
|
+
def name(self) -> str:
|
|
836
|
+
return "conflict_resolution"
|
|
837
|
+
|
|
838
|
+
@property
|
|
839
|
+
def priority(self) -> int:
|
|
840
|
+
return 1 # evaluated first
|
|
841
|
+
|
|
842
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
843
|
+
active = getattr(self._hub, "_active_intents", {})
|
|
844
|
+
if not active:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
current_priority = get_intent_priority(intent.intent.value)
|
|
848
|
+
current_devices = {a.device_id for a in plan.actions}
|
|
849
|
+
|
|
850
|
+
for active_intent_value, active_priority in active.items():
|
|
851
|
+
if active_intent_value == intent.intent.value:
|
|
852
|
+
continue
|
|
853
|
+
|
|
854
|
+
# Check if there are shared devices (conflict)
|
|
855
|
+
active_devices = getattr(self._hub, "_active_intent_devices", {}).get(
|
|
856
|
+
active_intent_value, set()
|
|
857
|
+
)
|
|
858
|
+
conflict_devices = current_devices & active_devices
|
|
859
|
+
|
|
860
|
+
if not conflict_devices:
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
# Conflict detected
|
|
864
|
+
if current_priority > active_priority:
|
|
865
|
+
# Current intent has LOWER priority — block conflicting actions
|
|
866
|
+
filtered = [a for a in plan.actions if a.device_id not in conflict_devices]
|
|
867
|
+
if not filtered:
|
|
868
|
+
return PolicyResult.block(
|
|
869
|
+
self.name,
|
|
870
|
+
f"Intent '{intent.intent.value}' (priority {current_priority}) blocked "
|
|
871
|
+
f"by '{active_intent_value}' (priority {active_priority}) "
|
|
872
|
+
f"on devices: {conflict_devices}"
|
|
873
|
+
)
|
|
874
|
+
log.info(
|
|
875
|
+
"ConflictResolution: '%s' loses to '%s' on %d device(s)",
|
|
876
|
+
intent.intent.value, active_intent_value, len(conflict_devices)
|
|
877
|
+
)
|
|
878
|
+
result = PolicyResult(
|
|
879
|
+
decision=PolicyDecision.MODIFY,
|
|
880
|
+
policy_name=self.name,
|
|
881
|
+
reason=f"Conflict with higher-priority intent '{active_intent_value}'",
|
|
882
|
+
modified_actions=filtered,
|
|
883
|
+
)
|
|
884
|
+
return result
|
|
885
|
+
|
|
886
|
+
elif current_priority < active_priority:
|
|
887
|
+
# Current intent has HIGHER priority — it wins, log the override
|
|
888
|
+
log.info(
|
|
889
|
+
"ConflictResolution: '%s' (priority %d) overrides '%s' (priority %d) "
|
|
890
|
+
"on devices: %s",
|
|
891
|
+
intent.intent.value, current_priority,
|
|
892
|
+
active_intent_value, active_priority,
|
|
893
|
+
conflict_devices,
|
|
894
|
+
)
|
|
895
|
+
# No modification needed — current intent executes fully
|
|
896
|
+
return None
|
|
897
|
+
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
# ── Contextual weighting policy ───────────────────────────────────────────────
|
|
902
|
+
|
|
903
|
+
class ContextualWeightingPolicy(BasePolicy):
|
|
904
|
+
"""
|
|
905
|
+
Adjusts intent context based on temporal and environmental factors.
|
|
906
|
+
|
|
907
|
+
The same physical event carries different weight depending on context:
|
|
908
|
+
- A motion sensor at 3am is more likely an intrusion than at 3pm
|
|
909
|
+
- A temperature anomaly in winter has different implications than in summer
|
|
910
|
+
- Monday morning routines differ from weekend patterns
|
|
911
|
+
|
|
912
|
+
This policy injects a 'context_weight' into the intent context,
|
|
913
|
+
which the resolver can use to adjust scoring.
|
|
914
|
+
|
|
915
|
+
Weight scale:
|
|
916
|
+
1.0 = normal weight (default)
|
|
917
|
+
> 1.0 = amplify response (e.g. motion at night = higher urgency)
|
|
918
|
+
< 1.0 = reduce response (e.g. motion during typical home hours = routine)
|
|
919
|
+
|
|
920
|
+
Built-in rules:
|
|
921
|
+
- motion_detected at night (22:00-06:00) → weight 1.8 (possible intrusion)
|
|
922
|
+
- motion_detected during work hours (09:00-17:00) weekday → weight 0.6 (likely routine)
|
|
923
|
+
- any intent on weekend → weight 0.9 (relaxed mode)
|
|
924
|
+
- temperature anomaly in extreme weather hours → weight 1.5
|
|
925
|
+
"""
|
|
926
|
+
|
|
927
|
+
def __init__(self, custom_rules: list[dict] | None = None):
|
|
928
|
+
self._custom_rules = custom_rules or []
|
|
929
|
+
|
|
930
|
+
@property
|
|
931
|
+
def name(self) -> str:
|
|
932
|
+
return "contextual_weighting"
|
|
933
|
+
|
|
934
|
+
@property
|
|
935
|
+
def priority(self) -> int:
|
|
936
|
+
return 2 # evaluated very early, before conflict resolution
|
|
937
|
+
|
|
938
|
+
def _compute_weight(self, intent: "Intent") -> float:
|
|
939
|
+
now = datetime.now()
|
|
940
|
+
hour = now.hour
|
|
941
|
+
weekday = now.weekday() # 0=Monday, 6=Sunday
|
|
942
|
+
is_night = hour >= 22 or hour < 6
|
|
943
|
+
is_work_hours = 9 <= hour < 17 and weekday < 5
|
|
944
|
+
is_weekend = weekday >= 5
|
|
945
|
+
intent_value = intent.intent.value
|
|
946
|
+
trigger = intent.context.get("trigger", "")
|
|
947
|
+
|
|
948
|
+
weight = 1.0
|
|
949
|
+
|
|
950
|
+
# Motion at night — possible intrusion
|
|
951
|
+
if trigger == "motion_detected" and is_night:
|
|
952
|
+
weight = 1.8
|
|
953
|
+
|
|
954
|
+
# Motion during typical work hours on weekday — likely routine
|
|
955
|
+
elif trigger == "motion_detected" and is_work_hours:
|
|
956
|
+
weight = 0.6
|
|
957
|
+
|
|
958
|
+
# Weekend — relaxed mode
|
|
959
|
+
if is_weekend and intent_value not in ("ensure_safety", "alert_anomaly"):
|
|
960
|
+
weight *= 0.9
|
|
961
|
+
|
|
962
|
+
# Temperature anomaly at extreme hours
|
|
963
|
+
if trigger == "temperature_anomaly" and is_night:
|
|
964
|
+
weight = max(weight, 1.5)
|
|
965
|
+
|
|
966
|
+
# Apply custom rules
|
|
967
|
+
for rule in self._custom_rules:
|
|
968
|
+
if rule.get("intent") == intent_value:
|
|
969
|
+
if rule.get("hour_start") is not None and rule.get("hour_end") is not None:
|
|
970
|
+
if rule["hour_start"] <= hour < rule["hour_end"]:
|
|
971
|
+
weight = rule.get("weight", weight)
|
|
972
|
+
|
|
973
|
+
return round(weight, 2)
|
|
974
|
+
|
|
975
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
976
|
+
from .models import Urgency
|
|
977
|
+
# Emergency always full weight
|
|
978
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
979
|
+
return None
|
|
980
|
+
|
|
981
|
+
weight = self._compute_weight(intent)
|
|
982
|
+
|
|
983
|
+
if weight == 1.0:
|
|
984
|
+
return None # no adjustment needed
|
|
985
|
+
|
|
986
|
+
# Inject weight into intent context for resolver awareness
|
|
987
|
+
intent.context["context_weight"] = weight
|
|
988
|
+
|
|
989
|
+
if weight < 0.7:
|
|
990
|
+
# Very low weight — reduce scope by keeping only high-scoring devices
|
|
991
|
+
log.info(
|
|
992
|
+
"ContextualWeighting: low weight %.2f for '%s' — reducing scope",
|
|
993
|
+
weight, intent.intent.value
|
|
994
|
+
)
|
|
995
|
+
high_priority_actions = plan.actions[:max(1, len(plan.actions) // 2)]
|
|
996
|
+
return PolicyResult(
|
|
997
|
+
decision=PolicyDecision.MODIFY,
|
|
998
|
+
policy_name=self.name,
|
|
999
|
+
reason=f"Low contextual weight ({weight}) — reduced scope",
|
|
1000
|
+
modified_actions=high_priority_actions,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
if weight > 1.5:
|
|
1004
|
+
# High weight — escalate urgency in context
|
|
1005
|
+
log.info(
|
|
1006
|
+
"ContextualWeighting: high weight %.2f for '%s' — escalating context",
|
|
1007
|
+
weight, intent.intent.value
|
|
1008
|
+
)
|
|
1009
|
+
intent.context["escalated"] = True
|
|
1010
|
+
intent.context["original_urgency"] = intent.urgency.value
|
|
1011
|
+
|
|
1012
|
+
log.info(
|
|
1013
|
+
"ContextualWeighting: weight=%.2f applied to '%s' (hour=%d, trigger=%s)",
|
|
1014
|
+
weight, intent.intent.value,
|
|
1015
|
+
__import__("datetime").datetime.now().hour,
|
|
1016
|
+
intent.context.get("trigger", "none")
|
|
1017
|
+
)
|
|
1018
|
+
return None # let intent proceed with modified context
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# ── Aerial domain policies (drone safety) ─────────────────────────────────────
|
|
1022
|
+
# These two policies are the ones the expert panel identified as the essential
|
|
1023
|
+
# barriers for a vehicle that moves through physical space. They are deliberately
|
|
1024
|
+
# ABSOLUTE: both set bypass_on_emergency = False. A geofence protects something
|
|
1025
|
+
# real (an airport, a populated area, restricted airspace); a DoSync "emergency"
|
|
1026
|
+
# is never a reason to fly the vehicle there. And once a human has taken manual
|
|
1027
|
+
# control, an emergency must NOT wrestle it back — the takeover is very likely the
|
|
1028
|
+
# response to that emergency.
|
|
1029
|
+
#
|
|
1030
|
+
# CRITICAL BOUNDARY: these policies filter INTENT before DoSync dispatches a MAVLink
|
|
1031
|
+
# command. They are NOT the flight safety system. The failsafe (lost link, low
|
|
1032
|
+
# battery, GPS loss) lives in the vehicle firmware and acts on its own, even if
|
|
1033
|
+
# DoSync does not exist. DoSync is a policy layer above the firmware, never the
|
|
1034
|
+
# firmware's replacement. The firmware also has its own geofence; this complements
|
|
1035
|
+
# it, it does not substitute for it.
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
class GeofencePolicy(BasePolicy):
|
|
1039
|
+
"""Blocks any go_to whose target lies outside the permitted perimeter.
|
|
1040
|
+
|
|
1041
|
+
The perimeter is a horizontal circle (center + max radius) plus a maximum
|
|
1042
|
+
altitude ceiling — the standard, easy-to-reason-about geofence that covers the
|
|
1043
|
+
overwhelming majority of cases. Each deployment configures its own perimeter
|
|
1044
|
+
via the constructor, exactly like NeverAfterHoursPolicy configures its hours.
|
|
1045
|
+
The protocol stays generic; the perimeter is deployment-specific.
|
|
1046
|
+
|
|
1047
|
+
Only go_to actions carry a destination, so only they are checked. take_off,
|
|
1048
|
+
land, return_home and loiter do not move the vehicle to an arbitrary point and
|
|
1049
|
+
are not constrained here (return_home in particular must always be allowed —
|
|
1050
|
+
it is how the vehicle comes back).
|
|
1051
|
+
|
|
1052
|
+
This is an ABSOLUTE limit: bypass_on_emergency is False. The perimeter exists
|
|
1053
|
+
because crossing it is dangerous or illegal, and no urgency changes that.
|
|
1054
|
+
|
|
1055
|
+
DESIGN NOTE — agnosticism and the deliberate coupling to `go_to`:
|
|
1056
|
+
The PolicyEngine itself stays fully domain-agnostic; this is just one more
|
|
1057
|
+
pluggable BasePolicy, registered only by deployments that fly something, like
|
|
1058
|
+
NeverAfterHoursPolicy is registered only where time restrictions matter. It is
|
|
1059
|
+
agnostic to the drone make/model (it reasons over lat/lon/alt, which are
|
|
1060
|
+
universal), but it is inherently SPATIAL — an oven or a light has no position,
|
|
1061
|
+
so a geofence is meaningless for them. That is correct and expected: generic
|
|
1062
|
+
mechanism, domain-specific policy.
|
|
1063
|
+
However, this policy is currently coupled to the MAVLink adapter's vocabulary:
|
|
1064
|
+
it checks the literal action name "go_to" and the params "lat"/"lon"/"alt".
|
|
1065
|
+
That coupling is DELIBERATE for now. DoSync has exactly one spatial adapter
|
|
1066
|
+
(the drone). Abstracting "an action with a spatial destination" into a protocol-
|
|
1067
|
+
level concept that any spatial adapter (a rover, a boat, a 3D robotic arm) would
|
|
1068
|
+
declare is the right move ONLY once a SECOND spatial device exists to inform the
|
|
1069
|
+
abstraction — designing it from a single example risks the wrong abstraction
|
|
1070
|
+
(the same "don't abstract on one example" discipline applied to the
|
|
1071
|
+
execution_model vocabulary). When a second spatial adapter arrives, extract a
|
|
1072
|
+
declared "spatial destination" capability and have this policy operate on that
|
|
1073
|
+
instead of the hardcoded "go_to". Until then, the explicit coupling is the
|
|
1074
|
+
honest, simplest correct choice.
|
|
1075
|
+
"""
|
|
1076
|
+
|
|
1077
|
+
def __init__(self, center_lat: float, center_lon: float,
|
|
1078
|
+
max_radius_m: float, max_altitude_m: float = None,
|
|
1079
|
+
applies_to_devices: list = None):
|
|
1080
|
+
"""
|
|
1081
|
+
Args:
|
|
1082
|
+
center_lat, center_lon: center of the permitted circle (degrees).
|
|
1083
|
+
max_radius_m: maximum allowed distance from center, in meters.
|
|
1084
|
+
max_altitude_m: optional altitude ceiling, in meters. None = no ceiling.
|
|
1085
|
+
applies_to_devices: optional list of device_ids this geofence governs.
|
|
1086
|
+
None = applies to every go_to in the plan (single-perimeter deploy).
|
|
1087
|
+
"""
|
|
1088
|
+
self._center_lat = center_lat
|
|
1089
|
+
self._center_lon = center_lon
|
|
1090
|
+
self._max_radius_m = max_radius_m
|
|
1091
|
+
self._max_altitude_m = max_altitude_m
|
|
1092
|
+
self._devices = set(applies_to_devices) if applies_to_devices else None
|
|
1093
|
+
|
|
1094
|
+
@property
|
|
1095
|
+
def name(self) -> str:
|
|
1096
|
+
return "geofence"
|
|
1097
|
+
|
|
1098
|
+
@property
|
|
1099
|
+
def priority(self) -> int:
|
|
1100
|
+
# Evaluate early — a geofence breach should be caught before lower-priority
|
|
1101
|
+
# conveniences even look at the plan.
|
|
1102
|
+
return 10
|
|
1103
|
+
|
|
1104
|
+
@property
|
|
1105
|
+
def bypass_on_emergency(self) -> bool:
|
|
1106
|
+
# ABSOLUTE. An emergency never licenses crossing the perimeter.
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
@staticmethod
|
|
1110
|
+
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
1111
|
+
"""Great-circle distance, delegated to the shared geo module so the formula
|
|
1112
|
+
lives in exactly one place (see dosync/geo.py). Kept as a thin wrapper for
|
|
1113
|
+
backward compatibility with any code/tests referencing it."""
|
|
1114
|
+
from .geo import haversine_m
|
|
1115
|
+
return haversine_m(lat1, lon1, lat2, lon2)
|
|
1116
|
+
|
|
1117
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
1118
|
+
from .geo import is_within_perimeter
|
|
1119
|
+
for action in plan.actions:
|
|
1120
|
+
if action.action != "go_to":
|
|
1121
|
+
continue
|
|
1122
|
+
if self._devices is not None and action.device_id not in self._devices:
|
|
1123
|
+
continue
|
|
1124
|
+
|
|
1125
|
+
params = action.params or {}
|
|
1126
|
+
lat = params.get("lat")
|
|
1127
|
+
lon = params.get("lon")
|
|
1128
|
+
if lat is None or lon is None:
|
|
1129
|
+
# A go_to without coordinates is malformed; let the adapter reject
|
|
1130
|
+
# it. The geofence only judges destinations it can locate.
|
|
1131
|
+
continue
|
|
1132
|
+
|
|
1133
|
+
# Same shared rule the in-flight guard uses — admission vs monitoring.
|
|
1134
|
+
ok, reason = is_within_perimeter(
|
|
1135
|
+
lat, lon, self._center_lat, self._center_lon,
|
|
1136
|
+
self._max_radius_m,
|
|
1137
|
+
alt=params.get("alt"), max_altitude_m=self._max_altitude_m,
|
|
1138
|
+
)
|
|
1139
|
+
if not ok:
|
|
1140
|
+
return PolicyResult.block(self.name, f"go_to target {reason}")
|
|
1141
|
+
|
|
1142
|
+
return None # every go_to in the plan is within the perimeter
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
class ManualControlActivePolicy(BasePolicy):
|
|
1146
|
+
"""Blocks commands to a vehicle whose active operation is `interrupted` — i.e.
|
|
1147
|
+
a human has taken manual control.
|
|
1148
|
+
|
|
1149
|
+
This is the policy-level counterpart to the MANUAL_CONTROL_TAKEN telemetry
|
|
1150
|
+
event. Once the pilot has the sticks, DoSync must not try to re-dispatch the
|
|
1151
|
+
original (or any) command to that vehicle, or it would fight the human for
|
|
1152
|
+
control. The pilot took over for a reason DoSync cannot see — most likely to
|
|
1153
|
+
avoid something. DoSync goes quiet until a human explicitly returns control
|
|
1154
|
+
(by clearing/finishing the interrupted operation out of band).
|
|
1155
|
+
|
|
1156
|
+
Consults the hub for the vehicle's active operations. An operation in the
|
|
1157
|
+
INTERRUPTED state means a human is flying; any command in the plan targeting
|
|
1158
|
+
that device is blocked.
|
|
1159
|
+
|
|
1160
|
+
ABSOLUTE: bypass_on_emergency is False. An emergency is very likely the reason
|
|
1161
|
+
the human took control — it must never be used to wrestle control back.
|
|
1162
|
+
"""
|
|
1163
|
+
|
|
1164
|
+
INTERRUPTED_STATE = "interrupted"
|
|
1165
|
+
|
|
1166
|
+
def __init__(self, hub):
|
|
1167
|
+
self._hub = hub
|
|
1168
|
+
|
|
1169
|
+
@property
|
|
1170
|
+
def name(self) -> str:
|
|
1171
|
+
return "manual_control_active"
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def priority(self) -> int:
|
|
1175
|
+
# Evaluate very early — if a human is flying, nothing else about the plan
|
|
1176
|
+
# for that device matters.
|
|
1177
|
+
return 5
|
|
1178
|
+
|
|
1179
|
+
@property
|
|
1180
|
+
def bypass_on_emergency(self) -> bool:
|
|
1181
|
+
# ABSOLUTE. An emergency never wrestles control back from a human pilot.
|
|
1182
|
+
return False
|
|
1183
|
+
|
|
1184
|
+
def _devices_under_manual_control(self) -> set:
|
|
1185
|
+
"""Return the set of device_ids whose active operation is interrupted."""
|
|
1186
|
+
try:
|
|
1187
|
+
active = self._hub.db.get_active_operations()
|
|
1188
|
+
except Exception:
|
|
1189
|
+
return set()
|
|
1190
|
+
return {
|
|
1191
|
+
o.get("device_id") for o in active
|
|
1192
|
+
if o.get("state") == self.INTERRUPTED_STATE and o.get("device_id")
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
def evaluate(self, intent: "Intent", plan: "ActionPlan") -> PolicyResult | None:
|
|
1196
|
+
manual = self._devices_under_manual_control()
|
|
1197
|
+
if not manual:
|
|
1198
|
+
return None
|
|
1199
|
+
|
|
1200
|
+
blocked = [a for a in plan.actions if a.device_id in manual]
|
|
1201
|
+
if not blocked:
|
|
1202
|
+
return None
|
|
1203
|
+
|
|
1204
|
+
devices = sorted({a.device_id for a in blocked})
|
|
1205
|
+
return PolicyResult.block(
|
|
1206
|
+
self.name,
|
|
1207
|
+
f"Device(s) {devices} are under manual human control "
|
|
1208
|
+
f"(operation interrupted). DoSync will not dispatch commands until a "
|
|
1209
|
+
f"human returns control.",
|
|
1210
|
+
)
|