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:
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.8.0] — 2026-06-23
|
||||||
### Added
|
### Added
|
||||||
- `src/surfaces/telegram.ts` : surface Telegram long-polling (zéro port publié),
|
- `src/surfaces/telegram.ts` : surface Telegram long-polling (zéro port publié),
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -4,15 +4,13 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"declaration": false,
|
"noEmit": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
|||||||
Reference in New Issue
Block a user