Files
Loki/internal/loki/chat_scripts.go
T
Claude 74fb1fc781 Tâches : scripts planifiés sans modèle, et l'IA planifie la sienne
Deux reprises d'AJEAN autour des tâches planifiées.

Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory,
hors du workspace. Le workspace est jetable — supprimer une discussion
emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa
place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses
scripts normalement.

Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script
sans charger le modèle ni consommer un token, sa sortie devient le
compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne
hors du verrou de génération — d'où un registre à part pour l'afficher « en
cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le
verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale.

Outils task_list/create/update/delete : l'IA se donne elle-même rappels et
veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et
ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000
caractères, avec le palier documenté dans le test.

Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent
plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et
c'est la condition d'un chiffrement de la mémoire à venir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:33:39 +00:00

176 lines
6.6 KiB
Go

package loki
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
//
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
// fichiers temporaires).
//
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
// protéger d'une suppression.
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
func scriptsPath(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("nom de script vide")
}
// On interdit les chemins absolus et la remontée : un script vit dans
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
}
// Caractères hostiles au shell : le nom finit dans une ligne de commande
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
// partout rend le comportement identique sur les deux plateformes. Un nom de
// fichier n'a de toute façon besoin d'aucun de ces caractères.
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
}
clean := filepath.Clean(filepath.FromSlash(name))
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
}
full := filepath.Join(scriptsDir(), clean)
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
if !underDir(full, scriptsDir()) {
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
}
return full, nil
}
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
func scriptExists(name string) error {
full, err := scriptsPath(name)
if err != nil {
return err
}
fi, err := os.Stat(full)
if err != nil {
return fmt.Errorf("script introuvable : %s", name)
}
if fi.IsDir() {
return fmt.Errorf("%s est un dossier, pas un script", name)
}
return nil
}
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
// leur taille), triés. Ignore les dossiers.
func listScripts() ([]scriptInfo, error) {
root := scriptsDir()
var out []scriptInfo
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return nil // dossier absent ou illisible : on renvoie ce qu'on a
}
if d.IsDir() {
return nil
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return nil
}
size := int64(0)
if fi, ferr := d.Info(); ferr == nil {
size = fi.Size()
}
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, err
}
type scriptInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
// sont pas concernés par ces gardes.
//
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
// d'avec le workspace jetable, pas d'un verrou.
func toolOnlyDirs() []string { return []string{memoryDir()} }
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
func toolOnlyLabel() string {
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
}
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
func guardToolOnlyPath(path string) string {
for _, d := range toolOnlyDirs() {
if underDir(path, d) {
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
}
}
return ""
}
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
func guardToolOnlyCommand(command string) string {
lc := strings.ToLower(command)
for _, d := range toolOnlyDirs() {
if strings.Contains(lc, strings.ToLower(normPath(d))) {
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
}
}
return ""
}
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
// (sous Windows) insensibilité à la casse.
func underDir(path, dir string) bool {
a := normPath(path)
b := normPath(dir)
if a == b {
return true
}
return strings.HasPrefix(a, b+string(filepath.Separator))
}
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
// et minusculé sous Windows (système de fichiers insensible à la casse).
func normPath(p string) string {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
p = filepath.Clean(p)
if runtime.GOOS == "windows" {
p = strings.ToLower(p)
}
return p
}