Aller au contenu

Choix d'isolation du Dev Container

Cette page explique pourquoi Kirexo embarque un Dev Container avec sa propre couche d'isolation réseau, et pourquoi pas certaines alternatives plus simples. Pour la liste exhaustive des fichiers, ports et variables, voir la Référence du Dev Container. Pour le pas-à-pas d'ouverture, voir le tutoriel PHPStorm.

Pourquoi un Dev Container

Le projet Kirexo a deux catégories d'utilisateurs côté dev : un humain qui code dans son IDE, et Claude Code qui écrit, teste et corrige du code dans le même environnement. Les deux ont besoin d'un environnement reproductible — mêmes versions de PHP, Node, Castor, glab, mêmes binaires, même configuration Xdebug — sans rien installer côté hôte. C'est exactement ce que vise le standard Dev Containers : décrire dans un fichier versionné un environnement de dev complet, reconstructible à partir des sources du projet.

Pour l'humain, le bénéfice est l'absence de divergence d'environnement : tout le monde travaille avec exactement le même outillage, peu importe la distribution Linux, la version de macOS ou la machine Windows. Pour Claude Code, le bénéfice est plus structurant — il rend l'usage de bypassPermissions raisonnable.

Pourquoi bypassPermissions exige une isolation forte

En dehors d'un conteneur, Claude Code demande une confirmation avant chaque action sensible (écriture de fichier, exécution de commande, requête réseau). Ce mode protège l'utilisateur, mais à un prix : la friction est continue, et le développeur prend l'habitude de cliquer « oui » par réflexe — ce qui annule en pratique la protection.

Le mode bypassPermissions lève toutes ces confirmations. Acceptable seulement si l'environnement d'exécution est lui-même circonscrit : un Claude Code qui peut tout faire dans le conteneur, mais qui ne peut rien faire sur la machine hôte.

Le mode reste un choix opt-in/opt-out : par défaut le devcontainer active bypassPermissions au postCreateCommand, mais un dev qui préfère le mode confirmation natif de Claude Code peut exporter KIREXO_CLAUDE_BYPASS=0 avant de créer le devcontainer — post-create.sh saute alors la création du settings.local.json. Pour un opt-out durable (qui survit aux rebuilds et n'oblige pas à ré-exporter la variable à chaque ouverture de l'IDE), il existe un mécanisme équivalent à base de marqueur fichier : créer un fichier vide .devcontainer/no-claude-bypass (gitignored, propre à chaque dev) produit le même effet. Les deux barrières ci-dessous (isolation filesystem + isolation réseau) restent en place dans tous les cas ; seul le confort d'usage de Claude change. Cf. Référence du Dev Container.

Le Dev Container Kirexo combine deux barrières pour atteindre ce niveau :

  1. Isolation filesystem — le conteneur ne voit que /app (bind-mount du dépôt) et son propre arbre de fichiers ; il n'a aucun accès aux fichiers de l'hôte.
  2. Isolation réseau — un pare-feu interne iptables restreint le trafic sortant à une whitelist explicite de domaines (cf. init-firewall.sh).

C'est l'addition des deux qui rend bypassPermissions acceptable. Une seule des deux ne suffirait pas : un Claude Code isolé en filesystem pourrait toujours exfiltrer du code via une requête HTTP arbitraire ; un Claude Code isolé réseau pourrait toujours écrire dans /etc ou ~/.ssh.

Pourquoi un pare-feu iptables interne plutôt que le sandbox Claude Code natif

Claude Code propose nativement un mécanisme de permission par tool call (Read, Bash, WebFetch…) avec des règles d'allowlist par domaine et par chemin. Pourquoi ne pas s'en contenter ?

Trois raisons :

  1. Le pare-feu protège tout, pas juste Claude. Un sous-processus lancé par Claude (un npm install qui exécute un script de post-install, un composer install qui télécharge un binaire) n'est pas filtré par les règles Claude — il échappe à tout contrôle. Un pare-feu iptables filtre toutes les sockets sortantes du conteneur, indépendamment du processus émetteur.
  2. Le filtrage est au niveau IP/port, pas applicatif. Claude Code peut être configuré pour bloquer une URL, mais un sous-processus qui ouvre une socket TCP brute vers 1.2.3.4:443 passe sous le radar. iptables -m set --match-set allowed-domains joue plus bas dans la pile.
  3. C'est portable et auditable. Le script init-firewall.sh est versionné, lisible, modifiable, debuggable avec iptables -L ou ipset list. La configuration de permissions Claude vit dans plusieurs fichiers (settings utilisateur, settings projet, bypassPermissions) et change selon la version du CLI.

L'approche est directement reprise de anthropics/claude-code puis dunglas/symfony-docker, avec une whitelist élargie aux domaines spécifiquement utiles à Kirexo (Symfony, PHP, Doctrine, Composer, FrankenPHP, JoliCode, GitLab).

