diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fcb12..08a65fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.13.0] — 2026-06-23 +### Added +- `src/gatekeeper/review.ts` : `ReviewService` (approuver/refuser/lister), + `runExpiryOnce` + `startExpiryCron` (cron horaire PROVISOIRE→BLOQUÉ). +- `src/surfaces/commands.ts` : commandes owner Telegram `/pending`, `/approve`, + `/refuse`, `/help` (hors boucle agent — le LLM n'y a pas accès). +- Câblage Phase 2 : review + cron démarrés, routage commande/agent dans Telegram, + arrêt propre du cron. 10 tests (review + commandes + cron). + ## [0.12.0] — 2026-06-23 ### Added - `registry.listAllTools()` : expose tous les outils (mutants inclus) en Phase 2, diff --git a/orchestrator/src/gatekeeper/review.ts b/orchestrator/src/gatekeeper/review.ts new file mode 100644 index 0000000..8c51412 --- /dev/null +++ b/orchestrator/src/gatekeeper/review.ts @@ -0,0 +1,62 @@ +import type { Logger } from "pino"; +import { AssetRepository } from "./repository.js"; +import type { Asset } from "./assets.js"; + +/** + * Review des assets (Phase 2). Action humaine qui sort un asset de l'état + * PROVISOIRE/BLOQUÉ vers APPROUVÉ (permanent) ou REFUSÉ (désactivé). + * + * C'est la SEULE voie de changement de statut côté humain. Le LLM n'y a pas + * accès (il ne manipule que des outils ; la review passe par une commande owner). + */ +export class ReviewService { + constructor( + private readonly repo: AssetRepository, + private readonly logger: Logger, + ) {} + + /** Assets en attente de décision (bloqués ou provisoires). */ + listPending(): Asset[] { + return [...this.repo.listByStatus("bloqué"), ...this.repo.listByStatus("provisoire")]; + } + + approve(id: string): Asset { + const asset = this.requireAsset(id); + this.repo.updateStatus(id, "approuvé"); + this.logger.info({ asset: id }, "asset APPROUVÉ"); + return { ...asset, status: "approuvé" }; + } + + refuse(id: string): Asset { + const asset = this.requireAsset(id); + this.repo.updateStatus(id, "refusé"); + this.logger.info({ asset: id }, "asset REFUSÉ"); + return { ...asset, status: "refusé" }; + } + + private requireAsset(id: string): Asset { + const asset = this.repo.get(id); + if (!asset) throw new Error(`asset inconnu: ${id}`); + return asset; + } +} + +/** Exécute un passage du cron PROVISOIRE→BLOQUÉ. Retourne les ids basculés. */ +export function runExpiryOnce(repo: AssetRepository, logger: Logger): string[] { + const switched = repo.expireProvisional(); + if (switched.length > 0) { + logger.warn({ assets: switched }, "sursis expiré → assets BLOQUÉS"); + } + return switched; +} + +/** Démarre le cron horaire d'expiration. Retourne une fonction d'arrêt. */ +export function startExpiryCron( + repo: AssetRepository, + logger: Logger, + intervalMs = 60 * 60 * 1000, +): () => void { + const timer = setInterval(() => runExpiryOnce(repo, logger), intervalMs); + timer.unref?.(); + return () => clearInterval(timer); +} diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 9ba0056..aa92e75 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -6,6 +6,8 @@ import { McpRegistry } from "./mcp/registry.js"; import { ReadOnlyGuard } from "./gatekeeper/guard.js"; import { Gatekeeper, GatekeeperGuard } from "./gatekeeper/gatekeeper.js"; import { AssetRepository } from "./gatekeeper/repository.js"; +import { ReviewService, startExpiryCron } from "./gatekeeper/review.js"; +import { isCommand, handleReviewCommand } from "./surfaces/commands.js"; import type { Guard, ToolHandle } from "./agent/types.js"; import { runAgentTurn } from "./agent/loop.js"; import { buildSystemPrompt } from "./agent/system-prompt.js"; @@ -43,6 +45,8 @@ async function main(): Promise { let tools: ToolHandle[]; let guard: Guard; let repo: AssetRepository | null = null; + let review: ReviewService | null = null; + let stopCron: (() => void) | null = null; if (cfg.phase === 2) { repo = new AssetRepository(cfg.dbPath); const gatekeeper = new Gatekeeper(repo, logger, { @@ -54,6 +58,8 @@ async function main(): Promise { }); guard = new GatekeeperGuard(gatekeeper); tools = await registry.listAllTools(); + review = new ReviewService(repo, logger); + stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire } else { guard = new ReadOnlyGuard(); tools = await registry.listReadOnlyTools(); @@ -89,6 +95,7 @@ async function main(): Promise { // Arrêt propre. const shutdown = async (): Promise => { telegram.stop(); + stopCron?.(); await registry.close(); await app.close(); repo?.close(); @@ -97,8 +104,11 @@ async function main(): Promise { process.on("SIGTERM", () => void shutdown()); process.on("SIGINT", () => void shutdown()); - // Boucle de service : chaque message autorisé → un tour d'agent. + // Boucle de service : commande de review (Phase 2) ou tour d'agent. await telegram.start(async ({ userId, text }) => { + if (review && isCommand(text)) { + return handleReviewCommand(review, text); + } const { reply, steps } = await runAgentTurn({ client, system: systemPrompt, diff --git a/orchestrator/src/surfaces/commands.ts b/orchestrator/src/surfaces/commands.ts new file mode 100644 index 0000000..14d1e83 --- /dev/null +++ b/orchestrator/src/surfaces/commands.ts @@ -0,0 +1,54 @@ +import type { ReviewService } from "../gatekeeper/review.js"; +import type { Asset } from "../gatekeeper/assets.js"; + +/** + * Commandes de review (Phase 2), tapées par un utilisateur autorisé dans Telegram. + * Hors boucle agent : le LLM n'émet jamais ces commandes — seul l'humain décide. + * + * /pending liste les assets en attente + * /approve approuve (permanent) + * /refuse refuse (désactivé) + * /help aide + */ + +export function isCommand(text: string): boolean { + return text.trim().startsWith("/"); +} + +function fmt(a: Asset): string { + const exp = a.expiresAt ? ` · expire ${new Date(a.expiresAt).toISOString().slice(0, 10)}` : ""; + return `• ${a.id} [${a.riskTier}/${a.status}] v${a.version}${exp}`; +} + +const HELP = + "Commandes review :\n" + + "/pending — assets en attente\n" + + "/approve — approuver\n" + + "/refuse — refuser"; + +export function handleReviewCommand(review: ReviewService, text: string): string { + const [cmd, ...rest] = text.trim().split(/\s+/); + const arg = rest.join(" ").trim(); + + switch (cmd) { + case "/pending": { + const pending = review.listPending(); + if (pending.length === 0) return "Aucun asset en attente."; + return `${pending.length} asset(s) en attente :\n${pending.map(fmt).join("\n")}`; + } + case "/approve": + case "/refuse": { + if (!arg) return `Usage : ${cmd} `; + try { + const asset = cmd === "/approve" ? review.approve(arg) : review.refuse(arg); + return `OK — ${asset.id} → ${asset.status}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + } + case "/help": + return HELP; + default: + return `Commande inconnue.\n${HELP}`; + } +} diff --git a/orchestrator/test/review.test.ts b/orchestrator/test/review.test.ts new file mode 100644 index 0000000..0f70f80 --- /dev/null +++ b/orchestrator/test/review.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { ReviewService, runExpiryOnce } from "../src/gatekeeper/review.js"; +import { handleReviewCommand, isCommand } from "../src/surfaces/commands.js"; +import { createAsset, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js"; +import { createLogger } from "../src/audit/log.js"; + +const log = createLogger("silent"); +let repo: AssetRepository; +let review: ReviewService; + +beforeEach(() => { + repo = new AssetRepository(":memory:"); + review = new ReviewService(repo, log); +}); +afterEach(() => repo.close()); + +describe("ReviewService", () => { + it("approuve un asset bloqué", () => { + repo.create(createAsset({ id: "p", type: "stack-portainer", version: "1.0.0", riskTier: "privileged" })); + expect(review.approve("p").status).toBe("approuvé"); + expect(repo.get("p")?.status).toBe("approuvé"); + }); + + it("refuse un asset", () => { + repo.create(createAsset({ id: "p", type: "tool", version: "1.0.0", riskTier: "privileged" })); + expect(review.refuse("p").status).toBe("refusé"); + }); + + it("lève sur asset inconnu", () => { + expect(() => review.approve("nope")).toThrow(/inconnu/); + }); + + it("listPending renvoie bloqués + provisoires", () => { + repo.create(createAsset({ id: "b", type: "tool", version: "1.0.0", riskTier: "privileged" })); + repo.create(createAsset({ id: "r", type: "tool", version: "1.0.0", riskTier: "reversible" })); + expect(review.listPending().map((a) => a.id).sort()).toEqual(["b", "r"]); + }); +}); + +describe("runExpiryOnce", () => { + it("bascule les provisoires échus", () => { + const t0 = Date.now() - PROVISIONAL_TTL_MS - 1000; + repo.create(createAsset({ id: "old", type: "tool", version: "1.0.0", riskTier: "reversible", now: t0 })); + expect(runExpiryOnce(repo, log)).toEqual(["old"]); + expect(repo.get("old")?.status).toBe("bloqué"); + }); +}); + +describe("commandes de review", () => { + it("isCommand détecte le préfixe /", () => { + expect(isCommand("/pending")).toBe(true); + expect(isCommand("bonjour")).toBe(false); + }); + + it("/pending liste ou indique vide", () => { + expect(handleReviewCommand(review, "/pending")).toMatch(/Aucun/); + repo.create(createAsset({ id: "p", type: "tool", version: "1.0.0", riskTier: "privileged" })); + expect(handleReviewCommand(review, "/pending")).toContain("p"); + }); + + it("/approve approuve", () => { + repo.create(createAsset({ id: "p", type: "tool", version: "1.0.0", riskTier: "privileged" })); + expect(handleReviewCommand(review, "/approve p")).toMatch(/approuvé/); + expect(repo.get("p")?.status).toBe("approuvé"); + }); + + it("/approve sans id renvoie l'usage", () => { + expect(handleReviewCommand(review, "/approve")).toMatch(/Usage/); + }); + + it("commande inconnue renvoie l'aide", () => { + expect(handleReviewCommand(review, "/wat")).toMatch(/inconnue/); + }); +});