tibet-ping 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ *.egg
8
+ .eggs/
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: tibet-ping
3
+ Version: 0.1.0
4
+ Summary: Intent-based discovery and communication protocol with TIBET provenance. ICMP replacement with identity, trust, and context.
5
+ Project-URL: Homepage, https://humotica.com
6
+ Project-URL: Repository, https://github.com/Humotica/tibet-ping
7
+ Author-email: "J. van de Meent" <jasper@humotica.com>, "R. AI" <root_idd@humotica.nl>
8
+ License: MIT
9
+ Keywords: airlock,beacon,bootstrap,discovery,hub,intent,iot,jis,mesh,ping,pod,provenance,station,tibet,trust,vouching
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Intended Audience :: Telecommunications Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: System :: Networking
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: tibet-core>=0.2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == 'dev'
25
+ Provides-Extra: overlay
26
+ Requires-Dist: tibet-overlay>=0.1.1; extra == 'overlay'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # tibet-ping
30
+
31
+ **Intent-based discovery and communication protocol with TIBET provenance.**
32
+
33
+ ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
34
+
35
+ tibet-ping replaces this with a TIBET token as handshake. Every ping carries identity (JIS), intent, context, and purpose. Responses are trust-gated through Airlock zones.
36
+
37
+ ## Features
38
+
39
+ - **PingPacket** — TIBET-backed ping with identity, intent, context, purpose
40
+ - **NonceTracker** — Replay protection (30-second time window)
41
+ - **Airlock** — Trust-gated access (GROEN/GEEL/ROOD zones) with rules engine
42
+ - **Vouching** — Trust delegation for device groups (solves HITL scaling)
43
+ - **Topology** — Hub/Hubby/Pod/Station network modeling
44
+ - **Beacon** — Airgapped bootstrap for new devices (chicken-and-egg solved)
45
+
46
+ ## Quick Start
47
+
48
+ ```python
49
+ from tibet_ping import PingNode, PingDecision
50
+
51
+ # Create nodes
52
+ hub = PingNode("jis:home:hub")
53
+ sensor = PingNode("jis:home:sensor_temp")
54
+
55
+ # Hub trusts sensor
56
+ hub.set_trust("jis:home:sensor_temp", 0.9)
57
+
58
+ # Sensor pings hub
59
+ packet = sensor.ping(
60
+ target="jis:home:hub",
61
+ intent="temperature.report",
62
+ purpose="Periodic temperature reading",
63
+ payload={"celsius": 21.5},
64
+ )
65
+
66
+ # Hub receives and processes
67
+ response = hub.receive(packet)
68
+ assert response.decision == PingDecision.ACCEPT
69
+ assert response.airlock_zone == "GROEN"
70
+ ```
71
+
72
+ ## Airlock Zones
73
+
74
+ | Zone | Trust | Action |
75
+ |------|-------|--------|
76
+ | **GROEN** | >= 0.7 | Auto-allow |
77
+ | **GEEL** | 0.3 - 0.7 | Pending (rules or HITL) |
78
+ | **ROOD** | < 0.3 | Silent drop |
79
+
80
+ ## Vouching (HITL Scaling)
81
+
82
+ ```python
83
+ # Hub vouches for 50 sensors at once
84
+ hub.vouch(
85
+ vouched_dids=["jis:home:s1", "jis:home:s2", ...],
86
+ my_trust=0.9,
87
+ vouch_factor=0.7, # Vouched trust = 0.9 * 0.7 = 0.63
88
+ )
89
+ ```
90
+
91
+ ## Beacon Bootstrap
92
+
93
+ ```python
94
+ # New device broadcasts beacon (no secrets!)
95
+ beacon = new_device.broadcast_beacon(
96
+ capabilities=["temperature"],
97
+ device_type="sensor",
98
+ )
99
+
100
+ # Hub auto-vouches or escalates to HITL
101
+ response = hub.handle_beacon(beacon)
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT — Humotica AI Lab 2025-2026
@@ -0,0 +1,78 @@
1
+ # tibet-ping
2
+
3
+ **Intent-based discovery and communication protocol with TIBET provenance.**
4
+
5
+ ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
6
+
7
+ tibet-ping replaces this with a TIBET token as handshake. Every ping carries identity (JIS), intent, context, and purpose. Responses are trust-gated through Airlock zones.
8
+
9
+ ## Features
10
+
11
+ - **PingPacket** — TIBET-backed ping with identity, intent, context, purpose
12
+ - **NonceTracker** — Replay protection (30-second time window)
13
+ - **Airlock** — Trust-gated access (GROEN/GEEL/ROOD zones) with rules engine
14
+ - **Vouching** — Trust delegation for device groups (solves HITL scaling)
15
+ - **Topology** — Hub/Hubby/Pod/Station network modeling
16
+ - **Beacon** — Airgapped bootstrap for new devices (chicken-and-egg solved)
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from tibet_ping import PingNode, PingDecision
22
+
23
+ # Create nodes
24
+ hub = PingNode("jis:home:hub")
25
+ sensor = PingNode("jis:home:sensor_temp")
26
+
27
+ # Hub trusts sensor
28
+ hub.set_trust("jis:home:sensor_temp", 0.9)
29
+
30
+ # Sensor pings hub
31
+ packet = sensor.ping(
32
+ target="jis:home:hub",
33
+ intent="temperature.report",
34
+ purpose="Periodic temperature reading",
35
+ payload={"celsius": 21.5},
36
+ )
37
+
38
+ # Hub receives and processes
39
+ response = hub.receive(packet)
40
+ assert response.decision == PingDecision.ACCEPT
41
+ assert response.airlock_zone == "GROEN"
42
+ ```
43
+
44
+ ## Airlock Zones
45
+
46
+ | Zone | Trust | Action |
47
+ |------|-------|--------|
48
+ | **GROEN** | >= 0.7 | Auto-allow |
49
+ | **GEEL** | 0.3 - 0.7 | Pending (rules or HITL) |
50
+ | **ROOD** | < 0.3 | Silent drop |
51
+
52
+ ## Vouching (HITL Scaling)
53
+
54
+ ```python
55
+ # Hub vouches for 50 sensors at once
56
+ hub.vouch(
57
+ vouched_dids=["jis:home:s1", "jis:home:s2", ...],
58
+ my_trust=0.9,
59
+ vouch_factor=0.7, # Vouched trust = 0.9 * 0.7 = 0.63
60
+ )
61
+ ```
62
+
63
+ ## Beacon Bootstrap
64
+
65
+ ```python
66
+ # New device broadcasts beacon (no secrets!)
67
+ beacon = new_device.broadcast_beacon(
68
+ capabilities=["temperature"],
69
+ device_type="sensor",
70
+ )
71
+
72
+ # Hub auto-vouches or escalates to HITL
73
+ response = hub.handle_beacon(beacon)
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT — Humotica AI Lab 2025-2026
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tibet-ping"
7
+ version = "0.1.0"
8
+ description = "Intent-based discovery and communication protocol with TIBET provenance. ICMP replacement with identity, trust, and context."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "J. van de Meent", email = "jasper@humotica.com"},
14
+ {name = "R. AI", email = "root_idd@humotica.nl"},
15
+ ]
16
+ keywords = [
17
+ "tibet", "jis", "ping", "discovery", "intent", "iot",
18
+ "mesh", "provenance", "trust", "airlock", "vouching",
19
+ "beacon", "bootstrap", "hub", "pod", "station"
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: System Administrators",
25
+ "Intended Audience :: Telecommunications Industry",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Topic :: System :: Networking",
32
+ "Topic :: Security",
33
+ ]
34
+ dependencies = [
35
+ "tibet-core>=0.2.0",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ overlay = ["tibet-overlay>=0.1.1"]
40
+ dev = ["pytest>=7.0"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://humotica.com"
44
+ Repository = "https://github.com/Humotica/tibet-ping"
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["/src"]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/tibet_ping"]
@@ -0,0 +1,82 @@
1
+ """
2
+ tibet-ping: Intent-Based Discovery & Communication Protocol.
3
+
4
+ ICMP ping is dumb: "are you there?" → "yes". No identity, no intent, no trust.
5
+ tibet-ping replaces this with a TIBET token as handshake.
6
+
7
+ Every ping carries:
8
+ Identity — JIS DID (who are you)
9
+ Intent — what do you want
10
+ Context — pod, station, network
11
+ Purpose — why are you pinging
12
+
13
+ Responses are trust-gated through Airlock (GROEN/GEEL/ROOD).
14
+
15
+ Usage::
16
+
17
+ from tibet_ping import PingNode
18
+
19
+ # Create a node
20
+ hub = PingNode("jis:home:hub")
21
+ sensor = PingNode("jis:home:sensor_temp")
22
+
23
+ # Hub trusts sensor
24
+ hub.set_trust("jis:home:sensor_temp", 0.9)
25
+
26
+ # Sensor pings hub
27
+ packet = sensor.ping(
28
+ target="jis:home:hub",
29
+ intent="temperature.report",
30
+ purpose="Periodic temperature reading",
31
+ payload={"celsius": 21.5},
32
+ )
33
+
34
+ # Hub receives and processes
35
+ response = hub.receive(packet)
36
+ print(response.decision) # PingDecision.ACCEPT
37
+ print(response.airlock_zone) # "GROEN"
38
+ """
39
+
40
+ from .proto import PingPacket, PingResponse, PingType, Priority, RoutingMode, PingDecision
41
+ from .nonce import NonceTracker
42
+ from .airlock import Airlock, AirlockRule, AirlockZone, PendingPing
43
+ from .vouch import Vouch, VouchRegistry
44
+ from .topology import TopologyManager, Pod, Station, NodeRole
45
+ from .beacon import Beacon, BeaconHandler, BeaconResponse
46
+ from .handler import PingHandler
47
+ from .node import PingNode
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [
52
+ # Node (main entry point)
53
+ "PingNode",
54
+ # Proto
55
+ "PingPacket",
56
+ "PingResponse",
57
+ "PingType",
58
+ "Priority",
59
+ "RoutingMode",
60
+ "PingDecision",
61
+ # Nonce
62
+ "NonceTracker",
63
+ # Airlock
64
+ "Airlock",
65
+ "AirlockRule",
66
+ "AirlockZone",
67
+ "PendingPing",
68
+ # Vouch
69
+ "Vouch",
70
+ "VouchRegistry",
71
+ # Topology
72
+ "TopologyManager",
73
+ "Pod",
74
+ "Station",
75
+ "NodeRole",
76
+ # Beacon
77
+ "Beacon",
78
+ "BeaconHandler",
79
+ "BeaconResponse",
80
+ # Handler
81
+ "PingHandler",
82
+ ]
@@ -0,0 +1,176 @@
1
+ """
2
+ Trust-gated access control for incoming pings.
3
+
4
+ Three zones:
5
+ GROEN — Known + trusted → auto-allow
6
+ GEEL — Unknown → pending (rules or HITL)
7
+ ROOD — Untrusted → silent drop (no info leak)
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Callable, Dict, List, Optional, Tuple
13
+
14
+ from .proto import PingDecision, PingPacket
15
+
16
+
17
+ class AirlockZone(Enum):
18
+ """Three-zone trust model."""
19
+ GROEN = "GROEN"
20
+ GEEL = "GEEL"
21
+ ROOD = "ROOD"
22
+
23
+
24
+ @dataclass
25
+ class AirlockRule:
26
+ """
27
+ Pattern-based auto-decision rule.
28
+
29
+ Patterns support simple glob: "*" matches everything,
30
+ "prefix*" matches start, "*suffix" matches end.
31
+
32
+ Examples:
33
+ {"source_did": "jis:home:*", "intent": "temperature.*"} → GROEN
34
+ {"intent": "door.unlock"} → GEEL (force HITL)
35
+ """
36
+ rule_id: str
37
+ name: str
38
+ pattern: Dict[str, str]
39
+ decision: PingDecision
40
+ zone: AirlockZone
41
+ priority: int = 50 # Higher = checked first
42
+
43
+ def matches(self, packet: PingPacket, sender_trust: float) -> bool:
44
+ """Check if packet matches this rule's pattern."""
45
+ for key, pattern in self.pattern.items():
46
+ if key == "source_did":
47
+ if not _glob_match(packet.source_did, pattern):
48
+ return False
49
+ elif key == "intent":
50
+ if not _glob_match(packet.intent, pattern):
51
+ return False
52
+ elif key == "pod_id":
53
+ if packet.pod_id != pattern:
54
+ return False
55
+ elif key == "ping_type":
56
+ if packet.ping_type.value != pattern:
57
+ return False
58
+ elif key == "min_trust":
59
+ if sender_trust < float(pattern):
60
+ return False
61
+ return True
62
+
63
+
64
+ def _glob_match(value: str, pattern: str) -> bool:
65
+ """Simple glob: *, prefix*, *suffix, exact."""
66
+ if pattern == "*":
67
+ return True
68
+ if pattern.startswith("*") and pattern.endswith("*") and len(pattern) > 2:
69
+ return pattern[1:-1] in value
70
+ if pattern.endswith("*"):
71
+ return value.startswith(pattern[:-1])
72
+ if pattern.startswith("*"):
73
+ return value.endswith(pattern[1:])
74
+ return value == pattern
75
+
76
+
77
+ @dataclass
78
+ class PendingPing:
79
+ """Ping awaiting HITL decision."""
80
+ packet: PingPacket
81
+ sender_trust: float
82
+ reason: str
83
+
84
+
85
+ class Airlock:
86
+ """
87
+ Trust-gated access control.
88
+
89
+ Rules are checked first (highest priority wins).
90
+ Then trust thresholds determine zone.
91
+ GEEL pings go to pending queue and trigger on_hitl_needed callback.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ trust_threshold_groen: float = 0.7,
97
+ trust_threshold_rood: float = 0.3,
98
+ on_hitl_needed: Optional[Callable[[PendingPing], None]] = None,
99
+ ) -> None:
100
+ self.trust_threshold_groen = trust_threshold_groen
101
+ self.trust_threshold_rood = trust_threshold_rood
102
+ self.on_hitl_needed = on_hitl_needed
103
+ self._rules: List[AirlockRule] = []
104
+ self.pending: Dict[str, PendingPing] = {}
105
+
106
+ def add_rule(self, rule: AirlockRule) -> None:
107
+ """Add a rule (auto-sorted by priority, highest first)."""
108
+ self._rules.append(rule)
109
+ self._rules.sort(key=lambda r: r.priority, reverse=True)
110
+
111
+ @property
112
+ def rules(self) -> List[AirlockRule]:
113
+ return list(self._rules)
114
+
115
+ def gate(
116
+ self, packet: PingPacket, sender_trust: float
117
+ ) -> Tuple[AirlockZone, Optional[AirlockRule]]:
118
+ """
119
+ Determine zone for a packet.
120
+ Returns (zone, matched_rule_or_None).
121
+ """
122
+ # Rules first
123
+ for rule in self._rules:
124
+ if rule.matches(packet, sender_trust):
125
+ return (rule.zone, rule)
126
+
127
+ # Trust thresholds
128
+ if sender_trust >= self.trust_threshold_groen:
129
+ return (AirlockZone.GROEN, None)
130
+ if sender_trust < self.trust_threshold_rood:
131
+ return (AirlockZone.ROOD, None)
132
+
133
+ return (AirlockZone.GEEL, None)
134
+
135
+ def process(self, packet: PingPacket, sender_trust: float) -> PingDecision:
136
+ """
137
+ Full processing: gate → decision → pending queue if GEEL.
138
+ """
139
+ zone, rule = self.gate(packet, sender_trust)
140
+
141
+ if zone == AirlockZone.GROEN:
142
+ return PingDecision.ACCEPT
143
+ if zone == AirlockZone.ROOD:
144
+ return PingDecision.REJECT
145
+
146
+ # GEEL: add to pending
147
+ pending = PendingPing(
148
+ packet=packet,
149
+ sender_trust=sender_trust,
150
+ reason=f"Trust {sender_trust:.2f} in GEEL zone"
151
+ + (f" (rule: {rule.name})" if rule else ""),
152
+ )
153
+ self.pending[packet.packet_id] = pending
154
+
155
+ if self.on_hitl_needed:
156
+ self.on_hitl_needed(pending)
157
+
158
+ return PingDecision.PENDING
159
+
160
+ def approve_pending(self, packet_id: str) -> bool:
161
+ """HITL approves a pending ping."""
162
+ return self.pending.pop(packet_id, None) is not None
163
+
164
+ def reject_pending(self, packet_id: str) -> bool:
165
+ """HITL rejects a pending ping."""
166
+ return self.pending.pop(packet_id, None) is not None
167
+
168
+ def stats(self) -> dict:
169
+ return {
170
+ "rules": len(self._rules),
171
+ "pending_count": len(self.pending),
172
+ "thresholds": {
173
+ "groen": self.trust_threshold_groen,
174
+ "rood": self.trust_threshold_rood,
175
+ },
176
+ }
@@ -0,0 +1,179 @@
1
+ """
2
+ Bootstrap beacon for new devices (airgapped, local-only).
3
+
4
+ Solves the chicken-and-egg problem: new device knows nobody.
5
+ Beacon broadcasts on local network. Hub can auto-vouch or escalate to HITL.
6
+
7
+ NO secrets in beacons — assume adversarial broadcast medium.
8
+ """
9
+
10
+ import re
11
+ import secrets
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Callable, Dict, List, Optional
15
+
16
+
17
+ @dataclass
18
+ class Beacon:
19
+ """
20
+ Bootstrap beacon broadcast by a new device.
21
+
22
+ Contains only public information:
23
+ - JIS DID
24
+ - Capabilities
25
+ - Requested pod
26
+ - Public key HASH (not the key itself!)
27
+ """
28
+ beacon_id: str
29
+ source_did: str
30
+ capabilities: List[str] = field(default_factory=list)
31
+ requested_pod: Optional[str] = None
32
+ device_type: str = "generic" # sensor, actuator, gateway, controller
33
+ public_key_hash: str = ""
34
+ timestamp: str = field(
35
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
36
+ )
37
+
38
+ def is_fresh(self, max_age_seconds: int = 300) -> bool:
39
+ """Check if beacon is recent enough (default 5 minutes)."""
40
+ now = datetime.now(timezone.utc)
41
+ try:
42
+ ts = self.timestamp.replace("Z", "+00:00")
43
+ beacon_time = datetime.fromisoformat(ts)
44
+ if beacon_time.tzinfo is None:
45
+ beacon_time = beacon_time.replace(tzinfo=timezone.utc)
46
+ age = (now - beacon_time).total_seconds()
47
+ return age <= max_age_seconds
48
+ except (ValueError, OSError):
49
+ return False
50
+
51
+ def to_dict(self) -> dict:
52
+ return {
53
+ "beacon_id": self.beacon_id,
54
+ "source_did": self.source_did,
55
+ "capabilities": self.capabilities,
56
+ "requested_pod": self.requested_pod,
57
+ "device_type": self.device_type,
58
+ "public_key_hash": self.public_key_hash,
59
+ "timestamp": self.timestamp,
60
+ }
61
+
62
+ @classmethod
63
+ def create(
64
+ cls,
65
+ source_did: str,
66
+ capabilities: Optional[List[str]] = None,
67
+ requested_pod: Optional[str] = None,
68
+ device_type: str = "generic",
69
+ public_key_hash: str = "",
70
+ ) -> "Beacon":
71
+ """Factory method with auto-generated beacon_id."""
72
+ return cls(
73
+ beacon_id=f"beacon_{secrets.token_hex(8)}",
74
+ source_did=source_did,
75
+ capabilities=capabilities or [],
76
+ requested_pod=requested_pod,
77
+ device_type=device_type,
78
+ public_key_hash=public_key_hash,
79
+ )
80
+
81
+
82
+ @dataclass
83
+ class BeaconResponse:
84
+ """Hub response to a beacon."""
85
+ response_id: str
86
+ in_response_to: str # beacon_id
87
+ hub_did: str
88
+ decision: str # "auto_vouched", "hitl_pending", "rejected"
89
+ vouch_id: Optional[str] = None
90
+ assigned_pod: Optional[str] = None
91
+ message: str = ""
92
+ timestamp: str = field(
93
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
94
+ )
95
+
96
+
97
+ class BeaconHandler:
98
+ """
99
+ Handle beacon broadcasts for bootstrapping.
100
+
101
+ auto_vouch_rules: list of dicts with matching conditions.
102
+ Each rule can match on: device_type, required_capabilities, source_pattern.
103
+ If matched, the beacon is auto-vouched into the specified pod.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ auto_vouch_rules: Optional[List[dict]] = None,
109
+ on_hitl_needed: Optional[Callable[["Beacon"], None]] = None,
110
+ ) -> None:
111
+ self.auto_vouch_rules = auto_vouch_rules or []
112
+ self.on_hitl_needed = on_hitl_needed
113
+ self._recent: Dict[str, Beacon] = {}
114
+
115
+ def handle_beacon(self, beacon: Beacon, hub_did: str) -> BeaconResponse:
116
+ """
117
+ Process an incoming beacon.
118
+
119
+ 1. Check freshness
120
+ 2. Try auto-vouch rules
121
+ 3. Escalate to HITL if no rule matches
122
+ """
123
+ resp_id = f"resp_{beacon.beacon_id}"
124
+
125
+ if not beacon.is_fresh():
126
+ return BeaconResponse(
127
+ response_id=resp_id,
128
+ in_response_to=beacon.beacon_id,
129
+ hub_did=hub_did,
130
+ decision="rejected",
131
+ message="Beacon too old",
132
+ )
133
+
134
+ # Track recent beacons
135
+ self._recent[beacon.beacon_id] = beacon
136
+
137
+ # Try auto-vouch rules
138
+ for rule in self.auto_vouch_rules:
139
+ if self._matches_rule(beacon, rule):
140
+ vouch_id = f"vouch_{secrets.token_hex(8)}"
141
+ return BeaconResponse(
142
+ response_id=resp_id,
143
+ in_response_to=beacon.beacon_id,
144
+ hub_did=hub_did,
145
+ decision="auto_vouched",
146
+ vouch_id=vouch_id,
147
+ assigned_pod=rule.get("pod_id"),
148
+ message=f"Auto-vouched via rule: {rule.get('name', 'unnamed')}",
149
+ )
150
+
151
+ # No rule matched → HITL
152
+ if self.on_hitl_needed:
153
+ self.on_hitl_needed(beacon)
154
+
155
+ return BeaconResponse(
156
+ response_id=resp_id,
157
+ in_response_to=beacon.beacon_id,
158
+ hub_did=hub_did,
159
+ decision="hitl_pending",
160
+ message="Awaiting HITL approval",
161
+ )
162
+
163
+ @staticmethod
164
+ def _matches_rule(beacon: Beacon, rule: dict) -> bool:
165
+ """Check if beacon matches an auto-vouch rule."""
166
+ if "device_type" in rule:
167
+ if beacon.device_type != rule["device_type"]:
168
+ return False
169
+
170
+ if "required_capabilities" in rule:
171
+ required = set(rule["required_capabilities"])
172
+ if not required.issubset(set(beacon.capabilities)):
173
+ return False
174
+
175
+ if "source_pattern" in rule:
176
+ if not re.match(rule["source_pattern"], beacon.source_did):
177
+ return False
178
+
179
+ return True