Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-150 — Manifeste, octroi et arbitrage de lancement

Statut : Accepté (2026-08-03, décisions BDFL). Réalise ce que l’amendement D6 d’ADR-046 (« La libc Air : doctrine d’exécution ») lui a explicitement renvoyé. Complète ADR-010 (« Format .airapp et .airservice, entitlements déclaratifs signés »), qu’il amende sur un point (D2), et s’appuie sur ADR-112 (air-sandbox, le mécanisme de cage) et ADR-089 (SecurityManager, l’oracle de sécurité active).

Catégorie : Sécurité (fondateur, transverse). Débloque AirSandboxManager (ADR-149 D4/D9).

Contexte

L’amendement D6 d’ADR-046 (2026-08-03) a gravé les invariants d’exécution — il n’existe plus d’exécution non confinée ; l’absence d’octroi est un refus, pas une mise en cage ; la provenance décide la largeur d’enveloppe, pas l’exemption — et a renvoyé le mécanisme à un ADR sécurité dédié. C’est celui-ci.

Une vérification préalable a réduit son périmètre de moitié. Beaucoup de ce qu’on croyait à concevoir est déjà décidé :

QuestionDéjà tranchée par
Format du bundle et du manifesteADR-010 — .airapp, .airservice, signatures détachées, schémas AirCom publiés
Les entitlementsADR-010 — deny-by-default, signés, quatre familles (filesystem → Landlock, réseau → namespace + filtrage, AirCom → capabilities, devices)
Niveaux de signatureADR-010 — développeur (self-signed possible), notarisation (modèle ouvert), politique locale
Qui applique, à quel étageADR-010 — air-launchd (couche 5), au lancement ; POLA strict, aucune autorité ambiante
Le mécanisme de cageADR-112, fait, en production dans air-sshd
L’oracle de politique en cours d’exécutionADR-089 — SecurityManager per-process, check(operation) -> Allow/Deny(motif) journalisé

Ce qui manquait vraiment, et qui justifie cet ADR : l’octroi. ADR-010 ne décrit que la demandeEntitlements.toml, signé par le développeur. Rien ne dit ce que la machine accorde. C’est exactement l’erreur d’Android avant sa version 6 : déclarer valait obtenir.

Décision

D1 — L’octroi existe, et il est distinct du manifeste

Deux artefacts, deux signataires, deux moments. Les confondre, c’est retomber dans l’erreur citée ci-dessus.

ManifesteOctroi
Qui l’écritle développeurla machine
Ce qu’il ditce que l’application demandece que le système accorde
Qui le signela clé du développeurla clé de la machine
Quandà la construction du bundleà l’installation
Où il vitdans le bundle .airapphors du bundle, sur la partition inscriptible
Qui peut le modifierpersonne après signaturela porte d’administration seule

Invariant. Un manifeste ne confère rien. Il demande. Seul un octroi valide, signé par la machine, autorise une exécution — et il n’autorise jamais plus que ce que le manifeste demandait.

Conséquence sur la mise à jour d’application. Air voit une mise à jour comme une désinstallation suivie d’une installation. L’octroi de l’ancienne version est donc révoqué, et un octroi neuf est émis pour la nouvelle. Seules les données apportées par l’ancienne version survivent. Un développeur ne peut pas élargir silencieusement ses privilèges par une mise à jour : la demande élargie doit être ré-octroyée.

D2 — Manifeste et octroi sont binaires ; un codec texte est fourni aux développeurs

Décision BDFL du 2026-08-03. ADR-010 spécifiait Manifest.toml et Entitlements.toml, « TOML pour lisibilité et idiome Rust ». Cet ADR l’amende sur ce point, et sur celui-là seulement : les deux artefacts sont des artefacts binaires, schéma Cap’n Proto (ADR-040) sous enveloppe AIRCFGv1 (ADR-073). Le reste d’ADR-010 tient inchangé.

Deux raisons, et la seconde suffirait seule :

Cohérence de doctrine. Le Principe 11 (« Entrée stricte, sortie lisible ») impose un schéma à disposition fixe et bornée pour tout ce qu’un composant lit en entrée afin de décider. Un manifeste et un octroi sont lus par le lanceur pour décider de privilèges : les exempter serait une exception sans justification.

Sécurité, et c’est l’argument décisif. Un parseur TOML est un parseur généraliste : échappements, imbrication, types dynamiques, surface d’erreur large. Le placer sur le chemin de décision de privilèges, dans le composant qui décide qui a le droit de faire quoi, sur une entrée fournie par un tiers, est une surface d’attaque offerte sans contrepartie. Un schéma binaire à disposition fixe ne présente pas cette surface.

Le codec texte reste fourni, et Air ne l’utilise jamais. Même motif que pour les configurations : un développeur écrit et lit son manifeste en texte, l’outillage de construction le compile en artefact binaire signé, et le lanceur ne lit que le binaire. La lisibilité est un service rendu à l’humain, pas un format d’exécution.

D3 — L’ordre d’arbitrage à l’exec, et pourquoi il est celui-là

À chaque tentative d’exécution, l’arbitre — air-launchd, ADR-010 — vérifie dans cet ordre :

  1. La signature du binaire — établit qui il est : identité et provenance.
  2. L’existence d’un octroi valide pour cette identité — établit ce que cette identité s’est vu accorder sur cette machine.
  3. La conformité de l’octroi à l’enveloppe de l’image — établit si cet accord tient encore dans ce que l’image permet.

L’ordre est dicté par la dépendance, pas par le coût. Chaque étape établit ce dont la suivante a besoin. Évaluer un octroi avant d’avoir authentifié le binaire n’a aucun sens : l’octroi est lié à une identité, et si l’identité n’est pas établie on évalue un accord contre un inconnu. C’est même directement exploitable — remplacer le binaire, garder l’octroi.

Un ordre par coût croissant donnerait l’inverse, et serait faux. La vérification de signature est une opération cryptographique, de loin la plus chère des trois ; lire un octroi est un accès fichier, vérifier une enveloppe une comparaison. Le classement par coût mettrait la signature en dernier — c’est-à-dire après avoir raisonné sur une identité non établie. L’ordre n’est pas une optimisation, c’est une condition de validité.

Conséquences de cet ordre, analysées

1. Le refus est uniforme au chronomètre, par construction. C’est la leçon d’ADR-118 sur AllowUsers : un refus qui coûte moins cher qu’un autre est un canal auxiliaire. Ici, le coût dominant — la cryptographie — est payé dans tous les cas de refus, puisqu’il vient en premier. L’ordre inverse aurait fait refuser vite un binaire sans octroi et lentement un binaire mal signé, divulguant laquelle des deux conditions a échoué. L’uniformité est un effet de bord gratuit du bon ordre.

2. Le coût sur le chemin chaud est réel et assumé. Chaque exec paie une vérification cryptographique, y compris pour un binaire sans octroi qui sera refusé de toute façon. Un cache indexé par (inode, empreinte) est envisageable mais mettre en cache une décision de sécurité est en soi un risque — fenêtre TOCTOU entre la vérification et l’exécution. Non tranché ici (cf. questions ouvertes).

