Aller au contenu
jeune projet · systemd · docker · paquets
gilfoyle — systèmes. réseau. sécurité.
gilfoyle@host:~$

Je n'ai pas de shell.

Je suis un serveur MCP pour hôtes Linux. Ton agent IA me joint par SSH et appelle un ensemble fixe d'outils. Il ne peut pas lancer de commande : je ne sais pas en lancer.

Rust · non privilégié · clés publiques uniquement · refus par défaut

nestor@web1ssh
$ssh gilfoyle@web1
·authentifié comme « nestor ». sous-système gilfoyle-mcp, pas de shell.
$ssh gilfoyle@web1 exec "rm -rf /"
✗refusé. exec n'est pas dans mon vocabulaire.
$mcp › start_unit nginx.service
✓fait. nginx.service est actif.
$mcp › stop_unit sshd.service
✗refusé. sshd.service n'est pas dans manage_units.
$
les refus netspas de shellpas de rootpas d'execpas de créationpas d'installationpas de suppression
[01]pourquoi

Tu as donné un shell à une IA. Audacieux. Idiot aussi.

Avec un shell, tout ce que le modèle écrit peut s'exécuter comme commande. Une injection de prompt, une hallucination, une faute de frappe : tout part.

Je ne prends pas de commandes. J'expose une liste fixe de fonctions compilées. Les lectures sont libres. Je ne modifie que ce que tu as autorisé : démarrer et arrêter des unités et des conteneurs, rafraîchir et mettre à jour des paquets. Je n'installe ni ne supprime jamais rien.

“Je ne fais pas confiance à ton prompt. Je fais confiance au compilateur.”

— gilfoyle, architecte systèmes

[02]refus

Je refuse plus que je n'accepte.

Aucun de ces refus n'est une option de configuration. Le code ne sait simplement pas le faire.

ce que je fais

  • +lireuptime, unités, conteneurs, paquets. Toute clé peut lire.
  • +démarrer / arrêter / redémarrerseulement les unités et conteneurs que tu as autorisés
  • +rafraîchir / mettre à jourles paquets, si tu l'autorises. De confiance uniquement.
  • +agrégerune seule surface MCP pour plusieurs nœuds

ce que je refuse

  • −shelljamais servi
  • −execcette requête n'existe pas
  • −ptyrefusé dans le code
  • −redirectionlocale, distante, agent : tout est refusé
  • −rootje tourne sans privilèges et ne les élève jamais
  • −installercette opération n'existe pas
  • −supprimercette opération n'existe pas
[03]garanties

Ce qui est imposé, et par quoi.

Certaines garanties viennent du compilateur, d'autres du noyau, d'autres d'une configuration que tu contrôles. Chaque carte dit laquelle.

01compilateur

Aucune exécution de code

Les outils sont des fonctions compilées. Rien n'accepte une commande, un script ou un chemin à exécuter. unsafe est interdit, et lancer un processus (std::process::Command) est une erreur de lint.

02code

SSH est un protocole, pas un shell

Seul le sous-système gilfoyle-mcp est servi. shell, exec, pty et toute demande de redirection sont refusés.

03code

Ni installation, ni suppression, ni création

Les opérations sur les paquets forment une énumération fermée, sans install ni remove. Docker n'a aucune requête de création ou de suppression. Une mise à jour qui installerait ou supprimerait un paquet est refusée.

04code

Clés publiques uniquement

none, password et keyboard-interactive sont refusés. Une clé peut être limitée à un sous-ensemble d'outils, jamais élargie au-delà de la liste globale.

05unité systemd

Pas root

Un utilisateur dédié sans privilèges, NoNewPrivileges, aucune capacité. Je n'élève jamais mes privilèges ; polkit décide de ce que systemd et les paquets peuvent faire.

06config

Les modifications sont refusées par défaut

Les listes d'autorisation sont vides par défaut : [systemd] manage_units, [docker] manage_containers, [packagekit] allow. Rien n'est démarré, arrêté ou mis à jour tant que tu ne l'as pas listé.

07lint

Aucun sous-processus

systemd et PackageKit sont joints par D-Bus, Docker par son socket UNIX, le tout dans mon processus. Je ne lance jamais systemctl, apt ou docker.

08cargo

Un cœur sans E/S

Le cœur du domaine n'a ni réseau, ni système de fichiers, ni table de processus. Les E/S vivent dans les adaptateurs, et le graphe de dépendances Cargo les y garde.

09démarrage

Refus par défaut au démarrage

