feat: gatekeeper service + gate de phase (v0.11.0)

Gatekeeper vérifie le statut d'asset avant chaque exécution : privilégié
inconnu → BLOQUÉ (aucun sursis) refusé jusqu'à review + hook d'alerte ;
approuvé → exécutable. Lecture seule autorisée sans asset. Gate de phase
CHLOVA_PHASE (défaut 1, fail-safe) ; assertReadOnlyPhase phase-aware. 7
nouveaux tests.

Palier de risque : reversible (logique de contrôle, n'exécute aucune mutation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:27:35 +02:00
parent 56e948c976
commit 93d93bef0e
5 changed files with 221 additions and 6 deletions
+12
View File
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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) ## [0.10.0] — 2026-06-23 — début Phase 2 (écriture + need-review)
### Added ### Added
- `src/gatekeeper/repository.ts` : `AssetRepository` SQLite (`node:sqlite`, zéro - `src/gatekeeper/repository.ts` : `AssetRepository` SQLite (`node:sqlite`, zéro
+17 -5
View File
@@ -19,6 +19,13 @@ const schema = z.object({
.enum(["fatal", "error", "warn", "info", "debug", "trace"]) .enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"), .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) // Ollama (proxy cloud)
ollamaBaseUrl: z.string().url(), ollamaBaseUrl: z.string().url(),
ollamaApiKey: nonEmpty, // SECRET ollamaApiKey: nonEmpty, // SECRET
@@ -68,6 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const parsed = schema.safeParse({ const parsed = schema.safeParse({
env: env.CHLOVA_ENV, env: env.CHLOVA_ENV,
logLevel: env.CHLOVA_LOG_LEVEL, logLevel: env.CHLOVA_LOG_LEVEL,
phase: env.CHLOVA_PHASE,
ollamaBaseUrl: env.OLLAMA_BASE_URL, ollamaBaseUrl: env.OLLAMA_BASE_URL,
ollamaApiKey: env.OLLAMA_API_KEY, ollamaApiKey: env.OLLAMA_API_KEY,
ollamaModel: env.OLLAMA_MODEL, 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 * Cohérence phase ↔ lecture seule.
* Phase 2 (écriture) n'est pas activée, démarrer avec PORTAINER_READ_ONLY=false * - Phase 1 : la lecture seule Portainer est OBLIGATOIRE. Démarrer avec
* est une erreur. Empêche un glissement silencieux vers l'écriture. * 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 { export function assertReadOnlyPhase(cfg: Config): void {
if (!cfg.portainerReadOnly) { if (cfg.phase === 1 && !cfg.portainerReadOnly) {
throw new Error( throw new Error(
"PORTAINER_READ_ONLY=false interdit en Phase 1 (lecture seule). " + "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.",
); );
} }
} }
+98
View File
@@ -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);
}
}
+16 -1
View File
@@ -39,11 +39,26 @@ describe("verrou lecture seule Phase 1", () => {
expect(() => assertReadOnlyPhase(loadConfig(fullEnv()))).not.toThrow(); expect(() => assertReadOnlyPhase(loadConfig(fullEnv()))).not.toThrow();
}); });
it("refuse PORTAINER_READ_ONLY=false", () => { it("refuse PORTAINER_READ_ONLY=false en Phase 1", () => {
const env = fullEnv(); const env = fullEnv();
env.PORTAINER_READ_ONLY = "false"; env.PORTAINER_READ_ONLY = "false";
expect(() => assertReadOnlyPhase(loadConfig(env))).toThrow(/lecture seule/); 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", () => { describe("redactedConfig masque les secrets", () => {
@@ -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);
});
});