feat: cron expiration + commandes de review owner (v0.13.0)

ReviewService (approuver/refuser/lister) + cron horaire PROVISOIRE→BLOQUÉ.
Commandes Telegram owner /pending /approve /refuse hors boucle agent (le
LLM ne peut pas décider de la review). Câblage Phase 2 : routage
commande/agent, cron démarré + arrêt propre. 45 tests verts.

Palier de risque : reversible (contrôle humain ; n'exécute aucune mutation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:32:18 +02:00
parent 48aa75d95e
commit a193b4e912
5 changed files with 211 additions and 1 deletions
+9
View File
@@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.12.0] — 2026-06-23
### Added ### Added
- `registry.listAllTools()` : expose tous les outils (mutants inclus) en Phase 2, - `registry.listAllTools()` : expose tous les outils (mutants inclus) en Phase 2,
+62
View File
@@ -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);
}
+11 -1
View File
@@ -6,6 +6,8 @@ import { McpRegistry } from "./mcp/registry.js";
import { ReadOnlyGuard } from "./gatekeeper/guard.js"; import { ReadOnlyGuard } from "./gatekeeper/guard.js";
import { Gatekeeper, GatekeeperGuard } from "./gatekeeper/gatekeeper.js"; import { Gatekeeper, GatekeeperGuard } from "./gatekeeper/gatekeeper.js";
import { AssetRepository } from "./gatekeeper/repository.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 type { Guard, ToolHandle } from "./agent/types.js";
import { runAgentTurn } from "./agent/loop.js"; import { runAgentTurn } from "./agent/loop.js";
import { buildSystemPrompt } from "./agent/system-prompt.js"; import { buildSystemPrompt } from "./agent/system-prompt.js";
@@ -43,6 +45,8 @@ async function main(): Promise<void> {
let tools: ToolHandle[]; let tools: ToolHandle[];
let guard: Guard; let guard: Guard;
let repo: AssetRepository | null = null; let repo: AssetRepository | null = null;
let review: ReviewService | null = null;
let stopCron: (() => void) | null = null;
if (cfg.phase === 2) { if (cfg.phase === 2) {
repo = new AssetRepository(cfg.dbPath); repo = new AssetRepository(cfg.dbPath);
const gatekeeper = new Gatekeeper(repo, logger, { const gatekeeper = new Gatekeeper(repo, logger, {
@@ -54,6 +58,8 @@ async function main(): Promise<void> {
}); });
guard = new GatekeeperGuard(gatekeeper); guard = new GatekeeperGuard(gatekeeper);
tools = await registry.listAllTools(); tools = await registry.listAllTools();
review = new ReviewService(repo, logger);
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
} else { } else {
guard = new ReadOnlyGuard(); guard = new ReadOnlyGuard();
tools = await registry.listReadOnlyTools(); tools = await registry.listReadOnlyTools();
@@ -89,6 +95,7 @@ async function main(): Promise<void> {
// Arrêt propre. // Arrêt propre.
const shutdown = async (): Promise<void> => { const shutdown = async (): Promise<void> => {
telegram.stop(); telegram.stop();
stopCron?.();
await registry.close(); await registry.close();
await app.close(); await app.close();
repo?.close(); repo?.close();
@@ -97,8 +104,11 @@ async function main(): Promise<void> {
process.on("SIGTERM", () => void shutdown()); process.on("SIGTERM", () => void shutdown());
process.on("SIGINT", () => 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 }) => { await telegram.start(async ({ userId, text }) => {
if (review && isCommand(text)) {
return handleReviewCommand(review, text);
}
const { reply, steps } = await runAgentTurn({ const { reply, steps } = await runAgentTurn({
client, client,
system: systemPrompt, system: systemPrompt,
+54
View File
@@ -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 <id> approuve (permanent)
* /refuse <id> 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 <id> — approuver\n" +
"/refuse <id> — 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} <id>`;
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}`;
}
}
+75
View File
@@ -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 <id> 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/);
});
});