3. Une mise à jour d’image peut empêcher une application de se relancer. Si une nouvelle image rétrécit l’enveloppe, des octrois jusque-là valides deviennent non conformes à l’étape 3. C’est le comportement voulu — l’image fixe le plafond, la partie mutable remplit dedans — mais il doit être visible et annoncé, jamais une surprise post-mise à jour.

4. La révocation passe par l’étape 2. Une désinstallation retire l’octroi ; l’étape 2 échoue ; le binaire reste sur le disque, inerte. Aucun mécanisme de révocation supplémentaire n’est nécessaire pour ce cas.

5. L’ordre est amendable, et le dire fait partie de la décision. Il est fixé maintenant parce qu’il est cohérent, pas parce qu’il est démontré optimal. Tout amendement devra justifier le nouvel ordre par la dépendance et réanalyser les cinq conséquences ci-dessus — en particulier l’uniformité au chronomètre, qui est fragile à tout réordonnancement.

D4 — Le refus est muet vers l’appelant, tracé vers le journal

Amendé le 2026-08-21 — voir D11 en fin de document. Le refus n’est plus muet pour tous les motifs : il l’est pour le seul qui renseigne — l’octroi. Les trois autres sont rendus explicites, parce qu’ils n’apprennent rien qu’un stat ou une vérification hors ligne ne donne déjà.

Le motif du refus — signature invalide, octroi absent, octroi hors enveloppe — est journalisé dans le journal machine, ce qu’ADR-089 exige déjà de toute décision de sécurité. Il n’est pas communiqué au demandeur.

Même discipline que l’AccessNotAllowed d’air-sshd : un motif de refus rendu à l’appelant est un oracle d’énumération. Savoir pourquoi on est refusé permet de chercher la condition qui manque.

D5 — Ce que l’arbitrage ne couvre pas : la chaîne d’amorçage

PID 1 et le début de la chaîne de démarrage s’exécutent avant que l’arbitre existe. Leur confiance ne vient donc pas de cette procédure, mais du démarrage vérifié et de dm-verity. Il faut le dire, sinon on croira que tout exécutable de la machine passe par l’arbitre.

Cohérent avec l’exception PID 1 d’ADR-046 D6 §A1 : le confinement est monotone à travers execve, confiner PID 1 plafonnerait tout ce que la machine lance ensuite ; il tire sa protection d’être minimal et vérifié.

D6 — Deux portes distinctes : le lancement et l’opération

Une tension apparente doit être levée explicitement, faute de quoi deux ADR se liront l’un contre l’autre.

Porte du lancementPorte de l’opération
Question poséece binaire a-t-il le droit de s’exécuter ?ce processus a-t-il le droit de faire ceci ?
Qui décidel’arbitre (air-launchd), au execle SecurityManager (ADR-089), per-process
Défautrefus (ADR-046 D6 §A2)permissif (allow-all + log) tant que le moteur n’existe pas
Statuttranché icimoteur explicitement différé (ADR-089 §5)

Ce ne sont pas les mêmes portes, et leurs défauts opposés ne se contredisent pas. Le refus par défaut porte sur l’entrée ; le permissif par défaut porte sur ce qu’un processus déjà admis et déjà confiné tente de faire. Un processus admis a franchi la première porte, et sa cage seccomp/Landlock est déjà posée — le SecurityManager est une couche de politique au-dessus du confinement kernel, pas à sa place.

Conséquences

AirSandboxManager est débloqué en partie. ADR-149 D9 et l’amendement d’ADR-112 le donnaient bloqué par « l’ADR sécurité dédié ». Cet ADR fixe le manifeste, l’octroi et la procédure d’arbitrage. Reste bloquante la largeur des enveloppes par défaut (question ouverte 1), qui détermine ce que le Manager doit savoir poser.

ADR à mettre en conformité. Ces documents contiennent des affirmations que le présent ADR rend fausses ou incomplètes. Conformément à la politique éditoriale rappelée par le BDFL le 2026-08-03 — les ADR sont historisés par git, donc nettoyer ce qui est faux vaut mieux qu’empiler des sections barrées ; git log reste la mémoire — la mise à jour se fait par correction du texte, avec renvoi à l’ADR qui fait foi.

ADRCe qui est à corrigerQui fait foi
ADR-010Manifest.toml / Entitlements.tomlartefacts binaires (D2). Le volet octroi est absent et doit renvoyer iciADR-150 sur ces deux points ; ADR-010 sur tout le reste
ADR-089Le défaut permissif doit être qualifié : il porte sur la porte de l’opération, pas sur celle du lancement (D6)ADR-150 pour la distinction ; ADR-089 pour le SecurityManager
ADR-046D6 renvoyait « à un ADR sécurité dédié » sans le nommer — le renvoi devient expliciteADR-046 pour les invariants ; ADR-150 pour le mécanisme
ADR-112Le blocage d’AirSandboxManager est partiellement levé ; la réserve restante est la largeur des enveloppesADR-150

La révocation n’a pas de mécanisme propre : elle est un effet de l’absence d’octroi (D3, conséquence 4).

Alternatives rejetées

  • Le manifeste seul, sans octroi (modèle Android pré-6.0) : rejeté — déclarer vaudrait obtenir, ce qui vide de sens la notion de permission.
  • Garder le TOML pour le manifeste au motif qu’il est écrit par un humain : rejeté — la lisibilité est servie par le codec texte, sans mettre un parseur généraliste sur le chemin de décision de privilèges.
  • Ordonner l’arbitrage par coût croissant : rejeté — il ferait raisonner sur une identité non établie, et casserait l’uniformité au chronomètre (D3).
  • Rendre le motif du refus à l’appelant au nom de l’ergonomie : rejeté — oracle d’énumération (D4). Le motif est dans le journal, accessible à l’administrateur.
  • Un démon d’arbitrage central : non instruit ici ; ADR-089 a déjà rejeté ce modèle pour le SecurityManager (goulot d’étranglement et point de défaillance unique).

Questions ouvertes

  1. La largeur des enveloppes par défaut. TRANCHÉE le 2026-08-10 (BDFL) — instruction : docs/notes/instruction-largeur-enveloppes-defaut-fr.md.

    L’énoncé faisait croire à un curseur ; c’est en réalité le contenu d’un plafond, famille par famille, et ADR-046 A2 en imposait déjà deux (étranger / signé Air).

    L’instruction a trouvé le vrai obstacle : le vocabulaire déclare quatre familles, air-sandbox n’en applique qu’une (filesystem, Landlock ABI v1). Option B retenue : enveloppes filesystem écrites, familles sans applicateur vides par défaut — tout octroi qui les invoque est refusé à l’étape 3. Une enveloppe qui bornerait ce que la cage ne pose pas serait une promesse écrite dans un fichier, pas une protection.

    Elle a aussi révélé que l’étape 3 ne pouvait pas employer l’égalité stricte de D3/§5.1 : une image est écrite une fois, pour des applications inconnues. Deux questions distinctes, deux rapprochements — égalité stricte pour demande ↔ octroi, couverture par gabarit pour enveloppe ↔ octroi (Exact/Subtree + marqueur {bundleId}, sans glob).

    Restent ouverts : le grain du profil seccomp, et le calendrier de Landlock v4 (réseau) — AirSandboxManager demeure partiellement bloqué par eux.

  2. Où vit exactement l’octroi. Racine immuable, partition inscriptible, délégation bornée — cf. la note docs/notes/etude-administration-et-placement-configuration-fr.md.

  3. Système contre applications. Le gestionnaire de paquets installe les applications et les services non système et doit rester cantonné à ce rôle ; l’installation du système est un processus distinct, dont la forme n’est pas décidée.

  4. Cache de vérification de signature. Souhaitable pour le coût, risqué pour le TOCTOU. À instruire avec une mesure, pas avant (Principe 5).

  5. Le premier octroi. Qui émet l’octroi des composants installés par l’installeur système, alors que la porte d’administration n’existe pas encore à ce moment ? Même famille de problème que la naissance du premier compte.

