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:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user