feat: module alertes (sender fail-soft + builders digest/J-1) (v0.16.0)

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 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:50:18 +02:00
parent bfae5bbbdb
commit 0a2eb203ee
5 changed files with 205 additions and 0 deletions
+12
View File
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.15.0] — 2026-06-23
### Changed ### Changed
- MCP n8n = **natif** (instance n8n ≥ 2.18.4) au lieu d'un conteneur dédié : - MCP n8n = **natif** (instance n8n ≥ 2.18.4) au lieu d'un conteneur dédié :
+49
View File
@@ -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,
);
}
+47
View File
@@ -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<void> {
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<void> {
this.logger.info({ alert: event }, "alerte (non envoyée : pas de webhook)");
}
}
+44
View File
@@ -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<void>;
}
+53
View File
@@ -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"]);
});
});