From 1cce8c9db6e800952241f0bb38c599c02620ffc5 Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 01:18:02 +0200 Subject: [PATCH] test: gatekeeper + readonly-filter + config, interfaces need-review (v0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 12 +++ orchestrator/package.json | 4 +- orchestrator/src/gatekeeper/assets.ts | 98 +++++++++++++++++++++++ orchestrator/test/config.test.ts | 59 ++++++++++++++ orchestrator/test/gatekeeper.test.ts | 83 +++++++++++++++++++ orchestrator/test/readonly-filter.test.ts | 51 ++++++++++++ orchestrator/tsconfig.build.json | 10 +++ orchestrator/tsconfig.json | 4 +- 8 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 orchestrator/src/gatekeeper/assets.ts create mode 100644 orchestrator/test/config.test.ts create mode 100644 orchestrator/test/gatekeeper.test.ts create mode 100644 orchestrator/test/readonly-filter.test.ts create mode 100644 orchestrator/tsconfig.build.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b25c2..7625e83 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.9.0] — 2026-06-23 — fin Phase 1 (cerveau lecture seule) +### Added +- `src/gatekeeper/assets.ts` : interfaces du cycle "need review" posées pour la + Phase 2 (table Asset, sursis 7 j réversible / blocage immédiat privilégié, + `assertNoEscalation` anti-escalade, `canExecute`). Non câblé au runtime P1. +- Tests Vitest (22) : `readonly-filter` (fail-safe + tiers), `gatekeeper` + (Guard read-only, sursis par palier, invariant anti-escalade, canExecute), + `config` (fail-closed, verrou lecture seule, masquage des secrets). +### Changed +- Split TS config : `tsconfig.json` (typecheck+tests, noEmit) / + `tsconfig.build.json` (emit `dist/`, rootDir `src`). + ## [0.8.0] — 2026-06-23 ### Added - `src/surfaces/telegram.ts` : surface Telegram long-polling (zéro port publié), diff --git a/orchestrator/package.json b/orchestrator/package.json index aae8de4..7a67753 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -8,10 +8,10 @@ "node": ">=22" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json", "test": "vitest run", "test:watch": "vitest" }, diff --git a/orchestrator/src/gatekeeper/assets.ts b/orchestrator/src/gatekeeper/assets.ts new file mode 100644 index 0000000..1a27f84 --- /dev/null +++ b/orchestrator/src/gatekeeper/assets.ts @@ -0,0 +1,98 @@ +import type { RiskTier } from "../audit/log.js"; + +/** + * Modèle d'asset & cycle "need review" — INTERFACES posées pour la Phase 2. + * + * Non câblé au runtime en Phase 1 (aucune écriture). On fige ici le contrat et + * les invariants non négociables (paliers de risque, sursis, anti-escalade) + + * leurs tests, pour que la Phase 2 les implémente sans dériver des règles. + * Voir docs/risk-tiers.md. + */ + +export type AssetType = "workflow-n8n" | "stack-portainer" | "tool" | "image"; + +export type AssetStatus = "provisoire" | "approuvé" | "refusé" | "bloqué"; + +/** Durée du sursis PROVISOIRE pour un asset réversible. */ +export const PROVISIONAL_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Reflète la table d'assets décrite dans CLAUDE.md. */ +export interface Asset { + id: string; + type: AssetType; + version: string; // semver de l'asset + riskTier: RiskTier; + status: AssetStatus; + createdAt: number; // epoch ms + expiresAt: number | null; // null si pas de sursis (privilégié) + execCount: number; + commitLink: string | null; + docLink: string | null; +} + +export interface CreateAssetInput { + id: string; + type: AssetType; + version: string; + riskTier: RiskTier; + now?: number; + commitLink?: string; + docLink?: string; +} + +/** + * Crée un asset en appliquant la RÈGLE DE SURSIS (non négociable) : + * - `reversible` → PROVISOIRE, exécutable, expire à +7 jours ; + * - `privileged` → BLOQUÉ immédiatement, AUCUN sursis, pas d'expiration. + */ +export function createAsset(input: CreateAssetInput): Asset { + const now = input.now ?? Date.now(); + const reversible = input.riskTier === "reversible"; + return { + id: input.id, + type: input.type, + version: input.version, + riskTier: input.riskTier, + status: reversible ? "provisoire" : "bloqué", + createdAt: now, + expiresAt: reversible ? now + PROVISIONAL_TTL_MS : null, + execCount: 0, + commitLink: input.commitLink ?? null, + docLink: input.docLink ?? null, + }; +} + +export class EscalationError extends Error {} + +/** + * Invariant anti-escalade : un asset `privileged` ne peut JAMAIS être reclassé + * `reversible` (le LLM ne doit pas pouvoir contourner la review en abaissant le + * palier). Tout autre changement est permis (durcissement autorisé). + */ +export function assertNoEscalation(current: RiskTier, next: RiskTier): void { + if (current === "privileged" && next === "reversible") { + throw new EscalationError( + "Reclassement interdit : un asset privilégié ne peut pas devenir réversible.", + ); + } +} + +/** + * GATEKEEPER (Phase 2) : décide si un asset peut s'exécuter MAINTENANT. + * Posé ici comme référence ; non appelé en Phase 1. + */ +export function canExecute(asset: Asset, now = Date.now()): { ok: boolean; reason?: string } { + switch (asset.status) { + case "approuvé": + return { ok: true }; + case "refusé": + return { ok: false, reason: "asset refusé" }; + case "bloqué": + return { ok: false, reason: "asset bloqué (review requise)" }; + case "provisoire": + if (asset.expiresAt !== null && now > asset.expiresAt) { + return { ok: false, reason: "sursis expiré → bloqué" }; + } + return { ok: true }; + } +} diff --git a/orchestrator/test/config.test.ts b/orchestrator/test/config.test.ts new file mode 100644 index 0000000..a0bd761 --- /dev/null +++ b/orchestrator/test/config.test.ts @@ -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"); + }); +}); diff --git a/orchestrator/test/gatekeeper.test.ts b/orchestrator/test/gatekeeper.test.ts new file mode 100644 index 0000000..74f55dd --- /dev/null +++ b/orchestrator/test/gatekeeper.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/orchestrator/test/readonly-filter.test.ts b/orchestrator/test/readonly-filter.test.ts new file mode 100644 index 0000000..c1729ad --- /dev/null +++ b/orchestrator/test/readonly-filter.test.ts @@ -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"); + }); +}); diff --git a/orchestrator/tsconfig.build.json b/orchestrator/tsconfig.build.json new file mode 100644 index 0000000..dc2b8b5 --- /dev/null +++ b/orchestrator/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "declaration": false + }, + "include": ["src/**/*.ts"] +} diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index 63aa9ce..097fa2d 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -4,15 +4,13 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2023"], - "outDir": "dist", - "rootDir": "src", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "resolveJsonModule": true, - "declaration": false, + "noEmit": true, "sourceMap": true }, "include": ["src/**/*.ts", "test/**/*.ts"]