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/memory/store.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Memory — Jarvis never forgets.
|
|
5
|
+
|
|
6
|
+
SQLite + FTS5. Persistent across sessions.
|
|
7
|
+
Facts (key-value), Journal (timeline), Skills (learned patterns).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..types import ToolCategory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Memory:
|
|
22
|
+
"""Persistent memory. Survives reboots. Searchable."""
|
|
23
|
+
|
|
24
|
+
__slots__ = ("_db", "_path")
|
|
25
|
+
|
|
26
|
+
def __init__(self, path: str = "~/.jarvis/memory.db") -> None:
|
|
27
|
+
self._path = Path(path).expanduser()
|
|
28
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self._db = sqlite3.connect(str(self._path), check_same_thread=False)
|
|
30
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
31
|
+
self._db.execute("PRAGMA synchronous=NORMAL")
|
|
32
|
+
self._init()
|
|
33
|
+
|
|
34
|
+
def _init(self) -> None:
|
|
35
|
+
self._db.executescript("""
|
|
36
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
37
|
+
key TEXT PRIMARY KEY,
|
|
38
|
+
value TEXT NOT NULL,
|
|
39
|
+
category TEXT NOT NULL DEFAULT 'general',
|
|
40
|
+
updated_at REAL NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS journal (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
ts REAL NOT NULL,
|
|
45
|
+
event TEXT NOT NULL,
|
|
46
|
+
detail TEXT NOT NULL DEFAULT '',
|
|
47
|
+
node TEXT NOT NULL DEFAULT 'local'
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
50
|
+
name TEXT PRIMARY KEY,
|
|
51
|
+
trigger_pattern TEXT NOT NULL,
|
|
52
|
+
actions TEXT NOT NULL,
|
|
53
|
+
times_used INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
created_at REAL NOT NULL
|
|
55
|
+
);
|
|
56
|
+
""")
|
|
57
|
+
# FTS5 for full-text search (safe to call multiple times)
|
|
58
|
+
try:
|
|
59
|
+
self._db.execute(
|
|
60
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts "
|
|
61
|
+
"USING fts5(key, value, category)"
|
|
62
|
+
)
|
|
63
|
+
except sqlite3.OperationalError:
|
|
64
|
+
pass # Already exists or FTS5 not available
|
|
65
|
+
self._db.commit()
|
|
66
|
+
|
|
67
|
+
# ─── Facts ──────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def remember(self, key: str, value: Any, category: str = "general") -> None:
|
|
70
|
+
v = json.dumps(value) if not isinstance(value, str) else value
|
|
71
|
+
now = time.time()
|
|
72
|
+
self._db.execute(
|
|
73
|
+
"INSERT OR REPLACE INTO facts (key, value, category, updated_at) VALUES (?, ?, ?, ?)",
|
|
74
|
+
(key, v, category, now),
|
|
75
|
+
)
|
|
76
|
+
# Sync FTS
|
|
77
|
+
try:
|
|
78
|
+
self._db.execute("DELETE FROM facts_fts WHERE key = ?", (key,))
|
|
79
|
+
self._db.execute("INSERT INTO facts_fts (key, value, category) VALUES (?, ?, ?)", (key, v, category))
|
|
80
|
+
except sqlite3.OperationalError:
|
|
81
|
+
pass
|
|
82
|
+
self._db.commit()
|
|
83
|
+
|
|
84
|
+
def recall(self, key: str) -> str | None:
|
|
85
|
+
row = self._db.execute("SELECT value FROM facts WHERE key = ?", (key,)).fetchone()
|
|
86
|
+
return row[0] if row else None
|
|
87
|
+
|
|
88
|
+
def forget(self, key: str) -> bool:
|
|
89
|
+
cur = self._db.execute("DELETE FROM facts WHERE key = ?", (key,))
|
|
90
|
+
self._db.commit()
|
|
91
|
+
return cur.rowcount > 0
|
|
92
|
+
|
|
93
|
+
def search(self, query: str, limit: int = 10) -> list[dict[str, str]]:
|
|
94
|
+
"""Full-text search. Falls back to LIKE if FTS5 unavailable."""
|
|
95
|
+
try:
|
|
96
|
+
rows = self._db.execute(
|
|
97
|
+
"SELECT key, value, category FROM facts_fts WHERE facts_fts MATCH ? LIMIT ?",
|
|
98
|
+
(query, limit),
|
|
99
|
+
).fetchall()
|
|
100
|
+
except sqlite3.OperationalError:
|
|
101
|
+
like = f"%{query}%"
|
|
102
|
+
rows = self._db.execute(
|
|
103
|
+
"SELECT key, value, category FROM facts WHERE key LIKE ? OR value LIKE ? LIMIT ?",
|
|
104
|
+
(like, like, limit),
|
|
105
|
+
).fetchall()
|
|
106
|
+
return [{"key": r[0], "value": r[1], "category": r[2]} for r in rows]
|
|
107
|
+
|
|
108
|
+
def facts(self, category: str | None = None) -> list[dict[str, str]]:
|
|
109
|
+
if category:
|
|
110
|
+
rows = self._db.execute(
|
|
111
|
+
"SELECT key, value, category FROM facts WHERE category = ? ORDER BY updated_at DESC",
|
|
112
|
+
(category,),
|
|
113
|
+
).fetchall()
|
|
114
|
+
else:
|
|
115
|
+
rows = self._db.execute(
|
|
116
|
+
"SELECT key, value, category FROM facts ORDER BY updated_at DESC"
|
|
117
|
+
).fetchall()
|
|
118
|
+
return [{"key": r[0], "value": r[1], "category": r[2]} for r in rows]
|
|
119
|
+
|
|
120
|
+
# ─── Journal ────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def log(self, event: str, detail: str = "", node: str = "local") -> None:
|
|
123
|
+
self._db.execute(
|
|
124
|
+
"INSERT INTO journal (ts, event, detail, node) VALUES (?, ?, ?, ?)",
|
|
125
|
+
(time.time(), event, detail, node),
|
|
126
|
+
)
|
|
127
|
+
self._db.commit()
|
|
128
|
+
|
|
129
|
+
def recent(self, n: int = 20) -> list[dict[str, Any]]:
|
|
130
|
+
rows = self._db.execute(
|
|
131
|
+
"SELECT ts, event, detail, node FROM journal ORDER BY id DESC LIMIT ?", (n,)
|
|
132
|
+
).fetchall()
|
|
133
|
+
return [{"ts": r[0], "event": r[1], "detail": r[2], "node": r[3]} for r in rows]
|
|
134
|
+
|
|
135
|
+
# ─── Skills ─────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def learn(self, name: str, trigger: str, actions: list[str]) -> None:
|
|
138
|
+
self._db.execute(
|
|
139
|
+
"INSERT OR REPLACE INTO skills (name, trigger_pattern, actions, times_used, created_at) "
|
|
140
|
+
"VALUES (?, ?, ?, 0, ?)",
|
|
141
|
+
(name, trigger, json.dumps(actions), time.time()),
|
|
142
|
+
)
|
|
143
|
+
self._db.commit()
|
|
144
|
+
|
|
145
|
+
def get_skill(self, name: str) -> dict[str, Any] | None:
|
|
146
|
+
row = self._db.execute(
|
|
147
|
+
"SELECT name, trigger_pattern, actions, times_used FROM skills WHERE name = ?",
|
|
148
|
+
(name,),
|
|
149
|
+
).fetchone()
|
|
150
|
+
if row:
|
|
151
|
+
return {"name": row[0], "trigger": row[1], "actions": json.loads(row[2]), "used": row[3]}
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def close(self) -> None:
|
|
155
|
+
self._db.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ─── Tool wrappers ──────────────────────────────────
|
|
159
|
+
|
|
160
|
+
_instance: Memory | None = None
|
|
161
|
+
|
|
162
|
+
def _mem() -> Memory:
|
|
163
|
+
global _instance
|
|
164
|
+
if _instance is None:
|
|
165
|
+
_instance = Memory()
|
|
166
|
+
return _instance
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tool_remember(args: dict[str, Any], node: Any) -> str:
|
|
170
|
+
_mem().remember(args["key"], args["value"], args.get("category", "general"))
|
|
171
|
+
return "remembered"
|
|
172
|
+
|
|
173
|
+
def tool_recall(args: dict[str, Any], node: Any) -> str | None:
|
|
174
|
+
return _mem().recall(args["key"])
|
|
175
|
+
|
|
176
|
+
def tool_forget(args: dict[str, Any], node: Any) -> str:
|
|
177
|
+
ok = _mem().forget(args["key"])
|
|
178
|
+
return "forgotten" if ok else "not found"
|
|
179
|
+
|
|
180
|
+
def tool_search(args: dict[str, Any], node: Any) -> list[dict[str, str]]:
|
|
181
|
+
return _mem().search(args["query"], args.get("limit", 10))
|
|
182
|
+
|
|
183
|
+
def tool_log(args: dict[str, Any], node: Any) -> str:
|
|
184
|
+
_mem().log(args["event"], args.get("detail", ""), args.get("node", "local"))
|
|
185
|
+
return "logged"
|
|
186
|
+
|
|
187
|
+
def tool_journal(args: dict[str, Any], node: Any) -> list[dict[str, Any]]:
|
|
188
|
+
return _mem().recent(args.get("n", 20))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def register_memory(engine: Any) -> None:
|
|
192
|
+
"""Plug memory tools into the engine."""
|
|
193
|
+
reg = engine.registry
|
|
194
|
+
reg.register_tool("remember", tool_remember, ToolCategory.MEMORY, "Store a fact")
|
|
195
|
+
reg.register_tool("recall", tool_recall, ToolCategory.MEMORY, "Recall a fact by key")
|
|
196
|
+
reg.register_tool("forget", tool_forget, ToolCategory.MEMORY, "Remove a fact")
|
|
197
|
+
reg.register_tool("search_memory", tool_search, ToolCategory.MEMORY, "Search all memory")
|
|
198
|
+
reg.register_tool("log_event", tool_log, ToolCategory.MEMORY, "Log an event")
|
|
199
|
+
reg.register_tool("journal", tool_journal, ToolCategory.MEMORY, "Recent events")
|
|
200
|
+
|
halyn/nrp_bridge.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
NRP Bridge v2 — Connects Driver + Identity + Manifest + Events to Engine.
|
|
5
|
+
|
|
6
|
+
This is the integration layer. One function does everything:
|
|
7
|
+
1. Connects the driver
|
|
8
|
+
2. Reads the manifest (auto-description)
|
|
9
|
+
3. Generates engine tools from manifest
|
|
10
|
+
4. Wires events
|
|
11
|
+
5. Enforces shield rules from manifest
|
|
12
|
+
|
|
13
|
+
After registration, the AI sees the node and knows everything about it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from nrp import NRPDriver, ShieldRule, ShieldType
|
|
22
|
+
from .engine import Engine
|
|
23
|
+
from nrp import NRPId
|
|
24
|
+
from nrp import NRPManifest
|
|
25
|
+
from nrp import EventBus, NRPEvent, Severity
|
|
26
|
+
from .types import Node, NodeKind, ToolCategory, ActionStatus
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("jarvis.nrp")
|
|
29
|
+
|
|
30
|
+
NODE_KIND_MAP: dict[str, NodeKind] = {
|
|
31
|
+
"ssh": NodeKind.SSH, "adb": NodeKind.ADB, "docker": NodeKind.DOCKER,
|
|
32
|
+
"ros2": NodeKind.LOCAL, "unitree": NodeKind.LOCAL, "mqtt": NodeKind.LOCAL,
|
|
33
|
+
"dji": NodeKind.LOCAL, "browser": NodeKind.LOCAL, "opcua": NodeKind.LOCAL,
|
|
34
|
+
"http": NodeKind.LOCAL, "serial": NodeKind.LOCAL,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def register_nrp_node(
|
|
39
|
+
engine: Engine,
|
|
40
|
+
nrp_id: NRPId | str,
|
|
41
|
+
driver: NRPDriver,
|
|
42
|
+
event_bus: EventBus | None = None,
|
|
43
|
+
) -> NRPManifest:
|
|
44
|
+
"""
|
|
45
|
+
Register a device into the engine via NRP.
|
|
46
|
+
|
|
47
|
+
Primary registration entry point.
|
|
48
|
+
Returns the manifest for inspection/logging.
|
|
49
|
+
"""
|
|
50
|
+
# 1. Parse ID
|
|
51
|
+
if isinstance(nrp_id, str):
|
|
52
|
+
nrp_id = NRPId.parse(nrp_id)
|
|
53
|
+
node_name = nrp_id.short # "robot/g1-01"
|
|
54
|
+
|
|
55
|
+
# 2. Bind events
|
|
56
|
+
bus = event_bus or EventBus()
|
|
57
|
+
driver.bind(nrp_id, bus)
|
|
58
|
+
|
|
59
|
+
# 3. Connect
|
|
60
|
+
alive = await driver.connect()
|
|
61
|
+
if not alive:
|
|
62
|
+
log.warning("nrp.connect_failed id=%s", nrp_id.uri)
|
|
63
|
+
|
|
64
|
+
# 4. Read manifest
|
|
65
|
+
manifest = driver.manifest()
|
|
66
|
+
log.info("nrp.manifest id=%s observe=%d act=%d shield=%d",
|
|
67
|
+
nrp_id.uri, len(manifest.observe), len(manifest.act), len(manifest.shield))
|
|
68
|
+
|
|
69
|
+
# 5. Register node
|
|
70
|
+
kind_str = nrp_id.kind
|
|
71
|
+
kind = NODE_KIND_MAP.get(kind_str, NodeKind.LOCAL)
|
|
72
|
+
node = Node(
|
|
73
|
+
name=node_name, kind=kind, alive=alive,
|
|
74
|
+
labels={"nrp_id": nrp_id.uri, "nrp_kind": kind_str,
|
|
75
|
+
"manufacturer": manifest.manufacturer, "model": manifest.model},
|
|
76
|
+
)
|
|
77
|
+
engine.registry.register_node(node)
|
|
78
|
+
|
|
79
|
+
# 6. Get shield rules for enforcement
|
|
80
|
+
shield_rules = driver.shield_rules()
|
|
81
|
+
|
|
82
|
+
# 7. Register observe tool (reads all or specific channels)
|
|
83
|
+
async def _observe(args: dict[str, Any], target: Any) -> dict[str, Any]:
|
|
84
|
+
channels = args.get("channels")
|
|
85
|
+
if isinstance(channels, str):
|
|
86
|
+
channels = [c.strip() for c in channels.split(",")]
|
|
87
|
+
return await driver.observe(channels)
|
|
88
|
+
|
|
89
|
+
engine.registry.register_tool(
|
|
90
|
+
f"{node_name}.observe", _observe, ToolCategory.OBSERVER,
|
|
91
|
+
f"Observe {nrp_id.uri} — {_describe_observe(manifest)}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 8. Register act tool (with shield enforcement)
|
|
95
|
+
async def _act(args: dict[str, Any], target: Any) -> Any:
|
|
96
|
+
command = args.get("command", "")
|
|
97
|
+
cmd_args = {k: v for k, v in args.items() if k != "command"}
|
|
98
|
+
# Enforce shield rules
|
|
99
|
+
violation = _check_shield(command, cmd_args, shield_rules)
|
|
100
|
+
if violation:
|
|
101
|
+
log.warning("nrp.shield_blocked id=%s cmd=%s rule=%s",
|
|
102
|
+
nrp_id.uri, command, violation)
|
|
103
|
+
await driver.emit("shield_blocked", Severity.WARNING,
|
|
104
|
+
command=command, rule=violation)
|
|
105
|
+
raise PermissionError(f"Shield blocked: {violation}")
|
|
106
|
+
result = await driver.act(command, cmd_args)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
engine.registry.register_tool(
|
|
110
|
+
f"{node_name}.act", _act, ToolCategory.EXECUTOR,
|
|
111
|
+
f"Act on {nrp_id.uri} — {_describe_act(manifest)}", dangerous=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# 9. Register individual action shortcuts
|
|
115
|
+
for action_spec in manifest.act:
|
|
116
|
+
aname = action_spec.name
|
|
117
|
+
|
|
118
|
+
async def _shortcut(args: dict[str, Any], target: Any, _cmd: str = aname) -> Any:
|
|
119
|
+
return await _act({"command": _cmd, **args}, target)
|
|
120
|
+
|
|
121
|
+
engine.registry.register_tool(
|
|
122
|
+
f"{node_name}.{aname}", _shortcut, ToolCategory.EXECUTOR,
|
|
123
|
+
action_spec.description or f"{aname} on {nrp_id.short}",
|
|
124
|
+
dangerous=action_spec.dangerous,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 10. Register shield info tool
|
|
128
|
+
def _shield_info(args: dict[str, Any], target: Any) -> list[dict[str, Any]]:
|
|
129
|
+
return [s.to_dict() for s in manifest.shield]
|
|
130
|
+
|
|
131
|
+
engine.registry.register_tool(
|
|
132
|
+
f"{node_name}.shield", _shield_info, ToolCategory.OBSERVER,
|
|
133
|
+
f"Safety rules for {nrp_id.uri}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# 11. Register manifest tool (the AI can inspect the full description)
|
|
137
|
+
def _manifest_info(args: dict[str, Any], target: Any) -> str:
|
|
138
|
+
return manifest.to_llm_description()
|
|
139
|
+
|
|
140
|
+
engine.registry.register_tool(
|
|
141
|
+
f"{node_name}.info", _manifest_info, ToolCategory.OBSERVER,
|
|
142
|
+
f"Full description of {nrp_id.uri}",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 12. Heartbeat
|
|
146
|
+
async def _heartbeat(args: dict[str, Any], target: Any) -> dict[str, Any]:
|
|
147
|
+
return await driver.heartbeat()
|
|
148
|
+
|
|
149
|
+
engine.registry.register_tool(
|
|
150
|
+
f"{node_name}.heartbeat", _heartbeat, ToolCategory.OBSERVER,
|
|
151
|
+
f"Health check {nrp_id.uri}",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# 13. Log registration event
|
|
155
|
+
await bus.emit_simple(nrp_id.uri, "node_registered", Severity.INFO,
|
|
156
|
+
alive=alive, tools=len(manifest.act) + 4,
|
|
157
|
+
observe_channels=len(manifest.observe))
|
|
158
|
+
|
|
159
|
+
tool_count = len(manifest.act) + 4 # observe + act + shield + info + heartbeat + shortcuts
|
|
160
|
+
log.info("nrp.registered id=%s alive=%s tools=%d", nrp_id.uri, alive, tool_count)
|
|
161
|
+
|
|
162
|
+
return manifest
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ─── Shield enforcement ─────────────────────────────
|
|
166
|
+
|
|
167
|
+
def _check_shield(command: str, args: dict[str, Any], rules: list[ShieldRule]) -> str | None:
|
|
168
|
+
"""Check if a command violates any shield rule. Returns rule name or None."""
|
|
169
|
+
for rule in rules:
|
|
170
|
+
if rule.type == ShieldType.PATTERN:
|
|
171
|
+
pattern = str(rule.value).lower()
|
|
172
|
+
cmd_lower = command.lower() + " " + " ".join(str(v) for v in args.values()).lower()
|
|
173
|
+
if pattern in cmd_lower:
|
|
174
|
+
return rule.name
|
|
175
|
+
|
|
176
|
+
elif rule.type == ShieldType.LIMIT:
|
|
177
|
+
# Check if any numeric arg exceeds the limit
|
|
178
|
+
limit_val = float(rule.value) if rule.value is not None else None
|
|
179
|
+
if limit_val is not None:
|
|
180
|
+
for v in args.values():
|
|
181
|
+
try:
|
|
182
|
+
if abs(float(v)) > limit_val:
|
|
183
|
+
return rule.name
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
elif rule.type == ShieldType.THRESHOLD:
|
|
188
|
+
# Threshold = minimum value. Below = blocked.
|
|
189
|
+
thresh = float(rule.value) if rule.value is not None else None
|
|
190
|
+
if thresh is not None and "battery" in rule.name.lower():
|
|
191
|
+
# Battery threshold checked at observe time, not act time
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ─── Description helpers ────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _describe_observe(m: NRPManifest) -> str:
|
|
200
|
+
if not m.observe:
|
|
201
|
+
return "no channels"
|
|
202
|
+
names = [c.name for c in m.observe[:5]]
|
|
203
|
+
more = f" +{len(m.observe) - 5} more" if len(m.observe) > 5 else ""
|
|
204
|
+
return "channels: " + ", ".join(names) + more
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _describe_act(m: NRPManifest) -> str:
|
|
208
|
+
if not m.act:
|
|
209
|
+
return "no actions"
|
|
210
|
+
names = [a.name for a in m.act[:5]]
|
|
211
|
+
more = f" +{len(m.act) - 5} more" if len(m.act) > 5 else ""
|
|
212
|
+
return "commands: " + ", ".join(names) + more
|
|
213
|
+
|
halyn/py.typed
ADDED
|
File without changes
|
halyn/sanitizer.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Sanitizer — Clean all inputs before execution.
|
|
5
|
+
|
|
6
|
+
Prevents: command injection, path traversal, output flooding.
|
|
7
|
+
Applied BEFORE the shield, BEFORE the driver.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("jarvis.sanitizer")
|
|
17
|
+
|
|
18
|
+
# Max output size to prevent OOM
|
|
19
|
+
MAX_OUTPUT_BYTES: int = 1_048_576 # 1MB
|
|
20
|
+
|
|
21
|
+
# Max timeout any user can request
|
|
22
|
+
MAX_TIMEOUT: int = 300 # 5 minutes
|
|
23
|
+
|
|
24
|
+
# Shell injection patterns (beyond what shield catches)
|
|
25
|
+
INJECTION_PATTERNS: tuple[str, ...] = (
|
|
26
|
+
"$(", "`", # Command substitution
|
|
27
|
+
" | ", " || ", # Pipe to another command
|
|
28
|
+
" ; ", # Command chaining
|
|
29
|
+
" && ", # Conditional chaining
|
|
30
|
+
"\n", # Newline injection
|
|
31
|
+
">> /etc/", "> /etc/", # Write to system files
|
|
32
|
+
"curl ", "wget ", # Download and execute
|
|
33
|
+
"nc ", "ncat ", # Netcat reverse shells
|
|
34
|
+
"python -c", "python3 -c", # Inline code execution
|
|
35
|
+
"perl -e", "ruby -e",
|
|
36
|
+
"base64 -d", # Decode hidden payloads
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Path traversal patterns
|
|
40
|
+
PATH_TRAVERSAL: tuple[str, ...] = (
|
|
41
|
+
"..",
|
|
42
|
+
"~root",
|
|
43
|
+
"/etc/shadow",
|
|
44
|
+
"/etc/passwd",
|
|
45
|
+
"/proc/",
|
|
46
|
+
"/sys/",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sanitize_action(tool: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Sanitize action arguments. Returns cleaned args.
|
|
53
|
+
Raises ValueError if dangerous.
|
|
54
|
+
"""
|
|
55
|
+
args = dict(args) # Don't mutate original
|
|
56
|
+
|
|
57
|
+
# Cap timeout everywhere
|
|
58
|
+
if "timeout" in args:
|
|
59
|
+
args["timeout"] = min(int(args["timeout"]), MAX_TIMEOUT)
|
|
60
|
+
|
|
61
|
+
# Cap output limit
|
|
62
|
+
if "limit" in args:
|
|
63
|
+
args["limit"] = min(int(args["limit"]), MAX_OUTPUT_BYTES)
|
|
64
|
+
if "lines" in args:
|
|
65
|
+
args["lines"] = min(int(args["lines"]), 500)
|
|
66
|
+
if "n" in args and isinstance(args["n"], int):
|
|
67
|
+
args["n"] = min(args["n"], 500)
|
|
68
|
+
|
|
69
|
+
# Shell command sanitization
|
|
70
|
+
if tool in ("shell",) and "command" in args:
|
|
71
|
+
cmd = args["command"]
|
|
72
|
+
_check_injection(cmd)
|
|
73
|
+
|
|
74
|
+
# File path sanitization
|
|
75
|
+
if "path" in args:
|
|
76
|
+
path = str(args["path"])
|
|
77
|
+
_check_path(path)
|
|
78
|
+
|
|
79
|
+
return args
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sanitize_output(data: Any) -> Any:
|
|
83
|
+
"""Truncate output to prevent OOM."""
|
|
84
|
+
if isinstance(data, str) and len(data) > MAX_OUTPUT_BYTES:
|
|
85
|
+
truncated = data[:MAX_OUTPUT_BYTES]
|
|
86
|
+
log.warning("sanitizer.output_truncated original=%d", len(data))
|
|
87
|
+
return truncated + f"\n... [truncated, {len(data)} bytes total]"
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_injection(cmd: str) -> None:
|
|
92
|
+
"""Check for shell injection patterns in non-shell tools."""
|
|
93
|
+
# For the "shell" tool, we ALLOW these (that's the point of shell).
|
|
94
|
+
# But we LOG them for the audit trail.
|
|
95
|
+
for pattern in INJECTION_PATTERNS:
|
|
96
|
+
if pattern in cmd:
|
|
97
|
+
log.info("sanitizer.injection_pattern cmd=%s pattern=%s", cmd[:100], pattern)
|
|
98
|
+
# We don't block — shield handles dangerous patterns.
|
|
99
|
+
# We just make sure the audit knows.
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_path(path: str) -> None:
|
|
104
|
+
"""Block path traversal."""
|
|
105
|
+
for pattern in PATH_TRAVERSAL:
|
|
106
|
+
if pattern in path:
|
|
107
|
+
raise ValueError(f"path traversal blocked: {pattern}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def redact_error(error: str) -> str:
|
|
111
|
+
"""Remove sensitive info from error messages before sending to client."""
|
|
112
|
+
# Remove file paths
|
|
113
|
+
error = re.sub(r"/[\w/.-]+\.(?:pem|key|crt|conf|env)", "[REDACTED_PATH]", error)
|
|
114
|
+
# Remove IP:port patterns that might reveal internal topology
|
|
115
|
+
error = re.sub(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+", "[REDACTED_HOST]", error)
|
|
116
|
+
# Remove potential credentials
|
|
117
|
+
error = re.sub(r"(?:password|passwd|token|secret|key)[\s=:]+\S+", "[REDACTED]", error, flags=re.IGNORECASE)
|
|
118
|
+
# Limit length
|
|
119
|
+
return error[:300]
|
|
120
|
+
|