Amendement — la forme de l’arbitre : la surface que le développeur touche (2026-08-21)

Origine. Question du BDFL, posée depuis la place du développeur tiers — celui qui écrit un shell, ou un service réseau équivalent à sshd, et qui doit lancer le binaire que son utilisateur réclame :

Si cette action peut ne me coûter qu’une ligne de code, je serais super content. Il faut qu’on pense « simplicité ». Si c’est simple, le développeur utilisera d’autant plus facilement cette API — et notre modèle de sécurité renforcé sera de fait promu. C’est si nous rendons ça complexe que le développeur cherchera des portes plus simples et sera tenté de contourner notre sécurité.

Ce n’est pas un argument de confort, c’est un argument de sécurité. Une porte sûre mais pénible n’est pas empruntée ; elle est contournée, et le contournement, lui, n’est pas audité. Cet amendement traite la forme de l’arbitre — l’une des quatre questions que l’amendement d’ADR-112 du 2026-08-03 déclarait bloquantes pour la pose d’AirSandboxManager.

Ce qui découle déjà des décisions prises, et qu’il faut cesser de re-dériver

Deux propriétés ont été redécouvertes trois fois au cours de la conception. Elles sont déjà acquises ; les répéter ici évite un quatrième aller-retour.

Le confinement est monotone, et ce n’est pas une politique — c’est le noyau. D5 l’énonce déjà (« le confinement est monotone à travers execve », hérité d’ADR-046 D6 §A1). Les filtres seccomp sont hérités à travers fork et execve, et plusieurs filtres se composent par intersection : le plus restrictif gagne. Les jeux de règles Landlock s’héritent et s’empilent de la même façon. Un enfant ne peut donc jamais être plus large que son parent, quoi que fasse l’appelant.

Ce qui n’avait pas été tiré comme conséquence, et qui l’est ici : la largeur des enveloppes par défaut ne se pose qu’à la racine d’une chaîne de lancement. Partout ailleurs, le parent borne. L’amendement d’ADR-112 qualifie cette largeur de dernière question bloquante pour AirSandboxManager ; elle ne cesse pas d’être bloquante, mais elle cesse d’être générale — elle devient une décision unique, prise une fois, et non un paramètre à trancher pour chaque famille d’appelants.

D7 — L’appelant ne nomme jamais la cage

La cage ne vient pas de celui qui lance : elle vient du bundle de la cible, par la procédure d’arbitrage que D3 fixe déjà (signature → octroi → conformité à l’enveloppe).

Le développeur du shell n’a donc rien à passer, parce qu’il n’a rien à accorder.

C’est plus fort que de lui faire passer un octroi. S’il en passait un, il pourrait en passer un trop large : le shell tourne avec l’autorité de l’utilisateur, on lui demande de lancer quelque chose, et il prêterait cette autorité — le député confus dans sa forme classique. Ici il ne prête rien. Il n’est pas une autorité, il est un demandeur.

Conséquence directe sur la surface : aucune signature publique de lancement ne prend de profil, de politique Landlock ni de catégorie d’appels système. Le chemin d’appel qui les prend existe — air-launchd doit pouvoir imposer un octroi précis — mais il porte un nom long qui se lit comme une exception, et ses points d’usage sont marqués // SECOND-PATH: (ADR-149 D10).

D8 — Trois verbes, nommés comme le développeur les pense déjà

VerbeRendLe cas
run(cible)statut de sortielance et attend — le cas à 90 %
spawn(cible)un handle de processuslance et rend la main — le & d’un shell
exec(cible)Result<Infallible, _>remplace l’image — le builtin exec d’un shell

Avec une variante …_with(AirCommand) quand il faut des arguments, un environnement ou des redirections. exec rend Result<Infallible, _> conformément à la convention 5 d’ADR-021 : une fonction qui ne revient jamais en succès le dit dans son type.

Trois verbes et pas un : un shell a besoin des trois, et les confondre obligerait à distinguer par un paramètre ce que le nom dit mieux. Trois verbes et pas dix : au-delà, le développeur choisit, et un développeur qui choisit peut choisir mal.

D9 — On exécute le descripteur qu’on a vérifié, pas le chemin qu’on a vérifié

La procédure de D3 vérifie une identité, puis exécute. Entre les deux, si l’objet est désigné par un chemin, il peut changer : c’est la fenêtre TOCTOU que la Question ouverte n°4 de cet ADR nomme sans la refermer, en écartant le cache de vérification pour cette raison.

Décision : l’arbitre ouvre la cible, vérifie le descripteur, et exécute le descripteurexecveat(fd, "", AT_EMPTY_PATH). Il n’y a plus deux résolutions, donc plus de fenêtre entre elles. On exécute l’objet qu’on a vérifié, pas le nom qu’on a vérifié.

Cela change aussi la forme de la question ouverte n°4 sans la trancher : un cache indexé sur l’objet vérifié n’a pas le défaut d’un cache indexé sur un nom. Il reste à mesurer avant d’être construit (Principe 5) ; il cesse d’être exclu par principe.

D10 — Le lien symbolique ne détermine jamais la cage

Le scénario est celui du BDFL : rien ne dit que le gestionnaire de paquets n’installe pas sous /usr/local/bin un lien symbolique vers l’exécutable dans son bundle, pour faciliter la vie de l’utilisateur.

Si la cage se déduisait de « quel bundle contient ce chemin », alors qui contrôle le chemin contrôlerait la cage : il suffirait de faire pointer un lien vers un binaire logé dans un bundle largement doté.

Décision : la cible est résolue complètement, et la cage vient du bundle qui déclare la cible résolue — le manifeste porte une liste executables, chacun avec son path ; un binaire atteint par un chemin qui n’est pas déclaré par le bundle qui le contient n’est pas lancé. L’endroit d’où l’on vient n’accorde rien.

D11 — Le refus devient gradué : explicite par défaut, opaque là où il renseigne

Cet article amende D4. D4 rend le refus muet vers l’appelant quel qu’en soit le motif, par analogie avec l’AccessNotAllowed d’air-sshd. L’analogie tient pour un motif sur quatre ; pour les trois autres, elle achète une opacité sans acheter de secret — et une opacité gratuite se paie ailleurs, en développeurs qui contournent une porte dont ils ne comprennent pas le refus.

Ce que la doctrine de transparence dit — on ne cache pas au développeur ce que le système fait — et ce que D4 dit — on ne répond pas à une sonde — ne se contredisent que si l’on confond le développeur et le demandeur. Ce ne sont ni la même personne, ni le même moment, ni le même canal.

Règle : le motif rendu à l’appelant peut être aussi précis que ce que le demandeur pourrait apprendre par un autre moyen. Au-delà, il va au journal.

