diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a703a0..935cd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.11.0] — 2026-06-23 +### Added +- `src/gatekeeper/gatekeeper.ts` : service `Gatekeeper` (vérifie le statut AVANT + chaque exécution) + `GatekeeperGuard`. Privilégié inconnu → enregistré BLOQUÉ, + refusé jusqu'à review, hook d'alerte `onBlockedAttempt` ; approuvé → exécutable + + incrément du compteur. Lecture seule → autorisée sans asset. +- Gate de phase `CHLOVA_PHASE` (défaut 1, fail-safe) dans la config. +- Tests gatekeeper service (5) + phase config (2). +### Changed +- `assertReadOnlyPhase` devient phase-aware : Phase 1 exige read-only ; Phase 2 + autorise `PORTAINER_READ_ONLY=false`. + ## [0.10.0] — 2026-06-23 — début Phase 2 (écriture + need-review) ### Added - `src/gatekeeper/repository.ts` : `AssetRepository` SQLite (`node:sqlite`, zéro diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 01eac45..7e57eb1 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -19,6 +19,13 @@ const schema = z.object({ .enum(["fatal", "error", "warn", "info", "debug", "trace"]) .default("info"), + // Gate de phase : 1 = lecture seule (aucune écriture branchée), + // 2 = écriture sous gatekeeper + cycle need-review. Défaut 1 (fail-safe). + phase: z + .string() + .default("1") + .transform((v) => (v.trim() === "2" ? 2 : 1)), + // Ollama (proxy cloud) ollamaBaseUrl: z.string().url(), ollamaApiKey: nonEmpty, // SECRET @@ -68,6 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const parsed = schema.safeParse({ env: env.CHLOVA_ENV, logLevel: env.CHLOVA_LOG_LEVEL, + phase: env.CHLOVA_PHASE, ollamaBaseUrl: env.OLLAMA_BASE_URL, ollamaApiKey: env.OLLAMA_API_KEY, ollamaModel: env.OLLAMA_MODEL, @@ -95,15 +103,19 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** - * Verrou Phase 1 : la lecture seule Portainer est obligatoire. Tant que la - * Phase 2 (écriture) n'est pas activée, démarrer avec PORTAINER_READ_ONLY=false - * est une erreur. Empêche un glissement silencieux vers l'écriture. + * Cohérence phase ↔ lecture seule. + * - Phase 1 : la lecture seule Portainer est OBLIGATOIRE. Démarrer avec + * PORTAINER_READ_ONLY=false est une erreur (empêche un glissement silencieux + * vers l'écriture). + * - Phase 2 : l'écriture est permise (sous gatekeeper) ; PORTAINER_READ_ONLY + * peut être false. S'il reste true, c'est cohérent mais les écritures + * Portainer échoueront côté serveur MCP (à l'appelant de le savoir). */ export function assertReadOnlyPhase(cfg: Config): void { - if (!cfg.portainerReadOnly) { + if (cfg.phase === 1 && !cfg.portainerReadOnly) { throw new Error( "PORTAINER_READ_ONLY=false interdit en Phase 1 (lecture seule). " + - "L'écriture est une capacité Phase 2 sous revue. Voir docs/security.md.", + "Passe CHLOVA_PHASE=2 pour activer l'écriture sous review. Voir docs/security.md.", ); } } diff --git a/orchestrator/src/gatekeeper/gatekeeper.ts b/orchestrator/src/gatekeeper/gatekeeper.ts new file mode 100644 index 0000000..d353750 --- /dev/null +++ b/orchestrator/src/gatekeeper/gatekeeper.ts @@ -0,0 +1,98 @@ +import type { Logger } from "pino"; +import type { Guard, ToolSpec } from "../agent/types.js"; +import { AssetRepository } from "./repository.js"; +import { canExecute, createAsset, type Asset } from "./assets.js"; + +/** + * Gatekeeper (Phase 2) : vérifie le statut d'un asset AVANT chaque exécution. + * + * Règle non négociable (docs/risk-tiers.md) : + * - `reversible` / lecture seule → autorisé (sursis 7 j si asset provisoire). + * - `privileged` → AUCUN sursis. À la 1ʳᵉ tentative, l'asset est enregistré + * BLOQUÉ et l'exécution est REFUSÉE jusqu'à review humaine. Toute tentative + * sur un asset BLOQUÉ déclenche une alerte. + * + * Le LLM ne peut jamais reclasser un asset (le palier est dérivé du ToolSpec, + * lui-même issu des annotations MCP — voir mcp/readonly-filter.ts). + */ + +export type BlockedAttemptHook = (asset: Asset, spec: ToolSpec) => void; + +export interface GatekeeperOptions { + /** Appelé à chaque tentative refusée sur un asset privilégié/bloqué (alerte P3). */ + onBlockedAttempt?: BlockedAttemptHook; + now?: () => number; +} + +export class Gatekeeper { + private readonly now: () => number; + + constructor( + private readonly repo: AssetRepository, + private readonly logger: Logger, + private readonly opts: GatekeeperOptions = {}, + ) { + this.now = opts.now ?? Date.now; + } + + /** Id stable d'asset pour une capacité d'outil. */ + static assetIdForTool(spec: ToolSpec): string { + return `tool:${spec.name}`; + } + + /** + * Décide si l'exécution d'un outil est autorisée MAINTENANT, en mutant l'état + * de la table assets si besoin (enregistrement BLOQUÉ d'un privilégié inconnu, + * incrément du compteur d'exécution). + */ + authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } { + // Lecture seule : pas d'asset, pas de review. + if (spec.riskTier === "reversible" && spec.readOnly) { + return { allowed: true }; + } + + const id = Gatekeeper.assetIdForTool(spec); + let asset = this.repo.get(id); + + // 1ʳᵉ rencontre d'une capacité privilégiée → enregistrée BLOQUÉE (aucun sursis). + if (!asset) { + asset = createAsset({ + id, + type: "tool", + version: "0.0.0", + riskTier: "privileged", + now: this.now(), + }); + this.repo.create(asset); + this.logger.warn( + { asset: id, tool: spec.name }, + "capacité privilégiée enregistrée BLOQUÉE (review requise)", + ); + } + + const verdict = canExecute(asset, this.now()); + if (!verdict.ok) { + this.opts.onBlockedAttempt?.(asset, spec); + return { + allowed: false, + reason: verdict.reason ?? "exécution refusée (review requise)", + }; + } + + this.repo.incrementExec(id); + return { allowed: true }; + } +} + +/** + * Adaptateur Guard pour la boucle agent. La boucle ne dépend que de l'interface + * `Guard` : passer du `ReadOnlyGuard` (P1) au `GatekeeperGuard` (P2) ne change + * pas la boucle. + */ +export class GatekeeperGuard implements Guard { + constructor(private readonly gatekeeper: Gatekeeper) {} + + authorize(spec: ToolSpec): { allowed: boolean; reason?: string } { + return this.gatekeeper.authorizeTool(spec); + } +} diff --git a/orchestrator/test/config.test.ts b/orchestrator/test/config.test.ts index a0bd761..6ffbf7d 100644 --- a/orchestrator/test/config.test.ts +++ b/orchestrator/test/config.test.ts @@ -39,11 +39,26 @@ describe("verrou lecture seule Phase 1", () => { expect(() => assertReadOnlyPhase(loadConfig(fullEnv()))).not.toThrow(); }); - it("refuse PORTAINER_READ_ONLY=false", () => { + it("refuse PORTAINER_READ_ONLY=false en Phase 1", () => { const env = fullEnv(); env.PORTAINER_READ_ONLY = "false"; expect(() => assertReadOnlyPhase(loadConfig(env))).toThrow(/lecture seule/); }); + + it("Phase 2 autorise PORTAINER_READ_ONLY=false", () => { + const env = fullEnv(); + env.CHLOVA_PHASE = "2"; + env.PORTAINER_READ_ONLY = "false"; + const cfg = loadConfig(env); + expect(cfg.phase).toBe(2); + expect(() => assertReadOnlyPhase(cfg)).not.toThrow(); + }); + + it("phase invalide retombe sur 1 (fail-safe)", () => { + const env = fullEnv(); + env.CHLOVA_PHASE = "9"; + expect(loadConfig(env).phase).toBe(1); + }); }); describe("redactedConfig masque les secrets", () => { diff --git a/orchestrator/test/gatekeeper-service.test.ts b/orchestrator/test/gatekeeper-service.test.ts new file mode 100644 index 0000000..e5f6c7e --- /dev/null +++ b/orchestrator/test/gatekeeper-service.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { Gatekeeper, GatekeeperGuard } from "../src/gatekeeper/gatekeeper.js"; +import { createLogger } from "../src/audit/log.js"; +import type { ToolSpec } from "../src/agent/types.js"; + +const log = createLogger("silent"); + +const readTool: ToolSpec = { + name: "n8n.list_workflows", + description: "", + parameters: {}, + server: "n8n", + readOnly: true, + riskTier: "reversible", +}; +const writeTool: ToolSpec = { + name: "portainer.deploy_stack", + description: "", + parameters: {}, + server: "portainer", + readOnly: false, + riskTier: "privileged", +}; + +let repo: AssetRepository; +beforeEach(() => { + repo = new AssetRepository(":memory:"); +}); +afterEach(() => { + repo.close(); +}); + +describe("Gatekeeper (Phase 2)", () => { + it("autorise la lecture seule sans créer d'asset", () => { + const gk = new Gatekeeper(repo, log); + expect(gk.authorizeTool(readTool).allowed).toBe(true); + expect(repo.list()).toHaveLength(0); + }); + + it("refuse un privilégié inconnu, l'enregistre BLOQUÉ et alerte", () => { + const onBlockedAttempt = vi.fn(); + const gk = new Gatekeeper(repo, log, { onBlockedAttempt }); + const v = gk.authorizeTool(writeTool); + expect(v.allowed).toBe(false); + const id = Gatekeeper.assetIdForTool(writeTool); + const asset = repo.get(id); + expect(asset?.status).toBe("bloqué"); + expect(asset?.riskTier).toBe("privileged"); + expect(asset?.expiresAt).toBeNull(); // aucun sursis + expect(onBlockedAttempt).toHaveBeenCalledOnce(); + }); + + it("autorise un privilégié APPROUVÉ et incrémente le compteur", () => { + const gk = new Gatekeeper(repo, log); + gk.authorizeTool(writeTool); // crée l'asset bloqué + const id = Gatekeeper.assetIdForTool(writeTool); + repo.updateStatus(id, "approuvé"); // review humaine + + expect(gk.authorizeTool(writeTool).allowed).toBe(true); + expect(gk.authorizeTool(writeTool).allowed).toBe(true); + expect(repo.get(id)?.execCount).toBe(2); + }); + + it("refuse un asset REFUSÉ", () => { + const gk = new Gatekeeper(repo, log); + gk.authorizeTool(writeTool); + const id = Gatekeeper.assetIdForTool(writeTool); + repo.updateStatus(id, "refusé"); + expect(gk.authorizeTool(writeTool).allowed).toBe(false); + }); + + it("GatekeeperGuard délègue au gatekeeper", () => { + const guard = new GatekeeperGuard(new Gatekeeper(repo, log)); + expect(guard.authorize(readTool).allowed).toBe(true); + expect(guard.authorize(writeTool).allowed).toBe(false); + }); +});