Comment ça marche — flow réseau

Le pare-feu ne se contente pas d'une liste statique d'IPs : il couple la résolution DNS et le filtrage IP via un ipset alimenté en temps réel par dnsmasq. Trois diagrammes pour comprendre ce qui se passe quand une socket sortante essaie de s'ouvrir.

Cas nominal — requête vers un domaine whitelisté (ex. symfony.com) :

flowchart TB
    client["Client<br/>(composer, npm, curl…)"]
    dnat["iptables nat OUTPUT<br/>DNAT 53 → 127.0.0.2:53"]
    dnsmasq["dnsmasq<br/>127.0.0.2:53"]
    dockerdns["Docker DNS<br/>127.0.0.11:port aléatoire"]
    ipset["ipset allowed-domains<br/>(set d'IPs autorisées)"]
    accept["iptables OUTPUT<br/>match-set allowed-domains → ACCEPT"]
    out(["Socket TCP/443 vers IP résolue"])

    client -- "1- requête DNS symfony.com" --> dnat
    dnat -- "2- redirection" --> dnsmasq
    dnsmasq -- "3- forward upstream" --> dockerdns
    dockerdns -- "4- réponse A: 1.2.3.4" --> dnsmasq
    dnsmasq -- "5- ajoute 1.2.3.4 à l'ipset" --> ipset
    dnsmasq -- "6- réponse DNS au client" --> client
    client -- "7- connect 1.2.3.4:443" --> accept
    ipset -. "lookup" .-> accept
    accept -- "8- paquet sort" --> out

    classDef allow fill:#16a34a,stroke:#14532d,color:#fff
    classDef neutral fill:#1e293b,stroke:#0f172a,color:#fff
    class accept,out allow
    class dnsmasq,dockerdns,ipset,dnat,client neutral

L'ipset joue le rôle de pont entre deux couches qui ne se parlent normalement pas : la résolution DNS (couche 7) et le filtrage par socket (couche 3/4). C'est dnsmasq qui fait le lien — chaque réponse DNS pour un domaine de la whitelist alimente l'ipset, et iptables consulte cet ipset au moment d'ouvrir la socket.

Cas rejet — requête vers un domaine NON whitelisté (ex. example.com) :

flowchart TB
    client2["Client<br/>(composer, npm, curl…)"]
    dnat2["iptables nat OUTPUT<br/>DNAT 53 → 127.0.0.2:53"]
    dnsmasq2["dnsmasq<br/>127.0.0.2:53"]
    dockerdns2["Docker DNS<br/>127.0.0.11:port aléatoire"]
    ipset2["ipset allowed-domains<br/>(IP NON ajoutée)"]
    reject["iptables OUTPUT<br/>REJECT icmp-admin-prohibited"]
    drop(["Connexion refusée"])

    client2 -- "1- requête DNS example.com" --> dnat2
    dnat2 -- "2- redirection" --> dnsmasq2
    dnsmasq2 -- "3- forward upstream" --> dockerdns2
    dockerdns2 -- "4- réponse A: 9.9.9.9" --> dnsmasq2
    dnsmasq2 -- "5- domaine hors whitelist :<br/>rien dans l'ipset" --> ipset2
    dnsmasq2 -- "6- réponse DNS au client" --> client2
    client2 -- "7- connect 9.9.9.9:443" --> reject
    reject --> drop

    classDef deny fill:#b91c1c,stroke:#7f1d1d,color:#fff
    classDef neutral fill:#1e293b,stroke:#0f172a,color:#fff
    class reject,drop deny
    class dnsmasq2,dockerdns2,ipset2,dnat2,client2 neutral

Le DNS résout normalement (le pare-feu ne ment pas sur la résolution) : ce qui coupe, c'est l'étape 7. La policy par défaut OUTPUT DROP posée juste après le flush rejetterait déjà, mais la règle finale REJECT --reject-with icmp-admin-prohibited rend l'échec explicite (« Connection refused » immédiat) au lieu d'un timeout silencieux.

Cas particulier : les services intra-stack (database, redis, rabbitmq, …) ne passent pas par ce flow. Ils sont pré-résolus dans /etc/hosts au boot du script (avant que le DNAT vers dnsmasq soit posé), et le trafic sortant vers eux est autorisé par la règle CIDR iptables -A OUTPUT -d 172.30.0.0/24 -j ACCEPT — pas par l'ipset. C'est ce qui permet de couper l'accès aux noms internes via le DNS sans casser la connectivité à la stack.

Ordre d'exécution dans init-firewall.sh :