Motif de refusRendu à l’appelantPourquoi
chemin introuvable, ou non déclaré executable par le bundle qui le contient (D10)expliciteun stat le dit déjà
aucun bundle pour cette cibleexpliciteidem
signature du binaire invalideexplicitel’artefact est sur le disque, vérifiable hors ligne
octroi absent, ou octroi hors enveloppeopaque + jetonc’est là, et là seulement, qu’est la topologie des décisions d’administration

Ce qui est opaque, exactement

L’appelant apprend la classe du refus et rien de plus :

AirError { kind: PermissionDenied,
           « lancement refusé — décision d'octroi »,
           jeton: 7f3a91c4e05b2d68… }

Il n’apprend pas : si un octroi existe ou s’il n’y en a aucun ; quelle capacité manquait ; quelle clause de l’enveloppe d’image a été violée.

C’est bien là qu’est le renseignement. « Absent » contre « hors enveloppe » dit si un administrateur a approuvé quelque chose ; et nommer la capacité manquante permettrait de cartographier la politique de la machine sans jamais rien exécuter — on sonde, on note ce qui est refusé, on obtient la forme des octrois.

Ce qu’est le jeton, et ce qu’il n’est pas

Un identifiant de corrélation. Trois propriétés le rendent inoffensif, la quatrième le rend utile :

  1. Sans information — tiré au hasard (128 bits), non dérivé du motif, ni du binaire, ni de l’octroi. On ne peut rien en lire.
  2. Non devinable — on ne peut pas en fabriquer un pour consulter l’entrée d’un autre.
  3. Il ne confère rien — le détenir n’ouvre pas le journal. C’est une clé de recherche, jamais une permission ; lire le journal reste soumis à l’autorisation du journal.
  4. Il désigne l’entrée que D4 exige déjà d’écrire, laquelle porte le motif complet.

Qui peut lire l’entrée — et le coût, nommé

Le jeton ne protège que d’un demandeur qui ne peut pas lire le journal. Si le sujet du refus pouvait consulter ses propres refus, l’oracle serait intégralement restauré : il sonde, puis il lit ; le jeton n’aurait ajouté que de la latence.

Décision : l’entrée de refus est lisible par l’administrateur, pas par le sujet du refus.

Le coût est réel et doit être dit : sur une machine partagée, un utilisateur ordinaire ne peut pas établir lui-même pourquoi son lancement a été refusé — il doit le demander. C’est cohérent : sur une machine partagée, cet utilisateur est exactement la partie contre laquelle D4 se défend. Sur sa propre machine, le développeur est l’administrateur, et la friction disparaît là où elle comptait.

C’est l’application du Principe 11 dans son second membre — en sortie, l’inverse : la lisibilité est un service rendu là où le système est la source, ici l’outil qui lit le journal, et non la valeur de retour d’un appel exposé à quiconque sait l’appeler.

D12 — Le lanceur médie, et la session ne plafonne pas (BDFL, 2026-08-22)

La question de la largeur des enveloppes par défaut — dernière bloquante pour AirSandboxManager selon l’amendement d’ADR-112 — est tranchée ici. Elle ne portait pas sur ce qu’on croyait.

Ce qu’elle n’était pas : « la cage par défaut d’une application ». Une application empaquetée n’en a jamais — la sienne se dérive de son octroi (D3). Lui appliquer un défaut serait un défaut de conception : on la confinerait selon une politique que personne n’a signée.

Ce qu’elle était : que reçoit le premier processus d’une chaîne, là où il n’y a ni parent dont hériter ni octroi dont dériver ? Trois racines existent ; deux étaient déjà closes. PID 1 et l’amorçage sont hors arbitrage (D5) — leur confiance vient du démarrage vérifié et de dm-verity. Le binaire de provenance étrangère ne s’exécute pas (BDFL, 2026-08-21), donc il n’a pas d’enveloppe. Restait la session de l’utilisateur.

La contradiction qu’il fallait lever

Le confinement est monotone : la cage du shell de session plafonnerait tout ce que l’utilisateur lance depuis lui, y compris une application dont l’octroi, signé par l’administrateur, dit davantage. Une session étroite rendrait donc inapplicables des octrois légitimes ; une session large rendrait son enveloppe décorative. On ne pouvait pas choisir la largeur avant d’avoir choisi le modèle.

La décision

Le shell de session n’est pas le plafond : il est le demandeur. Le lancement d’un programme est médié par le lanceur, hors de la cage de la session ; c’est lui qui pose la cage, dérivée de l’octroi du programme, et qui rend les descripteurs à l’appelant (ADR-151, passage explicite de descripteurs).

Le motif est déjà implémenté : ADR-147 a tranché exactement ce problème pour air-sshd — le worker de session, toujours confiné, ne fait plus d’execve, et le lancement est médié par le monitor. On rejoue, on n’invente pas.

Ce que la médiation oblige à écrire, sous peine d’ouvrir une escalade

Sous ce modèle, le lanceur devient l’arbitre du privilège visible par l’utilisateur : un programme peut obtenir davantage que la session qui l’a demandé. C’est voulu — l’administrateur l’a signé — mais cela n’est sûr qu’à deux conditions, qui sont ici des décisions et non des recommandations :

  1. Le lanceur vérifie lui-même, et ne fait confiance à aucun demandeur : l’octroi existe, il couvre ce programme, l’empreinte concorde (D9, D10).
  2. Le lanceur vérifie que cet utilisateur a le droit d’invoquer cet octroi. Sans ce second contrôle, tout utilisateur capable de nommer un octroi large obtiendrait ses capacités — l’escalade exacte que le modèle prétend fermer.

air-run retrouve ici son sens, et sa limite : il demande, il ne peut rien élargir de lui-même — c’est un fait du noyau, pas une politique.

D13 — La largeur de l’enveloppe de session

Sous D12, l’enveloppe de session n’a plus à anticiper les besoins des applications : elle n’a à couvrir que ce que le shell fait lui-même. Les enveloppes exprimables ont été mesurées le 2026-08-21 contre air-sandbox — 8 catégories, union distincte de 93 appels système :

Enveloppe candidateAppelsPart des 93Ce qu’un programme peut y faire
A — survivre3335 %vivre et sortir ; rien sur des descripteurs nouveaux
B — démarrer4144 %A + amorçage du runtime + exécuter son propre binaire
C — + fichiers6570 %B + les appels de fichiers (ce qu’ils atteignent reste dicté par Landlock)
D — + passage de fd6671 %C + recevoir des descripteurs
E — + réseau7985 %D + les appels réseau
F — tout93100 %E + protocole de lancement + transitoire glibc

Retenu : C — 65 appels sur 93. Le second axe, Landlock, est indépendant : C autorise à appeler open, la politique décide sur quoi.

CheminAccès
$HOMElecture-écriture de fichiers et de répertoires, plus REFER
/usr, /liblecture et exécution
Rectification du 2026-08-22 — deux droits manquaient, et leur absence n’était pas neutre

