@nosserb/prmt 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Client HTTP d'Ollama (API locale)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
# --------------------------------------------------------------------------- Ollama
|
|
10
|
+
def ollama_url() -> str:
|
|
11
|
+
host = os.environ.get("OLLAMA_HOST", "").strip() or "127.0.0.1:11434"
|
|
12
|
+
if "://" not in host:
|
|
13
|
+
host = "http://" + host
|
|
14
|
+
host = host.replace("0.0.0.0", "127.0.0.1").rstrip("/")
|
|
15
|
+
if host.count(":") == 1: # pas de port précisé
|
|
16
|
+
host += ":11434"
|
|
17
|
+
return host
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
OLLAMA = ollama_url()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def api(path: str, payload: dict | None = None, timeout: float = 30, on_open=None) -> dict:
|
|
24
|
+
"""Appel à l'API d'Ollama. on_open reçoit la réponse dès qu'elle est ouverte : la fermer depuis un autre fil
|
|
25
|
+
interrompt la lecture (Esc pendant un appel long)."""
|
|
26
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
27
|
+
req = urllib.request.Request(
|
|
28
|
+
OLLAMA + path,
|
|
29
|
+
data=data,
|
|
30
|
+
headers={"Content-Type": "application/json"},
|
|
31
|
+
method="POST" if data is not None else "GET",
|
|
32
|
+
)
|
|
33
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
34
|
+
if on_open is not None:
|
|
35
|
+
on_open(resp)
|
|
36
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def context_from_env(default: int = 4096) -> int:
|
|
40
|
+
"""Taille de contexte par défaut d'Ollama (OLLAMA_CONTEXT_LENGTH), si le modèle n'en fixe pas."""
|
|
41
|
+
try:
|
|
42
|
+
value = int(os.environ.get("OLLAMA_CONTEXT_LENGTH") or default)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return default
|
|
45
|
+
return value if value > 0 else default
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def http_error_text(err: urllib.error.HTTPError) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(err.read().decode("utf-8")).get("error", str(err))
|
|
51
|
+
except Exception:
|
|
52
|
+
return str(err)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Mode plan, liste de tâches et étapes de la méthode d'audit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import CREATE_NO_WINDOW
|
|
9
|
+
from .shell import decode_output, refreshed_env
|
|
10
|
+
|
|
11
|
+
# --------------------------------------------------------------------------- Mode plan et liste de tâches
|
|
12
|
+
# Format mesuré : titres courts (bons pour la liste de tâches), commandes réelles, plus de « executer_commande »
|
|
13
|
+
# ni de script .bat écrits en texte (que le modèle imitait ensuite au lieu d'appeler l'outil).
|
|
14
|
+
PLAN_REMINDER = ("\n\n(Mode plan, activé par l'utilisateur : n'exécute rien maintenant. Réponds UNIQUEMENT par un plan "
|
|
15
|
+
"numéroté de 2 à 6 étapes, avec seulement ce qui est nécessaire pour ma demande (pas d'étape en plus, "
|
|
16
|
+
"comme enregistrer les résultats dans un fichier si je ne l'ai pas demandé), une par ligne, au format : "
|
|
17
|
+
"`N. Titre court : action`. Le titre fait 2 à 5 "
|
|
18
|
+
"mots. L'action est UNE commande PowerShell simple qui existe vraiment, entre backticks, ou le fichier "
|
|
19
|
+
"à créer ou modifier (pas de nom d'outil). La dernière étape est le bilan, sans commande. Termine par : "
|
|
20
|
+
"« Je l'exécute ? »)")
|
|
21
|
+
# Mesuré : sans cette phrase, « quelle est l'IP de ma VM Kali ? » lançait ipconfig (3/3) au lieu de lire la mémoire ;
|
|
22
|
+
# avec elle : réponse depuis la mémoire 3/3, et « quelle est mon adresse IP ? » lance toujours ipconfig (3/3).
|
|
23
|
+
MEMORY_HINT = " Si l'information demandée est déjà dans ta mémoire (PROMETHEE.md), réponds directement avec elle, sans commande."
|
|
24
|
+
PLAN_APPROVED = ("J'approuve ton plan. Exécute-le maintenant, étape par étape, avec les outils "
|
|
25
|
+
"(une commande à la fois), puis fais le bilan.")
|
|
26
|
+
PLAN_STEP = re.compile(r"^\s*(\d{1,2})[.)]\s+(.+)$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plan_steps(text: str, limit: int = 10) -> list[tuple[str, str, bool]]:
|
|
30
|
+
"""Étapes numérotées d'un plan : (titre court, texte complet, contient une commande ou un fichier)."""
|
|
31
|
+
steps = []
|
|
32
|
+
for line in text.split("\n"):
|
|
33
|
+
match = PLAN_STEP.match(line)
|
|
34
|
+
if not match or match.group(2).strip().endswith("?"): # « 6. Je l'exécute ? » n'est pas une étape
|
|
35
|
+
continue
|
|
36
|
+
full = re.sub(r"[*_`]", "", match.group(2)).strip()
|
|
37
|
+
title = re.split(r"\s+:\s+|\s+[-–—]\s+|:\s", full, maxsplit=1)[0].strip().rstrip(":")
|
|
38
|
+
if len(title) > 60:
|
|
39
|
+
title = title[:58].rsplit(" ", 1)[0] + "…"
|
|
40
|
+
steps.append((title or full[:60], full, "`" in match.group(2)))
|
|
41
|
+
return steps[:limit]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Le petit modèle invente des cmdlets dans ses plans (« Get-OpenPort ») : on les repère avant de montrer le plan.
|
|
45
|
+
PLAN_CODE = re.compile(r"`([^`\n]+)`")
|
|
46
|
+
PLAN_CMDLET = re.compile(r"(?:^|[|;({])\s*([A-Za-z]{2,}-[A-Za-z]{2,})(?![\w-])")
|
|
47
|
+
PLAN_FIX = ("(Contrôle automatique du plan : {names} n'existe pas sur ce PC, c'est une commande inventée. Réécris le "
|
|
48
|
+
"plan complet, au même format, en remplaçant chaque commande inventée par une vraie commande PowerShell. "
|
|
49
|
+
"Termine par : « Je l'exécute ? »)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def missing_cmdlets(text: str, cwd: Path) -> list[str]:
|
|
53
|
+
"""Cmdlets (Verbe-Nom) en tête des commandes d'un plan qui n'existent pas sur cette machine."""
|
|
54
|
+
names: list[str] = []
|
|
55
|
+
for line in text.split("\n"):
|
|
56
|
+
if PLAN_STEP.match(line):
|
|
57
|
+
for code in PLAN_CODE.findall(line):
|
|
58
|
+
for name in PLAN_CMDLET.findall(code.strip()):
|
|
59
|
+
if name.lower() not in (n.lower() for n in names):
|
|
60
|
+
names.append(name)
|
|
61
|
+
if not names:
|
|
62
|
+
return []
|
|
63
|
+
script = ("foreach ($n in @(" + ",".join(f"'{n}'" for n in names) + ")) "
|
|
64
|
+
"{ if (-not (Get-Command $n -ErrorAction SilentlyContinue)) { $n } }")
|
|
65
|
+
try:
|
|
66
|
+
proc = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
67
|
+
cwd=str(cwd), env=refreshed_env(), stdin=subprocess.DEVNULL, capture_output=True,
|
|
68
|
+
creationflags=CREATE_NO_WINDOW, timeout=20)
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return [] # la vérification ne doit jamais bloquer l'utilisateur
|
|
71
|
+
found = {line.strip().lower() for line in decode_output(proc.stdout).split("\n") if line.strip()}
|
|
72
|
+
return [n for n in names if n.lower() in found]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Étapes de la méthode d'audit, cochées d'après les commandes réellement exécutées (pas d'après le modèle)
|
|
76
|
+
AUDIT_TASKS = [
|
|
77
|
+
("Résolution DNS", r"Resolve-DnsName|nslookup|\bdig\b"),
|
|
78
|
+
("Ports et services (nmap)", r"\bnmap\b(?!.*ssl-enum)"),
|
|
79
|
+
("En-têtes HTTP", r"curl(?:\.exe)?\b.*\s-\w*I|Invoke-WebRequest|\biwr\b|-Method\s+Head"),
|
|
80
|
+
("Configuration TLS", r"ssl-enum-ciphers|testssl|openssl\s+s_client|sslscan"),
|
|
81
|
+
("robots.txt", r"robots\.txt"),
|
|
82
|
+
("Rapport final", None),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_keywords(command: str) -> list[str]:
|
|
87
|
+
"""Mots qui identifient une commande (programme, fichiers) pour la rattacher à une étape du plan."""
|
|
88
|
+
first = re.split(r"[\s|;]", command.strip().strip("&'\" "), maxsplit=1)[0]
|
|
89
|
+
words = [Path(first.strip("'\"")).name.lower().removesuffix(".exe")]
|
|
90
|
+
words += [w.lower() for w in re.findall(r"[\w.-]+\.[A-Za-z]\w{0,4}", command)]
|
|
91
|
+
return [w for w in dict.fromkeys(words) if len(w) >= 3]
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Politique d'exécution : liste blanche, périmètre et verdicts, partagés avec la console web.
|
|
2
|
+
|
|
3
|
+
La TUI ne décide pas seule si une commande est autorisée. Elle le demande au moteur de la console
|
|
4
|
+
(TypeScript), par un petit programme, le « pont », qu'elle lance et à qui elle parle en JSON, une
|
|
5
|
+
demande par ligne. Le pont analyse la commande avec le vrai analyseur de PowerShell et rend un verdict.
|
|
6
|
+
Il n'exécute jamais rien.
|
|
7
|
+
|
|
8
|
+
Deux modes :
|
|
9
|
+
- stricte (par défaut) : tout refus de la liste blanche est appliqué, comme dans la console ;
|
|
10
|
+
- souple : ce que l'analyse ne sait pas vérifier (scripts, compilateurs, programmes lancés par leur
|
|
11
|
+
chemin) t'est montré comme « hors politique » et tu le valides toi-même. Les planchers refusent
|
|
12
|
+
dans les deux modes : commande destructrice non débloquée, cible hors périmètre, protections de
|
|
13
|
+
Windows, chemin réseau.
|
|
14
|
+
|
|
15
|
+
Si le pont n'est pas disponible (console absente, Node introuvable), verdict() renvoie None : la TUI
|
|
16
|
+
retombe sur sa validation manuelle, commande par commande, et l'affiche dans la barre d'état.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .config import CREATE_NO_WINDOW, SAVE_DIR
|
|
31
|
+
from .shell import refreshed_env
|
|
32
|
+
|
|
33
|
+
MODES = ("stricte", "souple")
|
|
34
|
+
RENDEZ_VOUS = SAVE_DIR / "plateforme.json"
|
|
35
|
+
DELAI_DEMARRAGE = 25.0 # secondes : Node puis l'analyseur PowerShell du pont
|
|
36
|
+
DELAI_DEMANDE = 20.0
|
|
37
|
+
ATTENTE_APRES_ECHEC = 30.0 # avant de retenter un pont qui n'a pas démarré
|
|
38
|
+
_AUTO = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Indisponible(Exception):
|
|
42
|
+
"""Le pont ne répond pas (absent, arrêté, délai dépassé)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Refus(Exception):
|
|
46
|
+
"""Le pont a répondu par une erreur (demande invalide, cible inconnue…)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code: str, message: str) -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code = code
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --------------------------------------------------------------------------- verdict
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Raison:
|
|
56
|
+
code: str
|
|
57
|
+
message: str
|
|
58
|
+
gravite: str = "block" # block · warn · info
|
|
59
|
+
plancher: bool = False # refus qui tient aussi en politique souple
|
|
60
|
+
sujet: str | None = None # ce qu'il faudrait autoriser : nom de commande, ou hôte
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Verdict:
|
|
65
|
+
issue: str # read · sensitive · outside (hors politique, à valider) · refused
|
|
66
|
+
niveau: str = "read" # niveau brut de la liste blanche : read · sensitive · denied
|
|
67
|
+
mode: str = "stricte"
|
|
68
|
+
raisons: tuple[Raison, ...] = ()
|
|
69
|
+
blocages: tuple[Raison, ...] = ()
|
|
70
|
+
ecritures: tuple[str, ...] = ()
|
|
71
|
+
reseau: tuple[str, ...] = ()
|
|
72
|
+
cd: str | None = None
|
|
73
|
+
commandes: tuple[str, ...] = field(default=())
|
|
74
|
+
modules: tuple[str, ...] = () # modules de fonctions à charger avant le mode contraint (NetTCPIP, NetSecurity…)
|
|
75
|
+
contraint: bool = False # réglage de la console : exécuter en mode de langage contraint
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def refusee(self) -> bool:
|
|
79
|
+
return self.issue == "refused"
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def lecture(self) -> bool:
|
|
83
|
+
return self.issue == "read"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def mode_contraint(self) -> bool:
|
|
87
|
+
"""La commande s'exécute en mode de langage contraint : PowerShell bloque alors lui-même les appels .NET
|
|
88
|
+
arbitraires, même si la politique était contournée. Vaut pour ce que la liste blanche a autorisé ; une
|
|
89
|
+
commande hors politique, que tu valides seul, garde le langage complet (un script en a souvent besoin)."""
|
|
90
|
+
return self.contraint and self.issue in ("read", "sensitive")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def codes(self) -> list[str]:
|
|
94
|
+
return [r.code for r in (self.blocages if self.refusee else self.raisons)]
|
|
95
|
+
|
|
96
|
+
def a_lire(self) -> list[Raison]:
|
|
97
|
+
"""Ce qu'il faut lire avant de valider : avertissements, et refus de la liste blanche levés par le mode souple."""
|
|
98
|
+
return [r for r in self.raisons if r.gravite in ("warn", "block")]
|
|
99
|
+
|
|
100
|
+
def conseils(self) -> list[str]:
|
|
101
|
+
"""Commandes de la TUI qui lèveraient le refus (aucune pour un plancher qui ne se lève pas)."""
|
|
102
|
+
out: list[str] = []
|
|
103
|
+
for r in self.blocages:
|
|
104
|
+
if r.code in ("destructive", "not-allowlisted") and r.sujet:
|
|
105
|
+
out.append(f"/permissions autoriser {r.sujet}")
|
|
106
|
+
elif r.code == "target-out-of-scope" and r.sujet:
|
|
107
|
+
out.append(f"/perimetre ajouter {r.sujet}")
|
|
108
|
+
elif not r.plancher and r.code not in ("not-found", "not-allowlisted") and self.mode == "stricte":
|
|
109
|
+
out.append("/politique souple")
|
|
110
|
+
return list(dict.fromkeys(out))
|
|
111
|
+
|
|
112
|
+
def resume(self) -> str:
|
|
113
|
+
"""Une ligne pour l'écran : la première raison du refus."""
|
|
114
|
+
if not self.blocages:
|
|
115
|
+
return ""
|
|
116
|
+
first = self.blocages[0].message
|
|
117
|
+
return first + (f" (+{len(self.blocages) - 1})" if len(self.blocages) > 1 else "")
|
|
118
|
+
|
|
119
|
+
def pour_modele(self) -> str:
|
|
120
|
+
"""Ce que le modèle reçoit à la place du résultat d'une commande refusée."""
|
|
121
|
+
reasons = " ; ".join(r.message for r in self.blocages) or "commande non autorisée"
|
|
122
|
+
text = ("Non exécutée : la politique de sécurité de Prométhée refuse cette commande (elle n'a pas été montrée à "
|
|
123
|
+
f"l'utilisateur). Raisons : {reasons}")
|
|
124
|
+
advice = self.conseils()
|
|
125
|
+
if advice:
|
|
126
|
+
text += (" L'utilisateur peut lever ce refus lui-même en tapant : " + " ou ".join(f"`{a}`" for a in advice)
|
|
127
|
+
+ ". Dis-le-lui en une phrase.")
|
|
128
|
+
return text + (" Ne contourne pas ce refus par une autre commande qui ferait la même chose. Si une commande de "
|
|
129
|
+
"lecture répond à la demande, utilise-la ; sinon explique le refus et arrête-toi.")
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def depuis(cls, data: dict) -> "Verdict":
|
|
133
|
+
def raison(r: dict) -> Raison:
|
|
134
|
+
return Raison(code=str(r.get("code") or "?"), message=str(r.get("plain") or r.get("message") or ""),
|
|
135
|
+
gravite=str(r.get("severity") or "block"), plancher=r.get("floor") is True,
|
|
136
|
+
sujet=r.get("subject") if isinstance(r.get("subject"), str) else None)
|
|
137
|
+
|
|
138
|
+
issue = data.get("outcome")
|
|
139
|
+
if issue not in ("read", "sensitive", "outside", "refused"):
|
|
140
|
+
raise Indisponible(f"verdict illisible (issue : {issue!r})")
|
|
141
|
+
return cls(
|
|
142
|
+
issue=issue, niveau=str(data.get("level") or "denied"), mode=str(data.get("mode") or "stricte"),
|
|
143
|
+
raisons=tuple(raison(r) for r in data.get("reasons") or [] if isinstance(r, dict)),
|
|
144
|
+
blocages=tuple(raison(r) for r in data.get("blocking") or [] if isinstance(r, dict)),
|
|
145
|
+
ecritures=tuple(str(w) for w in data.get("writes") or []),
|
|
146
|
+
reseau=tuple(str(h) for h in data.get("network") or []),
|
|
147
|
+
cd=data.get("cd") if isinstance(data.get("cd"), str) else None,
|
|
148
|
+
commandes=tuple(str(c.get("resolved") or c.get("name") or "") for c in data.get("commands") or [] if isinstance(c, dict)),
|
|
149
|
+
modules=tuple(str(m) for m in data.get("modules") or []),
|
|
150
|
+
contraint=data.get("constrained") is True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------- rendez-vous
|
|
155
|
+
def rendez_vous(fichier: Path | None = None) -> tuple[dict | None, str]:
|
|
156
|
+
"""Où trouver le pont. Renvoie (lien, raison si absent).
|
|
157
|
+
|
|
158
|
+
PROMETHEE_PLATEFORME : « aucune » (pas de politique partagée), ou le dossier de la console.
|
|
159
|
+
Sinon, le fichier plateforme.json que la console écrit dans Documents\\Promethee à son démarrage.
|
|
160
|
+
"""
|
|
161
|
+
forced = os.environ.get("PROMETHEE_PLATEFORME", "").strip()
|
|
162
|
+
if forced.lower() in ("aucune", "none", "0", "non"):
|
|
163
|
+
return None, "désactivée (PROMETHEE_PLATEFORME=aucune)"
|
|
164
|
+
if forced:
|
|
165
|
+
root = Path(forced)
|
|
166
|
+
lien = {"node": shutil.which("node") or "", "pont": str(root / "server" / "cli" / "pont.ts"),
|
|
167
|
+
"donnees": os.environ.get("PROMETHEE_DATA") or str(root / "data"), "racine": str(root), "port": 4317}
|
|
168
|
+
else:
|
|
169
|
+
try:
|
|
170
|
+
lien = json.loads((fichier or RENDEZ_VOUS).read_text(encoding="utf-8"))
|
|
171
|
+
except OSError:
|
|
172
|
+
return None, "console non reliée : lance « npm run lier » dans le dossier de la console"
|
|
173
|
+
except ValueError:
|
|
174
|
+
return None, f"fichier de liaison illisible ({fichier or RENDEZ_VOUS})"
|
|
175
|
+
if not isinstance(lien, dict):
|
|
176
|
+
return None, f"fichier de liaison illisible ({fichier or RENDEZ_VOUS})"
|
|
177
|
+
node, pont = str(lien.get("node") or ""), str(lien.get("pont") or "")
|
|
178
|
+
if not node or not (Path(node).is_file() or shutil.which(node)):
|
|
179
|
+
return None, "Node est introuvable : la console a besoin de Node pour rendre ses verdicts"
|
|
180
|
+
if not Path(pont).is_file():
|
|
181
|
+
return None, f"le pont de la console est introuvable ({pont}) : relance « npm run lier » dans son dossier"
|
|
182
|
+
if not isinstance(lien.get("donnees"), str) or not lien["donnees"]:
|
|
183
|
+
return None, "fichier de liaison incomplet (dossier des données)"
|
|
184
|
+
return lien, ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- pont (processus Node)
|
|
188
|
+
class Pont:
|
|
189
|
+
"""Le processus du pont : une demande par ligne sur son entrée, une réponse par ligne sur sa sortie."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, lien: dict, env: dict[str, str]) -> None:
|
|
192
|
+
self.lien = lien
|
|
193
|
+
self.env = {**env, "PROMETHEE_DATA": str(lien["donnees"])}
|
|
194
|
+
self.version = ""
|
|
195
|
+
self._proc: subprocess.Popen | None = None
|
|
196
|
+
self._attentes: dict[int, queue.Queue] = {}
|
|
197
|
+
self._verrou = threading.Lock()
|
|
198
|
+
self._pret = threading.Event()
|
|
199
|
+
self._numero = 0
|
|
200
|
+
self._erreurs: list[str] = []
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def vivant(self) -> bool:
|
|
204
|
+
return self._proc is not None and self._proc.poll() is None
|
|
205
|
+
|
|
206
|
+
def demarrer(self, delai: float = DELAI_DEMARRAGE) -> None:
|
|
207
|
+
try:
|
|
208
|
+
self._proc = subprocess.Popen(
|
|
209
|
+
[str(self.lien["node"]), str(self.lien["pont"])], env=self.env, stdin=subprocess.PIPE,
|
|
210
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=CREATE_NO_WINDOW,
|
|
211
|
+
cwd=str(Path(str(self.lien["pont"])).parent),
|
|
212
|
+
)
|
|
213
|
+
except OSError as err:
|
|
214
|
+
raise Indisponible(f"le pont n'a pas pu être lancé ({err})") from err
|
|
215
|
+
if self._proc.stdin is None or self._proc.stdout is None:
|
|
216
|
+
raise Indisponible("le pont n'a pas pu être lancé")
|
|
217
|
+
threading.Thread(target=self._lire, daemon=True).start()
|
|
218
|
+
threading.Thread(target=self._lire_erreurs, daemon=True).start()
|
|
219
|
+
if not self._pret.wait(delai):
|
|
220
|
+
detail = self.erreur()
|
|
221
|
+
self.fermer()
|
|
222
|
+
raise Indisponible("le pont n'a pas démarré" + (f" : {detail}" if detail else f" en {delai:.0f} s"))
|
|
223
|
+
|
|
224
|
+
def erreur(self) -> str:
|
|
225
|
+
"""Dernière ligne utile écrite par le pont sur sa sortie d'erreur."""
|
|
226
|
+
lines = [l for l in self._erreurs if l.strip() and "ExperimentalWarning" not in l and "--trace-warnings" not in l]
|
|
227
|
+
return lines[-1].strip()[:300] if lines else ""
|
|
228
|
+
|
|
229
|
+
def _lire(self) -> None:
|
|
230
|
+
proc = self._proc
|
|
231
|
+
try:
|
|
232
|
+
for raw in proc.stdout:
|
|
233
|
+
try:
|
|
234
|
+
message = json.loads(raw.decode("utf-8"))
|
|
235
|
+
except ValueError:
|
|
236
|
+
continue
|
|
237
|
+
if not isinstance(message, dict):
|
|
238
|
+
continue
|
|
239
|
+
if message.get("ready"):
|
|
240
|
+
self.version = str(message.get("version") or "")
|
|
241
|
+
self._pret.set()
|
|
242
|
+
continue
|
|
243
|
+
with self._verrou:
|
|
244
|
+
attente = self._attentes.pop(message.get("id"), None)
|
|
245
|
+
if attente is not None:
|
|
246
|
+
attente.put(message)
|
|
247
|
+
except (OSError, ValueError):
|
|
248
|
+
pass
|
|
249
|
+
# Le pont s'est arrêté : on libère ceux qui attendaient encore une réponse
|
|
250
|
+
with self._verrou:
|
|
251
|
+
attentes, self._attentes = list(self._attentes.values()), {}
|
|
252
|
+
for attente in attentes:
|
|
253
|
+
attente.put(None)
|
|
254
|
+
|
|
255
|
+
def _lire_erreurs(self) -> None:
|
|
256
|
+
proc = self._proc
|
|
257
|
+
try:
|
|
258
|
+
for raw in proc.stderr:
|
|
259
|
+
self._erreurs.append(raw.decode("utf-8", "replace"))
|
|
260
|
+
del self._erreurs[:-20]
|
|
261
|
+
except (OSError, ValueError):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def demander(self, op: str, delai: float = DELAI_DEMANDE, **champs) -> dict:
|
|
265
|
+
if not self.vivant:
|
|
266
|
+
raise Indisponible("le pont est arrêté" + (f" : {self.erreur()}" if self.erreur() else ""))
|
|
267
|
+
attente: queue.Queue = queue.Queue(maxsize=1)
|
|
268
|
+
with self._verrou:
|
|
269
|
+
self._numero += 1
|
|
270
|
+
numero = self._numero
|
|
271
|
+
self._attentes[numero] = attente
|
|
272
|
+
try:
|
|
273
|
+
self._proc.stdin.write((json.dumps({"id": numero, "op": op, **champs}, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
274
|
+
self._proc.stdin.flush()
|
|
275
|
+
except (OSError, ValueError) as err:
|
|
276
|
+
self._attentes.pop(numero, None)
|
|
277
|
+
raise Indisponible(f"le pont ne reçoit plus les demandes ({err})") from err
|
|
278
|
+
try:
|
|
279
|
+
reponse = attente.get(timeout=delai)
|
|
280
|
+
except queue.Empty:
|
|
281
|
+
with self._verrou:
|
|
282
|
+
self._attentes.pop(numero, None)
|
|
283
|
+
raise Indisponible(f"le pont n'a pas répondu en {delai:.0f} s") from None
|
|
284
|
+
if reponse is None:
|
|
285
|
+
raise Indisponible("le pont s'est arrêté pendant la demande" + (f" : {self.erreur()}" if self.erreur() else ""))
|
|
286
|
+
if not reponse.get("ok"):
|
|
287
|
+
error = reponse.get("error") or {}
|
|
288
|
+
raise Refus(str(error.get("code") or "erreur"), str(error.get("message") or "demande refusée par le pont"))
|
|
289
|
+
result = reponse.get("result")
|
|
290
|
+
return result if isinstance(result, dict) else {}
|
|
291
|
+
|
|
292
|
+
def fermer(self) -> None:
|
|
293
|
+
proc, self._proc = self._proc, None
|
|
294
|
+
if proc is None:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
if proc.stdin:
|
|
298
|
+
proc.stdin.close() # fin de l'entrée : le pont s'arrête de lui-même
|
|
299
|
+
proc.wait(timeout=2)
|
|
300
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
301
|
+
try:
|
|
302
|
+
proc.kill()
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------- service utilisé par l'application
|
|
308
|
+
class Politique:
|
|
309
|
+
"""Ce que l'application demande à la politique : un verdict, la liste blanche, le périmètre."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, mode: str = "stricte", lien=_AUTO, environnement=refreshed_env) -> None:
|
|
312
|
+
self.mode = mode if mode in MODES else "stricte"
|
|
313
|
+
if lien is _AUTO:
|
|
314
|
+
self.lien, self.raison = rendez_vous()
|
|
315
|
+
else:
|
|
316
|
+
self.lien, self.raison = lien, "" if lien else "aucune console reliée"
|
|
317
|
+
self._environnement = environnement
|
|
318
|
+
self._pont: Pont | None = None
|
|
319
|
+
self._verrou = threading.Lock()
|
|
320
|
+
self._retenter_a = 0.0
|
|
321
|
+
self._path = ""
|
|
322
|
+
self.version = ""
|
|
323
|
+
|
|
324
|
+
# ---- état
|
|
325
|
+
@property
|
|
326
|
+
def reliee(self) -> bool:
|
|
327
|
+
return self.lien is not None
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def prete(self) -> bool:
|
|
331
|
+
return self._pont is not None and self._pont.vivant
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def etat(self) -> str:
|
|
335
|
+
"""« prête », « non reliée », « indisponible » ou « en attente » (pas encore démarrée)."""
|
|
336
|
+
if not self.reliee:
|
|
337
|
+
return "non reliée"
|
|
338
|
+
if self.prete:
|
|
339
|
+
return "prête"
|
|
340
|
+
return "indisponible" if self.raison else "en attente"
|
|
341
|
+
|
|
342
|
+
def libelle(self) -> str:
|
|
343
|
+
"""Texte court pour la barre d'état."""
|
|
344
|
+
if self.prete or self.etat == "en attente":
|
|
345
|
+
return f"politique {self.mode}"
|
|
346
|
+
return "validation manuelle"
|
|
347
|
+
|
|
348
|
+
# ---- pont
|
|
349
|
+
def _pont_pret(self) -> Pont:
|
|
350
|
+
"""Le pont, démarré au besoin. Relancé si le PATH a changé (logiciel installé pendant la session)."""
|
|
351
|
+
if self.lien is None:
|
|
352
|
+
raise Indisponible(self.raison or "aucune console reliée")
|
|
353
|
+
env = self._environnement()
|
|
354
|
+
path = env.get("PATH", "")
|
|
355
|
+
if self._pont is not None and self._pont.vivant and path == self._path:
|
|
356
|
+
return self._pont
|
|
357
|
+
if self._pont is not None:
|
|
358
|
+
self._pont.fermer()
|
|
359
|
+
self._pont = None
|
|
360
|
+
elif time.monotonic() < self._retenter_a:
|
|
361
|
+
raise Indisponible(self.raison or "le pont n'a pas démarré")
|
|
362
|
+
pont = Pont(self.lien, env)
|
|
363
|
+
try:
|
|
364
|
+
pont.demarrer()
|
|
365
|
+
except Indisponible as err:
|
|
366
|
+
self.raison = str(err)
|
|
367
|
+
self._retenter_a = time.monotonic() + ATTENTE_APRES_ECHEC
|
|
368
|
+
raise
|
|
369
|
+
self._pont, self._path, self.raison, self.version = pont, path, "", pont.version
|
|
370
|
+
return pont
|
|
371
|
+
|
|
372
|
+
def demarrer(self) -> bool:
|
|
373
|
+
"""Lance le pont à l'avance (au démarrage de la TUI). Renvoie False s'il n'est pas disponible."""
|
|
374
|
+
with self._verrou:
|
|
375
|
+
try:
|
|
376
|
+
self._pont_pret()
|
|
377
|
+
return True
|
|
378
|
+
except Indisponible:
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
def _appel(self, op: str, **champs) -> dict:
|
|
382
|
+
with self._verrou:
|
|
383
|
+
pont = self._pont_pret()
|
|
384
|
+
try:
|
|
385
|
+
return pont.demander(op, **champs)
|
|
386
|
+
except Indisponible as err:
|
|
387
|
+
self.raison = str(err)
|
|
388
|
+
pont.fermer()
|
|
389
|
+
self._pont = None
|
|
390
|
+
self._retenter_a = time.monotonic() + ATTENTE_APRES_ECHEC
|
|
391
|
+
raise
|
|
392
|
+
|
|
393
|
+
def fermer(self) -> None:
|
|
394
|
+
with self._verrou:
|
|
395
|
+
if self._pont is not None:
|
|
396
|
+
self._pont.fermer()
|
|
397
|
+
self._pont = None
|
|
398
|
+
|
|
399
|
+
# ---- demandes
|
|
400
|
+
def verdict(self, commande: str, cwd: Path | str) -> Verdict | None:
|
|
401
|
+
"""Verdict de la politique, ou None si elle n'est pas disponible (validation manuelle)."""
|
|
402
|
+
try:
|
|
403
|
+
return Verdict.depuis(self._appel("verdict", command=commande, cwd=str(cwd), mode=self.mode))
|
|
404
|
+
except Refus as err:
|
|
405
|
+
# Le pont refuse la demande elle-même (commande trop longue…) : c'est un refus, pas une panne
|
|
406
|
+
reason = Raison(code=err.code, message=str(err), plancher=True)
|
|
407
|
+
return Verdict(issue="refused", niveau="denied", mode=self.mode, raisons=(reason,), blocages=(reason,))
|
|
408
|
+
except Indisponible:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
def resume(self) -> dict | None:
|
|
412
|
+
"""Liste blanche (nombres par niveau, ajouts, déblocages), dossiers d'écriture et cibles. None si indisponible."""
|
|
413
|
+
try:
|
|
414
|
+
return self._appel("politique")
|
|
415
|
+
except (Indisponible, Refus):
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
def autoriser(self, noms: list[str]) -> dict:
|
|
419
|
+
return self._appel("autoriser", names=noms)
|
|
420
|
+
|
|
421
|
+
def retirer(self, noms: list[str]) -> dict:
|
|
422
|
+
return self._appel("retirer", names=noms)
|
|
423
|
+
|
|
424
|
+
def ajouter_cible(self, hote: str, note: str = "", sous_domaines: bool = False) -> dict:
|
|
425
|
+
"""Déclare une cible. N'est appelée qu'après l'attestation de l'utilisateur."""
|
|
426
|
+
return self._appel("cible-ajouter", host=hote, note=note, includeSubdomains=sous_domaines, attest=True)
|
|
427
|
+
|
|
428
|
+
def retirer_cible(self, hote: str) -> dict:
|
|
429
|
+
return self._appel("cible-retirer", host=hote)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Préférences gardées d'un lancement à l'autre : politique d'exécution, fond de l'interface et, quand la TUI n'est pas
|
|
2
|
+
reliée à la console, la recherche web (section « search », voir recherche.py)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import SAVE_DIR
|
|
10
|
+
|
|
11
|
+
FICHIER = SAVE_DIR / "preferences.json"
|
|
12
|
+
# politique : « stricte » (liste blanche appliquée) ou « souple » (le hors-liste passe à ta validation)
|
|
13
|
+
# fond : « promethee » (fond peint par l'application) ou « terminal » (fond de ton terminal)
|
|
14
|
+
DEFAUT = {"politique": "stricte", "fond": "promethee"}
|
|
15
|
+
VALEURS = {"politique": ("stricte", "souple"), "fond": ("promethee", "terminal")}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _brut(cible: Path) -> dict:
|
|
19
|
+
"""Le fichier tel qu'il est enregistré, ou {} s'il est absent ou illisible."""
|
|
20
|
+
try:
|
|
21
|
+
saved = json.loads(cible.read_text(encoding="utf-8"))
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return {}
|
|
24
|
+
return saved if isinstance(saved, dict) else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _enregistrer(cible: Path, data: dict) -> None:
|
|
28
|
+
cible.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
tmp = cible.with_name(f".{cible.name}.tmp")
|
|
30
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
31
|
+
os.replace(tmp, cible)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lire(fichier: Path | None = None) -> dict[str, str]:
|
|
35
|
+
"""Préférences enregistrées. Une valeur absente, illisible ou inconnue prend sa valeur par défaut."""
|
|
36
|
+
prefs = dict(DEFAUT)
|
|
37
|
+
saved = _brut(fichier or FICHIER)
|
|
38
|
+
for cle, permises in VALEURS.items():
|
|
39
|
+
if saved.get(cle) in permises:
|
|
40
|
+
prefs[cle] = saved[cle]
|
|
41
|
+
return prefs
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ecrire(fichier: Path | None = None, **changements: str) -> dict[str, str]:
|
|
45
|
+
"""Enregistre les préférences modifiées et renvoie l'ensemble. Une valeur inconnue lève ValueError.
|
|
46
|
+
Les autres sections du fichier (la recherche web) restent telles quelles."""
|
|
47
|
+
cible = fichier or FICHIER
|
|
48
|
+
prefs = lire(cible)
|
|
49
|
+
for cle, valeur in changements.items():
|
|
50
|
+
if valeur not in VALEURS.get(cle, ()):
|
|
51
|
+
raise ValueError(f"valeur inconnue pour « {cle} » : {valeur}")
|
|
52
|
+
prefs[cle] = valeur
|
|
53
|
+
_enregistrer(cible, {**_brut(cible), **prefs})
|
|
54
|
+
return prefs
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lire_section(nom: str, fichier: Path | None = None):
|
|
58
|
+
"""Une section enregistrée telle quelle (None si elle manque). C'est à l'appelant de la valider."""
|
|
59
|
+
return _brut(fichier or FICHIER).get(nom)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ecrire_section(nom: str, valeur, fichier: Path | None = None) -> None:
|
|
63
|
+
"""Enregistre une section sans toucher aux autres."""
|
|
64
|
+
cible = fichier or FICHIER
|
|
65
|
+
_enregistrer(cible, {**_brut(cible), nom: valeur})
|