flowchart LR
    preflight["0- pré-validation<br/>dnsmasq --test"]
    flush["1- flush<br/>iptables/ipset"]
    policy["2- policy DROP<br/>par défaut"]
    hosts["3- pré-résoudre<br/>services intra-stack<br/>vers /etc/hosts"]
    dnsdnat["4- DNAT 53 →<br/>127.0.0.2:53"]
    dnsmasqcfg["5- démarrer<br/>dnsmasq + ipset"]
    accepts["6- ACCEPT loopback,<br/>gateway, CIDR Docker,<br/>match-set"]
    rejectfinal["7- REJECT final<br/>icmp-admin-prohibited"]

    preflight --> flush --> policy --> hosts --> dnsdnat --> dnsmasqcfg --> accepts --> rejectfinal

    classDef deny fill:#b91c1c,stroke:#7f1d1d,color:#fff
    classDef allow fill:#16a34a,stroke:#14532d,color:#fff
    classDef preflightcls fill:#0891b2,stroke:#155e75,color:#fff
    classDef neutral fill:#1e293b,stroke:#0f172a,color:#fff
    class policy,rejectfinal deny
    class accepts allow
    class preflight preflightcls
    class flush,hosts,dnsdnat,dnsmasqcfg neutral

L'ordre n'est pas cosmétique. La pré-validation à l'étape 0 reconstruit la directive ipset=/dom1/dom2/.../allowed-domains à partir de .devcontainer/baseline-domains.txt + .devcontainer/whitelist.local.txt puis la confronte à dnsmasq --test --conf-file=<temp> avant de toucher à quoi que ce soit. Modèle de panne adressé : une config foireuse (typo dans la baseline, wildcard glissé dans le local, …) qui ne se révèlerait qu'au lancement effectif de dnsmasq. Sans pré-validation, le flush + OUTPUT DROP auraient déjà eu lieu au moment où dnsmasq plante — tout sortant rejeté, plus aucun moyen de réparer depuis l'intérieur puisque le sudoers n'autorise que la relance du même script qui replantera. Avec pré-validation, une config invalide échoue tôt, le pare-feu précédent (et son ipset alimenté) restent en place — le dev corrige et relance sereinement. Poser policy DROP juste après le flush ferme la fenêtre temporelle où la policy redevient ACCEPT le temps que les exceptions soient (re)posées — sans ça, un reload du pare-feu ouvrirait quelques millisecondes pendant lesquelles tout le trafic sortant passerait. La pré-résolution /etc/hosts doit se faire avant le DNAT vers dnsmasq (sinon le getent qui interroge 127.0.0.11:53 partirait en boucle via dnsmasq). Et le REJECT final ne peut venir qu'en dernier — il sert de catch-all, donc toute règle ACCEPT ajoutée après serait morte.

Ce que l'isolation ne protège pas

Le couple « filesystem /app-only + pare-feu sortant whitelist » couvre l'essentiel des scénarios qui rendent bypassPermissions raisonnable, mais il a deux limites assumées qu'il faut connaître avant de manipuler des secrets côté hôte ou d'élargir la whitelist.

Exfiltration via l'API Anthropic

anthropic.com et claude.ai sont dans la baseline (Claude Code en a besoin pour l'inférence et l'auth). En parallèle, ${HOME}/.claude est bind-monté dans le conteneur — y compris .credentials.json, surchargé en lecture seule par compose.devcontainer.yaml (cf. Durcissement : surcharge RO sur les fichiers sensibles). Le RO empêche un Claude compromis de réécrire les credentials côté hôte, pas de les lire.

Conséquence : un Claude compromis dans le conteneur peut lire les credentials Claude Code et les exfiltrer dans le contenu d'une requête vers api.anthropic.com — sans rien violer du pare-feu, puisque le domaine est légitimement whitelisté. Le RO sur les fichiers sensibles empêche la persistance d'une compromission entre sessions ; il n'empêche pas l'exfiltration en cours de session.

C'est un arbitrage explicite : Claude Code n'est utilisable qu'avec ces credentials accessibles en lecture, donc on accepte ce vecteur en échange de l'usage de l'outil. Limiter le rayon d'action :

  • ne pas stocker dans ${HOME}/.claude/ de credentials qui ne sont pas spécifiquement destinés à Claude Code (pas de tokens GitLab, de clés SSH ou de fichiers .env perso) ;
  • préférer un compte Anthropic dédié au projet (à révoquer en cas d'incident) plutôt que ton compte personnel principal.

Le même raisonnement vaut pour tout domaine ajouté à la whitelist qui accepte du contenu utilisateur (sentry.io, gitlab.com, …) — la whitelist est un filtre de destination, pas un filtre de contenu.

Contournement potentiel via DNS-over-HTTPS

Le pare-feu couple résolution DNS et filtrage IP via dnsmasq + ipset (cf. Comment ça marche — flow réseau). Le lien casse si un client résout son nom sans passer par dnsmasq — typiquement via du DNS-over-HTTPS (DoH) qui parle directement à un résolveur public en TCP/443.

En l'état, ce scénario reste bloqué : les résolveurs DoH publics (1.1.1.1, 8.8.8.8, 9.9.9.9, dns.google, …) ne sont pas whitelistés, donc une socket vers eux est rejetée par la policy OUTPUT DROP. La porte s'ouvrirait si un domaine whitelisté hébergeait un endpoint DoH (rare aujourd'hui, mais Cloudflare expose par exemple cloudflare-dns.com — pas dans la baseline Kirexo, mais à surveiller si on whitelistait un jour un CDN Cloudflare générique).

À garder à l'esprit en ajoutant un domaine : si tu whitelistes un sous-domaine d'un CDN majeur (*.cloudflare.com, *.fastly.net, …), tu autorises potentiellement aussi son endpoint DoH — et tout le mécanisme de filtrage par ipset devient bypassable pour un attaquant capable d'utiliser DoH. Préférer des FQDNs précis, pas des suffixes génériques de CDN.

Pourquoi NE PAS monter /var/run/docker.sock

Une convention répandue (« Docker-out-of-Docker », DooD) consiste à monter le socket Docker de l'hôte dans le conteneur de dev. Le conteneur peut alors lancer des docker compose up, des conteneurs frères, des images custom — pratique pour les outils CI ou les workflows multi-service.

Kirexo a délibérément choisi de ne pas le faire. Le coût d'isolation est trop élevé : un processus dans le conteneur qui peut parler au démon Docker hôte peut monter n'importe quel répertoire de l'hôte dans un nouveau conteneur et lire/écrire sur le système de fichiers du développeur sans restriction. C'est l'évasion classique des sandboxes via Docker — bien documentée, bien outillée.

La contrepartie est claire : depuis l'intérieur du devcontainer, certaines cibles Castor ne fonctionnent pas. castor docker:up, docker:down, docker:build, docker:logs, docker:sh, cert:trust, doctor et docs:build pilotent toutes Docker depuis l'extérieur — elles refusent l'intérieur via assert_outside_container() (cf. castor.php). Le développeur les lance depuis un terminal hôte ; le reste de l'outillage (cs:fix, phpstan, lint, test:*, composer, console, …) tourne directement, sans wrap, parce qu'on est déjà dans le conteneur cible.

C'est une dégradation acceptable : démarrer la stack reste un acte explicite du développeur, pas un acte que Claude Code automatise. Et l'isolation gagnée est, elle, structurelle.

Pourquoi cap_add: NET_ADMIN (et pas --privileged)

init-firewall.sh doit manipuler iptables et ipset. Sans capacité supplémentaire, ces commandes échouent (« Operation not permitted ») même en sudo — Linux refuse les opérations réseau privilégiées dans un conteneur sans la capacité kernel correspondante.

Deux options pour débloquer :

  1. --privileged — donne toutes les capacités, plus l'accès aux devices, plus le bypass des cgroups. Surface immense, équivalent à un accès root sur l'hôte dans certains scénarios.
  2. cap_add: NET_ADMIN, NET_RAW — donne uniquement les capacités réseau nécessaires. NET_ADMIN autorise les opérations sur les sockets et les règles iptables ; NET_RAW est requis pour le state-tracking sur les noyaux récents.

Kirexo prend la seconde. La capacité NET_ADMIN ne sort pas du conteneur : elle ne permet pas de manipuler les interfaces réseau de l'hôte, juste celles du conteneur. C'est un compromis bien dimensionné pour le besoin.

Pourquoi pas no-new-privileges en complément

La flag security_opt: ["no-new-privileges:true"] a été envisagée comme défense en profondeur indépendante de NET_ADMIN : elle pose le bit kernel NoNewPrivs sur le process et tous ses enfants, ce qui désactive le mécanisme setuid au moment d'un execve. Sur le papier, ça ferme une voie d'escalade par binaire setuid arbitraire dans l'image.

Le problème, c'est qu'elle a été retirée : no-new-privileges est incompatible avec le sudo du devcontainer. Le binaire /usr/bin/sudo est lui-même setuid root — il a besoin du bit setuid pour passer en root, avant de consulter /etc/sudoers et décider si l'utilisateur a le droit de lancer la commande. Avec no_new_privs posé, le kernel coupe l'élévation au niveau execve, donc sudo échoue avant même d'avoir lu sudoers, avec le message :

sudo: The "no new privileges" flag is set, which prevents sudo from running as root.

Conséquence directe sur Kirexo : post-start.sh lance sudo /app/.devcontainer/init-firewall.sh au démarrage du conteneur. Avec la flag posée, ce sudo échouait → le pare-feu et dnsmasq ne démarraient jamais. Et comme post-start.sh tourne en set -euo pipefail, l'échec faisait sortir le script avant le lancement du supervisor FrankenPHP — l'application et la doc devenaient inaccessibles depuis l'hôte à chaque redémarrage du devcontainer.

L'argument « compatible avec le sudoers NOPASSWD » qui figurait initialement dans ces lignes était faux. Le sudoers n'est consulté qu'après l'élévation root via setuid ; la directive NOPASSWD économise la saisie d'un mot de passe, elle ne contourne pas le besoin de setuid. Le bit kernel se vérifie d'ailleurs facilement depuis l'intérieur d'un conteneur : grep NoNewPrivs /proc/self/status retourne 1 quand la flag est posée, 0 sinon.

Note pour ne pas la rajouter par mégarde : tant que init-firewall.sh (ou le healthcheck) dépend de sudo pour passer root, no-new-privileges est inutilisable. L'alternative serait de donner directement la capability CAP_NET_ADMIN au binaire iptables via setcap dans le Dockerfile — mais ça déplace la complexité, complique l'audit, et n'apporte pas grand-chose tant que le sudoers reste verrouillé sur les deux seuls scripts du pare-feu.

Dans la même logique, le bloc global du frankenphp/Caddyfile active skip_install_trust. Sans cette directive, Caddy tente d'écrire la CA racine dans le trust store du conteneur au démarrage, opération qui exige sudo — refusée par notre sudoers strict. Et c'est très bien ainsi : aucun client TLS interne n'a besoin de cette CA. Le seul client TLS qui se connecte à FrankenPHP est le navigateur côté hôte, qui passe par castor cert:trust pour faire confiance au certificat (cible volontairement réservée à l'hôte, cf. Référence du Dev Container). Réduire ce que sudo peut faire et ce qui en aurait besoin est cohérent : moins d'opérations privilégiées, moins de surface d'attaque.

Aligné sur dunglas/symfony-docker

Le Dockerfile de Kirexo est dérivé de dunglas/symfony-docker, qui propose lui-même un Dev Container. Le pare-feu, la convention sudoers (NOPASSWD limité au seul init-firewall.sh), la stratégie « pas de socket Docker monté » sont reprises tels quels. Les ajouts spécifiques Kirexo sont :

  • la whitelist élargie aux domaines de l'écosystème PHP/Symfony/Doctrine,
  • une plage CIDR intra-stack unique (172.30.0.0/24, définie par un réseau Docker custom kirexo dans compose.override.yaml) au lieu d'ouvrir les trois RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). Ça ferme la fenêtre théorique « un service VPN d'entreprise écoute sur 10.x ou 192.168.x et reste joignable depuis le devcontainer » : seul le réseau dédié à la stack Kirexo est routable depuis l'intérieur,
  • l'installation de castor, glab et Node en binaires statiques figés par version et par SHA-256 (CASTOR_VERSION + CASTOR_SHA256, GLAB_VERSION + GLAB_SHA256, NODE_VERSION + NODE_SHA256) — vérifié via sha256sum -c avant écriture pour rejeter un mirror compromis ou une release republished sous le même tag. Pour Node spécifiquement, le tarball officiel nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz remplace l'installation par paquet Debian via NodeSource : la version patch est figée (au lieu de flotter à chaque rebuild) et l'intégrité est vérifiée par hash, pas par signature GPG du dépôt apt,
  • l'installation de Claude Code via npm install -g @anthropic-ai/claude-code directement dans le Dockerfile,
  • l'exclusion volontaire de GitHub de la whitelist — le projet est hébergé sur GitLab, et les binaires GitHub indispensables (Castor, glab) sont récupérés au build de l'image plutôt qu'au runtime depuis le conteneur,
  • un override local .devcontainer/whitelist.local.txt (gitignored) — concaténé à la whitelist baseline si présent, pour qu'un dev ajoute un domaine ponctuel sans modifier le script versionné. Les cibles castor devcontainer:whitelist-add <domain> et whitelist-remove <domain> automatisent l'édition et le reload du pare-feu, en validant le FQDN et en dédoublonnant contre la baseline,
  • la pose des policies par défaut OUTPUT/INPUT/FORWARD DROP immédiatement après le flush des règles, pour éliminer la fenêtre temporelle où la policy redevient ACCEPT le temps que les exceptions soient (re)posées,
  • des smoke-tests post-reload : un contrôle négatif bloquant (example.com doit échouer) + des contrôles positifs non bloquants sur gitlab.com, symfony.com, packagist.org, registry.npmjs.org — un drift de whitelist est signalé sans empêcher le devcontainer de démarrer si une panne externe rend un domaine temporairement injoignable.

Pourquoi ${HOME}/.claude est monté dans le conteneur

compose.devcontainer.yaml monte ${HOME}/.claude et ${HOME}/.claude.json de l'hôte dans /home/app/.claude* du devcontainer. Ce choix est une concession au principe d'isolation : sans ce bind-mount, Claude Code dans le conteneur ne récupère pas l'authentification de l'utilisateur, et le développeur doit se ré-authentifier à chaque ouverture du devcontainer (potentiellement à chaque rebuild de l'image).

Le coût en sécurité existe et mérite d'être nommé : un Claude Code en bypassPermissions qui exécuterait du code injecté (par exemple via un repo cloné contenant un script de post-install Composer hostile) peut lire la configuration Claude hôte — tokens d'API, historique de conversations, settings utilisateur. Le pare-feu sortant et l'absence de socket Docker monté limitent les dégâts (pas d'exfiltration vers un domaine non whitelisté, pas d'évasion via le démon Docker), mais le contenu de ~/.claude reste lisible depuis l'intérieur.

Le compromis est assumé en faveur du confort d'utilisation : se ré-authentifier à chaque ouverture casserait l'idée même de devcontainer reproductible « j'ouvre, je code ». Les barrières existantes (pare-feu, pas de Docker socket, sudoers strict, sous-agents délégués) maintiennent un niveau d'isolation suffisant pour le modèle de menace principal — code malveillant qui essaie d'attaquer le système hôte, pas un code malveillant qui essaie d'exfiltrer les secrets Claude eux-mêmes. Un développeur qui travaille sur du code particulièrement non-trusté peut court-circuiter le bind-mount à la main (commenter les deux lignes volumes: dans compose.devcontainer.yaml côté local) ; la perte de confort est claire mais récupérable.

Durcissement : surcharge RO sur les fichiers sensibles

Le dossier ${HOME}/.claude reste monté en lecture-écriture pour préserver l'historique de conversations (sessions/, projects/, history.jsonl, …) — sans ça, l'historique généré dans le devcontainer serait perdu à la fermeture du conteneur. Mais compose.devcontainer.yaml surcharge en lecture seule les trois fichiers qui n'ont aucune raison d'être réécrits depuis l'intérieur :

  • ${HOME}/.claude/.credentials.json — tokens d'authentification API.
  • ${HOME}/.claude/settings.json — settings utilisateur, y compris les deny-lists qui verrouillent Claude.
  • ${HOME}/.claude/CLAUDE.md — instructions globales lues à chaque démarrage de session.

Modèle de menace adressé : un Claude éventuellement compromis depuis l'intérieur du devcontainer (via un postinstall Composer hostile, par exemple) ne peut plus réécrire ces fichiers et donc plus :

  • exfiltrer les credentials en les altérant pour piéger la prochaine session lancée côté hôte (.credentials.json) ;
  • désactiver depuis l'intérieur la deny-list utilisateur qui le verrouille (settings.json) ;
  • injecter des instructions globales hostiles dans CLAUDE.md.

La lecture reste autorisée — sinon Claude Code ne pourrait pas s'authentifier ni lire ses settings au démarrage. L'arbitrage est explicitement choisi : protéger les secrets, garder l'historique.

Un effet de bord prévisible existe côté hôte : Docker ne crée pas un fichier vide à la place d'un bind-mount file manquant, il crée un dossier vide. Si l'un des trois fichiers n'existe pas avant le démarrage du devcontainer, Claude Code part en erreur au boot. Le tutoriel d'installation documente le touch préventif (cf. Tutoriel PHPStorm).

Pourquoi verrouiller les scripts d'isolation pour Claude

Un Claude en bypassPermissions peut écrire n'importe quel fichier du dépôt. Si rien ne l'en empêchait, il pourrait modifier init-firewall.sh, baseline-domains.txt, post-create.sh, compose.devcontainer.yaml ou le Dockerfile — c'est-à-dire les fichiers qui définissent son propre sandbox. Le contournement n'aurait pas besoin d'être malveillant : un Claude qui « simplifie » un script bash pourrait par inadvertance ouvrir la whitelist ou retirer un DROP.

.claude/settings.json interdit donc explicitement à Claude d'éditer les fichiers de la liste (Dockerfile, compose*.yaml, scripts .devcontainer/*.sh, devcontainer.json, baseline-domains.txt, whitelist.local.txt). La liste exhaustive est dans la référence du Dev Container. C'est une règle déclarative : Claude lit la deny-list au démarrage et refuse l'édition, sans avoir à se reposer sur sa propre vigilance.

Pour ajouter un domaine partagé par l'équipe, c'est donc le développeur qui édite baseline-domains.txt à la main et lance castor devcontainer:firewall-reload. Claude peut suggérer la ligne ; il ne peut pas l'écrire. Cette friction est volontaire — elle correspond à la fréquence réelle d'évolution de la whitelist (rare) et garde l'humain comme dernier maillon avant un changement de surface réseau.

Seconde barrière : bind-mount RO sur les scripts d'isolation

La deny-list Claude est la première barrière. Elle suffit tant que Claude est le seul vecteur d'écriture — mais elle s'évalue côté CLI Claude, pas côté noyau. Un sous-processus lancé par Claude (un composer install qui exécute un script de post-install, un binaire arbitraire invoqué via Bash) n'est pas filtré par la deny-list et peut, en théorie, réécrire init-firewall.sh depuis le bind-mount RW /app. Comme le sudoers NOPASSWD autorise app à lancer ce script en root, l'attaquant n'aurait qu'à attendre le prochain reload pour exécuter du code arbitraire en root dans le conteneur.

compose.devcontainer.yaml ajoute donc une seconde barrière au niveau du noyau : bind-mounts RO ciblés sur les scripts et données qui participent à la chaîne de privilèges du pare-feu :

  • .devcontainer/init-firewall.sh — le script lancé en sudo.
  • .devcontainer/init-firewall.lib.sh — bibliothèque de fonctions extraites du script principal, lue au sourcing.
  • .devcontainer/baseline-domains.txt — source de vérité de la whitelist alimentant l'ipset.
  • .devcontainer/firewall-healthcheck.sh — wrapper sudo read-only consulté par le healthcheck Docker (cf. plus bas).

Le RO ferme la voie d'escalade : aucun process du conteneur (Claude ou sous-process) ne peut altérer ces fichiers, même si la deny-list était contournée. Coût en ergonomie : pour modifier l'un d'eux pendant le dev, il faut redémarrer le devcontainer (équivaut à docker compose restart php) pour que le bind-mount reflète la version hôte. castor devcontainer:firewall-reload continue de fonctionner sans redémarrage — il exécute le script déjà bind-mounté, peu importe que ce dernier soit en RO côté conteneur.

Détecter un drift du pare-feu : healthcheck enrichi

Une fois la chaîne d'escalade fermée par les deux barrières ci-dessus, reste un cas de panne silencieuse : un iptables -F manuel lancé pour debug et oublié, ou un ipset destroy allowed-domains qui vide la cible de la règle match-set. Le pare-feu n'a plus de règles (ou plus d'IPs autorisées), tout le sortant redevient libre, mais dnsmasq et sa config sont toujours là — le healthcheck d'origine (dnsmasq + config + :2019/metrics) reste vert sur ces drifts.

Le wrapper read-only firewall-healthcheck.sh enchaîne donc trois conditions (toutes bloquantes) plutôt qu'une seule :

  1. Policy OUTPUT DROP poséeiptables -S OUTPUT | grep -q '^-P OUTPUT DROP'. Détecte un iptables -F oublié (le flush remet la policy à ACCEPT).
  2. ipset allowed-domains existeipset list -n allowed-domains. Détecte un ipset destroy manuel ou un init-firewall.sh interrompu en plein milieu.
  3. ipset alimenté (≥ 1 entrée) — la directive match-set allowed-domains dst ne matche jamais sur un set vide. Sans cette condition, un set fraîchement créé mais que dnsmasq n'alimente plus (process mort) resterait silencieusement muet.

Exit 0 si les trois passent, exit 1 au premier KO — Docker bascule alors le conteneur en unhealthy.

Pourquoi un wrapper plutôt qu'un sudoers générique iptables -S * ? Parce qu'un pattern générique laisserait passer n'importe quelle table et n'importe quelle chaîne. Le wrapper encapsule l'ensemble exact des commandes nécessaires ; le sudoers n'autorise NOPASSWD que ce wrapper précis. Il est lui-même verrouillé (deny-list Claude + bind-mount RO) pour empêcher qu'un Claude compromis le remplace par un exit 0 muet. Détail dans la Référence du Dev Container.

Pourquoi pas de features devcontainer

L'approche canonique pour ajouter Node ou Claude Code à un Dev Container est d'empiler des features dans devcontainer.json. Kirexo l'a tenté, puis abandonné : sous PHPStorm, le pipeline de build des features génère un .features.temp.dockerfile qui réapplique le target: frankenphp_dev du compose à un Dockerfile temporaire qui ne contient pas ce stage → target stage "frankenphp_dev" could not be found, build cassé. En embarquant Node + Claude Code directement dans le stage frankenphp_dev, le build temporaire disparaît, les versions sont fixées (NODE_VERSION=22.22.3 pin patch via tarball officiel, CLAUDE_CODE_VERSION figé via npm) et le démarrage du devcontainer ne fait plus qu'un seul build au lieu de deux. Le coût est minime — quelques RUN supplémentaires dans le Dockerfile — pour une portabilité PHPStorm/VS Code retrouvée.

Le revers du « tout figé dans le Dockerfile » est qu'une mise à jour de Castor, glab, Claude Code ou de Node exige d'éditer un ARG à la main. Pour éviter d'aller chercher la version courante sur quatre interfaces différentes (GitHub, GitLab, npm, schedule Node), l'upgrade est ergonomique grâce à castor devcontainer:upgrade-tools, qui résout les latest via API — et pour Node, la dernière patch publiée pour la dernière majeure encore en LTS active (LTS détectée via le calendrier officiel nodejs/Release servi par jsDelivr, patch résolue via nodejs.org/dist/index.json, SHA-256 lu dans SHASUMS256.txt) — affiche un diff Actuel → Dernier et bumpe les ARG du Dockerfile en place. La cible expose un mode --check (exit 2 si un drift est détecté) directement utilisable en CI pour notifier qu'une mise à jour est disponible sans rien appliquer.

La liste exhaustive des ARG gérés est définie par ToolsUpgrader::TOOLS dans src/DevContainer/ToolsUpgrader.php : CASTOR_VERSION, GLAB_VERSION, CLAUDE_CODE_VERSION, NODE_VERSION. Pour chaque outil distribué en binaire vérifié (Castor, glab, Node), un ARG _SHA256 associé (ToolsUpgrader::SHA_TOOLS) est bumpé en même temps que la version — sans ça, un upgrade casserait silencieusement le sha256sum -c du Dockerfile.

Suivre le pattern upstream a un coût en personnalisation, mais simplifie la maintenance : quand dunglas/symfony-docker corrige un bug du pare-feu ou ajoute une feature, Kirexo peut suivre.

Checklist de clôture — deux contextes, deux checklists

Le devcontainer et l'hôte ne touchent pas aux mêmes fichiers — la deny-list Claude et les bind-mounts RO le garantissent côté noyau (cf. Pourquoi verrouiller les scripts d'isolation pour Claude). La checklist de clôture suit la même symétrie : ne checker QUE ce que le contexte est censé pouvoir modifier, pas plus, pas moins. Symétrique de la règle « ne pas demander à un test ce qu'il ne peut pas garantir ».

Côté hôte — l'infrastructure

L'hôte est le seul contexte qui peut modifier les fichiers d'isolation (Dockerfile, compose*.yaml, scripts .devcontainer/*.sh, devcontainer.json) — ils sont en deny-list Claude et en bind-mount RO depuis l'intérieur. La cible castor host:check valide exactement ce périmètre :

  1. lint:shellshellcheck sur .devcontainer/*.sh et .claude/hooks/*.sh.
  2. docker compose config -q — valide la fusion compose.yaml + compose.override.yaml (+ compose.devcontainer.yaml).
  3. jq empty .devcontainer/devcontainer.json — valide que le JSON est parsable.

Pas de cs:fix, pas de phpstan, pas de test:unit côté hôte : ce sont des fichiers que l'hôte n'est, en pratique, pas censé modifier (le devcontainer s'en charge).

Côté devcontainer — l'applicatif

Le devcontainer est le seul contexte qui peut modifier le code applicatif (le bind-mount /app est RW). La cible castor all enchaîne la checklist applicative complète : cs:fixphpstanlintts:checkdoctrine:schema:validatetest:unittest:bashtest:e2e. L'ordre n'est pas cosmétique — il est documenté dans CLAUDE.md et reproduit chaque étape sur du code stabilisé par la précédente.

Pas de docker compose config -q ni de jq sur devcontainer.json côté devcontainer : ces fichiers sont en RO depuis l'intérieur, valider leur syntaxe n'aurait aucun effet — l'erreur, si erreur il y a, viendrait d'une édition hôte.

Détection automatique du contexte par le hook

Le hook .claude/hooks/stop-checklist.sh détecte le contexte d'exécution (KIREXO_INSIDE_CONTAINER=1 exporté par le devcontainer, ou présence du fichier /.dockerenv) et affiche la checklist appropriée à chaque clôture de tâche Claude — sans friction pour le développeur, qui n'a jamais à se demander « quelle commande je dois lancer ici ? ».

Côté CI, la séparation est explicite : un job lance castor host:check sur runner hôte, un autre lance castor all à l'intérieur du devcontainer. Les deux doivent passer avant merge.

Et la vitalité de la stack ?

castor devcontainer:doctor (FrankenPHP réactif sur 127.0.0.1, mkdocs joignable, services intra-stack résolus, migrations à jour) n'est vérifiée que côté devcontainer. Côté hôte, la stack peut tout à fait être down — c'est même le cas par défaut entre deux sessions de dev. Si le hook bloquait sur la vitalité de la stack à chaque tour côté hôte, il refuserait de clore une tâche d'édition d'infra (qui n'a, par nature, aucune raison de démarrer la stack). Le doctor reste à disposition manuellement quand le dev veut diagnostiquer un blocage, pas en pré-requis automatique.

Voir aussi

  • Architecture — pourquoi dunglas/symfony-docker, FrankenPHP, CQRS, plugins isolés.
  • Workflow des agents Claude — pourquoi déléguer à des sous-agents spécialisés. Le mode bypassPermissions rendu sûr par le devcontainer est ce qui permet à ces sous-agents de tourner sans friction.
  • Référence du Dev Container — l'inventaire factuel.