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]
|
## [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
|
||||||
|
|||||||
@@ -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.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user