test: pin HOSTS_DIR as the only write surface, document the read-only Defenseurs mounts (#36) #39
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#39
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-36-readonly-defenseurs-mounts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #36
Côté dépôt du passage en lecture seule des deux montages Défenseurs. Le changement Coolify est fait et vérifié (commentaire 17147 de #36) : suffixe
:rosur la destination,RW=falsesur/data/defenseurset/data/defenseurs/reports,RW=truesur/data/hosts, routes de lecture avec contenu réel, battementhost-agenten 204. Aucun code runtime ne change, donc aucun redéploiement après le merge.1.
__tests__/writes.test.js(11 cas) : l'API n'écrit que sousHOSTS_DIR, sur les chemins qu'il exerce. Une écriture ajoutée un jour sousREPORTS_DIRou/data/defenseurspasserait tous les autres tests (fixtures en dossier temporaire inscriptible, CI en root, donc unchmod 555ne prouverait rien) et tomberait en prod en 500EROFS. Le test espionne toutes les fonctions d'écriture denode:fs(formes callback, Sync etfs.promises) etchild_process, avant unrequirefrais, comme l'espiontimingSafeEquald'auth.test.js, après avoir purgé du cache tous les modules runtime. Il couvre :.tmp) ;POST /hosts/<id>: écritures sousHOSTS_DIRseulement, les deux bouts durenamecompris ;agents-map.jsoncorrompu, fichiers Défenseurs absents, snapshot corrompu, avec le contenu dégradé exigé (no_data,neverSeen) ;head -1 /proc/statetdf -k /, avec/healthcomme témoin.Chaque cas prouve que ses espions sont vivants, et le bloc
require.main, qui ne tourne jamais dans le processus de test, est épinglé sur l'ensemble exact de ses appels. Limites écrites dans l'en-tête et dansCLAUDE.md: un chemin qu'aucun cas n'emprunte, une écriture différée au-delà de 20 ms. Une troisième limite, une fonction d'écriture passée telle quelle à un mécanisme asynchrone, est suivie dans #40.2. Doc du dépôt.
CLAUDE.md,README.mdet.env.exampledécrivent les deux couches : le mode:rodu montage, et les droits Unix. Le contenu appartient àdefenseur:defenseur(gid 1001) en 755/775/664 ; les bits de groupe donnent l'écriture, donc cette couche ne tient que tant que le groupe propriétaire n'est pas le gid 1000 (node,ubuntu). Ils décrivent aussi les trois vérifications après un redéploiement (docker inspect,/proc/mounts, contenu des routes) et les pièges Coolify rencontrés : Directory Mount non modifiable, recréé en Volume Mount avec Source Path ; point de montage imbriqué à garder sous un parent en:ro; jamais de bind fichier par fichier. Les garanties du test y sont bornées aux chemins exercés.3. Doc hors dépôt, dans la même session.
7cc8239:coolify-ops.md, suffixe:ro, procédure du Directory Mount, montage imbriqué sous parent:ro, vérifications de l'étape 6.53e8abd:MASTERPLAN.md:169ne dit plus « lecture-écriture ».~/.claude/skills/diag-auth-drift/SKILL.md:284(non versionné) : le suffixe:rosurmount_pathfonctionne.Revues
7523b39et48c9a40; réponse et chiffres dans 17266.Preuves par mutation (sur
48c9a40)writes.test.jsREPORTS_DIRwriteFileSyncdanscollectScanReportsFromDirappendFileSync, non importé aujourd'hui, dans le handler findingsREPORTS_DIRwriteFileSyncdéstructuré dansmetrics.js/health)index.jswritePid()dans le blocrequire.mainagents-map.jsoncorrompuserveJsonFilerecrée le fichier absentREPORTS_DIRtryexecFileSync("touch", …)dansGET /defenseursfs/promises,openSyncnumérique,createWriteStream,setImmediate, chemin enURLHOSTS_DIRREPORTS_DIR/..Avant et après chaque série : 340/340. Chaque mutation a été vérifiée appliquée, puis restaurée depuis une copie de sauvegarde, jamais par
git checkout.Vérifications
npm test: 340 passés (329 + 11)bash test-curl.sh: 45 passéssh scripts/boot-from-copy.sh: PASS48c9a40🤖 Generated with Claude Code
Revue adversariale — PR #39
Verdict : REQUEST_CHANGES
Résumé. Le mécanisme tient : six mutations de relecteur, différentes de celles de l'auteur, rougissent chacune le cas visé. Mais les espions ne voient que les requêtes authentifiées sur fixtures complètes. Une écriture sous
/data/defenseursplacée sur un chemin dégradé (fichier absent, JSON corrompu, rejet 401 ou 400) passe les 338 tests, alors que la doc affirme désormais quewrites.test.jsexige que toute écriture viseHOSTS_DIR.Point bloquant
1. La garantie écrite dépasse la couverture du test.
CLAUDE.md:84(«__tests__/writes.test.jsexige que toute ecriture viseHOSTS_DIR»),CLAUDE.md:14(« figee par »),README.md:156(« pins that the API writes nowhere butHOSTS_DIR»), en-tête de__tests__/writes.test.js:9-14(« This file moves that failure to CI »). Les huit cas espionnés (writes.test.js:245-303) couvrent le démarrage, les six GET sur leur chemin nominal et un POST valide, rien d'autre.Mutations appliquées à une copie hors dépôt de
6a2f7fe, suite complète relancée à chaque fois :index.jswrites.test.jsagents-map.jsoncorrompu mis en quarantaine parrenameSync(…, .corrupt-<ts>), comme le fait le Sergentcatch: le 500 perd son message, la quarantaine n'a jamais lieuserveJsonFilerecrée le fichier absent (writeFileSync(filePath, …)sur ENOENT)no_datasur/defenseurset/defenseurs/statelogRejectionajoute chaque 401/403 àREPORTS_DIR/../auth.log, dans untryREPORTS_DIR/.., dans untryR2 est le scénario exact que la PR dit déplacer vers la CI (« passerait tous les autres tests […] et tomberait en prod en 500
EROFS»), et rien ne rougit. La variante de R3 sanstry(R3b) rougit 6 tests dansagent,healthetstate, mais par accident : ENOENT sur un dossier que ces fixtures ne créent pas. En prod, ce serait un rejet non géré danshandler, donc un crash du processus à chaque 401.Correctif proposé, validé ici. Un cas de plus dans le même montage d'espions, qui parcourt les chemins dégradés puis exige
violations(drain())vide. Le prototype ci-dessous, ajouté à une copie dewrites.test.js, est vert sur6a2f7feet rougit R1, R2, R3 et R11 (1 rouge chacun, sur ce cas). Les suppressions de fixtures faites par le test lui-même ne sont pas attribuées à l'API (aucune frame runtime dans leur pile). Pour la version finale, exiger aussi le contenu (no_data,neverSeen) des cinq GET dégradés, comme les cas nominaux exigent le leur. Le helpersendrend"transport-error"et non un faux code : une coupure réseau sur le 413 fait échouer l'assertion au lieu de passer.Prototype (≈50 lignes, à placer après le dernier
describe)Alternative minimale acceptable : restreindre
CLAUDE.md:14,73,84,README.md:156et l'en-tête du test aux chemins nominaux. Mais la quarantaine (R1) et la recréation d'un fichier absent (R2) sont les écritures les plus plausibles à venir, et elles tombent sur ces chemins-là.Vérifié
6a2f7fe(run 8) : 338 vitest dont les 9 dewrites.test.js, 45 passestest-curl.sh,boot-from-copyPASS. Même 338/338 en local sur une copie de la tête.writes.test.js, sur le cas visé :require("node:fs/promises").writeFileaprès la réponse de/reports/scans(fs.promisesetnode:fs/promisessont le même objet, l'espion couvre les deux) ;openSync(…, O_WRONLY | O_CREAT), drapeaux numériques, dans/defenseurs/state;createWriteStream(…, { flags: "a" })dans/hosts;setImmediateaprèsres.enddans/hosts;${HOSTS_DIR}-backup.json, refusé par la règlepath.relative;writeFileSync(new URL("file://…"))sousREPORTS_DIR.CLAUDE.md: 171 + 67 + 50 + 14 + 12 + 9 + 9 + 6 = 338.la-compagnie-maximus@7cc8239etdefenseurs@53e8abdpoussés,diag-auth-drift/SKILL.md:284à jour, contenu cohérent avec le commentaire 17147 de #36.pull_refclosesvenant de cette PR.Suggestions (non bloquantes)
child_process. L'espion ne voit quenode:fs. R4,execFileSync("touch", [<REPORTS_DIR>/.last-read])dans/defenseurs: 0 rouge danswrites.test.js, 3 rouges ailleurs par accident (fixtures sansREPORTS_DIR).metrics.jsimporte déjàexecSync. Deux options : écrire «node:fs» dans les affirmations, ou espionnerchild_processdepuis les frames runtime et épingler l'ensemble des commandes surhead -1 /proc/statetdf -k /.CLAUDE.md:48,README.md:153-155,.env.example:15-17). Avec des dossiers en 775 et des fichiers en 664, les bits de groupe accordent l'écriture. Ce qui tient l'uid 1000 dehors, c'est un groupe propriétaire différent du gid 1000 : groupe primaire denodedansnode:22-alpineet, sur une image Ubuntu cloud, groupe de l'utilisateurubuntu. Nommer ce groupe dans la doc : unchgrp ubuntuferait tomber la seconde couche sans bruit.(#36)sans mot-clé de fermeture. Un merge par l'API ou l'UI fermera #36 grâce au mot-clé du corps de la PR ; un fast-forward local (étape 5 de/merge-stack) ne la fermera pas, fermeture à la main dans ce cas.Correctif après revue (
7523b39,48c9a40)Réponse au commentaire 17233.
Point bloquant : chemins dégradés
Nouveau cas dans
writes.test.js, sur les mêmes espions. Il parcourt :agents-map.jsoncorrompu (500) ;no_datatrois fois,count: 0,neverSeen).Aucune écriture hors
HOSTS_DIRne doit en sortir. Le helpersendrendtransport-errorplutôt qu'un faux code.Tes mutations exactes (
mutations.js), rejouées sur l'arbre corrigé, suite complète à chaque fois :6a2f7fe)48c9a40)agents-map.jsoncorrompuserveJsonFilerecrée le fichier absentREPORTS_DIRREPORTS_DIR/..tryChaque mutation a été restaurée depuis une copie de sauvegarde, fichier identique à la copie ;
index.jsetmetrics.jssont intacts.Suggestions
child_process: espionné commenode:fs(exec,execSync,execFile,execFileSync,spawn,spawnSync,fork), depuis les frames runtime. Chaque cas exige que les seules commandes soienthead -1 /proc/statetdf -k /, et un cas témoin exige exactement ces deux-là sur/health. R4 (execFileSync("touch", …)) : 0 rouge danswrites.test.jsavant, 2 maintenant (GET /defenseurset chemins dégradés).CLAUDE.md,README.mdet.env.example:defenseur:defenseur(gid 1001). Les bits de groupe donnent l'écriture, donc la couche ne tient que tant que ce groupe n'est pas le gid 1000 ; unchgrp ubuntuou unchmod o+wla ferait tomber sans bruit.48c9a40porte le mot-clé de fermeture, donc #36 se ferme aussi par un fast-forward local, pas seulement par un merge API ou UI.Non-régression
R5 à R10 restent rouges, désormais 2 rouges chacun (le cas dégradé les voit aussi), sauf R9 (1). Mes mutations m0 à m6 aussi. m4b (purge du cache réduite à
index.js) fait maintenant rougir le témoin des commandes : unmetrics.jsresté en cache garderait aussi unexecSyncpérimé.Limite assumée, écrite dans l'en-tête du test et dans
CLAUDE.md: R8b (écriture différée de 100 ms) reste verte. Une écriture différée au-delà de 20 ms, ou placée sur un chemin qu'aucun cas n'emprunte, reste hors de vue. La doc ne promet plus « toute écriture », mais les chemins exercés.Vérifications
npm test: 340 passés (338 + 2)bash test-curl.sh: 45 passéssh scripts/boot-from-copy.sh: PASSRevue adversariale — PR #39, deuxième passe (tête
48c9a40)Verdict : APPROVE
Résumé. Le point bloquant du commentaire 17233 est corrigé. Sept écritures de relecteur, nouvelles et placées sur les chemins dégradés, rougissent chacune le nouveau cas ; l'espion
child_processattrape les commandes shell. Il reste une limite non écrite de l'attribution par pile, sans gravité, avec un correctif validé ici.Vérifié
48c9a40(run 9, log lu) : 340 vitest dont les 11 dewrites.test.js, 45 passestest-curl.sh,boot-from-copyPASS. 340/340 en local sur une copie hors dépôt.writes.test.jspasse 15 fois sur 15 en boucle : pas d'instabilité, y compris sur le 413 envoyé parfetch.index.jsREPORTS_DIR/..(appendFileSyncdans untry)findLatestReportForAgentrecrée le dossier manquant (mkdirSync,try)/data/defenseurs/last-404(try)REPORTS_DIR/..(try)fs.promises.appendFilesansawaitagents-map.jsoncorrompu copié à côté (copyFileSync,try)serveJsonFilerecrée le fichier absent, en différé parsetImmediate(() => …)spawnSync("sh", ["-c", "touch …"])dans/defenseurs/stateGET /defenseurs/state, cas dégradé)exec("touch …")asynchrone dansGET /hostssetIntervalde 60 s, posé au chargement, qui écrit sousREPORTS_DIR/..Aucun rouge accidentel hors de
writes.test.js.RW=false, compose en:ro, Volume Mount avec Source Path, point de montage root en 755 conservé. Docs hors dépôt poussées :la-compagnie-maximus@7cc8239(origin/main),defenseurs@53e8abd(origin/master),diag-auth-drift/SKILL.md:284à jour.CLAUDE.md: 329 + 11 = 340, conforme au run CI.pull_refclosesvenant de cette PR. Le message de48c9a40porte aussiFixes #36: la fermeture vaut pour un merge par l'API comme pour un fast-forward. Aucun code runtime ne change : pas de redéploiement après le merge.Suggestions (non bloquantes)
1. Une troisième limite non écrite : l'attribution par pile. Un appel n'est compté que si une frame d'un fichier runtime se trouve sur la pile synchrone au moment de l'appel (
__tests__/writes.test.js:21-22,:138,:153). Une fonctionfspassée telle quelle à un mécanisme asynchrone s'exécute sans cette frame. L'écriture passe alors, sur un chemin exercé (GET /defenseursnominal) :handleDefenseursprocess.nextTick(() => writeFileSync(tmp, "x"))process.nextTick(writeFileSync, tmp, "x")res.on("finish", writeFileSync.bind(null, tmp, "x"))new fs.WriteStream(tmp).end("x")Promise.resolve(tmp).then(fs.promises.rm)Ces formes sont rares dans ce dépôt. Mais
CLAUDE.md:84etREADME.md:160-163affirment que toute écriture parnode:fsviseHOSTS_DIRsur les chemins exercés, ce que G1 à G3 contredisent. Deux options :writes.test.js:27-28et à la parenthèse « Limites » deCLAUDE.md:73.writeFileSync,rmSync,rmdirSync), et vitest n'écrit rien pendant la fenêtre. Résultat de la variante : 11/11 vert sans mutation, G1 à G4 passent de 0 à 2 rouges chacun, V2, V7, C1 et G0 restent rouges. Le coût : si une version future de vitest écrit dans le worker, le test rougit au lieu de passer en silence, ce qui reste le bon sens d'échec.captureStack()etfromRuntimeFile()deviennent morts,isRuntimeFile()reste pour la purge du cache, et le paragraphe d'en-tête sur l'attribution est à réécrire.Diff de la variante (7 lignes ajoutées, 2 modifiées)
2. Corps de la PR périmé. Il décrit l'état de
6a2f7fe: « 9 cas », « 338/338 », m5 à « 8 rouges », sans le cas dégradé ni l'espionchild_process. Sur48c9a40, on compte 11 cas et 340 tests, et m5 (attribution morte) rougit 10 cas sur 11 : seule l'épingle du blocrequire.main, qui n'utilise pas d'espion, reste verte. Le commentaire 17266 donne l'état réel. Aligner le corps évite qu'une relecture future de la PR reparte des anciens chiffres.