diff --git a/README.md b/README.md index 593a8cf..8e15d59 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ systemctl enable --now agent_debian | `apt` | Gestion des paquets (install, remove, update, upgrade, search) | | `systemd` | Contrôle des services (start, stop, restart, status, enable) | | `shell` | Exécution de commandes shell arbitraires | -| `script` | Bibliothèque de scripts bash (save/list/show/exec/run/delete) | +| `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) | | `sysinfo` | CPU, RAM, disque, uptime | | `process` | Liste, kill, surveillance des processus | | `filesystem` | Lecture, écriture, liste, recherche de fichiers | @@ -51,6 +51,17 @@ Les scripts bash sont stockés dans `/opt/agent_debian/scripts/`. Ils peuvent ê Chaque exécution envoie une notification XMPP à l'admin via Nexus. +**Règles de nommage** : les noms sont normalisés (extensions strip automatique, `.service`/`.timer`/`.py`… interdits). Le contenu doit contenir au moins une commande réelle. + +## Confirmations requises + +Les actions suivantes demandent confirmation avant exécution (requêtes XMPP directes uniquement) : + +- **cron** : `add`, `remove`, `clear` +- **systemd** : `start`, `stop`, `restart`, `enable`, `disable`, `mask`, `unmask`, `daemon-reload` + +Répondre `oui` pour confirmer ou `non` pour annuler. + ## Surveillance proactive L'agent monitore en arrière-plan (toutes les 5 minutes) : diff --git a/skills/cron.py b/skills/cron.py index 6af70c6..cf8d52a 100644 --- a/skills/cron.py +++ b/skills/cron.py @@ -15,6 +15,15 @@ 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( @@ -38,8 +47,6 @@ def run(args: str, context) -> str: return result if result else "Crontab vide." if action == "add": - # Format attendu : "* * * * * commande" - # On split les 5 premiers champs (expression cron) + le reste (commande) words = rest.split() if len(words) < 6: return "Format : add \nEx: add 0 3 * * * /usr/bin/apt-get update" @@ -47,39 +54,48 @@ def run(args: str, context) -> str: command = " ".join(words[5:]) entry = f"{cron_expr} {command}" - # Récupère le crontab actuel, ajoute la ligne current = _run("crontab -l 2>/dev/null") if entry in current: return f"Cette entrée existe déjà : {entry}" - 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 + 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}" - 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] - new_cron = "\n".join(lines) + removed_count = len(current.splitlines()) - len(lines) + if removed_count == 0: + return f"Aucune entrée contenant '{rest}' trouvée." - with tempfile.NamedTemporaryFile(mode="w", suffix=".cron", delete=False) as f: - f.write(new_cron + "\n") - tmpfile = f.name + 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}" - out = _run(f"crontab {tmpfile}") - os.unlink(tmpfile) - removed = len(current.splitlines()) - len(lines) - return f"{removed} 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 _run("crontab -r 2>/dev/null && echo 'Crontab effacé'") + 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.* diff --git a/skills/script.py b/skills/script.py index b4f828e..112307a 100644 --- a/skills/script.py +++ b/skills/script.py @@ -14,6 +14,7 @@ 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 @@ -25,11 +26,12 @@ import subprocess import tempfile from datetime import datetime -DESCRIPTION = "Bibliothèque de scripts bash : sauvegarder, lister, afficher, exécuter" +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 " @@ -53,9 +55,18 @@ def _ensure_dir(context) -> str: 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.""" - return os.path.basename(name.strip().replace("/", "_")) + """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: @@ -137,6 +148,20 @@ def run(args: str, context) -> str: 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) @@ -148,6 +173,35 @@ def run(args: str, context) -> str: 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) diff --git a/skills/systemd.py b/skills/systemd.py index 997fb5f..373c5a7 100644 --- a/skills/systemd.py +++ b/skills/systemd.py @@ -19,6 +19,18 @@ 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: @@ -47,23 +59,29 @@ def run(args: str, context) -> str: if action == "start": if not service: return "Précise le service." - out = _run(f"systemctl start {service}") - status = _run(f"systemctl is-active {service}") - return f"Démarrage de {service}... Statut : {status}\n{out}" + 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." - out = _run(f"systemctl stop {service}") - status = _run(f"systemctl is-active {service}") - return f"Arrêt de {service}... Statut : {status}\n{out}" + 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." - out = _run(f"systemctl restart {service}") - status = _run(f"systemctl is-active {service}") - return f"Redémarrage de {service}... Statut : {status}\n{out}" + 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: @@ -73,22 +91,34 @@ def run(args: str, context) -> str: if action == "enable": if not service: return "Précise le service." - return _run(f"systemctl enable {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 _run(f"systemctl disable {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 _run(f"systemctl mask {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 _run(f"systemctl unmask {service}") + return _confirm_or_execute( + context, f"Démasquer le service : {service}", + lambda: _run(f"systemctl unmask {service}") + ) if action == "logs": if not service: @@ -114,7 +144,10 @@ def run(args: str, context) -> str: return _run("systemctl list-units --state=failed --no-pager") if action == "daemon-reload": - return _run("systemctl 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: