test: gatekeeper + readonly-filter + config, interfaces need-review (v0.9.0)

Fin Phase 1. 22 tests verts : barrière readonly-filter (fail-safe),
ReadOnlyGuard, paliers de risque + sursis, invariant anti-escalade,
config fail-closed + masquage secrets. Interfaces du cycle need-review
posées pour la Phase 2 (Asset, canExecute) sans câblage runtime. Split
tsconfig typecheck/build.

Palier de risque : reversible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:18:02 +02:00
parent c6309fd9a5
commit 1cce8c9db6
8 changed files with 316 additions and 5 deletions
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "../src/config.js";
const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_BASE_URL: "http://ollama:11434",
OLLAMA_API_KEY: "secret-ollama",
OLLAMA_MODEL: "qwen3:cloud",
MCP_N8N_URL: "http://mcp-n8n:3000",
MCP_N8N_AUTH_TOKEN: "secret-n8n",
MCP_PORTAINER_URL: "http://mcp-portainer:3000",
PORTAINER_MCP_AUTH_TOKEN: "secret-portainer",
PORTAINER_READ_ONLY: "true",
TELEGRAM_BOT_TOKEN: "secret-tg",
TELEGRAM_ALLOWED_USER_IDS: "111, 222",
});
describe("config fail-closed", () => {
it("charge une config complète", () => {
const cfg = loadConfig(fullEnv());
expect(cfg.ollamaModel).toBe("qwen3:cloud");
expect(cfg.telegramAllowedUserIds).toEqual(["111", "222"]);
});
it("refuse de démarrer si un secret manque", () => {
const env = fullEnv();
delete env.OLLAMA_API_KEY;
expect(() => loadConfig(env)).toThrow(/fail-closed/);
});
it("refuse une URL invalide", () => {
const env = fullEnv();
env.MCP_N8N_URL = "pas-une-url";
expect(() => loadConfig(env)).toThrow();
});
});
describe("verrou lecture seule Phase 1", () => {
it("accepte PORTAINER_READ_ONLY=true", () => {
expect(() => assertReadOnlyPhase(loadConfig(fullEnv()))).not.toThrow();
});
it("refuse PORTAINER_READ_ONLY=false", () => {
const env = fullEnv();
env.PORTAINER_READ_ONLY = "false";
expect(() => assertReadOnlyPhase(loadConfig(env))).toThrow(/lecture seule/);
});
});
describe("redactedConfig masque les secrets", () => {
it("ne révèle aucun secret", () => {
const red = redactedConfig(loadConfig(fullEnv()));
expect(red.ollamaApiKey).toBe("[REDACTED]");
expect(red.telegramBotToken).toBe("[REDACTED]");
expect(red.mcpN8nAuthToken).toBe("[REDACTED]");
expect(red.portainerMcpAuthToken).toBe("[REDACTED]");
// les non-secrets restent visibles
expect(red.ollamaModel).toBe("qwen3:cloud");
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { ReadOnlyGuard } from "../src/gatekeeper/guard.js";
import {
createAsset,
assertNoEscalation,
canExecute,
EscalationError,
PROVISIONAL_TTL_MS,
} from "../src/gatekeeper/assets.js";
import type { ToolSpec } from "../src/agent/types.js";
const spec = (over: Partial<ToolSpec>): ToolSpec => ({
name: "n8n.list_workflows",
description: "",
parameters: {},
server: "n8n",
readOnly: true,
riskTier: "reversible",
...over,
});
describe("ReadOnlyGuard (Phase 1)", () => {
const guard = new ReadOnlyGuard();
it("autorise un outil reversible + readOnly", () => {
expect(guard.authorize(spec({})).allowed).toBe(true);
});
it("refuse un outil privilégié", () => {
const v = guard.authorize(spec({ riskTier: "privileged", readOnly: false }));
expect(v.allowed).toBe(false);
expect(v.reason).toMatch(/privilégié/);
});
it("refuse un outil reversible mais non read-only (défense en profondeur)", () => {
const v = guard.authorize(spec({ riskTier: "reversible", readOnly: false }));
expect(v.allowed).toBe(false);
});
});
describe("paliers de risque & sursis", () => {
it("réversible → PROVISOIRE avec sursis de 7 jours", () => {
const t0 = 1_000_000;
const a = createAsset({ id: "a", type: "workflow-n8n", version: "1.0.0", riskTier: "reversible", now: t0 });
expect(a.status).toBe("provisoire");
expect(a.expiresAt).toBe(t0 + PROVISIONAL_TTL_MS);
});
it("privilégié → BLOQUÉ, AUCUN sursis", () => {
const a = createAsset({ id: "b", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" });
expect(a.status).toBe("bloqué");
expect(a.expiresAt).toBeNull();
});
});
describe("invariant anti-escalade", () => {
it("interdit privileged → reversible", () => {
expect(() => assertNoEscalation("privileged", "reversible")).toThrow(EscalationError);
});
it("autorise reversible → privileged (durcissement)", () => {
expect(() => assertNoEscalation("reversible", "privileged")).not.toThrow();
});
it("autorise les reclassements identiques", () => {
expect(() => assertNoEscalation("privileged", "privileged")).not.toThrow();
expect(() => assertNoEscalation("reversible", "reversible")).not.toThrow();
});
});
describe("canExecute (gatekeeper Phase 2, posé)", () => {
it("bloque un provisoire expiré", () => {
const t0 = 1_000_000;
const a = createAsset({ id: "c", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 });
expect(canExecute(a, t0 + 1).ok).toBe(true);
expect(canExecute(a, t0 + PROVISIONAL_TTL_MS + 1).ok).toBe(false);
});
it("bloque un asset bloqué et un refusé", () => {
const a = createAsset({ id: "d", type: "tool", version: "1.0.0", riskTier: "privileged" });
expect(canExecute(a).ok).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import {
isReadOnly,
filterReadOnly,
resolveRiskTier,
type McpToolLike,
} from "../src/mcp/readonly-filter.js";
const ro: McpToolLike = {
name: "list_workflows",
annotations: { readOnlyHint: true },
};
const write: McpToolLike = {
name: "deploy_stack",
annotations: { readOnlyHint: false },
};
const destructive: McpToolLike = {
name: "delete_container",
annotations: { readOnlyHint: true, destructiveHint: true },
};
const unannotated: McpToolLike = { name: "mystery_tool" };
describe("readonly-filter", () => {
it("autorise un outil readOnlyHint=true", () => {
expect(isReadOnly(ro)).toBe(true);
});
it("refuse un outil readOnlyHint=false", () => {
expect(isReadOnly(write)).toBe(false);
});
it("fail-safe : un outil sans annotation est NON read-only", () => {
expect(isReadOnly(unannotated)).toBe(false);
});
it("refuse un outil read-only mais marqué destructif", () => {
expect(isReadOnly(destructive)).toBe(false);
});
it("filterReadOnly ne garde que les outils read-only", () => {
const kept = filterReadOnly([ro, write, destructive, unannotated]);
expect(kept.map((t) => t.name)).toEqual(["list_workflows"]);
});
it("resolveRiskTier : read-only ⇒ reversible, sinon privileged", () => {
expect(resolveRiskTier(ro)).toBe("reversible");
expect(resolveRiskTier(write)).toBe("privileged");
expect(resolveRiskTier(unannotated)).toBe("privileged");
expect(resolveRiskTier(destructive)).toBe("privileged");
});
});