halyn 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- halyn/__init__.py +7 -0
- halyn/__main__.py +4 -0
- halyn/audit.py +278 -0
- halyn/auth.py +88 -0
- halyn/autonomy.py +262 -0
- halyn/cli.py +208 -0
- halyn/config.py +135 -0
- halyn/consent.py +243 -0
- halyn/control_plane.py +354 -0
- halyn/discovery.py +323 -0
- halyn/drivers/__init__.py +0 -0
- halyn/drivers/browser.py +60 -0
- halyn/drivers/dds.py +156 -0
- halyn/drivers/docker.py +62 -0
- halyn/drivers/http_auto.py +259 -0
- halyn/drivers/mqtt.py +93 -0
- halyn/drivers/opcua.py +77 -0
- halyn/drivers/ros2.py +124 -0
- halyn/drivers/serial.py +226 -0
- halyn/drivers/socket_raw.py +153 -0
- halyn/drivers/ssh.py +131 -0
- halyn/drivers/unitree.py +103 -0
- halyn/drivers/websocket.py +175 -0
- halyn/engine.py +222 -0
- halyn/intent.py +240 -0
- halyn/llm.py +178 -0
- halyn/mcp.py +239 -0
- halyn/memory/__init__.py +0 -0
- halyn/memory/store.py +200 -0
- halyn/nrp_bridge.py +213 -0
- halyn/py.typed +0 -0
- halyn/sanitizer.py +120 -0
- halyn/server.py +292 -0
- halyn/types.py +116 -0
- halyn/watchdog.py +252 -0
- halyn-0.2.0.dist-info/METADATA +246 -0
- halyn-0.2.0.dist-info/RECORD +41 -0
- halyn-0.2.0.dist-info/WHEEL +5 -0
- halyn-0.2.0.dist-info/entry_points.txt +2 -0
- halyn-0.2.0.dist-info/licenses/LICENSE +15 -0
- halyn-0.2.0.dist-info/top_level.txt +1 -0
halyn/__init__.py
ADDED
halyn/__main__.py
ADDED
halyn/audit.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Audit — Indestructible record of every action.
|
|
5
|
+
|
|
6
|
+
Append-only audit log with SHA-256 hash chaining.
|
|
7
|
+
Persisted via SQLite WAL. Tamper-detectable in O(n).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("halyn.audit")
|
|
24
|
+
|
|
25
|
+
_SCHEMA = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
timestamp REAL NOT NULL,
|
|
29
|
+
tool TEXT NOT NULL,
|
|
30
|
+
node TEXT NOT NULL DEFAULT '',
|
|
31
|
+
args TEXT NOT NULL DEFAULT '{}',
|
|
32
|
+
result TEXT NOT NULL DEFAULT '',
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
34
|
+
duration_ms REAL NOT NULL DEFAULT 0,
|
|
35
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
36
|
+
llm_model TEXT NOT NULL DEFAULT '',
|
|
37
|
+
intent TEXT NOT NULL DEFAULT '',
|
|
38
|
+
domain TEXT NOT NULL DEFAULT '',
|
|
39
|
+
autonomy_level INTEGER NOT NULL DEFAULT -1,
|
|
40
|
+
decision TEXT NOT NULL DEFAULT '',
|
|
41
|
+
hash TEXT NOT NULL,
|
|
42
|
+
prev_hash TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_audit_tool ON audit_log(tool);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_audit_node ON audit_log(node);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_audit_status ON audit_log(status);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class AuditEntry:
|
|
54
|
+
"""One audited action. Immutable. Hashable."""
|
|
55
|
+
timestamp: float
|
|
56
|
+
tool: str
|
|
57
|
+
node: str
|
|
58
|
+
args: dict[str, Any]
|
|
59
|
+
result: str
|
|
60
|
+
status: str
|
|
61
|
+
duration_ms: float
|
|
62
|
+
user_id: str
|
|
63
|
+
llm_model: str
|
|
64
|
+
intent: str # Why was this action taken?
|
|
65
|
+
domain: str # Which domain policy applied?
|
|
66
|
+
autonomy_level: int # What level authorized it?
|
|
67
|
+
decision: str # allow / confirm / deny
|
|
68
|
+
hash: str
|
|
69
|
+
prev_hash: str
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"timestamp": self.timestamp,
|
|
74
|
+
"tool": self.tool,
|
|
75
|
+
"node": self.node,
|
|
76
|
+
"args": self.args,
|
|
77
|
+
"result": self.result[:500],
|
|
78
|
+
"status": self.status,
|
|
79
|
+
"duration_ms": self.duration_ms,
|
|
80
|
+
"user_id": self.user_id,
|
|
81
|
+
"llm_model": self.llm_model,
|
|
82
|
+
"intent": self.intent,
|
|
83
|
+
"domain": self.domain,
|
|
84
|
+
"autonomy_level": self.autonomy_level,
|
|
85
|
+
"decision": self.decision,
|
|
86
|
+
"hash": self.hash,
|
|
87
|
+
"prev_hash": self.prev_hash,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AuditStore:
|
|
92
|
+
"""
|
|
93
|
+
Persistent, tamper-evident audit trail.
|
|
94
|
+
|
|
95
|
+
Every action is:
|
|
96
|
+
1. Written to disk BEFORE execution (WAL)
|
|
97
|
+
2. Hash-chained to the previous entry
|
|
98
|
+
3. Queryable by time, tool, node, user, status
|
|
99
|
+
|
|
100
|
+
Tampering breaks the chain. Detectable in O(n).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, db_path: str = "") -> None:
|
|
104
|
+
if not db_path:
|
|
105
|
+
data_dir = Path.home() / ".halyn"
|
|
106
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
db_path = str(data_dir / "audit.db")
|
|
108
|
+
self._db_path = db_path
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
self._prev_hash = "GENESIS"
|
|
111
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
112
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
113
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
114
|
+
self._conn.executescript(_SCHEMA)
|
|
115
|
+
self._conn.commit()
|
|
116
|
+
self._restore_chain()
|
|
117
|
+
log.info("audit.init db=%s chain_tip=%s", db_path, self._prev_hash[:16])
|
|
118
|
+
|
|
119
|
+
def record(
|
|
120
|
+
self,
|
|
121
|
+
tool: str,
|
|
122
|
+
node: str = "",
|
|
123
|
+
args: dict[str, Any] | None = None,
|
|
124
|
+
result: str = "",
|
|
125
|
+
status: str = "ok",
|
|
126
|
+
duration_ms: float = 0.0,
|
|
127
|
+
user_id: str = "",
|
|
128
|
+
llm_model: str = "",
|
|
129
|
+
intent: str = "",
|
|
130
|
+
domain: str = "",
|
|
131
|
+
autonomy_level: int = -1,
|
|
132
|
+
decision: str = "",
|
|
133
|
+
) -> AuditEntry:
|
|
134
|
+
"""Record an action. Returns the entry with its hash."""
|
|
135
|
+
ts = time.time()
|
|
136
|
+
args = args or {}
|
|
137
|
+
|
|
138
|
+
entry_hash = self._compute_hash(
|
|
139
|
+
ts, tool, node, json.dumps(args, default=str, sort_keys=True),
|
|
140
|
+
result[:500], status, self._prev_hash,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
entry = AuditEntry(
|
|
144
|
+
timestamp=ts, tool=tool, node=node, args=args,
|
|
145
|
+
result=result[:2000], status=status, duration_ms=duration_ms,
|
|
146
|
+
user_id=user_id, llm_model=llm_model, intent=intent,
|
|
147
|
+
domain=domain, autonomy_level=autonomy_level, decision=decision,
|
|
148
|
+
hash=entry_hash, prev_hash=self._prev_hash,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
with self._lock:
|
|
152
|
+
self._conn.execute(
|
|
153
|
+
"INSERT INTO audit_log "
|
|
154
|
+
"(timestamp, tool, node, args, result, status, duration_ms, "
|
|
155
|
+
"user_id, llm_model, intent, domain, autonomy_level, decision, "
|
|
156
|
+
"hash, prev_hash) "
|
|
157
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
158
|
+
(ts, tool, node, json.dumps(args, default=str),
|
|
159
|
+
result[:2000], status, duration_ms,
|
|
160
|
+
user_id, llm_model, intent, domain, autonomy_level,
|
|
161
|
+
decision, entry_hash, self._prev_hash),
|
|
162
|
+
)
|
|
163
|
+
self._conn.commit()
|
|
164
|
+
self._prev_hash = entry_hash
|
|
165
|
+
|
|
166
|
+
log.debug("audit.record tool=%s hash=%s", tool, entry_hash[:16])
|
|
167
|
+
return entry
|
|
168
|
+
|
|
169
|
+
def query(
|
|
170
|
+
self,
|
|
171
|
+
since: float = 0,
|
|
172
|
+
until: float = 0,
|
|
173
|
+
tool: str = "",
|
|
174
|
+
node: str = "",
|
|
175
|
+
user_id: str = "",
|
|
176
|
+
status: str = "",
|
|
177
|
+
limit: int = 100,
|
|
178
|
+
) -> list[AuditEntry]:
|
|
179
|
+
"""Query the audit trail with filters."""
|
|
180
|
+
conditions: list[str] = []
|
|
181
|
+
params: list[Any] = []
|
|
182
|
+
|
|
183
|
+
if since:
|
|
184
|
+
conditions.append("timestamp >= ?")
|
|
185
|
+
params.append(since)
|
|
186
|
+
if until:
|
|
187
|
+
conditions.append("timestamp <= ?")
|
|
188
|
+
params.append(until)
|
|
189
|
+
if tool:
|
|
190
|
+
conditions.append("tool LIKE ?")
|
|
191
|
+
params.append(f"%{tool}%")
|
|
192
|
+
if node:
|
|
193
|
+
conditions.append("node LIKE ?")
|
|
194
|
+
params.append(f"%{node}%")
|
|
195
|
+
if user_id:
|
|
196
|
+
conditions.append("user_id = ?")
|
|
197
|
+
params.append(user_id)
|
|
198
|
+
if status:
|
|
199
|
+
conditions.append("status = ?")
|
|
200
|
+
params.append(status)
|
|
201
|
+
|
|
202
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
203
|
+
sql = f"SELECT * FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT ?"
|
|
204
|
+
params.append(limit)
|
|
205
|
+
|
|
206
|
+
with self._lock:
|
|
207
|
+
rows = self._conn.execute(sql, params).fetchall()
|
|
208
|
+
|
|
209
|
+
return [self._row_to_entry(r) for r in rows]
|
|
210
|
+
|
|
211
|
+
def verify_chain(self) -> tuple[bool, int, str]:
|
|
212
|
+
"""
|
|
213
|
+
Verify the entire hash chain.
|
|
214
|
+
Returns: (valid, entries_checked, error_message)
|
|
215
|
+
"""
|
|
216
|
+
with self._lock:
|
|
217
|
+
rows = self._conn.execute(
|
|
218
|
+
"SELECT timestamp, tool, node, args, result, status, hash, prev_hash "
|
|
219
|
+
"FROM audit_log ORDER BY id ASC"
|
|
220
|
+
).fetchall()
|
|
221
|
+
|
|
222
|
+
prev = "GENESIS"
|
|
223
|
+
for i, row in enumerate(rows):
|
|
224
|
+
ts, tool, node, args_str, result, status, stored_hash, stored_prev = row
|
|
225
|
+
if stored_prev != prev:
|
|
226
|
+
return False, i, f"Chain broken at entry {i}: prev mismatch"
|
|
227
|
+
computed = self._compute_hash(ts, tool, node, args_str, result[:500], status, prev)
|
|
228
|
+
if computed != stored_hash:
|
|
229
|
+
return False, i, f"Tamper detected at entry {i}: hash mismatch"
|
|
230
|
+
prev = stored_hash
|
|
231
|
+
|
|
232
|
+
return True, len(rows), "Chain valid"
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def count(self) -> int:
|
|
236
|
+
with self._lock:
|
|
237
|
+
r = self._conn.execute("SELECT COUNT(*) FROM audit_log").fetchone()
|
|
238
|
+
return r[0] if r else 0
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def chain_tip(self) -> str:
|
|
242
|
+
return self._prev_hash
|
|
243
|
+
|
|
244
|
+
def export_jsonl(self, path: str) -> int:
|
|
245
|
+
"""Export full audit trail to JSONL file."""
|
|
246
|
+
entries = self.query(limit=1_000_000)
|
|
247
|
+
with open(path, "w") as f:
|
|
248
|
+
for e in reversed(entries):
|
|
249
|
+
f.write(json.dumps(e.to_dict(), default=str) + "\n")
|
|
250
|
+
return len(entries)
|
|
251
|
+
|
|
252
|
+
def _restore_chain(self) -> None:
|
|
253
|
+
row = self._conn.execute(
|
|
254
|
+
"SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1"
|
|
255
|
+
).fetchone()
|
|
256
|
+
if row:
|
|
257
|
+
self._prev_hash = row[0]
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _compute_hash(ts: float, tool: str, node: str, args: str,
|
|
261
|
+
result: str, status: str, prev_hash: str) -> str:
|
|
262
|
+
payload = f"{ts}|{tool}|{node}|{args}|{result}|{status}|{prev_hash}"
|
|
263
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
264
|
+
|
|
265
|
+
def _row_to_entry(self, row: tuple) -> AuditEntry:
|
|
266
|
+
return AuditEntry(
|
|
267
|
+
timestamp=row[1], tool=row[2], node=row[3],
|
|
268
|
+
args=json.loads(row[4]) if row[4] else {},
|
|
269
|
+
result=row[5], status=row[6], duration_ms=row[7],
|
|
270
|
+
user_id=row[8], llm_model=row[9], intent=row[10],
|
|
271
|
+
domain=row[11], autonomy_level=row[12], decision=row[13],
|
|
272
|
+
hash=row[14], prev_hash=row[15],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
with self._lock:
|
|
277
|
+
self._conn.close()
|
|
278
|
+
|
halyn/auth.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Auth — API authentication + rate limiting.
|
|
5
|
+
|
|
6
|
+
Simple, effective, no framework dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from aiohttp import web
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("jarvis.auth")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthMiddleware:
|
|
24
|
+
"""API key authentication + rate limiting."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_key: str = "", rate_limit: int = 60) -> None:
|
|
27
|
+
self.api_key = api_key or os.environ.get("JARVIS_API_KEY", "")
|
|
28
|
+
self.rate_limit = rate_limit # requests per minute
|
|
29
|
+
self._requests: dict[str, list[float]] = {} # ip -> timestamps
|
|
30
|
+
self.enabled = bool(self.api_key)
|
|
31
|
+
|
|
32
|
+
if not self.enabled:
|
|
33
|
+
log.warning("auth.disabled — set JARVIS_API_KEY to enable")
|
|
34
|
+
|
|
35
|
+
def check(self, request: web.Request) -> str | None:
|
|
36
|
+
"""Returns error message if denied, None if allowed."""
|
|
37
|
+
# Health endpoint is always public
|
|
38
|
+
if request.path == "/health":
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
# Auth check
|
|
42
|
+
if self.enabled:
|
|
43
|
+
key = (
|
|
44
|
+
request.headers.get("X-API-Key", "")
|
|
45
|
+
or request.headers.get("Authorization", "").removeprefix("Bearer ")
|
|
46
|
+
)
|
|
47
|
+
if not self._verify_key(key):
|
|
48
|
+
log.warning("auth.denied ip=%s path=%s", request.remote, request.path)
|
|
49
|
+
return "invalid or missing API key"
|
|
50
|
+
|
|
51
|
+
# Rate limit check
|
|
52
|
+
ip = request.remote or "unknown"
|
|
53
|
+
now = time.monotonic()
|
|
54
|
+
timestamps = self._requests.get(ip, [])
|
|
55
|
+
# Clean old entries (older than 60s)
|
|
56
|
+
timestamps = [t for t in timestamps if now - t < 60]
|
|
57
|
+
if len(timestamps) >= self.rate_limit:
|
|
58
|
+
log.warning("auth.rate_limited ip=%s count=%d", ip, len(timestamps))
|
|
59
|
+
return "rate limit exceeded"
|
|
60
|
+
timestamps.append(now)
|
|
61
|
+
self._requests[ip] = timestamps
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _verify_key(self, provided: str) -> bool:
|
|
66
|
+
"""Constant-time comparison to prevent timing attacks."""
|
|
67
|
+
if not provided or not self.api_key:
|
|
68
|
+
return False
|
|
69
|
+
return hmac.compare_digest(provided.encode(), self.api_key.encode())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_auth_middleware(api_key: str = "", rate_limit: int = 60):
|
|
73
|
+
"""Create aiohttp middleware for auth."""
|
|
74
|
+
auth = AuthMiddleware(api_key, rate_limit)
|
|
75
|
+
|
|
76
|
+
@web.middleware
|
|
77
|
+
async def middleware(request: web.Request, handler: Any) -> web.Response:
|
|
78
|
+
error = auth.check(request)
|
|
79
|
+
if error:
|
|
80
|
+
return web.Response(
|
|
81
|
+
text=f'{{"ok":false,"error":"{error}"}}',
|
|
82
|
+
content_type="application/json",
|
|
83
|
+
status=401 if "API key" in error else 429,
|
|
84
|
+
)
|
|
85
|
+
return await handler(request)
|
|
86
|
+
|
|
87
|
+
return middleware
|
|
88
|
+
|
halyn/autonomy.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Autonomy — The human always controls.
|
|
5
|
+
|
|
6
|
+
Domain-scoped authorization with 5 configurable trust levels.
|
|
7
|
+
Rate limiting, time-of-day restrictions, and per-command policies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
from typing import Any, Callable, Awaitable
|
|
17
|
+
|
|
18
|
+
from .types import Action, ActionStatus, ToolCategory
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("halyn.autonomy")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Level(IntEnum):
|
|
24
|
+
"""How much freedom the AI has."""
|
|
25
|
+
MANUAL = 0 # AI proposes. Human approves EVERY action.
|
|
26
|
+
SUPERVISED = 1 # AI reads alone. Asks before writing/acting.
|
|
27
|
+
GUIDED = 2 # Safe actions alone. Dangerous = confirm.
|
|
28
|
+
AUTONOMOUS = 3 # Does everything. Human can interrupt.
|
|
29
|
+
FULL_AUTO = 4 # Handles routine. Reports daily.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class DomainPolicy:
|
|
34
|
+
"""Policy for one domain (physical, financial, infra, etc.)."""
|
|
35
|
+
name: str
|
|
36
|
+
level: Level = Level.SUPERVISED
|
|
37
|
+
node_patterns: list[str] = field(default_factory=lambda: ["*"])
|
|
38
|
+
hours: tuple[int, int] | None = None # (start_hour, end_hour) or None=always
|
|
39
|
+
max_actions_per_hour: int = 1000
|
|
40
|
+
blocked_commands: list[str] = field(default_factory=list)
|
|
41
|
+
confirm_commands: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def matches_node(self, node_name: str) -> bool:
|
|
44
|
+
import fnmatch
|
|
45
|
+
return any(fnmatch.fnmatch(node_name, p) for p in self.node_patterns)
|
|
46
|
+
|
|
47
|
+
def is_active_now(self) -> bool:
|
|
48
|
+
if self.hours is None:
|
|
49
|
+
return True
|
|
50
|
+
hour = time.localtime().tm_hour
|
|
51
|
+
start, end = self.hours
|
|
52
|
+
if start <= end:
|
|
53
|
+
return start <= hour < end
|
|
54
|
+
return hour >= start or hour < end # Overnight range
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class ConfirmationRequest:
|
|
59
|
+
"""A pending action waiting for human approval."""
|
|
60
|
+
request_id: str
|
|
61
|
+
action: Action
|
|
62
|
+
reason: str
|
|
63
|
+
domain: str
|
|
64
|
+
created_at: float = field(default_factory=time.time)
|
|
65
|
+
expires_at: float = 0.0 # 0 = no expiry
|
|
66
|
+
status: str = "pending" # pending, approved, denied, expired
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if self.expires_at == 0.0:
|
|
70
|
+
self.expires_at = self.created_at + 300 # 5 min default
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def expired(self) -> bool:
|
|
74
|
+
return time.time() > self.expires_at and self.status == "pending"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AutonomyController:
|
|
78
|
+
"""
|
|
79
|
+
The gatekeeper. Every action passes through here.
|
|
80
|
+
|
|
81
|
+
Decides: execute immediately, ask for confirmation, or deny.
|
|
82
|
+
Based on: domain policy, action category, time of day, rate limits.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("_domains", "_pending", "_action_counts", "_default_level")
|
|
86
|
+
|
|
87
|
+
def __init__(self, default_level: Level = Level.SUPERVISED) -> None:
|
|
88
|
+
self._domains: dict[str, DomainPolicy] = {}
|
|
89
|
+
self._pending: dict[str, ConfirmationRequest] = {}
|
|
90
|
+
self._action_counts: dict[str, list[float]] = {} # domain -> timestamps
|
|
91
|
+
self._default_level = default_level
|
|
92
|
+
|
|
93
|
+
def add_domain(self, policy: DomainPolicy) -> None:
|
|
94
|
+
self._domains[policy.name] = policy
|
|
95
|
+
log.info("autonomy.domain name=%s level=%s nodes=%s",
|
|
96
|
+
policy.name, Level(policy.level).name, policy.node_patterns)
|
|
97
|
+
|
|
98
|
+
def check(self, action: Action, tool_category: ToolCategory,
|
|
99
|
+
tool_dangerous: bool) -> tuple[str, str]:
|
|
100
|
+
"""
|
|
101
|
+
Check if an action should proceed.
|
|
102
|
+
|
|
103
|
+
Returns: (decision, reason)
|
|
104
|
+
decision: "allow", "confirm", "deny"
|
|
105
|
+
reason: human-readable explanation
|
|
106
|
+
"""
|
|
107
|
+
# Find matching domain
|
|
108
|
+
domain = self._find_domain(action.node)
|
|
109
|
+
if domain is None:
|
|
110
|
+
domain = DomainPolicy(name="default", level=self._default_level)
|
|
111
|
+
|
|
112
|
+
# Check if domain is active
|
|
113
|
+
if not domain.is_active_now():
|
|
114
|
+
return "deny", f"Domain '{domain.name}' not active at this hour"
|
|
115
|
+
|
|
116
|
+
# Check rate limit
|
|
117
|
+
if not self._check_rate(domain):
|
|
118
|
+
return "deny", f"Rate limit exceeded for domain '{domain.name}'"
|
|
119
|
+
|
|
120
|
+
# Check blocked commands
|
|
121
|
+
cmd = action.tool + " " + action.args.get("command", "")
|
|
122
|
+
for blocked in domain.blocked_commands:
|
|
123
|
+
if blocked.lower() in cmd.lower():
|
|
124
|
+
return "deny", f"Command blocked by domain policy: {blocked}"
|
|
125
|
+
|
|
126
|
+
# Check confirm commands
|
|
127
|
+
for confirm_pattern in domain.confirm_commands:
|
|
128
|
+
if confirm_pattern.lower() in cmd.lower():
|
|
129
|
+
return "confirm", f"Requires confirmation: matches '{confirm_pattern}'"
|
|
130
|
+
|
|
131
|
+
# Apply autonomy level
|
|
132
|
+
level = domain.level
|
|
133
|
+
|
|
134
|
+
if level == Level.MANUAL:
|
|
135
|
+
return "confirm", "Level MANUAL: all actions require approval"
|
|
136
|
+
|
|
137
|
+
if level == Level.SUPERVISED:
|
|
138
|
+
if tool_category == ToolCategory.OBSERVER:
|
|
139
|
+
self._record_action(domain.name)
|
|
140
|
+
return "allow", "Level SUPERVISED: observe allowed"
|
|
141
|
+
return "confirm", "Level SUPERVISED: action requires approval"
|
|
142
|
+
|
|
143
|
+
if level == Level.GUIDED:
|
|
144
|
+
if tool_dangerous:
|
|
145
|
+
return "confirm", f"Level GUIDED: dangerous action requires approval"
|
|
146
|
+
self._record_action(domain.name)
|
|
147
|
+
return "allow", "Level GUIDED: safe action allowed"
|
|
148
|
+
|
|
149
|
+
if level == Level.AUTONOMOUS:
|
|
150
|
+
self._record_action(domain.name)
|
|
151
|
+
return "allow", "Level AUTONOMOUS: action allowed (interruptible)"
|
|
152
|
+
|
|
153
|
+
if level == Level.FULL_AUTO:
|
|
154
|
+
self._record_action(domain.name)
|
|
155
|
+
return "allow", "Level FULL_AUTO: autonomous execution"
|
|
156
|
+
|
|
157
|
+
return "confirm", "Unknown level: defaulting to confirm"
|
|
158
|
+
|
|
159
|
+
# ─── Confirmation management ────────────────────
|
|
160
|
+
|
|
161
|
+
def request_confirmation(self, request_id: str, action: Action,
|
|
162
|
+
reason: str, domain: str = "") -> ConfirmationRequest:
|
|
163
|
+
req = ConfirmationRequest(
|
|
164
|
+
request_id=request_id, action=action,
|
|
165
|
+
reason=reason, domain=domain,
|
|
166
|
+
)
|
|
167
|
+
self._pending[request_id] = req
|
|
168
|
+
log.info("autonomy.confirm_requested id=%s tool=%s reason=%s",
|
|
169
|
+
request_id, action.tool, reason)
|
|
170
|
+
return req
|
|
171
|
+
|
|
172
|
+
def approve(self, request_id: str) -> bool:
|
|
173
|
+
req = self._pending.get(request_id)
|
|
174
|
+
if req and req.status == "pending" and not req.expired:
|
|
175
|
+
req.status = "approved"
|
|
176
|
+
log.info("autonomy.approved id=%s", request_id)
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def deny(self, request_id: str) -> bool:
|
|
181
|
+
req = self._pending.get(request_id)
|
|
182
|
+
if req and req.status == "pending":
|
|
183
|
+
req.status = "denied"
|
|
184
|
+
log.info("autonomy.denied id=%s", request_id)
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def get_pending(self) -> list[ConfirmationRequest]:
|
|
189
|
+
self._clean_expired()
|
|
190
|
+
return [r for r in self._pending.values() if r.status == "pending"]
|
|
191
|
+
|
|
192
|
+
def get_request(self, request_id: str) -> ConfirmationRequest | None:
|
|
193
|
+
return self._pending.get(request_id)
|
|
194
|
+
|
|
195
|
+
# ─── Internal ──────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def _find_domain(self, node: str) -> DomainPolicy | None:
|
|
198
|
+
for domain in self._domains.values():
|
|
199
|
+
if domain.matches_node(node):
|
|
200
|
+
return domain
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def _check_rate(self, domain: DomainPolicy) -> bool:
|
|
204
|
+
now = time.time()
|
|
205
|
+
timestamps = self._action_counts.get(domain.name, [])
|
|
206
|
+
timestamps = [t for t in timestamps if now - t < 3600]
|
|
207
|
+
self._action_counts[domain.name] = timestamps
|
|
208
|
+
return len(timestamps) < domain.max_actions_per_hour
|
|
209
|
+
|
|
210
|
+
def _record_action(self, domain_name: str) -> None:
|
|
211
|
+
if domain_name not in self._action_counts:
|
|
212
|
+
self._action_counts[domain_name] = []
|
|
213
|
+
self._action_counts[domain_name].append(time.time())
|
|
214
|
+
|
|
215
|
+
def _clean_expired(self) -> None:
|
|
216
|
+
for req_id, req in list(self._pending.items()):
|
|
217
|
+
if req.expired:
|
|
218
|
+
req.status = "expired"
|
|
219
|
+
log.info("autonomy.expired id=%s", req_id)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ─── Default domain presets ─────────────────────────
|
|
223
|
+
|
|
224
|
+
PRESET_DOMAINS = {
|
|
225
|
+
"physical": DomainPolicy(
|
|
226
|
+
name="physical",
|
|
227
|
+
level=Level.SUPERVISED,
|
|
228
|
+
node_patterns=["robot/*", "drone/*", "arm/*", "vehicle/*"],
|
|
229
|
+
confirm_commands=["emergency_stop", "move", "pick", "deploy"],
|
|
230
|
+
),
|
|
231
|
+
"financial": DomainPolicy(
|
|
232
|
+
name="financial",
|
|
233
|
+
level=Level.MANUAL,
|
|
234
|
+
node_patterns=["finance/*", "bank/*", "payment/*"],
|
|
235
|
+
blocked_commands=["delete", "drop"],
|
|
236
|
+
),
|
|
237
|
+
"infrastructure": DomainPolicy(
|
|
238
|
+
name="infrastructure",
|
|
239
|
+
level=Level.GUIDED,
|
|
240
|
+
node_patterns=["server/*", "cloud/*", "docker/*"],
|
|
241
|
+
confirm_commands=["restart", "deploy", "delete"],
|
|
242
|
+
),
|
|
243
|
+
"monitoring": DomainPolicy(
|
|
244
|
+
name="monitoring",
|
|
245
|
+
level=Level.FULL_AUTO,
|
|
246
|
+
node_patterns=["sensor/*", "monitor/*", "metric/*"],
|
|
247
|
+
max_actions_per_hour=10000,
|
|
248
|
+
),
|
|
249
|
+
"home": DomainPolicy(
|
|
250
|
+
name="home",
|
|
251
|
+
level=Level.AUTONOMOUS,
|
|
252
|
+
node_patterns=["home/*"],
|
|
253
|
+
blocked_commands=["unlock_door"],
|
|
254
|
+
),
|
|
255
|
+
"communication": DomainPolicy(
|
|
256
|
+
name="communication",
|
|
257
|
+
level=Level.SUPERVISED,
|
|
258
|
+
node_patterns=["email/*", "slack/*", "telegram/*"],
|
|
259
|
+
confirm_commands=["send", "post", "reply"],
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
|