Pas de bind, pas de clé ou pas de clé d'hôte : je ne démarre pas. Le client vérifie l'empreinte de la clé d'hôte de chaque nœud et coupe la connexion en cas de différence.

[04]défense en profondeur

Essaie de percer. Je patiente.

Plusieurs couches, chacune avec son rôle. Voici ce que rencontrent les attaques habituelles.

01Réseau

clés publiques uniquement. Ni mot de passe, ni keyboard-interactive.

02Transport

seul le sous-système gilfoyle-mcp. shell, exec, pty et redirection refusés.

03Identité

chaque clé peut être limitée à un sous-ensemble d'outils

04Outils

un ensemble fixe de fonctions compilées. Aucune ne prend une commande ou un chemin.

05Autorité

listes d'autorisation vides par défaut. polkit derrière systemd et les paquets.

06Processus

utilisateur sans privilèges, aucune capacité, filtre d'appels système

07Noyau

système de fichiers en lecture seule, pas de mémoire à la fois inscriptible et exécutable, namespaces restreints

↓ le cœur

  • ouvrir un shellpolitique de canauxrefusé
  • lancer une commande (exec)politique de canauxrefusé
  • demander un ptypolitique de canauxrefusé
  • rediriger un portpolitique de canauxrefusé
  • se connecter par mot de passeclés publiques uniquementrefusé
  • modifier avec une clé en lecture seuleautorisation d'outils par clérefusé
  • injecter une commande dans un argumentaucun shell pour l'interpréterrien ne s'exécute
  • lire un fichier arbitraireaucun outil ne prend de cheminoutil inexistant
  • lancer un processuslint sur std::process::Commandle build échoue
  • installer ou supprimer un paqueténumération ferméeopération inexistante
  • créer ou supprimer un conteneurensemble de requêtes fermérequête inexistante
  • démarrer une unité non listée[systemd] manage_unitsrefusé
  • démarrer un conteneur non listé[docker] manage_containersrefusé
  • obtenir des privilèges en plusNoNewPrivileges, aucune capacitérefusé
  • se faire passer pour un nœud (MITM)empreinte de clé d'hôte épingléeconnexion coupée
refusé à l'exécutionimpossible à écrire
[05]durcissement

Verrouillé par défaut.

L'unité systemd que je livre active tout ça.

/etc/systemd/system/gilfoyle.servicetelle que livrée
User=gilfoyle
Group=gilfoyle
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=∅
AmbientCapabilities=∅
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadOnlyPaths=/proc/uptime

Isolation du processus

  • ✓NoNewPrivileges
  • ✓LockPersonality
  • ✓MemoryDenyWriteExecute
  • ✓RestrictNamespaces
  • ✓RestrictRealtime
  • ✓RestrictSUIDSGID

Système de fichiers

  • ✓ProtectSystem=strict
  • ✓ProtectHome
  • ✓PrivateTmp
  • ✓PrivateDevices
  • ✓ReadOnlyPaths=/proc/uptime

Capacités & appels système

  • ✓CapabilityBoundingSet= (vide)
  • ✓AmbientCapabilities= (vide)
  • ✓SystemCallFilter=@system-service
  • ✓SystemCallErrorNumber=EPERM
  • ✓SystemCallArchitectures=native

Surface noyau

  • ✓ProtectKernelTunables
  • ✓ProtectKernelModules
  • ✓ProtectControlGroups
  • ✓ProtectClock
  • ✓ProtectHostname

Réseau

  • ✓RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

À la compilation

  • ✓unsafe interdit dans tout le workspace
  • ✓std::process::Command est une erreur de lint
  • ✓std::process::exit est banni : arrêt propre uniquement

Docker n'a pas de polkit

Le socket Docker vaut root : un conteneur privilégié peut monter l'hôte. Mets devant un proxy de socket qui n'autorise que les endpoints que j'utilise, plutôt que d'ajouter mon utilisateur au groupe docker. manage_containers est vide par défaut, et l'action porte sur l'id résolu du conteneur : un renommage ne peut pas la rediriger.

/proc/uptime est le seul fichier de l'hôte que je lis. systemd et PackageKit passent par le bus système et polkit décide. Docker passe par son socket, sans polkit.

[06]qualité

Chaque changement doit passer par là.

make check lance le formateur, clippy, les tests et le test de mutation. Un seul mutant survivant le fait échouer.

make checkscore de mutation exigé
fmt --checkclippy -D warningstestscargo-mutants100 %

Test de mutation : 100 %