Le tableau ci-dessus disait « $HOME : 5 droits » et « /usr, /lib : lecture (1 droit) ». La mise en œuvre a montré que cette lecture littérale produisait l’inverse de l’intention, sur deux points. La cause est commune : dans l’implémentation, le jeu de droits que le noyau arbitre (handled_access_fs) est l’union des droits que les règles accordent — un droit qu’aucune règle ne mentionne n’est donc pas « omis », il sort de l’arbitrage.

  1. EXECUTE (décision du BDFL, 2026-08-22). Aucune ligne ne le mentionnait, donc le noyau ne bornait l’exécution nulle part — et un binaire déposé dans $HOME, qui y est en lecture-écriture, s’exécutait. C’était l’inverse exact de la décision du 2026-08-21 sur les binaires de provenance étrangère (ADR-010 D-a). /usr et /lib portent donc EXECUTE, et seulement eux : l’exécution entre dans l’arbitrage et se borne aux chemins du système, $HOME reste non exécutable. Le développeur n’en est pas empêché — sa chaîne d’outils tourne sous son propre octroi via le lanceur (D12), et son répertoire de travail relève de l’octroi de développement d’ADR-010 D-e.

  2. REFER, et il était invisible. read_write_dir() ne porte que des droits de répertoire — lister, créer, supprimer — ni READ_FILE, ni WRITE_FILE, ni REFER. Mesuré sur cible x86_64-unknown-linux-air le 2026-08-22 : sous l’enveloppe, renommer un fichier dans un dossier rendait Ok, et le déplacer vers un autre dossier rendait Err(EXDEV). Déplacer un document d’un dossier à l’autre est le geste le plus ordinaire de l’utilisateur que ce document décrit ; le lui interdire par omission était une cage qui se trompait de cible. $HOME porte donc aussi READ_FILE, WRITE_FILE et REFER.

Ces deux droits sont acquis sur le plancher d’Air : REFER est l’ABI Landlock v2, et le plancher est 6.12 = v6 (ADR-156).

Le réseau reste dehors, et c’est le point. Un shell n’a pas besoin de socket ; ce qui en a besoin l’obtient par son octroi, via le lanceur. Sans D12, il aurait fallu ouvrir E — soit 14 appels système de plus pour tout ce que l’utilisateur lance, au motif que l’une de ses applications pourrait en vouloir.

Ancrage empirique : la seule cage orientée utilisateur qu’Air ait jamais posée en production est celle du worker d’air-sshdAirLandlockPolicy::new().allow(home, AirPathAccess::read_write_dir()), soit $HOME en lecture-écriture et rien d’autre. La proposition ci-dessus y ajoute la lecture du système, sans quoi aucun binaire ne se charge.

Ce que D13 ne couvre pas

L’enveloppe de session n’est pas un défaut d’application : aucune application empaquetée n’y est soumise, puisque la sienne vient de son octroi. Elle ne concerne que le processus racine de la session de l’utilisateur, et par héritage ce qu’il lance sans passer par le lanceur.


Amendement — la clé qui signe les manifestes livrés (2026-09-03)

Ce que l’arbitrage attend déjà. D3 vérifie deux signatures d’origines distinctes : le manifeste par une developer_key, l’octroi par la machine_key. L’octroi lie les deux — il porte l’empreinte du manifeste et la clé du développeur —, de sorte qu’un manifeste re-signé par un autre invalide l’octroi sans qu’on ait rien à vérifier de plus.

Ce qui l’occupait en fait. air-sshd provision-bundle signait son manifeste avec une clé générée sur la machine au premier postinst. Elle tenait la place de la developer_key — si bien que le manifeste était « signé par le développeur », ce développeur étant la machine elle-même. Deux machines produisaient deux clés pour un même paquet.

Ce n’était pas une faille : l’octroi restait lié à cette clé, donc rien de fabriqué ailleurs ne passait. C’était une fiction — la structure disait « le développeur affirme », et personne n’affirmait.

D14 — Le manifeste est signé à la CONSTRUCTION, par une clé de projet

Le manifeste d’un service livré est produit et signé par celui qui construit le paquet, non par la machine qui le reçoit. La machine_key retrouve son seul rôle : signer l’octroi, c’est-à-dire ce que cette machine accorde.

Les deux emplacements disent alors deux choses différentes, et c’est ce qui les rend lisibles :

chemincontenuqui l’affirme
/usr/lib/air/…le binairele paquet
/var/lib/air/services/<id>/le manifeste scellé et signé à la construction, et sa signaturele paquet — ce que le service demande
/etc/air/grants/…l’octroi et sa signaturela machine — ce qu’elle accorde

⚠️ LE SERVICE VIT ENTIER DANS SON BUNDLE — tranché le 2026-09-04.

Le schéma exige un entryPoint relatif à la racine du bundle, et le chargeur vérifie que ce fichier existe et correspond à son empreinte — en NOFOLLOW, pour qu’un lien symbolique ne détourne pas la vérification. Un binaire dans /usr et un manifeste dans /var/lib ne peuvent donc pas se rejoindre : l’empreinte publiée porterait sur un fichier que le chargeur ne peut pas atteindre, c’est-à-dire sur rien.

L’exécutable est donc dans le bundle, avec son manifeste et sa signature — /var/lib/air/services/<id>/. Un service Air est une unité qui porte ce qu’elle déclare : c’est ce que le schéma décrit, ce qui rend l’empreinte vérifiable, et ce sur quoi dm-verity pourra porter.

Ce que cela a d’inhabituel, et pourquoi c’est transitoire. Un exécutable hors de /usr détonne dans un paquet Debian, dont la hiérarchie suppose le code sous /usr et l’état sous /var. Air n’a pas cette hiérarchie. Décision du BDFL : on emprunte le format Debian tant qu’il sert, avec cette entorse assumée — Air aura, avant la v1, son propre format de paquet, conçu pour ses exigences de sécurité et son architecture de système de fichiers. La question ne se posera alors plus dans ces termes.

⚠️ LE MANIFESTE NE DÉMÉNAGE PAS SOUS /usr — corrigé le 2026-09-04. La première rédaction l’y plaçait, par symétrie avec le binaire. Le dépôt avait déjà tranché autrement, et l’avait payé : le code porte, à l’endroit de system_bundle_root(), la trace d’une panne constatée en production le 2026-08-15 — le bundle posé sous IMAGE_BUNDLE_ROOT rendait provision-bundle incapable de produire une configuration démarrable, et le démon d’une machine déployée depuis deux semaines refusait de redémarrer, sans que rien ne le signale tant que personne ne le relançait.

La cause : le lanceur ne balaie que SERVICE_BUNDLE_ROOT. Un manifeste de service posé sous la racine d’image est inactivable — l’arbitrage refuse sur KindMismatch.

Ce que D14 décide vraiment est donc QUI signe, non OÙ le manifeste réside. Le manifeste reste sous /var/lib/air/services, là où le lanceur le cherche ; ce qui change est qu’il y arrive déjà scellé et signé par le paquet, au lieu d’être fabriqué par la machine qui le reçoit. Déplacer aussi le fichier aurait demandé que le lanceur distingue « service porté par l’image » de « service installé » — une décision à part entière, que rien n’appelle aujourd’hui.

Les emplacements ci-dessus sont ceux du code, vérifiésGRANT_ROOT = /etc/air/grants, et non /var/lib/air/services, qui est la racine des bundles (SERVICE_BUNDLE_ROOT). La première rédaction de ce tableau confondait les deux ; l’erreur a été relevée le jour même, en lisant air-bundle/src/load.rs pour mettre en œuvre D14.

