ADR-148 — Isolation par namespaces des cages pré-auth et forward-only d’air-sshd
Statut : Accepté (2026-08-02, ratifié BDFL). Durcissement défense-en-profondeur du modèle de séparation de privilèges (ADR-124, ADR-146), complémentaire du filtre seccomp d’ADR-147.
Catégorie : durcissement couche 2 qui exige des additifs couche 0 scellée
(unshare, mount, pivot_root, éventuellement setns) — aucune primitive namespace
n’existe aujourd’hui dans air-sys-syscall. C’est donc, contrairement à ADR-147, un
chantier multi-couches et multi-incréments, avec re-sceau additif de la couche 0 à
la clé. La portée exacte des additifs est le premier objet d’arbitrage.
Contexte
La campagne ADR-105 (2026-08-02) a relevé, en observant l’arbre privsep déployé, que la
cage pre-auth (uid 124, zéro capability, seccomp) et le worker de session
partagent tous les namespaces du listener :
mnt:[4026531832] net:[4026531833] pid:[4026531836] user:[4026531837] ← identiques listener/cage/worker
Le confinement repose donc entièrement sur uid + capabilities + seccomp (+ Landlock
pour la cage sftp). C’est solide, mais il manque une dimension que le noyau Linux
offre et qu’Air revendique d’exploiter (choix Linux-only assumés, CLAUDE.md : « namespaces/
cgroups »). Concrètement, la cage pre-auth, qui exécute le KEX face à un pair non
authentifié :
- voit toute la pile réseau de l’hôte (
netpartagé) — une faille lui donnerait la capacité de créer des sockets vers le voisinage (bornée par seccomp si ADR-147 étend le filtre à la cage, mais pas isolée) ; - voit tout le système de fichiers (
mntpartagé) — bornée par Landlock côté sftp, mais la cagepre-authelle-même n’a pas de racine vidée ; - voit tous les PID de l’hôte (
pidpartagé) — surface d’information et cible de signaux.
Un network namespace vide + un mount namespace à racine minimale (pivot_root
vers un répertoire quasi vide) transformeraient « la cage peut appeler seccomp-permis
mais il n’y a rien à joindre » en garantie structurelle : défense-en-profondeur
orthogonale à seccomp (seccomp borne quels syscalls ; le namespace borne ce qu’ils
peuvent atteindre).
Décision (esquisse — à ratifier avant tout code)
Périmètre d’application, par rôle
- Cage
pre-auth— candidate idéale : elle ne fait aucun accès fichier et aucun accès réseau propre (le fd du client lui est passé déjà connecté). On peut donc, après le drop d’identité et avant le KEX :- entrer un network namespace neuf et vide (
CLONE_NEWNET) — le socket client, déjà ouvert, reste utilisable ; toute nouvelle tentative réseau n’a aucune interface ; - entrer un mount namespace (
CLONE_NEWNS) etpivot_rootvers un répertoire vide dédié (/run/air-sshd/empty,0555 root:root), démontant le reste ; - optionnellement un pid namespace (
CLONE_NEWPID) — plus intrusif (exige unforksupplémentaire pour devenir PID 1 du namespace) ; différé, à évaluer.
- entrer un network namespace neuf et vide (
- Worker forward-only (
-N) — même logique que la cage pré-auth : network namespace vide (la mediation ADR-146 garantit qu’il n’ouvre aucun socket lui-même ; le fd cible lui vient parSCM_RIGHTS),mntà racine vide (il n’ouvre aucun fichier). Fortement complémentaire d’ADR-147 : seccomp interditsocket(), le netns garantit qu’il n’y a de toute façon rien à joindre. - Worker shell / exec — exclu.
execvedu shell utilisateur exige la vraie vuemnt(/bin,/lib,$HOME) et souvent la vraie vuenet(l’utilisateur a droit au réseau). Y mettre des namespaces d’isolation casserait la session. C’est la limite dure : l’isolation par namespace vise le code exposé sans exec, pas la session utilisateur légitime. - Worker sftp —
netvide envisageable (le SFTP ne fait pas de réseau) ;mntreste contraint par Landlock déjà en place — un mount namespace y ajouterait peu. Différé.
Additifs couche 0 nécessaires (nouveau descellement)
Amendé le 2026-09-01 — cette section est reprise par ADR-175. Elle déclarait que « la portée exacte des additifs est le premier objet d’arbitrage » : cet arbitrage a eu lieu. ADR-175 porte le descellement
couche-0-v1.17pour deux consommateurs — les cages décrites ici, et le jalon C d’ADR-168 (air-launchden PID 1) — et tranche quatre points laissés ouverts ci-dessous :
mountest exposé par une fonction par usage, jamais par un appel générique : son argumentdataest une chaîne d’options que le noyau analyserait. Ce qu’ADR-148 réclame ici (MS_REC|MS_PRIVATE) devientremount_private_recursive;- les namespaces s’entrent par
unshareaprès le fork, et non par des flags declone3— la question « à trancher » ci-dessous est donc tranchée ;setnsest hors périmètre et inscrit dansdocs/UNSUPPORTED.md, comme cette section le suggérait elle-même ;- le re-sceau visé est
couche-0-v1.17, ce qui suppose uncouche-0-v1.16régularisantfchmodat2(ADR-144), entré dans la couche scellée le 2026-07-31 sans re-sceau.Le texte d’origine est conservé ci-dessous, tel qu’il a été ratifié.
Aucune de ces primitives n’existe : à créer dans air-sys-syscall (+ types dans
air-sys-types), chacune fonction dédiée typée (ADR-021), Result<…, Errno>, SAFETY
documenté, 100 % couverture + fuzz des chemins d’octets :
unshare(flags)— ou usage declone3avecCLONE_NEWNET|CLONE_NEWNSdéjà partiellement câblés pourthread::spawn; à trancher :unsharepost-fork vs flags declone3.mount(source, target, fstype, flags, data)— au minimum pourMS_REC|MS_PRIVATEet le remontage de la racine.pivot_root(new_root, put_old).setns(fd, nstype)— peut-être pas nécessaire si l’on ne fait qu’entrer des namespaces neufs ; à exclure du périmètre si non utilisé (UNSUPPORTED.md).
Ces additifs relèvent du re-sceau additif couche 0 (modèle ADR-126 / ADR-144) — autorisation BDFL explicite requise, jamais un raccourci.
Couche 1
Ces primitives doivent être bindées en couche 1 (règle de layering : air-sshd ne
touche jamais la couche 0 en direct) — vraisemblablement une extension d’air-sandbox
(un AirNamespacePolicy à côté d’AirLandlockPolicy / AirSandboxProfile), appliqué par
le même SandboxApplier, dans l’ordre : namespaces → Landlock → seccomp.
Conséquences
- Gain : la cage pré-auth (code face à l’attaquant non authentifié) et le worker forward-only passent d’« isolés par politique » à « isolés par le noyau, sans réseau ni système de fichiers à atteindre ». Dimension orthogonale à seccomp/caps.
- Coût — le plus élevé des deux pistes : descellement couche 0 (primitives + tests +
fuzz + re-sceau), extension couche 1, câblage couche 2, et un
/run/air-sshd/emptyprovisionné par le packaging. Plusieurs incréments. - Risque : les namespaces interagissent avec le drop d’identité (ordre
unsharevssetuid), avecPDEATHSIG/pidfd du monitor (un pid namespace change la sémantique parent-mort), et avec io_uring (déjà non hérité, ADR-146 — à re-vérifier sous netns). Chaque interaction = un test dédié. - Périmètre négatif explicite : shell/exec jamais isolé par namespace ; sftp et pid-namespace différés. À journaliser (pas de cap silencieux).
Alternatives rejetées
- Tout worker dans des namespaces, shell compris. Casse les sessions interactives
(pas de
/bin, pas de réseau). Rejeté — l’isolation vise le code sans exec. - Se contenter de seccomp (ADR-147) sans namespaces. Seccomp borne quels appels ;
il ne retire pas ce qu’ils atteignent (un
socket()permis par erreur voit encore le réseau). Les deux sont complémentaires, pas substituables — d’où deux ADR distincts. - Réutiliser une brique de conteneur tierce (runc/…). Contraire à la règle des 80 % (ADR-024) et au périmètre : Air pose ses propres primitives noyau typées. Rejeté.
chrootau lieu de mount-namespace +pivot_root.chrootest contournable et ne cache pas la pile de montages ;pivot_rootenmntneuf est la forme correcte. Rejeté.
Suite
Ne pas implémenter avant ratification : ce chantier engage un descellement couche 0.
Séquence proposée si accepté : (1) additifs couche 0 (unshare/mount/pivot_root) +
re-sceau couche-0-v1.17 ; (2) AirNamespacePolicy couche 1 (air-sandbox) ; (3) pose sur
la cage pré-auth seule, mesurée et testée ; (4) extension au worker forward-only
(après ADR-147). sftp et pid-namespace évalués séparément. Chaque étape, sa PR et ses
tests d’interaction (drop d’identité, PDEATHSIG, io_uring).