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/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
CLI — halyn serve | scan | status | test | emergency-stop
|
|
5
|
+
|
|
6
|
+
The command line is the cockpit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="halyn",
|
|
23
|
+
description="Halyn — NRP Control Plane",
|
|
24
|
+
)
|
|
25
|
+
sub = parser.add_subparsers(dest="command")
|
|
26
|
+
|
|
27
|
+
# serve
|
|
28
|
+
p_serve = sub.add_parser("serve", help="Start the control plane + HTTP server")
|
|
29
|
+
p_serve.add_argument("--config", "-c", default="", help="Config file path")
|
|
30
|
+
p_serve.add_argument("--host", default="", help="Override host")
|
|
31
|
+
p_serve.add_argument("--port", type=int, default=0, help="Override port")
|
|
32
|
+
|
|
33
|
+
# scan
|
|
34
|
+
p_scan = sub.add_parser("scan", help="Discover nodes on the network")
|
|
35
|
+
p_scan.add_argument("--subnet", default="", help="Subnet to scan (e.g. 192.168.1.0/24)")
|
|
36
|
+
p_scan.add_argument("--ssh", nargs="*", default=[], help="SSH hosts to check")
|
|
37
|
+
p_scan.add_argument("--mqtt", nargs="*", default=[], help="MQTT brokers to check")
|
|
38
|
+
p_scan.add_argument("--http", nargs="*", default=[], help="HTTP URLs to check")
|
|
39
|
+
p_scan.add_argument("--docker", nargs="*", default=[], help="Docker hosts")
|
|
40
|
+
p_scan.add_argument("--json", action="store_true", help="Output as JSON")
|
|
41
|
+
|
|
42
|
+
# status
|
|
43
|
+
p_status = sub.add_parser("status", help="Show control plane status")
|
|
44
|
+
p_status.add_argument("--config", "-c", default="")
|
|
45
|
+
|
|
46
|
+
# test
|
|
47
|
+
p_test = sub.add_parser("test", help="Run test suite")
|
|
48
|
+
|
|
49
|
+
# emergency-stop
|
|
50
|
+
sub.add_parser("emergency-stop", help="STOP ALL NODES IMMEDIATELY")
|
|
51
|
+
|
|
52
|
+
# version
|
|
53
|
+
sub.add_parser("version", help="Show version")
|
|
54
|
+
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
if args.command == "serve":
|
|
58
|
+
_cmd_serve(args)
|
|
59
|
+
elif args.command == "scan":
|
|
60
|
+
_cmd_scan(args)
|
|
61
|
+
elif args.command == "status":
|
|
62
|
+
_cmd_status(args)
|
|
63
|
+
elif args.command == "test":
|
|
64
|
+
_cmd_test()
|
|
65
|
+
elif args.command == "emergency-stop":
|
|
66
|
+
_cmd_emergency_stop(args)
|
|
67
|
+
elif args.command == "version":
|
|
68
|
+
from . import __version__
|
|
69
|
+
print(f"Halyn v{__version__}")
|
|
70
|
+
else:
|
|
71
|
+
parser.print_help()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_serve(args: Any) -> None:
|
|
75
|
+
from .config import HalynConfig
|
|
76
|
+
from .control_plane import ControlPlane
|
|
77
|
+
|
|
78
|
+
logging.basicConfig(
|
|
79
|
+
level=logging.INFO,
|
|
80
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
81
|
+
datefmt="%H:%M:%S",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
config = HalynConfig.load(args.config)
|
|
85
|
+
if args.host:
|
|
86
|
+
config.host = args.host
|
|
87
|
+
if args.port:
|
|
88
|
+
config.port = args.port
|
|
89
|
+
|
|
90
|
+
cp = ControlPlane(config)
|
|
91
|
+
|
|
92
|
+
async def run() -> None:
|
|
93
|
+
await cp.start()
|
|
94
|
+
status = cp.status()
|
|
95
|
+
print(f"\n Halyn v0.1.0 — listening on {config.host}:{config.port}")
|
|
96
|
+
print(f" {status['nodes']} nodes | {status['tools']} tools | MCP ready")
|
|
97
|
+
print(f" Audit: {status['audit_entries']} entries | Chain: {'valid' if status['audit_chain_valid'] else 'BROKEN'}")
|
|
98
|
+
print(f" Watchdog: {status['watchdog']['overall']}")
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
from .server import create_app
|
|
103
|
+
from aiohttp import web
|
|
104
|
+
app = create_app(cp, config.api_key)
|
|
105
|
+
from .mcp import mount_mcp
|
|
106
|
+
mount_mcp(app, cp)
|
|
107
|
+
runner = web.AppRunner(app)
|
|
108
|
+
await runner.setup()
|
|
109
|
+
site = web.TCPSite(runner, config.host, config.port)
|
|
110
|
+
await site.start()
|
|
111
|
+
print(f" HTTP: http://{config.host}:{config.port}")
|
|
112
|
+
print(f" MCP: http://{config.host}:{config.port}/mcp")
|
|
113
|
+
print(f" SSE: http://{config.host}:{config.port}/events")
|
|
114
|
+
print()
|
|
115
|
+
await asyncio.Event().wait() # Run forever
|
|
116
|
+
except ImportError:
|
|
117
|
+
print(" aiohttp not installed — running without HTTP server")
|
|
118
|
+
print(" Install: pip install aiohttp")
|
|
119
|
+
await asyncio.Event().wait()
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
pass
|
|
122
|
+
finally:
|
|
123
|
+
await cp.stop()
|
|
124
|
+
|
|
125
|
+
asyncio.run(run())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cmd_scan(args: Any) -> None:
|
|
129
|
+
from .discovery import Scanner
|
|
130
|
+
|
|
131
|
+
logging.basicConfig(level=logging.WARNING)
|
|
132
|
+
|
|
133
|
+
scanner = Scanner(timeout=1.0)
|
|
134
|
+
config: dict[str, Any] = {}
|
|
135
|
+
|
|
136
|
+
if args.ssh:
|
|
137
|
+
config["ssh_hosts"] = args.ssh
|
|
138
|
+
if args.subnet:
|
|
139
|
+
config["subnets"] = [args.subnet]
|
|
140
|
+
if args.mqtt:
|
|
141
|
+
config["mqtt_brokers"] = args.mqtt
|
|
142
|
+
if args.http:
|
|
143
|
+
config["http_urls"] = args.http
|
|
144
|
+
if args.docker:
|
|
145
|
+
config["docker_hosts"] = args.docker
|
|
146
|
+
|
|
147
|
+
if not config:
|
|
148
|
+
# Default: scan localhost
|
|
149
|
+
config = {"docker_hosts": ["localhost"]}
|
|
150
|
+
|
|
151
|
+
print("\n Halyn — Scanning...\n")
|
|
152
|
+
t0 = time.time()
|
|
153
|
+
|
|
154
|
+
nodes = asyncio.run(scanner.scan_all(config))
|
|
155
|
+
|
|
156
|
+
elapsed = time.time() - t0
|
|
157
|
+
|
|
158
|
+
if getattr(args, 'json', False):
|
|
159
|
+
print(json.dumps([{
|
|
160
|
+
"address": n.address, "port": n.port,
|
|
161
|
+
"protocol": n.protocol, "name": n.name,
|
|
162
|
+
"nrp_id": n.suggested_nrp_id,
|
|
163
|
+
"metadata": n.metadata,
|
|
164
|
+
} for n in nodes], indent=2))
|
|
165
|
+
else:
|
|
166
|
+
print(scanner.format_results(nodes))
|
|
167
|
+
print(f"\n Scanned in {elapsed:.1f}s")
|
|
168
|
+
if nodes:
|
|
169
|
+
print(f"\n To connect: halyn serve --config halyn.yml")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _cmd_status(args: Any) -> None:
|
|
173
|
+
"""Query a running Halyn instance."""
|
|
174
|
+
import urllib.request
|
|
175
|
+
try:
|
|
176
|
+
url = "http://localhost:8935/health"
|
|
177
|
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
178
|
+
data = json.loads(resp.read())
|
|
179
|
+
print(json.dumps(data, indent=2))
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"Cannot reach Halyn at localhost:8935: {e}")
|
|
182
|
+
print("Is Halyn running? Start with: halyn serve")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _cmd_test() -> None:
|
|
186
|
+
"""Run the test suite."""
|
|
187
|
+
import subprocess
|
|
188
|
+
test_path = __file__.replace("cli.py", "").replace("src/halyn/", "") + "tests/test_halyn.py"
|
|
189
|
+
subprocess.run([sys.executable, test_path])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_emergency_stop(args: Any) -> None:
|
|
193
|
+
"""Send emergency stop to running instance."""
|
|
194
|
+
import urllib.request
|
|
195
|
+
try:
|
|
196
|
+
req = urllib.request.Request(
|
|
197
|
+
"http://localhost:8935/emergency-stop",
|
|
198
|
+
method="POST",
|
|
199
|
+
)
|
|
200
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
201
|
+
print("EMERGENCY STOP SENT")
|
|
202
|
+
print(resp.read().decode())
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(f"Cannot reach Halyn: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
main()
|
halyn/config.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Configuration — YAML-based Halyn setup.
|
|
5
|
+
|
|
6
|
+
YAML/env-based configuration loader.
|
|
7
|
+
Merges file config, environment variables, and defaults.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("halyn.config")
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONFIG = {
|
|
21
|
+
"version": "1",
|
|
22
|
+
"server": {
|
|
23
|
+
"host": "0.0.0.0",
|
|
24
|
+
"port": 8935,
|
|
25
|
+
"api_key": "",
|
|
26
|
+
},
|
|
27
|
+
"llm": {
|
|
28
|
+
"provider": "ollama",
|
|
29
|
+
"model": "",
|
|
30
|
+
"api_key": "",
|
|
31
|
+
},
|
|
32
|
+
"domains": {
|
|
33
|
+
"infrastructure": {
|
|
34
|
+
"level": 2,
|
|
35
|
+
"nodes": ["server/*", "cloud/*", "docker/*"],
|
|
36
|
+
"confirm": ["restart", "deploy", "delete"],
|
|
37
|
+
},
|
|
38
|
+
"monitoring": {
|
|
39
|
+
"level": 4,
|
|
40
|
+
"nodes": ["sensor/*", "monitor/*"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"nodes": [],
|
|
44
|
+
"logging": {
|
|
45
|
+
"level": "INFO",
|
|
46
|
+
"file": "",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class HalynConfig:
|
|
53
|
+
"""Parsed configuration."""
|
|
54
|
+
host: str = "0.0.0.0"
|
|
55
|
+
port: int = 8935
|
|
56
|
+
api_key: str = ""
|
|
57
|
+
llm_provider: str = "ollama"
|
|
58
|
+
llm_model: str = ""
|
|
59
|
+
llm_api_key: str = ""
|
|
60
|
+
domains: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
61
|
+
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
62
|
+
log_level: str = "INFO"
|
|
63
|
+
log_file: str = ""
|
|
64
|
+
data_dir: str = ""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def load(cls, path: str = "") -> HalynConfig:
|
|
68
|
+
"""Load config from YAML file, env vars, or defaults."""
|
|
69
|
+
raw = dict(_DEFAULT_CONFIG)
|
|
70
|
+
|
|
71
|
+
# Try loading YAML
|
|
72
|
+
config_path = path or os.environ.get("HALYN_CONFIG", "")
|
|
73
|
+
if not config_path:
|
|
74
|
+
for candidate in ["halyn.yml", "halyn.yaml", ".halyn.yml",
|
|
75
|
+
str(Path.home() / ".halyn" / "config.yml")]:
|
|
76
|
+
if Path(candidate).is_file():
|
|
77
|
+
config_path = candidate
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
if config_path and Path(config_path).is_file():
|
|
81
|
+
try:
|
|
82
|
+
import yaml
|
|
83
|
+
with open(config_path) as f:
|
|
84
|
+
user_config = yaml.safe_load(f) or {}
|
|
85
|
+
_deep_merge(raw, user_config)
|
|
86
|
+
log.info("config.loaded path=%s", config_path)
|
|
87
|
+
except ImportError:
|
|
88
|
+
# Fallback: try JSON
|
|
89
|
+
import json as json_mod
|
|
90
|
+
if config_path.endswith(".json"):
|
|
91
|
+
with open(config_path) as f:
|
|
92
|
+
user_config = json_mod.load(f)
|
|
93
|
+
_deep_merge(raw, user_config)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
log.warning("config.load_error path=%s error=%s", config_path, exc)
|
|
96
|
+
|
|
97
|
+
# Environment variable overrides
|
|
98
|
+
server = raw.get("server", {})
|
|
99
|
+
llm = raw.get("llm", {})
|
|
100
|
+
|
|
101
|
+
cfg = cls(
|
|
102
|
+
host=os.environ.get("HALYN_HOST", str(server.get("host", "0.0.0.0"))),
|
|
103
|
+
port=int(os.environ.get("HALYN_PORT", server.get("port", 8935))),
|
|
104
|
+
api_key=os.environ.get("HALYN_API_KEY", server.get("api_key", "")),
|
|
105
|
+
llm_provider=os.environ.get("HALYN_LLM_PROVIDER", llm.get("provider", "ollama")),
|
|
106
|
+
llm_model=os.environ.get("HALYN_LLM_MODEL", llm.get("model", "")),
|
|
107
|
+
llm_api_key=os.environ.get("ANTHROPIC_API_KEY",
|
|
108
|
+
os.environ.get("OPENAI_API_KEY", llm.get("api_key", ""))),
|
|
109
|
+
domains=raw.get("domains", {}),
|
|
110
|
+
nodes=raw.get("nodes", []),
|
|
111
|
+
log_level=os.environ.get("HALYN_LOG_LEVEL",
|
|
112
|
+
raw.get("logging", {}).get("level", "INFO")),
|
|
113
|
+
log_file=raw.get("logging", {}).get("file", ""),
|
|
114
|
+
data_dir=os.environ.get("HALYN_DATA_DIR",
|
|
115
|
+
str(Path.home() / ".halyn")),
|
|
116
|
+
)
|
|
117
|
+
return cfg
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"server": {"host": self.host, "port": self.port},
|
|
122
|
+
"llm": {"provider": self.llm_provider, "model": self.llm_model},
|
|
123
|
+
"domains": self.domains,
|
|
124
|
+
"nodes": self.nodes,
|
|
125
|
+
"logging": {"level": self.log_level},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _deep_merge(base: dict, override: dict) -> None:
|
|
130
|
+
for key, val in override.items():
|
|
131
|
+
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
|
|
132
|
+
_deep_merge(base[key], val)
|
|
133
|
+
else:
|
|
134
|
+
base[key] = val
|
|
135
|
+
|
halyn/consent.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Consent — The human decides what connects.
|
|
5
|
+
|
|
6
|
+
Every new node requires explicit operator approval before
|
|
7
|
+
it can observe or act through the control plane.
|
|
8
|
+
|
|
9
|
+
The consent flow:
|
|
10
|
+
1. Discovery finds a node
|
|
11
|
+
2. Halyn presents it to the human: "New device found: Unitree G1 at 10.0.1.50"
|
|
12
|
+
3. Human chooses: ALLOW (full) | READ_ONLY | DENY | TEMPORARY (24h)
|
|
13
|
+
4. Decision is recorded in the consent store (persistent)
|
|
14
|
+
5. On reconnection, the stored consent is reused (no repeated prompts)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sqlite3
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("halyn.consent")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConsentLevel(str, Enum):
|
|
33
|
+
"""What the human allows for a node."""
|
|
34
|
+
FULL = "full" # observe + act (within shield limits)
|
|
35
|
+
READ_ONLY = "read_only" # observe only, no act
|
|
36
|
+
DENY = "deny" # blocked entirely
|
|
37
|
+
TEMPORARY = "temporary" # full access for limited time
|
|
38
|
+
PENDING = "pending" # waiting for human decision
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class ConsentRecord:
|
|
43
|
+
"""Persistent record of a consent decision."""
|
|
44
|
+
nrp_id: str
|
|
45
|
+
level: ConsentLevel
|
|
46
|
+
granted_at: float = 0.0
|
|
47
|
+
expires_at: float = 0.0 # 0 = never expires
|
|
48
|
+
granted_by: str = "" # user ID
|
|
49
|
+
device_info: str = "" # manifest summary at time of consent
|
|
50
|
+
reason: str = "" # why this level was chosen
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def expired(self) -> bool:
|
|
54
|
+
if self.expires_at == 0.0:
|
|
55
|
+
return False
|
|
56
|
+
return time.time() > self.expires_at
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def active(self) -> bool:
|
|
60
|
+
return self.level not in (ConsentLevel.DENY, ConsentLevel.PENDING) and not self.expired
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"nrp_id": self.nrp_id,
|
|
65
|
+
"level": self.level.value,
|
|
66
|
+
"granted_at": self.granted_at,
|
|
67
|
+
"expires_at": self.expires_at,
|
|
68
|
+
"granted_by": self.granted_by,
|
|
69
|
+
"device_info": self.device_info,
|
|
70
|
+
"reason": self.reason,
|
|
71
|
+
"expired": self.expired,
|
|
72
|
+
"active": self.active,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_SCHEMA = """
|
|
77
|
+
CREATE TABLE IF NOT EXISTS consent (
|
|
78
|
+
nrp_id TEXT PRIMARY KEY,
|
|
79
|
+
level TEXT NOT NULL DEFAULT 'pending',
|
|
80
|
+
granted_at REAL NOT NULL DEFAULT 0,
|
|
81
|
+
expires_at REAL NOT NULL DEFAULT 0,
|
|
82
|
+
granted_by TEXT NOT NULL DEFAULT '',
|
|
83
|
+
device_info TEXT NOT NULL DEFAULT '',
|
|
84
|
+
reason TEXT NOT NULL DEFAULT ''
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_consent_level ON consent(level);
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ConsentStore:
|
|
91
|
+
"""
|
|
92
|
+
Persistent consent decisions.
|
|
93
|
+
|
|
94
|
+
Stored in SQLite. Survives restarts.
|
|
95
|
+
Persisted across restarts.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, db_path: str = "") -> None:
|
|
99
|
+
if not db_path:
|
|
100
|
+
data_dir = Path.home() / ".halyn"
|
|
101
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
db_path = str(data_dir / "consent.db")
|
|
103
|
+
self._db_path = db_path
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
106
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
107
|
+
self._conn.executescript(_SCHEMA)
|
|
108
|
+
self._conn.commit()
|
|
109
|
+
log.info("consent.init db=%s", db_path)
|
|
110
|
+
|
|
111
|
+
def check(self, nrp_id: str) -> ConsentRecord | None:
|
|
112
|
+
"""Check if consent exists for a node. Returns None if no record."""
|
|
113
|
+
with self._lock:
|
|
114
|
+
row = self._conn.execute(
|
|
115
|
+
"SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
|
|
116
|
+
"FROM consent WHERE nrp_id = ?", (nrp_id,)
|
|
117
|
+
).fetchone()
|
|
118
|
+
if not row:
|
|
119
|
+
return None
|
|
120
|
+
record = ConsentRecord(
|
|
121
|
+
nrp_id=row[0], level=ConsentLevel(row[1]),
|
|
122
|
+
granted_at=row[2], expires_at=row[3],
|
|
123
|
+
granted_by=row[4], device_info=row[5], reason=row[6],
|
|
124
|
+
)
|
|
125
|
+
# Auto-expire temporary consents
|
|
126
|
+
if record.expired and record.level == ConsentLevel.TEMPORARY:
|
|
127
|
+
self.revoke(nrp_id, reason="auto-expired")
|
|
128
|
+
return None
|
|
129
|
+
return record
|
|
130
|
+
|
|
131
|
+
def grant(
|
|
132
|
+
self,
|
|
133
|
+
nrp_id: str,
|
|
134
|
+
level: ConsentLevel,
|
|
135
|
+
granted_by: str = "",
|
|
136
|
+
device_info: str = "",
|
|
137
|
+
reason: str = "",
|
|
138
|
+
duration_hours: float = 0,
|
|
139
|
+
) -> ConsentRecord:
|
|
140
|
+
"""Grant consent for a node."""
|
|
141
|
+
now = time.time()
|
|
142
|
+
expires = now + (duration_hours * 3600) if duration_hours > 0 else 0.0
|
|
143
|
+
|
|
144
|
+
record = ConsentRecord(
|
|
145
|
+
nrp_id=nrp_id, level=level,
|
|
146
|
+
granted_at=now, expires_at=expires,
|
|
147
|
+
granted_by=granted_by, device_info=device_info,
|
|
148
|
+
reason=reason,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
with self._lock:
|
|
152
|
+
self._conn.execute(
|
|
153
|
+
"INSERT OR REPLACE INTO consent "
|
|
154
|
+
"(nrp_id, level, granted_at, expires_at, granted_by, device_info, reason) "
|
|
155
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
156
|
+
(nrp_id, level.value, now, expires, granted_by, device_info, reason),
|
|
157
|
+
)
|
|
158
|
+
self._conn.commit()
|
|
159
|
+
|
|
160
|
+
log.info("consent.granted nrp_id=%s level=%s by=%s expires=%s",
|
|
161
|
+
nrp_id, level.value, granted_by,
|
|
162
|
+
f"{duration_hours}h" if duration_hours else "never")
|
|
163
|
+
return record
|
|
164
|
+
|
|
165
|
+
def revoke(self, nrp_id: str, reason: str = "") -> bool:
|
|
166
|
+
"""Revoke consent for a node."""
|
|
167
|
+
with self._lock:
|
|
168
|
+
cursor = self._conn.execute(
|
|
169
|
+
"UPDATE consent SET level = ?, reason = ? WHERE nrp_id = ?",
|
|
170
|
+
(ConsentLevel.DENY.value, reason or "revoked", nrp_id),
|
|
171
|
+
)
|
|
172
|
+
self._conn.commit()
|
|
173
|
+
revoked = cursor.rowcount > 0
|
|
174
|
+
|
|
175
|
+
if revoked:
|
|
176
|
+
log.info("consent.revoked nrp_id=%s reason=%s", nrp_id, reason)
|
|
177
|
+
return revoked
|
|
178
|
+
|
|
179
|
+
def list_all(self, level: ConsentLevel | None = None) -> list[ConsentRecord]:
|
|
180
|
+
"""List all consent records, optionally filtered by level."""
|
|
181
|
+
with self._lock:
|
|
182
|
+
if level:
|
|
183
|
+
rows = self._conn.execute(
|
|
184
|
+
"SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
|
|
185
|
+
"FROM consent WHERE level = ? ORDER BY granted_at DESC",
|
|
186
|
+
(level.value,)
|
|
187
|
+
).fetchall()
|
|
188
|
+
else:
|
|
189
|
+
rows = self._conn.execute(
|
|
190
|
+
"SELECT nrp_id, level, granted_at, expires_at, granted_by, device_info, reason "
|
|
191
|
+
"FROM consent ORDER BY granted_at DESC"
|
|
192
|
+
).fetchall()
|
|
193
|
+
|
|
194
|
+
return [
|
|
195
|
+
ConsentRecord(
|
|
196
|
+
nrp_id=r[0], level=ConsentLevel(r[1]),
|
|
197
|
+
granted_at=r[2], expires_at=r[3],
|
|
198
|
+
granted_by=r[4], device_info=r[5], reason=r[6],
|
|
199
|
+
)
|
|
200
|
+
for r in rows
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def pending_count(self) -> int:
|
|
204
|
+
"""How many nodes are waiting for consent."""
|
|
205
|
+
with self._lock:
|
|
206
|
+
r = self._conn.execute(
|
|
207
|
+
"SELECT COUNT(*) FROM consent WHERE level = ?",
|
|
208
|
+
(ConsentLevel.PENDING.value,)
|
|
209
|
+
).fetchone()
|
|
210
|
+
return r[0] if r else 0
|
|
211
|
+
|
|
212
|
+
def request_consent(self, nrp_id: str, device_info: str = "") -> ConsentRecord:
|
|
213
|
+
"""
|
|
214
|
+
Request consent for a new node.
|
|
215
|
+
Creates a PENDING record. The human must approve or deny.
|
|
216
|
+
"""
|
|
217
|
+
existing = self.check(nrp_id)
|
|
218
|
+
if existing and existing.active:
|
|
219
|
+
return existing # Already consented
|
|
220
|
+
|
|
221
|
+
return self.grant(
|
|
222
|
+
nrp_id=nrp_id,
|
|
223
|
+
level=ConsentLevel.PENDING,
|
|
224
|
+
device_info=device_info,
|
|
225
|
+
reason="awaiting human approval",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def format_request(self, nrp_id: str, device_info: str = "") -> str:
|
|
229
|
+
"""Format a consent request for display to the human."""
|
|
230
|
+
return (
|
|
231
|
+
f"New device detected:\n"
|
|
232
|
+
f" Node: {nrp_id}\n"
|
|
233
|
+
f" {device_info}\n"
|
|
234
|
+
f"\n"
|
|
235
|
+
f" [ALLOW] Full access (observe + act within shield limits)\n"
|
|
236
|
+
f" [READ_ONLY] Observe only (no actions)\n"
|
|
237
|
+
f" [TEMPORARY] Full access for 24 hours\n"
|
|
238
|
+
f" [DENY] Block this device\n"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def close(self) -> None:
|
|
242
|
+
with self._lock:
|
|
243
|
+
self._conn.close()
|