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
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
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
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.
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.
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.
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.
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.
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.
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é.
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.
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.
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.
Essaie de percer. Je patiente.
Plusieurs couches, chacune avec son rôle. Voici ce que rencontrent les attaques habituelles.
clés publiques uniquement. Ni mot de passe, ni keyboard-interactive.
seul le sous-système gilfoyle-mcp. shell, exec, pty et redirection refusés.
chaque clé peut être limitée à un sous-ensemble d'outils
un ensemble fixe de fonctions compilées. Aucune ne prend une commande ou un chemin.
listes d'autorisation vides par défaut. polkit derrière systemd et les paquets.
utilisateur sans privilèges, aucune capacité, filtre d'appels système
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écutelire un fichier arbitraireaucun outil ne prend de cheminoutil inexistantlancer un processuslint sur std::process::Commandle build échoueinstaller ou supprimer un paqueténumération ferméeopération inexistantecréer ou supprimer un conteneurensemble de requêtes fermérequête inexistantedé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
Verrouillé par défaut.
L'unité systemd que je livre active tout ça.
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.
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.
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.
Tout le vocabulaire. C'est tout.
Chaque outil et ses arguments. Si ce n'est pas ici, je ne le fais pas.
get_uptime—uptime de l'hôte, depuis le démarrage du noyaulecture
list_unitsactive_state?, name_contains?, unit_type?, limit?lister les unitéslectureunit_statusnameétat détaillé d'une unitélecturestart_unitnamedémarrer une unité autoriséemutationstop_unitnamearrêter une unité autoriséemutation
list_containersstate?, name_contains?, limit?lister les conteneurslecturecontainer_statuscontainerétat détaillé d'un conteneurlecturestart_containercontainerdémarrer un conteneur autorisémutationstop_containercontainerarrêter un conteneur autorisémutationrestart_containercontainerredémarrer un conteneur autorisémutation
list_packagesname_contains?, limit?paquets installés et versionslecturelist_updatesmin_severity?, limit?mises à jour disponibleslecturepreview_upgrade—simuler une mise à jour, ne rien changerlecturerefresh_package_cacheforce?rafraîchir les métadonnées (apt update)mutationupgrade_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.
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.
l'IA qui décide quels outils appeler
garde une connexion vers chaque nœud et présente une seule surface MCP
un processus sur un hôte, servi via SSH
un processus sur un hôte, servi via SSH
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'usageaucunegilfoyle-systemadaptateur piloté (uptime de l'hôte)lecture seulegilfoyle-systemdadaptateur piloté (systemd via D-Bus)bus systèmegilfoyle-packagekitadaptateur piloté (PackageKit via D-Bus)bus systèmegilfoyle-dockeradaptateur piloté (Docker via son socket UNIX)socketgilfoyle-sshadaptateur pilotant : serveur SSH embarqué + politiqueréseaugilfoyle-clientadaptateur pilotant : agrégateur multi-nœudsréseaugilfoyle-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.