diff --git a/CHANGELOG.md b/CHANGELOG.md index 7625e83..6a703a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.10.0] — 2026-06-23 — début Phase 2 (écriture + need-review) +### Added +- `src/gatekeeper/repository.ts` : `AssetRepository` SQLite (`node:sqlite`, zéro + dépendance native) — schema table assets, CRUD, `listByStatus`, `incrementExec`, + `setRiskTier` (anti-escalade), `expireProvisional` (cron PROVISOIRE→BLOQUÉ). +- Tests repository (6) : persistance, anti-escalade, expiration du sursis. +### Changed +- Node 24 (sqlite natif stable) : `engines`, base Dockerfile `node:24.13`, + `@types/node` 24, copie `tsconfig.build.json` dans l'image. `npm audit` 0 vuln. + ## [0.9.0] — 2026-06-23 — fin Phase 1 (cerveau lecture seule) ### Added - `src/gatekeeper/assets.ts` : interfaces du cycle "need review" posées pour la diff --git a/orchestrator/Dockerfile b/orchestrator/Dockerfile index ba0a03a..cbb1d15 100644 --- a/orchestrator/Dockerfile +++ b/orchestrator/Dockerfile @@ -1,15 +1,15 @@ # CHLOVA backend — image multi-stage, base épinglée (jamais :latest). -# TODO épingler le digest (node:22.14-bookworm-slim@sha256:...) avant déploiement réel. +# TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel. -FROM node:22.14-bookworm-slim AS build +FROM node:24.13-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci -COPY tsconfig.json ./ +COPY tsconfig.json tsconfig.build.json ./ COPY src ./src RUN npm run build -FROM node:22.14-bookworm-slim AS runtime +FROM node:24.13-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app COPY package.json package-lock.json* ./ diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index e7a8248..c4b9f1f 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -14,13 +14,13 @@ "zod": "3.24.1" }, "devDependencies": { - "@types/node": "22.13.1", + "@types/node": "^24.13.2", "tsx": "4.22.4", "typescript": "5.7.3", "vitest": "4.1.9" }, "engines": { - "node": ">=22" + "node": ">=24" } }, "node_modules/@emnapi/core": { @@ -1048,13 +1048,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", - "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.18.0" } }, "node_modules/@vitest/expect": { @@ -3305,9 +3305,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/orchestrator/package.json b/orchestrator/package.json index 7a67753..71844ec 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "CHLOVA — cerveau LLM en boucle tool-calling (Phase 1 : lecture seule)", "engines": { - "node": ">=22" + "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", @@ -22,7 +22,7 @@ "zod": "3.24.1" }, "devDependencies": { - "@types/node": "22.13.1", + "@types/node": "24.13.2", "tsx": "4.22.4", "typescript": "5.7.3", "vitest": "4.1.9" diff --git a/orchestrator/src/gatekeeper/repository.ts b/orchestrator/src/gatekeeper/repository.ts new file mode 100644 index 0000000..ca6f32a --- /dev/null +++ b/orchestrator/src/gatekeeper/repository.ts @@ -0,0 +1,156 @@ +import { DatabaseSync } from "node:sqlite"; +import type { RiskTier } from "../audit/log.js"; +import { + type Asset, + type AssetStatus, + type AssetType, + assertNoEscalation, +} from "./assets.js"; + +/** + * Persistance de la table d'assets (cycle "need review", Phase 2). + * + * Backend SQLite via `node:sqlite` (intégré à Node 24, zéro dépendance native). + * La table fait foi de l'état du cycle de vie ; le commit/doc d'un asset vivent + * dans Git (champs commit_link / doc_link). Voir docs/risk-tiers.md. + */ + +interface Row { + id: string; + type: string; + version: string; + risk_tier: string; + status: string; + created_at: number; + expires_at: number | null; + exec_count: number; + commit_link: string | null; + doc_link: string | null; +} + +function rowToAsset(r: Row): Asset { + return { + id: r.id, + type: r.type as AssetType, + version: r.version, + riskTier: r.risk_tier as RiskTier, + status: r.status as AssetStatus, + createdAt: r.created_at, + expiresAt: r.expires_at, + execCount: r.exec_count, + commitLink: r.commit_link, + docLink: r.doc_link, + }; +} + +export class AssetRepository { + private readonly db: DatabaseSync; + + constructor(dbPath: string) { + this.db = new DatabaseSync(dbPath); + this.db.exec("PRAGMA journal_mode = WAL;"); + this.migrate(); + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS assets ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + version TEXT NOT NULL, + risk_tier TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + exec_count INTEGER NOT NULL DEFAULT 0, + commit_link TEXT, + doc_link TEXT + ); + CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status); + `); + } + + create(asset: Asset): void { + this.db + .prepare( + `INSERT INTO assets + (id, type, version, risk_tier, status, created_at, expires_at, exec_count, commit_link, doc_link) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + asset.id, + asset.type, + asset.version, + asset.riskTier, + asset.status, + asset.createdAt, + asset.expiresAt, + asset.execCount, + asset.commitLink, + asset.docLink, + ); + } + + get(id: string): Asset | null { + const row = this.db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as + | Row + | undefined; + return row ? rowToAsset(row) : null; + } + + list(): Asset[] { + const rows = this.db + .prepare("SELECT * FROM assets ORDER BY created_at DESC") + .all() as unknown as Row[]; + return rows.map(rowToAsset); + } + + listByStatus(status: AssetStatus): Asset[] { + const rows = this.db + .prepare("SELECT * FROM assets WHERE status = ? ORDER BY created_at DESC") + .all(status) as unknown as Row[]; + return rows.map(rowToAsset); + } + + updateStatus(id: string, status: AssetStatus): void { + this.db.prepare("UPDATE assets SET status = ? WHERE id = ?").run(status, id); + } + + /** Change le palier de risque en refusant toute escalade (privileged→reversible). */ + setRiskTier(id: string, next: RiskTier): void { + const current = this.get(id); + if (!current) throw new Error(`asset inconnu: ${id}`); + assertNoEscalation(current.riskTier, next); + this.db.prepare("UPDATE assets SET risk_tier = ? WHERE id = ?").run(next, id); + } + + incrementExec(id: string): number { + this.db + .prepare("UPDATE assets SET exec_count = exec_count + 1 WHERE id = ?") + .run(id); + return this.get(id)?.execCount ?? 0; + } + + /** + * Cron : passe en BLOQUÉ les assets PROVISOIRE dont le sursis a expiré. + * Retourne les ids basculés. (Les privilégiés sont déjà BLOQUÉS, sans sursis.) + */ + expireProvisional(now = Date.now()): string[] { + const due = this.db + .prepare( + "SELECT id FROM assets WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?", + ) + .all(now) as unknown as { id: string }[]; + if (due.length === 0) return []; + this.db + .prepare( + "UPDATE assets SET status = 'bloqué' WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?", + ) + .run(now); + return due.map((r) => r.id); + } + + close(): void { + this.db.close(); + } +} diff --git a/orchestrator/test/repository.test.ts b/orchestrator/test/repository.test.ts new file mode 100644 index 0000000..54cfbbf --- /dev/null +++ b/orchestrator/test/repository.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { createAsset, EscalationError, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js"; + +let repo: AssetRepository; + +beforeEach(() => { + repo = new AssetRepository(":memory:"); +}); +afterEach(() => { + repo.close(); +}); + +describe("AssetRepository", () => { + it("crée et relit un asset", () => { + const a = createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible", now: 1000 }); + repo.create(a); + expect(repo.get("a")).toEqual(a); + }); + + it("liste par statut", () => { + repo.create(createAsset({ id: "r", type: "tool", version: "1.0.0", riskTier: "reversible" })); + repo.create(createAsset({ id: "p", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" })); + expect(repo.listByStatus("provisoire").map((x) => x.id)).toEqual(["r"]); + expect(repo.listByStatus("bloqué").map((x) => x.id)).toEqual(["p"]); + }); + + it("incrémente le compteur d'exécution", () => { + repo.create(createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible" })); + expect(repo.incrementExec("a")).toBe(1); + expect(repo.incrementExec("a")).toBe(2); + }); + + it("setRiskTier refuse l'escalade privileged→reversible", () => { + repo.create(createAsset({ id: "p", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" })); + expect(() => repo.setRiskTier("p", "reversible")).toThrow(EscalationError); + // le palier n'a pas changé + expect(repo.get("p")?.riskTier).toBe("privileged"); + }); + + it("expireProvisional bascule les provisoires échus en bloqué", () => { + const t0 = 1_000_000; + repo.create(createAsset({ id: "old", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 })); + repo.create(createAsset({ id: "fresh", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 })); + // bascule à un instant juste après l'expiration de "old" mais on garde "fresh" frais + const switched = repo.expireProvisional(t0 + PROVISIONAL_TTL_MS + 1); + expect(switched).toEqual(expect.arrayContaining(["old", "fresh"])); + expect(repo.get("old")?.status).toBe("bloqué"); + }); + + it("n'expire rien avant l'échéance", () => { + const t0 = 1_000_000; + repo.create(createAsset({ id: "a", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 })); + expect(repo.expireProvisional(t0 + 1)).toEqual([]); + expect(repo.get("a")?.status).toBe("provisoire"); + }); +});