# Sécurité

Le Hub exécute, avec vos droits et dans vos applications, des scripts qu'un modèle a écrits. Cette page dit ce que le Hub borne, les risques qui restent et ce qui quitte votre machine, et donne les mesures rejouées pour elle. Elle s'adresse à qui décide s'il faut brancher un modèle sur ses applications.

Les mesures ont été faites sous Debian 13 avec le Hub 0.1.0 compilé depuis ses sources ; celles qui portent sur un Hub en marche ont utilisé un répertoire personnel jetable avec son propre jeton.

> **Limite**
>
> Le Hub n'est pas un bac à sable. Un script s'exécute avec les droits de votre utilisateur, dans une application dont le Python peut faire tout ce que vous faites. Le garde-fou de script arrête un script maladroit, pas un script déterminé. Le port UNO de LibreOffice n'a pas d'authentification, et un autre processus de votre utilisateur peut lire le fichier du jeton. Un script qui boucle garde son application occupée jusqu'à ce que vous la fermiez, sauf en mode `cli`.


## Ce qui quitte la machine

Tout ce que le Hub renvoie va au modèle de langage, donc à qui l'exploite.

| Donnée | Quitte la machine |
|---|---|
| Le script que le modèle a écrit | Oui : le modèle en est l'auteur |
| `stdout`, `stderr` et `result` d'un script | Oui : ils peuvent reprendre le contenu d'un document |
| La trace d'erreur | Oui : elle contient des chemins de la machine |
| `state` | Oui : il contient du contenu tiré des documents ouverts |
| Le chemin d'une capture | Oui : il fait partie de la réponse |
| L'image d'une capture | Seulement si le modèle lit le fichier |
| Le journal d'audit, le jeton et la configuration | Jamais |

Un utilisateur qui ouvre un document confidentiel dans une application connectée sait que le modèle peut en lire le contenu. Lors du rejeu, `get_state` a renvoyé l'étiquette `Notes` d'un calque qu'un script avait créé, avec `content_origin` à `document` ; cette étiquette se lit comme se lit le contenu d'un document.

Le Hub ne se connecte qu'aux ponts sur `127.0.0.1`. Lancé sous `strace -f -e trace=connect,bind,listen` pendant une session qui comprenait un script qui dépassait son délai, le Hub a fait au total un seul `connect` IP : vers `127.0.0.1`, sur le port du pont de Blender. `cargo test --locked --test invariants` lit les sources à la recherche de toute autre connexion.

## Qui peut atteindre un pont

Un pont écoute sur `127.0.0.1` seulement, accepte une connexion à la fois et répond à un client qui ouvre avec le jeton. `ss -ltn` a montré un seul socket en écoute pour le pont de Blender, sur le port réglé pour l'exécution : `127.0.0.1:9313`. Quatre sondes lui ont été envoyées avant la connexion du Hub :

- Un `hello` avec un mauvais jeton : 0 octet en retour, et la connexion fermée aussitôt.
- Une requête HTTP : 0 octet en retour, et la connexion fermée aussitôt.
- Rien d'envoyé : 0 octet en retour, et la connexion fermée après 5,0 s.
- Un `hello` avec le bon jeton : 99 octets en retour, la réponse au `hello`, qui nomme l'application et les versions et ne contient pas le jeton. La connexion est restée ouverte.

Le port UNO propre à LibreOffice n'a pas d'authentification. Avec `soffice` en écoute sur `127.0.0.1:2102` et aucun jeton présenté, un client Python utilisant `python3-uno` a lu la version de LibreOffice, `25.2.3.2`, ouvert un document Calc et y a écrit une cellule. Ce chemin contourne le jeton, le garde-fou de script et le journal d'audit.

## Ce que le Hub borne

Les limites sont des clés de la section `[hub]` de `config.toml`, dans le fichier qu'écrit `init` ; les valeurs ci-dessous sont les valeurs par défaut qu'il liste.

- **Taille du script**, `max_script_bytes`, 262 144 octets : un script de 300 009 octets a été refusé avec `script is 300009 bytes; the limit is 262144 bytes`.
- **Sortie par flux**, `max_output_bytes`, 1 048 576 octets : un script qui imprimait 2 000 000 de caractères a renvoyé un `stdout` de 1 048 576 octets et `"truncated": true`.
- **Durée**, `default_timeout_s`, 30 s : en mode `cli`, `timeout` après 30,0 s, aucun processus fils laissé, et l'appel suivant `ok` en 31 ms. En mode `tcp`, `timeout` après 31,2 s (32,0 s lors de la seconde exécution), puis `busy` pour l'appel suivant.
- **Un script à la fois par application** : le `busy` ci-dessus. En mode `tcp`, l'application n'a pas été arrêtée ; elle a mené son script de 40 secondes à son terme, et le Hub a répondu de nouveau `ok` 40,3 s après le départ (40,1 s lors de la seconde exécution).

