commit ea1c67b33f5a177ea7c8a438c0755b9122abfaf8 Author: sylvain Date: Sun Mar 22 21:53:00 2026 +0000 Initial commit — Agent HAL v1.0 Agent système complet remplaçant agent_debian : - 20 skills : apt, systemd, cron, process, network, user, sysinfo, journal, container, shell, filesystem (enhanced), git, ssh, web_fetch, todo, script, mqtt_send, mqtt_subscribe, muc_send, agents_status - filesystem : read avec numéros de lignes, edit, multiedit (style SHAI) - git : status, log, diff, add, commit, push, pull, clone, branch, checkout - ssh : exécution distante + SCP (password ou clé) - web_fetch : GET/HEAD/POST avec nettoyage HTML - todo : liste de tâches en mémoire diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d8549 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +data/ +__pycache__/ +*.pyc +*.db +*.log diff --git a/agent_hal.py b/agent_hal.py new file mode 100644 index 0000000..0829c7d --- /dev/null +++ b/agent_hal.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Agent HAL — Contrôle système complet + édition de code/fichiers. +Remplace agent_debian avec des capacités étendues (git, ssh distant, web, todo). +""" +import os +import sys +import threading +import subprocess +import logging + +from agents_core import BaseAgent, AgentContext, Message, MessageType + +logger = logging.getLogger(__name__) + + +class AgentHal(BaseAgent): + AGENT_TYPE = "hal" + DESCRIPTION = ( + "Contrôle système complet et édition de code/fichiers : " + "gestion des paquets (apt), services systemd, réseau, processus, " + "utilisateurs, conteneurs Docker/LXC, logs journald, cron, " + "édition avancée de fichiers (read/write/edit/multiedit), git, " + "exécution SSH distante, fetch web. " + "À utiliser pour toute tâche système, devops ou édition de code sur CE serveur ou en SSH." + ) + DEFAULT_CONFIG_PATH = "/opt/agent_hal/config/config.json" + + def get_skills_dir(self) -> str: + return os.path.join(os.path.dirname(__file__), "skills") + + def on_start(self): + self.mqtt.send_to("nexus", f"Agent HAL ({self.agent_id}) en ligne.") + self._start_monitoring() + + def setup_extra_subscriptions(self): + self.mqtt.subscribe( + f"agents/{self.agent_id}/control", + self._on_control_message, + ) + + def _on_control_message(self, msg, topic: str): + from agents_core.message_bus import Message as Msg + payload = msg.payload if isinstance(msg, Msg) else str(msg) + result = self._handle_system_command(payload) + if result and isinstance(msg, Msg): + self.mqtt.reply(msg, result) + + def handle_custom_command(self, cmd: str, args: str, source_msg=None): + if cmd == "report": + return self._build_report() + if cmd == "update": + return self._self_update() + return f"Commande inconnue : /{cmd}" + + def on_broadcast(self, msg: Message): + if "status" in str(msg.payload).lower(): + self.mqtt.reply(msg, self._build_report()) + + def _build_report(self) -> str: + context = AgentContext(self) + lines = [f"── Rapport {self.agent_id} ──"] + stats = self.queue.daily_stats() + lines.append( + f"Tâches : {stats['total']} total / " + f"{stats['completed']} OK / {stats['failed']} erreurs / " + f"durée moy. {stats['avg_duration_s']}s" + ) + try: + uptime = subprocess.check_output("uptime -p", shell=True, text=True).strip() + disk = subprocess.check_output( + "df -h / | tail -1 | awk '{print $3\"/\"$2\" (\"$5\" utilisé)\"}'", + shell=True, text=True + ).strip() + mem = subprocess.check_output( + "free -h | awk '/^Mem:/{print $3\"/\"$2}'", shell=True, text=True + ).strip() + lines.append(f"Uptime : {uptime} | RAM : {mem} | Disque / : {disk}") + except Exception: + pass + return "\n".join(lines) + + def _self_update(self) -> str: + try: + out = subprocess.check_output( + "cd /opt/agent_hal && git pull", + shell=True, text=True, stderr=subprocess.STDOUT + ) + subprocess.Popen(["systemctl", "restart", "agent_hal"]) + return f"Mise à jour effectuée :\n{out}\nRedémarrage en cours..." + except subprocess.CalledProcessError as e: + return f"Erreur mise à jour : {e.output}" + + def _start_monitoring(self): + t = threading.Thread(target=self._monitor_loop, daemon=True) + t.start() + + def _monitor_loop(self): + import time + while self._running: + try: + self._check_disk_usage() + self._check_memory() + except Exception as e: + logger.debug(f"[Monitor] {e}") + time.sleep(300) + + def _check_disk_usage(self): + result = subprocess.run( + "df -h | awk 'NR>1 && $5+0 > 85 {print $0}'", + shell=True, capture_output=True, text=True + ) + if result.stdout.strip(): + self.mqtt.alert( + f"Espace disque critique :\n{result.stdout.strip()}", + severity="critical" + ) + + def _check_memory(self): + result = subprocess.run( + "free | awk '/^Mem:/{if ($3/$2*100 > 90) print \"RAM utilisée à \"int($3/$2*100)\"%\"}'", + shell=True, capture_output=True, text=True + ) + if result.stdout.strip(): + self.mqtt.alert(result.stdout.strip(), severity="warning") + + +if __name__ == "__main__": + AgentHal().run() diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..47c3f12 --- /dev/null +++ b/config/config.json @@ -0,0 +1,30 @@ +{ + "agent_id": "hal", + "xmpp": { + "jid": "hal@xmpp.ovh", + "password": "Matador3721", + "admin_jid": "sylvain@xmpp.ovh", + "muc_room": "agents@muc.xmpp.ovh", + "use_omemo": true + }, + "mqtt": { + "host": "localhost", + "port": 1883, + "username": null, + "password": null, + "tls": false + }, + "llm": { + "base_url": "http://192.168.7.119:11434", + "model": "qwen3:8b", + "temperature": 0.3 + }, + "work_hours": "00:00-23:59", + "queue_db": "/opt/agent_hal/data/queue.db", + "system_prompt": "/opt/agent_hal/config/system_prompt.txt", + "llm_profiles": { + "local": "qwen3:8b", + "code": "qwen2.5-coder:7b" + }, + "use_llm_coordinator": true +} diff --git a/config/system_prompt.txt b/config/system_prompt.txt new file mode 100644 index 0000000..452effb --- /dev/null +++ b/config/system_prompt.txt @@ -0,0 +1,54 @@ +Tu es HAL, un agent de contrôle système et d'édition de code. Tu contrôles pleinement ce serveur et peux agir sur des machines distantes via SSH. +Tu reçois des instructions via MQTT (depuis Nexus) ou XMPP (directement). + +## Tes skills disponibles et quand les utiliser + +### Système +- **sysinfo** : informations système (CPU, RAM, disque, uptime, réseau) +- **apt** : gestion des paquets (install, remove, update, upgrade, search) +- **systemd** : gestion des services (start, stop, restart, status, logs, enable, disable) +- **cron** : tâches planifiées (list, add, remove) +- **process** : processus (list, kill, top, find, tree) +- **network** : réseau (ip, ping, traceroute, DNS, ports, firewall, bandwidth) +- **user** : utilisateurs (add, delete, passwd, groups, sudo, ssh-key) +- **container** : Docker et LXC (ps, start, stop, logs, exec, stats, images) +- **journal** : logs système (tail, service, errors, since, grep, kernel) +- **shell** : commande bash directe (fallback si aucun skill ne convient) + +### Fichiers & Code +- **filesystem** : opérations fichiers (ls, cat, read, write, edit, multiedit, append, delete, mkdir, move, copy, find, grep, df, du, stat, chmod, chown) + - `read` : lire avec numéros de lignes + - `edit` : search & replace dans un fichier + - `multiedit` : plusieurs search & replace en une passe +- **git** : opérations git (status, log, diff, add, commit, push, pull, clone, branch, checkout, init) + +### Distant +- **ssh** : exécuter des commandes sur une machine distante via SSH (password ou clé) + - supporte aussi `COPY` pour transférer des fichiers + +### Web & Utilitaires +- **web_fetch** : récupérer le contenu d'une URL HTTP/HTTPS +- **todo** : liste de tâches en mémoire (add, list, done, delete, clear) +- **script** : créer et exécuter des scripts bash persistants +- **mqtt_send** : envoyer un message à un agent ou topic MQTT +- **agents_status** : voir le statut de tous les agents en temps réel + +## Règles importantes + +1. Utilise toujours le skill le plus spécifique disponible +2. Pour éditer du code, préfère `filesystem edit` ou `filesystem multiedit` plutôt que `shell sed` +3. Après chaque action importante (install, restart, delete, commit), vérifie le résultat +4. Pour les scripts complexes, utilise SKILL:script pour les créer puis les exécuter +5. En cas d'erreur, diagnostique avant de réessayer +6. Réponds toujours en français +7. Sois concis dans tes réponses — l'essentiel, pas tout le stdout brut +8. Pour les opérations git, vérifie toujours le status avant de committer + +## Communication MQTT + +Tu peux envoyer des messages à d'autres agents : + SKILL:mqtt_send ARGS:agents/nexus/inbox | {"type":"result","payload":"mon résultat"} + +Pour les scripts qui doivent retourner un résultat : + Les variables $MQTT_BROKER et $MQTT_REPLY_TOPIC sont disponibles dans l'environnement. + mosquitto_pub -h $MQTT_BROKER -t $MQTT_REPLY_TOPIC -m "résultat" diff --git a/skills/agents_status.py b/skills/agents_status.py new file mode 100644 index 0000000..1e7b2a9 --- /dev/null +++ b/skills/agents_status.py @@ -0,0 +1,28 @@ +""" +Skill AGENTS_STATUS — afficher le statut en temps réel de tous les agents. + +Usage LLM : SKILL:agents_status ARGS: +""" +DESCRIPTION = "Afficher le statut en temps réel de tous les agents (online/offline)" +USAGE = "SKILL:agents_status ARGS:(aucun argument)" + + +def run(args: str, context) -> str: + with context.agent._online_lock: + online = set(context.agent._online_agents) + + all_caps = context.registry.all_agents() + + if not all_caps: + return "Aucun agent connu dans le registre." + + lines = ["── Statut des agents ──────────────────"] + for caps in sorted(all_caps, key=lambda c: c.agent_id): + if caps.agent_id == context.agent_id: + continue # Ne pas s'afficher soi-même + icon = "🟢" if caps.agent_id in online else "🔴" + label = "en ligne" if caps.agent_id in online else "hors ligne" + lines.append(f" {icon} {caps.agent_id} [{caps.agent_type}] — {label}") + lines.append(f" {caps.description}") + + return "\n".join(lines) if len(lines) > 1 else "Aucun autre agent connu." diff --git a/skills/apt.py b/skills/apt.py new file mode 100644 index 0000000..5b9e439 --- /dev/null +++ b/skills/apt.py @@ -0,0 +1,107 @@ +""" +Skill APT — gestion des paquets Debian. + +Usage LLM : + SKILL:apt ARGS:update + SKILL:apt ARGS:upgrade + SKILL:apt ARGS:install [paquet2...] + SKILL:apt ARGS:remove + SKILL:apt ARGS:purge + SKILL:apt ARGS:search + SKILL:apt ARGS:show + SKILL:apt ARGS:list-installed [filtre] + SKILL:apt ARGS:list-upgradable + SKILL:apt ARGS:autoremove + SKILL:apt ARGS:check-updates +""" +import subprocess + +DESCRIPTION = "Gestion des paquets Debian via apt/dpkg" +USAGE = "SKILL:apt ARGS:install | remove | update | upgrade | search | show | list-installed | list-upgradable | autoremove" + +ENV = {"DEBIAN_FRONTEND": "noninteractive", "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"} + + +def _run(cmd: str, timeout: int = 120) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout, env=ENV + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s) — commande trop longue." + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + extra = parts[1] if len(parts) > 1 else "" + + if action == "update": + return _run("apt-get update -q") + + if action == "upgrade": + return _run("apt-get upgrade -y -q", timeout=300) + + if action == "dist-upgrade": + return _run("apt-get dist-upgrade -y -q", timeout=300) + + if action == "install": + if not extra: + return "Précise le(s) paquet(s) à installer." + return _run(f"apt-get install -y -q {extra}", timeout=180) + + if action == "remove": + if not extra: + return "Précise le paquet à supprimer." + return _run(f"apt-get remove -y {extra}") + + if action == "purge": + if not extra: + return "Précise le paquet à purger." + return _run(f"apt-get purge -y {extra}") + + if action == "autoremove": + return _run("apt-get autoremove -y") + + if action == "search": + if not extra: + return "Précise le terme de recherche." + return _run(f"apt-cache search {extra} | head -30") + + if action == "show": + if not extra: + return "Précise le paquet." + return _run(f"apt-cache show {extra}") + + if action in ("list-installed", "list"): + cmd = f"dpkg -l | grep '^ii' | awk '{{print $2\" \"$3}}'" + if extra: + cmd += f" | grep {extra}" + return _run(cmd) + + if action == "list-upgradable": + return _run("apt list --upgradable 2>/dev/null") + + if action == "check-updates": + out = _run("apt-get -s upgrade 2>/dev/null | grep '^[0-9]'") + return out or "Système à jour." + + if action == "hold": + if not extra: + return "Précise le paquet." + return _run(f"apt-mark hold {extra}") + + if action == "unhold": + if not extra: + return "Précise le paquet." + return _run(f"apt-mark unhold {extra}") + + return ( + "Action inconnue. Disponible : update, upgrade, install, remove, purge, " + "search, show, list-installed, list-upgradable, autoremove, check-updates, hold, unhold" + ) diff --git a/skills/container.py b/skills/container.py new file mode 100644 index 0000000..5887f95 --- /dev/null +++ b/skills/container.py @@ -0,0 +1,160 @@ +""" +Skill CONTAINER — gestion des conteneurs Docker et LXC/LXD. + +Usage LLM : + SKILL:container ARGS:docker ps [all] + SKILL:container ARGS:docker start + SKILL:container ARGS:docker stop + SKILL:container ARGS:docker restart + SKILL:container ARGS:docker logs [N] + SKILL:container ARGS:docker stats + SKILL:container ARGS:docker images + SKILL:container ARGS:docker pull + SKILL:container ARGS:docker rm + SKILL:container ARGS:docker rmi + SKILL:container ARGS:docker exec + SKILL:container ARGS:docker inspect + SKILL:container ARGS:lxc list + SKILL:container ARGS:lxc start + SKILL:container ARGS:lxc stop + SKILL:container ARGS:lxc exec + SKILL:container ARGS:lxc info +""" +import subprocess + +DESCRIPTION = "Gestion conteneurs Docker et LXC/LXD : start, stop, logs, exec, stats, images" +USAGE = "SKILL:container ARGS:docker ps|start|stop|restart|logs|exec|stats|images|pull | lxc list|start|stop|exec|info" + + +def _run(cmd: str, timeout: int = 30) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def _docker(action: str, args: list) -> str: + a = args[0] if args else "" + + if action == "ps": + flag = "-a" if (a == "all" or a == "-a") else "" + return _run(f"docker ps {flag} --format 'table {{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}'") + + if action == "start": + return _run(f"docker start {a}") if a else "Précise le nom du conteneur." + + if action == "stop": + return _run(f"docker stop {a}") if a else "Précise le nom du conteneur." + + if action == "restart": + return _run(f"docker restart {a}") if a else "Précise le nom du conteneur." + + if action == "logs": + if not a: + return "Précise le nom du conteneur." + n = args[1] if len(args) > 1 else "50" + return _run(f"docker logs --tail {n} {a}") + + if action == "stats": + return _run("docker stats --no-stream --format 'table {{{{.Name}}}}\\t{{{{.CPUPerc}}}}\\t{{{{.MemUsage}}}}'") + + if action == "images": + return _run("docker images --format 'table {{{{.Repository}}}}\\t{{{{.Tag}}}}\\t{{{{.Size}}}}\\t{{{{.CreatedSince}}}}'") + + if action == "pull": + return _run(f"docker pull {a}", timeout=120) if a else "Précise l'image." + + if action == "rm": + return _run(f"docker rm {a}") if a else "Précise le nom du conteneur." + + if action == "rm-stopped": + return _run("docker container prune -f") + + if action == "rmi": + return _run(f"docker rmi {a}") if a else "Précise l'image." + + if action == "exec": + if len(args) < 2: + return "Format : docker exec " + container = args[0] + cmd = " ".join(args[1:]) + return _run(f"docker exec {container} {cmd}") + + if action == "inspect": + return _run(f"docker inspect {a}") if a else "Précise le nom du conteneur." + + if action == "network": + return _run("docker network ls") + + if action == "volumes": + return _run("docker volume ls") + + if action == "compose-up": + return _run("docker compose up -d", timeout=120) if not a else _run(f"docker compose -f {a} up -d", timeout=120) + + if action == "compose-down": + return _run("docker compose down") + + return f"Action docker inconnue : {action}" + + +def _lxc(action: str, args: list) -> str: + a = args[0] if args else "" + + if action == "list": + return _run("lxc list --format table 2>/dev/null || lxc-ls -f 2>/dev/null") + + if action == "start": + return _run(f"lxc start {a}") if a else "Précise le nom." + + if action == "stop": + return _run(f"lxc stop {a}") if a else "Précise le nom." + + if action == "restart": + return _run(f"lxc restart {a}") if a else "Précise le nom." + + if action == "exec": + if len(args) < 2: + return "Format : lxc exec " + cmd = " ".join(args[1:]) + return _run(f"lxc exec {a} -- {cmd}") + + if action == "info": + return _run(f"lxc info {a}") if a else _run("lxc info") + + if action == "snapshot": + return _run(f"lxc snapshot {a}") if a else "Précise le nom." + + if action == "delete": + return _run(f"lxc delete {a} --force") if a else "Précise le nom." + + return f"Action lxc inconnue : {action}" + + +def run(args: str, context) -> str: + parts = args.strip().split() + if not parts: + return "Précise : docker ou lxc suivi d'une action." + + runtime = parts[0].lower() + action = parts[1].lower() if len(parts) > 1 else "ps" + rest = parts[2:] if len(parts) > 2 else [] + + if runtime == "docker": + return _docker(action, rest) + + if runtime in ("lxc", "lxd"): + return _lxc(action, rest) + + # Tentative de détection auto + if runtime in ("ps", "stats", "images", "logs", "start", "stop", "restart", "exec"): + return _docker(runtime, parts[1:]) + + return "Précise le runtime : docker ou lxc. Ex: SKILL:container ARGS:docker ps" diff --git a/skills/cron.py b/skills/cron.py new file mode 100644 index 0000000..cf8d52a --- /dev/null +++ b/skills/cron.py @@ -0,0 +1,109 @@ +""" +Skill CRON — gestion des tâches cron. + +Usage LLM : + SKILL:cron ARGS:list [utilisateur] + SKILL:cron ARGS:add + SKILL:cron ARGS:remove + SKILL:cron ARGS:system-list +""" +import subprocess +import tempfile +import os + +DESCRIPTION = "Gestion des tâches cron (crontab)" +USAGE = "SKILL:cron ARGS:list | add <* * * * *> | remove | system-list" + + +def _confirm_or_execute(context, description: str, action_fn) -> str: + """Demande confirmation si requête XMPP directe, sinon exécute immédiatement.""" + sender = getattr(context.agent, '_last_xmpp_sender', '') + if not sender: + return action_fn() + context.agent._pending_confirmations[sender] = {"description": description, "fn": action_fn} + return f"⚠️ Confirmation requise :\n{description}\n\nRéponds **oui** pour confirmer ou **non** pour annuler." + + +def _run(cmd: str, timeout: int = 10) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + return (result.stdout + result.stderr).strip() or "(aucune sortie)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "list" + rest = parts[1] if len(parts) > 1 else "" + + if action == "list": + user = rest.strip() or "" + flag = f"-u {user}" if user else "" + result = _run(f"crontab {flag} -l 2>/dev/null") + return result if result else "Crontab vide." + + if action == "add": + words = rest.split() + if len(words) < 6: + return "Format : add \nEx: add 0 3 * * * /usr/bin/apt-get update" + cron_expr = " ".join(words[:5]) + command = " ".join(words[5:]) + entry = f"{cron_expr} {command}" + + current = _run("crontab -l 2>/dev/null") + if entry in current: + return f"Cette entrée existe déjà : {entry}" + + def _do_add(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".cron", delete=False) as f: + if current and "no crontab" not in current.lower(): + f.write(current + "\n") + f.write(entry + "\n") + tmpfile = f.name + out = _run(f"crontab {tmpfile}") + os.unlink(tmpfile) + return f"Entrée ajoutée : {entry}\n{out}" + + return _confirm_or_execute(context, f"Ajouter cron : {entry}", _do_add) + + if action == "remove": + if not rest: + return "Précise le pattern à supprimer." + current = _run("crontab -l 2>/dev/null") + lines = [l for l in current.splitlines() if rest not in l] + removed_count = len(current.splitlines()) - len(lines) + if removed_count == 0: + return f"Aucune entrée contenant '{rest}' trouvée." + + def _do_remove(): + new_cron = "\n".join(lines) + with tempfile.NamedTemporaryFile(mode="w", suffix=".cron", delete=False) as f: + f.write(new_cron + "\n") + tmpfile = f.name + out = _run(f"crontab {tmpfile}") + os.unlink(tmpfile) + return f"{removed_count} entrée(s) supprimée(s) contenant '{rest}'.\n{out}" + + return _confirm_or_execute(context, f"Supprimer {removed_count} cron contenant '{rest}'", _do_remove) + + if action == "clear": + return _confirm_or_execute( + context, + "Effacer TOUT le crontab root (action irréversible)", + lambda: _run("crontab -r 2>/dev/null && echo 'Crontab effacé'") + ) + + if action == "system-list": + # Crons système dans /etc/cron.* + out = [] + for d in ["/etc/cron.d", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"]: + files = _run(f"ls {d} 2>/dev/null") + if files: + out.append(f"{d}:\n{files}") + return "\n\n".join(out) or "Aucun cron système trouvé." + + return "Action inconnue. Disponible : list, add, remove, clear, system-list" diff --git a/skills/filesystem.py b/skills/filesystem.py new file mode 100644 index 0000000..ecd49e0 --- /dev/null +++ b/skills/filesystem.py @@ -0,0 +1,254 @@ +""" +Skill FILESYSTEM — opérations avancées sur le système de fichiers. + +Usage LLM : + SKILL:filesystem ARGS:ls + SKILL:filesystem ARGS:cat + SKILL:filesystem ARGS:read (avec numéros de lignes) + SKILL:filesystem ARGS:write | + SKILL:filesystem ARGS:edit | -> + SKILL:filesystem ARGS:multiedit | -> ;; -> + SKILL:filesystem ARGS:append | + SKILL:filesystem ARGS:delete + SKILL:filesystem ARGS:mkdir + SKILL:filesystem ARGS:move | + SKILL:filesystem ARGS:copy | + SKILL:filesystem ARGS:chmod + SKILL:filesystem ARGS:chown + SKILL:filesystem ARGS:find + SKILL:filesystem ARGS:grep + SKILL:filesystem ARGS:df + SKILL:filesystem ARGS:du + SKILL:filesystem ARGS:stat + SKILL:filesystem ARGS:tail [N] + SKILL:filesystem ARGS:head [N] +""" +import os +import subprocess + +DESCRIPTION = "Opérations filesystem avancées : ls, read, write, edit, multiedit, delete, find, grep, git-style édition" +USAGE = ( + "SKILL:filesystem ARGS:ls | read | write | | " + "edit | -> | multiedit | -> ;; -> | " + "delete | find | grep | df | du " +) + +FORBIDDEN = ["/proc", "/sys", "/dev", "/run/systemd"] + + +def _safe_path(path: str) -> bool: + path = os.path.realpath(path) + return not any(path.startswith(f) for f in FORBIDDEN) + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + rest = parts[1] if len(parts) > 1 else "" + + if action == "ls": + path = rest or "." + return _run(f"ls -lah {path}") + + if action == "cat": + if not rest: + return "Précise le fichier." + if not _safe_path(rest): + return f"Accès refusé : {rest}" + return _run(f"cat {rest}") + + if action == "read": + # Lecture avec numéros de lignes (style SHAI) + if not rest: + return "Précise le fichier." + if not _safe_path(rest): + return f"Accès refusé : {rest}" + try: + with open(rest.strip(), "r", errors="replace") as f: + lines = f.readlines() + numbered = "".join(f"{i+1:4d} {line}" for i, line in enumerate(lines)) + if len(numbered) > 6000: + numbered = numbered[:6000] + f"\n... (tronqué, {len(lines)} lignes total)" + return numbered or "(fichier vide)" + except Exception as e: + return str(e) + + if action == "edit": + # Search & replace : edit | -> + if "|" not in rest: + return "Format : edit | -> " + filepath, change = rest.split("|", 1) + filepath = filepath.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + if " -> " not in change: + return "Format : edit | -> " + old_text, new_text = change.split(" -> ", 1) + old_text = old_text.strip() + new_text = new_text.strip() + try: + with open(filepath, "r", errors="replace") as f: + content = f.read() + if old_text not in content: + return f"Texte non trouvé dans {filepath} : {old_text[:80]!r}" + count = content.count(old_text) + new_content = content.replace(old_text, new_text) + with open(filepath, "w") as f: + f.write(new_content) + return f"Édition OK : {count} remplacement(s) dans {filepath}" + except Exception as e: + return str(e) + + if action == "multiedit": + # Plusieurs search & replace : multiedit | old1 -> new1 ;; old2 -> new2 + if "|" not in rest: + return "Format : multiedit | -> ;; -> " + filepath, changes_str = rest.split("|", 1) + filepath = filepath.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + try: + with open(filepath, "r", errors="replace") as f: + content = f.read() + results = [] + for change in changes_str.split(";;"): + change = change.strip() + if " -> " not in change: + continue + old_text, new_text = change.split(" -> ", 1) + old_text = old_text.strip() + new_text = new_text.strip() + count = content.count(old_text) + if count == 0: + results.append(f"Non trouvé : {old_text[:60]!r}") + else: + content = content.replace(old_text, new_text) + results.append(f"{count} remplacement(s) : {old_text[:40]!r}") + with open(filepath, "w") as f: + f.write(content) + return f"Multiedit OK dans {filepath} :\n" + "\n".join(results) + except Exception as e: + return str(e) + + if action == "tail": + parts2 = rest.split() + filepath = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "50" + return _run(f"tail -n {n} {filepath}") + + if action == "head": + parts2 = rest.split() + filepath = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "30" + return _run(f"head -n {n} {filepath}") + + if action == "write": + if "|" not in rest: + return "Format : write | " + filepath, content = rest.split("|", 1) + filepath = filepath.strip() + content = content.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + try: + os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True) + with open(filepath, "w") as f: + f.write(content) + return f"Fichier écrit : {filepath} ({len(content)} caractères)" + except Exception as e: + return str(e) + + if action == "append": + if "|" not in rest: + return "Format : append | " + filepath, content = rest.split("|", 1) + filepath = filepath.strip() + if not _safe_path(filepath): + return f"Accès refusé : {filepath}" + try: + with open(filepath, "a") as f: + f.write(content.strip() + "\n") + return f"Contenu ajouté à {filepath}" + except Exception as e: + return str(e) + + if action == "delete": + if not rest: + return "Précise le chemin." + if not _safe_path(rest): + return f"Accès refusé : {rest}" + if os.path.isdir(rest): + return _run(f"rm -rf {rest}") + return _run(f"rm -f {rest}") + + if action == "mkdir": + if not rest: + return "Précise le chemin." + return _run(f"mkdir -p {rest}") + + if action == "move": + if "|" not in rest: + return "Format : move | " + src, dst = rest.split("|", 1) + return _run(f"mv {src.strip()} {dst.strip()}") + + if action == "copy": + if "|" not in rest: + return "Format : copy | " + src, dst = rest.split("|", 1) + return _run(f"cp -r {src.strip()} {dst.strip()}") + + if action == "chmod": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : chmod " + return _run(f"chmod {parts2[0]} {parts2[1]}") + + if action == "chown": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : chown " + return _run(f"chown -R {parts2[0]} {parts2[1]}") + + if action == "find": + parts2 = rest.split(None, 1) + path = parts2[0] if parts2 else "." + pattern = parts2[1] if len(parts2) > 1 else "*" + return _run(f"find {path} -name '{pattern}' 2>/dev/null | head -50") + + if action == "grep": + parts2 = rest.split(None, 1) + if len(parts2) < 2: + return "Format : grep " + return _run(f"grep -rn '{parts2[0]}' {parts2[1]} 2>/dev/null | head -50") + + if action == "df": + return _run("df -h") + + if action == "du": + path = rest or "." + return _run(f"du -sh {path}/* 2>/dev/null | sort -rh | head -20") + + if action == "stat": + if not rest: + return "Précise le chemin." + return _run(f"stat {rest}") + + return ( + "Action inconnue. Disponible : ls, cat, read, tail, head, write, append, edit, multiedit, " + "delete, mkdir, move, copy, chmod, chown, find, grep, df, du, stat" + ) diff --git a/skills/git.py b/skills/git.py new file mode 100644 index 0000000..c85ad61 --- /dev/null +++ b/skills/git.py @@ -0,0 +1,160 @@ +""" +Skill GIT — opérations git sur des dépôts locaux. + +Usage LLM : + SKILL:git ARGS:status [chemin] + SKILL:git ARGS:log [chemin] [n] + SKILL:git ARGS:diff [chemin] + SKILL:git ARGS:add | + SKILL:git ARGS:commit | + SKILL:git ARGS:push [chemin] [remote] [branche] + SKILL:git ARGS:pull [chemin] [remote] [branche] + SKILL:git ARGS:clone [destination] + SKILL:git ARGS:branch [chemin] + SKILL:git ARGS:checkout [chemin] + SKILL:git ARGS:init + SKILL:git ARGS:stash [chemin] + SKILL:git ARGS:tag [chemin] +""" +import os +import subprocess + +DESCRIPTION = "Opérations git : status, log, diff, add, commit, push, pull, clone, branch, checkout, init" +USAGE = ( + "SKILL:git ARGS:status [path] | log [path] [n] | diff [path] | " + "add | | commit | | push [path] | pull [path] | " + "clone [dest] | branch [path] | checkout [path] | init " +) + + +def _git(cmd: str, cwd: str = None, timeout: int = 60) -> str: + try: + result = subprocess.run( + f"git {cmd}", shell=True, text=True, + capture_output=True, timeout=timeout, + cwd=cwd + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else f"(code retour : {result.returncode})" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def _find_git_root(path: str) -> str: + """Remonte jusqu'à trouver un dépôt git.""" + try: + result = subprocess.run( + "git rev-parse --show-toplevel", shell=True, text=True, + capture_output=True, cwd=path + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return path + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + rest = parts[1] if len(parts) > 1 else "" + + if action == "status": + path = rest or "." + cwd = _find_git_root(path) + return _git("status -sb", cwd=cwd) + + if action == "log": + parts2 = rest.split() + path = parts2[0] if parts2 else "." + n = parts2[1] if len(parts2) > 1 else "10" + cwd = _find_git_root(path) + return _git(f"log --oneline --graph -n {n}", cwd=cwd) + + if action == "diff": + parts2 = rest.split(None, 1) + path = parts2[0] if parts2 else "." + extra = parts2[1] if len(parts2) > 1 else "" + cwd = _find_git_root(path) + return _git(f"diff {extra}", cwd=cwd) + + if action == "add": + if "|" not in rest: + # Essaie d'interpréter comme "add " + cwd = _find_git_root(rest or ".") + return _git("add -A", cwd=cwd) + repo, files = rest.split("|", 1) + cwd = _find_git_root(repo.strip()) + return _git(f"add {files.strip()}", cwd=cwd) + + if action == "commit": + if "|" not in rest: + return "Format : commit | " + repo, message = rest.split("|", 1) + cwd = _find_git_root(repo.strip()) + msg = message.strip().replace('"', '\\"') + return _git(f'commit -m "{msg}"', cwd=cwd) + + if action == "push": + parts2 = rest.split() + path = parts2[0] if parts2 else "." + remote = parts2[1] if len(parts2) > 1 else "origin" + branch = parts2[2] if len(parts2) > 2 else "" + cwd = _find_git_root(path) + return _git(f"push {remote} {branch}", cwd=cwd, timeout=120) + + if action == "pull": + parts2 = rest.split() + path = parts2[0] if parts2 else "." + remote = parts2[1] if len(parts2) > 1 else "origin" + branch = parts2[2] if len(parts2) > 2 else "" + cwd = _find_git_root(path) + return _git(f"pull {remote} {branch}", cwd=cwd, timeout=120) + + if action == "clone": + parts2 = rest.split(None, 1) + if not parts2: + return "Format : clone [destination]" + url = parts2[0] + dest = parts2[1] if len(parts2) > 1 else "" + return _git(f"clone {url} {dest}", timeout=180) + + if action == "branch": + path = rest or "." + cwd = _find_git_root(path) + return _git("branch -a", cwd=cwd) + + if action == "checkout": + parts2 = rest.split() + if not parts2: + return "Format : checkout [chemin]" + branch = parts2[0] + path = parts2[1] if len(parts2) > 1 else "." + cwd = _find_git_root(path) + return _git(f"checkout {branch}", cwd=cwd) + + if action == "init": + path = rest or "." + os.makedirs(path, exist_ok=True) + return _git("init", cwd=path) + + if action == "stash": + path = rest or "." + cwd = _find_git_root(path) + return _git("stash", cwd=cwd) + + if action == "tag": + parts2 = rest.split() + if not parts2: + return "Format : tag [chemin]" + name = parts2[0] + path = parts2[1] if len(parts2) > 1 else "." + cwd = _find_git_root(path) + return _git(f"tag {name}", cwd=cwd) + + return ( + "Action inconnue. Disponible : status, log, diff, add, commit, push, pull, " + "clone, branch, checkout, init, stash, tag" + ) diff --git a/skills/journal.py b/skills/journal.py new file mode 100644 index 0000000..bb48b21 --- /dev/null +++ b/skills/journal.py @@ -0,0 +1,108 @@ +""" +Skill JOURNAL — consultation des logs système via journalctl. + +Usage LLM : + SKILL:journal ARGS:tail [N] + SKILL:journal ARGS:service [N] + SKILL:journal ARGS:boot [N] + SKILL:journal ARGS:errors [N] + SKILL:journal ARGS:since (ex: "1h", "30min", "yesterday") + SKILL:journal ARGS:grep [service] + SKILL:journal ARGS:kernel [N] + SKILL:journal ARGS:disk-usage +""" +import subprocess + +DESCRIPTION = "Consultation des logs système via journalctl et /var/log" +USAGE = "SKILL:journal ARGS:tail [N] | service [N] | errors [N] | since | grep | boot | kernel" + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "tail" + rest = parts[1] if len(parts) > 1 else "" + + if action == "tail": + n = rest.strip() or "50" + return _run(f"journalctl -n {n} --no-pager -o short-iso") + + if action == "service": + parts2 = rest.split() + service = parts2[0] if parts2 else "" + n = parts2[1] if len(parts2) > 1 else "50" + if not service: + return "Précise le service." + return _run(f"journalctl -u {service} -n {n} --no-pager -o short-iso") + + if action == "boot": + n = rest.strip() or "50" + return _run(f"journalctl -b -n {n} --no-pager -o short-iso") + + if action == "errors": + n = rest.strip() or "30" + return _run(f"journalctl -p err..emerg -n {n} --no-pager -o short-iso") + + if action == "warnings": + n = rest.strip() or "30" + return _run(f"journalctl -p warning..emerg -n {n} --no-pager -o short-iso") + + if action == "since": + if not rest: + return "Précise la durée (ex: '1h', '30min', 'yesterday', '2024-01-01')" + # Convertit "1h" → "1 hour ago", "30min" → "30 minutes ago" + since = rest.strip() + if since.endswith("h") and since[:-1].isdigit(): + since = f"{since[:-1]} hours ago" + elif since.endswith("min") and since[:-3].isdigit(): + since = f"{since[:-3]} minutes ago" + return _run(f"journalctl --since='{since}' --no-pager -o short-iso | tail -100") + + if action == "grep": + parts2 = rest.split(None, 1) + pattern = parts2[0] if parts2 else "" + service = parts2[1].strip() if len(parts2) > 1 else "" + if not pattern: + return "Précise le pattern." + cmd = f"journalctl --no-pager -o short-iso" + if service: + cmd += f" -u {service}" + cmd += f" | grep -i '{pattern}' | tail -50" + return _run(cmd) + + if action == "kernel": + n = rest.strip() or "30" + return _run(f"journalctl -k -n {n} --no-pager -o short-iso") + + if action == "disk-usage": + return _run("journalctl --disk-usage") + + if action == "vacuum": + # Nettoyage des vieux logs + size = rest.strip() or "500M" + return _run(f"journalctl --vacuum-size={size}") + + if action == "file": + # Lire un fichier de log classique + filepath = rest.strip() + if not filepath: + return "Précise le fichier." + return _run(f"tail -100 {filepath}") + + return ( + "Action inconnue. Disponible : tail, service, boot, errors, warnings, " + "since, grep, kernel, disk-usage, vacuum, file" + ) diff --git a/skills/mqtt_send.py b/skills/mqtt_send.py new file mode 100644 index 0000000..1ed7dd9 --- /dev/null +++ b/skills/mqtt_send.py @@ -0,0 +1,23 @@ +""" +Skill MQTT_SEND — publier un message sur n'importe quel topic MQTT. +Permet à l'agent de communiquer proactivement avec d'autres agents. + +Usage LLM : SKILL:mqtt_send ARGS: | +""" +DESCRIPTION = "Publier un message sur un topic MQTT (communication inter-agents)" +USAGE = "SKILL:mqtt_send ARGS: | " + + +def run(args: str, context) -> str: + if "|" not in args: + return "Format : SKILL:mqtt_send ARGS: | " + + topic, message = args.split("|", 1) + topic = topic.strip() + message = message.strip() + + if not topic: + return "Topic vide." + + context.mqtt.publish_raw(topic, message) + return f"Message publié sur '{topic}'." diff --git a/skills/mqtt_subscribe.py b/skills/mqtt_subscribe.py new file mode 100644 index 0000000..0b0c148 --- /dev/null +++ b/skills/mqtt_subscribe.py @@ -0,0 +1,59 @@ +""" +Skill MQTT_SUBSCRIBE — s'abonner dynamiquement à un topic MQTT. + +Les messages reçus sont transmis via XMPP (admin) et loggés. + +Usage LLM : + SKILL:mqtt_subscribe ARGS:subscribe | + SKILL:mqtt_subscribe ARGS:unsubscribe | + SKILL:mqtt_subscribe ARGS:list +""" +import logging + +DESCRIPTION = "S'abonner / se désabonner dynamiquement d'un topic MQTT et recevoir les messages" +USAGE = "SKILL:mqtt_subscribe ARGS:subscribe| ou unsubscribe| ou list" + +logger = logging.getLogger(__name__) + +# Stockage des souscriptions dynamiques : {topic: callback} +_dynamic_subs: dict = {} + + +def run(args: str, context) -> str: + parts = [p.strip() for p in args.split("|", 1)] + action = parts[0].lower() + + if action == "list": + if not _dynamic_subs: + return "Aucun topic MQTT surveillé." + return "Topics surveillés :\n" + "\n".join(f" • {t}" for t in _dynamic_subs) + + if len(parts) < 2 or not parts[1]: + return "Format : subscribe| ou unsubscribe| ou list" + + topic = parts[1] + + if action == "unsubscribe": + if topic in _dynamic_subs: + del _dynamic_subs[topic] + return f"Désabonné du topic '{topic}'." + return f"Pas abonné à '{topic}'." + + if action == "subscribe": + if topic in _dynamic_subs: + return f"Déjà abonné à '{topic}'." + + agent_id = context.agent_id + + def _on_message(msg, t): + payload = msg.payload if hasattr(msg, "payload") else str(msg) + text = f"[MQTT:{t}] {payload}" + logger.info(f"[mqtt_subscribe] {text}") + if context.xmpp: + context.xmpp.send_to_all_admins(text) + + _dynamic_subs[topic] = _on_message + context.mqtt.subscribe(topic, _on_message) + return f"Abonné au topic '{topic}'. Les messages seront transmis via XMPP." + + return f"Action inconnue '{action}'. Utilise : subscribe, unsubscribe, list." diff --git a/skills/muc_send.py b/skills/muc_send.py new file mode 100644 index 0000000..799edbe --- /dev/null +++ b/skills/muc_send.py @@ -0,0 +1,24 @@ +""" +Skill MUC_SEND — envoyer un message dans le groupe XMPP des agents. + +Le groupe est agents@muc.xmpp.ovh (configuré dans config.json). + +Usage LLM : SKILL:muc_send ARGS: +""" +DESCRIPTION = "Envoyer un message dans le groupe XMPP des agents (MUC)" +USAGE = "SKILL:muc_send ARGS:" + + +def run(args: str, context) -> str: + message = args.strip() + if not message: + return "Message vide." + + if not context.xmpp: + return "XMPP non configuré sur cet agent." + + if not context.xmpp.muc_room: + return "Aucun groupe MUC configuré." + + context.xmpp.send_to_group(message) + return f"Message envoyé dans le groupe {context.xmpp.muc_room}." diff --git a/skills/network.py b/skills/network.py new file mode 100644 index 0000000..20ef2e4 --- /dev/null +++ b/skills/network.py @@ -0,0 +1,179 @@ +""" +Skill NETWORK — administration réseau. + +Usage LLM : + SKILL:network ARGS:ip [show|route|link] + SKILL:network ARGS:ping [count] + SKILL:network ARGS:traceroute + SKILL:network ARGS:dns + SKILL:network ARGS:ports [tcp|udp] + SKILL:network ARGS:connections + SKILL:network ARGS:firewall status + SKILL:network ARGS:firewall allow + SKILL:network ARGS:firewall deny + SKILL:network ARGS:firewall delete + SKILL:network ARGS:firewall list + SKILL:network ARGS:bandwidth [interface] + SKILL:network ARGS:hosts [add|remove] + SKILL:network ARGS:wget + SKILL:network ARGS:curl +""" +import subprocess + +DESCRIPTION = "Administration réseau : ip, ping, traceroute, DNS, ports, firewall ufw/iptables" +USAGE = "SKILL:network ARGS:ip | ping | traceroute | dns | ports | connections | firewall status|allow|deny|list | wget " + + +def _run(cmd: str, timeout: int = 20) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + rest = parts[1] if len(parts) > 1 else "" + + if action == "ip": + sub = rest.strip().lower() or "show" + if sub == "show" or sub == "addr": + return _run("ip -br addr show") + if sub == "route": + return _run("ip route show") + if sub == "link": + return _run("ip -br link show") + if sub == "full": + return _run("ip addr show") + return _run(f"ip {rest}") + + if action == "ping": + parts2 = rest.split() + host = parts2[0] if parts2 else "" + count = parts2[1] if len(parts2) > 1 else "4" + if not host: + return "Précise l'hôte." + return _run(f"ping -c {count} {host}", timeout=int(count) * 3 + 5) + + if action == "traceroute": + host = rest.strip() + if not host: + return "Précise l'hôte." + return _run(f"traceroute -m 15 {host}", timeout=60) + + if action == "dns": + host = rest.strip() + if not host: + return "Précise le nom à résoudre." + return _run(f"dig +short {host} && dig +short -x $(dig +short {host} | head -1) 2>/dev/null || nslookup {host}") + + if action == "ports": + proto = rest.strip().lower() + if proto == "udp": + return _run("ss -ulnp") + return _run("ss -tlnp") + + if action == "connections": + return _run("ss -tunp | head -50") + + if action == "netstat": + return _run("ss -s") + + if action == "firewall": + parts2 = rest.split(None, 1) + sub = parts2[0].lower() if parts2 else "status" + arg = parts2[1] if len(parts2) > 1 else "" + + # Détecte ufw ou iptables + ufw_available = _run("which ufw") != "" + + if sub == "status": + if ufw_available: + return _run("ufw status verbose") + return _run("iptables -L -n -v --line-numbers") + + if sub == "allow": + if not arg: + return "Précise le port/service." + if ufw_available: + return _run(f"ufw allow {arg}") + return _run(f"iptables -A INPUT -p tcp --dport {arg} -j ACCEPT") + + if sub == "deny": + if not arg: + return "Précise le port/service." + if ufw_available: + return _run(f"ufw deny {arg}") + return _run(f"iptables -A INPUT -p tcp --dport {arg} -j DROP") + + if sub == "delete": + if not arg: + return "Précise la règle ou le numéro." + if ufw_available: + return _run(f"ufw delete {arg}") + return _run(f"iptables -D INPUT {arg}") + + if sub == "list": + if ufw_available: + return _run("ufw status numbered") + return _run("iptables -L INPUT -n -v --line-numbers") + + if sub == "enable": + return _run("ufw --force enable") if ufw_available else "ufw non disponible." + + if sub == "disable": + return _run("ufw disable") if ufw_available else "ufw non disponible." + + return f"Sous-commande firewall inconnue : {sub}" + + if action == "bandwidth": + iface = rest.strip() or "eth0" + # ifstat ou /proc/net/dev si pas dispo + result = _run(f"cat /proc/net/dev | grep {iface}") + if not result: + return f"Interface {iface} introuvable." + return result + + if action == "hosts": + parts2 = rest.split() + sub = parts2[0].lower() if parts2 else "list" + if sub == "list" or not parts2: + return _run("cat /etc/hosts") + if sub == "add" and len(parts2) >= 3: + ip, name = parts2[1], parts2[2] + return _run(f"echo '{ip} {name}' >> /etc/hosts && echo 'Ajouté : {ip} {name}'") + if sub == "remove" and len(parts2) >= 2: + name = parts2[1] + return _run(f"sed -i '/{name}/d' /etc/hosts && echo 'Supprimé : {name}'") + return "Usage : hosts list | add | remove " + + if action == "wget": + url = rest.strip() + if not url: + return "Précise l'URL." + return _run(f"wget -q --spider {url} && echo 'URL accessible' || echo 'URL inaccessible'") + + if action == "curl": + url = rest.strip() + if not url: + return "Précise l'URL." + return _run(f"curl -sI {url} | head -10") + + if action == "arp": + return _run("arp -n") + + if action == "hostname": + return _run("hostname -f") + + return ( + "Action inconnue. Disponible : ip, ping, traceroute, dns, ports, connections, " + "firewall, hosts, wget, curl, arp, hostname, netstat" + ) diff --git a/skills/process.py b/skills/process.py new file mode 100644 index 0000000..ecfba66 --- /dev/null +++ b/skills/process.py @@ -0,0 +1,99 @@ +""" +Skill PROCESS — gestion des processus. + +Usage LLM : + SKILL:process ARGS:list [filtre] + SKILL:process ARGS:top [N] + SKILL:process ARGS:kill [signal] + SKILL:process ARGS:killall + SKILL:process ARGS:nice + SKILL:process ARGS:info + SKILL:process ARGS:tree + SKILL:process ARGS:find +""" +import subprocess + +DESCRIPTION = "Gestion des processus : list, top, kill, killall, nice, find" +USAGE = "SKILL:process ARGS:list [filtre] | top [N] | kill [signal] | killall | info | tree | find " + + +def _run(cmd: str, timeout: int = 10) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "list" + rest = parts[1] if len(parts) > 1 else "" + + if action == "list": + cmd = "ps aux --sort=-%cpu | head -30" + if rest: + cmd = f"ps aux | grep -i {rest} | grep -v grep" + return _run(cmd) + + if action == "top": + n = rest.strip() or "15" + return _run( + f"ps aux --sort=-%cpu | head -{n} | " + "awk 'NR==1{print} NR>1{printf \"%-10s %-6s %-5s %-5s %s\\n\",$1,$2,$3,$4,$11}'" + ) + + if action == "kill": + parts2 = rest.split() + if not parts2: + return "Précise le PID." + pid = parts2[0] + signal = parts2[1] if len(parts2) > 1 else "15" + return _run(f"kill -{signal} {pid}") + + if action == "kill9": + if not rest: + return "Précise le PID." + return _run(f"kill -9 {rest.strip()}") + + if action == "killall": + if not rest: + return "Précise le nom du processus." + return _run(f"killall {rest.strip()}") + + if action == "nice": + parts2 = rest.split() + if len(parts2) < 2: + return "Format : nice " + pid, prio = parts2[0], parts2[1] + return _run(f"renice {prio} -p {pid}") + + if action == "info": + if not rest: + return "Précise le PID." + pid = rest.strip() + out = _run(f"ps -p {pid} -o pid,ppid,user,%cpu,%mem,vsz,rss,stat,start,time,comm --no-headers") + cmdline = _run(f"cat /proc/{pid}/cmdline 2>/dev/null | tr '\\0' ' '") + return f"Process {pid}:\n{out}\nCmdline: {cmdline}" + + if action == "tree": + return _run("pstree -p | head -50") + + if action == "find": + if not rest: + return "Précise le nom du processus." + return _run(f"pgrep -a -i {rest.strip()}") + + if action == "lsof": + # Fichiers ouverts par un processus + if rest: + return _run(f"lsof -p {rest.strip()} | head -30") + return _run("lsof | wc -l && echo fichiers ouverts au total") + + return "Action inconnue. Disponible : list, top, kill, kill9, killall, nice, info, tree, find, lsof" diff --git a/skills/script.py b/skills/script.py new file mode 100644 index 0000000..112307a --- /dev/null +++ b/skills/script.py @@ -0,0 +1,251 @@ +""" +Skill SCRIPT — bibliothèque de scripts bash par agent. + +Chaque agent dispose de son propre dossier scripts/ (configurable via +"scripts_dir" dans config.json, sinon /opt//scripts). + +L'environnement du script expose automatiquement : + MQTT_BROKER, MQTT_PORT, MQTT_REPLY_TOPIC, AGENT_ID, SCRIPTS_DIR + +Ainsi un script peut publier son résultat directement : + mosquitto_pub -h $MQTT_BROKER -t $MQTT_REPLY_TOPIC -m "mon résultat" + +Usage LLM : + SKILL:script ARGS:list + SKILL:script ARGS:show + SKILL:script ARGS:save | + SKILL:script ARGS:edit | + SKILL:script ARGS:exec [args...] + SKILL:script ARGS:run | + SKILL:script ARGS:delete +""" +import json +import os +import stat +import subprocess +import tempfile +from datetime import datetime + +DESCRIPTION = "Bibliothèque de scripts bash : sauvegarder, lister, afficher, éditer, exécuter" +USAGE = ( + "SKILL:script ARGS:list\n" + "SKILL:script ARGS:show \n" + "SKILL:script ARGS:save | \n" + "SKILL:script ARGS:edit | \n" + "SKILL:script ARGS:exec [args]\n" + "SKILL:script ARGS:run | \n" + "SKILL:script ARGS:delete " +) + + +def _scripts_dir(context) -> str: + """Détermine le répertoire scripts de cet agent.""" + if context.config.get("scripts_dir"): + return context.config["scripts_dir"] + queue_db = context.config.get("queue_db", "") + if queue_db: + install = os.path.dirname(os.path.dirname(queue_db)) + return os.path.join(install, "scripts") + return f"/opt/{context.agent_id}/scripts" + + +def _ensure_dir(context) -> str: + d = _scripts_dir(context) + os.makedirs(d, exist_ok=True) + return d + + +_FORBIDDEN_EXTENSIONS = {".service", ".timer", ".socket", ".target", ".mount", ".conf", ".py", ".js"} + + +def _safe_name(name: str) -> str: + """Empêche les traversées de répertoire et normalise le nom.""" + n = os.path.basename(name.strip().replace("/", "_")) + # Retire toute extension connue pour obtenir le nom brut + root, ext = os.path.splitext(n) + while ext: + n = root + root, ext = os.path.splitext(n) + return n + + +def _build_env(context, scripts_dir: str) -> dict: + env = os.environ.copy() + mc = context.config.get("mqtt", {}) + env["MQTT_BROKER"] = mc.get("host", "localhost") + env["MQTT_PORT"] = str(mc.get("port", 1883)) + env["MQTT_REPLY_TOPIC"] = "agents/nexus/inbox" + env["AGENT_ID"] = context.agent_id + env["SCRIPTS_DIR"] = scripts_dir + return env + + +def _notify(context, script_name: str, result: str): + """Publie un événement d'exécution sur MQTT pour que Nexus notifie l'utilisateur.""" + try: + context.mqtt.publish_raw("agents/scripts/execution", json.dumps({ + "agent_id": context.agent_id, + "script": script_name, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "result": result[:1000], + })) + except Exception: + pass + + +def _run_script(cmd: str, env: dict, timeout: int = 120) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout, + env=env, executable="/bin/bash", + ) + out = (result.stdout + result.stderr).strip() + if len(out) > 4000: + out = out[:4000] + "\n... [tronqué]" + return out or f"(code retour : {result.returncode})" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s dépassé)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "list" + rest = parts[1] if len(parts) > 1 else "" + + # ── list ────────────────────────────────────────────────────────────── + if action == "list": + d = _ensure_dir(context) + files = sorted(f for f in os.listdir(d) if f.endswith(".sh")) + if not files: + return f"Aucun script dans {d}" + lines = [f"Scripts disponibles ({d}) :"] + for f in files: + path = os.path.join(d, f) + size = os.path.getsize(path) + lines.append(f" {f[:-3]:30s} ({size} octets)") + return "\n".join(lines) + + # ── show ────────────────────────────────────────────────────────────── + if action == "show": + name = _safe_name(rest) + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + with open(path) as f: + content = f.read() + return f"── {name}.sh ──\n{content}" + + # ── save ────────────────────────────────────────────────────────────── + if action == "save": + if "|" not in rest: + return "Format : save | " + name_raw, content = rest.split("|", 1) + name = _safe_name(name_raw) + content = content.strip().replace("\\n", "\n") + + if not name: + return "Nom de script invalide." + + # Vérifie extension interdite sur le nom brut + _, raw_ext = os.path.splitext(name_raw.strip()) + if raw_ext.lower() in _FORBIDDEN_EXTENSIONS: + return f"Extension '{raw_ext}' interdite. Utilise un nom sans extension (ex: mon_script)." + + # Vérifie que le contenu est substantiel (pas juste un shebang ou vide) + lines = [l.strip() for l in content.splitlines() if l.strip() and not l.strip().startswith("#")] + if len(lines) < 1: + return "Contenu du script vide ou incomplet. Fournis au moins une commande." + + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + existed = os.path.exists(path) + with open(path, "w") as f: + if not content.startswith("#!"): + f.write("#!/bin/bash\n") + f.write(content + "\n") + os.chmod(path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IROTH) + verb = "mis à jour" if existed else "créé" + return f"Script '{name}' {verb} : {path}" + + # ── edit ────────────────────────────────────────────────────────────── + if action == "edit": + # Format : edit | + if "|" not in rest: + return "Format : edit | \nEx: edit mon_script 3 | echo 'nouveau'" + head, new_line_content = rest.split("|", 1) + head_parts = head.strip().split(None, 1) + if len(head_parts) < 2: + return "Format : edit | " + name = _safe_name(head_parts[0]) + try: + line_no = int(head_parts[1].strip()) + except ValueError: + return "Le numéro de ligne doit être un entier." + if line_no < 1: + return "Le numéro de ligne doit être >= 1." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + with open(path) as f: + lines = f.readlines() + if line_no > len(lines): + return f"Le script '{name}' n'a que {len(lines)} lignes." + lines[line_no - 1] = new_line_content.strip() + "\n" + with open(path, "w") as f: + f.writelines(lines) + return f"Ligne {line_no} du script '{name}' modifiée.\nNouveau contenu :\n{''.join(lines)}" + + # ── exec ────────────────────────────────────────────────────────────── + if action == "exec": + parts2 = rest.split(None, 1) + name = _safe_name(parts2[0]) if parts2 else "" + sargs = parts2[1] if len(parts2) > 1 else "" + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable. Utilise 'list' pour voir les scripts disponibles." + env = _build_env(context, d) + out = _run_script(f'"{path}" {sargs}', env=env, timeout=120) + _notify(context, name, out) + return out + + # ── run (inline) ────────────────────────────────────────────────────── + if action == "run": + if not rest: + return "Précise le contenu du script." + d = _ensure_dir(context) + content = rest.replace("\\n", "\n") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False, dir="/tmp" + ) as f: + f.write("#!/bin/bash\nset -e\n" + content) + tmpfile = f.name + os.chmod(tmpfile, stat.S_IRWXU) + env = _build_env(context, d) + out = _run_script(tmpfile, env=env, timeout=60) + os.unlink(tmpfile) + _notify(context, "", out) + return out + + # ── delete ──────────────────────────────────────────────────────────── + if action == "delete": + name = _safe_name(rest) + if not name: + return "Précise le nom du script." + d = _ensure_dir(context) + path = os.path.join(d, name + ".sh") + if not os.path.exists(path): + return f"Script '{name}' introuvable dans {d}" + os.unlink(path) + return f"Script '{name}' supprimé." + + return "Action inconnue. Disponible : list, show, save, exec, run, delete" diff --git a/skills/shell.py b/skills/shell.py new file mode 100644 index 0000000..15f0846 --- /dev/null +++ b/skills/shell.py @@ -0,0 +1,62 @@ +""" +Skill SHELL — exécution de commandes shell arbitraires. +Skill de dernier recours quand aucun skill spécialisé ne convient. + +Usage LLM : SKILL:shell ARGS: +""" +import subprocess + +DESCRIPTION = "Exécution de commandes shell arbitraires (fallback général)" +USAGE = "SKILL:shell ARGS:" + +# Commandes bloquées pour éviter les accidents critiques +BLOCKED = [ + "rm -rf /", + "dd if=/dev/zero of=/dev/", + "mkfs", + "> /dev/sda", + ":(){ :|:& };:", # fork bomb +] + + +def run(args: str, context) -> str: + cmd = args.strip() + if not cmd: + return "Commande vide." + + # Vérification des commandes dangereuses + for blocked in BLOCKED: + if blocked in cmd: + return f"Commande bloquée pour sécurité : {blocked}" + + try: + result = subprocess.run( + cmd, + shell=True, + text=True, + capture_output=True, + timeout=60, + executable="/bin/bash", + ) + stdout = result.stdout.strip() + stderr = result.stderr.strip() + returncode = result.returncode + + output = "" + if stdout: + output += stdout + if stderr: + output += ("\n" if output else "") + f"[stderr] {stderr}" + if not output: + output = f"(Commande exécutée, code retour : {returncode})" + + # Tronqué à 4000 caractères + if len(output) > 4000: + output = output[:4000] + f"\n... [tronqué, {len(output)} caractères total]" + + return output + + except subprocess.TimeoutExpired: + return "Timeout (60s) — commande trop longue." + except Exception as e: + return f"Erreur : {e}" diff --git a/skills/ssh.py b/skills/ssh.py new file mode 100644 index 0000000..256cc4c --- /dev/null +++ b/skills/ssh.py @@ -0,0 +1,86 @@ +""" +Skill SSH — exécuter des commandes sur une machine distante via SSH. + +Usage LLM : + SKILL:ssh ARGS: password | + SKILL:ssh ARGS: key | + SKILL:ssh ARGS:copy password | +""" +import subprocess + +DESCRIPTION = "Exécuter des commandes SSH sur une machine distante" +USAGE = "SKILL:ssh ARGS: password | ou key | " + + +def _ssh_cmd(host: str, user: str, auth: str, credential: str, cmd: str, timeout: int = 30) -> str: + if auth == "password": + full_cmd = ( + f"sshpass -p '{credential}' ssh " + f"-o StrictHostKeyChecking=no " + f"-o ConnectTimeout=10 " + f"{user}@{host} '{cmd}'" + ) + else: + full_cmd = ( + f"ssh -i {credential} " + f"-o StrictHostKeyChecking=no " + f"-o ConnectTimeout=10 " + f"{user}@{host} '{cmd}'" + ) + try: + result = subprocess.run( + full_cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else f"(code retour : {result.returncode})" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + if "|" not in args: + return "Format : password|key | " + + left, command = args.split("|", 1) + command = command.strip() + parts = left.strip().split() + + if len(parts) < 4: + return "Format : password|key | " + + host, user, auth, credential = parts[0], parts[1], parts[2], parts[3] + + if auth not in ("password", "key"): + return "Auth doit être 'password' ou 'key'" + + # Sous-commande copy + if command.startswith("COPY "): + file_parts = command[5:].split() + if len(file_parts) < 2: + return "Format copy : COPY " + local_file, remote_path = file_parts[0], file_parts[1] + if auth == "password": + scp_cmd = ( + f"sshpass -p '{credential}' scp " + f"-o StrictHostKeyChecking=no " + f"{local_file} {user}@{host}:{remote_path}" + ) + else: + scp_cmd = ( + f"scp -i {credential} " + f"-o StrictHostKeyChecking=no " + f"{local_file} {user}@{host}:{remote_path}" + ) + try: + result = subprocess.run( + scp_cmd, shell=True, text=True, + capture_output=True, timeout=60 + ) + return (result.stdout + result.stderr).strip() or f"Fichier copié vers {host}:{remote_path}" + except Exception as e: + return str(e) + + return _ssh_cmd(host, user, auth, credential, command) diff --git a/skills/sysinfo.py b/skills/sysinfo.py new file mode 100644 index 0000000..837881c --- /dev/null +++ b/skills/sysinfo.py @@ -0,0 +1,63 @@ +""" +Skill SYSINFO — informations système complètes. + +Usage LLM : SKILL:sysinfo ARGS:all | cpu | mem | disk | uptime | load | net +""" +import subprocess + +DESCRIPTION = "Informations système : CPU, RAM, disque, uptime, charge, réseau" +USAGE = "SKILL:sysinfo ARGS:all | cpu | mem | disk | uptime | load | net" + + +def _run(cmd: str) -> str: + try: + return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.STDOUT).strip() + except subprocess.CalledProcessError as e: + return e.output.strip() or str(e) + + +def run(args: str, context) -> str: + what = args.strip().lower() or "all" + + sections = [] + + if what in ("all", "uptime"): + uptime = _run("uptime -p") + since = _run("uptime -s") + sections.append(f"Uptime : {uptime} (depuis {since})") + + if what in ("all", "load"): + load = _run("cat /proc/loadavg") + cpus = _run("nproc") + sections.append(f"Charge système : {load} ({cpus} CPU)") + + if what in ("all", "cpu"): + cpu_info = _run("lscpu | grep -E 'Model name|CPU\\(s\\)|MHz'") + cpu_usage = _run( + "top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4\"%\"}'" + ) + sections.append(f"CPU :\n{cpu_info}\nUtilisation : {cpu_usage}") + + if what in ("all", "mem"): + mem = _run("free -h") + sections.append(f"Mémoire :\n{mem}") + + if what in ("all", "disk"): + disk = _run("df -h --output=source,size,used,avail,pcent,target | column -t") + sections.append(f"Disques :\n{disk}") + + if what in ("all", "net"): + ifaces = _run("ip -br addr show") + sections.append(f"Interfaces réseau :\n{ifaces}") + + if what == "os": + osinfo = _run("cat /etc/os-release | grep -E '^(NAME|VERSION)='") + kernel = _run("uname -r") + sections.append(f"OS :\n{osinfo}\nKernel : {kernel}") + + if not sections: + return ( + "Option inconnue. Utilise : all, cpu, mem, disk, uptime, load, net, os" + ) + + return "\n\n".join(sections) diff --git a/skills/systemd.py b/skills/systemd.py new file mode 100644 index 0000000..373c5a7 --- /dev/null +++ b/skills/systemd.py @@ -0,0 +1,166 @@ +""" +Skill SYSTEMD — gestion des services systemd. + +Usage LLM : + SKILL:systemd ARGS:status + SKILL:systemd ARGS:start + SKILL:systemd ARGS:stop + SKILL:systemd ARGS:restart + SKILL:systemd ARGS:reload + SKILL:systemd ARGS:enable + SKILL:systemd ARGS:disable + SKILL:systemd ARGS:logs [lignes] + SKILL:systemd ARGS:list [pattern] + SKILL:systemd ARGS:failed + SKILL:systemd ARGS:daemon-reload +""" +import subprocess + +DESCRIPTION = "Gestion des services systemd (start/stop/restart/status/logs/enable/disable)" +USAGE = "SKILL:systemd ARGS:status | start | stop | restart | enable | disable | logs [N] | list | failed" + +# Actions destructives qui nécessitent confirmation (lecture seule = pas de confirmation) +_ACTIONS_REQUIRING_CONFIRMATION = {"start", "stop", "restart", "enable", "disable", "mask", "unmask", "daemon-reload"} + + +def _confirm_or_execute(context, description: str, action_fn) -> str: + """Demande confirmation si requête XMPP directe, sinon exécute immédiatement.""" + sender = getattr(context.agent, '_last_xmpp_sender', '') + if not sender: + return action_fn() + context.agent._pending_confirmations[sender] = {"description": description, "fn": action_fn} + return f"⚠️ Confirmation requise :\n{description}\n\nRéponds **oui** pour confirmer ou **non** pour annuler." + + +def _run(cmd: str, timeout: int = 30) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:4000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split() + action = parts[0].lower() if parts else "" + service = parts[1] if len(parts) > 1 else "" + + if action == "status": + if not service: + return "Précise le service." + return _run(f"systemctl status {service} --no-pager -l") + + if action == "start": + if not service: + return "Précise le service." + def _do(): + out = _run(f"systemctl start {service}") + status = _run(f"systemctl is-active {service}") + return f"Démarrage de {service}... Statut : {status}\n{out}" + return _confirm_or_execute(context, f"Démarrer le service : {service}", _do) + + if action == "stop": + if not service: + return "Précise le service." + def _do(): + out = _run(f"systemctl stop {service}") + status = _run(f"systemctl is-active {service}") + return f"Arrêt de {service}... Statut : {status}\n{out}" + return _confirm_or_execute(context, f"Arrêter le service : {service}", _do) + + if action == "restart": + if not service: + return "Précise le service." + def _do(): + out = _run(f"systemctl restart {service}") + status = _run(f"systemctl is-active {service}") + return f"Redémarrage de {service}... Statut : {status}\n{out}" + return _confirm_or_execute(context, f"Redémarrer le service : {service}", _do) + + if action == "reload": + if not service: + return "Précise le service." + return _run(f"systemctl reload {service}") + + if action == "enable": + if not service: + return "Précise le service." + return _confirm_or_execute( + context, f"Activer au démarrage : {service}", + lambda: _run(f"systemctl enable {service}") + ) + + if action == "disable": + if not service: + return "Précise le service." + return _confirm_or_execute( + context, f"Désactiver au démarrage : {service}", + lambda: _run(f"systemctl disable {service}") + ) + + if action == "mask": + if not service: + return "Précise le service." + return _confirm_or_execute( + context, f"Masquer (bloquer) le service : {service}", + lambda: _run(f"systemctl mask {service}") + ) + + if action == "unmask": + if not service: + return "Précise le service." + return _confirm_or_execute( + context, f"Démasquer le service : {service}", + lambda: _run(f"systemctl unmask {service}") + ) + + if action == "logs": + if not service: + return "Précise le service." + n = parts[2] if len(parts) > 2 else "50" + try: + n = int(n) + except ValueError: + n = 50 + return _run(f"journalctl -u {service} -n {n} --no-pager -o short-iso") + + if action == "list": + pattern = parts[1] if len(parts) > 1 else "" + cmd = "systemctl list-units --type=service --no-pager" + if pattern: + cmd += f" | grep {pattern}" + return _run(cmd) + + if action == "list-all": + return _run("systemctl list-units --type=service --all --no-pager") + + if action == "failed": + return _run("systemctl list-units --state=failed --no-pager") + + if action == "daemon-reload": + return _confirm_or_execute( + context, "Recharger la configuration systemd (daemon-reload)", + lambda: _run("systemctl daemon-reload && echo 'daemon-reload effectué'") + ) + + if action == "is-active": + if not service: + return "Précise le service." + return _run(f"systemctl is-active {service}") + + if action == "is-enabled": + if not service: + return "Précise le service." + return _run(f"systemctl is-enabled {service}") + + return ( + "Action inconnue. Disponible : status, start, stop, restart, reload, " + "enable, disable, mask, unmask, logs, list, list-all, failed, " + "daemon-reload, is-active, is-enabled" + ) diff --git a/skills/todo.py b/skills/todo.py new file mode 100644 index 0000000..6729143 --- /dev/null +++ b/skills/todo.py @@ -0,0 +1,74 @@ +""" +Skill TODO — liste de tâches en mémoire pour la session courante. + +Usage LLM : + SKILL:todo ARGS:add + SKILL:todo ARGS:list + SKILL:todo ARGS:done + SKILL:todo ARGS:delete + SKILL:todo ARGS:clear +""" + +DESCRIPTION = "Liste de tâches en mémoire (session) : add, list, done, delete, clear" +USAGE = "SKILL:todo ARGS:add | list | done | delete | clear" + +# Stockage partagé entre tous les appels (même processus) +_todos: list = [] +_next_id: int = 1 + + +def run(args: str, context) -> str: + global _todos, _next_id + + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "" + rest = parts[1].strip() if len(parts) > 1 else "" + + if action == "add": + if not rest: + return "Précise le texte de la tâche." + _todos.append({"id": _next_id, "text": rest, "done": False}) + _next_id += 1 + return f"Tâche #{_next_id - 1} ajoutée : {rest}" + + if action == "list": + if not _todos: + return "Aucune tâche." + lines = [] + for t in _todos: + status = "✓" if t["done"] else "○" + lines.append(f" {status} #{t['id']} {t['text']}") + return "Tâches :\n" + "\n".join(lines) + + if action == "done": + if not rest: + return "Précise l'ID de la tâche." + try: + tid = int(rest) + except ValueError: + return f"ID invalide : {rest}" + for t in _todos: + if t["id"] == tid: + t["done"] = True + return f"Tâche #{tid} marquée comme terminée." + return f"Tâche #{tid} introuvable." + + if action == "delete": + if not rest: + return "Précise l'ID de la tâche." + try: + tid = int(rest) + except ValueError: + return f"ID invalide : {rest}" + before = len(_todos) + _todos = [t for t in _todos if t["id"] != tid] + if len(_todos) < before: + return f"Tâche #{tid} supprimée." + return f"Tâche #{tid} introuvable." + + if action == "clear": + _todos = [] + _next_id = 1 + return "Liste de tâches vidée." + + return "Action inconnue. Disponible : add, list, done, delete, clear" diff --git a/skills/user.py b/skills/user.py new file mode 100644 index 0000000..18253e9 --- /dev/null +++ b/skills/user.py @@ -0,0 +1,127 @@ +""" +Skill USER — gestion des utilisateurs et groupes. + +Usage LLM : + SKILL:user ARGS:list + SKILL:user ARGS:add [--sudo] + SKILL:user ARGS:delete + SKILL:user ARGS:passwd + SKILL:user ARGS:info + SKILL:user ARGS:groups + SKILL:user ARGS:addgroup + SKILL:user ARGS:removegroup + SKILL:user ARGS:lock + SKILL:user ARGS:unlock + SKILL:user ARGS:whoami + SKILL:user ARGS:logged + SKILL:user ARGS:sudoers +""" +import subprocess + +DESCRIPTION = "Gestion utilisateurs et groupes : add, delete, passwd, groups, lock/unlock, sudoers" +USAGE = "SKILL:user ARGS:list | add [--sudo] | delete | passwd | info | groups | addgroup | lock | unlock | logged" + + +def _run(cmd: str, timeout: int = 15) -> str: + try: + result = subprocess.run( + cmd, shell=True, text=True, + capture_output=True, timeout=timeout + ) + out = (result.stdout + result.stderr).strip() + return out[:3000] if out else "(aucune sortie)" + except subprocess.TimeoutExpired: + return f"Timeout ({timeout}s)" + except Exception as e: + return str(e) + + +def run(args: str, context) -> str: + parts = args.strip().split() + action = parts[0].lower() if parts else "list" + rest = parts[1:] if len(parts) > 1 else [] + + if action == "list": + return _run("getent passwd | awk -F: '$3>=1000 || $3==0 {print $1\" (uid=\"$3\", shell=\"$7\")\"}'") + + if action == "add": + if not rest: + return "Précise le nom d'utilisateur." + name = rest[0] + sudo = "--sudo" in rest or "-s" in rest + cmds = [ + f"adduser --gecos '' --disabled-password {name}", + ] + if sudo: + cmds.append(f"usermod -aG sudo {name}") + return "\n".join(_run(c) for c in cmds) + + if action == "delete": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"deluser --remove-home {rest[0]}") + + if action == "passwd": + if not rest: + return "Précise le nom d'utilisateur." + # Génère un mot de passe aléatoire + pwd = _run("openssl rand -base64 12").strip() + out = _run(f"echo '{rest[0]}:{pwd}' | chpasswd") + return f"{out}\nNouveauMDP : {pwd}" + + if action == "info": + if not rest: + return "Précise le nom d'utilisateur." + name = rest[0] + return _run(f"id {name} && getent passwd {name}") + + if action == "groups": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"groups {rest[0]}") + + if action == "addgroup": + if len(rest) < 2: + return "Format : addgroup " + return _run(f"usermod -aG {rest[1]} {rest[0]}") + + if action == "removegroup": + if len(rest) < 2: + return "Format : removegroup " + return _run(f"gpasswd -d {rest[0]} {rest[1]}") + + if action == "lock": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"usermod -L {rest[0]}") + + if action == "unlock": + if not rest: + return "Précise le nom d'utilisateur." + return _run(f"usermod -U {rest[0]}") + + if action == "whoami": + return _run("whoami && id") + + if action == "logged": + return _run("who && echo '---' && last -n 10") + + if action == "sudoers": + return _run("getent group sudo | cut -d: -f4") + + if action == "ssh-key": + # Ajouter une clé SSH pour un utilisateur + if len(rest) < 2: + return "Format : ssh-key " + name = rest[0] + key = " ".join(rest[1:]) + return _run( + f"mkdir -p /home/{name}/.ssh && " + f"echo '{key}' >> /home/{name}/.ssh/authorized_keys && " + f"chmod 700 /home/{name}/.ssh && " + f"chmod 600 /home/{name}/.ssh/authorized_keys && " + f"chown -R {name}:{name} /home/{name}/.ssh && " + f"echo 'Clé ajoutée pour {name}'" + ) + + return "Action inconnue. Disponible : list, add, delete, passwd, info, groups, addgroup, removegroup, lock, unlock, whoami, logged, sudoers, ssh-key" diff --git a/skills/web_fetch.py b/skills/web_fetch.py new file mode 100644 index 0000000..d27fd24 --- /dev/null +++ b/skills/web_fetch.py @@ -0,0 +1,109 @@ +""" +Skill WEB_FETCH — récupérer le contenu d'une URL HTTP/HTTPS. + +Usage LLM : + SKILL:web_fetch ARGS:get + SKILL:web_fetch ARGS:head + SKILL:web_fetch ARGS:post | +""" +import urllib.request +import urllib.error +import urllib.parse +import json +import re + +DESCRIPTION = "Récupérer le contenu d'une URL HTTP/HTTPS (GET, HEAD, POST)" +USAGE = "SKILL:web_fetch ARGS:get | head | post |" + +MAX_SIZE = 8000 + + +def _strip_html(html: str) -> str: + """Supprime les balises HTML et nettoie le texte.""" + # Supprime scripts et styles + html = re.sub(r'<(script|style)[^>]*>.*?', ' ', html, flags=re.DOTALL | re.IGNORECASE) + # Supprime les balises + html = re.sub(r'<[^>]+>', ' ', html) + # Décode les entités HTML basiques + html = html.replace('&', '&').replace('<', '<').replace('>', '>') \ + .replace('"', '"').replace(''', "'").replace(' ', ' ') + # Nettoie les espaces multiples + html = re.sub(r'\s+', ' ', html).strip() + return html + + +def run(args: str, context) -> str: + parts = args.strip().split(None, 1) + action = parts[0].lower() if parts else "get" + rest = parts[1] if len(parts) > 1 else "" + + if action == "get": + url = rest.strip() + if not url: + return "Précise une URL." + try: + req = urllib.request.Request( + url, + headers={ + "User-Agent": "HAL-Agent/1.0 (compatible; Python urllib)", + "Accept": "text/html,text/plain,application/json,*/*" + } + ) + with urllib.request.urlopen(req, timeout=15) as resp: + content_type = resp.headers.get("Content-Type", "") + raw = resp.read(MAX_SIZE * 3) # Lit plus pour avoir du contenu après stripping + charset = "utf-8" + if "charset=" in content_type: + charset = content_type.split("charset=")[-1].split(";")[0].strip() + text = raw.decode(charset, errors="replace") + + # Si HTML, nettoie les balises + if "html" in content_type.lower(): + text = _strip_html(text) + + if len(text) > MAX_SIZE: + text = text[:MAX_SIZE] + f"\n... (tronqué à {MAX_SIZE} caractères)" + return f"[{resp.status} {url}]\n{text}" + except urllib.error.HTTPError as e: + return f"Erreur HTTP {e.code} : {e.reason} — {url}" + except urllib.error.URLError as e: + return f"Erreur URL : {e.reason} — {url}" + except Exception as e: + return f"Erreur : {e}" + + if action == "head": + url = rest.strip() + if not url: + return "Précise une URL." + try: + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as resp: + headers = dict(resp.headers) + lines = [f"[{resp.status} {url}]"] + for k, v in headers.items(): + lines.append(f" {k}: {v}") + return "\n".join(lines) + except Exception as e: + return f"Erreur : {e}" + + if action == "post": + if "|" not in rest: + return "Format : post | " + url, body = rest.split("|", 1) + url = url.strip() + body = body.strip().encode("utf-8") + try: + req = urllib.request.Request( + url, data=body, method="POST", + headers={ + "User-Agent": "HAL-Agent/1.0", + "Content-Type": "application/json" + } + ) + with urllib.request.urlopen(req, timeout=15) as resp: + text = resp.read(MAX_SIZE).decode("utf-8", errors="replace") + return f"[{resp.status} {url}]\n{text}" + except Exception as e: + return f"Erreur : {e}" + + return "Action inconnue. Disponible : get, head, post"