From 0a2eb203eec9a0ffa7f0ca4aaaf6a9972cec952f Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 01:50:18 +0200 Subject: [PATCH] feat: module alertes (sender fail-soft + builders digest/J-1) (v0.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlertEvent sans secret ; HttpAlertSender (POST webhook n8n, best-effort) + NullAlertSender ; buildDailyDigest / selectExpiringSoon purs. 6 tests. Rattrape l'entrée CHANGELOG 0.15.1 omise. Palier de risque : reversible. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 +++++++ orchestrator/src/alerts/digest.ts | 49 ++++++++++++++++++++++++++++ orchestrator/src/alerts/sender.ts | 47 +++++++++++++++++++++++++++ orchestrator/src/alerts/types.ts | 44 +++++++++++++++++++++++++ orchestrator/test/alerts.test.ts | 53 +++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 orchestrator/src/alerts/digest.ts create mode 100644 orchestrator/src/alerts/sender.ts create mode 100644 orchestrator/src/alerts/types.ts create mode 100644 orchestrator/test/alerts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a9c86..d434ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.16.0] — 2026-06-23 — début Phase 3 (alertes) +### Added +- Module `src/alerts/` : `AlertEvent` (blocked / first-exec / J-1 / digest sans + secret), `HttpAlertSender` (POST webhook n8n, **fail-soft**) + `NullAlertSender` + (log-only fail-safe), builders purs `buildDailyDigest` / `selectExpiringSoon`. +- Tests alerts (6) : payload, fail-soft, digest, fenêtre J-1. + +## [0.15.1] — 2026-06-23 +### Changed +- Fige le chemin de l'endpoint MCP natif n8n : `MCP_N8N_URL = …/mcp-server/http` + (hôte interne préféré, URL publique TLS possible). Note egress (`networks.md`). + ## [0.15.0] — 2026-06-23 ### Changed - MCP n8n = **natif** (instance n8n ≥ 2.18.4) au lieu d'un conteneur dédié : diff --git a/orchestrator/src/alerts/digest.ts b/orchestrator/src/alerts/digest.ts new file mode 100644 index 0000000..7649c9f --- /dev/null +++ b/orchestrator/src/alerts/digest.ts @@ -0,0 +1,49 @@ +import type { Asset } from "../gatekeeper/assets.js"; +import type { DigestItem } from "./types.js"; + +/** + * Construction (pure, testable) des contenus d'alerte périodiques. + */ + +export function toDigestItem(a: Asset): DigestItem { + return { + id: a.id, + type: a.type, + riskTier: a.riskTier, + status: a.status, + expiresAt: a.expiresAt, + }; +} + +/** Digest quotidien des assets en attente (bloqués + provisoires). */ +export function buildDailyDigest(pending: Asset[]): { + blocked: number; + provisional: number; + items: DigestItem[]; +} { + let blocked = 0; + let provisional = 0; + for (const a of pending) { + if (a.status === "bloqué") blocked++; + else if (a.status === "provisoire") provisional++; + } + return { blocked, provisional, items: pending.map(toDigestItem) }; +} + +/** + * Assets PROVISOIRE dont le sursis expire dans la fenêtre [now, now+windowMs] + * (rappel J-1 par défaut : 24 h). Exclut ceux déjà expirés (gérés par le cron). + */ +export function selectExpiringSoon( + assets: Asset[], + now: number, + windowMs = 24 * 60 * 60 * 1000, +): Asset[] { + return assets.filter( + (a) => + a.status === "provisoire" && + a.expiresAt !== null && + a.expiresAt > now && + a.expiresAt <= now + windowMs, + ); +} diff --git a/orchestrator/src/alerts/sender.ts b/orchestrator/src/alerts/sender.ts new file mode 100644 index 0000000..4559bd6 --- /dev/null +++ b/orchestrator/src/alerts/sender.ts @@ -0,0 +1,47 @@ +import type { Logger } from "pino"; +import type { AlertEvent, AlertSender } from "./types.js"; + +/** + * Envoie les alertes à un webhook n8n (qui envoie le mail). Fail-soft : une + * panne d'alerte ne doit jamais casser la boucle agent ni le gatekeeper — on + * logge et on continue. + */ +export class HttpAlertSender implements AlertSender { + constructor( + private readonly webhookUrl: string, + private readonly logger: Logger, + private readonly timeoutMs = 10_000, + ) {} + + async send(event: AlertEvent): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const res = await fetch(this.webhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "chlova", ts: Date.now(), ...event }), + signal: controller.signal, + }); + if (!res.ok) { + this.logger.warn({ kind: event.kind, status: res.status }, "alerte: HTTP non-OK"); + } + } catch (err) { + // Jamais propagé : l'alerte est best-effort. + this.logger.warn( + { kind: event.kind, err: err instanceof Error ? err.message : String(err) }, + "alerte: envoi échoué (best-effort)", + ); + } finally { + clearTimeout(timer); + } + } +} + +/** Sender inactif : aucun webhook configuré → on logge seulement (fail-safe). */ +export class NullAlertSender implements AlertSender { + constructor(private readonly logger: Logger) {} + async send(event: AlertEvent): Promise { + this.logger.info({ alert: event }, "alerte (non envoyée : pas de webhook)"); + } +} diff --git a/orchestrator/src/alerts/types.ts b/orchestrator/src/alerts/types.ts new file mode 100644 index 0000000..4d16724 --- /dev/null +++ b/orchestrator/src/alerts/types.ts @@ -0,0 +1,44 @@ +/** + * Événements d'alerte CHLOVA (Phase 3). Émis vers n8n (webhook) qui envoie le + * mail. Stratégie anti-fatigue (docs/need-review.md, brief) : + * - 1ʳᵉ exécution d'un asset PROVISOIRE → alerte immédiate ; + * - ensuite → digest quotidien + rappel de compte à rebours à J-1 ; + * - toute tentative sur un asset BLOQUÉ → alerte systématique. + * + * Le payload ne contient JAMAIS de secret : ids, types, statuts, dates. + */ + +export interface DigestItem { + id: string; + type: string; + riskTier: string; + status: string; + expiresAt: number | null; +} + +export type AlertEvent = + | { + kind: "blocked_attempt"; + assetId: string; + tool: string; + status: string; + } + | { + kind: "first_provisional_exec"; + assetId: string; + tool: string; + } + | { + kind: "countdown_j1"; + items: DigestItem[]; + } + | { + kind: "daily_digest"; + blocked: number; + provisional: number; + items: DigestItem[]; + }; + +export interface AlertSender { + send(event: AlertEvent): Promise; +} diff --git a/orchestrator/test/alerts.test.ts b/orchestrator/test/alerts.test.ts new file mode 100644 index 0000000..1f5d1fb --- /dev/null +++ b/orchestrator/test/alerts.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { HttpAlertSender, NullAlertSender } from "../src/alerts/sender.js"; +import { buildDailyDigest, selectExpiringSoon } from "../src/alerts/digest.js"; +import { createAsset, PROVISIONAL_TTL_MS } from "../src/gatekeeper/assets.js"; +import { createLogger } from "../src/audit/log.js"; +import type { AlertEvent } from "../src/alerts/types.js"; + +const log = createLogger("silent"); +const blocked: AlertEvent = { kind: "blocked_attempt", assetId: "x", tool: "t", status: "bloqué" }; + +describe("HttpAlertSender", () => { + it("poste le payload au webhook", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + await new HttpAlertSender("http://n8n/webhook", log).send(blocked); + expect(fetchMock).toHaveBeenCalledOnce(); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ source: "chlova", kind: "blocked_attempt", assetId: "x" }); + vi.unstubAllGlobals(); + }); + + it("fail-soft : une panne réseau ne lève pas", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); + await expect(new HttpAlertSender("http://n8n/webhook", log).send(blocked)).resolves.toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("NullAlertSender ne lève pas", async () => { + await expect(new NullAlertSender(log).send(blocked)).resolves.toBeUndefined(); + }); +}); + +describe("digest", () => { + it("buildDailyDigest compte bloqués vs provisoires", () => { + const items = [ + createAsset({ id: "b", type: "tool", version: "1.0.0", riskTier: "privileged" }), + createAsset({ id: "r", type: "tool", version: "1.0.0", riskTier: "reversible" }), + ]; + const d = buildDailyDigest(items); + expect(d.blocked).toBe(1); + expect(d.provisional).toBe(1); + expect(d.items).toHaveLength(2); + }); + + it("selectExpiringSoon ne retient que les provisoires dans la fenêtre J-1", () => { + const now = 1_000_000_000; + const soon = createAsset({ id: "soon", type: "tool", version: "1.0.0", riskTier: "reversible", now: now - PROVISIONAL_TTL_MS + 3_600_000 }); + const far = createAsset({ id: "far", type: "tool", version: "1.0.0", riskTier: "reversible", now }); + const result = selectExpiringSoon([soon, far], now); + expect(result.map((a) => a.id)).toEqual(["soon"]); + }); +});