@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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. 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})