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:
2026-03-16 07:18:47 +00:00
parent d2596de207
commit d3a23fb301
4 changed files with 199 additions and 4 deletions
+3 -1
View File
@@ -28,7 +28,7 @@ systemctl enable --now agent_ansible
| `galaxy` | Installation de rôles et collections (`ansible-galaxy`) | | `galaxy` | Installation de rôles et collections (`ansible-galaxy`) |
| `vault` | Chiffrement/déchiffrement de secrets (`ansible-vault`) | | `vault` | Chiffrement/déchiffrement de secrets (`ansible-vault`) |
| `shell` | Commandes shell locales (utile pour diagnostics) | | `shell` | Commandes shell locales (utile pour diagnostics) |
| `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) |
| `agents_status` | Statut des agents du système | | `agents_status` | Statut des agents du système |
| `mqtt_send` | Publication sur un topic MQTT | | `mqtt_send` | Publication sur un topic MQTT |
| `mqtt_subscribe` | Souscription dynamique à un topic MQTT | | `mqtt_subscribe` | Souscription dynamique à un topic MQTT |
@@ -38,6 +38,8 @@ systemctl enable --now agent_ansible
Les scripts bash sont stockés dans `/opt/agent_ansible/scripts/`. Ils peuvent encapsuler des appels `ansible-playbook` ou des opérations de maintenance sur l'infra. Les scripts bash sont stockés dans `/opt/agent_ansible/scripts/`. Ils peuvent encapsuler des appels `ansible-playbook` ou des opérations de maintenance sur l'infra.
Les noms sont normalisés automatiquement (extensions strip, extensions système interdites). Le contenu doit contenir au moins une commande réelle.
## Structure ## Structure
``` ```
+30
View File
@@ -15,3 +15,33 @@ localhost ansible_connection=local
[all] [all]
192.168.7.100 192.168.7.100
debian.local debian.local
[net7]
192.168.7.79 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.80 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.90 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.91 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.112 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.119 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.120 ansible_user=admin ansible_ssh_private_key_file=/root/.ssh/id_rsa
192.168.7.120
192.168.7.119
192.168.7.112
192.168.7.91
192.168.7.90
192.168.7.80
192.168.7.79
192.168.7.76
192.168.7.75
192.168.7.74
192.168.7.73
192.168.7.72
192.168.7.55
192.168.7.54
192.168.7.53
192.168.7.52
192.168.7.51
192.168.7.19
192.168.7.15
[network]
+109
View File
@@ -0,0 +1,109 @@
"""
Skill CRON — gestion des tâches cron.
Usage LLM :
SKILL:cron ARGS:list [utilisateur]
SKILL:cron ARGS:add <expression_cron> <commande>
SKILL:cron ARGS:remove <pattern>
SKILL:cron ARGS:system-list
"""
import subprocess
import tempfile
import os
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(
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 <min> <heure> <jour> <mois> <jourSem> <commande>\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"
+57 -3
View File
@@ -14,6 +14,7 @@ Usage LLM :
SKILL:script ARGS:list SKILL:script ARGS:list
SKILL:script ARGS:show <nom> SKILL:script ARGS:show <nom>
SKILL:script ARGS:save <nom> | <contenu> 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:exec <nom> [args...]
SKILL:script ARGS:run | <contenu inline> SKILL:script ARGS:run | <contenu inline>
SKILL:script ARGS:delete <nom> SKILL:script ARGS:delete <nom>
@@ -25,11 +26,12 @@ import subprocess
import tempfile import tempfile
from datetime import datetime 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 = ( USAGE = (
"SKILL:script ARGS:list\n" "SKILL:script ARGS:list\n"
"SKILL:script ARGS:show <nom>\n" "SKILL:script ARGS:show <nom>\n"
"SKILL:script ARGS:save <nom> | <contenu>\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:exec <nom> [args]\n"
"SKILL:script ARGS:run | <contenu inline>\n" "SKILL:script ARGS:run | <contenu inline>\n"
"SKILL:script ARGS:delete <nom>" "SKILL:script ARGS:delete <nom>"
@@ -53,9 +55,18 @@ def _ensure_dir(context) -> str:
return d return d
_FORBIDDEN_EXTENSIONS = {".service", ".timer", ".socket", ".target", ".mount", ".conf", ".py", ".js"}
def _safe_name(name: str) -> str: def _safe_name(name: str) -> str:
"""Empêche les traversées de répertoire.""" """Empêche les traversées de répertoire et normalise le nom."""
return os.path.basename(name.strip().replace("/", "_")) 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: 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_raw, content = rest.split("|", 1)
name = _safe_name(name_raw) name = _safe_name(name_raw)
content = content.strip().replace("\\n", "\n") 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) d = _ensure_dir(context)
path = os.path.join(d, name + ".sh") path = os.path.join(d, name + ".sh")
existed = os.path.exists(path) existed = os.path.exists(path)
@@ -148,6 +173,35 @@ def run(args: str, context) -> str:
verb = "mis à jour" if existed else "créé" verb = "mis à jour" if existed else "créé"
return f"Script '{name}' {verb} : {path}" 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 ────────────────────────────────────────────────────────────── # ── exec ──────────────────────────────────────────────────────────────
if action == "exec": if action == "exec":
parts2 = rest.split(None, 1) parts2 = rest.split(None, 1)