diff --git a/__tests__/writes.test.js b/__tests__/writes.test.js
new file mode 100644
index 0000000..cd9fba7
--- /dev/null
+++ b/__tests__/writes.test.js
@@ -0,0 +1,323 @@
+const http = require("node:http");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { fileURLToPath } = require("node:url");
+
+// The API's only write surface is HOSTS_DIR (issue #36).
+//
+// Since 2026-09-30 the two Defenseurs mounts are read-only in the container
+// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR
+// or /data/defenseurs would not fail in the other test files: their fixtures
+// live in a writable temp dir, and CI runs as root, so a chmod-555 fixture
+// would prove nothing either. It would fail in production, as a 500 (EROFS)
+// on the route that tried it. This file moves that failure to CI.
+//
+// Every node:fs function that can write is spied BEFORE a fresh require,
+// because the runtime modules destructure their fs functions at load time
+// (same technique as the timingSafeEqual spy in auth.test.js). A call is
+// recorded when its stack goes through a runtime file of the repo, then passed
+// through to the real function.
+//
+// Boot is exercised by calling the exported sweepStaleTempFiles(), which is
+// what the `require.main === module` block runs before listen. That block
+// never runs in-process, so its content is pinned at the end of this file.
+
+const REPO_ROOT = path.resolve(__dirname, "..");
+const TOKEN = "test-token";
+const INGEST_TOKEN = "test-ingest-token";
+const ORPHAN = ".thinkpad.4242.1700000000000.tmp";
+
+// Runtime file = under the repo root, outside node_modules and __tests__.
+// Used both to attribute a write to the API and to purge the module cache.
+function isRuntimeFile(file) {
+ const rel = path.relative(REPO_ROOT, file);
+ if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
+ const top = rel.split(path.sep)[0];
+ return top !== "node_modules" && top !== "__tests__";
+}
+
+function fromRuntimeFile(stack) {
+ return stack
+ .split("\n")
+ .slice(1)
+ .some((frame) => {
+ const match = /\(?(\/[^()]+?):\d+:\d+\)?$/.exec(frame.trim());
+ return match !== null && isRuntimeFile(match[1]);
+ });
+}
+
+// Write APIs of node:fs, by the argument(s) naming what gets written. Each is
+// spied in its callback, Sync and fs.promises forms, when that form exists.
+const O_WRITE =
+ fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND;
+
+function opensForWriting(flags) {
+ if (flags === undefined || flags === null || typeof flags === "function") return false; // "r"
+ if (typeof flags === "number") return (flags & O_WRITE) !== 0;
+ return /[wa+]/.test(String(flags));
+}
+
+// mkdtemp's argument is a prefix: `
/x` creates `/xXXXXXX` below
+// , but `` alone creates the sibling `XXXXXX`. Checking the
+// prefix plus one character tells the two apart.
+function mkdtempTarget(prefix) {
+ if (prefix instanceof URL) return `${fileURLToPath(prefix)}X`;
+ if (typeof prefix === "string" || Buffer.isBuffer(prefix)) return `${prefix.toString()}X`;
+ return prefix;
+}
+
+const at = (...indexes) => (args) => indexes.map((i) => args[i]);
+const WRITE_APIS = [
+ // The destination is the first argument.
+ ...[
+ "writeFile", "appendFile", "unlink", "mkdir", "rm", "rmdir", "truncate",
+ "chmod", "lchmod", "chown", "lchown", "utimes", "lutimes", "createWriteStream",
+ ].map((name) => [name, at(0)]),
+ ["mkdtemp", (args) => [mkdtempTarget(args[0])]],
+ // Only the destination (second argument) is written.
+ ...["copyFile", "cp", "link", "symlink"].map((name) => [name, at(1)]),
+ // Both ends of a rename change.
+ ["rename", at(0, 1)],
+ // Metadata changes through a file descriptor: the target cannot be proven,
+ // so the fd itself is recorded and fails the path rule.
+ ...["fchmod", "fchown", "futimes"].map((name) => [name, at(0)]),
+ // An open is a write only when its flags say so.
+ ["open", (args) => (opensForWriting(args[1]) ? [args[0]] : null)],
+];
+
+let log = [];
+
+function drain() {
+ const out = log;
+ log = [];
+ return out;
+}
+
+function installWriteSpies() {
+ for (const [name, targetsOf] of WRITE_APIS) {
+ for (const [owner, key] of [
+ [fs, name],
+ [fs, `${name}Sync`],
+ [fs.promises, name],
+ ]) {
+ // lchmod is an own property holding undefined on Linux.
+ if (typeof owner[key] !== "function") continue;
+ const original = owner[key];
+ vi.spyOn(owner, key).mockImplementation(function (...args) {
+ const limit = Error.stackTraceLimit;
+ Error.stackTraceLimit = 50;
+ const { stack } = new Error();
+ Error.stackTraceLimit = limit;
+ if (fromRuntimeFile(stack)) {
+ const targets = targetsOf(args);
+ if (targets) log.push({ fn: key, targets });
+ }
+ return original.apply(this, args);
+ });
+ }
+ }
+}
+
+// Every runtime module, not only index.js: a module left in the cache would
+// keep the fs functions it destructured under a previous test's spies, which
+// vi.restoreAllMocks() has turned back into silent pass-throughs.
+function purgeRuntimeModules() {
+ for (const file of Object.keys(require.cache)) {
+ if (isRuntimeFile(file)) delete require.cache[file];
+ }
+}
+
+// Equal to HOSTS_DIR or below it. path.relative rather than startsWith, so a
+// sibling such as `/hosts-evil` does not pass. A file descriptor, or any
+// argument that is not a path, cannot be proven and fails.
+function toPath(arg) {
+ if (typeof arg === "string") return path.resolve(arg);
+ if (Buffer.isBuffer(arg)) return path.resolve(arg.toString());
+ if (arg instanceof URL) return path.resolve(fileURLToPath(arg));
+ return null;
+}
+
+function violations(writes) {
+ const hostsDir = path.resolve(process.env.HOSTS_DIR);
+ return writes.flatMap(({ fn, targets }) =>
+ targets
+ .filter((arg) => {
+ const target = toPath(arg);
+ if (target === null) return true;
+ const rel = path.relative(hostsDir, target);
+ return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
+ })
+ .map((arg) => `${fn}(${typeof arg === "string" ? arg : typeof arg})`),
+ );
+}
+
+function validSnapshot() {
+ return {
+ hostname: "thinkpad-x1",
+ uptime: 4242,
+ cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
+ memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
+ disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
+ };
+}
+
+function writeJson(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(value));
+}
+
+// One macrotask more, so a write deferred past res.end() is caught too.
+function settle() {
+ return new Promise((resolve) => setTimeout(resolve, 20));
+}
+
+let tmpDir;
+let server;
+let baseUrl;
+let bootWrites;
+
+// Created before the spies: nothing here is attributed to the API.
+function writeFixtures() {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-"));
+ const defenseurs = path.join(tmpDir, "defenseurs");
+ const reports = path.join(defenseurs, "reports");
+ const hosts = path.join(tmpDir, "hosts");
+ const report = (timestamp) => ({
+ agent: "defenseur-proj",
+ timestamp,
+ findings: [{ severity: "HIGH", category: "deps", title: "vulnerable dependency" }],
+ });
+
+ writeJson(path.join(defenseurs, "status.json"), {
+ schemaVersion: 1,
+ timestamp: "2026-09-30T07:30:00.000Z",
+ agents: [{ name: "defenseur-proj", status: "ok" }],
+ });
+ writeJson(path.join(defenseurs, "findings-state.json"), {
+ schemaVersion: 1,
+ updatedAt: "2026-09-30T07:30:00.000Z",
+ findings: { "proj:deps:1": { firstSeen: "2026-09-29T07:30:00.000Z" } },
+ });
+ writeJson(path.join(defenseurs, "agents-map.json"), { proj: "defenseur-proj" });
+ writeJson(path.join(reports, "defenseur-proj_2026-09-30T07-30-00-000Z.json"), report("2026-09-30T07:30:00.000Z"));
+ writeJson(path.join(reports, "archive", "defenseur-proj_2026-09-29T07-30-00-000Z.json"), report("2026-09-29T07:30:00.000Z"));
+ // A stored snapshot, so GET /hosts runs its real read path, not neverSeen.
+ writeJson(path.join(hosts, "thinkpad.json"), { ...validSnapshot(), receivedAt: new Date().toISOString() });
+ // An orphan temp file older than an hour: the boot sweep must remove it,
+ // which gives every test a write it can see (proof its spies are live).
+ const orphan = path.join(hosts, ORPHAN);
+ fs.writeFileSync(orphan, "{}");
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
+ fs.utimesSync(orphan, twoHoursAgo, twoHoursAgo);
+
+ process.env.HEALTH_TOKEN = TOKEN;
+ process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
+ process.env.REPORTS_DIR = reports;
+ process.env.DEFENSEURS_STATUS_PATH = path.join(defenseurs, "status.json");
+ process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(defenseurs, "findings-state.json");
+ process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json");
+ process.env.HOSTS_DIR = hosts;
+ process.env.HOSTS_ALLOWED_IDS = "thinkpad";
+ // Closed port: /health must never reach the real IdP.
+ process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
+}
+
+beforeEach(async () => {
+ writeFixtures();
+ log = [];
+ installWriteSpies();
+ purgeRuntimeModules();
+ const { handler, sweepStaleTempFiles } = require("../index.js");
+ sweepStaleTempFiles();
+ server = http.createServer(handler);
+ await new Promise((resolve) => server.listen(0, resolve));
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
+ bootWrites = drain();
+});
+
+afterEach(async () => {
+ vi.restoreAllMocks();
+ await new Promise((resolve) => server.close(() => resolve()));
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
+ test("boot: module load and the temp-file sweep write only under HOSTS_DIR", () => {
+ expect(bootWrites.map(({ fn }) => fn)).toContain("unlinkSync");
+ expect(fs.existsSync(path.join(process.env.HOSTS_DIR, ORPHAN))).toBe(false);
+ expect(violations(bootWrites)).toEqual([]);
+ });
+
+ // Each route with the content that proves its fixture was really read: a
+ // missing mount answers 200 no_data, which must not pass for a clean read.
+ const READ_ROUTES = [
+ ["/health", (body) => expect(body.cpu).toBeDefined()],
+ ["/defenseurs", (body) => expect(body.agents).toHaveLength(1)],
+ ["/defenseurs/state", (body) => expect(Object.keys(body.findings)).toHaveLength(1)],
+ [
+ "/defenseurs/findings?project=proj",
+ (body) => {
+ expect(body.agent).toBe("defenseur-proj");
+ expect(body.findings).toHaveLength(1);
+ },
+ ],
+ ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(1)],
+ [
+ "/hosts",
+ (body) => {
+ const host = body.hosts.find(({ id }) => id === "thinkpad");
+ expect(host.neverSeen).toBeUndefined();
+ expect(host.receivedAt).toEqual(expect.any(String));
+ },
+ ],
+ ];
+
+ test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
+ // This test's own spies are live: they saw the boot sweep.
+ expect(bootWrites).not.toHaveLength(0);
+
+ const res = await fetch(`${baseUrl}${route}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.status).not.toBe("no_data");
+ expectContent(body);
+
+ await settle();
+ expect(drain()).toEqual([]);
+ });
+
+ test("POST /hosts/ writes, and only under HOSTS_DIR", async () => {
+ const res = await fetch(`${baseUrl}/hosts/thinkpad`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${INGEST_TOKEN}`, "Content-Type": "application/json" },
+ body: JSON.stringify(validSnapshot()),
+ });
+ expect(res.status).toBe(204);
+
+ await settle();
+ const writes = drain();
+ expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"]));
+ expect(violations(writes)).toEqual([]);
+ });
+});
+
+describe("fs writes — the entrypoint adds no write of its own", () => {
+ // The block below never runs in-process: the tests above stand in for it by
+ // calling the exported sweep. This pin keeps that stand-in honest. A new call
+ // in the block (a PID file, a marker) must be looked at, then allowed here.
+ // The comparison is an exact set, so a block that cannot be found or comes
+ // back empty fails instead of passing vacuously.
+ test("the main block only sweeps, creates the server and listens", () => {
+ const source = fs.readFileSync(path.join(REPO_ROOT, "index.js"), "utf-8");
+ const start = source.indexOf("if (require.main === module) {");
+ expect(start).toBeGreaterThan(-1);
+ const block = source.slice(start, source.indexOf("\n}\n", start) + 2);
+ const calls = [...block.matchAll(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)]
+ .map((match) => match[1])
+ .filter((name) => name !== "if");
+ expect([...new Set(calls)].sort()).toEqual(
+ ["console.log", "http.createServer", "server.listen", "sweepStaleTempFiles"].sort(),
+ );
+ });
+});