Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
397 lines
15 KiB
Python
397 lines
15 KiB
Python
#!/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.
|
|
"""
|
|
|
|
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 "")
|