Ce que D14 déplace est donc le seul manifeste, de /var/lib/air/services/<id>/ vers /usr/lib/air/…. L’octroi ne bouge pas : il était déjà hors du bundle, dans /etc, là où vit ce que l’administrateur décide. La séparation que cet amendement rend lisible existait déjà à moitié — ce qui manquait était que le manifeste cesse d’être écrit par la machine qui le reçoit.

Pourquoi le manifeste doit sortir du binaire. Il est aujourd’hui une constante Rust compilée dans air-sshd. S’il devait porter l’empreinte de ce binaire, il n’existerait aucun point fixe : écrire l’empreinte change le binaire, donc change l’empreinte. Le manifeste devient donc un artefact de construction, produit après l’édition de liens.

L’ordre est contraint, et c’est ce qui le rend simple : compiler, empreindre le binaire lié, produire le manifeste, le compiler, le signer, empaqueter. Aucune circularité — le manifeste naît après le binaire et ne l’influence pas.

D15 — Ed25519, et surtout PAS OpenPGP

La clé de projet est une clé Ed25519, du même type que la clé machine : une graine de 32 octets, signée et vérifiée par air-crypto, sans dépendance externe sur le chemin de vérification.

OpenPGP est rejeté, et pour trois raisons dont deux sont de doctrine :

  • il n’apporterait rien de vérifiable en plus — le chargeur lit déjà une signature Ed25519 brute ; GPG ne servirait qu’à ranger une clé qu’il faudrait convertir ;
  • un parseur OpenPGP sur un chemin de décision heurte le Principe 11 de front : format tolérant, versionné, plein de paquets optionnels — l’exact contraire d’un schéma à disposition fixe et bornée ;
  • la règle des 80 % (ADR-024) refuserait une bibliothèque dont on n’emploierait que « vérifier une signature détachée ».

GPG garde son emploi là où il en a un : la signature des commits (DCO). Ce n’est pas la même chaîne, ni le même public, ni le même artefact.

D16 — La clé privée ne vit sur AUCUN exécuteur de CI

⚠️ AMENDÉ LE JOUR MÊME (2026-09-04) : la clé vit sur carbon, qui EST un exécuteur. Décision du BDFL, prise en connaissance de ce que D16 disait — le texte ci-dessous reste, parce que son raisonnement reste juste et qu’il faudra y revenir.

Pourquoi c’est tenable aujourd’hui, et vérifié plutôt que supposé : le dépôt est privé, et les quarante pull requests ouvertes à ce jour le sont toutes par le même auteur. Le scénario que D16 redoute — un tiers ouvre une PR, la CI l’exécute, la clé part — n’a aucun acteur.

Pourquoi c’est provisoire, et ce qui doit le faire changer : la condition ci-dessus ne tient qu’à l’état présent du dépôt. Trois évènements l’annulent, et chacun doit faire déménager la clé :

  1. le dépôt devient public ;
  2. un collaborateur est ajouté, quel que soit son niveau d’accès ;
  3. un workflow acquiert une action tierce qui s’exécute sur ces machines — un runner auto-hébergé n’isole rien, et aucun permissions: ne l’empêche de lire ~/.config/air/keys/.

Ce qui a empêché la solution propre : air-bundle-gnu dépend de la couche 0, écrite en assembleur Linux — il ne se lie donc pas sur macOS, et le poste de release d’origine (oxygene) ne peut pas l’exécuter. La clé était au bon endroit, l’outil n’y allait pas.

La sortie visée : découpler l’outil de build de la couche 0 — un outil qui fabrique un artefact n’a aucune raison de dépendre du système d’Air —, ce qui rendrait oxygene capable de signer et cet amendement caduc. À défaut, désenrôler une machine.

La clé est également conservée sur oxygene, qui reste sa sauvegarde et son origine.

Un runner exécute du code qui vient de pull requests. Y placer la clé de signature du projet reviendrait à confier la racine de confiance à quiconque en ouvre une. Les quatre exécuteurs du parc — carbon, hydrogen, raspi-srv-2, speedy — sont donc exclus par construction, et le resteront quel que soit le durcissement de la CI.

La clé vit sur un poste de release qui n’est enrôlé nulle part — au 2026-09-03, oxygene —, dans un fichier hors du dépôt :

~/.config/air/keys/                    0700
~/.config/air/keys/release.ed25519     0600   (graine de 32 octets)

Hors du dépôt, et sans règle .gitignore pour l’y protéger : une clé qu’un .gitignore protège est une clé qu’on a envisagé d’y mettre.

Conséquence assumée : construction et signature se séparent. Le poste de release ne compile pas la couche 0 ; une machine Linux construit et empreint, le poste signe. C’est un inconvénient qui se révèle une propriété — la clé ne met jamais les pieds sur une machine de build.

La CI ne signe PAS avec cette clé. Elle éprouve que le mécanisme fonctionne, avec une clé jetable née pour un seul empaquetage (AIR_RELEASE_KEY=ephemere). Solliciter la clé de projet à chaque pull request l’exposerait à chaque job, pour produire des artefacts que personne ne livrera. Conséquence assumée : un .deb construit en CI n’est pas installable — la CI ne fabrique pas des paquets destinés à une machine.

La signature est isolée derrière un verbecargo xtask sign-manifest — pour que passer un jour à un jeton matériel ou à un service de signature ne touche qu’un seul appelant.

D17 — Plusieurs clés ACCEPTÉES, une seule qui SIGNE

Le chargeur porte une liste ordonnée et bornée de clés acceptées, dans son image — jamais lue du disque, exactement comme la machine_key, et pour la même raison : une clé qu’on lit là où l’attaquant écrit ne prouve rien.

Une seule signe : la première. Les autres sont encore acceptées et ne produisent plus rien.

Pourquoi une liste et non une clé. Avec une seule clé acceptée, une rotation invaliderait tous les manifestes déjà installés : au démarrage suivant, chaque service serait refusé. La rotation casserait la machine — donc on ne la ferait jamais, et une clé qu’on ne peut pas retirer n’est plus une clé, c’est une dette.

Ce qui empêche la liste de grossir en silence : elle est dans le code. L’élargir demande de livrer un nouveau chargeur, donc un diff que quelqu’un relit. Une rotation ajoute en tête ; une release ultérieure retire en queue, explicitement.

Aucune date d’expiration dans l’artefact. L’horloge est une entrée que rien ne garantit — une machine dont la date recule ré-accepterait une clé retirée. Une clé expire parce qu’on la retire de la liste, pas parce qu’une date est passée.

D18 — Il n’y a PAS de clé de développement en production

Un « mode développement » que le chargeur de production accepterait est une porte. Elle serait ouverte par quelque chose — un drapeau, une variable d’environnement, un fichier — et ce quelque chose deviendrait la cible. Tout ce que D3 construit (clé hors du disque, cryptographie avant le reste, refus à coût uniforme) tomberait devant une condition qu’on peut retourner.

La réponse est déjà dans le chargeur, et elle n’a pas besoin d’un mode :

BundleLoader::system()la production — liste de clés figée dans l’image
BundleLoader::at(racine, propriétaire)« réservé aux tests et à l’outillage d’administration »