cargo-mutants modifie le code exprès. Si les tests passent quand même, c'est un échec. Seuls main.rs et la fine couche de colle russh, rmcp et hyper sont exclus ; leurs décisions vivent dans des modules testés.

Fuzzing, sous ASan

Huit cibles couvrent les parseurs : uptime, clés autorisées, config, config client, noms d'unités, ids de paquets, références de conteneurs et liste d'autorisation des conteneurs. Elles tournent en nightly avec AddressSanitizer.

Chaîne d'approvisionnement

cargo-deny : aucun avis ignoré, une liste de licences autorisées, registres et sources git inconnus refusés, openssl banni.

La structure est imposée

Le cœur ne peut pas importer un adaptateur : le graphe de dépendances Cargo l'interdit. Un trait par capacité.

Testé sans hôte

Aucun démon requis. Les adaptateurs systemd et PackageKit tournent sur des bus D-Bus simulés dans le processus, Docker sur un serveur HTTP simulé sur un socket UNIX, SSH sur un client et un serveur russh dans le même processus.

Les secrets restent hors des logs

Aucun secret n'arrive dans les logs ni les messages d'erreur. Les clés client doivent être en 0600 et les empreintes de clés d'hôte sont épinglées.

[07]les outils

Tout le vocabulaire. C'est tout.

Chaque outil et ses arguments. Si ce n'est pas ici, je ne le fais pas.

Hôte/proc/uptime
  • get_uptime—uptime de l'hôte, depuis le démarrage du noyaulecture
systemdD-Bus, dans le processus
  • list_unitsactive_state?, name_contains?, unit_type?, limit?lister les unitéslecture
  • unit_statusnameétat détaillé d'une unitélecture
  • start_unitnamedémarrer une unité autoriséemutation
  • stop_unitnamearrêter une unité autoriséemutation
Conteneurssocket Docker, dans le processus
  • list_containersstate?, name_contains?, limit?lister les conteneurslecture
  • container_statuscontainerétat détaillé d'un conteneurlecture
  • start_containercontainerdémarrer un conteneur autorisémutation
  • stop_containercontainerarrêter un conteneur autorisémutation
  • restart_containercontainerredémarrer un conteneur autorisémutation
PaquetsPackageKit via D-Bus, dans le processus
  • list_packagesname_contains?, limit?paquets installés et versionslecture
  • list_updatesmin_severity?, limit?mises à jour disponibleslecture
  • preview_upgrade—simuler une mise à jour, ne rien changerlecture
  • refresh_package_cacheforce?rafraîchir les métadonnées (apt update)mutation
  • upgrade_packages—appliquer les mises à jour (apt upgrade)mutation

Via le client, chaque outil prend un argument node. node=all lance une lecture sur tous les nœuds, et list_nodes indique lesquels sont joignables.

[08]topologie

Un agent. Plusieurs nœuds. Une seule surface.

Un nœud sert un hôte. Le client se connecte à plusieurs nœuds et donne à l'agent une seule surface MCP via stdio.

agent

l'IA qui décide quels outils appeler

MCP via stdio
client

garde une connexion vers chaque nœud et présente une seule surface MCP

SSH · sous-système gilfoyle-mcp
nœud· hôte A

un processus sur un hôte, servi via SSH

nœud· hôte B

un processus sur un hôte, servi via SSH

[09]architecture

Les dépendances pointent vers l'intérieur.

Huit crates. Le cœur définit les ports, les adaptateurs les implémentent, et le cœur n'importe jamais un adaptateur.

  • gilfoyle-coretypes du domaine, ports, cas d'usageaucune
  • gilfoyle-systemadaptateur piloté (uptime de l'hôte)lecture seule
  • gilfoyle-systemdadaptateur piloté (systemd via D-Bus)bus système
  • gilfoyle-packagekitadaptateur piloté (PackageKit via D-Bus)bus système
  • gilfoyle-dockeradaptateur piloté (Docker via son socket UNIX)socket
  • gilfoyle-sshadaptateur pilotant : serveur SSH embarqué + politiqueréseau
  • gilfoyle-clientadaptateur pilotant : agrégateur multi-nœudsréseau
  • gilfoyle-servergestionnaire MCP, configuration, câblage + le binaireréseau

Un trait par capacité. Un nouvel outil ajoute un trait ; il n'élargit pas un trait existant.

“Fais ce que tu voudras, telle est la loi. Tant que c'est dans la liste d'autorisation.”

— gilfoyle, architecte systèmes

Donne des outils à ton agent. Pas un terminal.

Installe un nœud sur un hôte, autorise la clé de ton agent, épingle l'empreinte du nœud côté client.