feat: hook 1ère exéc provisoire + scheduler digest/J-1 + config webhook (v0.17.0)

ALERT_WEBHOOK_URL (secret) → HttpAlertSender sinon NullAlertSender.
Gatekeeper émet onFirstProvisionalExec (1ère exéc PROVISOIRE) et route
onBlockedAttempt vers une alerte. Scheduler quotidien digest + rappel J-1,
câblé Phase 2 + arrêt propre. 53 tests verts.

Palier de risque : reversible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:52:59 +02:00
parent 0a2eb203ee
commit db96c4a25e
6 changed files with 172 additions and 6 deletions
+10
View File
@@ -6,6 +6,16 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.17.0] — 2026-06-23
### Added
- Config `ALERT_WEBHOOK_URL` (optionnel, traité comme secret) → `HttpAlertSender`,
sinon `NullAlertSender`.
- Gatekeeper : hook `onFirstProvisionalExec` (alerte immédiate à la 1ʳᵉ exécution
d'un asset PROVISOIRE) ; `onBlockedAttempt` route désormais vers une vraie alerte.
- `src/alerts/scheduler.ts` : `runAlertCycleOnce` + `startAlertScheduler` (digest
quotidien + rappel J-1). Câblé en Phase 2 (cron + alertes), arrêt propre.
- Tests (3) : émission 1ʳᵉ exéc unique, cycle J-1 + digest.
## [0.16.0] — 2026-06-23 — début Phase 3 (alertes) ## [0.16.0] — 2026-06-23 — début Phase 3 (alertes)
### Added ### Added
- Module `src/alerts/` : `AlertEvent` (blocked / first-exec / J-1 / digest sans - Module `src/alerts/` : `AlertEvent` (blocked / first-exec / J-1 / digest sans
+51
View File
@@ -0,0 +1,51 @@
import type { Logger } from "pino";
import { AssetRepository } from "../gatekeeper/repository.js";
import type { AlertSender } from "./types.js";
import { buildDailyDigest, selectExpiringSoon, toDigestItem } from "./digest.js";
/**
* Alertes périodiques (Phase 3) : digest quotidien des assets en attente +
* rappel de compte à rebours à J-1. Voir stratégie anti-fatigue dans
* docs/need-review.md.
*
* Les alertes "immédiates" (1ʳᵉ exéc provisoire, tentative bloquée) sont émises
* par le gatekeeper, pas ici.
*/
/** Un passage du cycle d'alertes périodiques (testable). */
export async function runAlertCycleOnce(
repo: AssetRepository,
sender: AlertSender,
now = Date.now(),
): Promise<void> {
const pending = [...repo.listByStatus("bloqué"), ...repo.listByStatus("provisoire")];
const expiring = selectExpiringSoon(pending, now);
if (expiring.length > 0) {
await sender.send({ kind: "countdown_j1", items: expiring.map(toDigestItem) });
}
if (pending.length > 0) {
const digest = buildDailyDigest(pending);
await sender.send({ kind: "daily_digest", ...digest });
}
}
/** Démarre le cycle quotidien. Retourne une fonction d'arrêt. */
export function startAlertScheduler(
repo: AssetRepository,
sender: AlertSender,
logger: Logger,
intervalMs = 24 * 60 * 60 * 1000,
): () => void {
const timer = setInterval(() => {
void runAlertCycleOnce(repo, sender).catch((err: unknown) =>
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"cycle d'alertes échoué (best-effort)",
),
);
}, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
+10
View File
@@ -59,6 +59,14 @@ const schema = z.object({
// Backend // Backend
dbPath: z.string().default("./data/chlova.db"), dbPath: z.string().default("./data/chlova.db"),
// Alertes (Phase 3) : webhook n8n qui envoie le mail. Optionnel : si absent,
// les alertes sont seulement loggées (NullAlertSender). Peut contenir un token
// de chemin → traité comme secret (jamais loggé).
alertWebhookUrl: z.preprocess(
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
z.string().url().optional(),
),
}); });
export type Config = z.infer<typeof schema>; export type Config = z.infer<typeof schema>;
@@ -69,6 +77,7 @@ const SECRET_KEYS = new Set<keyof Config>([
"mcpN8nAuthToken", "mcpN8nAuthToken",
"portainerMcpAuthToken", "portainerMcpAuthToken",
"telegramBotToken", "telegramBotToken",
"alertWebhookUrl",
]); ]);
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@@ -87,6 +96,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
telegramBotToken: env.TELEGRAM_BOT_TOKEN, telegramBotToken: env.TELEGRAM_BOT_TOKEN,
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS, telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
dbPath: env.CHLOVA_DB_PATH, dbPath: env.CHLOVA_DB_PATH,
alertWebhookUrl: env.ALERT_WEBHOOK_URL,
}); });
if (!parsed.success) { if (!parsed.success) {
+9 -2
View File
@@ -16,11 +16,13 @@ import { canExecute, createAsset, type Asset } from "./assets.js";
* lui-même issu des annotations MCP — voir mcp/readonly-filter.ts). * lui-même issu des annotations MCP — voir mcp/readonly-filter.ts).
*/ */
export type BlockedAttemptHook = (asset: Asset, spec: ToolSpec) => void; export type AssetEventHook = (asset: Asset, spec: ToolSpec) => void;
export interface GatekeeperOptions { export interface GatekeeperOptions {
/** Appelé à chaque tentative refusée sur un asset privilégié/bloqué (alerte P3). */ /** Appelé à chaque tentative refusée sur un asset privilégié/bloqué (alerte P3). */
onBlockedAttempt?: BlockedAttemptHook; onBlockedAttempt?: AssetEventHook;
/** Appelé à la 1ʳᵉ exécution réussie d'un asset PROVISOIRE (alerte P3). */
onFirstProvisionalExec?: AssetEventHook;
now?: () => number; now?: () => number;
} }
@@ -79,6 +81,11 @@ export class Gatekeeper {
}; };
} }
// 1ʳᵉ exécution d'un asset PROVISOIRE → alerte immédiate (avant l'incrément).
if (asset.status === "provisoire" && asset.execCount === 0) {
this.opts.onFirstProvisionalExec?.(asset, spec);
}
this.repo.incrementExec(id); this.repo.incrementExec(id);
return { allowed: true }; return { allowed: true };
} }
+24 -4
View File
@@ -8,6 +8,9 @@ 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 { ReviewService, startExpiryCron } from "./gatekeeper/review.js";
import { isCommand, handleReviewCommand } from "./surfaces/commands.js"; import { isCommand, handleReviewCommand } from "./surfaces/commands.js";
import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
import { startAlertScheduler } from "./alerts/scheduler.js";
import type { AlertSender } from "./alerts/types.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";
@@ -47,19 +50,35 @@ async function main(): Promise<void> {
let repo: AssetRepository | null = null; let repo: AssetRepository | null = null;
let review: ReviewService | null = null; let review: ReviewService | null = null;
let stopCron: (() => void) | null = null; let stopCron: (() => void) | null = null;
let stopAlerts: (() => void) | null = null;
if (cfg.phase === 2) { if (cfg.phase === 2) {
repo = new AssetRepository(cfg.dbPath); repo = new AssetRepository(cfg.dbPath);
// Alertes : webhook n8n si configuré, sinon log-only (fail-safe).
const alerts: AlertSender = cfg.alertWebhookUrl
? new HttpAlertSender(cfg.alertWebhookUrl, logger)
: new NullAlertSender(logger);
const gatekeeper = new Gatekeeper(repo, logger, { const gatekeeper = new Gatekeeper(repo, logger, {
onBlockedAttempt: (asset, spec) => onBlockedAttempt: (asset, spec) =>
logger.warn( void alerts.send({
{ asset: asset.id, tool: spec.name, status: asset.status }, kind: "blocked_attempt",
"ALERTE : tentative sur asset bloqué (review requise)", assetId: asset.id,
), tool: spec.name,
status: asset.status,
}),
onFirstProvisionalExec: (asset, spec) =>
void alerts.send({
kind: "first_provisional_exec",
assetId: asset.id,
tool: spec.name,
}),
}); });
guard = new GatekeeperGuard(gatekeeper); guard = new GatekeeperGuard(gatekeeper);
tools = await registry.listAllTools(); tools = await registry.listAllTools();
review = new ReviewService(repo, logger); review = new ReviewService(repo, logger);
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1
} else { } else {
guard = new ReadOnlyGuard(); guard = new ReadOnlyGuard();
tools = await registry.listReadOnlyTools(); tools = await registry.listReadOnlyTools();
@@ -96,6 +115,7 @@ async function main(): Promise<void> {
const shutdown = async (): Promise<void> => { const shutdown = async (): Promise<void> => {
telegram.stop(); telegram.stop();
stopCron?.(); stopCron?.();
stopAlerts?.();
await registry.close(); await registry.close();
await app.close(); await app.close();
repo?.close(); repo?.close();
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AssetRepository } from "../src/gatekeeper/repository.js";
import { Gatekeeper } from "../src/gatekeeper/gatekeeper.js";
import { runAlertCycleOnce } from "../src/alerts/scheduler.js";
import { createAsset, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js";
import { createLogger } from "../src/audit/log.js";
import type { AlertSender, AlertEvent } from "../src/alerts/types.js";
import type { ToolSpec } from "../src/agent/types.js";
const log = createLogger("silent");
class CapturingSender implements AlertSender {
events: AlertEvent[] = [];
async send(e: AlertEvent): Promise<void> {
this.events.push(e);
}
}
let repo: AssetRepository;
beforeEach(() => (repo = new AssetRepository(":memory:")));
afterEach(() => repo.close());
describe("Gatekeeper first_provisional_exec", () => {
it("alerte à la 1ʳᵉ exécution d'un asset PROVISOIRE puis plus", () => {
const onFirstProvisionalExec = vi.fn();
const gk = new Gatekeeper(repo, log, { onFirstProvisionalExec });
// asset provisoire pré-existant, exécutable via la capacité privilégiée
const id = "tool:portainer.redeploy";
repo.create(createAsset({ id, type: "tool", version: "1.0.0", riskTier: "reversible" }));
repo.updateStatus(id, "provisoire");
const spec: ToolSpec = {
name: "portainer.redeploy",
description: "",
parameters: {},
server: "portainer",
readOnly: false,
riskTier: "privileged",
};
expect(gk.authorizeTool(spec).allowed).toBe(true);
expect(gk.authorizeTool(spec).allowed).toBe(true);
expect(onFirstProvisionalExec).toHaveBeenCalledOnce(); // une seule fois
expect(repo.get(id)?.execCount).toBe(2);
});
});
describe("runAlertCycleOnce", () => {
it("émet J-1 + digest quand des assets sont en attente", async () => {
const now = 2_000_000_000;
// provisoire qui expire dans ~1h → J-1
repo.create(createAsset({ id: "soon", type: "tool", version: "1.0.0", riskTier: "reversible", now: now - PROVISIONAL_TTL_MS + 3_600_000 }));
// privilégié bloqué → compte dans le digest
repo.create(createAsset({ id: "blk", type: "stack-portainer", version: "1.0.0", riskTier: "privileged", now }));
const sender = new CapturingSender();
await runAlertCycleOnce(repo, sender, now);
const kinds = sender.events.map((e) => e.kind);
expect(kinds).toContain("countdown_j1");
expect(kinds).toContain("daily_digest");
});
it("n'émet rien sans asset en attente", async () => {
const sender = new CapturingSender();
await runAlertCycleOnce(repo, sender, Date.now());
expect(sender.events).toHaveLength(0);
});
});