Un développeur qui éprouve un bundle construit un chargeur avec sa liste à lui. Ce n’est pas le chargeur de production en mode dégradé : c’est un autre chargeur, dans du code de test. Pour essayer sur une vraie machine, il bâtit et installe un chargeur portant sa clé — un build à lui, ce qui est le niveau de friction souhaitable pour qui remplace la racine de confiance d’une machine.

Ce que cela coûte, et il faut le dire : un contributeur extérieur ne peut pas éprouver un bundle signé sur son poste sans reconstruire le chargeur. C’est un frein réel à la contribution. Il est préféré à une porte — mais c’est un arbitrage, pas une évidence, et il mérite d’être rouvert si le coût se révèle plus lourd que prévu.

Conséquences

  • air-sshd provision-bundle cesse de signer le manifeste ; il ne pose plus que l’octroi. keystore.service_manifest_key() perd son unique usage.
  • Le manifeste d’air-sshd cesse d’être une constante Rust et devient un gabarit que l’empaquetage instancie avec l’empreinte du binaire construit.
  • Un build reproductible qui signe n’est pas reproductible par un tiers : deux personnes obtiennent le même binaire et le même manifeste, mais pas la même signature faute de clé. Ce n’est pas une entorse à ADR-025 — l’artefact signé n’est pas l’artefact construit — mais cela doit être dit là où la reproductibilité est vérifiée.
  • Rien de tout ceci n’est en place au 2026-09-03 : c’est une décision, et son état d’avancement se lit dans docs/etat-avancement.md, non ici.

D20 — Le plafond d’un service est EXPLICITE, et la provenance le choisit (BDFL, 2026-09-06)

Ce que le jalon B a heurté

Le franchissement du jalon B d’ADR-168 s’est arrêté sur un refus, et le refus avait raison :

« org.air-desktop.sshd » — octroi refusé : Refused(Refusal { reason: OutsideEnvelope })

Un démon SSH demande à écouter sur un port, à lire sa configuration sous /etc/air et à piloter des anneaux io_uring. Aucun plafond du système ne permet cela :

foreign_default() (appliqué à tout bundle)air_signed_default()
filesystem/usr (r), /var/lib/<id> (rw)+ /etc/air (r), /var/cache/<id> (rw), /media (r)
bind_portsvide : aucune écoute
connect_ports[80, 443]
io_uringabsent du plafond entierabsent

D20.1 — Un plafond de service, distinct de celui des applications

Les deux plafonds existants visent des applications : nombreuses, écrites par des tiers, inconnues de l’image. Un plafond par défaut y est le seul moyen de dire quelque chose sur ce qu’on ne connaît pas encore.

Un service système est l’inverse : ils sont peu nombreux, connus de l’image, et leurs besoins n’ont rien de commun — un démon SSH écoute sur 22, un serveur de temps sur 123, un gestionnaire de sessions n’écoute rien.

Élargir foreign_default() pour les accueillir serait le pire choix : cela accorderait à un inconnu ce qui n’est justifié que pour du vérifié.

D20.2 — Aucun default() pour les services — l’explicite, ou rien

Question posée par le BDFL, tranchée ici.

Un défaut de service serait, par construction, l’union des besoins de tous les services : le port du plus bavard, les chemins du plus curieux, les familles du plus gourmand. Il serait donc trop large pour chacun d’eux, et deviendrait le plafond le plus permissif du système — appliqué automatiquement, à des composants qui tournent en permanence et souvent sous root.

C’est exactement le genre de valeur qui dérive : chaque service qui ne rentre pas l’élargit d’un cran, et personne ne le rétrécit jamais.

Un service déclare son plafond, nommément, ou il ne démarre pas. L’absence de plafond n’est pas un cas dégradé à combler par un défaut : c’est un refus, et il est fail-closed.

L’image connaît ses services — c’est ce qui rend cette exigence tenable, là où elle serait absurde pour une application. La forme suit : Ceiling::for_service(bundle_id) -> Option<Ceiling>, None valant refus. Pas de nouvel artefact sur disque : le plafond d’un service appartient à l’image, comme foreign_default() et air_signed_default() y appartiennent déjà.

D20.3 — La provenance choisit le plafond

launch_ceiling() rend aujourd’hui foreign_default() à tout bundle, avec ce commentaire : « le mur est visible et daté ; il tombera avec le registre de provenance, pas avant ».

Or la provenance est déjà prouvée : le manifeste porte la signature de la clé de projet, et le chargeur la vérifie avant d’arbitrer. Attendre un registre complet pour utiliser une preuve qu’on tient déjà revient à ignorer ce qu’on a vérifié.

Le choix devient donc :

Signataire du manifestePlafond
clé de projet, bundle de kind = servicefor_service(bundle_id), ou refus
clé de projet, autre kindair_signed_default()
toute autre clé, ou aucuneforeign_default() — inchangé

Le registre de provenance reste à écrire ; ceci n’en est pas un. C’est l’usage d’une preuve cryptographique déjà exigée, là où elle était jusqu’ici constatée puis oubliée.

Précision apportée à la mise en œuvre (2026-09-07). La phrase « la provenance est déjà prouvée » était trop courte d’un cran. Ce que le chargeur vérifiait, c’est qu’une signature valide couvre le manifeste, et il en rendait la clé — l’identité du signataire, donc, mais pas sa provenance : rien ne comparait cette clé à quoi que ce soit. La pièce manquante n’était pas le registre, c’était la liste des clés acceptées que D17 décrit depuis le 2026-09-04 et qui n’existait dans aucun fichier. Elle est écrite ici — ACCEPTED_PROJECT_KEYS, une entrée, la clé de publication du projet — et c’est elle qui fait de is_project_key() une question à laquelle le code sait répondre. Sans elle, le tableau ci-dessus n’aurait pas eu de première colonne.

D20.4 — io_uring doit entrer dans le plafond

[ADR-150] D19 a créé la famille la veille sans lui donner de plafond : Ceiling ne la mentionne nulle part, et permits() ne la regarde pas.

Une famille qu’aucun plafond ne borne est une famille hors arbitrage — n’importe quel octroi peut l’accorder, et l’image n’a aucun moyen de dire non. C’est contraire à tout ce que cet ADR construit, et c’est un défaut de D19, non une conséquence de D20.

Il est corrigé ici : Ceiling porte io_uring: bool, et permits() refuse un octroi qui le demande quand le plafond ne l’accorde pas.

D20.5 — La base des comptes : ce qu’un service ne demande pas un octroi nommé

Arbitrage BDFL du 2026-09-06 : « le démon s’en passe, il passe par manager. » Repris le 2026-09-07, au vu du fait établi ci-dessous.

Ce que disait la première rédaction

air-sshd avait demandé /etc/passwd en lecture. Aucun plafond ne le couvre, et aucun ne devrait : la base des comptes est médiée par air-account en couche 1 (ADR-149 D4). Un service qui lit /etc/passwd contourne son manager — c’est un second chemin au sens d’ADR-149 D10 […]. La demande est retirée du manifeste plutôt qu’ajoutée au plafond.

⚠️ La prémisse était fausse, et le premier lancement réel l’a montrée

air-sshd passe déjà par son manager. La chaîne, relevée dans le code le 2026-09-07 :

