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:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user