erplibre/script/todo/assistant/context.py

398 lines
15 KiB
Python
Raw Permalink Normal View History

2026-09-09 06:41:56 -04:00
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le contexte déclaré d'un gpt : ce qu'il a le droit de lire, et de dire.
Un gpt déclare des fichiers et des commandes ; ce module les lit, les borne,
les balaie, et rend le texte assemblé avec ce qu'il y a trouvé. Il ne décide
jamais d'envoyer : c'est le menu qui pose la porte à l'utilisateur, et
`gate` lui donne le verdict à afficher.
**La liste de refus passe avant tout, et se résout sur le chemin RÉEL.** Ni
« suivi par git » ni « ignoré par git » ne sont des portes utilisables :
`private/` est partiellement suivi, et `tasks/` n'est pas dans `.gitignore`.
Un lien symbolique est donc résolu avant d'être comparé, sinon un lien vers
`private/` traverserait la liste en la contournant par le nom.
**Une commande est un `argv`, jamais une chaîne d'interpréteur**, et elle est
confrontée à une liste d'autorisation qui vit dans le dépôt — un gpt venu
d'ailleurs ne peut donc pas l'étendre. Le refus d'un métacaractère dans un
argument n'est PAS une protection contre l'injection : sans interpréteur, il
n'y a rien à injecter. C'est un signal que l'auteur croyait écrire une ligne
de shell, donc que son gpt ne fera pas ce qu'il voulait — le lui dire au
chargement vaut mieux qu'un résultat surprenant.
**Les plafonds servent la lisibilité autant que le coût.** Un contexte qu'on
ne peut plus relire avant de l'envoyer n'est plus un contexte déclaré, c'est
un versement. La coupe se fait sur une frontière de ligne et porte une marque,
pour qu'une source tronquée se voie.
**Ce que le balayage voit, et ce qu'il ne voit pas.** `identifiants()`
reconnaît les adresses, les courriels et les chemins de compte. Il ne
reconnaît PAS les noms — d'hôte, de client, de base de données — sauf si
`private/noms_interdits.txt` les énumère, et ce fichier n'existe pas
d'ordinaire. La moitié « noms » du filtre est donc inerte par défaut, et
`gate` le dit au lieu de laisser croire à un contrôle complet : une
destination tierce est REFUSÉE tant que cette liste est vide.
"""
2026-09-09 06:41:56 -04:00
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from script import lib_identifiant
# Ce qu'aucun contexte ne lit, jamais, quel que soit le gpt qui le demande.
# `private/` est le seul endroit autorisé à porter une donnée de client ;
# `tasks/` porte l'enquête, que la convention y envoie pour qu'elle ne suive
# pas le dépôt ; le reste est un coffre, une clé, un cache ou un historique.
REFUS = (
"private",
"tasks",
".git",
".ssh",
".erplibre",
".venv",
"node_modules",
)
# Les suffixes refusés où qu'ils soient : un coffre reste un coffre.
SUFFIXES_REFUSES = (".kdbx", ".key", ".pem")
# Les commandes qu'un gpt peut déclarer, par PRÉFIXE d'argv. La liste vit
# dans le dépôt et n'est pas extensible depuis un gpt : c'est ce qui rend
# inoffensif un fichier que personne n'a relu.
AUTORISEES = (
("python3", "script/analyse/check_comment_hygiene.py"),
("python3", "script/analyse/check_manifest_gaps.py"),
("git", "diff"),
("git", "log"),
("git", "status"),
("./script/test/run_unit_test.sh",),
)
# Les caractères qui trahissent un auteur qui croyait écrire du shell.
METACARACTERES = ";|&><`$\n"
# Ce qu'une source peut peser, et ce que tout le contexte peut peser. Un
# contexte qu'on ne peut plus relire avant l'envoi n'est plus déclaré.
MAX_PAR_SOURCE = 8_000
MAX_TOTAL = 24_000
# Ce qu'une commande a le droit de durer, et ce que toutes ont ensemble. Une
# commande qui dépasse laisse le menu rendre la main plutôt que d'attendre.
DELAI_PAR_COMMANDE = 30
DELAI_TOTAL = 60
# La marque d'une source coupée. Elle est visible dans l'aperçu, donc la
# troncature ne se découvre pas dans la réponse du modèle.
MARQUE_COUPE = "… [cut]"
# Les verdicts de la porte.
OK = "ok"
AVERTIR = "warn"
BLOQUER = "block"
# Les clés de la porte, nommées une fois. Voir la raison dans `gpt.py`.
NOMS_INVERIFIABLES = (
"private/noms_interdits.txt is absent: no client, database, VM or host"
" name can be recognized."
)
TROUVAILLE_BLOQUE_UN_TIERS = (
"A finding blocks a send to a third party. No override."
)
TROUVAILLE_A_RELIRE = "finding to re-read before sending"
class ContextRefused(Exception):
"""Une source qu'aucun gpt n'a le droit de lire, ou un argv refusé.
Le message porte la raison en clair : il s'affiche tel quel dans les
problèmes du catalogue, à côté de la source refusée.
"""
def repo_root() -> Path:
"""La racine du dépôt, dérivée de l'emplacement de ce module."""
return Path(__file__).resolve().parents[3]
def resolve_file(path, *, root=None) -> Path:
"""Le chemin réel d'une source déclarée. Lève `ContextRefused`.
Deux refus, dans cet ordre. Le chemin est d'abord RÉSOLU — liens
symboliques compris — puis comparé : un lien vers `private/` ne doit pas
passer parce que son nom, lui, est anodin. Ensuite il doit rester SOUS la
racine du dépôt : un contexte n'a rien à lire ailleurs, et « ../ » est le
chemin le plus court vers le répertoire personnel.
"""
base = Path(root).resolve() if root else repo_root()
reel = (base / Path(path)).resolve()
try:
relatif = reel.relative_to(base)
except ValueError:
raise ContextRefused(f"hors du dépôt : {path}") from None
if reel.suffix in SUFFIXES_REFUSES:
raise ContextRefused(f"suffixe refusé : {reel.suffix}")
for partie in relatif.parts:
if partie in REFUS or partie.startswith(".venv"):
raise ContextRefused(f"chemin refusé : {partie}")
return reel
def check_argv(argv) -> tuple:
"""L'argv d'une commande déclarée. Lève `ContextRefused`. Fonction PURE.
Une chaîne est refusée d'emblée : elle voudrait dire qu'un interpréteur
la relira, et c'est justement ce qu'aucun contexte ne fait.
"""
if isinstance(argv, str):
raise ContextRefused("une commande est une liste, pas une chaîne")
if not isinstance(argv, (list, tuple)) or not argv:
raise ContextRefused("commande vide")
morceaux = []
for morceau in argv:
if not isinstance(morceau, str):
raise ContextRefused(f"argument non textuel : {morceau!r}")
if any(caractere in morceau for caractere in METACARACTERES):
# Sans interpréteur il n'y a rien à injecter : ce refus dit que
# l'auteur croyait écrire du shell, donc que son gpt ne fera pas
# ce qu'il voulait.
raise ContextRefused(f"métacaractère d'interpréteur : {morceau}")
morceaux.append(morceau)
for prefixe in AUTORISEES:
if tuple(morceaux[: len(prefixe)]) == prefixe:
return tuple(morceaux)
raise ContextRefused(f"hors liste d'autorisation : {morceaux[0]}")
def substituer(argv, inputs=None) -> list:
"""`argv` avec ses `{nom}` remplacés. Lève `ContextRefused`.
L'ORDRE compte, et c'est tout l'enjeu : la substitution a lieu AVANT
`check_argv`, jamais après. Une valeur saisie par l'utilisateur passe donc
par le contrôle des métacaractères et de la liste d'autorisation comme le
reste de la ligne — vérifier le gabarit puis y injecter une valeur
reviendrait à vérifier ce qu'on n'exécute pas.
Un `{nom}` sans valeur est refusé plutôt que laissé tel quel : une
commande qui recevrait « {test_file} » comme chemin échouerait plus loin,
avec une erreur qui ne dirait pas d'où elle vient.
"""
valeurs = dict(inputs or {})
remplis = []
for morceau in argv or ():
if not isinstance(morceau, str):
remplis.append(morceau)
continue
for nom, valeur in valeurs.items():
morceau = morceau.replace("{" + nom + "}", str(valeur))
manquant = re.search(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", morceau)
if manquant:
raise ContextRefused(f"entrée sans valeur : {manquant.group(1)}")
remplis.append(morceau)
return remplis
def borner(texte, maximum=MAX_PAR_SOURCE) -> str:
"""`texte` ramené sous `maximum`, coupé sur une frontière de ligne.
La coupe se voit : sans marque, une source tronquée se lit comme une
source complète, et le modèle répond sur ce qu'il n'a pas reçu.
"""
if len(texte) <= maximum:
return texte
coupe = texte[:maximum]
frontiere = coupe.rfind("\n")
if frontiere > 0:
coupe = coupe[:frontiere]
return coupe + "\n" + MARQUE_COUPE
def assemble(
files=(),
commands=(),
*,
inputs=None,
read=None,
run=None,
termes=None,
root=None,
):
"""Le contexte assemblé, et ce que le balayage y a trouvé.
Rend `(texte, trouvailles)`. Chaque trouvaille est un dictionnaire
`{source, motif, extrait, position}` : le menu les surligne dans
l'aperçu, et `gate` décide de ce qu'elles autorisent.
Les sources sont lues dans l'ordre déclaré, chacune bornée, et
l'assemblage s'arrête net au plafond total : une source qui n'entre pas
est ANNONCÉE plutôt que silencieusement absente.
`read`, `run` et `termes` sont injectés — un test décide alors ce que la
machine contient, ce que les commandes rendent, et quels noms le filtre
connaît, sans dépendre du poste qui le lance.
"""
lecteur = read or _lire
lanceur = run or _lancer
liste = lib_identifiant.termes_interdits() if termes is None else termes
morceaux: list[str] = []
trouvailles: list[dict] = []
total = 0
reste_delai = DELAI_TOTAL
for chemin in files or ():
libelle = str(chemin)
try:
chemin = substituer([str(chemin)], inputs)[0]
reel = resolve_file(chemin, root=root)
contenu = lecteur(reel)
except (ContextRefused, OSError) as refus:
morceaux.append(f"# {libelle} — {refus}")
continue
contenu, total, plein = _ajouter(contenu, total)
morceaux.append(f"# {libelle}\n{contenu}")
trouvailles.extend(_balayer(contenu, libelle, liste))
if plein:
morceaux.append(f"# {MARQUE_COUPE}")
return "\n\n".join(morceaux), trouvailles
for commande in commands or ():
libelle = _libelle(commande)
try:
argv = check_argv(substituer(_argv(commande), inputs))
except ContextRefused as refus:
morceaux.append(f"# {libelle} — {refus}")
continue
delai = min(DELAI_PAR_COMMANDE, reste_delai)
if delai <= 0:
morceaux.append(f"# {libelle} — délai total épuisé")
continue
try:
sortie = lanceur(argv, delai)
except Exception as panne:
morceaux.append(f"# {libelle} — {panne}")
continue
reste_delai -= delai
sortie, total, plein = _ajouter(sortie or "", total)
morceaux.append(f"# {libelle}\n{sortie}")
trouvailles.extend(_balayer(sortie, libelle, liste))
if plein:
morceaux.append(f"# {MARQUE_COUPE}")
break
return "\n\n".join(morceaux), trouvailles
def gate(trouvailles, hosting, *, names_checkable=True) -> tuple:
"""Ce que la porte autorise. Rend `(verdict, clé)`. Fonction PURE.
Trois verdicts. `ok` laisse passer. `warn` demande une confirmation que
l'utilisateur peut donner. `block` REFUSE sans passe-droit.
La règle tient à qui reçoit. Sur la boucle locale, une trouvaille est un
avertissement : rien ne quitte la machine, et l'opérateur décide chez lui.
Vers un TIERS, elle bloque — une adresse ou un chemin de compte envoyé à
quelqu'un d'autre ne se rattrape pas.
`names_checkable` dit si la liste des noms interdits est renseignée.
Vide, la moitié « noms » du filtre est inerte : le balayage ne verrait ni
nom d'hôte, ni nom de client, ni nom de base. Un envoi vers un tiers est
alors refusé même SANS trouvaille, parce que l'absence de trouvaille ne
prouve plus rien.
"""
tiers = hosting not in ("loopback", "lan")
if tiers and not names_checkable:
return (
BLOQUER,
NOMS_INVERIFIABLES,
)
if not trouvailles:
return OK, ""
if tiers:
return (
BLOQUER,
TROUVAILLE_BLOQUE_UN_TIERS,
)
return AVERTIR, TROUVAILLE_A_RELIRE
def _ajouter(contenu, total):
"""(contenu borné, nouveau total, plafond atteint)."""
contenu = borner(contenu, MAX_PAR_SOURCE)
place = MAX_TOTAL - total
if len(contenu) >= place:
return borner(contenu, max(place, 0)), MAX_TOTAL, True
return contenu, total + len(contenu), False
def _balayer(texte, source, termes):
"""Les données identifiantes d'une source, nommées par leur source.
Le filtre reconnaît les adresses, les courriels et les chemins de compte.
Les NOMS ne lui sont connus que par `termes`, d'où l'injection : une
liste vide rend un balayage muet sur toute une classe de données.
"""
return [
{
"source": source,
"motif": motif,
"extrait": extrait,
"position": position,
}
for motif, extrait, position in lib_identifiant.identifiants(
texte, termes=tuple(termes or ())
)
]
def _argv(commande):
"""L'argv d'une commande déclarée, quelle que soit sa forme."""
if isinstance(commande, dict):
return commande.get("argv")
return commande
def _libelle(commande):
"""Ce qui nomme une commande dans l'aperçu.
Le libellé de l'auteur s'il en donne un : « Trouvailles » se lit mieux
qu'une ligne d'argv, et c'est cet aperçu que l'utilisateur relit.
"""
if isinstance(commande, dict):
etiquette = commande.get("label")
if isinstance(etiquette, str) and etiquette.strip():
return etiquette.strip()
argv = commande.get("argv") or ()
else:
argv = commande or ()
return " ".join(str(morceau) for morceau in argv)[:80]
def _lire(chemin):
"""Le texte d'une source de contexte."""
return Path(chemin).read_text(encoding="utf-8", errors="replace")
def _lancer(argv, delai):
"""La sortie standard d'une commande déclarée, sans interpréteur.
`cwd` est la racine du dépôt : un gpt déclare des chemins relatifs à
elle, et non au répertoire d'où le menu a été lancé. La sortie d'erreur
est jointe — une commande qui explique pourquoi elle n'a rien produit est
plus utile qu'un vide.
"""
answer = subprocess.run(
list(argv),
capture_output=True,
text=True,
timeout=delai,
cwd=str(repo_root()),
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
return answer.stdout + (answer.stderr if answer.returncode else "")