PrivsepConfig::resolve()  →  AirPrivilegeDrop::for_user("_air-sshd")
                          →  air_account::lookup_user_by_name()   ← le manager, couche 1
                          →  read_to_bytes("/etc/passwd")

Il n’y a aucun second chemin ici. air-account est bien la couche 1 qui médie la base des comptes, et le démon ne fait rien d’autre que l’appeler.

Ce que la première rédaction supposait sans le dire, c’est que « passer par le manager » dispense de l’octroi. C’est faux tant que les managers sont per-process : air-account n’est pas un démon, c’est du code lié dans air-sshd. La lecture de /etc/passwd a donc lieu dans le processus confiné, sous sa propre cage Landlock, et l’octroi la gouverne.

Le résultat, sur carbon le 2026-09-07, au premier démarrage sous air-launchd :

air-sshd: contexte privsep indisponible : PermissionDenied
          "read_to_bytes : /etc/passwd" Errno(13)

Quatre tentatives, puis abandon. Un service qui ne démarre pas coûte plus cher qu’un droit de lecture nommé sur un fichier.

La décision

/etc/passwd et /etc/group sont accordés en lecture, et le plafond de service les nomme — CeilingRule::Exact, pas un sous-arbre : ces deux fichiers, et rien d’autre sous /etc.

Les deux, et non le premier seul : AirPrivilegeDrop::for_user résout l’utilisateur (lookup_user_by_name) puis ses groupes supplémentaires (groups_of_user). N’accorder que /etc/passwd fait échouer sur /etc/group, à l’identique — constaté au même démarrage, une minute après le premier refus.

Trois choses rendent ce droit acceptable là où « un service lit la base des comptes » ne le serait pas :

  1. il est médié — le manager fait la lecture, le démon ne parse rien ;
  2. il est nommé exactement, jamais un sous-arbre ;
  3. il est en lecture seule, et le démon en a besoin avant de pouvoir servir — la résolution du compte a lieu avant tout fork, délibérément (pour ne pas allouer dans la fenêtre post-fork, et pour échouer tôt si le compte manque).
Ce qui reste dû, et qui est la vraie réponse

Le droit ci-dessus est une concession datée, pas une fin. La bonne réponse est que le lanceur résolve l’identité et la transmette au service : l’uid/gid deviennent un élément de l’octroi, et plus un fichier à lire. Le service n’aurait alors aucun droit sur /etc.

Ce sera l’objet d’un ADR dédié — ADR-182, qui n’est pas encore écrit au 2026-09-07 — le numéro 179 avait d’abord été annoncé, à tort : il est pris par « PID 1 est minimal et survit à son superviseur ». L’écrire ici en toutes lettres, plutôt que d’y renvoyer par un lien, est délibéré : un texte qui pointe vers un document inexistant donne une assurance que personne ne vérifie.

Tant que cet ADR n’est pas mis en œuvre, ce droit tient — et il porte ici sa date et sa raison, plutôt que de se faire oublier.

D19 — io_uring devient la sixième famille d’octroi (BDFL, 2026-09-05)

Ce que le défaut a montré

Le vocabulaire de confinement ne connaissait aucun des trois appels d’io_uring. Comme aucune catégorie ne les portait, aucun service Air confiné ne pouvait faire d’entrées-sorties : air-sshd était tué par SIGSYS sur io_uring_setup, sous une cage pourtant complète par ailleurs.

Le défaut n’a été vu que le 2026-09-05, en rendant honnête le harnais du jalon B — celui qui rendait ok sans rien prouver. C’est la même famille que le granted.aircom qui n’ouvrait aucun appel (2026-09-03) : une catégorie qui se croit complète et ne l’est pas.

La décision

io_uring est une famille d’octroi à part entière, demandée explicitement au manifeste. Elle n’est ouverte par aucune autre.

FamilleOuvre
1. filesystemles appels de fichiers
2. networkles verbes réseau
3. aircomle registre de noms
4. devicesles nœuds nommés
5. sessionles programmes de session (ADR-153)
6. io_uringio_uring_setup, _enter, _register, plus eventfd2 et sched_getaffinity

Pourquoi dédiée, et non adossée à network ou filesystem

Un anneau est une seconde porte. Il exécute lecture, écriture, ouverture et connexion dans le noyau, sur les descripteurs qu’il a enregistrés, sans repasser par l’appel système correspondant.

L’adosser au réseau donnerait, à qui demande un port, le pouvoir d’ouvrir des fichiers ; l’adosser aux fichiers donnerait l’inverse. Une permission qui en accorde silencieusement une autre est exactement ce que l’arbitrage d’ADR-150 existe pour empêcher.

Ce que la famille NE borne PAS, et qu’il faut écrire

Les opérations soumises dans l’anneau échappent à seccomp. Elles voyagent dans une file en mémoire partagée : le filtre voit io_uring_enter, jamais l’openat qu’il transporte.

C’est pourquoi la famille ne porte qu’un booléen. Offrir une liste d’opérations « autorisées » laisserait croire à un arbitrage que rien ne rendrait — le défaut même que ce dépôt combat. Ce qui borne l’anneau reste Landlock, qui s’applique aux descripteurs quelle que soit la porte empruntée.

C’est une structure et non un bool nu, pour que la famille puisse se raffiner sans changer la forme du manifeste.

Les deux compagnons, et pourquoi ils sont dans la famille

eventfd2 et sched_getaffinity y sont, et pas au socle. Une première rédaction les y avait mis, sur la foi d’un grep de fichiers citant ces noms — air-handle, air-com et air-async ne font que les mentionner en commentaire. Le seul appelant réel est air-uring : eventfd2 pour l’éveil inter-fil d’un réacteur, sched_getaffinity pour dimensionner le pool juste avant io_uring_setup.

Les mettre au socle aurait élargi tous les profils figés — dont le pre-auth d’air-sshd, le plus sensible du projet — pour un appel dont aucun d’eux n’a besoin. Ce sont les tests de profils figés qui l’ont refusé.

Conséquence sur D13

Il y a désormais neuf catégories, non huit. D13 reste exact : io_uring n’entre dans aucune enveloppe de session — un shell n’ouvre pas d’anneau —, et l’union des huit catégories qu’il mesure demeure 93 appels, l’enveloppe C 65. Les tests le constatent, ils ne le recopient pas.

⚠️ Collision avec ADR-169, et comment elle est tranchée

ADR-169 — proposé le 2026-09-01, arbitrage BDFL le jour même — réserve startup @5 et s’intitule lui aussi « sixième famille d’entitlements ». Les deux ne peuvent pas coexister : Cap’n Proto impose des ordinaux consécutifs, donc @5 n’appartient qu’à un seul.

Le rang va à celui qui est mis en œuvre. io_uring prend @5 et le rang 6 parce qu’il est livré ici ; startup prendra @6 et le rang 7 quand ADR-169 sera implémenté. Rien de la décision d’ADR-169 n’est touché — son D1 porte sur l’existence de la famille et sur l’égalité d’octets du bundleId, non sur un numéro d’ordinal.

Ce qui aurait été inacceptable, c’est de laisser deux textes revendiquer le même @5 : le premier à régénérer le schéma aurait silencieusement écrasé l’autre.


Licence du document : MPL 2.0