feat: confirmations cron/systemd, renforcement script skill, éditeur de script
- base_agent: _pending_confirmations + intercepteur oui/non dans _on_xmpp_message - cron: add/remove/clear demandent confirmation (requêtes XMPP directes) - systemd: start/stop/restart/enable/disable/mask/unmask/daemon-reload demandent confirmation - script: _safe_name strip toutes les extensions, extensions système interdites, contenu vide rejeté, nouvelle commande edit <nom> <ligne> | <contenu> - README mis à jour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) :
|
||||
|
||||
+36
-20
@@ -15,6 +15,15 @@ DESCRIPTION = "Gestion des tâches cron (crontab)"
|
||||
USAGE = "SKILL:cron ARGS:list | add <* * * * *> <commande> | remove <pattern> | 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 <min> <heure> <jour> <mois> <jourSem> <commande>\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.*
|
||||
|
||||
+57
-3
@@ -14,6 +14,7 @@ Usage LLM :
|
||||
SKILL:script ARGS:list
|
||||
SKILL:script ARGS:show <nom>
|
||||
SKILL:script ARGS:save <nom> | <contenu>
|
||||
SKILL:script ARGS:edit <nom> <ligne> | <nouveau contenu de ligne>
|
||||
SKILL:script ARGS:exec <nom> [args...]
|
||||
SKILL:script ARGS:run | <contenu inline>
|
||||
SKILL:script ARGS:delete <nom>
|
||||
@@ -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 <nom>\n"
|
||||
"SKILL:script ARGS:save <nom> | <contenu>\n"
|
||||
"SKILL:script ARGS:edit <nom> <ligne> | <nouveau contenu>\n"
|
||||
"SKILL:script ARGS:exec <nom> [args]\n"
|
||||
"SKILL:script ARGS:run | <contenu inline>\n"
|
||||
"SKILL:script ARGS:delete <nom>"
|
||||
@@ -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 <nom> <numéro_ligne> | <nouveau contenu de ligne>
|
||||
if "|" not in rest:
|
||||
return "Format : edit <nom> <ligne> | <nouveau contenu>\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 <nom> <ligne> | <nouveau contenu>"
|
||||
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)
|
||||
|
||||
+47
-14
@@ -19,6 +19,18 @@ import subprocess
|
||||
DESCRIPTION = "Gestion des services systemd (start/stop/restart/status/logs/enable/disable)"
|
||||
USAGE = "SKILL:systemd ARGS:status <service> | start <service> | stop <service> | restart <service> | enable <service> | disable <service> | logs <service> [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:
|
||||
|
||||
Reference in New Issue
Block a user