test: pin HOSTS_DIR as the only write surface, document the read-only Defenseurs mounts (#36) #39

Merged
maximus merged 4 commits from issue-36-readonly-defenseurs-mounts into main 2026-10-01 00:26:31 +00:00
Owner

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 :ro sur la destination, RW=false sur /data/defenseurs et /data/defenseurs/reports, RW=true sur /data/hosts, routes de lecture avec contenu réel, battement host-agent en 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 sous HOSTS_DIR, sur les chemins qu'il exerce. Une écriture ajoutée un jour sous REPORTS_DIR ou /data/defenseurs passerait tous les autres tests (fixtures en dossier temporaire inscriptible, CI en root, donc un chmod 555 ne prouverait rien) et tomberait en prod en 500 EROFS. Le test espionne toutes les fonctions d'écriture de node:fs (formes callback, Sync et fs.promises) et child_process, avant un require frais, comme l'espion timingSafeEqual d'auth.test.js, après avoir purgé du cache tous les modules runtime. Il couvre :

  • le démarrage (balayage des .tmp) ;
  • les six routes GET nominales : aucune écriture, même une macrotâche après la réponse, contenu réel exigé ;
  • POST /hosts/<id> : écritures sous HOSTS_DIR seulement, les deux bouts du rename compris ;
  • les chemins dégradés : rejets 401/403/404, entrées 400/413, agents-map.json corrompu, fichiers Défenseurs absents, snapshot corrompu, avec le contenu dégradé exigé (no_data, neverSeen) ;
  • les commandes shell, figées sur head -1 /proc/stat et df -k /, avec /health comme 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 dans CLAUDE.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.md et .env.example décrivent les deux couches : le mode :ro du 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.

  • la-compagnie-maximus 7cc8239 : coolify-ops.md, suffixe :ro, procédure du Directory Mount, montage imbriqué sous parent :ro, vérifications de l'étape 6.
  • agent-defenseurs 53e8abd : MASTERPLAN.md:169 ne dit plus « lecture-écriture ».
  • ~/.claude/skills/diag-auth-drift/SKILL.md:284 (non versionné) : le suffixe :ro sur mount_path fonctionne.

Revues

  • 17233, REQUEST_CHANGES : les chemins dégradés n'étaient pas couverts. Corrigé par 7523b39 et 48c9a40 ; réponse et chiffres dans 17266.
  • 17331, APPROVE : V1 à V7 (chemins dégradés), C1 et C3 (commandes shell) rougissent chacun ; la limite des fonctions passées en callback est suivie dans #40.

Preuves par mutation (sur 48c9a40)

Mutation writes.test.js Ailleurs
m0 écriture au chargement du module, sous REPORTS_DIR 1 rouge (démarrage) —
m1 writeFileSync dans collectScanReportsFromDir 1 —
m2 appendFileSync, non importé aujourd'hui, dans le handler findings 2 —
m3 l'ingestion renomme vers REPORTS_DIR 1 hosts 22, agent 1
m4 writeFileSync déstructuré dans metrics.js 1 (/health) —
m4b m4 avec la purge du cache réduite à index.js 1 (témoin des commandes) ; l'écriture de m4 n'est plus vue —
m5 espion mort (attribution qui ne matche jamais) 10 sur 11 (tout sauf l'épingle) —
m6 nouvel appel writePid() dans le bloc require.main 1 (épingle) hosts 1
R1 quarantaine d'un agents-map.json corrompu 1 (chemins dégradés) —
R2 serveJsonFile recrée le fichier absent 1 —
R3 journal des 401/403 à côté de REPORTS_DIR 1 —
R3b R3 sans try 1 agent 2, health 1, state 3
R4 execFileSync("touch", …) dans GET /defenseurs 2 auth 1, state 2
R5 à R8, R10 fs/promises, openSync numérique, createWriteStream, setImmediate, chemin en URL 2 chacune —
R9 voisin de HOSTS_DIR 1 —
R11 corps du 400 déposé sous REPORTS_DIR/.. 1 —
R8b écriture différée de 100 ms 0 : limite écrite —

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és
  • sh scripts/boot-from-copy.sh : PASS
  • CI verte sur 48c9a40

🤖 Generated with Claude Code

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 `:ro` sur la destination, `RW=false` sur `/data/defenseurs` et `/data/defenseurs/reports`, `RW=true` sur `/data/hosts`, routes de lecture avec contenu réel, battement `host-agent` en 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 sous `HOSTS_DIR`, sur les chemins qu'il exerce.** Une écriture ajoutée un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures en dossier temporaire inscriptible, CI en root, donc un `chmod 555` ne prouverait rien) et tomberait en prod en 500 `EROFS`. Le test espionne toutes les fonctions d'écriture de `node:fs` (formes callback, Sync et `fs.promises`) et `child_process`, avant un `require` frais, comme l'espion `timingSafeEqual` d'`auth.test.js`, après avoir purgé du cache **tous** les modules runtime. Il couvre : - le démarrage (balayage des `.tmp`) ; - les six routes GET nominales : aucune écriture, même une macrotâche après la réponse, contenu réel exigé ; - `POST /hosts/<id>` : écritures sous `HOSTS_DIR` seulement, les deux bouts du `rename` compris ; - les chemins dégradés : rejets 401/403/404, entrées 400/413, `agents-map.json` corrompu, fichiers Défenseurs absents, snapshot corrompu, avec le contenu dégradé exigé (`no_data`, `neverSeen`) ; - les commandes shell, figées sur `head -1 /proc/stat` et `df -k /`, avec `/health` comme 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 dans `CLAUDE.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.md` et `.env.example` décrivent les deux couches : le mode `:ro` du 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.** - la-compagnie-maximus `7cc8239` : `coolify-ops.md`, suffixe `:ro`, procédure du Directory Mount, montage imbriqué sous parent `:ro`, vérifications de l'étape 6. - agent-defenseurs `53e8abd` : `MASTERPLAN.md:169` ne dit plus « lecture-écriture ». - `~/.claude/skills/diag-auth-drift/SKILL.md:284` (non versionné) : le suffixe `:ro` sur `mount_path` fonctionne. ## Revues - 17233, REQUEST_CHANGES : les chemins dégradés n'étaient pas couverts. Corrigé par `7523b39` et `48c9a40` ; réponse et chiffres dans 17266. - 17331, APPROVE : V1 à V7 (chemins dégradés), C1 et C3 (commandes shell) rougissent chacun ; la limite des fonctions passées en callback est suivie dans #40. ## Preuves par mutation (sur `48c9a40`) | | Mutation | `writes.test.js` | Ailleurs | |---|---|---|---| | m0 | écriture au chargement du module, sous `REPORTS_DIR` | 1 rouge (démarrage) | — | | m1 | `writeFileSync` dans `collectScanReportsFromDir` | 1 | — | | m2 | `appendFileSync`, non importé aujourd'hui, dans le handler findings | 2 | — | | m3 | l'ingestion renomme vers `REPORTS_DIR` | 1 | hosts 22, agent 1 | | m4 | `writeFileSync` déstructuré dans `metrics.js` | 1 (`/health`) | — | | m4b | m4 avec la purge du cache réduite à `index.js` | 1 (témoin des commandes) ; l'écriture de m4 n'est plus vue | — | | m5 | espion mort (attribution qui ne matche jamais) | 10 sur 11 (tout sauf l'épingle) | — | | m6 | nouvel appel `writePid()` dans le bloc `require.main` | 1 (épingle) | hosts 1 | | R1 | quarantaine d'un `agents-map.json` corrompu | 1 (chemins dégradés) | — | | R2 | `serveJsonFile` recrée le fichier absent | 1 | — | | R3 | journal des 401/403 à côté de `REPORTS_DIR` | 1 | — | | R3b | R3 sans `try` | 1 | agent 2, health 1, state 3 | | R4 | `execFileSync("touch", …)` dans `GET /defenseurs` | 2 | auth 1, state 2 | | R5 à R8, R10 | `fs/promises`, `openSync` numérique, `createWriteStream`, `setImmediate`, chemin en `URL` | 2 chacune | — | | R9 | voisin de `HOSTS_DIR` | 1 | — | | R11 | corps du 400 déposé sous `REPORTS_DIR/..` | 1 | — | | R8b | écriture différée de 100 ms | 0 : limite écrite | — | 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és - `sh scripts/boot-from-copy.sh` : PASS - CI verte sur `48c9a40` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 2 commits 2026-09-30 02:07:21 +00:00
The two Defenseurs mounts are now read-only in the container, so a write
added one day under REPORTS_DIR or /data/defenseurs would fail in
production as a 500 (EROFS) while every other test stays green: their
fixtures are writable temp dirs, and CI runs as root.

writes.test.js spies every node:fs write API (callback, Sync and
fs.promises forms) before a fresh require, after purging all runtime
modules from the require cache, and records the calls whose stack goes
through a runtime file. It covers boot (the temp-file sweep), the six GET
routes (no write at all, one macrotask after the response included) and
POST /hosts/<id> (writes only under HOSTS_DIR). Each case proves its own
spies are live, and the require.main block is pinned since it never runs
in-process.

Mutation proofs: a write at module load, in the scan collector, through an
fs function not imported today, in the ingest rename or in metrics.js, a
dead spy, and a new call in the main block each turn the expected case
red. With the cache purge narrowed to index.js, the metrics.js write stays
green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: record the read-only Defenseurs mounts (#36)
All checks were successful
CI / verification (pull_request) Successful in 20s
6a2f7fec04
Both Defenseurs bind mounts are `:ro` since 2026-09-30, set as a suffix
on the Coolify destination path; the reports Directory Mount was recreated
as a Volume Mount with a Source Path to take it. CLAUDE.md, README.md and
.env.example now describe the two layers (mount mode and Unix
permissions), the three checks to run after a redeploy, and the Coolify
gotchas met on the way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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/defenseurs placé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 que writes.test.js exige que toute écriture vise HOSTS_DIR.

Point bloquant

1. La garantie écrite dépasse la couverture du test. CLAUDE.md:84 (« __tests__/writes.test.js exige que toute ecriture vise HOSTS_DIR »), CLAUDE.md:14 (« figee par »), README.md:156 (« pins that the API writes nowhere but HOSTS_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 :

Mutation dans index.js writes.test.js Suite (338) Effet en prod
R1 agents-map.json corrompu mis en quarantaine par renameSync(…, .corrupt-<ts>), comme le fait le Sergent 0 rouge 0 rouge EROFS dans le catch : le 500 perd son message, la quarantaine n'a jamais lieu
R2 serveJsonFile recrée le fichier absent (writeFileSync(filePath, …) sur ENOENT) 0 rouge 0 rouge 500 EROFS au lieu de 200 no_data sur /defenseurs et /defenseurs/state
R3 logRejection ajoute chaque 401/403 à REPORTS_DIR/../auth.log, dans un try 0 rouge 0 rouge écriture refusée sans bruit
R11 le 400 « JSON illisible » dépose le corps reçu sous REPORTS_DIR/.., dans un try 0 rouge 0 rouge idem

R2 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 sans try (R3b) rougit 6 tests dans agent, health et state, mais par accident : ENOENT sur un dossier que ces fixtures ne créent pas. En prod, ce serait un rejet non géré dans handler, 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 de writes.test.js, est vert sur 6a2f7fe et 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 helper send rend "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)
// Reviewer prototype: the same spies, on the paths the nominal cases never take.
describe("fs writes — degraded paths (reviewer prototype)", () => {
  const send = async (route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) => {
    const headers = auth ? { Authorization: auth } : {};
    if (body !== undefined) headers["Content-Type"] = "application/json";
    try {
      const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
      await res.arrayBuffer();
      return res.status;
    } catch {
      return "transport-error";
    }
  };

  test("rejections, bad input, corrupt and missing files write nothing outside HOSTS_DIR", async () => {
    expect(bootWrites).not.toHaveLength(0);
    const ingest = `Bearer ${INGEST_TOKEN}`;
    const statuses = [
      await send("/defenseurs", { auth: null }),
      await send("/defenseurs", { auth: "Bearer wrong" }),
      await send("/hosts", { auth: ingest }),
      await send("/nope"),
      await send("/defenseurs/findings"),
      await send("/defenseurs/findings?project=proj&severity=BOGUS"),
      await send("/defenseurs/findings?project=unknown"),
      await send("/reports/scans?date=../../etc"),
      await send("/hosts/thinkpad", { method: "POST", auth: "Bearer wrong", body: "{}" }),
      await send("/hosts/laptop", { method: "POST", auth: ingest, body: JSON.stringify(validSnapshot()) }),
      await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: "{not json" }),
      await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: JSON.stringify({ hostname: "x" }) }),
      await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: "x".repeat(5000) }),
    ];
    expect(statuses).toEqual([401, 401, 401, 404, 400, 400, 404, 400, 401, 403, 400, 400, 413]);

    fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt");
    expect(await send("/defenseurs/findings?project=proj")).toBe(500);
    fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" }));
    fs.rmSync(process.env.DEFENSEURS_STATUS_PATH);
    fs.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH);
    fs.rmSync(process.env.REPORTS_DIR, { recursive: true });
    fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt");
    for (const route of ["/defenseurs", "/defenseurs/state", "/reports/scans?date=2026-09-30", "/defenseurs/findings?project=proj", "/hosts"]) {
      expect(await send(route)).toBe(200);
    }

    await settle();
    expect(violations(drain())).toEqual([]);
  });
});