Seul le mode `cli` tue un script qui dépasse son délai. Dans les modes `tcp` et `uno`, le Hub ne peut pas interrompre un script à l'intérieur de l'application : il répond `timeout`, refuse les nouveaux scripts avec `busy`, et attend.

## Le garde-fou de script

Le garde-fou lit le texte d'un script avant son envoi. Un script qui appelait `os.remove` a été refusé avant d'atteindre Inkscape, avec la catégorie, la construction et la ligne : `G-1 (file deletion) at line 3: os.remove`. Quand un chemin ne peut pas être vérifié à partir du texte, le garde-fou laisse passer le script et ajoute un avertissement dans `stderr`, par exemple `path not statically verifiable (open())`.

Un script qui a écrit un second fichier et l'a exécuté avec `runpy` a supprimé un fichier qu'il avait créé, et le Hub a répondu `ok`. C'est la limite que le README du dépôt énonce : le garde-fou ne voit ni `runpy`, ni `pickle`, ni `http.server`, ni les fonctions d'enregistrement des applications, ni un chemin passé en paramètre à une fonction. L'opérateur lève le garde-fou avec `allow_dangerous_scripts = true` dans la section `[security]` ; les deux outils ne prennent aucun paramètre de ce genre, et le journal enregistre alors `guard: bypassed`.

Sur les faux positifs du garde-fou, `cargo test --locked --test guard realistic -- --nocapture` a imprimé `false positives: 0 of 21 realistic scripts (0.0%)`.

## Fichiers, droits et refus de démarrer

Après `init`, `config.toml` et `token` avaient tous deux les droits 600. Une capture demandée par `get_state` était un fichier aux droits 600 dans un répertoire aux droits 700, sous `XDG_RUNTIME_DIR` ; elle y était encore après la sortie du Hub avec le code 0.

Le Hub a refusé de démarrer, avec le code de sortie 1 et une ligne d'explication, dans cinq cas :

| Cas | Message, sans le chemin |
|---|---|
| le fichier du jeton aux droits 0640 | `has mode 0640; expected 0600 (chmod 600)` |
| pas de fichier de jeton | `does not exist` |
| `config.toml` aux droits 0644 | `configuration file refused: … has mode 0644; expected 0600 (chmod 600)` |
| le répertoire d'exécution du Hub aux droits 0755 | `has mode 0755; expected 0700 (chmod 700)` |
| `XDG_RUNTIME_DIR` non définie | `XDG_RUNTIME_DIR is not set; refusing to write volatile files elsewhere` |

La valeur du jeton n'apparaissait dans aucun de ces messages. `libremodular-launch libreoffice` a refusé un fichier de pont aux droits 0664 avant de démarrer quoi que ce soit, avec le code de sortie 2 : `has mode 0664 and can be changed by the group or by others (chmod go-w)`.

## Le journal d'audit

Chaque appel écrit une ligne dans `audit.jsonl` du répertoire d'état, aux droits 600, `get_state` compris. Quatre appels (un script exécuté, un script refusé par le garde-fou, un qui a levé une erreur, un pour une application désactivée) ont écrit quatre lignes. Une ligne contient la date, l'outil, la cible, le statut, la durée, la taille et le SHA-256 du script, le résultat du garde-fou et la taille de la sortie :

```json
{
  "ts": "2026-10-21T08:56:27.564Z",
  "tool": "execute_script",
  "target_app": "inkscape",
  "status": "refused",
  "duration_ms": 0,
  "script_bytes": 60,
  "script_sha256": "3696b8a4f24f23da34ba239bbb401c56cd78fb53c2bef48ab2f7d668fd1fdbf8",
  "guard": [
    "G-1"
  ],
  "output_bytes": 524
}
```

Le fichier écrit chaque ligne sur une seule ligne ; elle est coupée ici pour être lue.

Ni la chaîne repère placée dans les scripts, ni le jeton n'ont été retrouvés dans le journal. Le texte du script n'est écrit que si l'opérateur règle `audit_script_text = true`.

## Les tests du dépôt

Ces commandes sont celles de l'intégration continue du dépôt. Elles ont été exécutées hors du dépôt (`CARGO_TARGET_DIR`).

| Commande | Résultat |
|---|---|
| `cargo fmt --check` | code de sortie 0 |
| `cargo clippy --all-targets --locked -- -D warnings` | code de sortie 0 |
| `cargo test --locked` | 328 réussis, 0 échoué, 3 ignorés : deux demandent un vrai LibreOffice, un demande un utilisateur qui n'est pas root |
| `cargo test --locked --test invariants` | 36 réussis |
| `python3 scripts/build_bridges.py --check` | code de sortie 0 |
| `python3 -m unittest discover -s tests/bridges` | 189 tests, OK, 6 ignorés : deux demandent root, quatre ne valent que sous Windows |

Pour signaler une vulnérabilité, voir [À propos](https://libremodular.davalan.fr/fr/about/index.md#signaler-une-vulnérabilité).