Alternative minimale acceptable : restreindre CLAUDE.md:14,73,84, README.md:156 et 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é

  • CI verte sur 6a2f7fe (run 8) : 338 vitest dont les 9 de writes.test.js, 45 passes test-curl.sh, boot-from-copy PASS. Même 338/338 en local sur une copie de la tête.
  • Mutations de relecteur, chacune 1 rouge dans toute la suite, dans writes.test.js, sur le cas visé :
    • R5 require("node:fs/promises").writeFile après la réponse de /reports/scans (fs.promises et node:fs/promises sont le même objet, l'espion couvre les deux) ;
    • R6 openSync(…, O_WRONLY | O_CREAT), drapeaux numériques, dans /defenseurs/state ;
    • R7 createWriteStream(…, { flags: "a" }) dans /hosts ;
    • R8 écriture différée par setImmediate après res.end dans /hosts ;
    • R9 le POST écrit aussi le voisin ${HOSTS_DIR}-backup.json, refusé par la règle path.relative ;
    • R10 writeFileSync(new URL("file://…")) sous REPORTS_DIR.
  • Chaque mutation vérifiée appliquée, puis restaurée octet pour octet (md5 identique à l'original) ; dépôt intact.
  • Décompte de CLAUDE.md : 171 + 67 + 50 + 14 + 12 + 9 + 9 + 6 = 338.
  • Docs hors dépôt : la-compagnie-maximus@7cc8239 et defenseurs@53e8abd poussés, diag-auth-drift/SKILL.md:284 à jour, contenu cohérent avec le commentaire 17147 de #36.
  • Commits conventionnels en anglais ; la timeline de #36 porte un pull_ref closes venant de cette PR.

Suggestions (non bloquantes)

  1. Portée child_process. L'espion ne voit que node:fs. R4, execFileSync("touch", [<REPORTS_DIR>/.last-read]) dans /defenseurs : 0 rouge dans writes.test.js, 3 rouges ailleurs par accident (fixtures sans REPORTS_DIR). metrics.js importe déjà execSync. Deux options : écrire « node:fs » dans les affirmations, ou espionner child_process depuis les frames runtime et épingler l'ensemble des commandes sur head -1 /proc/stat et df -k /.
  2. Couche Unix (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 de node dans node:22-alpine et, sur une image Ubuntu cloud, groupe de l'utilisateur ubuntu. Nommer ce groupe dans la doc : un chgrp ubuntu ferait tomber la seconde couche sans bruit.
  3. Chemin de merge. Les commits portent (#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.
## 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/defenseurs` placé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 que `writes.test.js` exige que *toute* écriture vise `HOSTS_DIR`. ### Point bloquant **1. La garantie écrite dépasse la couverture du test.** `CLAUDE.md:84` (« `__tests__/writes.test.js` exige que toute ecriture vise `HOSTS_DIR` »), `CLAUDE.md:14` (« figee par »), `README.md:156` (« pins that the API writes nowhere but `HOSTS_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 : | | Mutation dans `index.js` | `writes.test.js` | Suite (338) | Effet en prod | |---|---|---|---|---| | R1 | `agents-map.json` corrompu mis en quarantaine par `renameSync(…, .corrupt-<ts>)`, comme le fait le Sergent | 0 rouge | 0 rouge | EROFS dans le `catch` : le 500 perd son message, la quarantaine n'a jamais lieu | | R2 | `serveJsonFile` recrée le fichier absent (`writeFileSync(filePath, …)` sur ENOENT) | 0 rouge | 0 rouge | **500 EROFS au lieu de 200 `no_data`** sur `/defenseurs` et `/defenseurs/state` | | R3 | `logRejection` ajoute chaque 401/403 à `REPORTS_DIR/../auth.log`, dans un `try` | 0 rouge | 0 rouge | écriture refusée sans bruit | | R11 | le 400 « JSON illisible » dépose le corps reçu sous `REPORTS_DIR/..`, dans un `try` | 0 rouge | 0 rouge | idem | R2 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 sans `try` (R3b) rougit 6 tests dans `agent`, `health` et `state`, mais par accident : ENOENT sur un dossier que ces fixtures ne créent pas. En prod, ce serait un rejet non géré dans `handler`, 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 de `writes.test.js`, est vert sur `6a2f7fe` et 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 helper `send` rend `"transport-error"` et non un faux code : une coupure réseau sur le 413 fait échouer l'assertion au lieu de passer. <details><summary>Prototype (≈50 lignes, à placer après le dernier <code>describe</code>)</summary> ```js // Reviewer prototype: the same spies, on the paths the nominal cases never take. describe("fs writes — degraded paths (reviewer prototype)", () => { const send = async (route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) => { const headers = auth ? { Authorization: auth } : {}; if (body !== undefined) headers["Content-Type"] = "application/json"; try { const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); await res.arrayBuffer(); return res.status; } catch { return "transport-error"; } }; test("rejections, bad input, corrupt and missing files write nothing outside HOSTS_DIR", async () => { expect(bootWrites).not.toHaveLength(0); const ingest = `Bearer ${INGEST_TOKEN}`; const statuses = [ await send("/defenseurs", { auth: null }), await send("/defenseurs", { auth: "Bearer wrong" }), await send("/hosts", { auth: ingest }), await send("/nope"), await send("/defenseurs/findings"), await send("/defenseurs/findings?project=proj&severity=BOGUS"), await send("/defenseurs/findings?project=unknown"), await send("/reports/scans?date=../../etc"), await send("/hosts/thinkpad", { method: "POST", auth: "Bearer wrong", body: "{}" }), await send("/hosts/laptop", { method: "POST", auth: ingest, body: JSON.stringify(validSnapshot()) }), await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: "{not json" }), await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: JSON.stringify({ hostname: "x" }) }), await send("/hosts/thinkpad", { method: "POST", auth: ingest, body: "x".repeat(5000) }), ]; expect(statuses).toEqual([401, 401, 401, 404, 400, 400, 404, 400, 401, 403, 400, 400, 413]); fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); expect(await send("/defenseurs/findings?project=proj")).toBe(500); fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); fs.rmSync(process.env.DEFENSEURS_STATUS_PATH); fs.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH); fs.rmSync(process.env.REPORTS_DIR, { recursive: true }); fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); for (const route of ["/defenseurs", "/defenseurs/state", "/reports/scans?date=2026-09-30", "/defenseurs/findings?project=proj", "/hosts"]) { expect(await send(route)).toBe(200); } await settle(); expect(violations(drain())).toEqual([]); }); }); ``` </details> Alternative minimale acceptable : restreindre `CLAUDE.md:14,73,84`, `README.md:156` et 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é - CI verte sur `6a2f7fe` (run 8) : 338 vitest dont les 9 de `writes.test.js`, 45 passes `test-curl.sh`, `boot-from-copy` PASS. Même 338/338 en local sur une copie de la tête. - Mutations de relecteur, chacune 1 rouge dans toute la suite, dans `writes.test.js`, sur le cas visé : - R5 `require("node:fs/promises").writeFile` après la réponse de `/reports/scans` (`fs.promises` et `node:fs/promises` sont le même objet, l'espion couvre les deux) ; - R6 `openSync(…, O_WRONLY | O_CREAT)`, drapeaux numériques, dans `/defenseurs/state` ; - R7 `createWriteStream(…, { flags: "a" })` dans `/hosts` ; - R8 écriture différée par `setImmediate` après `res.end` dans `/hosts` ; - R9 le POST écrit aussi le voisin `${HOSTS_DIR}-backup.json`, refusé par la règle `path.relative` ; - R10 `writeFileSync(new URL("file://…"))` sous `REPORTS_DIR`. - Chaque mutation vérifiée appliquée, puis restaurée octet pour octet (md5 identique à l'original) ; dépôt intact. - Décompte de `CLAUDE.md` : 171 + 67 + 50 + 14 + 12 + 9 + 9 + 6 = 338. - Docs hors dépôt : `la-compagnie-maximus@7cc8239` et `defenseurs@53e8abd` poussés, `diag-auth-drift/SKILL.md:284` à jour, contenu cohérent avec le commentaire 17147 de #36. - Commits conventionnels en anglais ; la timeline de #36 porte un `pull_ref` `closes` venant de cette PR. ### Suggestions (non bloquantes) 1. **Portée `child_process`.** L'espion ne voit que `node:fs`. R4, `execFileSync("touch", [<REPORTS_DIR>/.last-read])` dans `/defenseurs` : 0 rouge dans `writes.test.js`, 3 rouges ailleurs par accident (fixtures sans `REPORTS_DIR`). `metrics.js` importe déjà `execSync`. Deux options : écrire « `node:fs` » dans les affirmations, ou espionner `child_process` depuis les frames runtime et épingler l'ensemble des commandes sur `head -1 /proc/stat` et `df -k /`. 2. **Couche Unix** (`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 de `node` dans `node:22-alpine` et, sur une image Ubuntu cloud, groupe de l'utilisateur `ubuntu`. Nommer ce groupe dans la doc : un `chgrp ubuntu` ferait tomber la seconde couche sans bruit. 3. **Chemin de merge.** Les commits portent `(#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.
maximus added 2 commits 2026-09-30 23:59:47 +00:00
Review of PR #39: the spies only watched authenticated requests on
complete fixtures, so a write on an error path passed the whole suite
(reviewer mutations R1, R2, R3 and R11: quarantining a corrupt
agents-map.json, recreating a missing status.json, logging a 401/403 or a
refused payload to disk).

A new case walks rejections (401/403/404), invalid input (400/413), a
corrupt agents-map.json, missing Defenseurs files and a corrupt snapshot,
requires the degraded content (no_data, neverSeen), and requires no write
outside HOSTS_DIR. child_process is now spied the same way as node:fs and
the set of shell commands is pinned to the two read-only ones metrics.js
runs, with GET /health as positive control.

R1, R2, R3 and R11 went from 0 to 1 red each; R4 (touch through
execFileSync) is now caught by this file. A write deferred by a timer
longer than 20 ms (R8b) stays out of sight, as the header says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: bound the write guard's claims and name the owning group (#36)
All checks were successful
CI / verification (pull_request) Successful in 22s
CI / verification (push) Successful in 20s
48c9a408f6
The docs promised that writes.test.js pins every write. They now say what
it covers: node:fs writes and shell commands, on the paths it exercises.
They also name what keeps the second layer up: the Defenseurs content is
group-writable, and only the owning group (defenseur, gid 1001, not the
gid 1000 of node and ubuntu) keeps the container out, so a chgrp ubuntu
would drop that layer silently.

Fixes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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 :

  • les rejets : 401 sans token, avec un mauvais token, avec le token d'ingestion sur une lecture ; 403 hors allowlist ; 404 sur une route et un projet inconnus ;
  • les entrées invalides : 400 sans projet, sévérité inconnue, date traversante, JSON illisible, champ manquant ; 413 ;
  • un agents-map.json corrompu (500) ;
  • puis des fichiers Défenseurs absents et un snapshot corrompu, avec le contenu dégradé exigé (no_data trois fois, count: 0, neverSeen).

Aucune écriture hors HOSTS_DIR ne doit en sortir. Le helper send rend transport-error plutôt qu'un faux code.

Tes mutations exactes (mutations.js), rejouées sur l'arbre corrigé, suite complète à chaque fois :

Mutation Avant (6a2f7fe) Sous mutation (48c9a40) Après
R1 quarantaine d'un agents-map.json corrompu 0 rouge 1 rouge (chemins dégradés) 340/340
R2 serveJsonFile recrée le fichier absent 0 rouge 1 rouge 340/340
R3 journal des 401/403 à côté de REPORTS_DIR 0 rouge 1 rouge 340/340
R11 corps du 400 déposé sous REPORTS_DIR/.. 0 rouge 1 rouge 340/340
R3b R3 sans try 6 rouges, par accident 1 rouge voulu, plus les 6 accidentels 340/340

Chaque mutation a été restaurée depuis une copie de sauvegarde, fichier identique à la copie ; index.js et metrics.js sont intacts.

Suggestions

  1. child_process : espionné comme node:fs (exec, execSync, execFile, execFileSync, spawn, spawnSync, fork), depuis les frames runtime. Chaque cas exige que les seules commandes soient head -1 /proc/stat et df -k /, et un cas témoin exige exactement ces deux-là sur /health. R4 (execFileSync("touch", …)) : 0 rouge dans writes.test.js avant, 2 maintenant (GET /defenseurs et chemins dégradés).
  2. Groupe propriétaire nommé dans CLAUDE.md, README.md et .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 ; un chgrp ubuntu ou un chmod o+w la ferait tomber sans bruit.
  3. Chemin de merge : le commit 48c9a40 porte 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 : un metrics.js resté en cache garderait aussi un execSync pé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és
  • sh scripts/boot-from-copy.sh : PASS
## 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 : - les rejets : 401 sans token, avec un mauvais token, avec le token d'ingestion sur une lecture ; 403 hors allowlist ; 404 sur une route et un projet inconnus ; - les entrées invalides : 400 sans projet, sévérité inconnue, date traversante, JSON illisible, champ manquant ; 413 ; - un `agents-map.json` corrompu (500) ; - puis des fichiers Défenseurs absents et un snapshot corrompu, avec le contenu dégradé exigé (`no_data` trois fois, `count: 0`, `neverSeen`). Aucune écriture hors `HOSTS_DIR` ne doit en sortir. Le helper `send` rend `transport-error` plutôt qu'un faux code. Tes mutations exactes (`mutations.js`), rejouées sur l'arbre corrigé, suite complète à chaque fois : | | Mutation | Avant (`6a2f7fe`) | Sous mutation (`48c9a40`) | Après | |---|---|---|---|---| | R1 | quarantaine d'un `agents-map.json` corrompu | 0 rouge | 1 rouge (chemins dégradés) | 340/340 | | R2 | `serveJsonFile` recrée le fichier absent | 0 rouge | 1 rouge | 340/340 | | R3 | journal des 401/403 à côté de `REPORTS_DIR` | 0 rouge | 1 rouge | 340/340 | | R11 | corps du 400 déposé sous `REPORTS_DIR/..` | 0 rouge | 1 rouge | 340/340 | | R3b | R3 sans `try` | 6 rouges, par accident | 1 rouge voulu, plus les 6 accidentels | 340/340 | Chaque mutation a été restaurée depuis une copie de sauvegarde, fichier identique à la copie ; `index.js` et `metrics.js` sont intacts. ### Suggestions 1. **`child_process`** : espionné comme `node:fs` (`exec`, `execSync`, `execFile`, `execFileSync`, `spawn`, `spawnSync`, `fork`), depuis les frames runtime. Chaque cas exige que les seules commandes soient `head -1 /proc/stat` et `df -k /`, et un cas témoin exige exactement ces deux-là sur `/health`. R4 (`execFileSync("touch", …)`) : 0 rouge dans `writes.test.js` avant, 2 maintenant (`GET /defenseurs` et chemins dégradés). 2. **Groupe propriétaire** nommé dans `CLAUDE.md`, `README.md` et `.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 ; un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. 3. **Chemin de merge** : le commit `48c9a40` porte 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 : un `metrics.js` resté en cache garderait aussi un `execSync` pé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és - `sh scripts/boot-from-copy.sh` : PASS
Author
Owner

Revue 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_process attrape les commandes shell. Il reste une limite non écrite de l'attribution par pile, sans gravité, avec un correctif validé ici.

Vérifié

  • CI verte sur 48c9a40 (run 9, log lu) : 340 vitest dont les 11 de writes.test.js, 45 passes test-curl.sh, boot-from-copy PASS. 340/340 en local sur une copie hors dépôt. writes.test.js passe 15 fois sur 15 en boucle : pas d'instabilité, y compris sur le 413 envoyé par fetch.
  • Mutations de relecteur, différentes de R1, R2, R3 et R11. Elles sont appliquées sur une copie hors dépôt, avec la suite complète relancée à chaque fois, puis restaurées (md5 identique à l'original). Le dépôt n'est pas touché.
Mutation dans index.js Rouges sur 340
V1 400 « date invalide » journalisé sous REPORTS_DIR/.. (appendFileSync dans un try) 1 (cas dégradé)
V2 findLatestReportForAgent recrée le dossier manquant (mkdirSync, try) 1
V3 404 sur route inconnue : marqueur /data/defenseurs/last-404 (try) 1
V4 413 : corps refusé déposé sous REPORTS_DIR/.. (try) 1
V5 403 : fs.promises.appendFile sans await 1
V6 agents-map.json corrompu copié à côté (copyFileSync, try) 1
V7 serveJsonFile recrée le fichier absent, en différé par setImmediate(() => …) 1
C1 spawnSync("sh", ["-c", "touch …"]) dans /defenseurs/state 2 (GET /defenseurs/state, cas dégradé)
C3 exec("touch …") asynchrone dans GET /hosts 2
D1 setInterval de 60 s, posé au chargement, qui écrit sous REPORTS_DIR/.. 0 : limite déjà écrite (« différée au-delà de 20 ms »)

Aucun rouge accidentel hors de writes.test.js.

  • Docs du dépôt cohérentes avec le commentaire 17147 de #36 : 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.
  • Décompte de CLAUDE.md : 329 + 11 = 340, conforme au run CI.
  • Commits conventionnels en anglais. La timeline de #36 porte un pull_ref closes venant de cette PR. Le message de 48c9a40 porte aussi Fixes #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 fonction fs passée telle quelle à un mécanisme asynchrone s'exécute sans cette frame. L'écriture passe alors, sur un chemin exercé (GET /defenseurs nominal) :

Mutation dans handleDefenseurs Rouges Fichier écrit
G0 (témoin) process.nextTick(() => writeFileSync(tmp, "x")) 2 oui
G1 process.nextTick(writeFileSync, tmp, "x") 0 oui
G2 res.on("finish", writeFileSync.bind(null, tmp, "x")) 0 oui
G3 new fs.WriteStream(tmp).end("x") 0 oui
G4 Promise.resolve(tmp).then(fs.promises.rm) 0 sans objet

Ces formes sont rares dans ce dépôt. Mais CLAUDE.md:84 et README.md:160-163 affirment que toute écriture par node:fs vise HOSTS_DIR sur les chemins exercés, ce que G1 à G3 contredisent. Deux options :

  • Doc. Ajouter cette limite à writes.test.js:27-28 et à la parenthèse « Limites » de CLAUDE.md:73.
  • Code, validé sur une copie. Retirer l'attribution par pile des espions et compter toute écriture, sauf pendant les retouches de fixtures du cas dégradé. Une sonde a d'abord tout enregistré : sur les 11 cas, les seules écritures sans frame runtime sont les 8 retouches du test lui-même (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() et fromRuntimeFile() 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)
@@ -114,2 +114,3 @@
 let log = [];
+let paused = false; // set while the test edits its own fixtures
 let commandLog = [];
@@ -137,3 +138,3 @@
       vi.spyOn(owner, key).mockImplementation(function (...args) {
-        if (fromRuntimeFile(captureStack())) {
+        if (!paused) {
           const targets = targetsOf(args);
@@ -152,3 +153,3 @@
     vi.spyOn(child_process, key).mockImplementation(function (...args) {
-      if (fromRuntimeFile(captureStack())) commandLog.push(commandOf(args));
+      if (!paused) commandLog.push(commandOf(args));
       return original.apply(this, args);
@@ -391,3 +392,5 @@
     // A corrupt agents-map.json: where a quarantine rename would go.
+    paused = true;
     fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt");
+    paused = false;
     expect((await send("/defenseurs/findings?project=proj")).status).toBe(500);
@@ -396,2 +399,3 @@
     // write would go. Each route must degrade, with the content that says so.
+    paused = true;
     fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" }));
@@ -401,2 +405,3 @@
     fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt");
+    paused = false;
     const degraded = [

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'espion child_process. Sur 48c9a40, on compte 11 cas et 340 tests, et m5 (attribution morte) rougit 10 cas sur 11 : seule l'épingle du bloc require.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.

## Revue 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_process` attrape les commandes shell. Il reste une limite non écrite de l'attribution par pile, sans gravité, avec un correctif validé ici. ### Vérifié - CI verte sur `48c9a40` (run 9, log lu) : 340 vitest dont les 11 de `writes.test.js`, 45 passes `test-curl.sh`, `boot-from-copy` PASS. 340/340 en local sur une copie hors dépôt. `writes.test.js` passe 15 fois sur 15 en boucle : pas d'instabilité, y compris sur le 413 envoyé par `fetch`. - Mutations de relecteur, différentes de R1, R2, R3 et R11. Elles sont appliquées sur une copie hors dépôt, avec la suite complète relancée à chaque fois, puis restaurées (md5 identique à l'original). Le dépôt n'est pas touché. | | Mutation dans `index.js` | Rouges sur 340 | |---|---|---| | V1 | 400 « date invalide » journalisé sous `REPORTS_DIR/..` (`appendFileSync` dans un `try`) | 1 (cas dégradé) | | V2 | `findLatestReportForAgent` recrée le dossier manquant (`mkdirSync`, `try`) | 1 | | V3 | 404 sur route inconnue : marqueur `/data/defenseurs/last-404` (`try`) | 1 | | V4 | 413 : corps refusé déposé sous `REPORTS_DIR/..` (`try`) | 1 | | V5 | 403 : `fs.promises.appendFile` sans `await` | 1 | | V6 | `agents-map.json` corrompu copié à côté (`copyFileSync`, `try`) | 1 | | V7 | `serveJsonFile` recrée le fichier absent, en différé par `setImmediate(() => …)` | 1 | | C1 | `spawnSync("sh", ["-c", "touch …"])` dans `/defenseurs/state` | 2 (`GET /defenseurs/state`, cas dégradé) | | C3 | `exec("touch …")` asynchrone dans `GET /hosts` | 2 | | D1 | `setInterval` de 60 s, posé au chargement, qui écrit sous `REPORTS_DIR/..` | 0 : limite déjà écrite (« différée au-delà de 20 ms ») | Aucun rouge accidentel hors de `writes.test.js`. - Docs du dépôt cohérentes avec le commentaire 17147 de #36 : `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. - Décompte de `CLAUDE.md` : 329 + 11 = 340, conforme au run CI. - Commits conventionnels en anglais. La timeline de #36 porte un `pull_ref` `closes` venant de cette PR. Le message de `48c9a40` porte aussi `Fixes #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 fonction `fs` passée telle quelle à un mécanisme asynchrone s'exécute sans cette frame. L'écriture passe alors, sur un chemin exercé (`GET /defenseurs` nominal) : | | Mutation dans `handleDefenseurs` | Rouges | Fichier écrit | |---|---|---|---| | G0 (témoin) | `process.nextTick(() => writeFileSync(tmp, "x"))` | 2 | oui | | G1 | `process.nextTick(writeFileSync, tmp, "x")` | **0** | oui | | G2 | `res.on("finish", writeFileSync.bind(null, tmp, "x"))` | **0** | oui | | G3 | `new fs.WriteStream(tmp).end("x")` | **0** | oui | | G4 | `Promise.resolve(tmp).then(fs.promises.rm)` | **0** | sans objet | Ces formes sont rares dans ce dépôt. Mais `CLAUDE.md:84` et `README.md:160-163` affirment que toute écriture par `node:fs` vise `HOSTS_DIR` sur les chemins exercés, ce que G1 à G3 contredisent. Deux options : - **Doc.** Ajouter cette limite à `writes.test.js:27-28` et à la parenthèse « Limites » de `CLAUDE.md:73`. - **Code, validé sur une copie.** Retirer l'attribution par pile des espions et compter toute écriture, sauf pendant les retouches de fixtures du cas dégradé. Une sonde a d'abord tout enregistré : sur les 11 cas, les seules écritures sans frame runtime sont les 8 retouches du test lui-même (`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()` et `fromRuntimeFile()` deviennent morts, `isRuntimeFile()` reste pour la purge du cache, et le paragraphe d'en-tête sur l'attribution est à réécrire. <details><summary>Diff de la variante (7 lignes ajoutées, 2 modifiées)</summary> ```diff @@ -114,2 +114,3 @@ let log = []; +let paused = false; // set while the test edits its own fixtures let commandLog = []; @@ -137,3 +138,3 @@ vi.spyOn(owner, key).mockImplementation(function (...args) { - if (fromRuntimeFile(captureStack())) { + if (!paused) { const targets = targetsOf(args); @@ -152,3 +153,3 @@ vi.spyOn(child_process, key).mockImplementation(function (...args) { - if (fromRuntimeFile(captureStack())) commandLog.push(commandOf(args)); + if (!paused) commandLog.push(commandOf(args)); return original.apply(this, args); @@ -391,3 +392,5 @@ // A corrupt agents-map.json: where a quarantine rename would go. + paused = true; fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); + paused = false; expect((await send("/defenseurs/findings?project=proj")).status).toBe(500); @@ -396,2 +399,3 @@ // write would go. Each route must degrade, with the content that says so. + paused = true; fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); @@ -401,2 +405,3 @@ fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); + paused = false; const degraded = [ ``` </details> **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'espion `child_process`. Sur `48c9a40`, on compte 11 cas et 340 tests, et m5 (attribution morte) rougit 10 cas sur 11 : seule l'épingle du bloc `require.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.
maximus merged commit 48c9a408f6 into main 2026-10-01 00:26:31 +00:00
maximus deleted branch issue-36-readonly-defenseurs-mounts 2026-10-01 00:26:31 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/vps-health-api#39
No description provided.